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
@@ -64,6 +64,7 @@ import asyncio
import re
import tarfile
import time
from collections import Counter
from collections.abc import Iterator
from pathlib import Path
from typing import Any
@@ -74,8 +75,9 @@ from sqlalchemy.orm import Session
from app.config import Settings
from app.models import Chunk, Document
from app.rag.importer import ImportSummary, import_sources
from app.rag.pdf import PAGE_SEPARATOR
from app.rag.importer import PDF_IMAGE_CONTEXT_MAX_CHARS, ImportSummary, import_sources
from app.rag.pdf import PAGE_SEPARATOR, extract_pdf_page_texts
from app.rag.retriever import TRUNCATION_MARKER
from app.rag.summarizer import DESCRIBE_PROMPT
from tests.fakes import FakeEmbedder
@@ -125,6 +127,33 @@ pytestmark = pytest.mark.skipif(
)
#: The phase-137 (task 02, ASSUMPTION A5) generalized context prefix
#: (pinned here AND in the summarizer's code) — it covers BOTH the
#: provenance's document context and the phase-125 markdown reference
#: blocks (this PDF-only source has the former alone).
DESCRIBE_CONTEXT_PREFIX = (
"\n\nThis image is part of a document. The following context "
"describes where it comes from and what it is used for:\n"
)
def _expected_context_block(
pdf_rel: str, page: int, page_texts: dict[int, str]
) -> str:
"""The phase-137 (task 02) document-context block the importer
builds for an embedded image on *page* of *pdf_rel* (the guide's
CLEAN text layer: the origin line + the capped page text — the A7
origin-line-only shape belongs to the garbage layer, never this
fixture; a text-less page is the origin line alone)."""
header = f"Extracted from {pdf_rel} (page {page}):"
text = page_texts.get(page, "").strip()
if not text:
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}"
class GuideVisionLLM(FakeEmbedder):
"""The task-03-style vision mock the ``fakes.py`` comment
anticipates: the base ``chat`` is string-only — the MULTIMODAL user
@@ -194,7 +223,7 @@ def _nonprint_frac(content: str) -> float:
@pytest.fixture()
def guide_kb(
db: Session, tmp_path: Path
) -> Iterator[tuple[GuideVisionLLM, ImportSummary, float]]:
) -> Iterator[tuple[GuideVisionLLM, ImportSummary, float, Path]]:
"""The real guide, imported ONCE per test through the REAL importer
(the house KB pattern — ``test_read_line_ranges_handbook.py``):
``chunks``/``documents``/``folder_summaries`` are truncated around
@@ -202,7 +231,7 @@ def guide_kb(
source dir, and the deterministic in-process
:class:`GuideVisionLLM` stands in for the embed/summary/vision
calls (the whole 48-page index build stays fast and offline).
Yields ``(llm, summary, elapsed_s)``."""
Yields ``(llm, summary, elapsed_s, pdf_path)``."""
db.execute(text("TRUNCATE chunks, documents, folder_summaries"))
db.commit()
root = tmp_path / SOURCE
@@ -214,7 +243,7 @@ def guide_kb(
started = time.monotonic()
summary = asyncio.run(import_sources([root], llm, session=db))
elapsed = time.monotonic() - started
yield llm, summary, elapsed
yield llm, summary, elapsed, root / PDF_NAME
db.execute(text("TRUNCATE chunks, documents, folder_summaries"))
db.commit()
db.expire_all()
@@ -222,15 +251,17 @@ def guide_kb(
def test_hnb_guide_imports_clean_through_the_real_pipeline(
db: Session,
guide_kb: tuple[GuideVisionLLM, ImportSummary, float],
guide_kb: tuple[GuideVisionLLM, ImportSummary, float, Path],
) -> None:
"""The owner's exact file lands CLEAN through the real pipeline:
the walk admits the one file (``pdf`` token — the PDF branch, never
the text branch), the stored content is clean + carries the real
first-page text, the page-anchored chunks are embedded, the ``lite``
summary is stored, and every embedded-image doc got the fake vision
description (the multimodal ``chat`` was called)."""
llm, summary, elapsed = guide_kb
description (the multimodal ``chat`` was called) enriched with the
phase-137 (task 02) document context (the provenance block first —
the owner's stock photos finally name their document)."""
llm, summary, elapsed, pdf_path = guide_kb
# The task's wall-time guard — the suite must stay fast.
assert elapsed < WALL_TIME_BAR_S, (
@@ -291,7 +322,19 @@ def test_hnb_guide_imports_clean_through_the_real_pipeline(
# The embedded-image docs (the phase-126 A3 ``#page=…#img=…``
# rows): every one of them got the FAKE vision description — the
# multimodal ``chat`` was called (the task's literal assertion),
# exactly once per image doc, with the locked bare prompt.
# exactly once per image doc — and phase 137 (task 02) enriched it
# with the document context: the provenance block (the origin line
# + the page's own text, capped — the guide's layer is CLEAN, so
# the gate leaves the page texts IN; the A7 origin-line-only shape
# is the garbage layer's, pinned in the unit suite) FIRST, then
# the description.
page_texts = extract_pdf_page_texts(pdf_path)
def _page_of(path: str) -> int:
m = re.search(r"#page=(\d+)", path)
assert m is not None, f"unparseable embedded-image path {path!r}"
return int(m.group(1))
image_docs = db.scalars(
select(Document).where(
Document.source == SOURCE,
@@ -301,7 +344,9 @@ def test_hnb_guide_imports_clean_through_the_real_pipeline(
).all()
assert image_docs, "the guide's embedded images did not index"
for img in image_docs:
assert img.content == GuideVisionLLM.VISION_REPLY
context = _expected_context_block(PDF_REL, _page_of(img.path), page_texts)
assert img.image_context == context
assert img.content == f"{context}\n\n{GuideVisionLLM.VISION_REPLY}"
assert img.summary == img.content # the image convention
assert img.image_path is not None
copy = Path(img.image_path)
@@ -318,9 +363,16 @@ def test_hnb_guide_imports_clean_through_the_real_pipeline(
assert len(llm.vision_texts) == len(image_docs), (
"one vision call per embedded image doc (no failures)"
)
# The bare locked prompt on every call (no markdown reference
# exists in this PDF-only source — the phase-126 A3 shape).
assert all(t == DESCRIBE_PROMPT for t in llm.vision_texts)
# The locked prompt + the generalized prefix + the document context
# on EVERY call (no markdown reference exists in this PDF-only
# source — the phase-137 A2 shape). The multiset is the
# order-independent exact pin (the rows come back unsorted).
expected_calls = Counter(
DESCRIBE_PROMPT + DESCRIBE_CONTEXT_PREFIX
+ _expected_context_block(PDF_REL, _page_of(img.path), page_texts)
for img in image_docs
)
assert Counter(llm.vision_texts) == expected_calls
assert summary.images_failed == 0
# The added accounting (see the module docstring): the walked file