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:
+422
-3
@@ -14,6 +14,8 @@ for the fail-soft path.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
@@ -985,14 +987,16 @@ def test_import_summary_log_line_includes_summary_counters(
|
||||
"""PLAN §9 summary line: the phase-30 counters sit between
|
||||
``embed_batches`` and ``formats``; the phase-118 backfill counter
|
||||
sits between ``summary_errors`` and ``dates_updated``; the
|
||||
phase-106 date-refresh counter and the phase-122 ``images_failed``
|
||||
counter sit after ``dates_updated``, before ``formats``."""
|
||||
phase-106 date-refresh counter, the phase-122 ``images_failed``
|
||||
counter, and the phase-133 ``pdfs_screenshotted`` counter sit after
|
||||
``dates_updated``, before ``formats``."""
|
||||
s = ImportSummary()
|
||||
s.files, s.added, s.chunks, s.embed_batches = 3, 3, 5, 4
|
||||
s.summaries, s.summary_errors = 2, 1
|
||||
s.summary_backfilled = 1
|
||||
s.dates_updated = 0
|
||||
s.images_failed = 0
|
||||
s.pdfs_screenshotted = 1
|
||||
s.formats = {"md": 1, "yaml": 2}
|
||||
with caplog.at_level(logging.INFO, logger="app.importer"):
|
||||
s.log()
|
||||
@@ -1000,7 +1004,7 @@ def test_import_summary_log_line_includes_summary_counters(
|
||||
assert line == (
|
||||
"import: summary files=3 added=3 updated=0 unchanged=0 pruned=0 errors=0 "
|
||||
"chunks=5 embed_batches=4 summaries=2 summary_errors=1 summary_backfilled=1 "
|
||||
"dates_updated=0 images_failed=0 formats=yaml:2,md:1"
|
||||
"dates_updated=0 images_failed=0 pdfs_screenshotted=1 formats=yaml:2,md:1"
|
||||
)
|
||||
|
||||
|
||||
@@ -1644,3 +1648,418 @@ def test_on_chunk_progress_pdf_branch_reports_content_chunks(
|
||||
# The content embed (the 2 page chunks, one batch) + the excluded
|
||||
# summary-chunk embed — the hook fired for the first only.
|
||||
assert [len(c) for c in llm.calls] == [2, 1]
|
||||
|
||||
|
||||
# ---------- phase 133, task 03: the page-screenshot fallback ----------
|
||||
|
||||
|
||||
class _PageVisionLLM(FakeEmbedder):
|
||||
"""The phase-133 mock VISION client — a STABLE vision model: the
|
||||
MULTIMODAL describe call (``user`` content = a part list — the
|
||||
shape ``describe_image`` sends) gets a canned reply DETERMINED BY
|
||||
THE IMAGE'S BYTES (a short sha256 of the data URL's base64 — the
|
||||
same rendered page always gets the same reply, ACROSS RUNS; the
|
||||
pages' bytes differ, so the replies differ and the page order
|
||||
stays checkable, and the digest-stability test's "stable vision
|
||||
model" premise holds). Text (``lite``) calls keep the
|
||||
``FakeEmbedder`` behaviour. Every vision call's model is recorded
|
||||
(``vision_models``)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.vision_calls = 0
|
||||
self.vision_models: list[str | None] = []
|
||||
|
||||
@staticmethod
|
||||
def reply_for(image_bytes: bytes) -> str:
|
||||
"""The canned reply a vision call for *image_bytes* gets —
|
||||
deterministic in the bytes (tests build the expected content
|
||||
with the same helper)."""
|
||||
return f"Page {hashlib.sha256(image_bytes).hexdigest()[:8]} described."
|
||||
|
||||
async def chat(self, messages, model=None): # pyright: ignore[reportIncompatibleMethodOverride]
|
||||
self.chat_calls.append(list(messages))
|
||||
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
|
||||
if isinstance(user, list):
|
||||
self.vision_calls += 1
|
||||
self.vision_models.append(model)
|
||||
for part in user:
|
||||
if part.get("type") == "image_url":
|
||||
b64 = part["image_url"]["url"].rsplit(",", 1)[1]
|
||||
return self.reply_for(base64.b64decode(b64))
|
||||
raise LLMError("no image part in the multimodal message (test bug)")
|
||||
first = user.split()
|
||||
return "Summary of " + (first[0] if first else "<empty>")
|
||||
|
||||
|
||||
class _FirstPageFailsVisionLLM(FakeEmbedder):
|
||||
"""A vision client that REJECTS the first multimodal call (a dead
|
||||
page's vision request) and then describes — drives the per-page
|
||||
fail-soft (the dead page becomes the placeholder, the next page
|
||||
still gets its description)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.vision_calls = 0
|
||||
|
||||
async def chat(self, messages, model=None): # pyright: ignore[reportIncompatibleMethodOverride]
|
||||
self.chat_calls.append(list(messages))
|
||||
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
|
||||
if isinstance(user, list):
|
||||
self.vision_calls += 1
|
||||
if self.vision_calls == 1:
|
||||
raise LLMError("simulated dead vision call (test sentinel)")
|
||||
return "Page two described fine."
|
||||
first = user.split()
|
||||
return "Summary of " + (first[0] if first else "<empty>")
|
||||
|
||||
|
||||
class _AlwaysFailsVisionLLM(FakeEmbedder):
|
||||
"""A vision client that REJECTS every multimodal call (the dead
|
||||
vision endpoint — the per-page fail-soft's total-failure case:
|
||||
every page becomes the placeholder and the doc lands empty)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.vision_calls = 0
|
||||
|
||||
async def chat(self, messages, model=None): # pyright: ignore[reportIncompatibleMethodOverride]
|
||||
self.chat_calls.append(list(messages))
|
||||
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
|
||||
if isinstance(user, list):
|
||||
self.vision_calls += 1
|
||||
raise LLMError("simulated dead vision endpoint (test sentinel)")
|
||||
first = user.split()
|
||||
return "Summary of " + (first[0] if first else "<empty>")
|
||||
|
||||
|
||||
def _pdf_settings(tmp_path: Path, images: bool = False) -> Settings:
|
||||
"""Settings admitting the ``pdf`` import token (the code DEFAULT set
|
||||
has no ``pdf`` — the phase-133 task-01 trap: a default fake would
|
||||
silently skip the PDF) with the image knobs at their test values
|
||||
(``images`` off unless pinned otherwise — the fallback's own vision
|
||||
calls need no image copy, only the phase-122 embedded-image pass
|
||||
does)."""
|
||||
return Settings(
|
||||
_env_file=None, # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
|
||||
import_extensions="md,txt,pdf",
|
||||
images=images,
|
||||
image_dir=str(tmp_path / "images"),
|
||||
)
|
||||
|
||||
|
||||
def _build_garbage_pdf(path: Path, pages: int = 2) -> None:
|
||||
"""A REAL tiny garbage PDF: every page carries ONLY XML-ish lines
|
||||
(``<foo:pN>i</foo:pN>`` — page-specific tags, so the rendered page
|
||||
PNGs differ and the stable fake's replies do too) — the gate's XML
|
||||
signal fails deterministically (100% of the text lines look like
|
||||
tags; the ``---PAGE-N---`` separators are not tag-shaped). The text
|
||||
layer is real EXTRACTABLE text — this is the FINDING's class (the
|
||||
pre-126 raw-XMP soup), not the empty-layer class."""
|
||||
doc = fitz.open()
|
||||
for p in range(1, pages + 1):
|
||||
page = doc.new_page()
|
||||
for i in range(10):
|
||||
page.insert_text((72, 72 + i * 14), f"<foo:p{p}>{i}</foo:p{p}>")
|
||||
doc.save(path)
|
||||
doc.close()
|
||||
|
||||
|
||||
def _pdf_doc(db, source: str, rel: str) -> Document:
|
||||
doc = db.scalar(
|
||||
select(Document).where(Document.source == source, Document.path == rel)
|
||||
)
|
||||
assert doc is not None, f"no documents row for ({source!r}, {rel!r})"
|
||||
return doc
|
||||
|
||||
|
||||
def test_garbage_pdf_takes_the_page_screenshot_fallback(
|
||||
db, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The owner's specified path end to end: a PDF whose text layer is
|
||||
XML garbage fails the gate, every page is rendered to a PNG and
|
||||
read by the vision model, and the doc lands with the
|
||||
``---PAGE-N---``-anchored page descriptions (PAGE ORDER pinned by
|
||||
the differing canned replies) — the page-aware chunker works
|
||||
unchanged on the separators, the digest is over the FINAL content,
|
||||
and the counter + the loud warning (the FINDING's signature) fire.
|
||||
``is_image`` stays False (it is a PDF document whose content
|
||||
happens to be vision text)."""
|
||||
root = tmp_path / "garbage"
|
||||
root.mkdir()
|
||||
_build_garbage_pdf(root / "garbage.pdf")
|
||||
llm = _PageVisionLLM()
|
||||
llm.settings = _pdf_settings(tmp_path)
|
||||
# The stable fake's expected replies (rendered page bytes → reply;
|
||||
# page order pinned: page i's bytes carry page i's description).
|
||||
pngs = importer.render_pdf_pages(root / "garbage.pdf")
|
||||
d1, d2 = _PageVisionLLM.reply_for(pngs[0]), _PageVisionLLM.reply_for(pngs[1])
|
||||
try:
|
||||
with caplog.at_level(logging.INFO, logger="app.importer"):
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
|
||||
assert summary.pdfs_screenshotted == 1
|
||||
assert llm.vision_calls == 2 # one vision call per page
|
||||
|
||||
doc = _pdf_doc(db, "garbage", "garbage.pdf")
|
||||
assert doc.is_image is False and doc.image_path is None
|
||||
assert doc.content == f"---PAGE-1---\n{d1}\n\n---PAGE-2---\n{d2}"
|
||||
assert d1 != d2, "the pages' bytes differ — so do their replies"
|
||||
# The digest runs over the FINAL (fallback) content — the
|
||||
# phase-126 digest rule, unchanged.
|
||||
assert doc.content_hash == hashlib.sha256(
|
||||
doc.content.encode("utf-8")
|
||||
).hexdigest()
|
||||
chunks = db.scalars(
|
||||
select(Chunk).where(Chunk.document_id == doc.id).order_by(Chunk.position)
|
||||
).all()
|
||||
content_chunks = [c for c in chunks if not c.is_summary]
|
||||
assert [c.content for c in content_chunks] == [
|
||||
f"Page 1:\n{d1}",
|
||||
f"Page 2:\n{d2}",
|
||||
]
|
||||
for c in chunks:
|
||||
assert c.embedding is not None and len(c.embedding) == 768
|
||||
summary_chunks = [c for c in chunks if c.is_summary]
|
||||
assert len(summary_chunks) == 1 and summary_chunks[0].position == -1
|
||||
assert doc.summary is not None
|
||||
|
||||
# The loud warning (PLAN §9, the FINDING's signature) — the
|
||||
# required line, exact.
|
||||
warnings = [
|
||||
r for r in caplog.records if "text layer unusable" in r.getMessage()
|
||||
]
|
||||
assert len(warnings) == 1 and warnings[0].levelno == logging.WARNING
|
||||
assert warnings[0].getMessage() == (
|
||||
"pdf: text layer unusable for garbage/garbage.pdf — "
|
||||
"falling back to 2 page screenshots"
|
||||
)
|
||||
# The vision calls went to the CHAT model (the phase-122 seam).
|
||||
assert llm.vision_models == [llm.settings.llm_chat_model] * 2
|
||||
finally:
|
||||
_cleanup_source(db, "garbage")
|
||||
|
||||
|
||||
def test_clean_pdf_never_burns_a_fallback_vision_call(
|
||||
db, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The gate is the ONLY gate: a clean text layer never reaches the
|
||||
fallback — ``pdfs_screenshotted`` stays 0 and the multimodal chat
|
||||
is NEVER called (the images toggle is off, so the only vision seam
|
||||
in play is the fallback itself; the phase-126 happy path is
|
||||
byte-identical — the extracted text is the content, no fallback
|
||||
warning, no ``Page N —`` placeholders)."""
|
||||
root = tmp_path / "clean"
|
||||
root.mkdir()
|
||||
_build_one_page_pdf(root / "clean.pdf", "Clean prose about the homelab.")
|
||||
llm = _PageVisionLLM()
|
||||
llm.settings = _pdf_settings(tmp_path)
|
||||
try:
|
||||
with caplog.at_level(logging.INFO, logger="app.importer"):
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
|
||||
assert summary.pdfs_screenshotted == 0
|
||||
assert llm.vision_calls == 0
|
||||
doc = _pdf_doc(db, "clean", "clean.pdf")
|
||||
assert doc.content == "---PAGE-1---\nClean prose about the homelab."
|
||||
assert not [
|
||||
r for r in caplog.records if "text layer unusable" in r.getMessage()
|
||||
]
|
||||
finally:
|
||||
_cleanup_source(db, "clean")
|
||||
|
||||
|
||||
def test_fallback_is_fail_soft_per_page(
|
||||
db, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A dead vision call on ONE page → the deterministic placeholder
|
||||
line (``page N — description unavailable``) for that page, the
|
||||
other page still gets its description — the doc still lands (a
|
||||
dead vision endpoint must not sink the doc, the phase-122
|
||||
fail-soft spirit). The vision seam logged its failure (the
|
||||
phase-122 warning); the fallback counted once."""
|
||||
root = tmp_path / "partfail"
|
||||
root.mkdir()
|
||||
_build_garbage_pdf(root / "partfail.pdf", pages=2)
|
||||
llm = _FirstPageFailsVisionLLM()
|
||||
llm.settings = _pdf_settings(tmp_path)
|
||||
try:
|
||||
with caplog.at_level(logging.INFO, logger="app.summarizer"):
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
|
||||
assert summary.pdfs_screenshotted == 1
|
||||
assert llm.vision_calls == 2 # both pages were attempted
|
||||
doc = _pdf_doc(db, "partfail", "partfail.pdf")
|
||||
assert doc.content == (
|
||||
"---PAGE-1---\npage 1 — description unavailable\n\n"
|
||||
"---PAGE-2---\nPage two described fine."
|
||||
)
|
||||
# The phase-122 seam's failure warning (the dead page's signal).
|
||||
assert [
|
||||
r for r in caplog.records if "image description failed" in r.getMessage()
|
||||
]
|
||||
finally:
|
||||
_cleanup_source(db, "partfail")
|
||||
|
||||
|
||||
def test_fallback_all_pages_failed_lands_empty_content(
|
||||
db, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A garbage PDF whose vision calls ALL fail (the dead vision
|
||||
endpoint) → the existing EMPTY-CONTENT path: the doc lands with
|
||||
``""`` (the empty byte string's digest, no content chunks), the
|
||||
fallback was still taken (``pdfs_screenshotted == 1`` — the gate
|
||||
fired), and the phase-126 "no extractable text" warning fires —
|
||||
the doc really is empty. A dead endpoint must not sink the sync."""
|
||||
root = tmp_path / "allfail"
|
||||
root.mkdir()
|
||||
_build_garbage_pdf(root / "allfail.pdf", pages=1)
|
||||
llm = _AlwaysFailsVisionLLM()
|
||||
llm.settings = _pdf_settings(tmp_path)
|
||||
try:
|
||||
with caplog.at_level(logging.INFO, logger="app.importer"):
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
|
||||
assert summary.pdfs_screenshotted == 1
|
||||
assert llm.vision_calls == 1
|
||||
doc = _pdf_doc(db, "allfail", "allfail.pdf")
|
||||
assert doc.content == ""
|
||||
assert doc.content_hash == hashlib.sha256(b"").hexdigest()
|
||||
chunks = db.scalars(
|
||||
select(Chunk).where(Chunk.document_id == doc.id).order_by(Chunk.position)
|
||||
).all()
|
||||
assert [c for c in chunks if not c.is_summary] == []
|
||||
warnings = [
|
||||
r for r in caplog.records if "no extractable text" in r.getMessage()
|
||||
]
|
||||
assert len(warnings) == 1 and warnings[0].levelno == logging.WARNING
|
||||
finally:
|
||||
_cleanup_source(db, "allfail")
|
||||
|
||||
|
||||
def test_fallback_unrenderable_page_gets_the_placeholder(
|
||||
db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A page that cannot be RENDERED (``b""`` at its index — the
|
||||
:func:`render_pdf_pages` fail-soft slot) gets the deterministic
|
||||
placeholder while the good pages keep their descriptions — the
|
||||
page index never shifts (``---PAGE-N---`` stays truthful), and the
|
||||
unrenderable page burns no vision call."""
|
||||
root = tmp_path / "halfblind"
|
||||
root.mkdir()
|
||||
_build_garbage_pdf(root / "halfblind.pdf", pages=3)
|
||||
monkeypatch.setattr(
|
||||
importer,
|
||||
"render_pdf_pages",
|
||||
lambda _path, dpi=150: [b"\x89PNG-page-1", b"", b"\x89PNG-page-3"],
|
||||
)
|
||||
llm = _PageVisionLLM()
|
||||
llm.settings = _pdf_settings(tmp_path)
|
||||
d1 = _PageVisionLLM.reply_for(b"\x89PNG-page-1")
|
||||
d3 = _PageVisionLLM.reply_for(b"\x89PNG-page-3")
|
||||
try:
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert summary.pdfs_screenshotted == 1
|
||||
assert llm.vision_calls == 2 # the blank page burned no vision call
|
||||
doc = _pdf_doc(db, "halfblind", "halfblind.pdf")
|
||||
assert doc.content == (
|
||||
f"---PAGE-1---\n{d1}\n\n"
|
||||
"---PAGE-2---\npage 2 — description unavailable\n\n"
|
||||
f"---PAGE-3---\n{d3}"
|
||||
)
|
||||
finally:
|
||||
_cleanup_source(db, "halfblind")
|
||||
|
||||
|
||||
def test_fallback_rerun_lands_unchanged_no_reembed(
|
||||
db, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Digest stability (the task's re-burn guard): the digest runs
|
||||
over the constructed fallback content — a re-sync of an unchanged
|
||||
garbage PDF (same pages + the deterministic fake's same replies)
|
||||
re-imports as UNCHANGED: the fallback re-runs (the gate + the
|
||||
render are cheap; the vision calls are re-issued — the documented
|
||||
no-cache cost), but the digest MATCHES, so the row is skipped —
|
||||
no re-chunk, no re-embed, no re-summary, no KB change (the row is
|
||||
not ``updated`` and records no changed path). The doc's content
|
||||
and digest are byte-stable across the re-sync."""
|
||||
root = tmp_path / "stable"
|
||||
root.mkdir()
|
||||
_build_garbage_pdf(root / "stable.pdf")
|
||||
llm = _PageVisionLLM()
|
||||
llm.settings = _pdf_settings(tmp_path)
|
||||
try:
|
||||
s1 = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (s1.added, s1.pdfs_screenshotted) == (1, 1)
|
||||
assert llm.vision_calls == 2
|
||||
embeds1 = len(llm.calls)
|
||||
doc1 = _pdf_doc(db, "stable", "stable.pdf")
|
||||
content1, digest1 = doc1.content, doc1.content_hash
|
||||
|
||||
s2 = asyncio.run(import_sources([root], llm, session=db))
|
||||
# The fallback re-ran (gate re-fired, vision re-issued) — but
|
||||
# the row itself re-imported as unchanged (no re-embed, no
|
||||
# re-chunk, no KB change: the stable replies give the same
|
||||
# digest, so the upsert is skipped entirely).
|
||||
assert (s2.added, s2.updated, s2.unchanged) == (0, 0, 1)
|
||||
assert s2.pdfs_screenshotted == 1 # the gate re-fired
|
||||
assert llm.vision_calls == 4 # the vision calls were re-issued
|
||||
assert len(llm.calls) == embeds1, "unchanged: no re-embed"
|
||||
assert s2.changed_paths == [], "unchanged: no KB change recorded"
|
||||
|
||||
doc2 = _pdf_doc(db, "stable", "stable.pdf")
|
||||
assert doc2.content == content1 # byte-stable content
|
||||
assert doc2.content_hash == digest1 # byte-stable digest
|
||||
finally:
|
||||
_cleanup_source(db, "stable")
|
||||
|
||||
|
||||
def test_fallback_embedded_images_still_join_the_pipeline(
|
||||
db, tmp_path: Path
|
||||
) -> None:
|
||||
"""Both, no conflict (the phase design): a fallback PDF with the
|
||||
images toggle ON ALSO runs the phase-126 embedded-image pass — the
|
||||
screenshots are the CONTENT (the fallback's vision calls: one per
|
||||
page), the extracted images are the citation figures (the
|
||||
phase-122 pass's vision calls: one per image). The content keeps
|
||||
the page descriptions; the image doc is separate."""
|
||||
root = tmp_path / "both"
|
||||
root.mkdir()
|
||||
# Garbage text (the gate fires) + an embedded image on page 2
|
||||
# (the phase-126 A3 pass indexes it).
|
||||
doc = fitz.open()
|
||||
page1 = doc.new_page()
|
||||
for i in range(10):
|
||||
page1.insert_text((72, 72 + i * 14), f"<foo:bar>{i}</foo:bar>")
|
||||
page2 = doc.new_page()
|
||||
for i in range(10):
|
||||
page2.insert_text((72, 72 + i * 14), f"<foo:baz>{i}</foo:baz>")
|
||||
pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, 40, 30), 0)
|
||||
pix.clear_with(200)
|
||||
page2.insert_image(fitz.Rect(72, 220, 112, 250), stream=pix.tobytes("png"))
|
||||
doc.save(root / "both.pdf")
|
||||
doc.close()
|
||||
llm = _PageVisionLLM()
|
||||
llm.settings = _pdf_settings(tmp_path, images=True)
|
||||
try:
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
# The PDF (screenshot content) + its one embedded image doc.
|
||||
assert (summary.files, summary.added, summary.errors) == (1, 2, 0)
|
||||
assert summary.pdfs_screenshotted == 1
|
||||
assert summary.images_failed == 0
|
||||
# 2 fallback page descriptions + 1 embedded-image description.
|
||||
assert llm.vision_calls == 3
|
||||
|
||||
pdf_doc = _pdf_doc(db, "both", "both.pdf")
|
||||
pngs = importer.render_pdf_pages(root / "both.pdf")
|
||||
d1 = _PageVisionLLM.reply_for(pngs[0])
|
||||
d2 = _PageVisionLLM.reply_for(pngs[1])
|
||||
assert d1 != d2
|
||||
assert pdf_doc.content == f"---PAGE-1---\n{d1}\n\n---PAGE-2---\n{d2}"
|
||||
# The embedded image (the clear_with(200) 40x30 PNG) gets its
|
||||
# OWN stable reply — the third vision call, the citation figure.
|
||||
image_doc = _pdf_doc(db, "both", "both.pdf#page=2#img=0.png")
|
||||
assert image_doc.is_image is True
|
||||
assert image_doc.content != d1 and image_doc.content != d2
|
||||
finally:
|
||||
_cleanup_source(db, "both")
|
||||
|
||||
+297
-16
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import tarfile
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
@@ -29,12 +30,17 @@ from app.rag import pdf as pdf_mod
|
||||
from app.rag.importer import import_sources
|
||||
from app.rag.llm import EmbeddingError, LLMError
|
||||
from app.rag.pdf import (
|
||||
_XMLISH_LINE_RE,
|
||||
NONPRINT_CHAR_FRACTION,
|
||||
PAGE_SEPARATOR,
|
||||
XMLISH_LINE_FRACTION,
|
||||
PdfImage,
|
||||
_mime_from_image_info,
|
||||
_page_image_entries,
|
||||
extract_pdf_images,
|
||||
extract_pdf_text,
|
||||
is_usable_pdf_text,
|
||||
render_pdf_pages,
|
||||
)
|
||||
from tests.fakes import FakeEmbedder
|
||||
|
||||
@@ -475,13 +481,20 @@ def test_pdf_text_change_updates_doc_and_keeps_unchanged_images(
|
||||
_cleanup_source(db, "pdfchg")
|
||||
|
||||
|
||||
def test_pdf_without_text_still_becomes_a_document(
|
||||
def test_pdf_without_text_takes_the_screenshot_fallback(
|
||||
db, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An image-only PDF (no extractable text) still becomes a document
|
||||
— with empty content (the empty byte string's digest), no content
|
||||
chunks, and a warning (the signal). The summary path still runs
|
||||
(best-effort on the empty content)."""
|
||||
"""Phase 133 (task 03): an image-only PDF (an EMPTY text layer — the
|
||||
gate's other target) takes the page-screenshot fallback: the single
|
||||
page is rendered to a PNG and vision-described (the mock's canned
|
||||
:data:`DESCRIPTION`), and the doc lands with the ``---PAGE-N---``-
|
||||
anchored description as content (chunked + summarized as any other
|
||||
PDF). The phase-126 empty-content behavior is retired for layers
|
||||
that CAN be screenshotted — it survives only for an unopenable
|
||||
file (``test_corrupt_pdf_file_becomes_an_empty_document``), where
|
||||
the render fails too. The loud fallback warning fires; the phase-
|
||||
126 "no extractable text" warning does NOT (the doc is not empty).
|
||||
"""
|
||||
root = tmp_path / "pdfempty"
|
||||
root.mkdir()
|
||||
_build_pdf(root / "pics.pdf", [("", [_png_bytes(40, 30)])])
|
||||
@@ -490,41 +503,61 @@ def test_pdf_without_text_still_becomes_a_document(
|
||||
with caplog.at_level(logging.INFO, logger="app.importer"):
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (summary.files, summary.added) == (1, 1)
|
||||
assert summary.pdfs_screenshotted == 1
|
||||
doc = _doc(db, "pdfempty", "pics.pdf")
|
||||
assert doc.content == ""
|
||||
assert doc.content_hash == hashlib.sha256(b"").hexdigest()
|
||||
assert doc.content == f"{PAGE_SEPARATOR.format(page=1)}\n{DESCRIPTION}"
|
||||
assert doc.content_hash == hashlib.sha256(doc.content.encode("utf-8")).hexdigest()
|
||||
chunks = db.scalars(
|
||||
select(Chunk).where(Chunk.document_id == doc.id).order_by(Chunk.position)
|
||||
).all()
|
||||
assert [c for c in chunks if not c.is_summary] == []
|
||||
assert doc.summary is not None # best-effort on the empty content
|
||||
content_chunks = [c for c in chunks if not c.is_summary]
|
||||
assert [c.content for c in content_chunks] == [f"Page 1:\n{DESCRIPTION}"]
|
||||
assert doc.summary is not None
|
||||
# The loud fallback warning (the FINDING's signature) fired —
|
||||
# and the phase-126 "no extractable text" warning did NOT (the
|
||||
# doc is not empty — the screenshots ARE the content).
|
||||
warnings = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if "no extractable text" in r.getMessage()
|
||||
r for r in caplog.records if "text layer unusable" in r.getMessage()
|
||||
]
|
||||
assert len(warnings) == 1 and warnings[0].levelno == logging.WARNING
|
||||
assert "falling back to 1 page screenshots" in warnings[0].getMessage()
|
||||
assert not [
|
||||
r for r in caplog.records if "no extractable text" in r.getMessage()
|
||||
]
|
||||
finally:
|
||||
_cleanup_source(db, "pdfempty")
|
||||
|
||||
|
||||
def test_corrupt_pdf_file_becomes_an_empty_document(db, tmp_path: Path) -> None:
|
||||
def test_corrupt_pdf_file_becomes_an_empty_document(
|
||||
db, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A .pdf file that is not a PDF at all: the parser's fail-soft
|
||||
contract (no text, no images) — the document lands with empty
|
||||
content and no chunks, and the sync stays green."""
|
||||
contract (no text, no images) AND the phase-133 fallback's
|
||||
render-less branch (``render_pdf_pages`` → ``[]`` → the helper's
|
||||
``""``) — the document lands with empty content (the empty byte
|
||||
string's digest) and no chunks, the fallback is still counted
|
||||
(the gate fired, the render produced nothing), and the phase-126
|
||||
"no extractable text" warning fires — the doc really is empty."""
|
||||
root = tmp_path / "pdfbad"
|
||||
root.mkdir()
|
||||
(root / "broken.pdf").write_bytes(b"this is not a pdf at all")
|
||||
llm = _pdf_llm(tmp_path)
|
||||
try:
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
with caplog.at_level(logging.INFO, logger="app.importer"):
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
|
||||
assert summary.pdfs_screenshotted == 1 # the gate fired
|
||||
doc = _doc(db, "pdfbad", "broken.pdf")
|
||||
assert doc.content == "" and doc.title == "broken"
|
||||
assert doc.content_hash == hashlib.sha256(b"").hexdigest()
|
||||
chunks = db.scalars(
|
||||
select(Chunk).where(Chunk.document_id == doc.id).order_by(Chunk.position)
|
||||
).all()
|
||||
assert [c for c in chunks if not c.is_summary] == []
|
||||
warnings = [
|
||||
r for r in caplog.records if "no extractable text" in r.getMessage()
|
||||
]
|
||||
assert len(warnings) == 1 and warnings[0].levelno == logging.WARNING
|
||||
finally:
|
||||
_cleanup_source(db, "pdfbad")
|
||||
|
||||
@@ -1074,3 +1107,251 @@ def test_page_image_entries_falls_back_on_old_pymupdf() -> None:
|
||||
)
|
||||
entries = _page_image_entries(page)
|
||||
assert entries == [{"xref": 7, "width": 40, "height": 30}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_usable_pdf_text (phase 133 task 02 — the deterministic garbage gate)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The owner's untracked fixture (the stable reference — the same
|
||||
#: provenance as the phase-133 task-01 integration regression pin:
|
||||
#: the tarball, never the extracted PDF, is what the test starts
|
||||
#: from, and it never touches the repo).
|
||||
TARBALL = Path(__file__).resolve().parents[2] / "test_sources" / "hnb_2026_guide.tar.gz"
|
||||
|
||||
#: The inner file's name (it keeps its name through extraction — the
|
||||
#: task-01 convention).
|
||||
PDF_NAME = "HBI-2026-Guide-Year-Round_260704_131221.pdf"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pre126_garbage_text(tmp_path_factory: pytest.TempPathFactory) -> str:
|
||||
"""THE REAL GARBAGE (the task's airtight pin, module-scoped):
|
||||
the owner's exact PDF read with the PRE-PHASE-126 recipe —
|
||||
``read_text(encoding="utf-8", errors="replace")`` +
|
||||
``.replace("\\x00", "")`` — the 46%-XML-ish, U+FFFD-heavy soup
|
||||
that was chunked and embedded before the parser shipped (the
|
||||
FINDING: 138,170 non-empty lines, ~46% XML-ish, the owner's
|
||||
``<illustrator:StartupProfile>`` preview). The tarball's single
|
||||
member is extracted into a temp dir (the task-01 integration
|
||||
pin's pattern, shared — the fixture stays out of the repo)."""
|
||||
if not TARBALL.is_file():
|
||||
pytest.skip(
|
||||
"test_sources/hnb_2026_guide.tar.gz is missing — the "
|
||||
"untracked owner fixture (2.6 MB, one 48-page Illustrator PDF)"
|
||||
)
|
||||
root = tmp_path_factory.mktemp("pre126")
|
||||
with tarfile.open(TARBALL, "r:gz") as tar:
|
||||
tar.extractall(root, filter="data")
|
||||
member = root / PDF_NAME
|
||||
return member.read_text(encoding="utf-8", errors="replace").replace("\x00", "")
|
||||
|
||||
|
||||
def test_gate_clean_text_with_page_separators_is_usable() -> None:
|
||||
"""Real prose + the phase-126 ``---PAGE-N---`` separators: the
|
||||
happy path the gate must NEVER block (a clean PDF never burns a
|
||||
vision call — the gate is the only gate, and the happy path stays
|
||||
byte-identical to phase 126)."""
|
||||
clean = (
|
||||
f"{PAGE_SEPARATOR.format(page=1)}\n2026 U.S. Benefits Guide\n"
|
||||
"\n"
|
||||
"Covered employees receive comprehensive medical, dental, and\n"
|
||||
"vision benefits under this plan document.\n"
|
||||
"\n"
|
||||
f"{PAGE_SEPARATOR.format(page=2)}\nPremiums are deducted from\n"
|
||||
"each paycheck; the plan year runs January 1 through\n"
|
||||
"December 31."
|
||||
)
|
||||
assert is_usable_pdf_text(clean) is True
|
||||
|
||||
|
||||
def test_gate_empty_text_layer_is_unusable() -> None:
|
||||
"""The empty text layer — a scanned/image-only PDF has nothing to
|
||||
read — is the fallback's other target."""
|
||||
assert is_usable_pdf_text("") is False
|
||||
assert is_usable_pdf_text(" \n\t\n \r\n") is False
|
||||
|
||||
|
||||
def test_gate_xml_heavy_layer_is_unusable() -> None:
|
||||
"""The raw-XMP / binary-soup signature: ≥ 30% of the non-empty
|
||||
lines look like tags (``<tag>``, ``</tag>``, ``<tag attr=`` — the
|
||||
regex covers them all, including the PDF-structure ``<</Key…``
|
||||
noise)."""
|
||||
tags = [f"<xmp:Prop{i}>value</xmp:Prop{i}>" for i in range(30)]
|
||||
prose = [f"Line {i} of ordinary prose." for i in range(70)]
|
||||
assert is_usable_pdf_text("\n".join(tags + prose)) is False # 30/100
|
||||
|
||||
|
||||
def test_gate_borderline_document_about_xml_stays_usable() -> None:
|
||||
"""ASSUMPTION A8's edge from below: a real document ABOUT XML
|
||||
whose tag-like lines stay UNDER the 0.30 bar (29%) is usable —
|
||||
the documented false-positive cost is paid only at/above the bar."""
|
||||
tags = [f"<example:{i}>text</example:{i}>" for i in range(29)]
|
||||
prose = [
|
||||
f"Paragraph {i} explains the markup in plain words."
|
||||
for i in range(71)
|
||||
]
|
||||
assert is_usable_pdf_text("\n".join(tags + prose)) is True # 29/100
|
||||
|
||||
|
||||
def test_gate_at_and_above_the_xml_bar_is_unusable() -> None:
|
||||
"""The threshold's edges from above: EXACTLY 30% tag-like lines is
|
||||
already unusable (the bar is ≥, not >), and 31% is too — a document
|
||||
ABOUT XML that reaches the bar pays the documented ASSUMPTION A8
|
||||
cost (the fallback still extracts it correctly via vision)."""
|
||||
for n in (30, 31):
|
||||
tags = [f"<example:{i}>text</example:{i}>" for i in range(n)]
|
||||
prose = [
|
||||
f"Paragraph {i} of ordinary explanation."
|
||||
for i in range(100 - n)
|
||||
]
|
||||
assert is_usable_pdf_text("\n".join(tags + prose)) is False
|
||||
|
||||
|
||||
def test_gate_replacement_char_soup_is_unusable() -> None:
|
||||
"""The SECOND signal, alone: a ≥ 5% U+FFFD load (the
|
||||
``errors="replace"`` binary-soup signal) is unusable even with few
|
||||
tag-like lines — the XML-ish gate does not mask it, and this soup
|
||||
has none (the two failure signals fire independently)."""
|
||||
soup = "Benefits summary paragraph. " * 24 + "\ufffd" * 60 # ~7.9%
|
||||
assert is_usable_pdf_text(soup) is False
|
||||
|
||||
|
||||
def test_gate_structural_newlines_and_tabs_are_printable() -> None:
|
||||
"""The documented exclusion: the ``\\n``/``\\t``/``\\r`` the
|
||||
extractor legitimately emits are NOT non-printable — a
|
||||
newline/tab/indent-heavy document is printable (counting them
|
||||
would flag every prose document)."""
|
||||
text = "\n\t".join(f"Indented line {i} with a tab." for i in range(20))
|
||||
assert is_usable_pdf_text(text) is True
|
||||
|
||||
|
||||
def test_gate_real_pre126_garbage_is_unusable(
|
||||
pre126_garbage_text: str,
|
||||
) -> None:
|
||||
"""THE REAL GARBAGE (the airtight pin): the owner's exact file,
|
||||
read with the pre-126 recipe (the module fixture) — the
|
||||
reproduced 46%-XML-ish, U+FFFD-heavy soup — must be flagged
|
||||
unusable. Both signals fire on the REAL string (the FINDING's
|
||||
evidence, pinned against the named thresholds: if the owner ever
|
||||
re-cuts the fixture, the drift shows up here — the pin has teeth,
|
||||
the same contract as the task-01 suite)."""
|
||||
assert is_usable_pdf_text(pre126_garbage_text) is False
|
||||
# The owner's exact preview line is in the reproduced soup.
|
||||
assert "<illustrator:StartupProfile>" in pre126_garbage_text
|
||||
lines = [line for line in pre126_garbage_text.splitlines() if line.strip()]
|
||||
xml_frac = sum(1 for line in lines if _XMLISH_LINE_RE.search(line)) / len(lines)
|
||||
nonprint_frac = sum(
|
||||
1
|
||||
for ch in pre126_garbage_text
|
||||
if ch == "\ufffd" or (ord(ch) < 32 and ch not in "\t\n\r")
|
||||
) / len(pre126_garbage_text)
|
||||
# Both signals independently clear their named bars on the real
|
||||
# string — neither signal can silently die.
|
||||
assert xml_frac >= XMLISH_LINE_FRACTION
|
||||
assert nonprint_frac >= NONPRINT_CHAR_FRACTION
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# render_pdf_pages (phase 133 task 03 — the fallback's visual input)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _two_page_text_pdf(path: Path) -> Path:
|
||||
"""A 2-page temp PDF with EXPLICIT letter pages (612x792pt —
|
||||
deterministic render sizes: 1275x1650 px at 150 DPI, 612x792 at
|
||||
72 DPI) and page-specific text (the render must be page-specific,
|
||||
so the pages' PNG bytes must differ)."""
|
||||
doc = fitz.open()
|
||||
doc.new_page(width=612, height=792).insert_text((72, 72), "First page marker text.")
|
||||
doc.new_page(width=612, height=792).insert_text((72, 72), "Second page marker text.")
|
||||
doc.save(path)
|
||||
doc.close()
|
||||
return path
|
||||
|
||||
|
||||
def test_render_pdf_pages_returns_one_png_per_page_in_order(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A 2-page PDF → 2 PNGs in PAGE ORDER (list index i is page i+1),
|
||||
each a genuine decodable PNG at the default 150-DPI render (612x792pt
|
||||
letter → 1275x1650 px), and page-specific (page 1's bytes != page
|
||||
2's — different text on the page)."""
|
||||
path = _two_page_text_pdf(tmp_path / "two.pdf")
|
||||
pages = render_pdf_pages(path)
|
||||
assert len(pages) == 2
|
||||
for png in pages:
|
||||
assert png.startswith(PNG_MAGIC)
|
||||
pix = fitz.Pixmap(png)
|
||||
assert (pix.width, pix.height) == (1275, 1650)
|
||||
assert pages[0] != pages[1] # the render is page-specific
|
||||
|
||||
|
||||
def test_render_pdf_pages_honors_the_dpi_argument(tmp_path: Path) -> None:
|
||||
"""ASSUMPTION A8's fixed 150 DPI is the DEFAULT; an explicit *dpi*
|
||||
changes the render (72 DPI → the page's natural 612x792 px)."""
|
||||
path = _two_page_text_pdf(tmp_path / "two.pdf")
|
||||
pix = fitz.Pixmap(render_pdf_pages(path, dpi=72)[0])
|
||||
assert (pix.width, pix.height) == (612, 792)
|
||||
|
||||
|
||||
def test_render_pdf_pages_unopenable_file_returns_empty_list(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A file that cannot be OPENED yields ``[]`` (the caller's existing
|
||||
empty-content path — the doc lands with the no-extractable-text
|
||||
behavior, unchanged) + a warning (the fail-soft style)."""
|
||||
bad = tmp_path / "bad.pdf"
|
||||
bad.write_bytes(b"this is not a pdf at all")
|
||||
with caplog.at_level(logging.WARNING, logger="app.rag.pdf"):
|
||||
assert render_pdf_pages(bad) == []
|
||||
assert render_pdf_pages(tmp_path / "missing.pdf") == []
|
||||
warnings = [
|
||||
r for r in caplog.records if "could not open" in r.getMessage()
|
||||
]
|
||||
assert len(warnings) == 2 and all(w.levelno == logging.WARNING for w in warnings)
|
||||
|
||||
|
||||
def test_render_pdf_pages_zero_page_doc_returns_empty_list(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
||||
) -> None:
|
||||
"""A zero-page PDF (openable, no pages — this PyMuPDF refuses to
|
||||
SAVE such a file, so it is stubbed) yields ``[]`` — the loop runs
|
||||
zero times, nothing renders."""
|
||||
path = tmp_path / "zero.pdf"
|
||||
path.write_bytes(b"%PDF-1.4 fake")
|
||||
monkeypatch.setattr(pdf_mod.fitz, "open", lambda _p: _FakeDoc([]))
|
||||
assert render_pdf_pages(path) == []
|
||||
|
||||
|
||||
def test_render_pdf_pages_broken_page_is_zero_bytes_at_its_index(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
||||
) -> None:
|
||||
"""A page whose get_pixmap raises → ``b""`` at ITS index (the
|
||||
caller's placeholder slot — the page index NEVER shifts, so
|
||||
``---PAGE-N---`` stays truthful); the good pages keep their PNGs
|
||||
at their own indices, and the failure is logged (the
|
||||
:func:`extract_pdf_text` fail-soft style)."""
|
||||
path = tmp_path / "fake.pdf"
|
||||
path.write_bytes(b"%PDF-1.4 fake")
|
||||
|
||||
class _RenderPage:
|
||||
def __init__(self, data: bytes | None) -> None:
|
||||
self._data = data
|
||||
|
||||
def get_pixmap(self, dpi: int) -> Any:
|
||||
if self._data is None:
|
||||
raise RuntimeError("boom: page render failed")
|
||||
return SimpleNamespace(tobytes=lambda _fmt: self._data)
|
||||
|
||||
doc = _FakeDoc(
|
||||
[
|
||||
_RenderPage(b"\x89PNG-fake-page-1"),
|
||||
_RenderPage(None),
|
||||
_RenderPage(b"\x89PNG-fake-page-3"),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(pdf_mod.fitz, "open", lambda _p: doc)
|
||||
assert render_pdf_pages(path) == [
|
||||
b"\x89PNG-fake-page-1", b"", b"\x89PNG-fake-page-3",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user