phase: 140_pictorial_pdf_pages
Build and Push Containers / build-and-push-app (push) Successful in 2m26s
Build and Push Containers / build-and-push-db (push) Successful in 12s

Everything is verified green. Final report:

## Phase 140 final verification pass — report

- Verified all 4 tasks already complete (code in working tree, task files in `complete/`): `pictorial_pages` gate + 2 settings (`BOR_PDF_PICTORIAL`, `BOR_PDF_PICTORIAL_MIN_CHARS` + validator), `PICTORIAL_PAGE_MODE`/`describe_pdf_page` seam + `render_pdf_pages` page selector + mock branch, `_pictorial_pdf_pass` in `_index_pdf_file` with log-only counters, dedicated E2E. No code changes needed.
- Test results (exact commands):
  - `uv run pytest --cov=app --cov-report=term-missing` → **3320 passed, 11 skipped, 0 failed; TOTAL 99%** (>90% gate)
  - `uv run pytest tests/e2e/test_pictorial_pdf.py -v --no-cov` → **3 passed in isolation** (Playwright, mock-only)
  - Pre-existing suites unedited: `test_pdf_support.py` 6 passed; `test_garbage_pdf.py` 2 passed + 1 skipped (owner fixture); `test_pdf_image_context.py` 4 passed
  - `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
- Completion criteria:
  1. IKEA tar import repro — pinned by 2 integration tests; **skipped loudly here** (`test_sources/ikea_instructions.tar` absent on this machine — verified repo-wide; same house convention as completed phase-133 HNB tests; runs real on the owner's machine). Equivalent logic fully covered by unit pins on synthetic IKEA-class (vector-drawn, zero-raster) PDFs.
  2. Chat grounded on described page — **green** (E2E test b: phrase-only-in-illustration question, PDF in `done.sources`, answer echoes the block)
  3. Clean-text no-op (zero vision calls) + garbage precedence pin — **green** (`test_zero_candidates_is_a_noop` unit; `test_garbage_pdf_with_pictorial_on_takes_the_fallback_not_the_pass` integration, verified alone)
  4. `BOR_PDF_PICTORIAL=0` byte-identical — **green** (unit pin on synthetic PDF; IKEA variant runs on owner's machine)
  5. Full gate + E2E isolation + pre-existing suites — **green** (above)
  6. ruff + pyright — **clean**
  7. Commit/phase-move — delegated to harness per run rules (left in working tree: 16 modified + 8 new files, uncommitted)
- Deviations: none beyond the expected owner-fixture skips (11, all pre-existing fixture-missing skips, including 2 IKEA).
- Next pending phase: **none** — `todo/` holds only 140; 139 is already complete.
This commit is contained in:
2026-09-30 19:16:35 -04:00
parent a86544a083
commit 9633eb7653
45 changed files with 3895 additions and 36 deletions
+20 -4
View File
@@ -989,8 +989,10 @@ def test_import_summary_log_line_includes_summary_counters(
sits between ``summary_errors`` and ``dates_updated``; the
phase-106 date-refresh counter, the phase-122 ``images_failed``
counter, the phase-137 ``image_context_backfilled`` counter (next
to ``images_failed``), and the phase-133 ``pdfs_screenshotted``
counter sit after ``dates_updated``, before ``formats``."""
to ``images_failed``), the phase-133 ``pdfs_screenshotted``
counter, and the phase-140 ``pdfs_pictorial`` /
``pdf_pictorial_pages`` counters (next to ``pdfs_screenshotted``)
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
@@ -999,6 +1001,8 @@ def test_import_summary_log_line_includes_summary_counters(
s.images_failed = 0
s.image_context_backfilled = 2
s.pdfs_screenshotted = 1
s.pdfs_pictorial = 2
s.pdf_pictorial_pages = 5
s.formats = {"md": 1, "yaml": 2}
with caplog.at_level(logging.INFO, logger="app.importer"):
s.log()
@@ -1007,7 +1011,8 @@ def test_import_summary_log_line_includes_summary_counters(
"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 image_context_backfilled=2 "
"pdfs_screenshotted=1 formats=yaml:2,md:1"
"pdfs_screenshotted=1 pdfs_pictorial=2 pdf_pictorial_pages=5 "
"formats=yaml:2,md:1"
)
@@ -1636,6 +1641,11 @@ def test_on_chunk_progress_pdf_branch_reports_content_chunks(
images=True,
image_dir=str(tmp_path / "images"),
import_extensions="md,txt,pdf",
# Phase 140: the pre-phase-140 shape pin — this fixture's pages
# are sparse (the default-ON pictorial pass would append
# illustration blocks and add vision calls); the pass is
# covered in tests/unit/test_pictorial_import.py.
pdf_pictorial=False,
)
try:
summary = asyncio.run(
@@ -1742,12 +1752,18 @@ def _pdf_settings(tmp_path: Path, images: bool = False) -> Settings:
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)."""
does). Phase 140: ``pdf_pictorial`` is pinned OFF — these suites
pin the PRE-phase-140 content shape (their fixture pages are
sparse — under the 100-char bar — so the default-ON pictorial
pass would append illustration blocks and move the byte-exact
pins; ``tests/unit/test_pictorial_import.py`` covers the pass
itself, with the kill switch in both states)."""
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"),
pdf_pictorial=False,
)
+7 -1
View File
@@ -155,11 +155,17 @@ def _pdf_llm[
"""A fake LLM whose settings admit the ``pdf`` import token and
carry the phase-122 image knobs (the image dir under *tmp_path*);
*llm_cls* may be a mock vision client (the ``FakeEmbedder``
subclass carrying extra state such as ``chat_models``)."""
subclass carrying extra state such as ``chat_models``). Phase 140:
``pdf_pictorial`` is pinned OFF — this suite pins the PRE-phase-140
content shape (its fixture pages are sparse — under the 100-char
bar — so the default-ON pictorial pass would append illustration
blocks and move the byte-exact pins; the pass is covered in
tests/unit/test_pictorial_import.py)."""
kwargs.setdefault("_env_file", None)
kwargs.setdefault("images", images)
kwargs.setdefault("image_dir", str(tmp_path / "images"))
kwargs.setdefault("import_extensions", "md,txt,pdf")
kwargs.setdefault("pdf_pictorial", False)
llm = llm_cls() # pyright: ignore[reportCallIssue]
llm.settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return llm
+196
View File
@@ -0,0 +1,196 @@
"""Unit tests: the pictorial PDF gate (phase 140, task 01, LOCKED A1/A3).
Covers the two new settings (``pdf_pictorial`` — the house kill-switch,
default ON — and ``pdf_pictorial_min_chars`` — the sparseness bar with
its fail-loud validator) and the pure, deterministic sparse-page gate
:func:`app.rag.pdf.pictorial_pages`: the ``<``/``=``/``>`` boundary,
whitespace-only pages, the empty-dict no-op, ascending order, and a
large dict. The IKEA docstring example is pinned against the named
threshold.
"""
from __future__ import annotations
from typing import Any
import pytest
from pydantic import ValidationError
from app.config import Settings
from app.rag.pdf import pictorial_pages
def _settings(**kwargs: Any) -> Settings:
"""Build Settings without reading a .env file (deterministic tests)."""
kwargs.setdefault("_env_file", None)
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
# ---------------------------------------------------------------------------
# The settings — defaults + env overrides
# ---------------------------------------------------------------------------
def test_pdf_pictorial_defaults_on_and_env_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""LOCKED A3: the pass is ON by default (the owner-confirmed
2026-09-30 default); ``BOR_PDF_PICTORIAL`` is the house kill-switch
(``0``/``false`` disables the pass entirely — the ``BOR_IMAGES``
naming pattern)."""
monkeypatch.delenv("BOR_PDF_PICTORIAL", raising=False)
assert _settings().pdf_pictorial is True
monkeypatch.setenv("BOR_PDF_PICTORIAL", "0")
assert _settings().pdf_pictorial is False
monkeypatch.setenv("BOR_PDF_PICTORIAL", "false")
assert _settings().pdf_pictorial is False
monkeypatch.setenv("BOR_PDF_PICTORIAL", "1")
assert _settings().pdf_pictorial is True
def test_pdf_pictorial_min_chars_defaults_to_100_and_env_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""LOCKED A1: the default sparseness bar is 100 stripped chars
(the IKEA fixture's sparse pages run 4–73, its text-heavy pages
3,091+ — 100 sits between the classes); ``BOR_PDF_PICTORIAL_MIN_CHARS``
overrides it."""
monkeypatch.delenv("BOR_PDF_PICTORIAL_MIN_CHARS", raising=False)
assert _settings().pdf_pictorial_min_chars == 100
monkeypatch.setenv("BOR_PDF_PICTORIAL_MIN_CHARS", "50")
assert _settings().pdf_pictorial_min_chars == 50
monkeypatch.setenv("BOR_PDF_PICTORIAL_MIN_CHARS", "0")
assert _settings().pdf_pictorial_min_chars == 0
# ---------------------------------------------------------------------------
# The validators — the fail-loud pattern
# ---------------------------------------------------------------------------
def test_pdf_pictorial_min_chars_zero_is_legal() -> None:
"""PLAN A21: ``0`` is the every-page-is-a-candidate mode (the LLM
calls are free) — it must NOT raise."""
assert _settings(pdf_pictorial_min_chars=0).pdf_pictorial_min_chars == 0
def test_pdf_pictorial_min_chars_default_is_legal() -> None:
"""The default (100) is trivially legal — the constructor must
accept it (guards a validator regression that would block startup)."""
assert _settings().pdf_pictorial_min_chars == 100
def test_pdf_pictorial_min_chars_negative_fails_loud_naming_the_field() -> None:
"""A negative threshold is a typo — the validator fails startup
loudly (the ``agent_max_rounds`` pattern), naming the field."""
with pytest.raises(ValidationError, match="pdf_pictorial_min_chars"):
_settings(pdf_pictorial_min_chars=-1)
# ---------------------------------------------------------------------------
# pictorial_pages — the pure, deterministic gate
# ---------------------------------------------------------------------------
def test_pictorial_pages_strict_less_than_boundary() -> None:
"""The ASSUMPTION's boundary, pinned from all three sides at
``min_chars=5``: 4 stripped chars IS a candidate (``<``), EXACTLY 5
is NOT (``=`` — a page with exactly the bar's stripped chars is not
sparse), and 6 is NOT (``>``)."""
page_texts = {
1: "abcd", # 4 < 5 → candidate
2: "abcde", # 5 == 5 → NOT a candidate (the pinned boundary)
3: "abcdef", # 6 > 5 → NOT a candidate
}
assert pictorial_pages(page_texts, min_chars=5) == [1]
def test_pictorial_pages_min_chars_zero_marks_every_page() -> None:
"""PLAN A21's special case: ``min_chars=0`` is the "describe
everything" mode — EVERY page in the map is a candidate (the strict
``<`` test cannot express this: no length is below 0, so 0 is not a
stricter bar); an empty map still yields ``[]``."""
assert pictorial_pages({1: "a", 2: "x" * 7000}, min_chars=0) == [1, 2]
# A whitespace-only entry (defensive — the extractor's map never
# holds one) is still a candidate: the 0 branch is ALL KEYS.
assert pictorial_pages({1: " "}, min_chars=0) == [1]
assert pictorial_pages({}, min_chars=0) == []
def test_pictorial_pages_whitespace_only_page_is_a_candidate() -> None:
"""A whitespace-only page (strips to nothing — 0 < any positive bar)
is a candidate: the gate counts STRIPPED chars, so padding cannot
mask sparseness. (A truly text-LESS page has no key in the map at
all — the importer's pass owns that case.)"""
assert pictorial_pages({3: " \n\t \r\n "}, min_chars=100) == [3]
def test_pictorial_pages_empty_dict_yields_empty_list() -> None:
"""An empty map (no readable pages at all) yields ``[]`` — the
clean-text PDF's zero-candidates no-op (no render, no vision
call)."""
assert pictorial_pages({}, min_chars=100) == []
def test_pictorial_pages_result_is_ascending_even_when_input_isnt() -> None:
"""The result is ASCENDING (the render/describe order) regardless of
the dict's insertion order — the unsorted candidate set
{9, 2, 50, 5} comes back as [2, 5, 9, 50], and the one non-candidate
(the 210-char page 12) stays out."""
page_texts = {
9: "tail marker",
2: "head marker",
12: "not sparse " + "x" * 200, # 12+? — long enough, excluded
50: "Illustration 50",
5: "mid marker",
}
assert pictorial_pages(page_texts, min_chars=100) == [2, 5, 9, 50]
def test_pictorial_pages_large_dict() -> None:
"""A 500-page map with every 10th page sparse (an IKEA-class shape
at scale): exactly the sparse pages come back, ascending, and the
450 text-heavy pages are excluded — the pure function handles a
whole book in one call (no cap, LOCKED A3/A21)."""
page_texts: dict[int, str] = {}
expected: list[int] = []
for page in range(1, 501):
if page % 10 == 0:
page_texts[page] = "Illustration " + str(page) # 11-13 chars
expected.append(page)
else:
page_texts[page] = "w" * 3000 # text-heavy, never a candidate
assert len(page_texts) == 500 and len(expected) == 50
assert pictorial_pages(page_texts, min_chars=100) == expected
def test_pictorial_pages_ikea_example_from_the_docstring() -> None:
"""The docstring's IKEA example, pinned against the named threshold
(LOCKED A1, default 100): the title page ("BESTÅ", 5 chars) and the
"Illustration N" marker pages ARE candidates; the 7 KB of
multilingual warnings is NOT. A title page is a candidate —
describing it is useful (the logo + product name) and never
harmful."""
page_texts = {
1: "BESTÅ", # 5 chars → candidate
2: "w" * 7000, # 7 KB warnings → NOT
6: "Illustration 6", # 14 chars → candidate
}
assert pictorial_pages(page_texts, min_chars=100) == [1, 6]
def test_pictorial_pages_is_pure_and_does_not_mutate_input() -> None:
"""Pure + deterministic: repeated calls agree, and the input map
survives untouched (the gate reads, never rewrites)."""
page_texts = {3: "marker", 1: "x" * 500, 2: " "}
original = dict(page_texts)
first = pictorial_pages(page_texts, min_chars=100)
assert pictorial_pages(page_texts, min_chars=100) == first
assert page_texts == original
def test_pictorial_pages_uses_stripped_length_defensively() -> None:
"""Defensive strip: an UNSTRIPPED map entry (a caller that did not
pre-strip) is judged on its stripped length — padding on either
side cannot push a sparse page past the bar."""
assert pictorial_pages({1: " " + "y" * 98 + " "}, min_chars=100) == [1]
assert pictorial_pages({1: " " + "y" * 100 + " "}, min_chars=100) == []
+607
View File
@@ -0,0 +1,607 @@
"""Phase 140, task 03 — the importer's pictorial pass (unit).
The pass itself (``app.rag.importer._pictorial_pdf_pass`` — the
IKEA assembly-instruction class, TODO L5): a PDF whose text layer
PASSES the phase-133 garbage gate but is SPARSE (mostly pictorial
pages) gets every candidate page (``pictorial_pages`` — the
deterministic gate, LOCKED A1) rendered and vision-described, and the
description appends to the page's content block as
``[Page N illustration: …]`` (LOCKED A2/A4 — no LLM judge; the digest
runs over the FINAL content).
Pinned here (the synthetic IKEA-class recipe — PyMuPDF, no binary
fixtures; the real tar is pinned in
``tests/integration/test_importer.py``):
* the happy path — every candidate page (computed IN the test from
``extract_pdf_page_texts`` + ``pictorial_pages`` — no hardcoded page
list) carries its illustration block, the text-heavy pages do NOT,
the chunks carry the canned description, the digest is over the
FINAL content, the counters + the ONE loud log line fire (PLAN §9).
* the assembly BYTE-IDENTITY pin — with NO descriptions (every vision
call fails) the stored content is ``extract_pdf_text``'s output
byte-for-byte (the page's text is never lost, never reworded).
* the branch matrix: the kill switch off (``pdf_pictorial=False`` →
byte-identical to pre-phase, zero vision calls), zero candidates
(a clean-text PDF — no render, no vision call, no log line), the
fail-soft ``None`` page (one dead page keeps its text only, the
others are described), the unreadable-page placeholder skip (the
zero-byte ``render_pdf_pages`` slot burns no vision call, the page
keeps its text only).
* the digest-stability rerun — a stable file + a stable vision reply
re-syncs as ``unchanged`` (no re-embed, byte-stable content +
digest); the vision calls are re-issued before the unchanged check
can skip them — the documented no-cache cost (the phase-133
convention, A21 endorses it).
The E2E surface (the real UI + the mock LLM's ``PICTORIAL_PAGE_MODE``
branch) is pinned in ``tests/e2e/test_pictorial_pdf.py`` (task 04).
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import logging
import textwrap
from pathlib import Path
from typing import Any
import pymupdf as fitz
import pytest
from sqlalchemy import select
from app.config import Settings
from app.models import Chunk, Document
from app.rag import importer
from app.rag.importer import import_sources
from app.rag.llm import LLMError
from app.rag.pdf import (
PAGE_SEPARATOR,
extract_pdf_page_texts,
extract_pdf_text,
pictorial_pages,
)
from app.rag.summarizer import PICTORIAL_PAGE_MODE, PICTORIAL_PROMPT
from tests.fakes import FakeEmbedder
#: The IKEA fixture's metadata title (the phase-140 fixture, TODO L5 —
#: Adobe InDesign's "Assembly Instructions").
TITLE = "Assembly Instructions"
#: The text-heavy pages (IKEA pages 2–5 — the multilingual warnings,
#: 3,091–7,288 chars in the real fixture): a fixed warning block well
#: over the 100-char sparseness bar, so it is NEVER a candidate.
DENSE_TEXT = (
"WARNING: Adult assembly required. Read all instructions before "
"starting. Keep this package away from children. The furniture "
"must be secured to the wall using the supplied anti-tip device."
)
assert len(DENSE_TEXT.strip()) >= 100 # the class property, not a typo
def _insert_dense(page) -> None:
"""One dense-warning page's text, inserted in 60-char wrapped
lines: a single 188-char line would be CLIPPED at the page edge
on extraction (verified — only ~104 chars survive), while the
multi-line read round-trips byte-exactly (the fixture's class
property — over the 100-char bar — must hold on the EXTRACTED
text, which the gate sees)."""
for i, line in enumerate(textwrap.wrap(DENSE_TEXT, 60)):
page.insert_text((72, 72 + i * 14), line)
def _build_ikea_like_pdf(path: Path) -> None:
"""A synthetic IKEA-class PDF (TODO L5 — the vector-drawn
assembly-instruction class): page 1 is the title page (``BESTA``
— 5 chars, as in the real fixture's ``BESTÅ``), pages 2–5 the
text-heavy warnings (≥100 chars — never candidates), pages 6–10
sparse ``Illustration N`` markers (15–16 chars — candidates)
standing in for the vector pictograms (no embedded raster images
— the phase-122 image pipeline sees nothing, as on the real
fixture). The metadata title is the fixture's."""
doc = fitz.open()
doc.new_page().insert_text((72, 72), "BESTA")
for _ in range(4):
_insert_dense(doc.new_page())
for n in range(6, 11):
doc.new_page().insert_text((72, 72), f"Illustration {n}")
doc.set_metadata({"title": TITLE})
doc.save(path)
doc.close()
def _build_dense_pdf(path: Path) -> None:
"""A clean-text PDF: every page over the 100-char bar — ZERO
pictorial candidates (the pass's no-op branch)."""
doc = fitz.open()
for _ in range(2):
_insert_dense(doc.new_page())
doc.set_metadata({"title": "Clean Reference"})
doc.save(path)
doc.close()
class _PictorialLLM(FakeEmbedder):
"""The capturing fake LLM (task 03): the MULTIMODAL describe call
(``user`` content = a part list) gets a canned reply DETERMINED BY
THE RENDERED PAGE'S BYTES (a short sha256 of the data-URL's
base64 — the same rendered page always gets the same reply,
ACROSS RUNS: the digest-stability premise holds), and the TEXT
part is recorded (``pictorial_texts`` — the ``PICTORIAL_PROMPT``
head + the ``Page N of the PDF`` block are the per-page context
witness). String (``lite``) calls keep the ``FakeEmbedder``
behaviour. The REAL ``import_sources`` → ``_pictorial_pdf_pass``
→ ``describe_pdf_page`` chain runs against it (no seam patch)."""
def __init__(self) -> None:
super().__init__()
self.pictorial_texts: list[str] = []
self.pictorial_calls = 0
@staticmethod
def reply_for(png: bytes) -> str:
"""The canned reply a vision call for *png* gets —
deterministic in the bytes (the tests build the expected
content with the same helper)."""
return (
f"Page {hashlib.sha256(png).hexdigest()[:8]} illustrated: "
"two parts are joined with a blue arrow."
)
async def chat(
self, messages: list[dict[str, Any]], model: str | None = None
) -> str:
self.chat_calls.append(list(messages))
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
if isinstance(user, list):
text = next((p["text"] for p in user if p.get("type") == "text"), "")
assert PICTORIAL_PAGE_MODE in text, (
"a multimodal call reached the fake that is not the "
f"pictorial pass's seam: {text[:80]!r}"
)
self.pictorial_calls += 1
self.pictorial_texts.append(text)
b64 = next(p for p in user if p.get("type") == "image_url")[
"image_url"
]["url"].rsplit(",", 1)[1]
return self.reply_for(base64.b64decode(b64))
first = user.split()
return "Summary of " + (first[0] if first else "<empty>")
class _FailPagePictorialLLM(_PictorialLLM):
"""Rejects the vision call for page *fail_page* (parsed from the
text part's ``Page N of the PDF.`` block) and describes the rest —
drives the per-page fail-soft (the dead page keeps its text only,
the other candidates still get their descriptions)."""
def __init__(self, fail_page: int) -> None:
super().__init__()
self.fail_page = fail_page
async def chat(
self, messages: list[dict[str, Any]], model: str | None = None
) -> str:
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
if isinstance(user, list):
text = next((p["text"] for p in user if p.get("type") == "text"), "")
if f"Page {self.fail_page} of the PDF." in text:
self.pictorial_calls += 1
self.pictorial_texts.append(text)
raise LLMError(
f"simulated dead vision call for page {self.fail_page} "
"(test sentinel)"
)
return await super().chat(messages, model=model)
class _DeadVisionLLM(FakeEmbedder):
"""A vision client that REJECTS every multimodal call (the dead
vision endpoint — the fail-soft's total-failure case: EVERY
candidate page keeps its text only, and the stored content is the
extraction byte-identical)."""
def __init__(self) -> None:
super().__init__()
self.pictorial_calls = 0
async def chat(
self, messages: list[dict[str, Any]], model: str | None = None
) -> str:
self.chat_calls.append(list(messages))
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
if isinstance(user, list):
self.pictorial_calls += 1
raise LLMError("simulated dead vision endpoint (test sentinel)")
first = user.split()
return "Summary of " + (first[0] if first else "<empty>")
def _settings(tmp_path: Path, **kwargs: Any) -> Settings:
"""Settings admitting the ``pdf`` import token with the phase-140
knobs at their CODE DEFAULTS (``pdf_pictorial`` ON — LOCKED A3 —
and ``pdf_pictorial_min_chars`` 100 — LOCKED A1) unless pinned
otherwise; ``images`` off (the embedded-image pass is not in play
— the fixture class is vector-drawn)."""
kwargs.setdefault("_env_file", None)
kwargs.setdefault("import_extensions", "md,txt,pdf")
kwargs.setdefault("images", False)
kwargs.setdefault("image_dir", str(tmp_path / "images"))
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
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 _cleanup_source(db, source: str) -> None:
for doc in db.scalars(select(Document).where(Document.source == source)).all():
db.delete(doc)
db.commit()
def _candidates_and_pngs(pdf_path: Path) -> tuple[list[int], list[bytes]]:
"""The pass's own inputs, computed IN the test (no hardcoded page
list — the fixture is stable but the test must not rot): the
deterministic gate over the per-page text map, and the candidate
render (aligned with the candidates — ascending, one slot each)."""
page_texts = extract_pdf_page_texts(pdf_path)
candidates = pictorial_pages(page_texts, min_chars=100)
pngs = importer.render_pdf_pages(pdf_path, pages=candidates)
return candidates, pngs
def _expected_content(
pdf_path: Path, fail_pages: frozenset[int] = frozenset()
) -> tuple[str, list[int]]:
"""The FINAL content the pass produces for *pdf_path* under the
stable fake — derived from the same inputs the pass uses: every
page of the extraction map as its ``PAGE_SEPARATOR`` + the page
text, the described candidates (the candidates MINUS
*fail_pages*) carrying ``[Page N illustration: <stable reply>]``
AFTER their text (LOCKED A4 — the text stays)."""
page_texts = extract_pdf_page_texts(pdf_path)
candidates, pngs = _candidates_and_pngs(pdf_path)
parts: list[str] = []
for page, text in page_texts.items():
block = text
if page in candidates and page not in fail_pages:
png = pngs[candidates.index(page)]
block += f"\n\n[Page {page} illustration: {_PictorialLLM.reply_for(png)}]"
parts.append(f"{PAGE_SEPARATOR.format(page=page)}\n{block}")
return "\n\n".join(parts), candidates
def test_sparse_pages_get_their_illustration_blocks(
db, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The happy path end to end (the owner's L5 rationale): every
candidate page (computed from the gate — the fixture's title page
+ its six illustration pages) carries its illustration block, the
text-heavy pages (2–5) do NOT, the chunks carry the canned
description (the embeddings hit the illustrations), the title is
the metadata title, the digest is over the FINAL content, and the
counters + the ONE loud log line fire (PLAN §9)."""
root = tmp_path / "pik"
root.mkdir()
pdf = root / "ikea.pdf"
_build_ikea_like_pdf(pdf)
llm = _PictorialLLM()
llm.settings = _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)
content, candidates = _expected_content(pdf)
assert len(candidates) == 6 # the title page + the six markers
assert set(candidates) == {1, 6, 7, 8, 9, 10}
doc = _pdf_doc(db, "pik", "ikea.pdf")
assert doc.title == TITLE # the metadata title (the fixture's)
assert doc.is_image is False
# The assembly is byte-exact: the page text STAYS, the
# description appends after it (LOCKED A4).
assert doc.content == content
# The text-heavy pages (2–5 — the multilingual warnings) carry
# NO illustration block (the gate is the ONLY selector).
for n in (2, 3, 4, 5):
assert f"[Page {n} illustration" not in doc.content
# The digest runs over the FINAL content (the phase-126/133
# convention — A4).
assert doc.content_hash == hashlib.sha256(
doc.content.encode("utf-8")
).hexdigest()
# The counters (log-only — the sync detail shape is untouched):
# one PDF with six described sparse pages.
assert summary.pdfs_pictorial == 1
assert summary.pdf_pictorial_pages == 6
assert summary.pdfs_screenshotted == 0 # the gate passed (usable)
# The chunks carry the canned description — the joined chunk
# text hits every candidate's reply (each page is its own
# ``Page N:`` chunk, the page-aware chunker).
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]
joined = "\n".join(c.content for c in content_chunks)
_, pngs = _candidates_and_pngs(pdf)
for page in candidates:
reply = _PictorialLLM.reply_for(pngs[candidates.index(page)])
assert reply in joined, f"page {page}'s description is not indexed"
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
# ONE vision call per candidate, in candidate order — the text
# part heads with the locked prompt and carries the page's OWN
# text as context (the page block, task 02's contract).
assert llm.pictorial_calls == len(candidates)
assert len(llm.pictorial_texts) == len(candidates)
page_texts = extract_pdf_page_texts(pdf)
for page, text_part in zip(candidates, llm.pictorial_texts, strict=True):
assert text_part.startswith(PICTORIAL_PROMPT)
assert f"Page {page} of the PDF." in text_part
assert page_texts[page] in text_part # the page's own text
# The ONE loud log line (PLAN §9) — exact, with the computed N.
lines = [
r.getMessage()
for r in caplog.records
if "pictorial pass" in r.getMessage()
]
assert lines == [
"pdf: pictorial pass source=pik path=ikea.pdf — "
"6 candidate pages, 6 described (min_chars=100)"
]
finally:
_cleanup_source(db, "pik")
def test_assembly_byte_identity_without_descriptions(
db, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""THE byte-identity pin: with NO descriptions (the dead vision
endpoint — every candidate's call fails) the stored content is
``extract_pdf_text``'s output BYTE-FOR-BYTE (the page's text is
never lost, never reworded, the separators are untouched), the
pass still counted its candidates in the log line (0 described),
and no PDF is counted pictorial (a description is what counts)."""
root = tmp_path / "dead"
root.mkdir()
pdf = root / "ikea.pdf"
_build_ikea_like_pdf(pdf)
llm = _DeadVisionLLM()
llm.settings = _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 llm.pictorial_calls == 6 # every candidate was attempted
doc = _pdf_doc(db, "dead", "ikea.pdf")
assert doc.content == extract_pdf_text(pdf), (
"no descriptions → the stored content is the extraction "
"byte-identical (the assembly pin)"
)
assert summary.pdfs_pictorial == 0 # nothing was described
assert summary.pdf_pictorial_pages == 0
lines = [
r.getMessage()
for r in caplog.records
if "pictorial pass" in r.getMessage()
]
assert lines == [
"pdf: pictorial pass source=dead path=ikea.pdf — "
"6 candidate pages, 0 described (min_chars=100)"
]
# The dead pages' warnings (the fail-soft's signal, per page).
assert [
r
for r in caplog.records
if r.levelno == logging.WARNING
and "has no description" in r.getMessage()
]
finally:
_cleanup_source(db, "dead")
def test_feature_off_is_byte_identical_to_pre_phase(db, tmp_path: Path) -> None:
"""The kill switch (LOCKED A3, ``BOR_PDF_PICTORIAL=0``): the pass
never runs — no render, no vision call, no log line, no counters —
and the stored content is ``extract_pdf_text``'s output
byte-identical to the pre-phase-140 path."""
root = tmp_path / "off"
root.mkdir()
pdf = root / "ikea.pdf"
_build_ikea_like_pdf(pdf)
llm = _PictorialLLM()
llm.settings = _settings(tmp_path, pdf_pictorial=False)
try:
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
doc = _pdf_doc(db, "off", "ikea.pdf")
assert doc.content == extract_pdf_text(pdf)
assert llm.pictorial_calls == 0
assert (summary.pdfs_pictorial, summary.pdf_pictorial_pages) == (0, 0)
finally:
_cleanup_source(db, "off")
def test_zero_candidates_is_a_noop(
db, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A clean-text PDF (every page over the 100-char bar) has ZERO
candidates — the pass is a no-op: no render, NO vision call
(the pin — a clean-text PDF never burns a vision call), no log
line, no counters, and the content is the extraction untouched."""
root = tmp_path / "dense"
root.mkdir()
pdf = root / "clean.pdf"
_build_dense_pdf(pdf)
llm = _PictorialLLM()
llm.settings = _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)
doc = _pdf_doc(db, "dense", "clean.pdf")
assert doc.content == extract_pdf_text(pdf)
assert llm.pictorial_calls == 0
assert (summary.pdfs_pictorial, summary.pdf_pictorial_pages) == (0, 0)
assert not [
r for r in caplog.records if "pictorial pass" in r.getMessage()
]
finally:
_cleanup_source(db, "dense")
def test_fail_soft_page_keeps_its_text(
db, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""One dead candidate page (the vision call rejects it) keeps its
text only — a warning is the signal, the OTHER candidates still
get their descriptions, one dead page never kills the pass
(LOCKED A2 fail-soft). The described count is five of six; the
PDF still counts pictorial (≥1 described page)."""
root = tmp_path / "partfail"
root.mkdir()
pdf = root / "ikea.pdf"
_build_ikea_like_pdf(pdf)
llm = _FailPagePictorialLLM(fail_page=7)
llm.settings = _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 llm.pictorial_calls == 6 # every candidate was attempted
content, candidates = _expected_content(pdf, fail_pages=frozenset({7}))
doc = _pdf_doc(db, "partfail", "ikea.pdf")
assert doc.content == content
assert "[Page 7 illustration" not in doc.content # the dead page
assert "---PAGE-7---\nIllustration 7" in doc.content # its text stays
assert summary.pdfs_pictorial == 1
assert summary.pdf_pictorial_pages == 5
lines = [
r.getMessage()
for r in caplog.records
if "pictorial pass" in r.getMessage()
]
assert lines == [
"pdf: pictorial pass source=partfail path=ikea.pdf — "
"6 candidate pages, 5 described (min_chars=100)"
]
# The importer's per-page warning (the dead page's signal).
warnings = [
r
for r in caplog.records
if r.levelno == logging.WARNING
and "pictorial page 7" in r.getMessage()
]
assert len(warnings) == 1
finally:
_cleanup_source(db, "partfail")
def test_unreadable_page_placeholder_skips_the_vision_call(
db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A candidate page that could not be RENDERED (the zero-byte
``render_pdf_pages`` placeholder — the phase-133 handling) is
skipped with a warning: it burns NO vision call, keeps its text
only, and the OTHER candidates keep their positions +
descriptions (the result's order never shifts)."""
root = tmp_path / "halfblind"
root.mkdir()
pdf = root / "ikea.pdf"
_build_ikea_like_pdf(pdf)
real_render = importer.render_pdf_pages
def _render(path: Path, dpi: int = 150, pages: Any = None) -> list[bytes]:
assert pages is not None # only the pass calls it (with a selector)
pngs = real_render(path, dpi=dpi, pages=pages)
return [
b"" if n == 6 else png
for n, png in zip(pages, pngs, strict=True)
]
monkeypatch.setattr(importer, "render_pdf_pages", _render)
llm = _PictorialLLM()
llm.settings = _settings(tmp_path)
try:
with caplog.at_level(logging.WARNING, logger="app.importer"):
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
assert llm.pictorial_calls == 5 # the blank page burned no call
content, candidates = _expected_content(pdf, fail_pages=frozenset({6}))
doc = _pdf_doc(db, "halfblind", "ikea.pdf")
assert doc.content == content
assert "[Page 6 illustration" not in doc.content
assert "---PAGE-6---\nIllustration 6" in doc.content
assert summary.pdfs_pictorial == 1
assert summary.pdf_pictorial_pages == 5
warnings = [
r
for r in caplog.records
if r.levelno == logging.WARNING
and "skipping pictorial page 6" in r.getMessage()
]
assert len(warnings) == 1
finally:
_cleanup_source(db, "halfblind")
def test_rerun_lands_unchanged_with_byte_stable_content(
db, tmp_path: Path
) -> None:
"""Digest stability (A4 — the phase-126/133 convention): the
digest runs over the FINAL content, so a re-sync of an unchanged
IKEA-class PDF (same pages + the stable fake's same replies)
re-imports as UNCHANGED: no re-chunk, no re-embed, no re-summary,
no KB change (not ``updated``, no changed path), and the content +
digest are byte-stable. The vision calls ARE re-issued before the
unchanged check can skip them — the documented no-cache cost (the
phase-133 convention; a non-deterministic vision model re-describes
the row each sync — a cost, not a bug; A21 endorses it)."""
root = tmp_path / "stable"
root.mkdir()
pdf = root / "ikea.pdf"
_build_ikea_like_pdf(pdf)
llm = _PictorialLLM()
llm.settings = _settings(tmp_path)
try:
s1 = asyncio.run(import_sources([root], llm, session=db))
assert (s1.added, s1.pdfs_pictorial, s1.pdf_pictorial_pages) == (1, 1, 6)
assert llm.pictorial_calls == 6
embeds1 = len(llm.calls)
doc1 = _pdf_doc(db, "stable", "ikea.pdf")
content1, digest1 = doc1.content, doc1.content_hash
s2 = asyncio.run(import_sources([root], llm, session=db))
# The pass re-ran (gate re-fired, vision re-issued — the
# documented cost) — but the row itself re-imported unchanged.
assert (s2.added, s2.updated, s2.unchanged) == (0, 0, 1)
assert s2.pdfs_pictorial == 1 # the pass re-fired
assert llm.pictorial_calls == 12 # 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", "ikea.pdf")
assert doc2.content == content1 # byte-stable content
assert doc2.content_hash == digest1 # byte-stable digest
finally:
_cleanup_source(db, "stable")
+450
View File
@@ -0,0 +1,450 @@
"""Phase 140, task 02 — the ``PICTORIAL_PAGE_MODE`` vision seam + the
``render_pdf_pages`` page selector (unit).
Pins the task-02 contracts:
* ``PICTORIAL_PAGE_MODE`` / ``PICTORIAL_INSTRUCTION`` /
``PICTORIAL_PROMPT`` — the marker convention next to
``IMAGE_DESCRIPTION_MODE`` (marker first, the deterministic E2E mock
LLM keys on it, PLAN §6) with the LOCKED wording (phase 140 design:
describe in detail what the page shows — the assembly steps, part
names, arrows, tool icons, before/after states — ESPECIALLY the
information the extracted text does not contain).
* ``DESCRIBE_PROMPT`` byte-identity (regression pin — the phase 122/126/
137 suites pass unedited; the pictorial pass has its OWN marker, it
never re-wraps the image prompt).
* :func:`describe_pdf_page` — the same one-chat-call multimodal
contract as :func:`describe_image`: a capturing fake LLM pins the
message shape (the text part starts with ``PICTORIAL_PROMPT``, then
the page block — the page's own text as context, the
``"(none)`` case; the ``image_url`` part is the data URL from the
PNG bytes + mime), the CHAT (vision) model (never ``lite``), the
phase-30 cap, and ``None`` on an empty reply or a client error
(fail-soft — one dead page never stalls a sync).
* :func:`render_pdf_pages`' optional 1-based *pages* selector — the
subset's count + ascending order + PNG magic bytes (a 3-page
fixture, the phase-126/133 recipe), ``None`` = every page (the
phase-133 caller stays byte-identical), an unreadable/out-of-range
named page → the zero-byte placeholder at its position, an
unopenable file → ``[]``.
"""
from __future__ import annotations
import asyncio
import base64
import logging
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pymupdf as fitz
import pytest
from app.config import Settings
from app.rag import pdf as pdf_mod
from app.rag.llm import LLMError
from app.rag.pdf import render_pdf_pages
from app.rag.summarizer import (
DESCRIBE_PROMPT,
IMAGE_DESCRIPTION_MODE,
PICTORIAL_INSTRUCTION,
PICTORIAL_PAGE_MODE,
PICTORIAL_PROMPT,
describe_pdf_page,
)
PNG_MAGIC = b"\x89PNG"
#: A tiny non-empty PNG-ish payload for the describe-call's ``data``
#: (the seam is content-agnostic — it never parses the image; a
#: well-formed magic keeps the data-URL assertion honest).
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
)
def _settings(**kwargs: Any) -> Settings:
"""Build Settings without reading a .env file (deterministic tests)."""
kwargs.setdefault("_env_file", None)
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
class _VisionMock:
"""Duck-typed chat-model mock for :func:`describe_pdf_page` (task
02): records the message + model and returns a canned reply (or
raises it). Satisfies the summarizer's ``SummaryLLM`` protocol.
"""
def __init__(
self, reply: str | Exception = "Two parts joined with an arrow."
) -> None:
self.settings = _settings()
self.reply = reply
self.messages: list[Any] = []
self.model: str | None = None
async def chat(
self, messages: list[dict[str, Any]], model: str | None = None
) -> str:
self.messages = list(messages)
self.model = model
if isinstance(self.reply, Exception):
raise self.reply
return self.reply
def _message(mock: _VisionMock) -> list[dict[str, Any]]:
"""The one user message's multimodal ``content`` list (asserted to
be a list of two parts by the shape test)."""
assert len(mock.messages) == 1
message = mock.messages[0]
assert message["role"] == "user"
content = message["content"]
assert isinstance(content, list)
return content
# ---------------------------------------------------------------------------
# The marker + the locked instruction (the mock's key, PLAN §6)
# ---------------------------------------------------------------------------
def test_pictorial_prompt_carries_the_mode_marker_and_locked_wording() -> None:
"""The ``PICTORIAL_PAGE_MODE`` marker heads the pictorial prompt
(the ``SUMMARY_MODE``/``IMAGE_DESCRIPTION_MODE`` convention — the
deterministic E2E mock LLM keys on it), and the LOCKED wording's
load-bearing fragments are in the instruction: a re-wrap of the
prompt cannot silently re-route the mock, and the "describe what
the text does NOT contain" mandate stays in the constant."""
assert PICTORIAL_PAGE_MODE == "PICTORIAL_PAGE_MODE"
assert PICTORIAL_PROMPT.startswith(PICTORIAL_PAGE_MODE + ": ")
assert (
PICTORIAL_PROMPT == PICTORIAL_PAGE_MODE + ": " + PICTORIAL_INSTRUCTION
)
for fragment in (
"assembly steps",
"part names",
"arrows",
"tool icons",
"before/after states",
"ESPECIALLY",
"extracted text",
"plain text",
):
assert fragment in PICTORIAL_PROMPT
# The markers are disjoint fixed app constants (neither contains
# the other) — the mock's two vision branches cannot shadow each
# other regardless of branch order.
assert IMAGE_DESCRIPTION_MODE not in PICTORIAL_PROMPT
assert PICTORIAL_PAGE_MODE not in DESCRIBE_PROMPT
def test_describe_prompt_byte_identity_regression_pin() -> None:
"""REGRESSION PIN: the phase-122 ``DESCRIBE_PROMPT`` is untouched
by the pictorial seam (the phase 122/126/137 suites pass unedited —
the image marker's mock branch serves the exact pre-phase-140
prompt). The full value is pinned, not just its shape: any
re-wrap of the locked instruction fails here, not in the story
suites."""
assert DESCRIBE_PROMPT == (
"IMAGE_DESCRIPTION_MODE: Describe this image faithfully, in "
"plain text, for a search index. State what is depicted, "
"transcribe any visible text, labels, or titles, describe the "
"structure of any diagram, table, or layout, and call out the "
"most salient details. Write 2-4 sentences of substance. Do not "
"use markdown. Do not invent anything that is not visible in "
"the image. Your description is the ONLY text that will ever be "
"retrieved for this image — it must carry the image's full "
"meaning."
)
# ---------------------------------------------------------------------------
# describe_pdf_page — message shape, model, cap, fail-soft
# ---------------------------------------------------------------------------
def test_describe_pdf_page_message_shape_multimodal_and_chat_model() -> None:
"""ONE chat-model call (LOCKED A2 — the vision ``llm_chat_model``,
NOT the ``lite`` summary model) with the OpenAI-compatible
multimodal user message: the text part is exactly
``PICTORIAL_PROMPT + "\\n\\nPage {page} of the PDF. Text extracted
from this page:\\n" + page_text.strip()`` (the page's OWN text as
context, so the model describes what the text does NOT carry) and
the ``image_url`` part is the data URL ``data:<mime>;base64,<png
bytes>`` — no system prompt, no tools."""
mock = _VisionMock("Two parts joined with an arrow.")
result = asyncio.run(
describe_pdf_page(
mock,
data=PNG_1X1,
mime="image/png",
page=7,
page_text="Illustration 7",
)
)
assert result == "Two parts joined with an arrow."
content = _message(mock)
assert len(content) == 2
assert content[0] == {
"type": "text",
"text": (
PICTORIAL_PROMPT
+ "\n\nPage 7 of the PDF. Text extracted from this page:\n"
+ "Illustration 7"
),
}
b64 = base64.b64encode(PNG_1X1).decode("ascii")
assert content[1] == {
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
}
# LOCKED A2: the CHAT (vision) model — never the ``lite`` summary
# model (it is not assumed vision-capable).
assert mock.model == mock.settings.llm_chat_model
assert mock.model != mock.settings.llm_summary_model
@pytest.mark.parametrize(
("page_text", "suffix"),
[
(None, "(none)"),
("", "(none)"),
(" \t\n ", "(none)"),
(" Illustration 7 ", "Illustration 7"),
],
)
def test_describe_pdf_page_page_block_none_and_strip_cases(
page_text: str | None, suffix: str,
) -> None:
"""The page block's tail is ``page_text.strip() or "(none)"`` — a
page with no extracted text (``None``/empty/blank) sends the
explicit ``(none)`` sentinel (the vision model knows there is
nothing to restate), and a text-carrying page sends its STRIPPED
text (the leading/trailing whitespace of the extraction never
reaches the model)."""
mock = _VisionMock("ok")
asyncio.run(
describe_pdf_page(
mock, data=PNG_1X1, mime="image/png", page=3, page_text=page_text
)
)
content = _message(mock)
assert content[0]["text"] == (
PICTORIAL_PROMPT
+ f"\n\nPage 3 of the PDF. Text extracted from this page:\n{suffix}"
)
def test_describe_pdf_page_strips_and_caps_at_summary_max_chars() -> None:
"""The reply is whitespace-stripped and cut exactly at
``summary_max_chars`` (the phase-30 cap — the same uniform ceiling
as :func:`describe_image`). The explicit ``settings`` parameter
wins; ``settings=None`` falls back to the LLM's own settings."""
long_reply = "x" * 500
mock = _VisionMock(long_reply)
capped = asyncio.run(
describe_pdf_page(
mock,
data=PNG_1X1,
mime="image/png",
page=2,
page_text=None,
settings=_settings(summary_max_chars=100),
)
)
assert capped == "x" * 100
mock2 = _VisionMock(" " + "y" * 50 + "\n")
mock2.settings = _settings(summary_max_chars=42)
result = asyncio.run(
describe_pdf_page(mock2, data=PNG_1X1, mime="image/png", page=2, page_text=None)
)
assert result == ("y" * 42)
def test_describe_pdf_page_returns_none_on_failure_classes(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Fail-soft (LOCKED A2 — one dead page never stalls a sync):
``None`` on a client error (with a warning log naming the page —
the house one-shot style) and on an empty or whitespace-only reply
(the house one-shot empty-content policy)."""
failing = _VisionMock(LLMError("simulated vision-model failure (test sentinel)"))
with caplog.at_level(logging.WARNING, logger="app.summarizer"):
assert (
asyncio.run(
describe_pdf_page(
failing, data=PNG_1X1, mime="image/png", page=9, page_text=None
)
)
is None
)
warnings = [
r
for r in caplog.records
if r.levelno == logging.WARNING and "pictorial page description" in r.getMessage()
]
assert len(warnings) == 1 and "page=9" in warnings[0].getMessage()
for empty in ("", " \n"):
blank = _VisionMock(empty)
assert (
asyncio.run(
describe_pdf_page(
blank, data=PNG_1X1, mime="image/png", page=9, page_text=None
)
)
is None
)
# ---------------------------------------------------------------------------
# render_pdf_pages — the optional 1-based page selector
# ---------------------------------------------------------------------------
def _three_page_text_pdf(path: Path) -> Path:
"""A 3-page temp PDF with EXPLICIT letter pages (612x792pt —
deterministic render sizes) and page-specific text (the render must
be page-specific, so the pages' PNG bytes must differ — the
phase-126/133 recipe, extended to three pages for the selector's
count + order assertions)."""
doc = fitz.open()
for marker in ("Alpha page text.", "Beta page text.", "Gamma page text."):
doc.new_page(width=612, height=792).insert_text((72, 72), marker)
doc.save(path)
doc.close()
return path
def test_render_selector_none_renders_every_page(tmp_path: Path) -> None:
"""``pages=None`` (the default) = every page in page order — the
phase-133 caller's result, byte-identical: a 3-page PDF → 3 PNGs,
each a genuine decodable PNG, page-specific (the bytes differ)."""
path = _three_page_text_pdf(tmp_path / "three.pdf")
pages = render_pdf_pages(path)
assert len(pages) == 3
for png in pages:
assert png.startswith(PNG_MAGIC)
pix = fitz.Pixmap(png)
assert (pix.width, pix.height) == (1275, 1650) # 150-DPI letter
assert pages[0] != pages[1] != pages[2] and pages[0] != pages[2]
def test_render_selector_subset_count_ascending_order_and_pngs(
tmp_path: Path,
) -> None:
"""A named subset renders ONLY the named pages (3-page PDF,
``pages=[3, 1]`` → 2 PNGs, not 3), in ASCENDING order regardless of
the input order (list index *i* is the *i*-th smallest NAMED page —
index 0 is page 1, index 1 is page 3), each a genuine PNG whose
bytes equal the corresponding page's render in the full
(``pages=None``) pass — the selector changes the set, never the
per-page output."""
path = _three_page_text_pdf(tmp_path / "three.pdf")
full = render_pdf_pages(path)
selected = render_pdf_pages(path, pages=[3, 1])
assert len(selected) == 2
for png in selected:
assert png.startswith(PNG_MAGIC)
pix = fitz.Pixmap(png)
assert (pix.width, pix.height) == (1275, 1650) # 150-DPI letter
assert selected[0] == full[0] # the smallest named page (1) first
assert selected[1] == full[2] # page 3 second, page 2 rendered by no one
single = render_pdf_pages(path, pages=[2])
assert single == [full[1]]
def test_render_selector_out_of_range_named_page_is_zero_bytes(
tmp_path: Path, caplog: pytest.LogCaptureFixture,
) -> None:
"""A named page outside the document's page range yields ``b""`` at
ITS position (the caller's placeholder — the result's order never
shifts, so it stays aligned with the requested pages) + a warning;
the in-range page keeps its PNG."""
path = _three_page_text_pdf(tmp_path / "three.pdf")
full = render_pdf_pages(path)
with caplog.at_level(logging.WARNING, logger="app.rag.pdf"):
selected = render_pdf_pages(path, pages=[2, 99])
assert selected == [full[1], b""]
warnings = [
r
for r in caplog.records
if r.levelno == logging.WARNING and "could not render page 99" in r.getMessage()
]
assert len(warnings) == 1
def test_render_selector_empty_list_renders_nothing(tmp_path: Path) -> None:
"""``pages=[]`` (no candidates) renders nothing — the zero-
candidates no-op the importer relies on (a clean-text PDF's path
never burns a render or a vision call)."""
path = _three_page_text_pdf(tmp_path / "three.pdf")
assert render_pdf_pages(path, pages=[]) == []
def test_render_selector_unopenable_file_returns_empty(
tmp_path: Path,
) -> None:
"""A file that cannot be OPENED yields ``[]`` even with a selector
(the caller's existing empty-content path — unchanged by the
phase-140 parameter)."""
bad = tmp_path / "bad.pdf"
bad.write_bytes(b"this is not a pdf at all")
assert render_pdf_pages(bad, pages=[1, 2]) == []
assert render_pdf_pages(tmp_path / "missing.pdf", pages=[1]) == []
def test_render_selector_broken_named_page_is_zero_bytes(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
) -> None:
"""A named page whose ``get_pixmap`` raises → ``b""`` at ITS
position (the caller's placeholder slot — the page index NEVER
shifts, so the result stays aligned with the requested pages); the
good named pages keep their PNGs, 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)
class _FakeDoc:
def __init__(self, pages: list[Any]) -> None:
self._pages = pages
def __len__(self) -> int:
return len(self._pages)
def __getitem__(self, index: int) -> Any:
return self._pages[index]
def __enter__(self) -> _FakeDoc: # noqa: F821 (nested class, future annotations)
return self
def __exit__(self, *args: object) -> bool:
return False
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)
# pages=[3, 1, 2] renders ascending 1, 2, 3 — the broken page 2
# holds ITS position (index 1), not the tail.
assert render_pdf_pages(path, pages=[3, 1, 2]) == [
b"\x89PNG-fake-page-1",
b"",
b"\x89PNG-fake-page-3",
]
# A repeated page number renders ONCE (the selector is a set of
# pages, ascending).
assert render_pdf_pages(path, pages=[1, 1]) == [b"\x89PNG-fake-page-1"]