phase: 140_pictorial_pdf_pages
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:
+143
-9
@@ -33,10 +33,24 @@ it is not assumed vision-capable). Fail-soft by contract: any client
|
||||
error, empty reply, or non-2xx yields ``None`` — the importer skips
|
||||
the doc, counts ``images_failed``, and the sync continues.
|
||||
|
||||
The ``SUMMARY_MODE`` / ``IMAGE_DESCRIPTION_MODE`` markers follow the
|
||||
``DEFLECT_MODE`` convention: the deterministic E2E mock LLM keys on
|
||||
them (``tests/e2e/mock_llm.py`` — the image branch is wired by task
|
||||
06's story suite).
|
||||
Pictorial PDF pages (phase 140, LOCKED A2): :func:`describe_pdf_page`
|
||||
is this module's third one-shot generation path — a SINGLE CHAT-model
|
||||
(vision) call describing ONE rendered sparse PDF page, with the
|
||||
page's OWN extracted text as context so the model describes what the
|
||||
text does NOT carry (the IKEA assembly-instruction class: vector
|
||||
pictograms around a 4–73-char text layer). The description is
|
||||
APPENDED to the page's indexed content block by the importer (the
|
||||
``[Page N illustration: …]`` line, LOCKED A4) — the page's text stays;
|
||||
more information is better for the embeddings (the owner's L5
|
||||
rationale). Fail-soft by contract: any client error or empty reply
|
||||
yields ``None`` — the importer skips that page with a warning and
|
||||
continues (one dead page never stalls a sync).
|
||||
|
||||
The ``SUMMARY_MODE`` / ``IMAGE_DESCRIPTION_MODE`` /
|
||||
``PICTORIAL_PAGE_MODE`` markers follow the ``DEFLECT_MODE``
|
||||
convention: the deterministic E2E mock LLM keys on them
|
||||
(``tests/e2e/mock_llm.py`` — the image branch is wired by task
|
||||
06's story suite, the pictorial branch by phase 140 task 02).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -92,8 +106,41 @@ DESCRIBE_INSTRUCTION = (
|
||||
|
||||
#: The full describe prompt: marker first (the mock's key), then the
|
||||
#: instruction — the single text part of the multimodal user message.
|
||||
#: BYTE-IDENTICAL CONTRACT: the phase 122/126/137 suites pin this
|
||||
#: string untouched — pictorial-page descriptions go through their
|
||||
#: own marker (:data:`PICTORIAL_PAGE_MODE`), never a re-wrap of this
|
||||
#: one.
|
||||
DESCRIBE_PROMPT = f"{IMAGE_DESCRIPTION_MODE}: {DESCRIBE_INSTRUCTION}"
|
||||
|
||||
#: Pictorial-page marker (phase 140, task 02, LOCKED A1/A2) — the
|
||||
#: deterministic E2E mock LLM keys on it (same convention as
|
||||
#: ``IMAGE_DESCRIPTION_MODE``, PLAN §6). It heads the text part of
|
||||
#: the multimodal describe message, so it rides the user message, not
|
||||
#: a system prompt.
|
||||
PICTORIAL_PAGE_MODE = "PICTORIAL_PAGE_MODE"
|
||||
|
||||
#: Locked instruction for the CHAT (vision) model (phase 140, LOCKED
|
||||
#: A2): the description APPENDS to the page's indexed content block
|
||||
#: (``[Page N illustration: …]``) — the embeddings then carry what
|
||||
#: the thin text layer couldn't (the owner's L5 rationale) — so it
|
||||
#: must be a faithful, retrieval-oriented account of what the page
|
||||
#: SHOWS: the assembly steps, part names, arrows, tool icons, and
|
||||
#: before/after states — ESPECIALLY the information the extracted
|
||||
#: text does not contain.
|
||||
PICTORIAL_INSTRUCTION = (
|
||||
"Describe in detail what this page of the PDF shows, in plain text, "
|
||||
"for a search index. State the assembly steps, part names, arrows, "
|
||||
"tool icons, and before/after states it illustrates — ESPECIALLY the "
|
||||
"information the extracted text does not contain. Write 2-4 sentences "
|
||||
"of substance. Do not use markdown. Do not invent anything that is "
|
||||
"not visible on the page."
|
||||
)
|
||||
|
||||
#: The full pictorial-page prompt: marker first (the mock's key), then
|
||||
#: the instruction — the head of the multimodal user message's text
|
||||
#: part (the page block is appended by :func:`describe_pdf_page`).
|
||||
PICTORIAL_PROMPT = f"{PICTORIAL_PAGE_MODE}: {PICTORIAL_INSTRUCTION}"
|
||||
|
||||
#: Extension → MIME type for the image family (phase 122). Dotted,
|
||||
#: lowercase keys — the ``image_extension_set`` shape. Task 03 uses it
|
||||
#: for the describe call's data-URL mime; task 04's serve route reuses
|
||||
@@ -266,11 +313,43 @@ async def describe_image(
|
||||
if context
|
||||
else ""
|
||||
)
|
||||
return await _one_shot_vision_reply(
|
||||
llm,
|
||||
data_url=data_url,
|
||||
text=DESCRIBE_PROMPT + context_prefix,
|
||||
settings=settings,
|
||||
what="image description",
|
||||
)
|
||||
|
||||
|
||||
async def _one_shot_vision_reply(
|
||||
llm: SummaryLLM,
|
||||
*,
|
||||
data_url: str,
|
||||
text: str,
|
||||
settings: Settings | None,
|
||||
what: str,
|
||||
) -> str | None:
|
||||
"""The shared one-chat-call multimodal contract (phase 122, reused
|
||||
by phase 140): ONE user message
|
||||
``[{type: "text", text: <text>},
|
||||
{type: "image_url", image_url: {url: <data_url>}}]`` against
|
||||
``llm.settings.llm_chat_model`` — no system prompt, no tools, no
|
||||
app-level retry.
|
||||
|
||||
Returns the stripped reply cut exactly at
|
||||
``(settings or llm.settings).summary_max_chars`` (the phase-30 cap);
|
||||
``None`` on any client error (the client raises :class:`LLMError`
|
||||
for a failure, an empty reply, or a non-2xx) or an empty/blank
|
||||
reply after trimming (the house one-shot empty-content policy — the
|
||||
duck-typed fakes may return one even though the real client
|
||||
rejects it). *what* names the caller for the warning log line.
|
||||
"""
|
||||
messages: list[dict[str, Any]] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": DESCRIBE_PROMPT + context_prefix},
|
||||
{"type": "text", "text": text},
|
||||
{"type": "image_url", "image_url": {"url": data_url}},
|
||||
],
|
||||
}
|
||||
@@ -279,12 +358,67 @@ async def describe_image(
|
||||
try:
|
||||
raw = await llm.chat(messages, model=model)
|
||||
except LLMError as e:
|
||||
logger.warning("image description failed (model=%s): %s", model, e)
|
||||
logger.warning("%s failed (model=%s): %s", what, model, e)
|
||||
return None
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
text_out = raw.strip()
|
||||
if not text_out:
|
||||
# The client already rejects empty content; this is the
|
||||
# defensive re-assert (the duck-typed fakes may return it).
|
||||
return None
|
||||
limit = (settings or llm.settings).summary_max_chars
|
||||
return text[:limit]
|
||||
return text_out[:limit]
|
||||
|
||||
|
||||
async def describe_pdf_page(
|
||||
llm: SummaryLLM,
|
||||
*,
|
||||
data: bytes,
|
||||
mime: str,
|
||||
page: int,
|
||||
page_text: str | None,
|
||||
settings: Settings | None = None,
|
||||
) -> str | None:
|
||||
"""One-shot CHAT-model (vision) description of ONE rendered PDF page
|
||||
(phase 140, task 02, LOCKED A2) — the text the importer appends to
|
||||
that page's indexed content block as
|
||||
``[Page N illustration: …]`` (the page's own text stays; the
|
||||
embeddings then carry what the thin text layer couldn't — the
|
||||
owner's L5 rationale).
|
||||
|
||||
ONE chat-model call against ``llm.settings.llm_chat_model`` (the
|
||||
vision model — the ``lite`` summary model is NOT assumed
|
||||
vision-capable) with the same multimodal contract as
|
||||
:func:`describe_image` (via :func:`_one_shot_vision_reply`): no
|
||||
system prompt, no tools, no app-level retry. The message's text
|
||||
part is :data:`PICTORIAL_PROMPT` (the ``PICTORIAL_PAGE_MODE``
|
||||
marker + the locked instruction) followed by the page block — the
|
||||
page's OWN extracted text as context, so the model describes what
|
||||
the text does NOT contain::
|
||||
|
||||
PICTORIAL_PROMPT
|
||||
+ "\\n\\nPage {page} of the PDF. Text extracted from this page:\n"
|
||||
+ (page_text.strip() or "(none)")
|
||||
|
||||
``data`` is the rendered page's PNG bytes (phase 133's
|
||||
``render_pdf_pages`` output), ``mime`` its MIME type (``image/png``
|
||||
for the rendered page).
|
||||
|
||||
Returns the stripped reply cut exactly at
|
||||
``(settings or llm.settings).summary_max_chars`` (the phase-30 cap).
|
||||
Returns ``None`` on any client error, empty reply, or non-2xx
|
||||
(fail-soft — the importer skips THAT page with a warning and
|
||||
continues: one dead page never stalls a sync, LOCKED A2).
|
||||
"""
|
||||
data_url = image_data_url(data, mime)
|
||||
extracted = page_text.strip() if page_text is not None else ""
|
||||
return await _one_shot_vision_reply(
|
||||
llm,
|
||||
data_url=data_url,
|
||||
text=(
|
||||
PICTORIAL_PROMPT
|
||||
+ f"\n\nPage {page} of the PDF. Text extracted from this page:\n"
|
||||
+ (extracted or "(none)")
|
||||
),
|
||||
settings=settings,
|
||||
what=f"pictorial page description (page={page})",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user