phase: 133_pdf_screenshot_fallback
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`.
This commit is contained in:
+106
@@ -18,6 +18,16 @@ package, imported as ``fitz`` — its stable API name).
|
||||
MIME type: the embedded file extension (``ext``) first, then the
|
||||
image type code (``type``: 2=JPEG, 3=PNG, 4=GIF, 8=TIFF, 9=WebP),
|
||||
else the ``application/octet-stream`` fallback.
|
||||
* :func:`is_usable_pdf_text` — the phase-133 garbage gate: a pure,
|
||||
deterministic predicate over the extracted text layer (no PDF, no
|
||||
I/O) — REAL content vs. garbage (the pre-126 XML/binary soup, the
|
||||
empty text layer). It is the single trigger for the page-screenshot
|
||||
vision fallback (phase 133 task 03).
|
||||
* :func:`render_pdf_pages` — the fallback's visual input (phase 133
|
||||
task 03): one PNG per page (150 DPI, page order); an unreadable page
|
||||
yields ``b""`` (the caller's placeholder — the page index never
|
||||
shifts), an unopenable file yields ``[]`` (the caller's existing
|
||||
empty-content path).
|
||||
|
||||
Every failure mode is fail-soft: an unreadable file returns an empty
|
||||
result, an unreadable page or image is logged and skipped — this module
|
||||
@@ -26,6 +36,7 @@ never raises for malformed input (the importer owns the loud failures).
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -142,6 +153,101 @@ def extract_pdf_text(pdf_path: Path) -> str:
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
#: 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/>=]")
|
||||
|
||||
#: Garbage-gate thresholds (phase 133 task 02): an extracted text
|
||||
#: layer is UNUSABLE when its XML-ish line fraction reaches this bar.
|
||||
#: The FINDING's reproduced pre-126 read of the owner's file is 46%
|
||||
#: XML-ish; a real document ABOUT XML that reaches the bar is a
|
||||
#: documented false positive (ASSUMPTION A8 — the fallback still
|
||||
#: extracts it via vision; the cost is a few vision calls).
|
||||
XMLISH_LINE_FRACTION = 0.30
|
||||
|
||||
#: Garbage-gate thresholds (phase 133 task 02): the non-printable
|
||||
#: fraction (U+FFFD replacement chars + C0 controls over the whole
|
||||
#: text) at which the layer is UNUSABLE — the ``errors="replace"``
|
||||
#: binary-soup signal (the pre-126 read is ~19% non-printable; real
|
||||
#: extracted text is ~0.05%).
|
||||
NONPRINT_CHAR_FRACTION = 0.05
|
||||
|
||||
|
||||
def is_usable_pdf_text(text: str) -> bool:
|
||||
"""Whether an extracted PDF text layer is REAL content (not garbage).
|
||||
|
||||
Unusable (→ the page-screenshot fallback) when ANY of:
|
||||
|
||||
* ``text.strip() == ""`` — the empty text layer (a scanned/image-
|
||||
only PDF has nothing to read);
|
||||
* ≥ :data:`XMLISH_LINE_FRACTION` (0.30) of the non-empty lines
|
||||
match :data:`_XMLISH_LINE_RE` — the raw-XMP / binary-soup
|
||||
signature (the FINDING's reproduced pre-126 read is 46% —
|
||||
``read_text(errors="replace")`` on the owner's file left
|
||||
``<illustrator:StartupProfile>Web</illustrator:StartupProfile>``
|
||||
lines in the preview);
|
||||
* ≥ :data:`NONPRINT_CHAR_FRACTION` (0.05) of all characters are
|
||||
non-printable — the ``errors="replace"`` binary-soup signal.
|
||||
The non-printable set is ``U+FFFD`` + C0 control chars
|
||||
(0x00–0x1F) EXCEPT the structural ``\\t``/``\\n``/``\\r`` the
|
||||
extractor legitimately emits (the explicit set is deliberate: a
|
||||
``unicodedata`` Cc-category check would catch those too, and
|
||||
counting them would flag every prose document).
|
||||
|
||||
Pure + deterministic (no PDF, no I/O) — the unit tests pin the
|
||||
thresholds against the REAL reproduced garbage string, not a
|
||||
hand-drawn one. A real document ABOUT XML whose tag-like lines
|
||||
reach 30% is a documented false positive (ASSUMPTION A8 — the
|
||||
fallback still extracts it correctly via vision; the cost is a
|
||||
few vision calls).
|
||||
"""
|
||||
if not text.strip():
|
||||
return False
|
||||
non_empty_lines = [line for line in text.splitlines() if line.strip()]
|
||||
xml_ish = sum(1 for line in non_empty_lines if _XMLISH_LINE_RE.search(line))
|
||||
if xml_ish / len(non_empty_lines) >= XMLISH_LINE_FRACTION:
|
||||
return False
|
||||
non_printable = sum(
|
||||
1
|
||||
for ch in text
|
||||
if ch == "\ufffd" or (ord(ch) < 32 and ch not in "\t\n\r")
|
||||
)
|
||||
return non_printable / len(text) < NONPRINT_CHAR_FRACTION
|
||||
|
||||
|
||||
def render_pdf_pages(pdf_path: Path, dpi: int = 150) -> list[bytes]:
|
||||
"""Render every page of *pdf_path* to a PNG (page order — list index
|
||||
*i* is page *i*+1).
|
||||
|
||||
The fallback's visual input (phase 133 task 03, ASSUMPTION A8:
|
||||
fixed 150 DPI — the balance of vision legibility vs data-URL
|
||||
payload). A page that cannot be rendered yields ``b""`` (the
|
||||
caller's placeholder — the page index NEVER shifts, so
|
||||
``---PAGE-N---`` stays truthful; the failure is logged, the
|
||||
:func:`extract_pdf_text` fail-soft style). A file that cannot be
|
||||
OPENED yields ``[]`` (the caller's existing empty-content path —
|
||||
the doc lands with the no-extractable-text behavior, unchanged).
|
||||
"""
|
||||
try:
|
||||
doc = fitz.open(str(pdf_path))
|
||||
except Exception as exc:
|
||||
logger.warning("pdf: could not open %s for page rendering — %s", pdf_path, exc)
|
||||
return []
|
||||
|
||||
pages: list[bytes] = []
|
||||
with doc:
|
||||
for page_num in range(len(doc)):
|
||||
try:
|
||||
pages.append(doc[page_num].get_pixmap(dpi=dpi).tobytes("png"))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"pdf: could not render page %d of %s — %s",
|
||||
page_num + 1, pdf_path, exc,
|
||||
)
|
||||
pages.append(b"")
|
||||
return pages
|
||||
|
||||
|
||||
def extract_pdf_images(pdf_path: Path) -> list[PdfImage]:
|
||||
"""Extract all embedded images from *pdf_path*.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user