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)
|
||||
|
||||
+64
-15
@@ -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
@@ -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
@@ -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
|
||||
```` 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 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 ""
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user