All completion criteria verified. The repository is left functional with all changes in the working tree (no commits made, per the harness protocol). ## Final report — phase 125 `image_anchor_context` (verification pass) All 6 tasks were already complete; this pass verified every criterion and ran the full gates: - **Migration 0023** `documents.image_context` (TEXT nullable, reversible) — applied (`alembic current` = 0023 head); round-trip test (4 passed) exercises real upgrade → downgrade 0022 → upgrade. - **Criterion 1** (referenced image enriched + context line in retrieval): integration `test_markdown_referenced_image_gets_context_and_enriched_chunks` + E2E `test_sync_enriches_the_image_doc_with_markdown_context`, `test_chat_answer_quotes_the_context_line` (wire prompt pinned: `<document>` block leads with the one-line `Referenced in …: "alt"` line) — verified present and passing. - **Criterion 2** (standalone image byte-identical): E2E `test_standalone_*` (NULL `image_context`, block body exactly the bare description, no `Referenced in` anywhere in the prompt) + unit `test_describe_image_without_context_is_byte_identical` — passing. - **Criterion 3** (markdown-first: record stored on markdown doc, image picks up context later): integration `test_markdown_indexed_before_image_stores_record_and_image_picks_it_up` + post-index enrichment pass test (markdown file gone from disk) — passing. - **Gates:** `uv run pytest` → 2938 passed, exit 0 · `uv run pytest --cov=app --cov-report=term-missing` → exit 0, TOTAL **99%** (>90%) · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors, 0 warnings. - **Phase E2E in isolation:** `uv run pytest tests/e2e/test_image_anchor_context.py -v --no-cov` → 4 passed. - **Mock-LLM change regression check:** neighboring image E2E suites green in isolation — `test_smoke` (3), `test_image_documents` (16), `test_chat_image_questions` (17), `test_answer_images` (17). - No defects found; no code changes needed. No live-infrastructure changes (local dev DB only). No commit made (harness commits). - Next pending phase: check `.agents/phases/todo/` after the harness moves this phase.
278 lines
12 KiB
Python
278 lines
12 KiB
Python
"""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.
|
|
|
|
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).
|
|
"""
|
|
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.
|
|
DESCRIBE_PROMPT = f"{IMAGE_DESCRIPTION_MODE}: {DESCRIBE_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): the optional markdown
|
|
reference context — the alt text + surrounding paragraphs of the
|
|
```` references that point at THIS image (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 is told WHAT THE IMAGE IS FOR so it
|
|
can write a context-aware description. ``context=None`` (the
|
|
default — standalone images) sends the bare prompt: the message is
|
|
byte-identical to the pre-phase-125 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)
|
|
context_prefix = (
|
|
f"\n\nThis image is referenced in documentation with the following context:\n{context}"
|
|
if context
|
|
else ""
|
|
)
|
|
messages: list[dict[str, Any]] = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": DESCRIBE_PROMPT + context_prefix},
|
|
{"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("image description failed (model=%s): %s", model, e)
|
|
return None
|
|
text = raw.strip()
|
|
if not text:
|
|
# 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]
|