phase: 137_pdf_image_context
Build and Push Containers / build-and-push-app (push) Successful in 2m51s
Build and Push Containers / build-and-push-db (push) Successful in 13s

All verification complete — everything is 100% green. Final report:

**Phase 137 final verification pass — all tasks already complete, all criteria verified**

- **Full suite:** `uv run pytest --cov=app --cov-report=term-missing` → 3272 passed, exit 0; `app/` coverage **99%** (>90% ✓)
- **Phase E2E in isolation:** `uv run pytest tests/e2e/test_pdf_image_context.py -v --no-cov` → **4 passed** (forward context, display line, standalone parity, both-blocks markdown case)
- **Pre-existing suites in isolation:** `test_pdf_support.py` + `test_image_anchor_context.py` + `test_image_documents.py` → **14 passed**; ripple-edited `test_garbage_pdf.py` → **3 passed**
- **Lint/types:** `uv run ruff check .` → all passed; `uv run pyright` → 0 errors

**Completion criteria:** (1) forward-path context in vision msg + stored `image_context`/summary — ✓ (unit builder/seam + integration fake-LLM + E2E); (2) both blocks document-first + standalone describe byte-identical — ✓ (combiner + standalone pins); (3) backfill with context + zero-vision idempotent re-sync + parent-gone origin-only — ✓ (6 integration tests); (4) suggestion block leads with `From <source>/<pdf> (page N)` — ✓ (renderer units + E2E); (5) all gates green — ✓ above; (6) no behavior change in phases 122/125/126 + sync `detail` shape untouched — ✓ (verified in code: new counter is log-only); (7) ruff+pyright — ✓; (8) commit — left to the harness per pipeline rules (working tree untouched by me).

**Documentation:** README.md audited in full — the new phase-137 bullet verified against code (6 000-char cap, log-only `image_context_backfilled=N`, `<document>`-body locator placement), all env tables/defaults cross-checked vs `app/config.py` and `.env.example` — consistent, no changes needed. AGENTS.md: every rule/command verified — consistent, no changes needed.

**Notable deviations (pre-existing, documented in task-05 report, forced by design):** `test_pdf_support.py`/`test_garbage_pdf.py` got minimal ripple edits (the "UNEDITED" pin was unsatisfiable once describe calls carry context by design); the unit `test_image_anchor_context.py` CONTEXT_PREFIX pin generalized per ASSUMPTION A5; one extra mock line (`_seed_context_line` accepts `From …`).

**Next pending phase:** none — `todo/` holds only this phase.
This commit is contained in:
2026-09-29 02:14:46 -04:00
parent 0035bdf533
commit 5e87622b30
39 changed files with 3565 additions and 167 deletions
+443 -25
View File
@@ -163,6 +163,56 @@ with ``image_context`` set is never touched; a markdown doc's record
is NOT cleared on success — it is the retrieval display's record
also, task 05).
PDF-embedded image document context (phase 137, task 02): a PDF's
embedded images are ALWAYS described with their document context —
the ``Extracted from <source>/<pdf path> (page <N>):`` provenance
block (see :func:`_pdf_image_context_block`: the origin line is
ALWAYS present — even for a text-less page; the page's own text
follows, stripped, cut at :data:`PDF_IMAGE_CONTEXT_MAX_CHARS` with
the shared :data:`~app.rag.retriever.TRUNCATION_MARKER` on its own
line; the page text is dropped entirely for a text-less page OR when
the phase-133 garbage gate (:func:`app.rag.pdf.is_usable_pdf_text`)
marks the PDF's text layer unusable — origin line only, A7) —
combined with any phase-125 markdown reference blocks (document block
FIRST — provenance before purpose — the single
:func:`_combine_image_contexts` combiner, shared with the task-03
backfill so the two paths can never drift). The per-page text map
(:func:`app.rag.pdf.extract_pdf_page_texts`) is extracted ONCE per
PDF for the whole image pass. The combined context is the vision
call's ``context`` (the generalized phase-125 prefix — the model
names the document and page in its description), the stored
``image_context`` (re-scanned on re-index — the context follows the
current source state; the unchanged-image digest skip is untouched),
and the ``content``/``summary`` enrichment
(``<context>\n\n<description>`` — the same text the embedder indexes).
Standalone images (no PDF) are untouched: no document block, the
phase-125 markdown shape unchanged, and the reference-free image's
bare prompt stays byte-identical.
Document-context backfill (phase 137, task 03): the per-sync,
idempotent, best-effort pass (:func:`_backfill_image_contexts`) runs
for each source immediately after the phase-125 enrichment pass, and
re-describes every PDF-embedded image doc that is still missing the
document block (``image_context`` NULL — a pre-phase-137 row with no
context at all — or markdown-only: the phase-125 ``Referenced in …``
value with the document block still missing). The bytes come from the
``image_dir`` copy, the page text is re-extracted from the parent PDF
while it is still in the source (the parent gone for good → the
origin line only — the origin is still valuable and needs no disk),
the fresh description goes through the SAME seam
(:func:`_describe_or_skip`), and the row's ``content``/``summary`` /
``image_context`` + chunks are replaced (one transaction per image).
A describe ``None`` or an embed failure leaves the row untouched and
retries next sync. A success writes the ``Extracted from …`` header
into ``image_context`` — the idempotency gate — so the doc never
qualifies again (the following sync burns zero vision calls for it).
Deliberately unchanged on the row: ``content_hash`` (the image
identity), ``created_at``/``created_at_manual`` (the SOURCE did not
change — the phase-106 date rules do not apply), ``title``, and
``image_path``. The new ``image_context_backfilled`` counter is
log-only (the sync ``detail`` shape is untouched — the stats log-only
convention, phase 133).
``import_sources`` accepts an optional per-file ``progress`` callback
(phase 64, task 01) reporting the file being processed right now, and
an optional ``on_chunk_progress`` callback (phase 129, task 02)
@@ -197,10 +247,12 @@ from app.rag.pdf import (
PAGE_SEPARATOR,
PdfImage,
extract_pdf_images,
extract_pdf_page_texts,
extract_pdf_text,
is_usable_pdf_text,
render_pdf_pages,
)
from app.rag.retriever import TRUNCATION_MARKER
from app.rag.summarizer import (
IMAGE_FALLBACK_MIME,
IMAGE_MIMES,
@@ -289,6 +341,11 @@ class ImportSummary:
#: ``---PAGE-N---``-anchored descriptions, or ``""`` when the
#: fallback produced nothing — the phase-126 empty-content path).
pdfs_screenshotted: int = 0
#: PDF-embedded image docs whose stored ``image_context`` was
#: backfilled with the document-context block (phase 137, task 03)
#: — the one-time best-effort pass; a success never repeats, a
#: failure retries next sync.
image_context_backfilled: int = 0
#: Documents this run ADDED, UPDATED, or PRUNED, as (source, path)
#: tuples in encounter order (added/updated in walk order, pruned in
#: _prune order). Drives the sync's incremental folder-summary
@@ -310,7 +367,7 @@ class ImportSummary:
"import: summary files=%d added=%d updated=%d unchanged=%d pruned=%d "
"errors=%d chunks=%d embed_batches=%d summaries=%d summary_errors=%d "
"summary_backfilled=%d dates_updated=%d images_failed=%d "
"pdfs_screenshotted=%d formats=%s",
"image_context_backfilled=%d pdfs_screenshotted=%d formats=%s",
self.files,
self.added,
self.updated,
@@ -324,6 +381,7 @@ class ImportSummary:
self.summary_backfilled,
self.dates_updated,
self.images_failed,
self.image_context_backfilled,
self.pdfs_screenshotted,
self.format_counts(),
)
@@ -570,6 +628,23 @@ async def import_sources(
``limit`` too (it deletes nothing), and a per-image failure
(missing copy, failed re-description, embed error) keeps that image
as-is and moves on.
Phase 137 (task 03): the document-context backfill
(:func:`_backfill_image_contexts`) runs for that source immediately
AFTER the enrichment pass (enrichment first: it owns the
markdown-FIRST case and may have just set a markdown-only context,
which the backfill then prepends the document block to). It
re-describes the PDF-embedded image docs a pre-phase-137 sync left
with ``image_context`` NULL or markdown-only — the bytes from the
persistent copy, the page text re-extracted from the parent PDF
while it is still in the source (gone → the origin line only), the
SAME describe seam, and the row's ``content``/``summary`` /
``image_context``/chunks replaced. Idempotent (a success writes the
``Extracted from …`` header — the doc never qualifies again, the
following sync burns zero vision calls for it) and fail-soft (a
failure leaves the row untouched — the next sync retries it).
Driven by committed doc rows, so it is safe under ``limit`` too
(it deletes nothing).
"""
if limit is not None and limit <= 0:
raise ValueError("limit must be >= 1")
@@ -666,6 +741,15 @@ async def import_sources(
# markdown file is no longer on disk). Idempotent +
# fail-soft (see the function).
await _enrich_pending_images(session, source=source, llm=llm, summary=summary)
# Phase 137 (task 03): the one-time document-context
# backfill — the PDF-embedded image docs a pre-phase-137
# sync left WITHOUT the document block (``image_context``
# NULL or markdown-only) get it now: re-described with the
# context, the row's content/summary/context/chunks
# replaced. Idempotent + fail-soft (see the function).
await _backfill_image_contexts(
session, source=source, root=root, llm=llm, summary=summary
)
if prune:
if limit is not None:
logger.warning("import: --prune ignored because --limit was given")
@@ -1383,7 +1467,18 @@ async def _index_pdf_embedded_images(
the PDF's text index and the other images are already committed.
* a new/updated image doc counts ``added``/``updated`` (it really
changed the KB — the sources-version bump fires on it, like a
standalone image's).
standalone image's);
* phase 137 (task 02, A2/A7): the document context is built ONCE
per PDF — :func:`app.rag.pdf.extract_pdf_page_texts` (task 01)
reads the per-page text map a single time for the whole pass, and
the phase-133 garbage gate
(:func:`app.rag.pdf.is_usable_pdf_text`) is run over the PDF's
extracted text layer: a layer the gate flags as unusable yields
``text_usable=False`` → every block is the origin line only
(the "page text" would be the pre-126 soup — ASSUMPTION A7). An
unopenable PDF reads as an empty map + an unusable (empty)
layer: origin-line-only blocks, fail-soft like the rest of the
parser.
``raw_date`` is the PDF's resolved source date (the pass runs only
for (re)indexed PDFs, so it is always concrete) — the image docs
@@ -1400,11 +1495,20 @@ async def _index_pdf_embedded_images(
source, rel,
)
return
# Phase 137 (task 02/03): the document context's raw material —
# the SHARED probe (task 01's per-page text map extracted ONCE per
# PDF — the pass reads it per image — plus the phase-133 garbage
# gate's verdict over the layer, A7 — the gate's empty-layer target
# makes a text-less PDF's blocks origin-line-only, too). The
# task-03 backfill probes the parent with the SAME helper when it
# is still in the source, so the two paths can never drift.
page_texts, text_usable = _pdf_image_context_inputs(full_path)
images = extract_pdf_images(full_path)
for img in images:
await _index_one_embedded_image(
session, img, source=source, rel=rel, full_path=full_path,
llm=llm, summary=summary, raw_date=raw_date,
page_texts=page_texts, text_usable=text_usable,
)
@@ -1418,11 +1522,21 @@ async def _index_one_embedded_image(
llm: Embedder,
summary: ImportSummary,
raw_date: datetime,
page_texts: dict[int, str],
text_usable: bool = True,
) -> None:
"""One embedded image of one PDF → the phase-122 image pipeline,
with the phase-125 task-03 markdown context scan (parity with the
standalone image's :func:`_index_image_file` — see
:func:`_index_pdf_embedded_images` for the full contract)."""
standalone image's :func:`_index_image_file`), and the phase-137
(task 02, A2) document-context block — the combined context
(document block FIRST, markdown blocks second) is the vision call's
``context``, the stored ``image_context``, and the
``content``/``summary`` enrichment (see
:func:`_index_pdf_embedded_images` for the full contract).
*page_texts* / *text_usable* are the pass's per-PDF context inputs
(the task-01 page map; the phase-133 gate's verdict — A7).
"""
settings = llm.settings
ext = _MIME_TO_EXT.get(img.mime, ".bin")
if ext not in settings.image_extension_set:
@@ -1458,16 +1572,26 @@ async def _index_one_embedded_image(
# ``![alt](<pdf path>#page=<N>#img=<index>.<ext>)`` reference in a
# same-source markdown file (the synthetic path is the doc's
# ``path``, and the reference resolves to it through the shared
# :func:`_resolve_ref_path` rule) — or ``None`` (no reference —
# the description call and the row stay byte-identical to the
# un-enriched shape). It goes to the vision model (a context-aware
# description, LOCKED A3) AND is stored on the doc (``image_context``
# — the retrieval display's context line, task 05). A re-indexed
# image re-scans: the context follows the current source state
# (a deleted referencing markdown drops it back to ``None``).
image_context = _markdown_image_context(
# :func:`_resolve_ref_path` rule) — or ``None`` (no reference).
# A re-indexed image re-scans: the context follows the current
# source state (a deleted referencing markdown drops it back to
# ``None``).
md_context = _markdown_image_context(
_source_root_for(full_path, rel), llm, source=source, image_rel=image_rel
)
# Phase 137 (task 02, A2) — the document-context block (the
# provenance: WHERE the image came from — ALWAYS present, even for
# a text-less page or a garbage-gated text layer — A7) combined
# with the markdown reference blocks (document FIRST — provenance
# before purpose). The SAME combined value goes to the vision model
# (a context-aware description, LOCKED A3), is stored on the doc
# (``image_context`` — the retrieval display's context line), and
# enriches ``content``/``summary`` below — the text the embedder
# indexes carries the context the retrieval needs.
image_context = _combine_image_contexts(
_pdf_image_context_block(source, rel, img.page, page_texts, text_usable=text_usable),
md_context,
)
# The description through the single seam (the mime resolves from
# the copy path's extension — the extension was derived from the
# mime, so the round-trip is lossless for the default family). The
@@ -1493,12 +1617,13 @@ async def _index_one_embedded_image(
image_dir.mkdir(parents=True, exist_ok=True)
copy_path.write_bytes(img.data)
# Phase 125 (task 03): the enriched content — the reference context
# FIRST (for retrieval), then the description (the standalone image's
# rule; ``summary = content`` below keeps the image convention).
content = (
f"{image_context}\n\n{description}" if image_context is not None else description
)
# Phase 125 (task 03) / phase 137 (task 02): the enriched content —
# the combined context FIRST (for retrieval), then the description
# (the image convention; ``summary = content`` below). For embedded
# images the context is ALWAYS present (the document block is the
# provenance — never empty), so the bare-description shape is
# standalone-only.
content = f"{image_context}\n\n{description}"
title = f"Embedded image from {full_path.stem} page {img.page}"
if image_doc is None:
@@ -1635,6 +1760,92 @@ def _ref_points_at(ref: ImageRef, md_rel: str, image_rel: str) -> bool:
return resolved is not None and resolved == image_rel
#: The page-text cap of one embedded image's document-context block
#: (phase 137, task 02, ASSUMPTION A3) — a module constant, no env var.
#: A single page rarely runs past it; a runaway page (a wall of dense
#: text) is cut exactly at the cap, with the shared
#: :data:`~app.rag.retriever.TRUNCATION_MARKER` on its own line (the
#: summarizer's existing truncation convention).
PDF_IMAGE_CONTEXT_MAX_CHARS = 6000
def _pdf_image_context_block(
source: str,
pdf_rel: str,
page: int,
page_texts: dict[int, str],
text_usable: bool = True,
) -> str:
"""The document-context block for one embedded image (phase 137, A2).
Always non-empty: the origin line is present even for a text-less
page (the owner's core complaint — a stock-photo description that
names nothing about the document it came from). The page text (the
:func:`app.rag.pdf.extract_pdf_page_texts` value for *page* —
already stripped; a missing key is the text-less-page case) is cut
exactly at :data:`PDF_IMAGE_CONTEXT_MAX_CHARS` (overflow cut at the
cap, then :data:`~app.rag.retriever.TRUNCATION_MARKER` on its own
line — the summarizer's existing truncation convention) and
OMITTED entirely when the page has no text OR when *text_usable*
is ``False`` — the caller ran the phase-133 garbage gate
(:func:`app.rag.pdf.is_usable_pdf_text`) over the PDF's extracted
text layer and it failed, so the "text" is the pre-126 class of
garbage and must not be fed to the vision model (ASSUMPTION A7).
*page* is the 1-indexed page number (the forward path passes
``img.page``; the task-03 backfill passes the page parsed from the
synthetic path — taking a plain ``int``, deliberately NOT a
``PdfImage``, is what lets the backfill need no fake image
object). The header is a stable single line with NO page-total
(the total is not on the image record, and the task-04 display
renderer regexes this exact line).
"""
header = f"Extracted from {source}/{pdf_rel} (page {page}):"
text = page_texts.get(page, "").strip()
if not text or not text_usable:
return header
if len(text) > PDF_IMAGE_CONTEXT_MAX_CHARS:
text = text[:PDF_IMAGE_CONTEXT_MAX_CHARS] + "\n" + TRUNCATION_MARKER
return f"{header}\n{text}"
def _combine_image_contexts(doc_block: str, md_blocks: str | None) -> str:
"""Document block first, markdown reference block(s) second (A2).
The single combiner of the two context block kinds — provenance
(WHERE the image came from) before purpose (WHAT the markdown says
it illustrates). Used by BOTH the forward path
(:func:`_index_one_embedded_image`) and the task-03 backfill, so
the two can never drift. ``md_blocks`` ``None``/empty → the doc
block alone; the doc block is ALWAYS present for embedded images
(it is the provenance — never empty). Markdown-only values (no doc
block) are produced by the standalone path only and never pass
through this helper.
"""
if not md_blocks:
return doc_block
return f"{doc_block}\n\n{md_blocks}"
def _pdf_image_context_inputs(full_path: Path) -> tuple[dict[int, str], bool]:
"""The phase-137 document-context inputs for one PDF on disk — the
task-02 probe, shared by the forward path
(:func:`_index_pdf_embedded_images`) and the task-03 backfill
(:func:`_backfill_image_contexts`) so the two can never drift:
* the per-page text map (task 01's
:func:`app.rag.pdf.extract_pdf_page_texts` — fail-soft: an
unreadable file reads as an empty map with one warning);
* the phase-133 garbage gate's verdict over the layer (ASSUMPTION
A7 — :func:`app.rag.pdf.is_usable_pdf_text` over the whole
extracted text; ``False`` → the blocks are origin-line-only, the
"page text" is the pre-126 class of garbage).
"""
page_texts = extract_pdf_page_texts(full_path)
text_usable = is_usable_pdf_text("\n\n".join(page_texts.values()))
return page_texts, text_usable
def _markdown_image_context(
root: Path,
llm: Embedder,
@@ -1975,6 +2186,209 @@ async def _enrich_pending_images(
)
#: The phase-126 embedded-image synthetic ``Document.path`` —
#: ``<pdf path>#page=<N>#img=<I>.<ext>`` (the upsert key built by
#: :func:`_index_one_embedded_image`: the parent PDF's source-relative
#: path, the 1-indexed page, the 0-based index on the page, the
#: mime-derived dotted extension). The task-03 backfill gates on this
#: exact shape: a non-matching ``is_image`` row (a standalone image's
#: plain path) is NEVER touched by the backfill. The ``pdf`` suffix is
#: case-insensitive (scoped flag) because the walk matches extensions
#: case-insensitively — an on-disk ``report.PDF`` produces a synthetic
#: path the backfill must still recognize.
_EMBEDDED_IMAGE_PATH_RE = re.compile(
r"^(?P<pdf>.+(?i:pdf))#page=(?P<page>\d+)#img=(?P<img>\d+)(?P<ext>\.\w+)$"
)
async def _backfill_image_contexts(
session: Session, *, source: str, root: Path, llm: Embedder, summary: ImportSummary
) -> None:
"""The phase-137 (task 03) per-sync document-context backfill — once
per source, immediately AFTER the phase-125 enrichment pass
(:func:`_enrich_pending_images`) in :func:`import_sources`.
Closes the one-time gap the forward path (task 02) cannot reach:
the PDF-embedded image docs a PRE-phase-137 sync left behind
without the document block — ``image_context`` NULL (no context at
all) or markdown-only (the phase-125 ``Referenced in …`` value —
the document block missing) — are re-described WITH the document
context, so the embedder indexes the provenance (the owner's
HNB-guide stock-photo complaint: descriptions that name nothing
about the document the image came from).
Candidates — this source's ``is_image`` docs where ALL hold:
1. the ``path`` parses as the phase-126 embedded-image synthetic
path (:data:`_EMBEDDED_IMAGE_PATH_RE` — ``pdf_rel``, page,
index, extension); a non-match (a standalone image's plain
path) is NEVER touched;
2. the stored ``image_context`` is NULL or does NOT start with
``Extracted from `` — the idempotency gate: a success writes a
context starting with the header, so the doc never qualifies
again (the following sync burns zero vision calls for it);
3. the ``image_path`` names a file that exists on disk (the
``image_dir`` copy — the bytes source; missing → debug log,
skip, the doc stays as-is).
Per candidate (fail-soft, ONE transaction per image — the
:func:`_enrich_pending_images` error handling, mirrored): the page
text is re-extracted from the parent PDF WHEN IT IS STILL IN THE
SOURCE (:func:`_pdf_image_context_inputs` ONCE per unique
``pdf_rel`` — a local cache, one parent may own many images; a
parent gone for good reads as an empty map + usable → the origin
line only, which still needs no disk), the document block
(:func:`_pdf_image_context_block`) is combined with the stored
markdown blocks VERBATIM when present (:func:`_combine_image_contexts`
— document FIRST; the phase-125 pass owns the markdown context and
ran earlier this sync, so re-scanning the disk for it is NOT this
pass's job), and the fresh description goes through the SAME seam
(:func:`_describe_or_skip` — the mime comes from the copy's
extension, exactly like the forward path). A ``None`` description
leaves the row UNTOUCHED (nothing was written) —
``images_failed`` + a warning, and the gate still matches, so the
next sync retries it. A success replaces the row's
``content``/``summary``/``image_context`` + chunks
(:func:`_replace_content_chunks` in the image's own transaction —
an embed failure rolls back THIS image only, the copy stays — then
the image-convention :func:`_store_summary`, the description IS the
summary, verbatim) and counts the log-only
``summary.image_context_backfilled`` (the sync ``detail`` shape is
untouched — the stats log-only convention, phase 133) with one loud
line (PLAN §9).
Deliberately UNCHANGED on the row: ``content_hash`` (the byte
digest — the image identity is untouched), ``created_at`` /
``created_at_manual`` (the SOURCE did not change — the phase-106
date rules do not apply), ``title``, and ``image_path``.
Driven by committed doc rows (like the enrichment pass), so it is
safe under ``limit`` (it deletes nothing); the common case after
the one-time backfill (no candidates) returns with zero vision
calls and zero disk reads beyond the candidate query.
"""
image_docs = session.scalars(
select(Document).where(
Document.source == source, Document.is_image.is_(True)
)
).all()
if not image_docs:
return
# The parent PDF's context inputs, cached per unique ``pdf_rel``
# (one parent may own many images): a parent still in the source
# gets the task-02 probe (the per-page map + the phase-133 gate
# verdict — the same helper the forward path uses, A7); a parent
# gone for good reads as an empty map + usable — the origin line
# only (the origin is still valuable and needs no disk).
context_cache: dict[str, tuple[dict[int, str], bool]] = {}
def _context_for(pdf_rel: str) -> tuple[dict[int, str], bool]:
if pdf_rel not in context_cache:
parent = root / pdf_rel
if parent.is_file():
context_cache[pdf_rel] = _pdf_image_context_inputs(parent)
else:
context_cache[pdf_rel] = ({}, True)
logger.debug(
"import: backfill parent PDF missing source=%s path=%s "
"(origin line only)",
source, pdf_rel,
)
return context_cache[pdf_rel]
for doc in image_docs:
parsed = _EMBEDDED_IMAGE_PATH_RE.match(doc.path)
if parsed is None:
continue # standalone / non-embedded — NEVER touched
if doc.image_context is not None and doc.image_context.startswith(
"Extracted from "
):
continue # already backfilled (the idempotency gate)
if not doc.image_path or not Path(doc.image_path).is_file():
# No persistent bytes to re-describe — keep the doc as-is
# (the next sync retries; a copy gone for good leaves an
# unbackfillable doc, and the debug line is the signal).
logger.debug(
"import: backfill skipped (no persistent copy) source=%s path=%s",
source, doc.path,
)
continue
pdf_rel = parsed.group("pdf")
page = int(parsed.group("page"))
page_texts, text_usable = _context_for(pdf_rel)
# The stored markdown blocks (the phase-125 value) are kept
# VERBATIM under the document block — document FIRST. A value
# that is neither the markdown shape nor NULL contributes
# nothing (the doc block alone).
existing = doc.image_context
md_blocks = (
existing
if existing is not None and existing.startswith("Referenced in ")
else None
)
combined = _combine_image_contexts(
_pdf_image_context_block(
source, pdf_rel, page, page_texts, text_usable=text_usable
),
md_blocks,
)
copy_path = Path(doc.image_path)
try:
data = copy_path.read_bytes()
except OSError as e:
logger.debug(
"import: backfill skipped (copy unreadable) source=%s path=%s — %s",
source, doc.path, e,
)
continue
description = await _describe_or_skip(
llm, data=data, source=source, rel=doc.path,
full_path=copy_path, context=combined,
)
if description is None:
# Fail-soft: nothing was written — the row is untouched,
# the gate still matches, the next sync retries it.
summary.images_failed += 1
logger.warning(
"import: image context backfill re-description failed "
"source=%s path=%s",
source, doc.path,
)
continue
try:
# The SOURCE did not change — the phase-106 date rules do
# not apply: only the index-time and the context-carrying
# fields move (``content_hash``/``title``/``image_path`` /
# the creation date stay deliberately untouched).
doc.image_context = combined
doc.content = f"{combined}\n\n{description}"
doc.indexed_at = datetime.now(UTC)
chunks_n = await _replace_content_chunks(
session, doc=doc, llm=llm, content=doc.content, rel=doc.path
)
session.commit()
except EmbeddingError as e:
session.rollback()
# The description succeeded, but the embed gave up: the row
# is untouched (rolled back), the copy stays, the next sync
# retries it.
summary.errors += 1
logger.error(
"import: image context backfill failed source=%s path=%s — %s",
source, doc.path, e,
)
continue
summary.image_context_backfilled += 1
logger.info(
"import: backfilled image context source=%s path=%s chunks=%d",
source, doc.path, chunks_n,
)
await _store_summary(
session, doc=doc, source=source, rel=doc.path,
content=doc.content, llm=llm, summary=summary,
)
async def _describe_or_skip(
llm: Embedder,
*,
@@ -2003,13 +2417,17 @@ async def _describe_or_skip(
caller (and the patch) reads like the document being described;
the failure's doc identity is logged by the caller's warning.
``context`` (phase 125, task 03) is the optional markdown reference
context (:func:`_markdown_image_context`), forwarded verbatim to
:func:`app.rag.summarizer.describe_image` — appended AFTER the
locked describe prompt so the vision model can write a
context-aware description (LOCKED A3: the description remains the
primary content). ``None`` (standalone image) keeps the message
byte-identical to pre-phase-125.
``context`` (phase 125, task 03; generalized in phase 137, task
02) is the optional document/reference context built by the
importer — the markdown reference blocks
(:func:`_markdown_image_context`) for a standalone image, the
document block (:func:`_pdf_image_context_block`) + markdown blocks
(:func:`_combine_image_contexts`) for a PDF-embedded image —
forwarded verbatim to :func:`app.rag.summarizer.describe_image`
— appended AFTER the locked describe prompt so the vision model can
write a context-aware description (LOCKED A3: the description
remains the primary content). ``None`` (a reference-free standalone
image) keeps the message byte-identical to the pre-phase shape.
"""
mime = IMAGE_MIMES.get(full_path.suffix.lower(), IMAGE_FALLBACK_MIME)
return await describe_image(llm, data=data, mime=mime, context=context)
+64 -15
View File
@@ -10,6 +10,12 @@ package, imported as ``fitz`` — its stable API name).
these markers so every chunk keeps its page anchor. Pages without
text (image-only pages) contribute nothing, so a PDF with no
extractable text returns ``""``.
* :func:`extract_pdf_page_texts` — one entry per page that has text:
1-indexed page number → the page's stripped text (the same page read
as :func:`extract_pdf_text`, so the two agree page-for-page; the
value for page N is exactly that page's separator-block body). The
phase-137 image-context block is built from this map (page N's entry
is the document context for an image embedded on page N).
* :func:`extract_pdf_images` — one :class:`PdfImage` per embedded-image
occurrence, in page order then 0-based index on the page. ``data`` is
the image's original encoded bytes as stored in the PDF (no
@@ -37,6 +43,7 @@ from __future__ import annotations
import logging
import re
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -117,6 +124,32 @@ def _page_image_entries(page: Any) -> list[dict[str, Any]]:
return [dict(info) for info in infos]
def _iter_page_texts(doc: Any, pdf_path: Path) -> Iterator[tuple[int, str]]:
"""Yield ``(page, text)`` for every page of *doc* that has text.
``page`` is the 1-indexed page number and ``text`` the page's
stripped text (the ``get_text("text")`` read — it always returns a
str; the union return type comes from pymupdf's other flavors,
dict/words/blocks). A page that cannot be read is logged and
skipped; a page without text contributes nothing. The shared page
iteration behind :func:`extract_pdf_text` and
:func:`extract_pdf_page_texts` — the two callers share the exact
same read and cannot drift.
"""
for page_num in range(len(doc)):
try:
raw = doc[page_num].get_text("text")
except Exception as exc:
logger.warning(
"pdf: could not read text of page %d of %s — %s",
page_num + 1, pdf_path, exc,
)
continue
text = raw.strip() if isinstance(raw, str) else ""
if text:
yield page_num + 1, text
def extract_pdf_text(pdf_path: Path) -> str:
"""Extract the text of all pages of *pdf_path*.
@@ -134,25 +167,41 @@ def extract_pdf_text(pdf_path: Path) -> str:
logger.warning("pdf: could not open %s — %s", pdf_path, exc)
return ""
parts: list[str] = []
with doc:
for page_num in range(len(doc)):
try:
raw = doc[page_num].get_text("text")
except Exception as exc:
logger.warning(
"pdf: could not read text of page %d of %s — %s",
page_num + 1, pdf_path, exc,
)
continue
# get_text("text") always returns a str; the union return type
# comes from pymupdf's other flavors (dict/words/blocks).
text = raw.strip() if isinstance(raw, str) else ""
if text:
parts.append(f"{PAGE_SEPARATOR.format(page=page_num + 1)}\n{text}")
parts = [
f"{PAGE_SEPARATOR.format(page=page)}\n{text}"
for page, text in _iter_page_texts(doc, pdf_path)
]
return "\n\n".join(parts)
def extract_pdf_page_texts(pdf_path: Path) -> dict[int, str]:
"""One entry per page of *pdf_path* that has text.
Keys are the 1-indexed page numbers, values the page's stripped
text — the shared :func:`_iter_page_texts` read, so this map agrees
with :func:`extract_pdf_text` page-for-page: the value for page N
is exactly the text after that page's ``---PAGE-N---`` separator.
Pages without text (image-only pages) contribute nothing — no key.
The phase-137 document context for an embedded image is built from
this map: one image on page N gets page N's text as the context
block's body.
Fail-soft: an unreadable file returns ``{}`` (one warning — the
:func:`extract_pdf_text` open contract); a page that cannot be
read is logged and skipped.
"""
try:
doc = fitz.open(str(pdf_path))
except Exception as exc:
logger.warning("pdf: could not open %s for page texts — %s", pdf_path, exc)
return {}
with doc:
return dict(_iter_page_texts(doc, pdf_path))
#: A line "looks like XML" when it carries a tag-shaped token — the
#: pre-126 garbage's signature (raw XMP blocks, PDF-structure noise).
_XMLISH_LINE_RE = re.compile(r"</?[A-Za-z_][\w:.-]*[\s/>=]")
+117 -40
View File
@@ -445,6 +445,22 @@ def build_kb_section(overview: str, max_chars: int | None = None) -> str:
#: (graceful degradation, A4).
_IMAGE_CTX_HEADER_RE = re.compile(r"^Referenced in (?P<ref>.+):$")
#: Phase 137 (task 04, ASSUMPTION A6) — the header line of the PDF
#: provenance block of a PDF-embedded image doc's stored
#: ``image_context`` (the exact shape the importer's document-block
#: writer emits, phase 137 tasks 02/03 — written FIRST, before any
#: markdown reference blocks, ASSUMPTION A2):
#: ``Extracted from <source>/<pdf path> (page <N>):`` — ``loc`` is the
#: ``<source>/<pdf path>`` locator (``.+`` greedy to the LAST
#: `` (page N):`` — the pdf path may itself contain spaces); the line
#: FOLLOWING the header is page-text body (or the next block's header)
#: and is never part of the clause — the header line alone IS the
#: clause (no bullet is required or expected). A look-alike line inside
#: the page-text body emits a clause the same way — accepted: the
#: stored value is the context the importer wrote, not free model text
#: (the ``Referenced in`` twin IS guarded by its bullet requirement).
_PDF_CTX_HEADER_RE = re.compile(r"^Extracted from (?P<loc>.+) \(page (?P<page>\d+)\):$")
#: The ``- Alt text: "..."`` bullet of the same block. The greedy alt
#: field (the block writer does not escape quotes) is captured between
#: the opening quote (the pattern's literal) and the CLOSING quote
@@ -469,48 +485,96 @@ def _image_ctx_clause(ref: str, alt: str | None) -> str:
def _extract_context_line(image_context: str) -> str | None:
"""The ONE-LINE rendering of an enriched image doc's stored
``image_context`` (phase 125, task 05, LOCKED A4) — or ``None``
when nothing in the value parses as a reference block (graceful
degradation: the ``<document>`` block then shows only the
description, exactly pre-phase — the value never raises into the
prompt build).
``image_context`` (phase 125, task 05, LOCKED A4; phase 137, task
04, ASSUMPTION A6) — or ``None`` when nothing in the value parses
as a reference block OR a provenance block (graceful degradation:
the ``<document>`` block then shows only the description, exactly
pre-phase — the value never raises into the prompt build).
The stored value (tasks 03/04) is one block per referring markdown
file, blank-line joined::
The stored value is blank-line-joined blocks of two kinds:
Referenced in <source>/<markdown_path>:
- Alt text: "<alt_text>"
Context: <context paragraphs — may span many lines>
- Alt text: "<a second reference's alt>"
* the phase-137 document (provenance) block (tasks 02/03) — written
FIRST for a PDF-embedded image (ASSUMPTION A2: provenance before
purpose); ONE header line, no bullet::
The result keeps only what tells the LLM WHY the image exists in
the documentation — one clause per referring file (its identity +
the FIRST reference's alt), after a single ``Referenced in ``
prefix, ``; ``-joined::
Extracted from <source>/<pdf path> (page <N>):
<page text — never rendered: it already reached the vision
model at describe time>
Referenced in docs/a.md: "alt a"; docs/b.md
→ the clause ``From <source>/<pdf path> (page <N>)`` — the
header line alone (the page-text body is skipped as context
body; a spurious ``Extracted from … (page N):`` line inside the
page text emits a clause the same way — it is the stored context
the importer wrote, not free model text, accepted).
The ``Context:`` body is never rendered (the surrounding paragraphs
already reached the vision model at enrichment time, LOCKED A3 —
the line is the locator + purpose, not a duplicate of the
paragraphs), so the line stays short enough not to bloat the
prompt. The markdown doc's ``IMAGE_CONTEXT:`` pending record
(task 04) carries no ``Referenced in`` header at all and parses to
``None`` — and it rides a TEXT doc, which :func:`_document_body`
excludes from the line via the ``is_image`` guard anyway.
* the phase-125 reference block (tasks 03/04) — one per referring
markdown file; the line FOLLOWING the header is always its first
``- Alt text: "..."`` bullet (a look-alike header without its
bullet is spurious and dropped)::
Referenced in <source>/<markdown_path>:
- Alt text: "<alt_text>"
Context: <context paragraphs — may span many lines>
- Alt text: "<a second reference's alt>"
→ the clause ``Referenced in <ref>: "<alt>"`` (identity + the
FIRST reference's alt; the ``Context:`` body is never rendered —
LOCKED A3, the paragraphs already reached the vision model at
enrichment time, so the line stays short enough not to bloat
the prompt).
Clauses join with ``"; "`` IN STORED ORDER:
* BOTH kinds (the phase-137 combined shape — document block first,
then the markdown blocks) render each clause with its own
prefix::
From <source>/<pdf> (page N); Referenced in docs/a.md: "alt"
* a MARKDOWN-ONLY value keeps the phase-125 shape byte-identically
(the regression pin — ONE ``Referenced in `` prefix for the
joined referenced clauses)::
Referenced in docs/a.md: "alt a"; docs/b.md
A value with NO parsable header of either kind parses to ``None``
(pre-phase rows, unparseable text). The markdown doc's
``IMAGE_CONTEXT:`` pending record (phase 125, task 04) carries no
parsable header of either kind and parses to ``None`` — and it
rides a TEXT doc, which :func:`_document_body` excludes from the
line via the ``is_image`` guard anyway.
"""
clauses: list[str] = []
clauses: list[tuple[str, str]] = []
# (kind, text), in stored order: a ``"pdf"`` clause's text already
# carries its ``From …`` prefix; a ``"ref"`` clause's text is the
# BARE ``<ref>: "<alt>"`` core — the prefix is applied at the join,
# below (a markdown-only value keeps ONE prefix, byte-identically).
ref: str | None = None # the current block's "source/markdown_path"
alt: str | None = None # the current block's first bullet's alt text
expecting_alt = False # the line after a header must be its bullet
expecting_alt = False # the line after a "Referenced in" header must be its bullet
for line in image_context.splitlines():
header = _IMAGE_CTX_HEADER_RE.match(line)
if header is not None:
if ref is not None and not expecting_alt:
clauses.append(_image_ctx_clause(ref, alt))
clauses.append(("ref", _image_ctx_clause(ref, alt)))
ref, alt = header.group("ref"), None
expecting_alt = True
continue
pdf_header = _PDF_CTX_HEADER_RE.match(line)
if pdf_header is not None:
# A provenance header requires NO following bullet — the
# header line alone is the clause (the next line is
# page-text body or the next header; neither changes it).
# A pending reference block's bullet never came → dropped
# by the same spurious-header rule; a complete one is
# kept, in stored order.
if ref is not None and not expecting_alt:
clauses.append(("ref", _image_ctx_clause(ref, alt)))
clauses.append(
("pdf", f"From {pdf_header.group('loc')} (page {pdf_header.group('page')})")
)
ref, alt, expecting_alt = None, None, False
continue
alt_match = _IMAGE_CTX_ALT_RE.match(line) if ref is not None else None
if expecting_alt:
if alt_match is None:
@@ -521,16 +585,27 @@ def _extract_context_line(image_context: str) -> str | None:
alt = alt_match.group("alt")
expecting_alt = False
continue
# Any other line is context body or a second bullet of the
# same block — the first reference's alt is already captured,
# so the one-line rendering is unaffected (no branch needed:
# with a live block and no pending bullet, ``alt`` is never
# ``None``).
# Any other line is context body — a provenance block's page
# text, a reference block's Context: paragraphs, or a second
# bullet of the same block — and the one-line rendering is
# unaffected (no branch needed: with a live block and no
# pending bullet, ``alt`` is never ``None``).
if ref is not None and not expecting_alt:
clauses.append(_image_ctx_clause(ref, alt))
clauses.append(("ref", _image_ctx_clause(ref, alt)))
if not clauses:
return None
return "Referenced in " + "; ".join(clauses)
if all(kind == "ref" for kind, _ in clauses):
# Markdown-only: the phase-125 shape, byte-identically (the
# regression pin) — ONE ``Referenced in `` prefix for the
# joined clauses.
return "Referenced in " + "; ".join(text for _, text in clauses)
# A provenance clause is present: every clause carries its own
# prefix (the ``pdf`` ones already do; the referenced clauses gain
# theirs) — the stored order (document block first, ASSUMPTION A2)
# is the render order.
return "; ".join(
text if kind == "pdf" else f"Referenced in {text}" for kind, text in clauses
)
def _image_context_line(doc: Document) -> str | None:
@@ -559,12 +634,14 @@ def _document_body(doc: Document) -> str:
or under the cap rides whole, unmarked (nothing was cut). No LLM
call at chat time — the preview is deterministic.
Phase 125 (task 05, LOCKED A4): an ENRICHED image doc (``is_image``
with a non-NULL ``image_context`` that parses — see
:func:`_image_context_line`) leads its body with the ONE short
context line — ``Referenced in <source>/<path>: "<alt>"`` — before
the description, so the LLM knows WHY the image exists in the
documentation, not only what it shows. Every other doc is
Phase 125 (task 05, LOCKED A4; phase 137, task 04, A6): an ENRICHED
image doc (``is_image`` with a non-NULL ``image_context`` that
parses — see :func:`_image_context_line`) leads its body with the
ONE short context line — ``Referenced in <source>/<path>: "<alt>"``
(the phase-125 reference shape) or ``From <source>/<pdf> (page N)``
(the phase-137 provenance shape) — before the description, so the
LLM knows WHY the image exists in the documentation, not only what
it shows. Every other doc is
byte-identical to the pre-phase body: every text doc (its
``image_context`` may carry the task-04 pending record, which never
renders) and every non-enriched image doc (NULL ``image_context``
+23 -10
View File
@@ -227,16 +227,22 @@ async def describe_image(
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
``![alt](path)`` 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
*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 — standalone images) sends the bare prompt: the message is
byte-identical to the pre-phase-125 shape.
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 —
@@ -248,8 +254,15 @@ async def describe_image(
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 = (
f"\n\nThis image is referenced in documentation with the following context:\n{context}"
"\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 ""
)