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)