Files
brain-of-reese/app/rag/summarizer.py
T
ducoterra 9633eb7653
Build and Push Containers / build-and-push-app (push) Successful in 2m26s
Build and Push Containers / build-and-push-db (push) Successful in 12s
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.
2026-09-30 19:16:35 -04:00

425 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Document summarizer (phase 30, task 03) + image descriptions
(phase 122, task 03).
Builds the ``SUMMARY_MODE`` prompt for one document, calls the aipi
``lite`` model through the one-shot ``LLMClient.chat`` (phase 30,
task 01), and returns the validated summary text with a
**code-deterministic** pointer line back to the source::
Source: <source>/<path>
The pointer is appended by this module, never model-generated — the
model is told what to summarize, not to cite.
Quality contracts enforced here:
* **Capped input** — the document content is cut at
``BOR_SUMMARY_MAX_CHARS`` (default 12 000) before the single model
call; overflow is cut exactly at the cap and the shared
``TRUNCATION_MARKER`` (``[…truncated…]``) is appended, so the model
never sees more than the cap and the cut is visible.
* **No silent summaries** — a reply that is empty after trimming raises
:class:`LLMError` (the client already rejects empty content; the
summarizer re-asserts defensively and never hands the importer a
pointer-only row).
Image descriptions (phase 122, LOCKED A3): :func:`describe_image` is
this module's second one-shot generation path — a SINGLE CHAT-model
(vision) call describing one image's bytes as a base64 data URL. The
description becomes the image document's ``content`` AND ``summary``
(it is the ONLY embedded text of the doc — the embedding model never
sees pixels, and the ``lite`` summary model is deliberately NOT used:
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.
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
import base64
import logging
from typing import Any, Protocol
from app.config import Settings, get_settings
from app.rag.llm import LLMError
from app.rag.retriever import TRUNCATION_MARKER
logger = logging.getLogger("app.summarizer")
#: System-prompt marker for summary generation — the E2E mock LLM keys on
#: it (same convention as ``DEFLECT_MODE``, PLAN §6).
SUMMARY_MODE = "SUMMARY_MODE"
#: Locked instruction for the ``lite`` model (phase 30): the summary is a
#: natural-language retrieval target, so it must be plain, concrete, and
#: strictly grounded in the document.
SUMMARY_INSTRUCTION = (
"Write a 3-6 sentence plain-text summary of this document in natural "
"language. Cover what it configures/defines and its most important "
"values. Do not use markdown. Do not invent anything that is not in "
"the document."
)
#: Full system prompt: marker first (the mock's key), then the instruction.
SYSTEM_PROMPT = f"{SUMMARY_MODE}: {SUMMARY_INSTRUCTION}"
#: Image-description marker (phase 122, task 03) — the deterministic
#: E2E mock LLM keys on it (same convention as ``SUMMARY_MODE`` /
#: ``DEFLECT_MODE``, PLAN §6; the story suite wires the mock's branch
#: in task 06). It heads the text part of the multimodal describe
#: message, so it rides the user message, not a system prompt.
IMAGE_DESCRIPTION_MODE = "IMAGE_DESCRIPTION_MODE"
#: Locked instruction for the CHAT (vision) model (phase 122, LOCKED
#: A3): the description is the ONLY retrievable text of the image
#: document (the embedding model never sees pixels), so it must be a
#: faithful, retrieval-oriented account that carries the image's full
#: meaning — what is depicted, any visible text/labels/titles,
#: diagram/table structure, salient details.
DESCRIBE_INSTRUCTION = (
"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."
)
#: 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
#: it for the image bytes' ``Content-Type`` (one map, one truth). A
#: ``BOR_IMAGE_EXTENSIONS`` token outside this map (a custom format)
#: takes the :data:`IMAGE_FALLBACK_MIME` data-URL mime in the describe
#: call — the vision endpoint may reject it, and the fail-soft skip
#: (``images_failed``) is the honest outcome.
IMAGE_MIMES: dict[str, str] = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
}
#: The data-URL mime for an image extension :data:`IMAGE_MIMES` does not
#: name (phase 122) — the best-effort generic, never a guess at a
#: specific type.
IMAGE_FALLBACK_MIME = "application/octet-stream"
def image_data_url(data: bytes, mime: str) -> str:
"""One image's bytes as a data URL for a multimodal message (phase
122; factored for phase 123, task 01).
``data:<mime>;base64,<ascii>`` — the OpenAI-compatible ``image_url``
payload's ``url``. ONE helper, both call sites: :func:`describe_image`
(document-image descriptions) and the chat question-image path
(``app.api.chat``'s multimodal user message, phase 123).
"""
return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}"
class SummaryLLM(Protocol):
"""The one-shot chat surface the summarizer needs.
:class:`app.rag.llm.LLMClient` satisfies it; unit tests pass a
duck-typed fake (``chat`` + ``settings``) instead — same pattern as
the importer's ``Embedder`` protocol.
``content`` may be a string (text calls — ``generate_summary``) or
a list of OpenAI-compatible parts (phase 122 multimodal image
descriptions — ``{type: "text", …}`` + ``{type: "image_url", …}``);
the client passes message dicts through untouched.
"""
settings: Settings
async def chat(
self, messages: list[dict[str, Any]], model: str | None = None
) -> str: ...
def _capped_content(content: str, max_chars: int | None) -> str:
"""Document content for the user message, capped at *max_chars*.
The default cap is ``BOR_SUMMARY_MAX_CHARS``. Overflow is cut exactly
at the cap and the shared ``TRUNCATION_MARKER`` is appended on its
own line; content that fits (length ≤ cap) passes through unchanged.
"""
limit = max_chars if max_chars is not None else get_settings().summary_max_chars
if len(content) <= limit:
return content
return content[:limit] + "\n" + TRUNCATION_MARKER
def build_summary_prompt(
source: str, path: str, content: str, max_chars: int | None = None
) -> tuple[str, str]:
"""The ``(system, user)`` message pair for one summary call.
* ``system`` — :data:`SYSTEM_PROMPT`: the ``SUMMARY_MODE`` marker +
the locked instruction.
* ``user`` — the document content, capped (see :func:`_capped_content`).
*source* and *path* are part of the signature so the call site reads
like the document it summarizes (and for :func:`generate_summary`'s
pointer) — the pointer is built in code and deliberately **not** part
of the prompt, so the model cannot echo or mangle it.
"""
return SYSTEM_PROMPT, _capped_content(content, max_chars)
async def generate_summary(
llm: SummaryLLM, *, source: str, path: str, content: str
) -> str:
"""One-shot ``lite`` summary of *content*, ending in the pointer line.
Returns the model's text (trimmed) plus the deterministic
``Source: <source>/<path>`` line — the pointer is appended by code,
never model-generated. Raises :class:`LLMError` when the model
returns nothing usable after trimming, and propagates any
:class:`LLMError` the client raises (the importer's fail-soft path
turns that into a logged, counted ``summary_errors`` entry).
"""
system, user = build_summary_prompt(source, path, content)
raw = await llm.chat(
[{"role": "system", "content": system}, {"role": "user", "content": user}],
model=llm.settings.llm_summary_model,
)
summary = raw.strip()
if not summary:
raise LLMError(
f"summary model returned empty content for {source}/{path} — "
"refusing to store a silent summary"
)
return f"{summary}\nSource: {source}/{path}"
async def describe_image(
llm: SummaryLLM,
*,
data: bytes,
mime: str,
settings: Settings | None = None,
context: str | None = None,
) -> str | None:
"""One-shot CHAT-model (vision) description of one image (phase 122,
LOCKED A3) — the text that becomes the image document's ``content``
AND ``summary`` (the ONLY embedded text of the doc; the embedding
model never sees pixels).
ONE chat-model call against ``settings.llm_chat_model`` (the vision
model — the ``lite`` summary model is NOT assumed vision-capable)
with the multimodal user message the OpenAI-compatible API expects:
``[{type: "text", text: DESCRIBE_PROMPT + context prefix},
{type: "image_url", image_url: {url: <data URL from *data* +
*mime*>}}]`` — no system prompt, no tools, no app-level retries
beyond the client's own (SDK-level + the house one-shot empty-
content policy) — a description failure must not stall a sync.
*context* (phase 125, task 03, LOCKED A3; generalized in phase 137,
task 02): the optional DOCUMENT/REFERENCE context built by the
importer — for a PDF-embedded image, the
``Extracted from <source>/<pdf> (page N):`` provenance block (the
page's text, capped — WHERE the image came from) combined with any
phase-125 markdown reference blocks (the ``![alt](path)`` alt text
+ surrounding paragraphs — WHAT it illustrates; document block
first); for a standalone image, the markdown reference context
alone (the importer's same-source scan). When provided, it is
appended to the text part AFTER the locked :data:`DESCRIBE_PROMPT`
(the marker + instruction stay byte-identical — the
``IMAGE_DESCRIPTION_MODE`` mock key is untouched): the vision model
learns where the image comes from AND what it is used for, so it
can write a context-aware description. ``context=None`` (the
default — a reference-free standalone image) sends the bare prompt:
the message is byte-identical to the pre-phase shape.
Returns the stripped reply cut exactly at
``(settings or llm.settings).summary_max_chars`` (the phase-30 cap —
the description IS the summary, so it keeps the same uniform
ceiling). Returns ``None`` on any client error, empty reply, or
non-2xx (the client raises :class:`LLMError` for all three classes)
— the caller (the importer's ``_describe_or_skip`` seam) fails soft:
the doc is skipped, counted in ``images_failed``, and the sync
continues (LOCKED A3).
"""
data_url = image_data_url(data, mime)
# Phase 137 (task 02, ASSUMPTION A5): the prefix wording is neutral
# — it covers BOTH block kinds (the provenance's ``Extracted from
# …`` document context and the phase-125 ``Referenced in …``
# markdown references). ``DESCRIBE_PROMPT`` (the marker + the locked
# instruction) stays byte-identical; ``context=None`` sends the bare
# prompt (byte-identical to the pre-phase-137 message).
context_prefix = (
"\n\nThis image is part of a document. The following context "
f"describes where it comes from and what it is used for:\n{context}"
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": text},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
]
model = llm.settings.llm_chat_model
try:
raw = await llm.chat(messages, model=model)
except LLMError as e:
logger.warning("%s failed (model=%s): %s", what, model, e)
return None
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_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})",
)