phase: 140_pictorial_pdf_pages
Build and Push Containers / build-and-push-app (push) Successful in 2m26s
Build and Push Containers / build-and-push-db (push) Successful in 12s

Everything is verified green. Final report:

## Phase 140 final verification pass — report

- Verified all 4 tasks already complete (code in working tree, task files in `complete/`): `pictorial_pages` gate + 2 settings (`BOR_PDF_PICTORIAL`, `BOR_PDF_PICTORIAL_MIN_CHARS` + validator), `PICTORIAL_PAGE_MODE`/`describe_pdf_page` seam + `render_pdf_pages` page selector + mock branch, `_pictorial_pdf_pass` in `_index_pdf_file` with log-only counters, dedicated E2E. No code changes needed.
- Test results (exact commands):
  - `uv run pytest --cov=app --cov-report=term-missing` → **3320 passed, 11 skipped, 0 failed; TOTAL 99%** (>90% gate)
  - `uv run pytest tests/e2e/test_pictorial_pdf.py -v --no-cov` → **3 passed in isolation** (Playwright, mock-only)
  - Pre-existing suites unedited: `test_pdf_support.py` 6 passed; `test_garbage_pdf.py` 2 passed + 1 skipped (owner fixture); `test_pdf_image_context.py` 4 passed
  - `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
- Completion criteria:
  1. IKEA tar import repro — pinned by 2 integration tests; **skipped loudly here** (`test_sources/ikea_instructions.tar` absent on this machine — verified repo-wide; same house convention as completed phase-133 HNB tests; runs real on the owner's machine). Equivalent logic fully covered by unit pins on synthetic IKEA-class (vector-drawn, zero-raster) PDFs.
  2. Chat grounded on described page — **green** (E2E test b: phrase-only-in-illustration question, PDF in `done.sources`, answer echoes the block)
  3. Clean-text no-op (zero vision calls) + garbage precedence pin — **green** (`test_zero_candidates_is_a_noop` unit; `test_garbage_pdf_with_pictorial_on_takes_the_fallback_not_the_pass` integration, verified alone)
  4. `BOR_PDF_PICTORIAL=0` byte-identical — **green** (unit pin on synthetic PDF; IKEA variant runs on owner's machine)
  5. Full gate + E2E isolation + pre-existing suites — **green** (above)
  6. ruff + pyright — **clean**
  7. Commit/phase-move — delegated to harness per run rules (left in working tree: 16 modified + 8 new files, uncommitted)
- Deviations: none beyond the expected owner-fixture skips (11, all pre-existing fixture-missing skips, including 2 IKEA).
- Next pending phase: **none** — `todo/` holds only 140; 139 is already complete.
This commit is contained in:
2026-09-30 19:16:35 -04:00
parent a86544a083
commit 9633eb7653
45 changed files with 3895 additions and 36 deletions
+11
View File
@@ -85,6 +85,17 @@ os.environ["BOR_IMAGE_DIR"] = str(_Settings.model_fields["image_dir"].default)
# ``answer_image`` done-frame key absent) must see the code default.
os.environ["BOR_ANSWER_IMAGES"] = str(_Settings.model_fields["answer_images"].default)
# Phase 140: the same leak class for the pictorial-PDF toggle +
# threshold — an operator's shell may carry BOR_PDF_PICTORIAL /
# BOR_PDF_PICTORIAL_MIN_CHARS (the owner runs the feature on in
# production), and the task-01 default pins (LOCKED A3 default-on,
# LOCKED A1 default-100) and the task-03 pass tests must see the code
# defaults.
os.environ["BOR_PDF_PICTORIAL"] = str(_Settings.model_fields["pdf_pictorial"].default)
os.environ["BOR_PDF_PICTORIAL_MIN_CHARS"] = str(
_Settings.model_fields["pdf_pictorial_min_chars"].default
)
from app.db import SessionLocal, db_available # noqa: E402
from app.main import app as fastapi_app # noqa: E402
+11
View File
@@ -41,6 +41,17 @@ USE_REAL_LLM = os.environ.get("E2E_REAL_LLM") == "1"
ADMIN_PASSWORD = "e2e-admin-password"
SESSION_SECRET = "e2e-session-secret-0123456789abcdef0123456789abcdef"
# Phase 140 (task 03): the pictorial-PDF kill switch is pinned OFF for
# every E2E app. The code default is ON (LOCKED A3), and the pre-phase-
# 140 story suites pin the PRE-phase content shape byte-exactly (their
# synthetic PDFs carry sparse pages — under the 100-char bar — so a
# default-ON app would append ``[Page N illustration: …]`` blocks and
# move those pins). The line rides into every app's env through the
# house ``env = dict(os.environ)`` pattern; the phase-140 story suite
# (tests/e2e/test_pictorial_pdf.py) opts in explicitly
# (``env["BOR_PDF_PICTORIAL"] = "1"`` in its module app).
os.environ["BOR_PDF_PICTORIAL"] = "0"
def _wait_http(url: str, timeout: float = 40.0) -> None:
deadline = time.monotonic() + timeout
+35
View File
@@ -34,6 +34,15 @@ Implements just enough of the aipi surface:
the image document's whole content + summary, so it is byte-
stable and token-dense (its cosine against the story suite's
question clears the mock-calibrated gate)
- ``PICTORIAL_PAGE_MODE`` (phase 140, pictorial PDF pages) -> the
fixed ``PICTORIAL_PAGE_ANSWER`` description: the CHAT model's
(vision) reply to ``summarizer.describe_pdf_page``'s multimodal
user message (one rendered sparse PDF page — the marker is its
first text part; the call is non-streaming, so only this answer
path serves it). The reply is appended to the page's indexed
content block as ``[Page N illustration: …]`` (LOCKED A4), so it
is byte-stable: a re-sync with the same reply re-digests to the
same content and lands as ``unchanged``
- ``DEFLECT_MODE`` -> honest "I haven't done anything like that" answer
- otherwise -> upbeat answer quoting the provided document context
- user message containing ``pretend to think slowly`` -> 3s warm-up delay
@@ -992,6 +1001,21 @@ IMAGE_DESCRIPTION_CONTEXT_ANSWER = (
"cable runs. Title: Homelab Network Map."
)
#: Phase 140 (pictorial PDF pages, LOCKED A2): the fixed description
#: the mock's vision (CHAT) model returns for a
#: ``PICTORIAL_PAGE_MODE`` request — the multimodal user message
#: ``[{type: "text", …PICTORIAL_PAGE_MODE…}, {type: "image_url",
#: …}]`` (``app.rag.summarizer.describe_pdf_page`` — one rendered
#: sparse PDF page, the page's own text as context). The reply is
#: appended to the page's indexed content block as
#: ``[Page N illustration: …]`` (LOCKED A4), so it is byte-stable and
#: retrieval-friendly (distinctive + token-dense on the assembly
#: vocabulary the story E2E's question targets).
PICTORIAL_PAGE_ANSWER = (
"Pictorial page: the illustration shows two parts being joined "
"with an arrow."
)
TABLE_ANSWER = (
"Here's the shape, in a table:\n"
"\n"
@@ -2913,6 +2937,17 @@ def compose_answer(body: dict[str, Any]) -> str:
if "Extracted from " in user
else IMAGE_DESCRIPTION_ANSWER
)
elif "PICTORIAL_PAGE_MODE" in user:
# Phase 140 (pictorial PDF pages, LOCKED A2): the CHAT model's
# vision description of ONE rendered sparse PDF page — a
# NON-STREAMING multimodal user message, so only this path
# ever sees it. ``_content_text`` joins the text parts, so the
# marker (the first text part) lands in ``user``. The two
# markers are disjoint fixed app constants (neither contains
# the other), so the branch order vs ``IMAGE_DESCRIPTION_MODE``
# cannot shadow either flow; like that marker, it is an
# app-to-app marker a real question is never expected to type.
answer = PICTORIAL_PAGE_ANSWER
elif TABLE_TRIGGER in user.lower():
# Markdown tables (phase 44, TODO.md L6): the story E2E's
# deterministic table answer — a 3-column table, the
+729
View File
@@ -0,0 +1,729 @@
"""Phase 140 task 04 E2E (Playwright, mock-only): the pictorial PDF —
sparse pages rendered + vision-described into the indexed content.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_pictorial_pdf.py -v --no-cov
The dedicated suite for ``140_pictorial_pdf_pages`` (A16 — one Playwright
file per phase, run in isolation): it proves the owner's TODO L5 scenario
end to end on a SYNTHETIC fixture (the owner's real
``ikea_instructions.tar`` is pinned at the integration level, task 03 —
the E2E keeps the deterministic canned-reply premise): a 2-page PDF
built with PyMuPDF (the ``test_pdf_support.py`` recipe) —
* page 1 = a 700+ char prose paragraph (over the 100-char sparseness
bar — NOT a pictorial candidate),
* page 2 = the 14-char ``Illustration 2`` marker + a drawn VECTOR figure
(a rectangle + an arrow — the IKEA class: vector-drawn pictograms,
ZERO embedded raster images, so the phase-122/137 embedded-image
pipeline sees nothing; the thin text IS a candidate),
tarred as a single-root-member ``.tar`` (the owner's fixture's exact
format — the upload gate admits it) and arriving through the REAL admin
upload flow → the RAG page's Sync button:
* (a) the document appears on the Sources page and the document viewer
renders the stored content: page 1's prose WITHOUT an illustration
block, page 2 WITH ``[Page 2 illustration: <the mock's canned
PICTORIAL_PAGE_ANSWER>]`` (the task-02 ``PICTORIAL_PAGE_MODE`` branch
— deterministic; the stored content is asserted BYTE-EXACT against
the fixture's own extraction in the seed — the candidates are computed
from ``extract_pdf_page_texts`` + ``pictorial_pages`` IN the test, no
hardcoded page list);
* (b) a chat question that ONLY the illustration answers ("how are the
two parts joined on page two?" — the phrase lives NOWHERE in the text
layer) grounds (the described page's embedding retrieves — the mock's
deterministic token-overlap cosine clears the mock-calibrated 0.30
gate; FTS corroborates) and, scripted through the house seeded-read
convention (phase 119 A1 — a zero-read grounded turn chips NOTHING, so
the PDF must be READ to land in ``done.sources``), the SSE completes
without deflecting: the ``done`` event's ``sources`` carries the PDF
and the answer echoes the read result VERBATIM — the
``[Page 2 illustration: …]`` block and its phrase reach the model and
land in the answer;
* (c) the sync's ``detail`` JSON keeps its pre-phase keys EXACTLY (no
new field — the ``pdfs_pictorial`` / ``pdf_pictorial_pages`` counters
are log-only, PLAN §9, the phase-133 convention).
House pattern: one module app over the shared E2E Postgres (its own
port, module env — the conftest session app is never started in this
isolated run, so no port clash). The three tests SHARE the module seed
(one source, one document), so the shared registry + KB is truncated
ONCE, after the last test (a module-granularity autouse — a per-test
truncate would wipe the seed from under its siblings), with a
no-running-sync wait so a leaked run cannot race it.
App env: ``BOR_IMPORT_EXTENSIONS`` pinned WITH the ``pdf`` token (the
phase-126 convention — without the token the walk is blind to the file)
and ``BOR_PDF_PICTORIAL`` pinned EXPLICITLY to ``1``: the conftest pins
it OFF in ``os.environ`` for the pre-phase-140 story suites (their
synthetic PDFs carry sparse pages — a default-ON app would move their
byte-exact pins), and this suite is the documented opt-in (the
conftest's phase-140 comment). ``BOR_IMAGES`` is pinned ``false`` — the
embedded-image pipeline is out of scope (the fixture's figure is
vector-drawn; it yields no image doc either way). The byte-exact
content assertion in the seed is the toggle's premise witness: without
the opt-in, page 2 would lack its illustration block and the seed
fails loudly.
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the described
block is the mock's deterministic ``PICTORIAL_PAGE_MODE`` canned reply
(``PICTORIAL_PAGE_ANSWER``), so the byte-exact content + echo
assertions are exact only against it.
Test → observable mapping (Playwright Mapping Rule):
1. ``test_sources_page_and_viewer_show_the_described_page`` — the
Sources page reads 1 doc / 3 chunks (2 page-anchored content chunks +
the summary chunk — no image doc), the drill-down table lists the PDF
as a plain text row (LOCKED A4 — a text document, no thumbnail), and
the same-page document viewer (the row's path link) renders the
stored content under the METADATA title: the page-1 prose (NO
``[Page 1 illustration`` block), the page-2 marker + its
``[Page 2 illustration: <canned>]`` block, and the Summary panel
(a non-image doc with a non-empty summary).
2. ``test_chat_question_grounds_and_echoes_the_illustration`` — the raw
SSE over the scripted seeded-read question (httpx — the ``done``
event is the wire lens the task pins; the UI flow's premise is the
module seed): the stream settles ``done`` with ``deflected`` false
and NO ``error`` frame, EXACTLY one ``tool`` frame (the scripted
``read`` on the PDF; the doc is far under the read cap — no
truncation, so no ``tool_result`` frame), the ``done`` event's
``sources`` is EXACTLY the PDF (the citation surface — read docs
only, phase 119 A1), and the joined deltas echo the read result
VERBATIM — the ``Document <source>/<path>:`` header and the
``[Page 2 illustration: …]`` block with the canned phrase (the
described page's content reaches the model and lands in the answer).
3. ``test_sync_detail_keeps_the_pre_phase_keys`` — the module run's
terminal ``detail`` (``GET /api/sync/status`` — the same object the
UI's "1 added" line renders from) carries the pre-phase key set
EXACTLY: the ``pdfs_pictorial`` / ``pdf_pictorial_pages`` counters
never ride the wire (the stats log-only convention), and the run's
own file counters read 1 file / 1 added / 0 unchanged / 0 pruned /
0 errors.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import tarfile
import time
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import httpx
import pymupdf as fitz # PyMuPDF — the synthetic-fixture builder (the house recipe)
import pytest
from playwright.sync_api import Browser, Page, expect
from sqlalchemy import select, text
from app.config import Settings
from app.db import SessionLocal
from app.models import Document
from app.rag.pdf import (
PAGE_SEPARATOR,
extract_pdf_page_texts,
pictorial_pages,
)
from e2e.auth_helpers import login
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
from e2e.mock_llm import PICTORIAL_PAGE_ANSWER
REPO = Path(__file__).resolve().parents[2]
SOURCE_NAME = "e2e-pictorial" # the archive stem (archive_source_name)
DOC_PATH = "instructions.pdf"
PDF_TITLE = "Assembly Instructions" # the metadata title (LOCKED A2)
#: Page 1 — a 700+ char prose paragraph (over the 100-char sparseness
#: bar — NOT a candidate). Deliberately carries NO "two parts joined"
#: content: the phrase the question targets lives ONLY in the
#: description the vision pass appends to page 2 (the owner's L5
#: scenario — the illustration answers what the text layer cannot).
PAGE_ONE = (
"This manual covers the complete flat-pack furniture frame, its "
"shipped hardware kit, and the safety checks a first-time assembler "
"should run before starting. The box holds the two side panels, the "
"top board, the adjustable leg set, eight cam locks, fourteen wood "
"screws, two bolts, and the wall-anchoring strap. Work on a flat "
"surface with soft flooring, keep the hardware sorted by size, and "
"read every step through before you begin so you know which panel "
"is which. The frame stands roughly a metre tall once assembled, "
"weights under twelve kilograms, and needs no further tools beyond "
"the included Allen key. Keep the small parts away from children "
"until the final step is done and the cam locks are fully seated."
)
assert len(PAGE_ONE) > 500, "page 1 must be a 500+ char paragraph (the task's recipe)"
#: Page 2 — the sparse page: the marker line is ALL the text layer
#: carries (14 stripped chars, under the 100-char bar — the candidate);
#: the figure is drawn vector (no raster image).
PAGE_TWO = "Illustration 2"
#: The stored illustration block (LOCKED A4) — the mock's canned
#: ``PICTORIAL_PAGE_MODE`` reply, deterministic.
ILLUSTRATION_BLOCK = f"[Page 2 illustration: {PICTORIAL_PAGE_ANSWER}]"
#: The grounded question (b): the house seeded-read call (phase 119 A1 —
#: a zero-read grounded turn chips NOTHING, so the PDF must be READ to
#: earn its ``done.sources`` citation) + the natural question, whose
#: tokens ("two parts joined page") overlap ONLY the illustrated page's
#: content (the mock's token-overlap cosine grounds; the text layer
#: alone would answer nothing).
QUESTION = (
f"Read the suggested document: read {SOURCE_NAME}/{DOC_PATH} — "
"how are the two parts joined on page two?"
)
#: The sync ``detail`` key set EXACTLY as pre-phase 140 (the
#: ``app/api/sync.py`` terminal detail — phase 126/133 shape). The
#: phase-140 counters are log-only and must NOT appear (c).
PRE_PHASE_DETAIL_KEYS = frozenset(
{
"files",
"added",
"updated",
"unchanged",
"pruned",
"errors",
"chunks",
"summaries",
"summary_errors",
"dates_updated",
"overview",
"sources_version",
}
)
#: Phase 79 (task 04, full inventory): the conftest session app owns its
#: port in a combined run — this module app binds its own port instead
#: (a same-port second uvicorn dies on bind and would drive the wrong
#: server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_PICTPDF", "8155"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
UPLOAD_TIMEOUT_MS = 30_000
SYNC_TIMEOUT_MS = 60_000
SYNCED_LABEL = re.compile(r"^Synced \d{1,2}:\d{2}$")
# ---------------------------------------------------------------------------
# Fixture builder (the ``test_pdf_support.py`` PyMuPDF recipe, the
# owner's class: a dense prose page + a sparse vector-illustrated page)
# ---------------------------------------------------------------------------
def _build_pdf(path: Path) -> None:
"""ONE 2-page PDF (the task's recipe): page 1 = the 700+ char prose
paragraph (``insert_textbox`` — word-wrapped, no mid-word breaks in
the extraction), page 2 = the ``Illustration 2`` marker + a drawn
VECTOR figure (a rectangle + a line arrow with a two-stroke head —
the IKEA pictogram class: ``get_image_info()`` sees ZERO embedded
raster images, the phase-122/137 pipeline is blind to it)."""
doc = fitz.open()
page1 = doc.new_page()
rc = page1.insert_textbox(fitz.Rect(72, 72, 523, 772), PAGE_ONE, fontsize=11)
assert rc >= 0, "the page-1 paragraph must fit its text box"
page2 = doc.new_page()
page2.insert_text((72, 72), PAGE_TWO)
# The vector figure: a rectangle (a part) + an arrow (the join).
page2.draw_rect(fitz.Rect(100, 120, 300, 220))
page2.draw_line(fitz.Point(320, 170), fitz.Point(470, 170))
page2.draw_line(fitz.Point(470, 170), fitz.Point(450, 158))
page2.draw_line(fitz.Point(470, 170), fitz.Point(450, 182))
doc.set_metadata({"title": PDF_TITLE})
doc.save(path)
doc.close()
@pytest.fixture(scope="module")
def pdf_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The fixture PDF (module-scoped, built once) — the local copy the
byte-exact content assertion computes against (the app indexes the
unpacked copy of the SAME bytes)."""
root = tmp_path_factory.mktemp("bor_pict_pdf")
pdf = root / DOC_PATH
_build_pdf(pdf)
# The premise pins (deterministic — the pass's candidate set and the
# gate's own contract, computed from the fixture itself): page 1 is
# prose (no candidate), page 2 is sparse (the candidate).
page_texts = extract_pdf_page_texts(pdf)
candidates = pictorial_pages(
page_texts,
min_chars=Settings.model_fields["pdf_pictorial_min_chars"].default,
)
assert candidates == [2], (
f"the fixture's sparse-page premise broke — candidates={candidates}"
)
return pdf
@pytest.fixture(scope="module")
def tar_path(pdf_path: Path) -> Path:
"""The fixture archive: ONE PDF at the root (the ``.tar`` format —
the owner's ``ikea_instructions.tar`` class; the upload gate admits
it) — source ``e2e-pictorial``, document path ``instructions.pdf``."""
archive = pdf_path.parent / f"{SOURCE_NAME}.tar"
with tarfile.open(archive, "w") as tf:
tf.add(pdf_path, arcname=DOC_PATH)
return archive
# ---------------------------------------------------------------------------
# Module app (the conftest env pattern + the phase-140 knobs)
# ---------------------------------------------------------------------------
def _app_env(mock_llm: int, app_port: int, scratch: Path) -> dict[str, str]:
"""The conftest app env (leak guards included) with the phase-140
knobs: ``BOR_IMPORT_EXTENSIONS`` pinned to a set carrying the
``pdf`` token (the phase-126 gate — an operator's local gitignored
``.env`` cannot leak the scope either way), ``BOR_PDF_PICTORIAL``
pinned EXPLICITLY on (the conftest pins it off in ``os.environ``
for the pre-phase suites — this suite is the documented opt-in;
without it the whole premise is void), and ``BOR_IMAGES`` pinned
off (out of scope — the vector figure yields no image doc anyway)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# Mock-calibrated gate (conftest pattern) — the story's question
# grounds on these values (see QUESTION).
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
# Phase 67: instant retry waits + the code-default budget.
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# Leak guards (conftest pattern).
env["BOR_GIT_SOURCES"] = ""
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
Settings.model_fields["suggestions"].default
)
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
# Phase 140: the import scope WITH the ``pdf`` token + the toggle
# under test (the EXPLICIT opt-in — see the module docstring) + the
# suite-private homes.
env["BOR_IMPORT_EXTENSIONS"] = "md,markdown,txt,pdf"
env["BOR_PDF_PICTORIAL"] = "1"
env["BOR_IMAGES"] = "false"
env["BOR_UPLOAD_DIR"] = str(scratch / "uploads")
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
env["BOR_IMAGE_DIR"] = str(scratch / "images")
return env
@pytest.fixture(scope="module")
def app_server(
mock_llm: int, tmp_path_factory: pytest.TempPathFactory
) -> Iterator[str]:
"""The app under test (all three tests): one module app with the
phase-140 env (see :func:`_app_env`)."""
scratch = tmp_path_factory.mktemp("bor_pict_scratch")
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=_app_env(mock_llm, APP_PORT, scratch=scratch),
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
# ---------------------------------------------------------------------------
# DB + UI helpers
# ---------------------------------------------------------------------------
def _truncate_all() -> None:
"""Fresh KB + registry (the E2E isolation pattern): the suites share
one Postgres, and a leftover row would corrupt the counts.
``sources_meta`` (the KB generation counter) resets with the KB —
the sync re-creates the row."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, steering_notes, "
"kb_overview, git_sources, folder_summaries, sources_meta"
)
)
db.commit() # without the commit the TRUNCATE rolls back (the house pattern)
def _health_db(app_url: str) -> bool:
try:
return httpx.get(f"{app_url}/api/health", timeout=5).json()["db"] == "up"
except Exception: # noqa: BLE001 — unreachable is the skip case
return False
def _wait_sync_settled(base_url: str, timeout_s: float = 60.0) -> None:
"""No background sync may leak across tests (the run state lives in
the app's memory, and a still-running run would keep importing into
the NEXT test's truncated KB): wait for the app's sync status to be
non-running BEFORE the truncate (the phase-96 pattern)."""
with httpx.Client(base_url=base_url, timeout=5.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
r = client.get("/api/sync/status")
assert r.status_code == 200, r.text
if r.json()["state"] != "running":
return
time.sleep(0.2)
raise AssertionError(f"a sync was still running at a test boundary: {base_url}")
@pytest.fixture(scope="module", autouse=True)
def _clean_kb(app_url: str) -> Iterator[None]:
"""Module-end KB cleanup (the phase-96 pattern, module granularity):
the three tests SHARE the module seed's single source + document,
so there is NO per-test truncate (it would wipe the seed from under
the next test — the per-test pattern belongs to the suites where
every test imports its own source); instead the shared KB is
flushed ONCE, after the last test — no rows leak into the next
suite's run. The pre-wait keeps a leaked sync (the module app is
still up here) from racing the truncate."""
yield
_wait_sync_settled(app_url)
_truncate_all()
def _upload_tar(page: Page, app_url: str, archive: Path) -> None:
"""The real admin upload flow: form login → the git-sources page →
pick the archive → submit → the terminal result line (phase 90:
"Uploaded <source> — press Sync sources to import it.")."""
login(page, app_url, next="/git-sources.html")
expect(page).to_have_url(app_url + "/git-sources.html", timeout=30_000)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
page.set_input_files("#archive-upload-file", str(archive))
page.click("#archive-upload-btn")
result = page.locator("#archive-upload-result")
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
expect(result).to_have_text(
f"Uploaded {SOURCE_NAME} — press Sync sources to import it.",
timeout=UPLOAD_TIMEOUT_MS,
)
def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
"""The RAG page's "Sync sources" button (the scan): click →
"Syncing…" → the terminal "Synced HH:MM" label + the fresh counts
in #sync-result (the never-stale contract)."""
page.goto(app_url + "/sources.html")
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=30_000)
expect(page.locator("#sync-label")).to_have_text(
re.compile(r"^Sync sources$|^Synced \d{1,2}:\d{2}$")
)
btn.click()
expect(btn).to_be_disabled()
expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled()
expect(page.locator("#sync-result")).to_have_text(expected_result)
def _drill_to_source(page: Page, name: str) -> None:
"""Top level → the source (the file table then holds its direct
files — ours is at the source root)."""
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=30_000)
def _chat_frames(app_url: str, message: str) -> list[dict[str, Any]]:
"""One ``POST /api/chat`` turn as RAW SSE frames (the wire lens —
the house pattern from ``test_honest_deflection.py``): the signed
admin client streams the turn and parses the ``data: <json>``
frames (PLAN §4: ``data: <json>\\n\\n``)."""
with httpx.Client(base_url=app_url, timeout=120.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
frames: list[dict[str, Any]] = []
with client.stream(
"POST", "/api/chat", json={"message": message}, timeout=120.0
) as r:
assert r.status_code == 200, r.read().decode()
buf = ""
for part in r.iter_text():
buf += part
while "\n\n" in buf:
frame, buf = buf.split("\n\n", 1)
if frame.strip().startswith("data:"):
frames.append(
json.loads(frame.strip().removeprefix("data:").strip())
)
assert buf.strip() == "" # the stream ends cleanly on a frame boundary
return frames
# ---------------------------------------------------------------------------
# The module seed: upload + sync the fixture PDF through the real UI,
# once (the story E2E truncate/re-import-per-module pattern)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def seeded(
app_url: str,
browser: Browser,
tar_path: Path,
pdf_path: Path,
) -> Iterator[dict[str, str]]:
"""Upload the single-PDF tar through the admin UI, then scan it with
the RAG page's Sync button (the mock's vision model describes the
sparse page in-process — the ``PICTORIAL_PAGE_MODE`` canned reply).
The seed asserts the stored document BYTE-EXACT: the fixture's own
extraction (page blocks, the page-2 candidate carrying the canned
illustration block) — the candidates computed from
``extract_pdf_page_texts`` + ``pictorial_pages`` IN the fixture (no
hardcoded page list). This is the module app's toggle premise
witness too: without the ``BOR_PDF_PICTORIAL`` opt-in, page 2 would
lack its block and this fails loudly. Yields the doc's identity."""
if not _health_db(app_url):
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
_truncate_all()
page = browser.new_page(viewport={"width": 1280, "height": 800})
try:
_upload_tar(page, app_url, tar_path)
# The PDF alone (no embedded raster images — the vector figure
# yields no image doc): one file, one added document.
_sync_via_ui(page, app_url, expected_result="1 added")
with SessionLocal() as db:
doc = db.scalar(
select(Document).where(
Document.source == SOURCE_NAME, Document.path == DOC_PATH
)
)
assert doc is not None, "the uploaded PDF must become a document"
# LOCKED A2: one document per PDF, the metadata title; a TEXT
# document (no image doc, no image path).
assert doc.title == PDF_TITLE
assert doc.is_image is False and doc.image_path is None
# The candidates (computed from the fixture itself — the
# deterministic gate, LOCKED A1) + the byte-exact stored
# content: the extraction's page blocks, the described
# candidate carrying ``[Page N illustration: <canned>]``
# (LOCKED A4).
page_texts = extract_pdf_page_texts(pdf_path)
candidates = pictorial_pages(
page_texts,
min_chars=Settings.model_fields["pdf_pictorial_min_chars"].default,
)
assert candidates == [2]
parts = []
for p, t in page_texts.items():
block = t
if p in candidates:
block += f"\n\n[Page {p} illustration: {PICTORIAL_PAGE_ANSWER}]"
parts.append(f"{PAGE_SEPARATOR.format(page=p)}\n{block}")
expected = "\n\n".join(parts)
assert doc.content == expected, (
"the stored content is not the extraction with the "
"described sparse page — the pictorial pass misfired"
)
# The non-markdown doc got its lite summary (the mock's
# SUMMARY_MODE digest) — the summary chunk + the viewer's
# Summary panel exist.
assert doc.summary
finally:
page.close()
yield {"title": PDF_TITLE}
# ---------------------------------------------------------------------------
# 1. Sources page + document viewer (a): the described page renders
# ---------------------------------------------------------------------------
def test_sources_page_and_viewer_show_the_described_page(
page: Page, app_url: str, seeded: dict[str, str]
) -> None:
"""The KB total is 1 doc / 3 chunks (the PDF: 2 page-anchored
content chunks + the phase-30 summary chunk — no image doc; the
vector figure is invisible to the embedded-image pipeline). The
drill-down table lists the PDF as a plain text row (LOCKED A4), and
the same-page document viewer (the row's path link, phase 26)
renders the stored content under the METADATA title: page 1's prose
WITHOUT an illustration block (not a candidate) and page 2 WITH
``[Page 2 illustration: <canned>]`` (the described sparse page —
LOCKED A4's user surface), plus the Summary panel (a non-image doc
with a non-empty summary)."""
login(page, app_url) # lands on /sources.html
expect(page.locator("#stat-docs")).to_have_text("1")
expect(page.locator("#stat-chunks")).to_have_text("3")
expect(page.locator("#sources-empty")).to_be_hidden()
_drill_to_source(page, SOURCE_NAME)
expect(page.locator("#docs-tbody tr")).to_have_count(1)
row = page.locator("#docs-tbody tr").first
# A plain text row — no thumbnail box (it is a text document, A4).
expect(row.locator(".kb-doc-thumb")).to_have_count(0)
page.locator(f'#docs-tbody a.doc-link:text-is("{DOC_PATH}")').click()
modal = page.locator("#doc-modal")
expect(modal).to_be_visible(timeout=30_000)
expect(page.locator("#doc-modal-title")).to_have_text(PDF_TITLE)
raw = modal.locator("pre.doc-raw")
# Page 1 — the prose WITHOUT any illustration block. The viewer
# renders the content verbatim (``insert_textbox`` wraps mid-
# sentence, so compare whitespace-normalized).
expect(raw).to_contain_text("---PAGE-1---")
rendered = " ".join(raw.inner_text().split())
assert " ".join(PAGE_ONE.split()) in rendered, (
"the page-1 prose is not what the viewer renders"
)
assert "[Page 1 illustration" not in rendered, (
"page 1 (over the sparseness bar) must carry NO illustration block"
)
# Page 2 — the sparse page WITH the described block (a) — the mock's
# canned reply, deterministic.
expect(raw).to_contain_text("---PAGE-2---")
expect(raw).to_contain_text(PAGE_TWO)
expect(raw).to_contain_text(ILLUSTRATION_BLOCK)
# The Summary panel renders (a non-image doc with a non-empty
# summary); no image block (the PDF is not an image document).
expect(modal.locator(".doc-summary")).to_have_count(1)
expect(modal.locator(".doc-image-img")).to_have_count(0)
# ---------------------------------------------------------------------------
# 2. Chat (b): the question only the illustration answers grounds, and
# the answer echoes the described phrase
# ---------------------------------------------------------------------------
def test_chat_question_grounds_and_echoes_the_illustration(
app_url: str, seeded: dict[str, str]
) -> None:
"""The raw SSE over the scripted seeded-read question (phase 119 A1
— a zero-read grounded turn chips NOTHING, so the PDF must be READ
to earn its ``done.sources`` citation): the turn GROUNDS (no
deflection — the described page's embedding retrieves: the mock's
deterministic token-overlap cosine clears the mock-calibrated 0.30
gate, FTS corroborates), the mock's scripted ``read`` runs (EXACTLY
one ``tool`` frame — the doc is far under the read cap, so no
truncation and no ``tool_result`` frame), and the answer ECHOES the
read result verbatim — the ``[Page 2 illustration: …]`` block and
its phrase reach the model and land in the rendered answer (the
described page's content is what a chat turn can ground on — the
owner's L5 rationale end to end)."""
frames = _chat_frames(app_url, QUESTION)
assert frames, "the SSE stream settled with no frames at all"
assert not any(f.get("type") == "error" for f in frames), (
f"the turn errored: {[f for f in frames if f.get('type') == 'error']}"
)
# The scripted read — exactly one tool call, on the PDF.
tools = [f for f in frames if f.get("type") == "tool"]
assert len(tools) == 1, f"expected exactly one tool frame: {tools}"
assert tools[0]["name"] == "read"
assert tools[0]["argument"] == f"{SOURCE_NAME}/{DOC_PATH}"
# The doc is far under the read cap — no truncation frame (the echo
# below therefore carries the FULL content, the block included).
assert not any(f.get("type") == "tool_result" for f in frames)
# The terminal frame: ``done``, NOT deflected.
done = [f for f in frames if f.get("type") == "done"]
assert len(done) == 1 and frames[-1] is done[0], (
f"the stream must settle with exactly one terminal done frame: "
f"{[f.get('type') for f in frames]}"
)
assert done[0]["deflected"] is False, (
"the question grounded on the described page — a deflection means "
"the illustrated content did not retrieve"
)
# The ``done`` event's ``sources`` — the citation surface (read docs
# only, phase 119 A1) — carries EXACTLY the PDF.
refs = done[0]["sources"]
assert [(r["source"], r["path"]) for r in refs] == [(SOURCE_NAME, DOC_PATH)], (
f"the done event's sources must be exactly the read PDF: {refs}"
)
assert refs[0]["title"] == PDF_TITLE
# The answer (the joined deltas) echoes the read result VERBATIM —
# the header line and the described block with the canned phrase.
answer = "".join(f["text"] for f in frames if f.get("type") == "delta")
assert "Here's what the read returned:" in answer
assert f"Document {SOURCE_NAME}/{DOC_PATH}:" in answer
assert ILLUSTRATION_BLOCK in answer, (
"the answer does not echo the described illustration block — "
"the vision pass's content never reached the model"
)
assert PICTORIAL_PAGE_ANSWER in answer # the phrase itself (the task's pin)
# ---------------------------------------------------------------------------
# 3. Sync detail (c): the pre-phase key set is untouched (stats are
# log-only)
# ---------------------------------------------------------------------------
def test_sync_detail_keeps_the_pre_phase_keys(
app_url: str, seeded: dict[str, str]
) -> None:
"""The module run's terminal ``detail`` (``GET /api/sync/status`` —
the same object the UI's "1 added" line renders from) keeps the
pre-phase key set EXACTLY: the phase-140 counters
(``pdfs_pictorial`` / ``pdf_pictorial_pages``) are log-only (PLAN
§9, the phase-133 convention) and must NOT ride the wire, and the
run's own file counters read 1 file / 1 added / 0 unchanged /
0 pruned / 0 errors (the single PDF, a fresh KB)."""
with httpx.Client(base_url=app_url, timeout=30.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
r = client.get("/api/sync/status")
assert r.status_code == 200, r.text
body = r.json()
assert body["state"] == "success", body
detail = body["detail"]
# The stats log-only convention — the pictorial counters never
# reach the wire.
assert "pdfs_pictorial" not in detail, (
"the log-only pdfs_pictorial counter leaked into the sync detail"
)
assert "pdf_pictorial_pages" not in detail, (
"the log-only pdf_pictorial_pages counter leaked into the sync detail"
)
# The shape itself — the pre-phase key set EXACTLY (no new field).
assert set(detail) == set(PRE_PHASE_DETAIL_KEYS), (
f"the sync detail shape changed — got {sorted(detail)}"
)
# The run's own file counters (a fresh KB: one file, one added).
assert detail["files"] == 1, detail
assert detail["added"] == 1, detail
assert detail["unchanged"] == 0, detail
assert detail["pruned"] == 0, detail
assert detail["errors"] == 0, detail
+330 -2
View File
@@ -34,16 +34,26 @@ from __future__ import annotations
import asyncio
import base64
import hashlib
import logging
import tarfile
from collections.abc import Iterator
from pathlib import Path
import pymupdf as fitz # PyMuPDF — the temp-PDF fixture builder (phase 126)
import pytest
from sqlalchemy import select
from app.config import Settings
from app.models import Chunk, Document
from app.rag.importer import import_sources
from app.rag.llm import EmbeddingError
from app.rag.summarizer import DESCRIBE_PROMPT
from app.rag.pdf import (
PAGE_SEPARATOR,
extract_pdf_page_texts,
extract_pdf_text,
pictorial_pages,
)
from app.rag.summarizer import DESCRIBE_PROMPT, PICTORIAL_PAGE_MODE
from tests.fakes import FakeEmbedder
#: A real 1×1 transparent PNG (the phase-122 fixture — the importer is
@@ -99,10 +109,17 @@ def _image_llm[
](tmp_path, llm_cls: type[LLM] = _MockVisionLLM, **kwargs) -> LLM:
"""A fake LLM with the phase-122 image knobs (toggle ON; the image
dir defaults under *tmp_path*). *llm_cls* may be the mock vision
client (the default — ``text_parts``/``chat_models`` state)."""
client (the default — ``text_parts``/``chat_models`` state).
Phase 140: ``pdf_pictorial`` defaults to OFF here — the pre-phase-
140 PDF suites in this file pin the pre-phase content shape (their
fixture pages are sparse — under the 100-char bar — so the
default-ON pictorial pass would append illustration blocks and
move the byte-exact pins); the phase-140 suites pass
``pdf_pictorial=True`` explicitly."""
kwargs.setdefault("_env_file", None)
kwargs.setdefault("images", True)
kwargs.setdefault("image_dir", str(tmp_path / "images"))
kwargs.setdefault("pdf_pictorial", False)
llm = llm_cls()
llm.settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return llm
@@ -1506,3 +1523,314 @@ def test_backfill_embed_failure_rolls_back_the_image_and_retries_next_sync(
assert img2.image_context.startswith("Extracted from ")
finally:
_cleanup_source(db, source_root.name)
# ---------------------------------------------------------------------------
# Phase 140 — Pictorial PDFs: sparse pages rendered + vision-described
# into the indexed content (the owner's IKEA repro, TODO L5; the kill
# switch; the phase-133 precedence pin).
# ---------------------------------------------------------------------------
REPO_ROOT = Path(__file__).resolve().parents[2]
#: The owner's untracked fixture (TODO L5 — the stable reference: the
#: TARBALL, never the extracted PDF; the phase-133 real-fixture
#: convention — ``test_sources/`` is gitignored, the test extracts it
#: into ``tmp_path`` and never touches the repo, and the owner's
#: machine always has it: a missing tar skips LOUDLY with the named
#: reason, it is never silently green).
IKEA_TARBALL = REPO_ROOT / "test_sources" / "ikea_instructions.tar"
#: The temp source dir's basename is the source name (phase 38).
IKEA_SOURCE = "ikea"
#: The fixture's embedded metadata title (Adobe InDesign's
#: "Assembly Instructions" — the phase-140 context).
IKEA_TITLE = "Assembly Instructions"
#: The fixture's text-heavy pages (pages 2–5 — the multilingual
#: warnings, 3,091–7,288 chars — never candidates; the sparse pages
#: run 4–73 chars, so the 100-char bar sits between the classes).
IKEA_TEXT_HEAVY_PAGES = (2, 3, 4, 5)
class _PictorialVisionLLM(FakeEmbedder):
"""The capturing multimodal fake (task 03): a vision call whose
text part carries ``PICTORIAL_PAGE_MODE`` (the pictorial pass's
seam — ``describe_pdf_page``) gets the fixed per-candidate canned
reply and is recorded in ``pictorial_texts``; a call carrying
``IMAGE_DESCRIPTION_MODE`` (the phase-133 fallback's / phase-122
images' seam — ``describe_image``) gets a DISTINCT canned reply
and is recorded in ``image_texts``. The fixed replies make the
stored content a PURE FUNCTION of the fixture (the digest-stable
premise of the re-sync assertion), and the two recordings witness
WHICH seam fired (the precedence pin). String (``lite``) calls
keep the ``FakeEmbedder`` behaviour. The REAL
``import_sources`` → ``_pictorial_pdf_pass`` →
``describe_pdf_page`` chain runs against it (no seam patch)."""
PICTORIAL_ANSWER = (
"IKEA pictorial page: the illustration shows two parts being "
"joined with a blue arrow; a cam lock is tightened with the "
"included hex key."
)
IMAGE_ANSWER = (
"Fallback screenshot: the page shows an assembly pictogram."
)
def __init__(self) -> None:
super().__init__()
self.pictorial_texts: list[str] = []
self.image_texts: list[str] = []
async def chat(self, messages, model=None):
self.chat_calls.append(list(messages))
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
if isinstance(user, list):
text = next((p["text"] for p in user if p.get("type") == "text"), "")
if PICTORIAL_PAGE_MODE in text:
self.pictorial_texts.append(text)
return self.PICTORIAL_ANSWER
self.image_texts.append(text)
return self.IMAGE_ANSWER
first = user.split()
return "Summary of " + (first[0] if first else "<empty>")
@pytest.fixture()
def ikea_pdf(db, tmp_path) -> Iterator[Path]:
"""The owner's IKEA tarball extracted into a temp ``ikea/`` source
dir (the phase-133 real-fixture convention — ``tmp_path`` only,
never the repo). Any leftover ``ikea`` source rows from a crashed
earlier run are cleaned before AND after (the shared dev DB is
reused across runs). Skips LOUDLY with the named reason when the
untracked owner fixture is missing."""
if not IKEA_TARBALL.is_file():
pytest.skip(
"test_sources/ikea_instructions.tar is missing — the untracked "
"owner fixture (one 24-page vector-drawn assembly-instruction "
"PDF; the owner's machine always has it)"
)
_cleanup_source(db, IKEA_SOURCE)
root = tmp_path / IKEA_SOURCE
root.mkdir()
with tarfile.open(IKEA_TARBALL) as tar:
tar.extractall(root, filter="data")
pdfs = sorted(root.rglob("*.pdf"))
assert len(pdfs) == 1, f"the IKEA tar carries exactly one PDF, found: {pdfs}"
yield pdfs[0]
_cleanup_source(db, IKEA_SOURCE)
def test_ikea_tar_sparse_pages_are_described_and_rerun_unchanged(
db, tmp_path, ikea_pdf: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The owner's EXACT file (TODO L5) through the REAL importer:
the deterministic gate's candidates — computed IN the test from
``extract_pdf_page_texts`` + ``pictorial_pages`` (no hardcoded
page list — the fixture is stable but the test must not rot) —
every one carries ``[Page N illustration: <canned>]`` AFTER its
own text (LOCKED A2 — no LLM judge: every described page joins
the content; A4 — the page's text stays), the text-heavy pages
(2–5) carry NO block, the chunks carry the canned description
(the owner's rationale — more information is better for the
embeddings), the title is the metadata title, the digest is over
the FINAL content, and a re-sync with the same fake lands
``unchanged`` (digest stable, byte-stable row, NO re-embed; the
vision calls are re-issued before the unchanged check can skip
them — the documented no-cache cost, the phase-133 convention,
A21 endorses it)."""
pdf_path = ikea_pdf
llm = _image_llm(
tmp_path,
llm_cls=_PictorialVisionLLM,
pdf_pictorial=True,
import_extensions="md,txt,pdf",
)
try:
with caplog.at_level(logging.INFO, logger="app.importer"):
s1 = asyncio.run(import_sources([pdf_path.parent], llm, session=db))
assert (s1.files, s1.added, s1.errors) == (1, 1, 0)
assert s1.formats == {"pdf": 1}
# The candidates — computed IN the test (no hardcoded page
# list). The fixture class: the sparse pages are 4–73 chars,
# the text-heavy pages 3,091+ — the 100-char bar separates.
page_texts = extract_pdf_page_texts(pdf_path)
candidates = pictorial_pages(
page_texts, min_chars=llm.settings.pdf_pictorial_min_chars
)
assert candidates, "the fixture must carry sparse (pictorial) pages"
for n in IKEA_TEXT_HEAVY_PAGES:
assert n not in candidates, f"page {n} is text-heavy, never a candidate"
doc = db.scalar(
select(Document).where(
Document.source == IKEA_SOURCE, Document.path == pdf_path.name
)
)
assert doc is not None, "the IKEA PDF did not import"
assert doc.title == IKEA_TITLE # the PDF's metadata title
assert doc.is_image is False
content = doc.content
# Every page's block is byte-faithful: the page text, the
# described candidates carrying their illustration block
# AFTER the text (A4), and NO block on the non-candidates.
for n, text in page_texts.items():
block = f"{PAGE_SEPARATOR.format(page=n)}\n{text}"
if n in candidates:
block += (
f"\n\n[Page {n} illustration: "
f"{_PictorialVisionLLM.PICTORIAL_ANSWER}]"
)
assert block in content, f"page {n}'s block is not byte-faithful"
if n not in candidates:
assert f"[Page {n} illustration" not in content
assert content.count(" illustration: ") == len(candidates)
# The digest runs over the FINAL content (the phase-126/133
# convention — A4).
assert doc.content_hash == hashlib.sha256(
content.encode("utf-8")
).hexdigest()
# The chunks carry the canned description (the owner's L5
# rationale — the embeddings hit the illustrations), and the
# phase-30/118 summary ran on the final content.
chunks = db.scalars(
select(Chunk).where(Chunk.document_id == doc.id).order_by(Chunk.position)
).all()
content_chunks = [c for c in chunks if not c.is_summary]
assert any(
_PictorialVisionLLM.PICTORIAL_ANSWER in c.content for c in content_chunks
), "no content chunk carries the canned description"
for c in chunks:
assert c.embedding is not None and len(c.embedding) == 768
assert doc.summary is not None
# The counters (log-only — the sync detail shape is untouched)
# + the ONE loud log line (PLAN §9), with the computed N.
assert s1.pdfs_pictorial == 1
assert s1.pdf_pictorial_pages == len(candidates)
assert s1.pdfs_screenshotted == 0 # the layer is USABLE — no fallback
assert llm.image_texts == [] # the fallback seam never fired
min_chars = llm.settings.pdf_pictorial_min_chars
lines = [
r.getMessage() for r in caplog.records if "pictorial pass" in r.getMessage()
]
assert lines == [
f"pdf: pictorial pass source={IKEA_SOURCE} path={pdf_path.name} — "
f"{len(candidates)} candidate pages, {len(candidates)} described "
f"(min_chars={min_chars})"
]
# The re-sync with the SAME fake: ``unchanged`` — the final
# content is a pure function of the fixture + the canned
# replies, so the digest matches and the row is skipped (no
# re-chunk, no re-embed, no re-summary, no KB change). The
# vision calls ARE re-issued (the documented no-cache cost —
# the phase-133 convention; a non-deterministic vision model
# re-describes the row each sync: a cost, not a bug).
embeds1 = len(llm.calls)
pictorial1 = len(llm.pictorial_texts)
content1, digest1 = doc.content, doc.content_hash
s2 = asyncio.run(import_sources([pdf_path.parent], llm, session=db))
assert (s2.added, s2.updated, s2.unchanged) == (0, 0, 1)
assert s2.pdfs_pictorial == 1 # the pass re-fired
assert len(llm.pictorial_texts) == 2 * pictorial1 # re-issued (cost)
assert len(llm.calls) == embeds1, "unchanged: no re-embed"
assert s2.changed_paths == [], "unchanged: no KB change recorded"
doc2 = db.scalar(
select(Document).where(
Document.source == IKEA_SOURCE, Document.path == pdf_path.name
)
)
assert doc2 is not None
assert doc2.content == content1 # byte-stable content
assert doc2.content_hash == digest1 # byte-stable digest
finally:
_cleanup_source(db, IKEA_SOURCE)
def test_ikea_tar_kill_switch_is_byte_identical_to_extraction(
db, tmp_path, ikea_pdf: Path
) -> None:
"""``BOR_PDF_PICTORIAL=0`` (the house kill switch — LOCKED A3):
the pass never runs on the owner's file — no vision call, no
counters, no log line — and the stored content is
``extract_pdf_text``'s output BYTE-IDENTICAL to the pre-phase-140
path."""
pdf_path = ikea_pdf
llm = _image_llm(
tmp_path,
llm_cls=_PictorialVisionLLM,
pdf_pictorial=False,
import_extensions="md,txt,pdf",
)
try:
s1 = asyncio.run(import_sources([pdf_path.parent], llm, session=db))
assert (s1.files, s1.added, s1.errors) == (1, 1, 0)
doc = db.scalar(
select(Document).where(
Document.source == IKEA_SOURCE, Document.path == pdf_path.name
)
)
assert doc is not None
assert doc.content == extract_pdf_text(pdf_path), (
"the kill switch must keep the pre-phase-140 content "
"byte-identical"
)
assert llm.pictorial_texts == [] # zero vision calls
assert (s1.pdfs_pictorial, s1.pdf_pictorial_pages) == (0, 0)
finally:
_cleanup_source(db, IKEA_SOURCE)
def test_garbage_pdf_with_pictorial_on_takes_the_fallback_not_the_pass(
db, tmp_path
) -> None:
"""The precedence pin (LOCKED): an UNUSABLE text layer takes the
phase-133 full-page screenshot fallback — the pictorial pass
NEVER runs and never double-visions (the gate is the only gate):
with ``pdf_pictorial=True`` (the code default), a garbage-text
PDF (the phase-133 synthetic recipe — XML-ish lines) lands with
the fallback's full-page canned descriptions, and the fake
records ZERO ``PICTORIAL_PAGE_MODE`` calls."""
source_root = tmp_path / "garbpike"
source_root.mkdir()
doc = fitz.open()
for p in (1, 2):
page = doc.new_page()
for i in range(10):
page.insert_text((72, 72 + i * 14), f"<foo:p{p}>{i}</foo:p{p}>")
doc.save(source_root / "garbage.pdf")
doc.close()
llm = _image_llm(
tmp_path,
llm_cls=_PictorialVisionLLM,
pdf_pictorial=True,
import_extensions="md,txt,pdf",
)
try:
summary = asyncio.run(import_sources([source_root], llm, session=db))
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
assert summary.pdfs_screenshotted == 1 # the gate fired → the fallback
assert summary.pdfs_pictorial == 0
assert summary.pdf_pictorial_pages == 0
assert llm.pictorial_texts == [], (
"the pictorial pass must never run on an unusable layer "
"(no double vision)"
)
assert len(llm.image_texts) == 2 # one fallback describe per page
row = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "garbage.pdf"
)
)
assert row is not None
assert row.content == (
f"---PAGE-1---\n{_PictorialVisionLLM.IMAGE_ANSWER}\n\n"
f"---PAGE-2---\n{_PictorialVisionLLM.IMAGE_ANSWER}"
)
finally:
_cleanup_source(db, source_root.name)
@@ -360,7 +360,16 @@ def test_hnb_guide_imports_clean_through_the_real_pipeline(
)
]
assert len(multimodal_calls) >= 1, "the multimodal chat was never called"
assert len(llm.vision_texts) == len(image_docs), (
# Phase 140 (task 03): the default-ON pictorial pass ALSO runs on
# this fixture's sparse pages (the fake is seam-agnostic — the
# same canned reply) — so the pin counts only the EMBEDDED-IMAGE
# describe calls (the ``DESCRIBE_PROMPT``-headed text parts; the
# ``PICTORIAL_PAGE_MODE`` parts are the pass's own calls, covered
# by the phase-140 suites).
image_vision_texts = [
t for t in llm.vision_texts if t.startswith(DESCRIBE_PROMPT)
]
assert len(image_vision_texts) == len(image_docs), (
"one vision call per embedded image doc (no failures)"
)
# The locked prompt + the generalized prefix + the document context
@@ -372,7 +381,7 @@ def test_hnb_guide_imports_clean_through_the_real_pipeline(
+ _expected_context_block(PDF_REL, _page_of(img.path), page_texts)
for img in image_docs
)
assert Counter(llm.vision_texts) == expected_calls
assert Counter(image_vision_texts) == expected_calls
assert summary.images_failed == 0
# The added accounting (see the module docstring): the walked file
+20 -4
View File
@@ -989,8 +989,10 @@ def test_import_summary_log_line_includes_summary_counters(
sits between ``summary_errors`` and ``dates_updated``; the
phase-106 date-refresh counter, the phase-122 ``images_failed``
counter, the phase-137 ``image_context_backfilled`` counter (next
to ``images_failed``), and the phase-133 ``pdfs_screenshotted``
counter sit after ``dates_updated``, before ``formats``."""
to ``images_failed``), the phase-133 ``pdfs_screenshotted``
counter, and the phase-140 ``pdfs_pictorial`` /
``pdf_pictorial_pages`` counters (next to ``pdfs_screenshotted``)
sit after ``dates_updated``, before ``formats``."""
s = ImportSummary()
s.files, s.added, s.chunks, s.embed_batches = 3, 3, 5, 4
s.summaries, s.summary_errors = 2, 1
@@ -999,6 +1001,8 @@ def test_import_summary_log_line_includes_summary_counters(
s.images_failed = 0
s.image_context_backfilled = 2
s.pdfs_screenshotted = 1
s.pdfs_pictorial = 2
s.pdf_pictorial_pages = 5
s.formats = {"md": 1, "yaml": 2}
with caplog.at_level(logging.INFO, logger="app.importer"):
s.log()
@@ -1007,7 +1011,8 @@ def test_import_summary_log_line_includes_summary_counters(
"import: summary files=3 added=3 updated=0 unchanged=0 pruned=0 errors=0 "
"chunks=5 embed_batches=4 summaries=2 summary_errors=1 summary_backfilled=1 "
"dates_updated=0 images_failed=0 image_context_backfilled=2 "
"pdfs_screenshotted=1 formats=yaml:2,md:1"
"pdfs_screenshotted=1 pdfs_pictorial=2 pdf_pictorial_pages=5 "
"formats=yaml:2,md:1"
)
@@ -1636,6 +1641,11 @@ def test_on_chunk_progress_pdf_branch_reports_content_chunks(
images=True,
image_dir=str(tmp_path / "images"),
import_extensions="md,txt,pdf",
# Phase 140: the pre-phase-140 shape pin — this fixture's pages
# are sparse (the default-ON pictorial pass would append
# illustration blocks and add vision calls); the pass is
# covered in tests/unit/test_pictorial_import.py.
pdf_pictorial=False,
)
try:
summary = asyncio.run(
@@ -1742,12 +1752,18 @@ def _pdf_settings(tmp_path: Path, images: bool = False) -> Settings:
silently skip the PDF) with the image knobs at their test values
(``images`` off unless pinned otherwise — the fallback's own vision
calls need no image copy, only the phase-122 embedded-image pass
does)."""
does). Phase 140: ``pdf_pictorial`` is pinned OFF — these suites
pin the PRE-phase-140 content shape (their fixture pages are
sparse — under the 100-char bar — so the default-ON pictorial
pass would append illustration blocks and move the byte-exact
pins; ``tests/unit/test_pictorial_import.py`` covers the pass
itself, with the kill switch in both states)."""
return Settings(
_env_file=None, # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
import_extensions="md,txt,pdf",
images=images,
image_dir=str(tmp_path / "images"),
pdf_pictorial=False,
)
+7 -1
View File
@@ -155,11 +155,17 @@ def _pdf_llm[
"""A fake LLM whose settings admit the ``pdf`` import token and
carry the phase-122 image knobs (the image dir under *tmp_path*);
*llm_cls* may be a mock vision client (the ``FakeEmbedder``
subclass carrying extra state such as ``chat_models``)."""
subclass carrying extra state such as ``chat_models``). Phase 140:
``pdf_pictorial`` is pinned OFF — this suite pins the PRE-phase-140
content shape (its fixture pages are sparse — under the 100-char
bar — so the default-ON pictorial pass would append illustration
blocks and move the byte-exact pins; the pass is covered in
tests/unit/test_pictorial_import.py)."""
kwargs.setdefault("_env_file", None)
kwargs.setdefault("images", images)
kwargs.setdefault("image_dir", str(tmp_path / "images"))
kwargs.setdefault("import_extensions", "md,txt,pdf")
kwargs.setdefault("pdf_pictorial", False)
llm = llm_cls() # pyright: ignore[reportCallIssue]
llm.settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return llm
+196
View File
@@ -0,0 +1,196 @@
"""Unit tests: the pictorial PDF gate (phase 140, task 01, LOCKED A1/A3).
Covers the two new settings (``pdf_pictorial`` — the house kill-switch,
default ON — and ``pdf_pictorial_min_chars`` — the sparseness bar with
its fail-loud validator) and the pure, deterministic sparse-page gate
:func:`app.rag.pdf.pictorial_pages`: the ``<``/``=``/``>`` boundary,
whitespace-only pages, the empty-dict no-op, ascending order, and a
large dict. The IKEA docstring example is pinned against the named
threshold.
"""
from __future__ import annotations
from typing import Any
import pytest
from pydantic import ValidationError
from app.config import Settings
from app.rag.pdf import pictorial_pages
def _settings(**kwargs: Any) -> Settings:
"""Build Settings without reading a .env file (deterministic tests)."""
kwargs.setdefault("_env_file", None)
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
# ---------------------------------------------------------------------------
# The settings — defaults + env overrides
# ---------------------------------------------------------------------------
def test_pdf_pictorial_defaults_on_and_env_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""LOCKED A3: the pass is ON by default (the owner-confirmed
2026-09-30 default); ``BOR_PDF_PICTORIAL`` is the house kill-switch
(``0``/``false`` disables the pass entirely — the ``BOR_IMAGES``
naming pattern)."""
monkeypatch.delenv("BOR_PDF_PICTORIAL", raising=False)
assert _settings().pdf_pictorial is True
monkeypatch.setenv("BOR_PDF_PICTORIAL", "0")
assert _settings().pdf_pictorial is False
monkeypatch.setenv("BOR_PDF_PICTORIAL", "false")
assert _settings().pdf_pictorial is False
monkeypatch.setenv("BOR_PDF_PICTORIAL", "1")
assert _settings().pdf_pictorial is True
def test_pdf_pictorial_min_chars_defaults_to_100_and_env_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""LOCKED A1: the default sparseness bar is 100 stripped chars
(the IKEA fixture's sparse pages run 4–73, its text-heavy pages
3,091+ — 100 sits between the classes); ``BOR_PDF_PICTORIAL_MIN_CHARS``
overrides it."""
monkeypatch.delenv("BOR_PDF_PICTORIAL_MIN_CHARS", raising=False)
assert _settings().pdf_pictorial_min_chars == 100
monkeypatch.setenv("BOR_PDF_PICTORIAL_MIN_CHARS", "50")
assert _settings().pdf_pictorial_min_chars == 50
monkeypatch.setenv("BOR_PDF_PICTORIAL_MIN_CHARS", "0")
assert _settings().pdf_pictorial_min_chars == 0
# ---------------------------------------------------------------------------
# The validators — the fail-loud pattern
# ---------------------------------------------------------------------------
def test_pdf_pictorial_min_chars_zero_is_legal() -> None:
"""PLAN A21: ``0`` is the every-page-is-a-candidate mode (the LLM
calls are free) — it must NOT raise."""
assert _settings(pdf_pictorial_min_chars=0).pdf_pictorial_min_chars == 0
def test_pdf_pictorial_min_chars_default_is_legal() -> None:
"""The default (100) is trivially legal — the constructor must
accept it (guards a validator regression that would block startup)."""
assert _settings().pdf_pictorial_min_chars == 100
def test_pdf_pictorial_min_chars_negative_fails_loud_naming_the_field() -> None:
"""A negative threshold is a typo — the validator fails startup
loudly (the ``agent_max_rounds`` pattern), naming the field."""
with pytest.raises(ValidationError, match="pdf_pictorial_min_chars"):
_settings(pdf_pictorial_min_chars=-1)
# ---------------------------------------------------------------------------
# pictorial_pages — the pure, deterministic gate
# ---------------------------------------------------------------------------
def test_pictorial_pages_strict_less_than_boundary() -> None:
"""The ASSUMPTION's boundary, pinned from all three sides at
``min_chars=5``: 4 stripped chars IS a candidate (``<``), EXACTLY 5
is NOT (``=`` — a page with exactly the bar's stripped chars is not
sparse), and 6 is NOT (``>``)."""
page_texts = {
1: "abcd", # 4 < 5 → candidate
2: "abcde", # 5 == 5 → NOT a candidate (the pinned boundary)
3: "abcdef", # 6 > 5 → NOT a candidate
}
assert pictorial_pages(page_texts, min_chars=5) == [1]
def test_pictorial_pages_min_chars_zero_marks_every_page() -> None:
"""PLAN A21's special case: ``min_chars=0`` is the "describe
everything" mode — EVERY page in the map is a candidate (the strict
``<`` test cannot express this: no length is below 0, so 0 is not a
stricter bar); an empty map still yields ``[]``."""
assert pictorial_pages({1: "a", 2: "x" * 7000}, min_chars=0) == [1, 2]
# A whitespace-only entry (defensive — the extractor's map never
# holds one) is still a candidate: the 0 branch is ALL KEYS.
assert pictorial_pages({1: " "}, min_chars=0) == [1]
assert pictorial_pages({}, min_chars=0) == []
def test_pictorial_pages_whitespace_only_page_is_a_candidate() -> None:
"""A whitespace-only page (strips to nothing — 0 < any positive bar)
is a candidate: the gate counts STRIPPED chars, so padding cannot
mask sparseness. (A truly text-LESS page has no key in the map at
all — the importer's pass owns that case.)"""
assert pictorial_pages({3: " \n\t \r\n "}, min_chars=100) == [3]
def test_pictorial_pages_empty_dict_yields_empty_list() -> None:
"""An empty map (no readable pages at all) yields ``[]`` — the
clean-text PDF's zero-candidates no-op (no render, no vision
call)."""
assert pictorial_pages({}, min_chars=100) == []
def test_pictorial_pages_result_is_ascending_even_when_input_isnt() -> None:
"""The result is ASCENDING (the render/describe order) regardless of
the dict's insertion order — the unsorted candidate set
{9, 2, 50, 5} comes back as [2, 5, 9, 50], and the one non-candidate
(the 210-char page 12) stays out."""
page_texts = {
9: "tail marker",
2: "head marker",
12: "not sparse " + "x" * 200, # 12+? — long enough, excluded
50: "Illustration 50",
5: "mid marker",
}
assert pictorial_pages(page_texts, min_chars=100) == [2, 5, 9, 50]
def test_pictorial_pages_large_dict() -> None:
"""A 500-page map with every 10th page sparse (an IKEA-class shape
at scale): exactly the sparse pages come back, ascending, and the
450 text-heavy pages are excluded — the pure function handles a
whole book in one call (no cap, LOCKED A3/A21)."""
page_texts: dict[int, str] = {}
expected: list[int] = []
for page in range(1, 501):
if page % 10 == 0:
page_texts[page] = "Illustration " + str(page) # 11-13 chars
expected.append(page)
else:
page_texts[page] = "w" * 3000 # text-heavy, never a candidate
assert len(page_texts) == 500 and len(expected) == 50
assert pictorial_pages(page_texts, min_chars=100) == expected
def test_pictorial_pages_ikea_example_from_the_docstring() -> None:
"""The docstring's IKEA example, pinned against the named threshold
(LOCKED A1, default 100): the title page ("BESTÅ", 5 chars) and the
"Illustration N" marker pages ARE candidates; the 7 KB of
multilingual warnings is NOT. A title page is a candidate —
describing it is useful (the logo + product name) and never
harmful."""
page_texts = {
1: "BESTÅ", # 5 chars → candidate
2: "w" * 7000, # 7 KB warnings → NOT
6: "Illustration 6", # 14 chars → candidate
}
assert pictorial_pages(page_texts, min_chars=100) == [1, 6]
def test_pictorial_pages_is_pure_and_does_not_mutate_input() -> None:
"""Pure + deterministic: repeated calls agree, and the input map
survives untouched (the gate reads, never rewrites)."""
page_texts = {3: "marker", 1: "x" * 500, 2: " "}
original = dict(page_texts)
first = pictorial_pages(page_texts, min_chars=100)
assert pictorial_pages(page_texts, min_chars=100) == first
assert page_texts == original
def test_pictorial_pages_uses_stripped_length_defensively() -> None:
"""Defensive strip: an UNSTRIPPED map entry (a caller that did not
pre-strip) is judged on its stripped length — padding on either
side cannot push a sparse page past the bar."""
assert pictorial_pages({1: " " + "y" * 98 + " "}, min_chars=100) == [1]
assert pictorial_pages({1: " " + "y" * 100 + " "}, min_chars=100) == []
+607
View File
@@ -0,0 +1,607 @@
"""Phase 140, task 03 — the importer's pictorial pass (unit).
The pass itself (``app.rag.importer._pictorial_pdf_pass`` — the
IKEA assembly-instruction class, TODO L5): a PDF whose text layer
PASSES the phase-133 garbage gate but is SPARSE (mostly pictorial
pages) gets every candidate page (``pictorial_pages`` — the
deterministic gate, LOCKED A1) rendered and vision-described, and the
description appends to the page's content block as
``[Page N illustration: …]`` (LOCKED A2/A4 — no LLM judge; the digest
runs over the FINAL content).
Pinned here (the synthetic IKEA-class recipe — PyMuPDF, no binary
fixtures; the real tar is pinned in
``tests/integration/test_importer.py``):
* the happy path — every candidate page (computed IN the test from
``extract_pdf_page_texts`` + ``pictorial_pages`` — no hardcoded page
list) carries its illustration block, the text-heavy pages do NOT,
the chunks carry the canned description, the digest is over the
FINAL content, the counters + the ONE loud log line fire (PLAN §9).
* the assembly BYTE-IDENTITY pin — with NO descriptions (every vision
call fails) the stored content is ``extract_pdf_text``'s output
byte-for-byte (the page's text is never lost, never reworded).
* the branch matrix: the kill switch off (``pdf_pictorial=False`` →
byte-identical to pre-phase, zero vision calls), zero candidates
(a clean-text PDF — no render, no vision call, no log line), the
fail-soft ``None`` page (one dead page keeps its text only, the
others are described), the unreadable-page placeholder skip (the
zero-byte ``render_pdf_pages`` slot burns no vision call, the page
keeps its text only).
* the digest-stability rerun — a stable file + a stable vision reply
re-syncs as ``unchanged`` (no re-embed, byte-stable content +
digest); the vision calls are re-issued before the unchanged check
can skip them — the documented no-cache cost (the phase-133
convention, A21 endorses it).
The E2E surface (the real UI + the mock LLM's ``PICTORIAL_PAGE_MODE``
branch) is pinned in ``tests/e2e/test_pictorial_pdf.py`` (task 04).
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import logging
import textwrap
from pathlib import Path
from typing import Any
import pymupdf as fitz
import pytest
from sqlalchemy import select
from app.config import Settings
from app.models import Chunk, Document
from app.rag import importer
from app.rag.importer import import_sources
from app.rag.llm import LLMError
from app.rag.pdf import (
PAGE_SEPARATOR,
extract_pdf_page_texts,
extract_pdf_text,
pictorial_pages,
)
from app.rag.summarizer import PICTORIAL_PAGE_MODE, PICTORIAL_PROMPT
from tests.fakes import FakeEmbedder
#: The IKEA fixture's metadata title (the phase-140 fixture, TODO L5 —
#: Adobe InDesign's "Assembly Instructions").
TITLE = "Assembly Instructions"
#: The text-heavy pages (IKEA pages 2–5 — the multilingual warnings,
#: 3,091–7,288 chars in the real fixture): a fixed warning block well
#: over the 100-char sparseness bar, so it is NEVER a candidate.
DENSE_TEXT = (
"WARNING: Adult assembly required. Read all instructions before "
"starting. Keep this package away from children. The furniture "
"must be secured to the wall using the supplied anti-tip device."
)
assert len(DENSE_TEXT.strip()) >= 100 # the class property, not a typo
def _insert_dense(page) -> None:
"""One dense-warning page's text, inserted in 60-char wrapped
lines: a single 188-char line would be CLIPPED at the page edge
on extraction (verified — only ~104 chars survive), while the
multi-line read round-trips byte-exactly (the fixture's class
property — over the 100-char bar — must hold on the EXTRACTED
text, which the gate sees)."""
for i, line in enumerate(textwrap.wrap(DENSE_TEXT, 60)):
page.insert_text((72, 72 + i * 14), line)
def _build_ikea_like_pdf(path: Path) -> None:
"""A synthetic IKEA-class PDF (TODO L5 — the vector-drawn
assembly-instruction class): page 1 is the title page (``BESTA``
— 5 chars, as in the real fixture's ``BESTÅ``), pages 2–5 the
text-heavy warnings (≥100 chars — never candidates), pages 6–10
sparse ``Illustration N`` markers (15–16 chars — candidates)
standing in for the vector pictograms (no embedded raster images
— the phase-122 image pipeline sees nothing, as on the real
fixture). The metadata title is the fixture's."""
doc = fitz.open()
doc.new_page().insert_text((72, 72), "BESTA")
for _ in range(4):
_insert_dense(doc.new_page())
for n in range(6, 11):
doc.new_page().insert_text((72, 72), f"Illustration {n}")
doc.set_metadata({"title": TITLE})
doc.save(path)
doc.close()
def _build_dense_pdf(path: Path) -> None:
"""A clean-text PDF: every page over the 100-char bar — ZERO
pictorial candidates (the pass's no-op branch)."""
doc = fitz.open()
for _ in range(2):
_insert_dense(doc.new_page())
doc.set_metadata({"title": "Clean Reference"})
doc.save(path)
doc.close()
class _PictorialLLM(FakeEmbedder):
"""The capturing fake LLM (task 03): the MULTIMODAL describe call
(``user`` content = a part list) gets a canned reply DETERMINED BY
THE RENDERED PAGE'S BYTES (a short sha256 of the data-URL's
base64 — the same rendered page always gets the same reply,
ACROSS RUNS: the digest-stability premise holds), and the TEXT
part is recorded (``pictorial_texts`` — the ``PICTORIAL_PROMPT``
head + the ``Page N of the PDF`` block are the per-page context
witness). String (``lite``) calls keep the ``FakeEmbedder``
behaviour. The REAL ``import_sources`` → ``_pictorial_pdf_pass``
→ ``describe_pdf_page`` chain runs against it (no seam patch)."""
def __init__(self) -> None:
super().__init__()
self.pictorial_texts: list[str] = []
self.pictorial_calls = 0
@staticmethod
def reply_for(png: bytes) -> str:
"""The canned reply a vision call for *png* gets —
deterministic in the bytes (the tests build the expected
content with the same helper)."""
return (
f"Page {hashlib.sha256(png).hexdigest()[:8]} illustrated: "
"two parts are joined with a blue arrow."
)
async def chat(
self, messages: list[dict[str, Any]], model: str | None = None
) -> str:
self.chat_calls.append(list(messages))
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
if isinstance(user, list):
text = next((p["text"] for p in user if p.get("type") == "text"), "")
assert PICTORIAL_PAGE_MODE in text, (
"a multimodal call reached the fake that is not the "
f"pictorial pass's seam: {text[:80]!r}"
)
self.pictorial_calls += 1
self.pictorial_texts.append(text)
b64 = next(p for p in user if p.get("type") == "image_url")[
"image_url"
]["url"].rsplit(",", 1)[1]
return self.reply_for(base64.b64decode(b64))
first = user.split()
return "Summary of " + (first[0] if first else "<empty>")
class _FailPagePictorialLLM(_PictorialLLM):
"""Rejects the vision call for page *fail_page* (parsed from the
text part's ``Page N of the PDF.`` block) and describes the rest —
drives the per-page fail-soft (the dead page keeps its text only,
the other candidates still get their descriptions)."""
def __init__(self, fail_page: int) -> None:
super().__init__()
self.fail_page = fail_page
async def chat(
self, messages: list[dict[str, Any]], model: str | None = None
) -> str:
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
if isinstance(user, list):
text = next((p["text"] for p in user if p.get("type") == "text"), "")
if f"Page {self.fail_page} of the PDF." in text:
self.pictorial_calls += 1
self.pictorial_texts.append(text)
raise LLMError(
f"simulated dead vision call for page {self.fail_page} "
"(test sentinel)"
)
return await super().chat(messages, model=model)
class _DeadVisionLLM(FakeEmbedder):
"""A vision client that REJECTS every multimodal call (the dead
vision endpoint — the fail-soft's total-failure case: EVERY
candidate page keeps its text only, and the stored content is the
extraction byte-identical)."""
def __init__(self) -> None:
super().__init__()
self.pictorial_calls = 0
async def chat(
self, messages: list[dict[str, Any]], model: str | None = None
) -> str:
self.chat_calls.append(list(messages))
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
if isinstance(user, list):
self.pictorial_calls += 1
raise LLMError("simulated dead vision endpoint (test sentinel)")
first = user.split()
return "Summary of " + (first[0] if first else "<empty>")
def _settings(tmp_path: Path, **kwargs: Any) -> Settings:
"""Settings admitting the ``pdf`` import token with the phase-140
knobs at their CODE DEFAULTS (``pdf_pictorial`` ON — LOCKED A3 —
and ``pdf_pictorial_min_chars`` 100 — LOCKED A1) unless pinned
otherwise; ``images`` off (the embedded-image pass is not in play
— the fixture class is vector-drawn)."""
kwargs.setdefault("_env_file", None)
kwargs.setdefault("import_extensions", "md,txt,pdf")
kwargs.setdefault("images", False)
kwargs.setdefault("image_dir", str(tmp_path / "images"))
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
def _pdf_doc(db, source: str, rel: str) -> Document:
doc = db.scalar(
select(Document).where(Document.source == source, Document.path == rel)
)
assert doc is not None, f"no documents row for ({source!r}, {rel!r})"
return doc
def _cleanup_source(db, source: str) -> None:
for doc in db.scalars(select(Document).where(Document.source == source)).all():
db.delete(doc)
db.commit()
def _candidates_and_pngs(pdf_path: Path) -> tuple[list[int], list[bytes]]:
"""The pass's own inputs, computed IN the test (no hardcoded page
list — the fixture is stable but the test must not rot): the
deterministic gate over the per-page text map, and the candidate
render (aligned with the candidates — ascending, one slot each)."""
page_texts = extract_pdf_page_texts(pdf_path)
candidates = pictorial_pages(page_texts, min_chars=100)
pngs = importer.render_pdf_pages(pdf_path, pages=candidates)
return candidates, pngs
def _expected_content(
pdf_path: Path, fail_pages: frozenset[int] = frozenset()
) -> tuple[str, list[int]]:
"""The FINAL content the pass produces for *pdf_path* under the
stable fake — derived from the same inputs the pass uses: every
page of the extraction map as its ``PAGE_SEPARATOR`` + the page
text, the described candidates (the candidates MINUS
*fail_pages*) carrying ``[Page N illustration: <stable reply>]``
AFTER their text (LOCKED A4 — the text stays)."""
page_texts = extract_pdf_page_texts(pdf_path)
candidates, pngs = _candidates_and_pngs(pdf_path)
parts: list[str] = []
for page, text in page_texts.items():
block = text
if page in candidates and page not in fail_pages:
png = pngs[candidates.index(page)]
block += f"\n\n[Page {page} illustration: {_PictorialLLM.reply_for(png)}]"
parts.append(f"{PAGE_SEPARATOR.format(page=page)}\n{block}")
return "\n\n".join(parts), candidates
def test_sparse_pages_get_their_illustration_blocks(
db, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The happy path end to end (the owner's L5 rationale): every
candidate page (computed from the gate — the fixture's title page
+ its six illustration pages) carries its illustration block, the
text-heavy pages (2–5) do NOT, the chunks carry the canned
description (the embeddings hit the illustrations), the title is
the metadata title, the digest is over the FINAL content, and the
counters + the ONE loud log line fire (PLAN §9)."""
root = tmp_path / "pik"
root.mkdir()
pdf = root / "ikea.pdf"
_build_ikea_like_pdf(pdf)
llm = _PictorialLLM()
llm.settings = _settings(tmp_path)
try:
with caplog.at_level(logging.INFO, logger="app.importer"):
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
content, candidates = _expected_content(pdf)
assert len(candidates) == 6 # the title page + the six markers
assert set(candidates) == {1, 6, 7, 8, 9, 10}
doc = _pdf_doc(db, "pik", "ikea.pdf")
assert doc.title == TITLE # the metadata title (the fixture's)
assert doc.is_image is False
# The assembly is byte-exact: the page text STAYS, the
# description appends after it (LOCKED A4).
assert doc.content == content
# The text-heavy pages (2–5 — the multilingual warnings) carry
# NO illustration block (the gate is the ONLY selector).
for n in (2, 3, 4, 5):
assert f"[Page {n} illustration" not in doc.content
# The digest runs over the FINAL content (the phase-126/133
# convention — A4).
assert doc.content_hash == hashlib.sha256(
doc.content.encode("utf-8")
).hexdigest()
# The counters (log-only — the sync detail shape is untouched):
# one PDF with six described sparse pages.
assert summary.pdfs_pictorial == 1
assert summary.pdf_pictorial_pages == 6
assert summary.pdfs_screenshotted == 0 # the gate passed (usable)
# The chunks carry the canned description — the joined chunk
# text hits every candidate's reply (each page is its own
# ``Page N:`` chunk, the page-aware chunker).
chunks = db.scalars(
select(Chunk).where(Chunk.document_id == doc.id)
.order_by(Chunk.position)
).all()
content_chunks = [c for c in chunks if not c.is_summary]
joined = "\n".join(c.content for c in content_chunks)
_, pngs = _candidates_and_pngs(pdf)
for page in candidates:
reply = _PictorialLLM.reply_for(pngs[candidates.index(page)])
assert reply in joined, f"page {page}'s description is not indexed"
for c in chunks:
assert c.embedding is not None and len(c.embedding) == 768
summary_chunks = [c for c in chunks if c.is_summary]
assert len(summary_chunks) == 1 and summary_chunks[0].position == -1
assert doc.summary is not None
# ONE vision call per candidate, in candidate order — the text
# part heads with the locked prompt and carries the page's OWN
# text as context (the page block, task 02's contract).
assert llm.pictorial_calls == len(candidates)
assert len(llm.pictorial_texts) == len(candidates)
page_texts = extract_pdf_page_texts(pdf)
for page, text_part in zip(candidates, llm.pictorial_texts, strict=True):
assert text_part.startswith(PICTORIAL_PROMPT)
assert f"Page {page} of the PDF." in text_part
assert page_texts[page] in text_part # the page's own text
# The ONE loud log line (PLAN §9) — exact, with the computed N.
lines = [
r.getMessage()
for r in caplog.records
if "pictorial pass" in r.getMessage()
]
assert lines == [
"pdf: pictorial pass source=pik path=ikea.pdf — "
"6 candidate pages, 6 described (min_chars=100)"
]
finally:
_cleanup_source(db, "pik")
def test_assembly_byte_identity_without_descriptions(
db, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""THE byte-identity pin: with NO descriptions (the dead vision
endpoint — every candidate's call fails) the stored content is
``extract_pdf_text``'s output BYTE-FOR-BYTE (the page's text is
never lost, never reworded, the separators are untouched), the
pass still counted its candidates in the log line (0 described),
and no PDF is counted pictorial (a description is what counts)."""
root = tmp_path / "dead"
root.mkdir()
pdf = root / "ikea.pdf"
_build_ikea_like_pdf(pdf)
llm = _DeadVisionLLM()
llm.settings = _settings(tmp_path)
try:
with caplog.at_level(logging.INFO, logger="app.importer"):
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
assert llm.pictorial_calls == 6 # every candidate was attempted
doc = _pdf_doc(db, "dead", "ikea.pdf")
assert doc.content == extract_pdf_text(pdf), (
"no descriptions → the stored content is the extraction "
"byte-identical (the assembly pin)"
)
assert summary.pdfs_pictorial == 0 # nothing was described
assert summary.pdf_pictorial_pages == 0
lines = [
r.getMessage()
for r in caplog.records
if "pictorial pass" in r.getMessage()
]
assert lines == [
"pdf: pictorial pass source=dead path=ikea.pdf — "
"6 candidate pages, 0 described (min_chars=100)"
]
# The dead pages' warnings (the fail-soft's signal, per page).
assert [
r
for r in caplog.records
if r.levelno == logging.WARNING
and "has no description" in r.getMessage()
]
finally:
_cleanup_source(db, "dead")
def test_feature_off_is_byte_identical_to_pre_phase(db, tmp_path: Path) -> None:
"""The kill switch (LOCKED A3, ``BOR_PDF_PICTORIAL=0``): the pass
never runs — no render, no vision call, no log line, no counters —
and the stored content is ``extract_pdf_text``'s output
byte-identical to the pre-phase-140 path."""
root = tmp_path / "off"
root.mkdir()
pdf = root / "ikea.pdf"
_build_ikea_like_pdf(pdf)
llm = _PictorialLLM()
llm.settings = _settings(tmp_path, pdf_pictorial=False)
try:
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
doc = _pdf_doc(db, "off", "ikea.pdf")
assert doc.content == extract_pdf_text(pdf)
assert llm.pictorial_calls == 0
assert (summary.pdfs_pictorial, summary.pdf_pictorial_pages) == (0, 0)
finally:
_cleanup_source(db, "off")
def test_zero_candidates_is_a_noop(
db, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A clean-text PDF (every page over the 100-char bar) has ZERO
candidates — the pass is a no-op: no render, NO vision call
(the pin — a clean-text PDF never burns a vision call), no log
line, no counters, and the content is the extraction untouched."""
root = tmp_path / "dense"
root.mkdir()
pdf = root / "clean.pdf"
_build_dense_pdf(pdf)
llm = _PictorialLLM()
llm.settings = _settings(tmp_path)
try:
with caplog.at_level(logging.INFO, logger="app.importer"):
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
doc = _pdf_doc(db, "dense", "clean.pdf")
assert doc.content == extract_pdf_text(pdf)
assert llm.pictorial_calls == 0
assert (summary.pdfs_pictorial, summary.pdf_pictorial_pages) == (0, 0)
assert not [
r for r in caplog.records if "pictorial pass" in r.getMessage()
]
finally:
_cleanup_source(db, "dense")
def test_fail_soft_page_keeps_its_text(
db, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""One dead candidate page (the vision call rejects it) keeps its
text only — a warning is the signal, the OTHER candidates still
get their descriptions, one dead page never kills the pass
(LOCKED A2 fail-soft). The described count is five of six; the
PDF still counts pictorial (≥1 described page)."""
root = tmp_path / "partfail"
root.mkdir()
pdf = root / "ikea.pdf"
_build_ikea_like_pdf(pdf)
llm = _FailPagePictorialLLM(fail_page=7)
llm.settings = _settings(tmp_path)
try:
with caplog.at_level(logging.INFO, logger="app.importer"):
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
assert llm.pictorial_calls == 6 # every candidate was attempted
content, candidates = _expected_content(pdf, fail_pages=frozenset({7}))
doc = _pdf_doc(db, "partfail", "ikea.pdf")
assert doc.content == content
assert "[Page 7 illustration" not in doc.content # the dead page
assert "---PAGE-7---\nIllustration 7" in doc.content # its text stays
assert summary.pdfs_pictorial == 1
assert summary.pdf_pictorial_pages == 5
lines = [
r.getMessage()
for r in caplog.records
if "pictorial pass" in r.getMessage()
]
assert lines == [
"pdf: pictorial pass source=partfail path=ikea.pdf — "
"6 candidate pages, 5 described (min_chars=100)"
]
# The importer's per-page warning (the dead page's signal).
warnings = [
r
for r in caplog.records
if r.levelno == logging.WARNING
and "pictorial page 7" in r.getMessage()
]
assert len(warnings) == 1
finally:
_cleanup_source(db, "partfail")
def test_unreadable_page_placeholder_skips_the_vision_call(
db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A candidate page that could not be RENDERED (the zero-byte
``render_pdf_pages`` placeholder — the phase-133 handling) is
skipped with a warning: it burns NO vision call, keeps its text
only, and the OTHER candidates keep their positions +
descriptions (the result's order never shifts)."""
root = tmp_path / "halfblind"
root.mkdir()
pdf = root / "ikea.pdf"
_build_ikea_like_pdf(pdf)
real_render = importer.render_pdf_pages
def _render(path: Path, dpi: int = 150, pages: Any = None) -> list[bytes]:
assert pages is not None # only the pass calls it (with a selector)
pngs = real_render(path, dpi=dpi, pages=pages)
return [
b"" if n == 6 else png
for n, png in zip(pages, pngs, strict=True)
]
monkeypatch.setattr(importer, "render_pdf_pages", _render)
llm = _PictorialLLM()
llm.settings = _settings(tmp_path)
try:
with caplog.at_level(logging.WARNING, logger="app.importer"):
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
assert llm.pictorial_calls == 5 # the blank page burned no call
content, candidates = _expected_content(pdf, fail_pages=frozenset({6}))
doc = _pdf_doc(db, "halfblind", "ikea.pdf")
assert doc.content == content
assert "[Page 6 illustration" not in doc.content
assert "---PAGE-6---\nIllustration 6" in doc.content
assert summary.pdfs_pictorial == 1
assert summary.pdf_pictorial_pages == 5
warnings = [
r
for r in caplog.records
if r.levelno == logging.WARNING
and "skipping pictorial page 6" in r.getMessage()
]
assert len(warnings) == 1
finally:
_cleanup_source(db, "halfblind")
def test_rerun_lands_unchanged_with_byte_stable_content(
db, tmp_path: Path
) -> None:
"""Digest stability (A4 — the phase-126/133 convention): the
digest runs over the FINAL content, so a re-sync of an unchanged
IKEA-class PDF (same pages + the stable fake's same replies)
re-imports as UNCHANGED: no re-chunk, no re-embed, no re-summary,
no KB change (not ``updated``, no changed path), and the content +
digest are byte-stable. The vision calls ARE re-issued before the
unchanged check can skip them — the documented no-cache cost (the
phase-133 convention; a non-deterministic vision model re-describes
the row each sync — a cost, not a bug; A21 endorses it)."""
root = tmp_path / "stable"
root.mkdir()
pdf = root / "ikea.pdf"
_build_ikea_like_pdf(pdf)
llm = _PictorialLLM()
llm.settings = _settings(tmp_path)
try:
s1 = asyncio.run(import_sources([root], llm, session=db))
assert (s1.added, s1.pdfs_pictorial, s1.pdf_pictorial_pages) == (1, 1, 6)
assert llm.pictorial_calls == 6
embeds1 = len(llm.calls)
doc1 = _pdf_doc(db, "stable", "ikea.pdf")
content1, digest1 = doc1.content, doc1.content_hash
s2 = asyncio.run(import_sources([root], llm, session=db))
# The pass re-ran (gate re-fired, vision re-issued — the
# documented cost) — but the row itself re-imported unchanged.
assert (s2.added, s2.updated, s2.unchanged) == (0, 0, 1)
assert s2.pdfs_pictorial == 1 # the pass re-fired
assert llm.pictorial_calls == 12 # the vision calls were re-issued
assert len(llm.calls) == embeds1, "unchanged: no re-embed"
assert s2.changed_paths == [], "unchanged: no KB change recorded"
doc2 = _pdf_doc(db, "stable", "ikea.pdf")
assert doc2.content == content1 # byte-stable content
assert doc2.content_hash == digest1 # byte-stable digest
finally:
_cleanup_source(db, "stable")
+450
View File
@@ -0,0 +1,450 @@
"""Phase 140, task 02 — the ``PICTORIAL_PAGE_MODE`` vision seam + the
``render_pdf_pages`` page selector (unit).
Pins the task-02 contracts:
* ``PICTORIAL_PAGE_MODE`` / ``PICTORIAL_INSTRUCTION`` /
``PICTORIAL_PROMPT`` — the marker convention next to
``IMAGE_DESCRIPTION_MODE`` (marker first, the deterministic E2E mock
LLM keys on it, PLAN §6) with the LOCKED wording (phase 140 design:
describe in detail what the page shows — the assembly steps, part
names, arrows, tool icons, before/after states — ESPECIALLY the
information the extracted text does not contain).
* ``DESCRIBE_PROMPT`` byte-identity (regression pin — the phase 122/126/
137 suites pass unedited; the pictorial pass has its OWN marker, it
never re-wraps the image prompt).
* :func:`describe_pdf_page` — the same one-chat-call multimodal
contract as :func:`describe_image`: a capturing fake LLM pins the
message shape (the text part starts with ``PICTORIAL_PROMPT``, then
the page block — the page's own text as context, the
``"(none)`` case; the ``image_url`` part is the data URL from the
PNG bytes + mime), the CHAT (vision) model (never ``lite``), the
phase-30 cap, and ``None`` on an empty reply or a client error
(fail-soft — one dead page never stalls a sync).
* :func:`render_pdf_pages`' optional 1-based *pages* selector — the
subset's count + ascending order + PNG magic bytes (a 3-page
fixture, the phase-126/133 recipe), ``None`` = every page (the
phase-133 caller stays byte-identical), an unreadable/out-of-range
named page → the zero-byte placeholder at its position, an
unopenable file → ``[]``.
"""
from __future__ import annotations
import asyncio
import base64
import logging
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pymupdf as fitz
import pytest
from app.config import Settings
from app.rag import pdf as pdf_mod
from app.rag.llm import LLMError
from app.rag.pdf import render_pdf_pages
from app.rag.summarizer import (
DESCRIBE_PROMPT,
IMAGE_DESCRIPTION_MODE,
PICTORIAL_INSTRUCTION,
PICTORIAL_PAGE_MODE,
PICTORIAL_PROMPT,
describe_pdf_page,
)
PNG_MAGIC = b"\x89PNG"
#: A tiny non-empty PNG-ish payload for the describe-call's ``data``
#: (the seam is content-agnostic — it never parses the image; a
#: well-formed magic keeps the data-URL assertion honest).
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
)
def _settings(**kwargs: Any) -> Settings:
"""Build Settings without reading a .env file (deterministic tests)."""
kwargs.setdefault("_env_file", None)
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
class _VisionMock:
"""Duck-typed chat-model mock for :func:`describe_pdf_page` (task
02): records the message + model and returns a canned reply (or
raises it). Satisfies the summarizer's ``SummaryLLM`` protocol.
"""
def __init__(
self, reply: str | Exception = "Two parts joined with an arrow."
) -> None:
self.settings = _settings()
self.reply = reply
self.messages: list[Any] = []
self.model: str | None = None
async def chat(
self, messages: list[dict[str, Any]], model: str | None = None
) -> str:
self.messages = list(messages)
self.model = model
if isinstance(self.reply, Exception):
raise self.reply
return self.reply
def _message(mock: _VisionMock) -> list[dict[str, Any]]:
"""The one user message's multimodal ``content`` list (asserted to
be a list of two parts by the shape test)."""
assert len(mock.messages) == 1
message = mock.messages[0]
assert message["role"] == "user"
content = message["content"]
assert isinstance(content, list)
return content
# ---------------------------------------------------------------------------
# The marker + the locked instruction (the mock's key, PLAN §6)
# ---------------------------------------------------------------------------
def test_pictorial_prompt_carries_the_mode_marker_and_locked_wording() -> None:
"""The ``PICTORIAL_PAGE_MODE`` marker heads the pictorial prompt
(the ``SUMMARY_MODE``/``IMAGE_DESCRIPTION_MODE`` convention — the
deterministic E2E mock LLM keys on it), and the LOCKED wording's
load-bearing fragments are in the instruction: a re-wrap of the
prompt cannot silently re-route the mock, and the "describe what
the text does NOT contain" mandate stays in the constant."""
assert PICTORIAL_PAGE_MODE == "PICTORIAL_PAGE_MODE"
assert PICTORIAL_PROMPT.startswith(PICTORIAL_PAGE_MODE + ": ")
assert (
PICTORIAL_PROMPT == PICTORIAL_PAGE_MODE + ": " + PICTORIAL_INSTRUCTION
)
for fragment in (
"assembly steps",
"part names",
"arrows",
"tool icons",
"before/after states",
"ESPECIALLY",
"extracted text",
"plain text",
):
assert fragment in PICTORIAL_PROMPT
# The markers are disjoint fixed app constants (neither contains
# the other) — the mock's two vision branches cannot shadow each
# other regardless of branch order.
assert IMAGE_DESCRIPTION_MODE not in PICTORIAL_PROMPT
assert PICTORIAL_PAGE_MODE not in DESCRIBE_PROMPT
def test_describe_prompt_byte_identity_regression_pin() -> None:
"""REGRESSION PIN: the phase-122 ``DESCRIBE_PROMPT`` is untouched
by the pictorial seam (the phase 122/126/137 suites pass unedited —
the image marker's mock branch serves the exact pre-phase-140
prompt). The full value is pinned, not just its shape: any
re-wrap of the locked instruction fails here, not in the story
suites."""
assert DESCRIBE_PROMPT == (
"IMAGE_DESCRIPTION_MODE: Describe this image faithfully, in "
"plain text, for a search index. State what is depicted, "
"transcribe any visible text, labels, or titles, describe the "
"structure of any diagram, table, or layout, and call out the "
"most salient details. Write 2-4 sentences of substance. Do not "
"use markdown. Do not invent anything that is not visible in "
"the image. Your description is the ONLY text that will ever be "
"retrieved for this image — it must carry the image's full "
"meaning."
)
# ---------------------------------------------------------------------------
# describe_pdf_page — message shape, model, cap, fail-soft
# ---------------------------------------------------------------------------
def test_describe_pdf_page_message_shape_multimodal_and_chat_model() -> None:
"""ONE chat-model call (LOCKED A2 — the vision ``llm_chat_model``,
NOT the ``lite`` summary model) with the OpenAI-compatible
multimodal user message: the text part is exactly
``PICTORIAL_PROMPT + "\\n\\nPage {page} of the PDF. Text extracted
from this page:\\n" + page_text.strip()`` (the page's OWN text as
context, so the model describes what the text does NOT carry) and
the ``image_url`` part is the data URL ``data:<mime>;base64,<png
bytes>`` — no system prompt, no tools."""
mock = _VisionMock("Two parts joined with an arrow.")
result = asyncio.run(
describe_pdf_page(
mock,
data=PNG_1X1,
mime="image/png",
page=7,
page_text="Illustration 7",
)
)
assert result == "Two parts joined with an arrow."
content = _message(mock)
assert len(content) == 2
assert content[0] == {
"type": "text",
"text": (
PICTORIAL_PROMPT
+ "\n\nPage 7 of the PDF. Text extracted from this page:\n"
+ "Illustration 7"
),
}
b64 = base64.b64encode(PNG_1X1).decode("ascii")
assert content[1] == {
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
}
# LOCKED A2: the CHAT (vision) model — never the ``lite`` summary
# model (it is not assumed vision-capable).
assert mock.model == mock.settings.llm_chat_model
assert mock.model != mock.settings.llm_summary_model
@pytest.mark.parametrize(
("page_text", "suffix"),
[
(None, "(none)"),
("", "(none)"),
(" \t\n ", "(none)"),
(" Illustration 7 ", "Illustration 7"),
],
)
def test_describe_pdf_page_page_block_none_and_strip_cases(
page_text: str | None, suffix: str,
) -> None:
"""The page block's tail is ``page_text.strip() or "(none)"`` — a
page with no extracted text (``None``/empty/blank) sends the
explicit ``(none)`` sentinel (the vision model knows there is
nothing to restate), and a text-carrying page sends its STRIPPED
text (the leading/trailing whitespace of the extraction never
reaches the model)."""
mock = _VisionMock("ok")
asyncio.run(
describe_pdf_page(
mock, data=PNG_1X1, mime="image/png", page=3, page_text=page_text
)
)
content = _message(mock)
assert content[0]["text"] == (
PICTORIAL_PROMPT
+ f"\n\nPage 3 of the PDF. Text extracted from this page:\n{suffix}"
)
def test_describe_pdf_page_strips_and_caps_at_summary_max_chars() -> None:
"""The reply is whitespace-stripped and cut exactly at
``summary_max_chars`` (the phase-30 cap — the same uniform ceiling
as :func:`describe_image`). The explicit ``settings`` parameter
wins; ``settings=None`` falls back to the LLM's own settings."""
long_reply = "x" * 500
mock = _VisionMock(long_reply)
capped = asyncio.run(
describe_pdf_page(
mock,
data=PNG_1X1,
mime="image/png",
page=2,
page_text=None,
settings=_settings(summary_max_chars=100),
)
)
assert capped == "x" * 100
mock2 = _VisionMock(" " + "y" * 50 + "\n")
mock2.settings = _settings(summary_max_chars=42)
result = asyncio.run(
describe_pdf_page(mock2, data=PNG_1X1, mime="image/png", page=2, page_text=None)
)
assert result == ("y" * 42)
def test_describe_pdf_page_returns_none_on_failure_classes(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Fail-soft (LOCKED A2 — one dead page never stalls a sync):
``None`` on a client error (with a warning log naming the page —
the house one-shot style) and on an empty or whitespace-only reply
(the house one-shot empty-content policy)."""
failing = _VisionMock(LLMError("simulated vision-model failure (test sentinel)"))
with caplog.at_level(logging.WARNING, logger="app.summarizer"):
assert (
asyncio.run(
describe_pdf_page(
failing, data=PNG_1X1, mime="image/png", page=9, page_text=None
)
)
is None
)
warnings = [
r
for r in caplog.records
if r.levelno == logging.WARNING and "pictorial page description" in r.getMessage()
]
assert len(warnings) == 1 and "page=9" in warnings[0].getMessage()
for empty in ("", " \n"):
blank = _VisionMock(empty)
assert (
asyncio.run(
describe_pdf_page(
blank, data=PNG_1X1, mime="image/png", page=9, page_text=None
)
)
is None
)
# ---------------------------------------------------------------------------
# render_pdf_pages — the optional 1-based page selector
# ---------------------------------------------------------------------------
def _three_page_text_pdf(path: Path) -> Path:
"""A 3-page temp PDF with EXPLICIT letter pages (612x792pt —
deterministic render sizes) and page-specific text (the render must
be page-specific, so the pages' PNG bytes must differ — the
phase-126/133 recipe, extended to three pages for the selector's
count + order assertions)."""
doc = fitz.open()
for marker in ("Alpha page text.", "Beta page text.", "Gamma page text."):
doc.new_page(width=612, height=792).insert_text((72, 72), marker)
doc.save(path)
doc.close()
return path
def test_render_selector_none_renders_every_page(tmp_path: Path) -> None:
"""``pages=None`` (the default) = every page in page order — the
phase-133 caller's result, byte-identical: a 3-page PDF → 3 PNGs,
each a genuine decodable PNG, page-specific (the bytes differ)."""
path = _three_page_text_pdf(tmp_path / "three.pdf")
pages = render_pdf_pages(path)
assert len(pages) == 3
for png in pages:
assert png.startswith(PNG_MAGIC)
pix = fitz.Pixmap(png)
assert (pix.width, pix.height) == (1275, 1650) # 150-DPI letter
assert pages[0] != pages[1] != pages[2] and pages[0] != pages[2]
def test_render_selector_subset_count_ascending_order_and_pngs(
tmp_path: Path,
) -> None:
"""A named subset renders ONLY the named pages (3-page PDF,
``pages=[3, 1]`` → 2 PNGs, not 3), in ASCENDING order regardless of
the input order (list index *i* is the *i*-th smallest NAMED page —
index 0 is page 1, index 1 is page 3), each a genuine PNG whose
bytes equal the corresponding page's render in the full
(``pages=None``) pass — the selector changes the set, never the
per-page output."""
path = _three_page_text_pdf(tmp_path / "three.pdf")
full = render_pdf_pages(path)
selected = render_pdf_pages(path, pages=[3, 1])
assert len(selected) == 2
for png in selected:
assert png.startswith(PNG_MAGIC)
pix = fitz.Pixmap(png)
assert (pix.width, pix.height) == (1275, 1650) # 150-DPI letter
assert selected[0] == full[0] # the smallest named page (1) first
assert selected[1] == full[2] # page 3 second, page 2 rendered by no one
single = render_pdf_pages(path, pages=[2])
assert single == [full[1]]
def test_render_selector_out_of_range_named_page_is_zero_bytes(
tmp_path: Path, caplog: pytest.LogCaptureFixture,
) -> None:
"""A named page outside the document's page range yields ``b""`` at
ITS position (the caller's placeholder — the result's order never
shifts, so it stays aligned with the requested pages) + a warning;
the in-range page keeps its PNG."""
path = _three_page_text_pdf(tmp_path / "three.pdf")
full = render_pdf_pages(path)
with caplog.at_level(logging.WARNING, logger="app.rag.pdf"):
selected = render_pdf_pages(path, pages=[2, 99])
assert selected == [full[1], b""]
warnings = [
r
for r in caplog.records
if r.levelno == logging.WARNING and "could not render page 99" in r.getMessage()
]
assert len(warnings) == 1
def test_render_selector_empty_list_renders_nothing(tmp_path: Path) -> None:
"""``pages=[]`` (no candidates) renders nothing — the zero-
candidates no-op the importer relies on (a clean-text PDF's path
never burns a render or a vision call)."""
path = _three_page_text_pdf(tmp_path / "three.pdf")
assert render_pdf_pages(path, pages=[]) == []
def test_render_selector_unopenable_file_returns_empty(
tmp_path: Path,
) -> None:
"""A file that cannot be OPENED yields ``[]`` even with a selector
(the caller's existing empty-content path — unchanged by the
phase-140 parameter)."""
bad = tmp_path / "bad.pdf"
bad.write_bytes(b"this is not a pdf at all")
assert render_pdf_pages(bad, pages=[1, 2]) == []
assert render_pdf_pages(tmp_path / "missing.pdf", pages=[1]) == []
def test_render_selector_broken_named_page_is_zero_bytes(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
) -> None:
"""A named page whose ``get_pixmap`` raises → ``b""`` at ITS
position (the caller's placeholder slot — the page index NEVER
shifts, so the result stays aligned with the requested pages); the
good named pages keep their PNGs, and the failure is logged (the
:func:`extract_pdf_text` fail-soft style)."""
path = tmp_path / "fake.pdf"
path.write_bytes(b"%PDF-1.4 fake")
class _RenderPage:
def __init__(self, data: bytes | None) -> None:
self._data = data
def get_pixmap(self, dpi: int) -> Any:
if self._data is None:
raise RuntimeError("boom: page render failed")
return SimpleNamespace(tobytes=lambda _fmt: self._data)
class _FakeDoc:
def __init__(self, pages: list[Any]) -> None:
self._pages = pages
def __len__(self) -> int:
return len(self._pages)
def __getitem__(self, index: int) -> Any:
return self._pages[index]
def __enter__(self) -> _FakeDoc: # noqa: F821 (nested class, future annotations)
return self
def __exit__(self, *args: object) -> bool:
return False
doc = _FakeDoc(
[
_RenderPage(b"\x89PNG-fake-page-1"),
_RenderPage(None),
_RenderPage(b"\x89PNG-fake-page-3"),
]
)
monkeypatch.setattr(pdf_mod.fitz, "open", lambda _p: doc)
# pages=[3, 1, 2] renders ascending 1, 2, 3 — the broken page 2
# holds ITS position (index 1), not the tail.
assert render_pdf_pages(path, pages=[3, 1, 2]) == [
b"\x89PNG-fake-page-1",
b"",
b"\x89PNG-fake-page-3",
]
# A repeated page number renders ONCE (the selector is a set of
# pages, ascending).
assert render_pdf_pages(path, pages=[1, 1]) == [b"\x89PNG-fake-page-1"]