phase: 140_pictorial_pdf_pages
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:
@@ -85,6 +85,17 @@ os.environ["BOR_IMAGE_DIR"] = str(_Settings.model_fields["image_dir"].default)
|
||||
# ``answer_image`` done-frame key absent) must see the code default.
|
||||
os.environ["BOR_ANSWER_IMAGES"] = str(_Settings.model_fields["answer_images"].default)
|
||||
|
||||
# Phase 140: the same leak class for the pictorial-PDF toggle +
|
||||
# threshold — an operator's shell may carry BOR_PDF_PICTORIAL /
|
||||
# BOR_PDF_PICTORIAL_MIN_CHARS (the owner runs the feature on in
|
||||
# production), and the task-01 default pins (LOCKED A3 default-on,
|
||||
# LOCKED A1 default-100) and the task-03 pass tests must see the code
|
||||
# defaults.
|
||||
os.environ["BOR_PDF_PICTORIAL"] = str(_Settings.model_fields["pdf_pictorial"].default)
|
||||
os.environ["BOR_PDF_PICTORIAL_MIN_CHARS"] = str(
|
||||
_Settings.model_fields["pdf_pictorial_min_chars"].default
|
||||
)
|
||||
|
||||
from app.db import SessionLocal, db_available # noqa: E402
|
||||
from app.main import app as fastapi_app # noqa: E402
|
||||
|
||||
|
||||
@@ -41,6 +41,17 @@ USE_REAL_LLM = os.environ.get("E2E_REAL_LLM") == "1"
|
||||
ADMIN_PASSWORD = "e2e-admin-password"
|
||||
SESSION_SECRET = "e2e-session-secret-0123456789abcdef0123456789abcdef"
|
||||
|
||||
# Phase 140 (task 03): the pictorial-PDF kill switch is pinned OFF for
|
||||
# every E2E app. The code default is ON (LOCKED A3), and the pre-phase-
|
||||
# 140 story suites pin the PRE-phase content shape byte-exactly (their
|
||||
# synthetic PDFs carry sparse pages — under the 100-char bar — so a
|
||||
# default-ON app would append ``[Page N illustration: …]`` blocks and
|
||||
# move those pins). The line rides into every app's env through the
|
||||
# house ``env = dict(os.environ)`` pattern; the phase-140 story suite
|
||||
# (tests/e2e/test_pictorial_pdf.py) opts in explicitly
|
||||
# (``env["BOR_PDF_PICTORIAL"] = "1"`` in its module app).
|
||||
os.environ["BOR_PDF_PICTORIAL"] = "0"
|
||||
|
||||
|
||||
def _wait_http(url: str, timeout: float = 40.0) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
|
||||
@@ -34,6 +34,15 @@ Implements just enough of the aipi surface:
|
||||
the image document's whole content + summary, so it is byte-
|
||||
stable and token-dense (its cosine against the story suite's
|
||||
question clears the mock-calibrated gate)
|
||||
- ``PICTORIAL_PAGE_MODE`` (phase 140, pictorial PDF pages) -> the
|
||||
fixed ``PICTORIAL_PAGE_ANSWER`` description: the CHAT model's
|
||||
(vision) reply to ``summarizer.describe_pdf_page``'s multimodal
|
||||
user message (one rendered sparse PDF page — the marker is its
|
||||
first text part; the call is non-streaming, so only this answer
|
||||
path serves it). The reply is appended to the page's indexed
|
||||
content block as ``[Page N illustration: …]`` (LOCKED A4), so it
|
||||
is byte-stable: a re-sync with the same reply re-digests to the
|
||||
same content and lands as ``unchanged``
|
||||
- ``DEFLECT_MODE`` -> honest "I haven't done anything like that" answer
|
||||
- otherwise -> upbeat answer quoting the provided document context
|
||||
- user message containing ``pretend to think slowly`` -> 3s warm-up delay
|
||||
@@ -992,6 +1001,21 @@ IMAGE_DESCRIPTION_CONTEXT_ANSWER = (
|
||||
"cable runs. Title: Homelab Network Map."
|
||||
)
|
||||
|
||||
#: Phase 140 (pictorial PDF pages, LOCKED A2): the fixed description
|
||||
#: the mock's vision (CHAT) model returns for a
|
||||
#: ``PICTORIAL_PAGE_MODE`` request — the multimodal user message
|
||||
#: ``[{type: "text", …PICTORIAL_PAGE_MODE…}, {type: "image_url",
|
||||
#: …}]`` (``app.rag.summarizer.describe_pdf_page`` — one rendered
|
||||
#: sparse PDF page, the page's own text as context). The reply is
|
||||
#: appended to the page's indexed content block as
|
||||
#: ``[Page N illustration: …]`` (LOCKED A4), so it is byte-stable and
|
||||
#: retrieval-friendly (distinctive + token-dense on the assembly
|
||||
#: vocabulary the story E2E's question targets).
|
||||
PICTORIAL_PAGE_ANSWER = (
|
||||
"Pictorial page: the illustration shows two parts being joined "
|
||||
"with an arrow."
|
||||
)
|
||||
|
||||
TABLE_ANSWER = (
|
||||
"Here's the shape, in a table:\n"
|
||||
"\n"
|
||||
@@ -2913,6 +2937,17 @@ def compose_answer(body: dict[str, Any]) -> str:
|
||||
if "Extracted from " in user
|
||||
else IMAGE_DESCRIPTION_ANSWER
|
||||
)
|
||||
elif "PICTORIAL_PAGE_MODE" in user:
|
||||
# Phase 140 (pictorial PDF pages, LOCKED A2): the CHAT model's
|
||||
# vision description of ONE rendered sparse PDF page — a
|
||||
# NON-STREAMING multimodal user message, so only this path
|
||||
# ever sees it. ``_content_text`` joins the text parts, so the
|
||||
# marker (the first text part) lands in ``user``. The two
|
||||
# markers are disjoint fixed app constants (neither contains
|
||||
# the other), so the branch order vs ``IMAGE_DESCRIPTION_MODE``
|
||||
# cannot shadow either flow; like that marker, it is an
|
||||
# app-to-app marker a real question is never expected to type.
|
||||
answer = PICTORIAL_PAGE_ANSWER
|
||||
elif TABLE_TRIGGER in user.lower():
|
||||
# Markdown tables (phase 44, TODO.md L6): the story E2E's
|
||||
# deterministic table answer — a 3-column table, the
|
||||
|
||||
@@ -0,0 +1,729 @@
|
||||
"""Phase 140 task 04 E2E (Playwright, mock-only): the pictorial PDF —
|
||||
sparse pages rendered + vision-described into the indexed content.
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_pictorial_pdf.py -v --no-cov
|
||||
|
||||
The dedicated suite for ``140_pictorial_pdf_pages`` (A16 — one Playwright
|
||||
file per phase, run in isolation): it proves the owner's TODO L5 scenario
|
||||
end to end on a SYNTHETIC fixture (the owner's real
|
||||
``ikea_instructions.tar`` is pinned at the integration level, task 03 —
|
||||
the E2E keeps the deterministic canned-reply premise): a 2-page PDF
|
||||
built with PyMuPDF (the ``test_pdf_support.py`` recipe) —
|
||||
|
||||
* page 1 = a 700+ char prose paragraph (over the 100-char sparseness
|
||||
bar — NOT a pictorial candidate),
|
||||
* page 2 = the 14-char ``Illustration 2`` marker + a drawn VECTOR figure
|
||||
(a rectangle + an arrow — the IKEA class: vector-drawn pictograms,
|
||||
ZERO embedded raster images, so the phase-122/137 embedded-image
|
||||
pipeline sees nothing; the thin text IS a candidate),
|
||||
|
||||
tarred as a single-root-member ``.tar`` (the owner's fixture's exact
|
||||
format — the upload gate admits it) and arriving through the REAL admin
|
||||
upload flow → the RAG page's Sync button:
|
||||
|
||||
* (a) the document appears on the Sources page and the document viewer
|
||||
renders the stored content: page 1's prose WITHOUT an illustration
|
||||
block, page 2 WITH ``[Page 2 illustration: <the mock's canned
|
||||
PICTORIAL_PAGE_ANSWER>]`` (the task-02 ``PICTORIAL_PAGE_MODE`` branch
|
||||
— deterministic; the stored content is asserted BYTE-EXACT against
|
||||
the fixture's own extraction in the seed — the candidates are computed
|
||||
from ``extract_pdf_page_texts`` + ``pictorial_pages`` IN the test, no
|
||||
hardcoded page list);
|
||||
* (b) a chat question that ONLY the illustration answers ("how are the
|
||||
two parts joined on page two?" — the phrase lives NOWHERE in the text
|
||||
layer) grounds (the described page's embedding retrieves — the mock's
|
||||
deterministic token-overlap cosine clears the mock-calibrated 0.30
|
||||
gate; FTS corroborates) and, scripted through the house seeded-read
|
||||
convention (phase 119 A1 — a zero-read grounded turn chips NOTHING, so
|
||||
the PDF must be READ to land in ``done.sources``), the SSE completes
|
||||
without deflecting: the ``done`` event's ``sources`` carries the PDF
|
||||
and the answer echoes the read result VERBATIM — the
|
||||
``[Page 2 illustration: …]`` block and its phrase reach the model and
|
||||
land in the answer;
|
||||
* (c) the sync's ``detail`` JSON keeps its pre-phase keys EXACTLY (no
|
||||
new field — the ``pdfs_pictorial`` / ``pdf_pictorial_pages`` counters
|
||||
are log-only, PLAN §9, the phase-133 convention).
|
||||
|
||||
House pattern: one module app over the shared E2E Postgres (its own
|
||||
port, module env — the conftest session app is never started in this
|
||||
isolated run, so no port clash). The three tests SHARE the module seed
|
||||
(one source, one document), so the shared registry + KB is truncated
|
||||
ONCE, after the last test (a module-granularity autouse — a per-test
|
||||
truncate would wipe the seed from under its siblings), with a
|
||||
no-running-sync wait so a leaked run cannot race it.
|
||||
|
||||
App env: ``BOR_IMPORT_EXTENSIONS`` pinned WITH the ``pdf`` token (the
|
||||
phase-126 convention — without the token the walk is blind to the file)
|
||||
and ``BOR_PDF_PICTORIAL`` pinned EXPLICITLY to ``1``: the conftest pins
|
||||
it OFF in ``os.environ`` for the pre-phase-140 story suites (their
|
||||
synthetic PDFs carry sparse pages — a default-ON app would move their
|
||||
byte-exact pins), and this suite is the documented opt-in (the
|
||||
conftest's phase-140 comment). ``BOR_IMAGES`` is pinned ``false`` — the
|
||||
embedded-image pipeline is out of scope (the fixture's figure is
|
||||
vector-drawn; it yields no image doc either way). The byte-exact
|
||||
content assertion in the seed is the toggle's premise witness: without
|
||||
the opt-in, page 2 would lack its illustration block and the seed
|
||||
fails loudly.
|
||||
|
||||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the described
|
||||
block is the mock's deterministic ``PICTORIAL_PAGE_MODE`` canned reply
|
||||
(``PICTORIAL_PAGE_ANSWER``), so the byte-exact content + echo
|
||||
assertions are exact only against it.
|
||||
|
||||
Test → observable mapping (Playwright Mapping Rule):
|
||||
1. ``test_sources_page_and_viewer_show_the_described_page`` — the
|
||||
Sources page reads 1 doc / 3 chunks (2 page-anchored content chunks +
|
||||
the summary chunk — no image doc), the drill-down table lists the PDF
|
||||
as a plain text row (LOCKED A4 — a text document, no thumbnail), and
|
||||
the same-page document viewer (the row's path link) renders the
|
||||
stored content under the METADATA title: the page-1 prose (NO
|
||||
``[Page 1 illustration`` block), the page-2 marker + its
|
||||
``[Page 2 illustration: <canned>]`` block, and the Summary panel
|
||||
(a non-image doc with a non-empty summary).
|
||||
2. ``test_chat_question_grounds_and_echoes_the_illustration`` — the raw
|
||||
SSE over the scripted seeded-read question (httpx — the ``done``
|
||||
event is the wire lens the task pins; the UI flow's premise is the
|
||||
module seed): the stream settles ``done`` with ``deflected`` false
|
||||
and NO ``error`` frame, EXACTLY one ``tool`` frame (the scripted
|
||||
``read`` on the PDF; the doc is far under the read cap — no
|
||||
truncation, so no ``tool_result`` frame), the ``done`` event's
|
||||
``sources`` is EXACTLY the PDF (the citation surface — read docs
|
||||
only, phase 119 A1), and the joined deltas echo the read result
|
||||
VERBATIM — the ``Document <source>/<path>:`` header and the
|
||||
``[Page 2 illustration: …]`` block with the canned phrase (the
|
||||
described page's content reaches the model and lands in the answer).
|
||||
3. ``test_sync_detail_keeps_the_pre_phase_keys`` — the module run's
|
||||
terminal ``detail`` (``GET /api/sync/status`` — the same object the
|
||||
UI's "1 added" line renders from) carries the pre-phase key set
|
||||
EXACTLY: the ``pdfs_pictorial`` / ``pdf_pictorial_pages`` counters
|
||||
never ride the wire (the stats log-only convention), and the run's
|
||||
own file counters read 1 file / 1 added / 0 unchanged / 0 pruned /
|
||||
0 errors.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pymupdf as fitz # PyMuPDF — the synthetic-fixture builder (the house recipe)
|
||||
import pytest
|
||||
from playwright.sync_api import Browser, Page, expect
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import Document
|
||||
from app.rag.pdf import (
|
||||
PAGE_SEPARATOR,
|
||||
extract_pdf_page_texts,
|
||||
pictorial_pages,
|
||||
)
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||
from e2e.mock_llm import PICTORIAL_PAGE_ANSWER
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
SOURCE_NAME = "e2e-pictorial" # the archive stem (archive_source_name)
|
||||
DOC_PATH = "instructions.pdf"
|
||||
PDF_TITLE = "Assembly Instructions" # the metadata title (LOCKED A2)
|
||||
|
||||
#: Page 1 — a 700+ char prose paragraph (over the 100-char sparseness
|
||||
#: bar — NOT a candidate). Deliberately carries NO "two parts joined"
|
||||
#: content: the phrase the question targets lives ONLY in the
|
||||
#: description the vision pass appends to page 2 (the owner's L5
|
||||
#: scenario — the illustration answers what the text layer cannot).
|
||||
PAGE_ONE = (
|
||||
"This manual covers the complete flat-pack furniture frame, its "
|
||||
"shipped hardware kit, and the safety checks a first-time assembler "
|
||||
"should run before starting. The box holds the two side panels, the "
|
||||
"top board, the adjustable leg set, eight cam locks, fourteen wood "
|
||||
"screws, two bolts, and the wall-anchoring strap. Work on a flat "
|
||||
"surface with soft flooring, keep the hardware sorted by size, and "
|
||||
"read every step through before you begin so you know which panel "
|
||||
"is which. The frame stands roughly a metre tall once assembled, "
|
||||
"weights under twelve kilograms, and needs no further tools beyond "
|
||||
"the included Allen key. Keep the small parts away from children "
|
||||
"until the final step is done and the cam locks are fully seated."
|
||||
)
|
||||
assert len(PAGE_ONE) > 500, "page 1 must be a 500+ char paragraph (the task's recipe)"
|
||||
|
||||
#: Page 2 — the sparse page: the marker line is ALL the text layer
|
||||
#: carries (14 stripped chars, under the 100-char bar — the candidate);
|
||||
#: the figure is drawn vector (no raster image).
|
||||
PAGE_TWO = "Illustration 2"
|
||||
|
||||
#: The stored illustration block (LOCKED A4) — the mock's canned
|
||||
#: ``PICTORIAL_PAGE_MODE`` reply, deterministic.
|
||||
ILLUSTRATION_BLOCK = f"[Page 2 illustration: {PICTORIAL_PAGE_ANSWER}]"
|
||||
|
||||
#: The grounded question (b): the house seeded-read call (phase 119 A1 —
|
||||
#: a zero-read grounded turn chips NOTHING, so the PDF must be READ to
|
||||
#: earn its ``done.sources`` citation) + the natural question, whose
|
||||
#: tokens ("two parts joined page") overlap ONLY the illustrated page's
|
||||
#: content (the mock's token-overlap cosine grounds; the text layer
|
||||
#: alone would answer nothing).
|
||||
QUESTION = (
|
||||
f"Read the suggested document: read {SOURCE_NAME}/{DOC_PATH} — "
|
||||
"how are the two parts joined on page two?"
|
||||
)
|
||||
|
||||
#: The sync ``detail`` key set EXACTLY as pre-phase 140 (the
|
||||
#: ``app/api/sync.py`` terminal detail — phase 126/133 shape). The
|
||||
#: phase-140 counters are log-only and must NOT appear (c).
|
||||
PRE_PHASE_DETAIL_KEYS = frozenset(
|
||||
{
|
||||
"files",
|
||||
"added",
|
||||
"updated",
|
||||
"unchanged",
|
||||
"pruned",
|
||||
"errors",
|
||||
"chunks",
|
||||
"summaries",
|
||||
"summary_errors",
|
||||
"dates_updated",
|
||||
"overview",
|
||||
"sources_version",
|
||||
}
|
||||
)
|
||||
|
||||
#: Phase 79 (task 04, full inventory): the conftest session app owns its
|
||||
#: port in a combined run — this module app binds its own port instead
|
||||
#: (a same-port second uvicorn dies on bind and would drive the wrong
|
||||
#: server). Env-overridable.
|
||||
APP_PORT = int(os.environ.get("E2E_APP_PORT_PICTPDF", "8155"))
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
UPLOAD_TIMEOUT_MS = 30_000
|
||||
SYNC_TIMEOUT_MS = 60_000
|
||||
SYNCED_LABEL = re.compile(r"^Synced \d{1,2}:\d{2}$")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture builder (the ``test_pdf_support.py`` PyMuPDF recipe, the
|
||||
# owner's class: a dense prose page + a sparse vector-illustrated page)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_pdf(path: Path) -> None:
|
||||
"""ONE 2-page PDF (the task's recipe): page 1 = the 700+ char prose
|
||||
paragraph (``insert_textbox`` — word-wrapped, no mid-word breaks in
|
||||
the extraction), page 2 = the ``Illustration 2`` marker + a drawn
|
||||
VECTOR figure (a rectangle + a line arrow with a two-stroke head —
|
||||
the IKEA pictogram class: ``get_image_info()`` sees ZERO embedded
|
||||
raster images, the phase-122/137 pipeline is blind to it)."""
|
||||
doc = fitz.open()
|
||||
page1 = doc.new_page()
|
||||
rc = page1.insert_textbox(fitz.Rect(72, 72, 523, 772), PAGE_ONE, fontsize=11)
|
||||
assert rc >= 0, "the page-1 paragraph must fit its text box"
|
||||
page2 = doc.new_page()
|
||||
page2.insert_text((72, 72), PAGE_TWO)
|
||||
# The vector figure: a rectangle (a part) + an arrow (the join).
|
||||
page2.draw_rect(fitz.Rect(100, 120, 300, 220))
|
||||
page2.draw_line(fitz.Point(320, 170), fitz.Point(470, 170))
|
||||
page2.draw_line(fitz.Point(470, 170), fitz.Point(450, 158))
|
||||
page2.draw_line(fitz.Point(470, 170), fitz.Point(450, 182))
|
||||
doc.set_metadata({"title": PDF_TITLE})
|
||||
doc.save(path)
|
||||
doc.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pdf_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""The fixture PDF (module-scoped, built once) — the local copy the
|
||||
byte-exact content assertion computes against (the app indexes the
|
||||
unpacked copy of the SAME bytes)."""
|
||||
root = tmp_path_factory.mktemp("bor_pict_pdf")
|
||||
pdf = root / DOC_PATH
|
||||
_build_pdf(pdf)
|
||||
# The premise pins (deterministic — the pass's candidate set and the
|
||||
# gate's own contract, computed from the fixture itself): page 1 is
|
||||
# prose (no candidate), page 2 is sparse (the candidate).
|
||||
page_texts = extract_pdf_page_texts(pdf)
|
||||
candidates = pictorial_pages(
|
||||
page_texts,
|
||||
min_chars=Settings.model_fields["pdf_pictorial_min_chars"].default,
|
||||
)
|
||||
assert candidates == [2], (
|
||||
f"the fixture's sparse-page premise broke — candidates={candidates}"
|
||||
)
|
||||
return pdf
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tar_path(pdf_path: Path) -> Path:
|
||||
"""The fixture archive: ONE PDF at the root (the ``.tar`` format —
|
||||
the owner's ``ikea_instructions.tar`` class; the upload gate admits
|
||||
it) — source ``e2e-pictorial``, document path ``instructions.pdf``."""
|
||||
archive = pdf_path.parent / f"{SOURCE_NAME}.tar"
|
||||
with tarfile.open(archive, "w") as tf:
|
||||
tf.add(pdf_path, arcname=DOC_PATH)
|
||||
return archive
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module app (the conftest env pattern + the phase-140 knobs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _app_env(mock_llm: int, app_port: int, scratch: Path) -> dict[str, str]:
|
||||
"""The conftest app env (leak guards included) with the phase-140
|
||||
knobs: ``BOR_IMPORT_EXTENSIONS`` pinned to a set carrying the
|
||||
``pdf`` token (the phase-126 gate — an operator's local gitignored
|
||||
``.env`` cannot leak the scope either way), ``BOR_PDF_PICTORIAL``
|
||||
pinned EXPLICITLY on (the conftest pins it off in ``os.environ``
|
||||
for the pre-phase suites — this suite is the documented opt-in;
|
||||
without it the whole premise is void), and ``BOR_IMAGES`` pinned
|
||||
off (out of scope — the vector figure yields no image doc anyway)."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_LLM_BASE_URL"] = (
|
||||
"https://aipi.reeseapps.com/v1"
|
||||
if USE_REAL_LLM
|
||||
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||
)
|
||||
# Mock-calibrated gate (conftest pattern) — the story's question
|
||||
# grounds on these values (see QUESTION).
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
|
||||
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
|
||||
# Phase 67: instant retry waits + the code-default budget.
|
||||
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
|
||||
env.setdefault(
|
||||
"BOR_DATABASE_URL",
|
||||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||
)
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||
# Leak guards (conftest pattern).
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
env["BOR_DOCS_REPO"] = ""
|
||||
env["BOR_SUGGESTIONS"] = json.dumps(
|
||||
Settings.model_fields["suggestions"].default
|
||||
)
|
||||
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
|
||||
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
|
||||
# Phase 140: the import scope WITH the ``pdf`` token + the toggle
|
||||
# under test (the EXPLICIT opt-in — see the module docstring) + the
|
||||
# suite-private homes.
|
||||
env["BOR_IMPORT_EXTENSIONS"] = "md,markdown,txt,pdf"
|
||||
env["BOR_PDF_PICTORIAL"] = "1"
|
||||
env["BOR_IMAGES"] = "false"
|
||||
env["BOR_UPLOAD_DIR"] = str(scratch / "uploads")
|
||||
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
|
||||
env["BOR_IMAGE_DIR"] = str(scratch / "images")
|
||||
return env
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(
|
||||
mock_llm: int, tmp_path_factory: pytest.TempPathFactory
|
||||
) -> Iterator[str]:
|
||||
"""The app under test (all three tests): one module app with the
|
||||
phase-140 env (see :func:`_app_env`)."""
|
||||
scratch = tmp_path_factory.mktemp("bor_pict_scratch")
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=_app_env(mock_llm, APP_PORT, scratch=scratch),
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{APP_URL}/api/health")
|
||||
yield APP_URL
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB + UI helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _truncate_all() -> None:
|
||||
"""Fresh KB + registry (the E2E isolation pattern): the suites share
|
||||
one Postgres, and a leftover row would corrupt the counts.
|
||||
``sources_meta`` (the KB generation counter) resets with the KB —
|
||||
the sync re-creates the row."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"TRUNCATE chunks, documents, query_log, steering_notes, "
|
||||
"kb_overview, git_sources, folder_summaries, sources_meta"
|
||||
)
|
||||
)
|
||||
db.commit() # without the commit the TRUNCATE rolls back (the house pattern)
|
||||
|
||||
|
||||
def _health_db(app_url: str) -> bool:
|
||||
try:
|
||||
return httpx.get(f"{app_url}/api/health", timeout=5).json()["db"] == "up"
|
||||
except Exception: # noqa: BLE001 — unreachable is the skip case
|
||||
return False
|
||||
|
||||
|
||||
def _wait_sync_settled(base_url: str, timeout_s: float = 60.0) -> None:
|
||||
"""No background sync may leak across tests (the run state lives in
|
||||
the app's memory, and a still-running run would keep importing into
|
||||
the NEXT test's truncated KB): wait for the app's sync status to be
|
||||
non-running BEFORE the truncate (the phase-96 pattern)."""
|
||||
with httpx.Client(base_url=base_url, timeout=5.0) as client:
|
||||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, r.text
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
r = client.get("/api/sync/status")
|
||||
assert r.status_code == 200, r.text
|
||||
if r.json()["state"] != "running":
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"a sync was still running at a test boundary: {base_url}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _clean_kb(app_url: str) -> Iterator[None]:
|
||||
"""Module-end KB cleanup (the phase-96 pattern, module granularity):
|
||||
the three tests SHARE the module seed's single source + document,
|
||||
so there is NO per-test truncate (it would wipe the seed from under
|
||||
the next test — the per-test pattern belongs to the suites where
|
||||
every test imports its own source); instead the shared KB is
|
||||
flushed ONCE, after the last test — no rows leak into the next
|
||||
suite's run. The pre-wait keeps a leaked sync (the module app is
|
||||
still up here) from racing the truncate."""
|
||||
yield
|
||||
_wait_sync_settled(app_url)
|
||||
_truncate_all()
|
||||
|
||||
|
||||
def _upload_tar(page: Page, app_url: str, archive: Path) -> None:
|
||||
"""The real admin upload flow: form login → the git-sources page →
|
||||
pick the archive → submit → the terminal result line (phase 90:
|
||||
"Uploaded <source> — press Sync sources to import it.")."""
|
||||
login(page, app_url, next="/git-sources.html")
|
||||
expect(page).to_have_url(app_url + "/git-sources.html", timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
page.set_input_files("#archive-upload-file", str(archive))
|
||||
page.click("#archive-upload-btn")
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(result).to_have_text(
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it.",
|
||||
timeout=UPLOAD_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
|
||||
def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
|
||||
"""The RAG page's "Sync sources" button (the scan): click →
|
||||
"Syncing…" → the terminal "Synced HH:MM" label + the fresh counts
|
||||
in #sync-result (the never-stale contract)."""
|
||||
page.goto(app_url + "/sources.html")
|
||||
btn = page.locator("#sync-btn")
|
||||
expect(btn).to_be_visible(timeout=30_000)
|
||||
expect(page.locator("#sync-label")).to_have_text(
|
||||
re.compile(r"^Sync sources$|^Synced \d{1,2}:\d{2}$")
|
||||
)
|
||||
btn.click()
|
||||
expect(btn).to_be_disabled()
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(page.locator("#sync-result")).to_have_text(expected_result)
|
||||
|
||||
|
||||
def _drill_to_source(page: Page, name: str) -> None:
|
||||
"""Top level → the source (the file table then holds its direct
|
||||
files — ours is at the source root)."""
|
||||
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=30_000)
|
||||
|
||||
|
||||
def _chat_frames(app_url: str, message: str) -> list[dict[str, Any]]:
|
||||
"""One ``POST /api/chat`` turn as RAW SSE frames (the wire lens —
|
||||
the house pattern from ``test_honest_deflection.py``): the signed
|
||||
admin client streams the turn and parses the ``data: <json>``
|
||||
frames (PLAN §4: ``data: <json>\\n\\n``)."""
|
||||
with httpx.Client(base_url=app_url, timeout=120.0) as client:
|
||||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, r.text
|
||||
frames: list[dict[str, Any]] = []
|
||||
with client.stream(
|
||||
"POST", "/api/chat", json={"message": message}, timeout=120.0
|
||||
) as r:
|
||||
assert r.status_code == 200, r.read().decode()
|
||||
buf = ""
|
||||
for part in r.iter_text():
|
||||
buf += part
|
||||
while "\n\n" in buf:
|
||||
frame, buf = buf.split("\n\n", 1)
|
||||
if frame.strip().startswith("data:"):
|
||||
frames.append(
|
||||
json.loads(frame.strip().removeprefix("data:").strip())
|
||||
)
|
||||
assert buf.strip() == "" # the stream ends cleanly on a frame boundary
|
||||
return frames
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The module seed: upload + sync the fixture PDF through the real UI,
|
||||
# once (the story E2E truncate/re-import-per-module pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def seeded(
|
||||
app_url: str,
|
||||
browser: Browser,
|
||||
tar_path: Path,
|
||||
pdf_path: Path,
|
||||
) -> Iterator[dict[str, str]]:
|
||||
"""Upload the single-PDF tar through the admin UI, then scan it with
|
||||
the RAG page's Sync button (the mock's vision model describes the
|
||||
sparse page in-process — the ``PICTORIAL_PAGE_MODE`` canned reply).
|
||||
The seed asserts the stored document BYTE-EXACT: the fixture's own
|
||||
extraction (page blocks, the page-2 candidate carrying the canned
|
||||
illustration block) — the candidates computed from
|
||||
``extract_pdf_page_texts`` + ``pictorial_pages`` IN the fixture (no
|
||||
hardcoded page list). This is the module app's toggle premise
|
||||
witness too: without the ``BOR_PDF_PICTORIAL`` opt-in, page 2 would
|
||||
lack its block and this fails loudly. Yields the doc's identity."""
|
||||
if not _health_db(app_url):
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
|
||||
_truncate_all()
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||||
try:
|
||||
_upload_tar(page, app_url, tar_path)
|
||||
# The PDF alone (no embedded raster images — the vector figure
|
||||
# yields no image doc): one file, one added document.
|
||||
_sync_via_ui(page, app_url, expected_result="1 added")
|
||||
with SessionLocal() as db:
|
||||
doc = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == SOURCE_NAME, Document.path == DOC_PATH
|
||||
)
|
||||
)
|
||||
assert doc is not None, "the uploaded PDF must become a document"
|
||||
# LOCKED A2: one document per PDF, the metadata title; a TEXT
|
||||
# document (no image doc, no image path).
|
||||
assert doc.title == PDF_TITLE
|
||||
assert doc.is_image is False and doc.image_path is None
|
||||
# The candidates (computed from the fixture itself — the
|
||||
# deterministic gate, LOCKED A1) + the byte-exact stored
|
||||
# content: the extraction's page blocks, the described
|
||||
# candidate carrying ``[Page N illustration: <canned>]``
|
||||
# (LOCKED A4).
|
||||
page_texts = extract_pdf_page_texts(pdf_path)
|
||||
candidates = pictorial_pages(
|
||||
page_texts,
|
||||
min_chars=Settings.model_fields["pdf_pictorial_min_chars"].default,
|
||||
)
|
||||
assert candidates == [2]
|
||||
parts = []
|
||||
for p, t in page_texts.items():
|
||||
block = t
|
||||
if p in candidates:
|
||||
block += f"\n\n[Page {p} illustration: {PICTORIAL_PAGE_ANSWER}]"
|
||||
parts.append(f"{PAGE_SEPARATOR.format(page=p)}\n{block}")
|
||||
expected = "\n\n".join(parts)
|
||||
assert doc.content == expected, (
|
||||
"the stored content is not the extraction with the "
|
||||
"described sparse page — the pictorial pass misfired"
|
||||
)
|
||||
# The non-markdown doc got its lite summary (the mock's
|
||||
# SUMMARY_MODE digest) — the summary chunk + the viewer's
|
||||
# Summary panel exist.
|
||||
assert doc.summary
|
||||
finally:
|
||||
page.close()
|
||||
yield {"title": PDF_TITLE}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Sources page + document viewer (a): the described page renders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sources_page_and_viewer_show_the_described_page(
|
||||
page: Page, app_url: str, seeded: dict[str, str]
|
||||
) -> None:
|
||||
"""The KB total is 1 doc / 3 chunks (the PDF: 2 page-anchored
|
||||
content chunks + the phase-30 summary chunk — no image doc; the
|
||||
vector figure is invisible to the embedded-image pipeline). The
|
||||
drill-down table lists the PDF as a plain text row (LOCKED A4), and
|
||||
the same-page document viewer (the row's path link, phase 26)
|
||||
renders the stored content under the METADATA title: page 1's prose
|
||||
WITHOUT an illustration block (not a candidate) and page 2 WITH
|
||||
``[Page 2 illustration: <canned>]`` (the described sparse page —
|
||||
LOCKED A4's user surface), plus the Summary panel (a non-image doc
|
||||
with a non-empty summary)."""
|
||||
login(page, app_url) # lands on /sources.html
|
||||
expect(page.locator("#stat-docs")).to_have_text("1")
|
||||
expect(page.locator("#stat-chunks")).to_have_text("3")
|
||||
expect(page.locator("#sources-empty")).to_be_hidden()
|
||||
|
||||
_drill_to_source(page, SOURCE_NAME)
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(1)
|
||||
row = page.locator("#docs-tbody tr").first
|
||||
# A plain text row — no thumbnail box (it is a text document, A4).
|
||||
expect(row.locator(".kb-doc-thumb")).to_have_count(0)
|
||||
|
||||
page.locator(f'#docs-tbody a.doc-link:text-is("{DOC_PATH}")').click()
|
||||
modal = page.locator("#doc-modal")
|
||||
expect(modal).to_be_visible(timeout=30_000)
|
||||
expect(page.locator("#doc-modal-title")).to_have_text(PDF_TITLE)
|
||||
|
||||
raw = modal.locator("pre.doc-raw")
|
||||
# Page 1 — the prose WITHOUT any illustration block. The viewer
|
||||
# renders the content verbatim (``insert_textbox`` wraps mid-
|
||||
# sentence, so compare whitespace-normalized).
|
||||
expect(raw).to_contain_text("---PAGE-1---")
|
||||
rendered = " ".join(raw.inner_text().split())
|
||||
assert " ".join(PAGE_ONE.split()) in rendered, (
|
||||
"the page-1 prose is not what the viewer renders"
|
||||
)
|
||||
assert "[Page 1 illustration" not in rendered, (
|
||||
"page 1 (over the sparseness bar) must carry NO illustration block"
|
||||
)
|
||||
# Page 2 — the sparse page WITH the described block (a) — the mock's
|
||||
# canned reply, deterministic.
|
||||
expect(raw).to_contain_text("---PAGE-2---")
|
||||
expect(raw).to_contain_text(PAGE_TWO)
|
||||
expect(raw).to_contain_text(ILLUSTRATION_BLOCK)
|
||||
# The Summary panel renders (a non-image doc with a non-empty
|
||||
# summary); no image block (the PDF is not an image document).
|
||||
expect(modal.locator(".doc-summary")).to_have_count(1)
|
||||
expect(modal.locator(".doc-image-img")).to_have_count(0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Chat (b): the question only the illustration answers grounds, and
|
||||
# the answer echoes the described phrase
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_question_grounds_and_echoes_the_illustration(
|
||||
app_url: str, seeded: dict[str, str]
|
||||
) -> None:
|
||||
"""The raw SSE over the scripted seeded-read question (phase 119 A1
|
||||
— a zero-read grounded turn chips NOTHING, so the PDF must be READ
|
||||
to earn its ``done.sources`` citation): the turn GROUNDS (no
|
||||
deflection — the described page's embedding retrieves: the mock's
|
||||
deterministic token-overlap cosine clears the mock-calibrated 0.30
|
||||
gate, FTS corroborates), the mock's scripted ``read`` runs (EXACTLY
|
||||
one ``tool`` frame — the doc is far under the read cap, so no
|
||||
truncation and no ``tool_result`` frame), and the answer ECHOES the
|
||||
read result verbatim — the ``[Page 2 illustration: …]`` block and
|
||||
its phrase reach the model and land in the rendered answer (the
|
||||
described page's content is what a chat turn can ground on — the
|
||||
owner's L5 rationale end to end)."""
|
||||
frames = _chat_frames(app_url, QUESTION)
|
||||
assert frames, "the SSE stream settled with no frames at all"
|
||||
assert not any(f.get("type") == "error" for f in frames), (
|
||||
f"the turn errored: {[f for f in frames if f.get('type') == 'error']}"
|
||||
)
|
||||
|
||||
# The scripted read — exactly one tool call, on the PDF.
|
||||
tools = [f for f in frames if f.get("type") == "tool"]
|
||||
assert len(tools) == 1, f"expected exactly one tool frame: {tools}"
|
||||
assert tools[0]["name"] == "read"
|
||||
assert tools[0]["argument"] == f"{SOURCE_NAME}/{DOC_PATH}"
|
||||
# The doc is far under the read cap — no truncation frame (the echo
|
||||
# below therefore carries the FULL content, the block included).
|
||||
assert not any(f.get("type") == "tool_result" for f in frames)
|
||||
|
||||
# The terminal frame: ``done``, NOT deflected.
|
||||
done = [f for f in frames if f.get("type") == "done"]
|
||||
assert len(done) == 1 and frames[-1] is done[0], (
|
||||
f"the stream must settle with exactly one terminal done frame: "
|
||||
f"{[f.get('type') for f in frames]}"
|
||||
)
|
||||
assert done[0]["deflected"] is False, (
|
||||
"the question grounded on the described page — a deflection means "
|
||||
"the illustrated content did not retrieve"
|
||||
)
|
||||
# The ``done`` event's ``sources`` — the citation surface (read docs
|
||||
# only, phase 119 A1) — carries EXACTLY the PDF.
|
||||
refs = done[0]["sources"]
|
||||
assert [(r["source"], r["path"]) for r in refs] == [(SOURCE_NAME, DOC_PATH)], (
|
||||
f"the done event's sources must be exactly the read PDF: {refs}"
|
||||
)
|
||||
assert refs[0]["title"] == PDF_TITLE
|
||||
|
||||
# The answer (the joined deltas) echoes the read result VERBATIM —
|
||||
# the header line and the described block with the canned phrase.
|
||||
answer = "".join(f["text"] for f in frames if f.get("type") == "delta")
|
||||
assert "Here's what the read returned:" in answer
|
||||
assert f"Document {SOURCE_NAME}/{DOC_PATH}:" in answer
|
||||
assert ILLUSTRATION_BLOCK in answer, (
|
||||
"the answer does not echo the described illustration block — "
|
||||
"the vision pass's content never reached the model"
|
||||
)
|
||||
assert PICTORIAL_PAGE_ANSWER in answer # the phrase itself (the task's pin)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Sync detail (c): the pre-phase key set is untouched (stats are
|
||||
# log-only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sync_detail_keeps_the_pre_phase_keys(
|
||||
app_url: str, seeded: dict[str, str]
|
||||
) -> None:
|
||||
"""The module run's terminal ``detail`` (``GET /api/sync/status`` —
|
||||
the same object the UI's "1 added" line renders from) keeps the
|
||||
pre-phase key set EXACTLY: the phase-140 counters
|
||||
(``pdfs_pictorial`` / ``pdf_pictorial_pages``) are log-only (PLAN
|
||||
§9, the phase-133 convention) and must NOT ride the wire, and the
|
||||
run's own file counters read 1 file / 1 added / 0 unchanged /
|
||||
0 pruned / 0 errors (the single PDF, a fresh KB)."""
|
||||
with httpx.Client(base_url=app_url, timeout=30.0) as client:
|
||||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, r.text
|
||||
r = client.get("/api/sync/status")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["state"] == "success", body
|
||||
detail = body["detail"]
|
||||
# The stats log-only convention — the pictorial counters never
|
||||
# reach the wire.
|
||||
assert "pdfs_pictorial" not in detail, (
|
||||
"the log-only pdfs_pictorial counter leaked into the sync detail"
|
||||
)
|
||||
assert "pdf_pictorial_pages" not in detail, (
|
||||
"the log-only pdf_pictorial_pages counter leaked into the sync detail"
|
||||
)
|
||||
# The shape itself — the pre-phase key set EXACTLY (no new field).
|
||||
assert set(detail) == set(PRE_PHASE_DETAIL_KEYS), (
|
||||
f"the sync detail shape changed — got {sorted(detail)}"
|
||||
)
|
||||
# The run's own file counters (a fresh KB: one file, one added).
|
||||
assert detail["files"] == 1, detail
|
||||
assert detail["added"] == 1, detail
|
||||
assert detail["unchanged"] == 0, detail
|
||||
assert detail["pruned"] == 0, detail
|
||||
assert detail["errors"] == 0, detail
|
||||
@@ -34,16 +34,26 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import tarfile
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pymupdf as fitz # PyMuPDF — the temp-PDF fixture builder (phase 126)
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import Chunk, Document
|
||||
from app.rag.importer import import_sources
|
||||
from app.rag.llm import EmbeddingError
|
||||
from app.rag.summarizer import DESCRIBE_PROMPT
|
||||
from app.rag.pdf import (
|
||||
PAGE_SEPARATOR,
|
||||
extract_pdf_page_texts,
|
||||
extract_pdf_text,
|
||||
pictorial_pages,
|
||||
)
|
||||
from app.rag.summarizer import DESCRIBE_PROMPT, PICTORIAL_PAGE_MODE
|
||||
from tests.fakes import FakeEmbedder
|
||||
|
||||
#: A real 1×1 transparent PNG (the phase-122 fixture — the importer is
|
||||
@@ -99,10 +109,17 @@ def _image_llm[
|
||||
](tmp_path, llm_cls: type[LLM] = _MockVisionLLM, **kwargs) -> LLM:
|
||||
"""A fake LLM with the phase-122 image knobs (toggle ON; the image
|
||||
dir defaults under *tmp_path*). *llm_cls* may be the mock vision
|
||||
client (the default — ``text_parts``/``chat_models`` state)."""
|
||||
client (the default — ``text_parts``/``chat_models`` state).
|
||||
Phase 140: ``pdf_pictorial`` defaults to OFF here — the pre-phase-
|
||||
140 PDF suites in this file pin the pre-phase 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); the phase-140 suites pass
|
||||
``pdf_pictorial=True`` explicitly."""
|
||||
kwargs.setdefault("_env_file", None)
|
||||
kwargs.setdefault("images", True)
|
||||
kwargs.setdefault("image_dir", str(tmp_path / "images"))
|
||||
kwargs.setdefault("pdf_pictorial", False)
|
||||
llm = llm_cls()
|
||||
llm.settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
return llm
|
||||
@@ -1506,3 +1523,314 @@ def test_backfill_embed_failure_rolls_back_the_image_and_retries_next_sync(
|
||||
assert img2.image_context.startswith("Extracted from ")
|
||||
finally:
|
||||
_cleanup_source(db, source_root.name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 140 — Pictorial PDFs: sparse pages rendered + vision-described
|
||||
# into the indexed content (the owner's IKEA repro, TODO L5; the kill
|
||||
# switch; the phase-133 precedence pin).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
#: The owner's untracked fixture (TODO L5 — the stable reference: the
|
||||
#: TARBALL, never the extracted PDF; the phase-133 real-fixture
|
||||
#: convention — ``test_sources/`` is gitignored, the test extracts it
|
||||
#: into ``tmp_path`` and never touches the repo, and the owner's
|
||||
#: machine always has it: a missing tar skips LOUDLY with the named
|
||||
#: reason, it is never silently green).
|
||||
IKEA_TARBALL = REPO_ROOT / "test_sources" / "ikea_instructions.tar"
|
||||
|
||||
#: The temp source dir's basename is the source name (phase 38).
|
||||
IKEA_SOURCE = "ikea"
|
||||
|
||||
#: The fixture's embedded metadata title (Adobe InDesign's
|
||||
#: "Assembly Instructions" — the phase-140 context).
|
||||
IKEA_TITLE = "Assembly Instructions"
|
||||
|
||||
#: The fixture's text-heavy pages (pages 2–5 — the multilingual
|
||||
#: warnings, 3,091–7,288 chars — never candidates; the sparse pages
|
||||
#: run 4–73 chars, so the 100-char bar sits between the classes).
|
||||
IKEA_TEXT_HEAVY_PAGES = (2, 3, 4, 5)
|
||||
|
||||
|
||||
class _PictorialVisionLLM(FakeEmbedder):
|
||||
"""The capturing multimodal fake (task 03): a vision call whose
|
||||
text part carries ``PICTORIAL_PAGE_MODE`` (the pictorial pass's
|
||||
seam — ``describe_pdf_page``) gets the fixed per-candidate canned
|
||||
reply and is recorded in ``pictorial_texts``; a call carrying
|
||||
``IMAGE_DESCRIPTION_MODE`` (the phase-133 fallback's / phase-122
|
||||
images' seam — ``describe_image``) gets a DISTINCT canned reply
|
||||
and is recorded in ``image_texts``. The fixed replies make the
|
||||
stored content a PURE FUNCTION of the fixture (the digest-stable
|
||||
premise of the re-sync assertion), and the two recordings witness
|
||||
WHICH seam fired (the precedence pin). String (``lite``) calls
|
||||
keep the ``FakeEmbedder`` behaviour. The REAL
|
||||
``import_sources`` → ``_pictorial_pdf_pass`` →
|
||||
``describe_pdf_page`` chain runs against it (no seam patch)."""
|
||||
|
||||
PICTORIAL_ANSWER = (
|
||||
"IKEA pictorial page: the illustration shows two parts being "
|
||||
"joined with a blue arrow; a cam lock is tightened with the "
|
||||
"included hex key."
|
||||
)
|
||||
IMAGE_ANSWER = (
|
||||
"Fallback screenshot: the page shows an assembly pictogram."
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.pictorial_texts: list[str] = []
|
||||
self.image_texts: list[str] = []
|
||||
|
||||
async def chat(self, messages, model=None):
|
||||
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"), "")
|
||||
if PICTORIAL_PAGE_MODE in text:
|
||||
self.pictorial_texts.append(text)
|
||||
return self.PICTORIAL_ANSWER
|
||||
self.image_texts.append(text)
|
||||
return self.IMAGE_ANSWER
|
||||
first = user.split()
|
||||
return "Summary of " + (first[0] if first else "<empty>")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ikea_pdf(db, tmp_path) -> Iterator[Path]:
|
||||
"""The owner's IKEA tarball extracted into a temp ``ikea/`` source
|
||||
dir (the phase-133 real-fixture convention — ``tmp_path`` only,
|
||||
never the repo). Any leftover ``ikea`` source rows from a crashed
|
||||
earlier run are cleaned before AND after (the shared dev DB is
|
||||
reused across runs). Skips LOUDLY with the named reason when the
|
||||
untracked owner fixture is missing."""
|
||||
if not IKEA_TARBALL.is_file():
|
||||
pytest.skip(
|
||||
"test_sources/ikea_instructions.tar is missing — the untracked "
|
||||
"owner fixture (one 24-page vector-drawn assembly-instruction "
|
||||
"PDF; the owner's machine always has it)"
|
||||
)
|
||||
_cleanup_source(db, IKEA_SOURCE)
|
||||
root = tmp_path / IKEA_SOURCE
|
||||
root.mkdir()
|
||||
with tarfile.open(IKEA_TARBALL) as tar:
|
||||
tar.extractall(root, filter="data")
|
||||
pdfs = sorted(root.rglob("*.pdf"))
|
||||
assert len(pdfs) == 1, f"the IKEA tar carries exactly one PDF, found: {pdfs}"
|
||||
yield pdfs[0]
|
||||
_cleanup_source(db, IKEA_SOURCE)
|
||||
|
||||
|
||||
def test_ikea_tar_sparse_pages_are_described_and_rerun_unchanged(
|
||||
db, tmp_path, ikea_pdf: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The owner's EXACT file (TODO L5) through the REAL importer:
|
||||
the deterministic gate's candidates — computed IN the test from
|
||||
``extract_pdf_page_texts`` + ``pictorial_pages`` (no hardcoded
|
||||
page list — the fixture is stable but the test must not rot) —
|
||||
every one carries ``[Page N illustration: <canned>]`` AFTER its
|
||||
own text (LOCKED A2 — no LLM judge: every described page joins
|
||||
the content; A4 — the page's text stays), the text-heavy pages
|
||||
(2–5) carry NO block, the chunks carry the canned description
|
||||
(the owner's rationale — more information is better for the
|
||||
embeddings), the title is the metadata title, the digest is over
|
||||
the FINAL content, and a re-sync with the same fake lands
|
||||
``unchanged`` (digest stable, byte-stable row, NO re-embed; 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)."""
|
||||
pdf_path = ikea_pdf
|
||||
llm = _image_llm(
|
||||
tmp_path,
|
||||
llm_cls=_PictorialVisionLLM,
|
||||
pdf_pictorial=True,
|
||||
import_extensions="md,txt,pdf",
|
||||
)
|
||||
try:
|
||||
with caplog.at_level(logging.INFO, logger="app.importer"):
|
||||
s1 = asyncio.run(import_sources([pdf_path.parent], llm, session=db))
|
||||
assert (s1.files, s1.added, s1.errors) == (1, 1, 0)
|
||||
assert s1.formats == {"pdf": 1}
|
||||
|
||||
# The candidates — computed IN the test (no hardcoded page
|
||||
# list). The fixture class: the sparse pages are 4–73 chars,
|
||||
# the text-heavy pages 3,091+ — the 100-char bar separates.
|
||||
page_texts = extract_pdf_page_texts(pdf_path)
|
||||
candidates = pictorial_pages(
|
||||
page_texts, min_chars=llm.settings.pdf_pictorial_min_chars
|
||||
)
|
||||
assert candidates, "the fixture must carry sparse (pictorial) pages"
|
||||
for n in IKEA_TEXT_HEAVY_PAGES:
|
||||
assert n not in candidates, f"page {n} is text-heavy, never a candidate"
|
||||
|
||||
doc = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == IKEA_SOURCE, Document.path == pdf_path.name
|
||||
)
|
||||
)
|
||||
assert doc is not None, "the IKEA PDF did not import"
|
||||
assert doc.title == IKEA_TITLE # the PDF's metadata title
|
||||
assert doc.is_image is False
|
||||
|
||||
content = doc.content
|
||||
# Every page's block is byte-faithful: the page text, the
|
||||
# described candidates carrying their illustration block
|
||||
# AFTER the text (A4), and NO block on the non-candidates.
|
||||
for n, text in page_texts.items():
|
||||
block = f"{PAGE_SEPARATOR.format(page=n)}\n{text}"
|
||||
if n in candidates:
|
||||
block += (
|
||||
f"\n\n[Page {n} illustration: "
|
||||
f"{_PictorialVisionLLM.PICTORIAL_ANSWER}]"
|
||||
)
|
||||
assert block in content, f"page {n}'s block is not byte-faithful"
|
||||
if n not in candidates:
|
||||
assert f"[Page {n} illustration" not in content
|
||||
assert content.count(" illustration: ") == len(candidates)
|
||||
# The digest runs over the FINAL content (the phase-126/133
|
||||
# convention — A4).
|
||||
assert doc.content_hash == hashlib.sha256(
|
||||
content.encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
# The chunks carry the canned description (the owner's L5
|
||||
# rationale — the embeddings hit the illustrations), and the
|
||||
# phase-30/118 summary ran on the final content.
|
||||
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 any(
|
||||
_PictorialVisionLLM.PICTORIAL_ANSWER in c.content for c in content_chunks
|
||||
), "no content chunk carries the canned description"
|
||||
for c in chunks:
|
||||
assert c.embedding is not None and len(c.embedding) == 768
|
||||
assert doc.summary is not None
|
||||
|
||||
# The counters (log-only — the sync detail shape is untouched)
|
||||
# + the ONE loud log line (PLAN §9), with the computed N.
|
||||
assert s1.pdfs_pictorial == 1
|
||||
assert s1.pdf_pictorial_pages == len(candidates)
|
||||
assert s1.pdfs_screenshotted == 0 # the layer is USABLE — no fallback
|
||||
assert llm.image_texts == [] # the fallback seam never fired
|
||||
min_chars = llm.settings.pdf_pictorial_min_chars
|
||||
lines = [
|
||||
r.getMessage() for r in caplog.records if "pictorial pass" in r.getMessage()
|
||||
]
|
||||
assert lines == [
|
||||
f"pdf: pictorial pass source={IKEA_SOURCE} path={pdf_path.name} — "
|
||||
f"{len(candidates)} candidate pages, {len(candidates)} described "
|
||||
f"(min_chars={min_chars})"
|
||||
]
|
||||
|
||||
# The re-sync with the SAME fake: ``unchanged`` — the final
|
||||
# content is a pure function of the fixture + the canned
|
||||
# replies, so the digest matches and the row is skipped (no
|
||||
# re-chunk, no re-embed, no re-summary, no KB change). The
|
||||
# vision calls ARE re-issued (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).
|
||||
embeds1 = len(llm.calls)
|
||||
pictorial1 = len(llm.pictorial_texts)
|
||||
content1, digest1 = doc.content, doc.content_hash
|
||||
s2 = asyncio.run(import_sources([pdf_path.parent], llm, session=db))
|
||||
assert (s2.added, s2.updated, s2.unchanged) == (0, 0, 1)
|
||||
assert s2.pdfs_pictorial == 1 # the pass re-fired
|
||||
assert len(llm.pictorial_texts) == 2 * pictorial1 # re-issued (cost)
|
||||
assert len(llm.calls) == embeds1, "unchanged: no re-embed"
|
||||
assert s2.changed_paths == [], "unchanged: no KB change recorded"
|
||||
doc2 = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == IKEA_SOURCE, Document.path == pdf_path.name
|
||||
)
|
||||
)
|
||||
assert doc2 is not None
|
||||
assert doc2.content == content1 # byte-stable content
|
||||
assert doc2.content_hash == digest1 # byte-stable digest
|
||||
finally:
|
||||
_cleanup_source(db, IKEA_SOURCE)
|
||||
|
||||
|
||||
def test_ikea_tar_kill_switch_is_byte_identical_to_extraction(
|
||||
db, tmp_path, ikea_pdf: Path
|
||||
) -> None:
|
||||
"""``BOR_PDF_PICTORIAL=0`` (the house kill switch — LOCKED A3):
|
||||
the pass never runs on the owner's file — no vision call, no
|
||||
counters, no log line — and the stored content is
|
||||
``extract_pdf_text``'s output BYTE-IDENTICAL to the pre-phase-140
|
||||
path."""
|
||||
pdf_path = ikea_pdf
|
||||
llm = _image_llm(
|
||||
tmp_path,
|
||||
llm_cls=_PictorialVisionLLM,
|
||||
pdf_pictorial=False,
|
||||
import_extensions="md,txt,pdf",
|
||||
)
|
||||
try:
|
||||
s1 = asyncio.run(import_sources([pdf_path.parent], llm, session=db))
|
||||
assert (s1.files, s1.added, s1.errors) == (1, 1, 0)
|
||||
doc = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == IKEA_SOURCE, Document.path == pdf_path.name
|
||||
)
|
||||
)
|
||||
assert doc is not None
|
||||
assert doc.content == extract_pdf_text(pdf_path), (
|
||||
"the kill switch must keep the pre-phase-140 content "
|
||||
"byte-identical"
|
||||
)
|
||||
assert llm.pictorial_texts == [] # zero vision calls
|
||||
assert (s1.pdfs_pictorial, s1.pdf_pictorial_pages) == (0, 0)
|
||||
finally:
|
||||
_cleanup_source(db, IKEA_SOURCE)
|
||||
|
||||
|
||||
def test_garbage_pdf_with_pictorial_on_takes_the_fallback_not_the_pass(
|
||||
db, tmp_path
|
||||
) -> None:
|
||||
"""The precedence pin (LOCKED): an UNUSABLE text layer takes the
|
||||
phase-133 full-page screenshot fallback — the pictorial pass
|
||||
NEVER runs and never double-visions (the gate is the only gate):
|
||||
with ``pdf_pictorial=True`` (the code default), a garbage-text
|
||||
PDF (the phase-133 synthetic recipe — XML-ish lines) lands with
|
||||
the fallback's full-page canned descriptions, and the fake
|
||||
records ZERO ``PICTORIAL_PAGE_MODE`` calls."""
|
||||
source_root = tmp_path / "garbpike"
|
||||
source_root.mkdir()
|
||||
doc = fitz.open()
|
||||
for p in (1, 2):
|
||||
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(source_root / "garbage.pdf")
|
||||
doc.close()
|
||||
llm = _image_llm(
|
||||
tmp_path,
|
||||
llm_cls=_PictorialVisionLLM,
|
||||
pdf_pictorial=True,
|
||||
import_extensions="md,txt,pdf",
|
||||
)
|
||||
try:
|
||||
summary = asyncio.run(import_sources([source_root], llm, session=db))
|
||||
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
|
||||
assert summary.pdfs_screenshotted == 1 # the gate fired → the fallback
|
||||
assert summary.pdfs_pictorial == 0
|
||||
assert summary.pdf_pictorial_pages == 0
|
||||
assert llm.pictorial_texts == [], (
|
||||
"the pictorial pass must never run on an unusable layer "
|
||||
"(no double vision)"
|
||||
)
|
||||
assert len(llm.image_texts) == 2 # one fallback describe per page
|
||||
row = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == source_root.name, Document.path == "garbage.pdf"
|
||||
)
|
||||
)
|
||||
assert row is not None
|
||||
assert row.content == (
|
||||
f"---PAGE-1---\n{_PictorialVisionLLM.IMAGE_ANSWER}\n\n"
|
||||
f"---PAGE-2---\n{_PictorialVisionLLM.IMAGE_ANSWER}"
|
||||
)
|
||||
finally:
|
||||
_cleanup_source(db, source_root.name)
|
||||
|
||||
@@ -360,7 +360,16 @@ def test_hnb_guide_imports_clean_through_the_real_pipeline(
|
||||
)
|
||||
]
|
||||
assert len(multimodal_calls) >= 1, "the multimodal chat was never called"
|
||||
assert len(llm.vision_texts) == len(image_docs), (
|
||||
# Phase 140 (task 03): the default-ON pictorial pass ALSO runs on
|
||||
# this fixture's sparse pages (the fake is seam-agnostic — the
|
||||
# same canned reply) — so the pin counts only the EMBEDDED-IMAGE
|
||||
# describe calls (the ``DESCRIBE_PROMPT``-headed text parts; the
|
||||
# ``PICTORIAL_PAGE_MODE`` parts are the pass's own calls, covered
|
||||
# by the phase-140 suites).
|
||||
image_vision_texts = [
|
||||
t for t in llm.vision_texts if t.startswith(DESCRIBE_PROMPT)
|
||||
]
|
||||
assert len(image_vision_texts) == len(image_docs), (
|
||||
"one vision call per embedded image doc (no failures)"
|
||||
)
|
||||
# The locked prompt + the generalized prefix + the document context
|
||||
@@ -372,7 +381,7 @@ def test_hnb_guide_imports_clean_through_the_real_pipeline(
|
||||
+ _expected_context_block(PDF_REL, _page_of(img.path), page_texts)
|
||||
for img in image_docs
|
||||
)
|
||||
assert Counter(llm.vision_texts) == expected_calls
|
||||
assert Counter(image_vision_texts) == expected_calls
|
||||
assert summary.images_failed == 0
|
||||
|
||||
# The added accounting (see the module docstring): the walked 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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) == []
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user