phase: 137_pdf_image_context
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:
+443
-25
@@ -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(
|
||||
# ```` 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)
|
||||
|
||||
Reference in New Issue
Block a user