All verification is complete — everything is green. No repo-side changes were needed in this pass (the phase's work in the working tree is correct and complete).
## Phase 133 final verification report
- **Verified (already implemented in working tree):** `is_usable_pdf_text` gate (0.30 XML-ish / 0.05 non-printable / empty), `render_pdf_pages`, `_fallback_page_screenshots` + `pdfs_screenshotted` counter in `app/rag/{pdf,importer}.py`; regression pin, unit/integration/E2E suites.
- **Completion criteria:**
1. Real guide clean through real pipeline — ✅ `test_pdf_hnb_guide_regression` + E2E preview/viewer checks (0 XML-ish lines, first-page text present).
2. Garbage PDF → vision fallback end to end — ✅ unit + E2E (stored content = canned per-page descriptions, garbage not stored, sync `detail` shape unchanged).
3. Clean PDF never burns a fallback vision call — ✅ unit + E2E (byte-faithful content).
4. Gates — ✅ `uv run pytest --cov=app --cov-report=term-missing`: **3129 passed, app/ 99%** (>90%); `uv run pytest tests/e2e/test_garbage_pdf.py -v --no-cov`: **3 passed** in isolation.
5. No behavior change to completed phases — ✅ entire suite green (all prior-phase tests included); `ruff check .` + `pyright` clean.
6. Production stale row — post-deploy re-sync verification (live infra, no repo work; no live changes made — deploy of `main` is the owner's step).
- **Documentation:** README.md reviewed in full — every env default cross-checked against `app/config.py`, page routes against `app/main.py`, scripts/Containerfile/compose flags, `.env.example`; the phase-133 PDF bullet added in the working tree is accurate. AGENTS.md verified consistent (all referenced files/commands exist). **No changes needed.**
- **Next pending phase:** `134_sync_cancel`.
8.5 KiB
Phase 133 — Garbage PDF text is detected; the page-screenshot vision fallback extracts it
Source: TODO.md L9 — "test_sources/hnb_2026_guide.tar.gz contains a pdf that is being read as binary. Figure out why. It contains images, tables, and text but none of that is being extracted correctly. Instead, the raw xml is being embedded and the preview shows the entire document as lines like <illustrator:StartupProfile>Web</illustrator:StartupProfile>. This project should support this kind of PDF and should be able to extract it correctly, even if that means screenshotting the pages and reading each one as an image to preserve formatting when text/image/table extraction isn't working."
FINDING (diagnosed 2026-09-27, before phase authoring — the evidence, verified against the real fixture):
- The archive holds ONE file:
HBI-2026-Guide-Year-Round_260704_131221.pdf(6.2 MB, 48 pages, PDFium-produced, Adobe Illustrator export — the raw bytes carry 222 ×illustrator:StartupProfile+ 138 ×<x:xmpmetauncompressed XMP blocks). - The CURRENT code (phase 126 onward) extracts it CORRECTLY:
extract_pdf_textreturns 2,585 lines, 0 XML-ish; a full real-importer run stores clean content (---PAGE-N---blocks, "2026 U.S. Benefits Guide" first page), the title, and vision-described embedded images. - The garbage the owner saw is the pre-126 import:
pdfwas already inBOR_IMPORT_EXTENSIONSin the local.env(untracked — predates the parser) BEFORE126_pdf_supportshipped (commite0f11e7, 2026-09-27 09:36). The generic text path (importer.pyL689:read_text(encoding="utf-8", errors="replace").replace("\x00", "")) read the raw PDF bytes — the uncompressed XMP/structure blocks survive as literal text (reproduced: 138,170 non-empty lines, 46% XML-ish —<</Pages 2 0 R /Type/Catalog>>,<illustrator:StartupProfile>Web</…>, exactly the owner's preview) — and that garbage was chunked + embedded. - Production fix is a re-sync: the importer's content-hash delta sees the old garbage row's digest ≠ the newly extracted text's digest → the next sync re-indexes the file as
updatedwith clean content (deploy phase 126 first — it is already onmain). No migration needed. This is documented here, not a code task.
Story: n/a (TODO-derived phase, Protocol B append).
Context: app/rag/pdf.py (phase 126 — extract_pdf_text (L109, page.get_text("text") per page, PAGE_SEPARATOR blocks, fail-soft ""), extract_pdf_images (L145), the fitz import); app/rag/importer.py — _index_pdf_file (the phase-126 PDF branch: extract → doc row → digest over extracted text → chunk + embed → lite summary → embedded images via _describe_or_skip (L1779)); app/rag/summarizer.py — describe_image (L208: ONE chat-model call, multimodal user message, data: bytes + mime, returns str | None, no app-level retry); ImportSummary (L212, the stats counters + log() line); tests/e2e/mock_llm.py — the IMAGE_DESCRIPTION_MODE canned vision reply (deterministic for the E2E); test_sources/hnb_2026_guide.tar.gz (the untracked fixture, 2.6 MB compressed).
Objective
Two guarantees, one for today and one for the class of problem: (a) the owner's guide is pinned as a regression — it imports through the real pipeline with CLEAN content (the root cause is documented above: a pre-126 binary-text import, self-healed by a re-sync); (b) any future PDF whose extracted text is GARBAGE (XML/binary soup, empty text layer) is DETECTED by a deterministic gate and falls back to the owner's specified path — every page rendered to a screenshot and read by the vision model, preserving what the text layer couldn't.
Dependencies
132_image_upload_feedback(todo) — pipeline order only; code dependencies are the completed phase-126 PDF pipeline + the phase-122 vision path.
Design (shared by all tasks — the executor reads this, not the chat)
- The gate (task 02):
app/rag/pdf.pygainsis_usable_pdf_text(text: str) -> bool— pure, deterministic, unit-testable. Unusable when ANY of:text.strip() == ""(the empty text layer — the fallback's other target); the fraction of non-empty lines that look like XML is ≥ 0.30 (a line is XML-ish:re.search(r"</?[A-Za-z_][\w:.-]*[\s/>=]", line)— covers<tag>,</tag>,<tag attr=, and the PDF-structure<</Key…noise); the non-printable fraction (U+FFFD replacement chars + C0 control chars, over the whole text) is ≥ 0.05 (the binary-soup signal — the pre-126 read is 46% XML-ish AND heavy on U+FFFD; a real document about XML with ≥ 30% tag-like lines is a documented false-positive cost — ASSUMPTION A8). Applied in_index_pdf_fileAFTERextract_pdf_text(the extractor stays pure). - The fallback (task 03): when the gate fails,
_index_pdf_filetakes the owner's specified path — "screenshotting the pages and reading each one as an image":app/rag/pdf.pygainsrender_pdf_pages(pdf_path: Path, dpi: int = 150) -> list[bytes](one PNG per page, PyMuPDFpage.get_pixmap(dpi=dpi).tobytes("png"), in page order; an unreadable page → a zero-byte entry the caller replaces with the placeholder — no page index may shift); for each page,describe_image(llm, data=png, mime="image/png")— the phase-122 vision call, fail-soft per page (None→ the placeholder line). The storedcontent=PAGE_SEPARATORblocks of the page descriptions (---PAGE-N---\n<description or "page N — description unavailable">) — the SAME separator convention as the text path, so the chunker's page-boundary handling works unchanged.is_imagestaysFalse(it is a PDF document whose content happens to be vision text). The digest is over the constructed content (the phase-126 convention — the fallback output is stable for a stable file, so re-syncs don't re-burn vision: a cached row re-imports as unchanged). ONE loud log line (PLAN §9):pdf: text layer unusable for <source>/<path> (xml_frac=… nonprint=…) — falling back to N page screenshots.ImportSummarygainspdfs_screenshotted: int = 0(the counter + thelog()field; the syncdetailshape stays untouched — the stats log-only convention). - The regression pin (task 01): the real guide, imported through the REAL importer (the fake LLM with a multimodal
chatoverride for the embedded images), must land clean content — 0/low XML-ish fraction + the real first-page text present. The fixture stays the tar.gz (the test extracts it intotmp_path— never the repo,test_sources/is untracked).
Tasks
01_diagnosis_regression_test.md— the FINDING is documented in this overview; the task pins the real guide's clean import as a regression integration test.02_pdf_text_gate.md—is_usable_pdf_text(the heuristic) + the pre-126-garbage reproduction fixture.03_page_screenshot_fallback.md—render_pdf_pages+_index_pdf_file's fallback branch + the stats counter.04_e2e_garbage_pdf.md— dedicated Playwright E2E, run in isolation (the real guide's happy path + a synthetic garbage PDF's fallback path).
Testing & Quality
- Unit: the gate (clean text, the REAL pre-126 garbage string — reproduced by reading the fixture with the old
errors="replace"recipe, XML-heavy text, empty text, a borderline document about XML);render_pdf_pages(page count, PNG magic bytes, the zero-byte unreadable-page placeholder); the fallback content assembly (separators, placeholders, the digest stability). - Integration: the real-guide regression (task 01).
- E2E: one dedicated Playwright file, run in isolation, mock-only (the
IMAGE_DESCRIPTION_MODEcanned reply is the fallback's vision surface). - Coverage: >90% on new/modified code (
app/).
Completion Criteria
- the real guide imports with clean content through the real pipeline (the regression test green — the owner's exact file)
- a PDF whose text layer is XML/binary garbage (synthetic) lands with the vision-described page content — the owner's specified fallback fires end to end
- a clean PDF NEVER burns a single vision call for the fallback (the gate is the only gate — the happy path is byte-identical to phase 126)
uv run pytest --cov=app --cov-report=term-missinggreen,app/coverage >90%;uv run pytest tests/e2e/test_garbage_pdf.py -v --no-covgreen in isolation- no behavior change in completed phases (the phase-126 clean-PDF path, the image pipeline, the sync)
- the production stale row is fixed by a re-sync (documented above — verify post-deploy: the guide's content no longer shows XML lines)