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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user