# 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 `Web`. 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):** 1. 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 × `>`, `Web`, exactly the owner's preview) — and that garbage was chunked + embedded. 4. **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 `updated` with clean content (deploy phase 126 first — it is already on `main`). 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.py` gains `is_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"=]", line)` — covers ``, ``, ` list[bytes]` (one PNG per page, PyMuPDF `page.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 stored `content` = `PAGE_SEPARATOR` blocks of the page descriptions (`---PAGE-N---\n`) — the SAME separator convention as the text path, so the chunker's page-boundary handling works unchanged. `is_image` stays `False` (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 / (xml_frac=… nonprint=…) — falling back to N page screenshots`. `ImportSummary` gains `pdfs_screenshotted: int = 0` (the counter + the `log()` field; the sync `detail` shape 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 `chat` override 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 into `tmp_path` — never the repo, `test_sources/` is untracked). ## Tasks 1. `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. 2. `02_pdf_text_gate.md` — `is_usable_pdf_text` (the heuristic) + the pre-126-garbage reproduction fixture. 3. `03_page_screenshot_fallback.md` — `render_pdf_pages` + `_index_pdf_file`'s fallback branch + the stats counter. 4. `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_MODE` canned 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-missing` green, `app/` coverage >90%; `uv run pytest tests/e2e/test_garbage_pdf.py -v --no-cov` green 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)