phase: 148_pdf_pages_in_sources
Build and Push Containers / build-and-push-app (push) Successful in 2m28s
Build and Push Containers / build-and-push-db (push) Successful in 16s

**Phase 148 — final verification pass: complete, 100% green.**

- **Verified (tasks 01–04 already implemented):** page render/persist at index (`_persist_page_copies`, fail-soft, stale-replace, qualifier-loss, prune hook via `_delete_page_copies`); public `GET /api/documents/{id}/pages/{n}` (uuid4-credential, 404-all-non-servable, `image/png` + cache header); `DocContent.page_urls` additive-omitted; shared `renderDocument` strip (lazy imgs, "Page N" captions, text + Summary suppressed, onerror note, createElement-only); migration 0026; `pdf_page_dir` setting; `.env.example` entry
- **Completion criteria:** (1) pictorial + fallback classes get `p{N}.png` + `page_count`, text-heavy gets neither — pinned, pass; (2) unchanged re-sync renders zero (fast path) — pass; (3) changed re-index replaces copies, qualifier-loss/prune delete — pass; (4) route 404 matrix + payload key absent-never-null — pass; (5) modal AND /document.html strip with suppression, text-heavy byte-identical — E2E pass; (6) see below; (7) no completed-phase drift — full suite green
- **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → **3461 passed, 0 failed**, `app/` coverage **99%** (>90%); `uv run pytest tests/e2e/test_pdf_pages_in_sources.py -v --no-cov` → **4 passed** in isolation; `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings
- **Documentation:** README had **1 pre-existing inconsistency** — the Production Deployment section's standalone `podman run` example (and compose note) omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the fail-loud boot gate the README's own Admin section documents — the container would refuse to start); fixed with the two `-e` flags + a note for the compose path. All else verified consistent: 70/70 `.env.example` vars documented, all defaults match `config.py`, pages table matches routes, Containerfile/esbuild/uid-10001/healthcheck/entrypoint claims accurate, QA commands match pytest config. **AGENTS.md: verified consistent — no changes needed**
- **Notable:** repo uses `Containerfile` (Podman) — README's `podman build` example is valid as-is; no live infrastructure touched
- **Next pending phase:** `149_sync_progress_box`
This commit is contained in:
2026-10-01 16:58:01 -04:00
parent 863519e980
commit b233c8d714
39 changed files with 3426 additions and 56 deletions
+12
View File
@@ -547,6 +547,18 @@ def test_sources_dir_env_override_is_raw_string(monkeypatch: pytest.MonkeyPatch)
assert s.sources_dir == "/data/bor/sources"
def test_pdf_page_dir_default_and_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
"""Phase 148 (task 01): ``pdf_page_dir`` — the persistent home for
a page-qualifying PDF's rendered page PNGs — defaults to
``~/bor-sources/pages`` and, like ``sources_dir``/``upload_dir``/
``image_dir``, is a RAW string (``Path.expanduser()`` is applied by
the importer, not here) that ``BOR_PDF_PAGE_DIR`` overrides."""
monkeypatch.delenv("BOR_PDF_PAGE_DIR", raising=False)
assert _settings().pdf_page_dir == "~/bor-sources/pages"
monkeypatch.setenv("BOR_PDF_PAGE_DIR", "/data/bor/pages")
assert _settings().pdf_page_dir == "/data/bor/pages"
def test_suggestions_default_is_three_plus_real_questions() -> None:
s = _settings()
assert len(s.suggestions) >= 3
+74 -2
View File
@@ -21,6 +21,15 @@ Frontend side:
import-safe page guard, the modal skeleton on chat + Sources, and the
modal module's close/focus/URL contract.
Phase 148 (task 02) — the content payload's rendered-page routes
(the viewer strip, task 03, reads from them): the ``DocContent``
omission rule — ``page_urls`` ABSENT on the wire when ``None`` (never
``null``; a non-qualifying doc's payload is byte-identical to
pre-phase) — and the content endpoint (stubbed session) — a
page-qualifying PDF row (``page_count`` set) adds ``page_urls`` = the
exact 1-based ``/api/documents/<id>/pages/<i>`` list over
``1..page_count``.
Phase 145 (task 03) — the content payload's git origin fields + the
``renderDocument`` "Original" anchor (modal AND /document.html, one
site):
@@ -46,6 +55,7 @@ import json
import re
import shutil
import subprocess
import uuid
from datetime import UTC, datetime
from pathlib import Path
@@ -279,10 +289,13 @@ def _text_doc() -> Document:
def _text_doc_content(
origin_url: str | None = None, origin_branch: str | None = None
origin_url: str | None = None,
origin_branch: str | None = None,
page_urls: list[str] | None = None,
) -> DocContent:
"""One text doc's content payload (the pre-phase shape) with the
phase-145 origin fields optionally set."""
phase-145 origin fields + the phase-148 ``page_urls`` optionally
set."""
return DocContent(
source="S",
path="a.md",
@@ -294,6 +307,7 @@ def _text_doc_content(
chunks=1,
origin_url=origin_url,
origin_branch=origin_branch,
page_urls=page_urls,
)
@@ -316,6 +330,64 @@ def test_doc_content_origin_fields_absent_on_the_wire_when_unset() -> None:
assert dumped["origin_branch"] == "docs"
def test_doc_content_page_urls_absent_on_the_wire_when_unset() -> None:
"""Phase 148 (task 02) — the model-level omission rule (the
``image_url`` precedent): a non-qualifying document (``page_urls``
``None`` — a text doc, an image, a text-heavy PDF) serializes with
the key ABSENT — never ``null`` — so its payload is byte-identical
to pre-phase. A page-qualifying PDF keeps the exact route list."""
dumped = _text_doc_content().model_dump()
assert "page_urls" not in dumped # absent — never null
urls = ["/api/documents/d/pages/1", "/api/documents/d/pages/2"]
dumped = _text_doc_content(page_urls=urls).model_dump()
assert dumped["page_urls"] == urls # the exact 1-based route list
def _page_pdf_doc() -> Document:
"""A page-QUALIFYING PDF row (phase 148): ``page_count`` set — the
endpoint derives ``page_urls`` from it (the exact 1-based list)."""
doc = Document(
id=uuid.uuid4(),
source="Homelab",
path="ikea/besta.pdf",
full_path="/tmp/besta.pdf",
title="BESTA frame (white) assembly",
content="[Page 1 illustration: the parts tray]…",
content_hash="e" * 64,
is_image=False, # phase 122: the stub session never applies defaults
page_count=2,
)
doc.indexed_at = datetime(2026, 8, 22, 1, 2, 3, tzinfo=UTC)
doc.created_at = datetime(2026, 8, 20, 9, 0, 0, tzinfo=UTC)
return doc
def test_content_page_urls_carried_for_a_page_qualifying_pdf() -> None:
"""Phase 148 (task 02) — the endpoint (stubbed session, no
database): a page-qualifying PDF row (``page_count`` set) carries
``page_urls`` = the EXACT 1-based
``/api/documents/<id>/pages/<i>`` list over ``1..page_count``
(the viewer strip's source, task 03 — a per-page render gap is
still listed; the route 404s that N). Every pre-phase key stands
untouched (``is_image`` false, ``image_url`` ABSENT — the PDF is
not an image doc; the document's TEXT stays user-gated, only the
page bytes are public)."""
doc = _page_pdf_doc()
with _client_with_row((doc, 4)) as client:
r = client.get(
"/api/documents/content",
params={"source": "Homelab", "path": "ikea/besta.pdf"},
)
assert r.status_code == 200
body = r.json()
assert body["page_urls"] == [
f"/api/documents/{doc.id}/pages/1",
f"/api/documents/{doc.id}/pages/2",
] # the exact 1-based list, count == page_count
assert body["is_image"] is False
assert "image_url" not in body # absent — never null (not an image doc)
def test_content_origin_url_carried_for_a_matching_git_row() -> None:
"""The endpoint (stubbed session, no database): a ``kind='git'``
row whose URL basename matches the document's source adds
+628
View File
@@ -0,0 +1,628 @@
"""Phase 148, task 01 — render qualifying PDF pages at index time (unit
matrix).
The owner's TODO L5: "For primarily pictographic PDFs it's not super
helpful to see the summaries of the pages. I want to see the PDF pages
in the sources." The locked rule: a PDF gets rendered pages iff the
sync ALREADY decided its text is not the useful view — the phase-133
fallback fired (unusable text layer — the screenshot class) OR the
phase-140 pictorial pass described ≥1 page (the sparse-page class —
the IKEA fixture). Text-heavy PDFs (usable layer, zero candidates)
keep the text view, and ``BOR_PDF_PICTORIAL=0`` keeps its kill-switch
semantics (no pictorial descriptions → no page strip for the sparse
class).
Pinned here (the house PyMuPDF fixture recipes — the
``test_pictorial_import.py`` builder for the pictorial class, the
``test_importer.py`` garbage builder for the fallback class, the dense
builder for the negative; the copies land in a tmp ``pdf_page_dir``):
* eligibility × new index — the pictorial class (IKEA shape: usable
layer, ≥1 sparse page) and the fallback class (unusable layer) get
``<doc-id>-p{N}.png`` for EVERY page (not just the described
candidates — the strip shows the whole manual) + ``page_count`` =
the page count + real PNG bytes; a text-heavy PDF gets neither
(no copies, NULL count, ZERO render calls, byte-identical content);
* the phase-147 fast path — an unchanged qualifying PDF's second sync
re-renders NOTHING (zero render calls — the copies persist,
``page_count`` and the bytes untouched);
* stale replacement — a changed (shrunk) PDF's old copy set is
replaced: the new set covers the new pages, the old ``p{N}`` tail
is GONE (no orphans), ``page_count`` follows the new count;
* qualifier loss — a qualifying PDF whose new version gained a dense
text layer loses its strip: ``page_count`` NULL + copies deleted;
* unopenable — a render that yields no pages (``render_pdf_pages`` →
``[]``): ``page_count`` NULL, no copies, NO exception, the doc is
still indexed exactly as pre-phase (the row lands, the summary
runs);
* per-page gap — one unreadable page (the render's ``b""``
placeholder): the other copies are written, ``page_count`` keeps
the FULL count (the gap is a display-level 404 — task 02 pins the
route's side);
* fail-soft — the render raising (warning is the signal, ``page_count``
NULL, no copies, the doc indexed fully — chunks + summary) and the
WRITER raising (the copies this call wrote are removed — no partial
orphans, ``page_count`` NULL, the doc indexed fully);
* prune — a qualifying doc's file removed from the source: the row
goes and its page copies go with it (no orphans in the tmp dir).
The route + payload (task 02) and the viewer strip (task 03) are
pinned in their own task files; the E2E surface is
``tests/e2e/test_pdf_pages_in_sources.py`` (task 04).
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from collections.abc import Callable, Sequence
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 Document
from app.rag import importer
from app.rag.importer import _delete_page_copies, import_sources
from app.rag.pdf import extract_pdf_text
from tests.unit.test_importer import _build_garbage_pdf, _PageVisionLLM
from tests.unit.test_pictorial_import import (
TITLE,
_build_dense_pdf,
_build_ikea_like_pdf,
_insert_dense,
_PictorialLLM,
)
#: The PNG signature (``render_pdf_pages``' output is ``tobytes("png")``).
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
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
the phase-148 ``pdf_page_dir`` pinned to *tmp_path* (the copies
under test never land in the real home); ``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"))
kwargs.setdefault("pdf_page_dir", str(tmp_path / "pages"))
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
def _pages_dir(tmp_path: Path) -> Path:
return tmp_path / "pages"
def _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 _build_ikea_shrunk(path: Path) -> None:
"""A SHRUNK IKEA-class PDF (the stale-replacement target): 6 pages
— the title page, two dense warnings, three sparse ``Step N``
markers — still qualifying (candidates: 1 and 4–6), but four pages
shorter than :func:`_build_ikea_like_pdf`'s 10 (the old ``p7..p10``
tail must not leak)."""
doc = fitz.open()
doc.new_page().insert_text((72, 72), "BESTA")
_insert_dense(doc.new_page())
_insert_dense(doc.new_page())
for n in range(4, 7):
doc.new_page().insert_text((72, 72), f"Step {n}")
doc.set_metadata({"title": TITLE})
doc.save(path)
doc.close()
def _render_spy(
monkeypatch: pytest.MonkeyPatch,
*,
mutate_full: Callable[[list[bytes]], list[bytes]] | None = None,
raise_full: bool = False,
) -> list[Any]:
"""Count + steer the ``render_pdf_pages`` calls the importer makes
(the pictorial pass's candidate render AND the phase-148
full-page render both resolve the module global at call time):
returns the list of *pages* arguments seen (a candidate list for
the pass, ``None`` for the full render). *mutate_full* transforms
the full-render result (e.g. blanks one page's slot);
*raise_full* makes the full render raise. The candidate render
always runs the real helper."""
real = importer.render_pdf_pages
calls: list[Any] = []
def spy(pdf_path: Path, dpi: int = 150, pages: Sequence[int] | None = None) -> list[bytes]:
calls.append(pages)
if pages is not None:
return real(pdf_path, dpi=dpi, pages=pages)
if raise_full:
raise RuntimeError("simulated render failure (test sentinel)")
out = real(pdf_path, dpi=dpi, pages=pages)
return mutate_full(out) if mutate_full is not None else out
monkeypatch.setattr(importer, "render_pdf_pages", spy)
return calls
# ---------------------------------------------------------------------------
# Eligibility × new index
# ---------------------------------------------------------------------------
def test_pictorial_pdf_new_index_writes_every_page_copy(db, tmp_path: Path) -> None:
"""Eligible — the PICTORIAL class (IKEA shape: usable layer, ≥1
sparse page): the first index writes EVERY page of the PDF (not
just the described candidates — the strip shows the whole manual)
as ``<doc-id>-p{N}.png`` under ``pdf_page_dir`` (N 1-based), sets
``page_count`` to the page count, and the files carry real PNG
bytes — while the indexed content is unchanged by this phase (the
phase-140 illustration blocks stay: the descriptions help the
embeddings and stay in the DB for Q&A — they are simply not
displayed, task 03)."""
root = tmp_path / "pgp"
root.mkdir()
_build_ikea_like_pdf(root / "ikea.pdf")
llm = _PictorialLLM()
llm.settings = _settings(tmp_path)
try:
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
assert summary.pdfs_pictorial == 1 # the pass ran (the eligibility class)
doc = _doc(db, "pgp", "ikea.pdf")
assert doc.page_count == 10, "page_count = the PDF's page count"
page_dir = _pages_dir(tmp_path)
for n in range(1, 11):
path = page_dir / f"{doc.id}-p{n}.png"
assert path.is_file(), f"copy p{n} is missing"
assert path.read_bytes()[:8] == PNG_MAGIC, f"p{n} is not a real PNG"
# No behavior change: the final content still carries the
# phase-140 illustration blocks on the sparse pages (1, 6–10)
# and not on the dense ones (2–5).
assert "[Page 1 illustration:" in doc.content
assert "[Page 6 illustration:" in doc.content
assert "[Page 2 illustration:" not in doc.content
finally:
_cleanup_source(db, "pgp")
def test_fallback_class_garbage_pdf_writes_every_page_copy(db, tmp_path: Path) -> None:
"""Eligible — the FALLBACK class (unusable text layer — the
screenshot class): the garbage PDF takes the phase-133 screenshot
fallback UNCHANGED and now gets the page strip too — one copy per
page + ``page_count`` (the viewer can show the actual pages of the
screenshot class as well)."""
root = tmp_path / "pgf"
root.mkdir()
_build_garbage_pdf(root / "garbage.pdf") # 2 pages
llm = _PageVisionLLM()
llm.settings = _settings(tmp_path)
try:
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
assert summary.pdfs_screenshotted == 1 # the fallback fired (the class)
doc = _doc(db, "pgf", "garbage.pdf")
assert doc.page_count == 2
page_dir = _pages_dir(tmp_path)
for n in (1, 2):
path = page_dir / f"{doc.id}-p{n}.png"
assert path.is_file(), f"copy p{n} is missing"
assert path.read_bytes()[:8] == PNG_MAGIC
# No behavior change: the content is still the fallback's
# vision descriptions (the page-anchored separator shape).
assert doc.content.startswith("---PAGE-1---")
finally:
_cleanup_source(db, "pgf")
def test_text_heavy_pdf_keeps_the_text_view(
db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ineligible — a TEXT-HEAVY PDF (usable layer, zero candidates):
the text view stays — no copies, NULL ``page_count``, ZERO render
calls (the pictorial pass no-ops before any render), and the
content is byte-identical to ``extract_pdf_text`` (the
pre-phase-140/148 pin)."""
root = tmp_path / "pgd"
root.mkdir()
pdf = root / "clean.pdf"
_build_dense_pdf(pdf)
expected = extract_pdf_text(pdf)
llm = _PictorialLLM()
llm.settings = _settings(tmp_path)
calls = _render_spy(monkeypatch)
try:
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
assert summary.pdfs_pictorial == 0 # zero candidates — the pass no-ops
doc = _doc(db, "pgd", "clean.pdf")
assert doc.page_count is None, "a text-heavy PDF keeps the text view"
assert not _pages_dir(tmp_path).exists(), "no copies — the dir is never created"
assert calls == [], "zero render calls (no fallback, zero candidates)"
assert doc.content == expected, "byte-identical to the extraction"
finally:
_cleanup_source(db, "pgd")
# ---------------------------------------------------------------------------
# Lifecycle: unchanged / changed / qualifier loss
# ---------------------------------------------------------------------------
def test_unchanged_second_sync_renders_nothing(
db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The phase-147 fast path + phase 148: an UNCHANGED qualifying
PDF's second sync costs ZERO render calls (the copies persist — no
re-render, no orphans), and ``page_count`` and every copy's bytes
are untouched. The fast path returns before the branch, so the
render block is skipped by construction."""
root = tmp_path / "pgu"
root.mkdir()
_build_ikea_like_pdf(root / "ikea.pdf")
llm = _PictorialLLM()
llm.settings = _settings(tmp_path)
try:
calls = _render_spy(monkeypatch)
s1 = asyncio.run(import_sources([root], llm, session=db))
assert (s1.added, s1.unchanged) == (1, 0)
doc1 = _doc(db, "pgu", "ikea.pdf")
assert doc1.page_count == 10
assert len([c for c in calls if c is None]) == 1, (
"exactly one full-page render on the first index"
)
page_dir = _pages_dir(tmp_path)
copies = {
n: (page_dir / f"{doc1.id}-p{n}.png").read_bytes() for n in range(1, 11)
}
assert all(b[:8] == PNG_MAGIC for b in copies.values())
embeds1 = len(llm.calls)
s2 = asyncio.run(import_sources([root], llm, session=db))
assert (s2.added, s2.updated, s2.unchanged) == (0, 0, 1)
assert len(calls) == 2, "second sync: ZERO render calls (the fast path)"
assert len(llm.calls) == embeds1, "no re-embed (the fast path's zero-work)"
doc2 = _doc(db, "pgu", "ikea.pdf")
assert doc2.id == doc1.id
assert doc2.page_count == 10, "page_count untouched"
for n in range(1, 11):
assert (page_dir / f"{doc2.id}-p{n}.png").read_bytes() == copies[n], (
f"copy p{n} moved on an unchanged sync"
)
finally:
_cleanup_source(db, "pgu")
def test_changed_pdf_replaces_the_copy_set_without_orphans(
db, tmp_path: Path,
) -> None:
"""A CHANGED (shrunk, 10 → 6 pages) qualifying PDF: the re-index
replaces the copy set — the new set covers p1..p6, the old
version's ``p7..p10`` tail is GONE (the stale-replacement runs
before the write), the doc keeps its id, and ``page_count``
follows the new page count."""
root = tmp_path / "pgc"
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 == 1
doc1 = _doc(db, "pgc", "ikea.pdf")
assert doc1.page_count == 10
_build_ikea_shrunk(pdf) # same path, 6 pages — still qualifying
s2 = asyncio.run(import_sources([root], llm, session=db))
assert (s2.added, s2.updated, s2.unchanged) == (0, 1, 0)
doc2 = _doc(db, "pgc", "ikea.pdf")
assert doc2.id == doc1.id, "the doc keeps its id (the copy path is stable)"
assert doc2.page_count == 6, "page_count follows the new page count"
page_dir = _pages_dir(tmp_path)
for n in range(1, 7):
assert (page_dir / f"{doc2.id}-p{n}.png").is_file(), f"p{n} is missing"
for n in range(7, 11):
assert not (page_dir / f"{doc2.id}-p{n}.png").exists(), (
f"stale p{n} from the 10-page version leaked"
)
leftovers = sorted(p.name for p in page_dir.iterdir())
assert len(leftovers) == 6, "exactly the new copy set, no orphans"
finally:
_cleanup_source(db, "pgc")
def test_qualifier_loss_clears_page_count_and_copies(
db, tmp_path: Path,
) -> None:
"""A qualifying PDF whose NEW VERSION gained a dense text layer
(zero candidates, ``pdf_pictorial`` ON): the document STOPPED
qualifying — ``page_count`` is NULL, the copies are deleted, and
the text view returns (the content is the extraction,
byte-identical)."""
root = tmp_path / "pgl"
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 == 1
doc1 = _doc(db, "pgl", "ikea.pdf")
assert doc1.page_count == 10
assert all(
_pages_dir(tmp_path).joinpath(f"{doc1.id}-p{n}.png").is_file()
for n in range(1, 11)
)
_build_dense_pdf(pdf) # same path: dense text layer, zero candidates
s2 = asyncio.run(import_sources([root], llm, session=db))
assert (s2.added, s2.updated, s2.unchanged) == (0, 1, 0)
doc2 = _doc(db, "pgl", "ikea.pdf")
assert doc2.id == doc1.id
assert doc2.page_count is None, "a doc that stopped qualifying loses its strip"
assert not list(_pages_dir(tmp_path).glob(f"{doc2.id}-p*.png")), (
"the copies must be deleted on qualifier loss"
)
assert doc2.content == extract_pdf_text(pdf), "the text view returns"
finally:
_cleanup_source(db, "pgl")
# ---------------------------------------------------------------------------
# Fail-soft: unopenable / gap / render exception / write exception
# ---------------------------------------------------------------------------
def test_unopenable_pdf_page_render_is_fail_soft(
db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A render that yields NO pages (an unopenable file —
``render_pdf_pages`` → ``[]``): ``page_count`` NULL, no copies,
NO exception — the document still indexes exactly as pre-phase
(the fallback's all-failed path: an empty-content row lands, the
summary runs)."""
root = tmp_path / "pgn"
root.mkdir()
_build_garbage_pdf(root / "garbage.pdf") # the fallback class
llm = _PageVisionLLM()
llm.settings = _settings(tmp_path)
monkeypatch.setattr(importer, "render_pdf_pages", lambda *a, **k: [])
try:
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
doc = _doc(db, "pgn", "garbage.pdf")
assert doc.page_count is None, "no pages rendered — no strip"
assert not _pages_dir(tmp_path).exists(), "no copies — the dir is never created"
assert doc.content == "", "the fallback's all-failed render lands empty content"
assert summary.summaries == 1, "the document is indexed exactly as pre-phase"
finally:
_cleanup_source(db, "pgn")
def test_per_page_gap_writes_the_other_copies_and_keeps_the_full_count(
db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""One unreadable page (the render's ``b""`` placeholder — page 4
here): a per-page failure is NOT an exception — the other copies
are written, ``page_count`` keeps the FULL count, and the gap's
file is simply absent (a display-level 404 — the route of task 02
pins its side)."""
root = tmp_path / "pgg"
root.mkdir()
_build_ikea_like_pdf(root / "ikea.pdf")
llm = _PictorialLLM()
llm.settings = _settings(tmp_path)
def gap_page_four(pages: list[bytes]) -> list[bytes]:
pages[3] = b"" # index 3 = page 4 (the helper's list-index contract)
return pages
_render_spy(monkeypatch, mutate_full=gap_page_four)
try:
asyncio.run(import_sources([root], llm, session=db))
doc = _doc(db, "pgg", "ikea.pdf")
assert doc.page_count == 10, "the gap does not shrink the count"
page_dir = _pages_dir(tmp_path)
for n in range(1, 11):
path = page_dir / f"{doc.id}-p{n}.png"
if n == 4:
assert not path.exists(), "the gap has no file"
else:
assert path.is_file(), f"copy p{n} is missing"
finally:
_cleanup_source(db, "pgg")
def test_render_exception_is_fail_soft(
db,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The full-page render RAISES: the warning is the signal,
``page_count`` NULL, no copies — and the document indexes EXACTLY
as pre-phase (the pictorial content, the chunks, the summary) —
the render is a display concern, it never gates the indexing."""
root = tmp_path / "pge"
root.mkdir()
_build_ikea_like_pdf(root / "ikea.pdf")
llm = _PictorialLLM()
llm.settings = _settings(tmp_path)
_render_spy(monkeypatch, raise_full=True)
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)
doc = _doc(db, "pge", "ikea.pdf")
assert doc.page_count is None
assert not _pages_dir(tmp_path).exists(), "no copies — no partial orphans"
assert doc.chunks, "the chunk pipeline ran (fail-soft)"
assert summary.summaries == 1, "the summary ran (fail-soft)"
assert "[Page 6 illustration:" in doc.content, (
"the pictorial pass's content is untouched by the render failure"
)
assert any("PDF page render failed" in m for m in caplog.messages), (
caplog.messages
)
finally:
_cleanup_source(db, "pge")
def test_write_exception_is_fail_soft_and_leaves_no_orphans(
db,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The PNG WRITER raises (a full disk) after the FIRST copy
landed: the copies this call wrote are removed (no partial
orphans), ``page_count`` NULL, the warning is the signal, and the
document indexes exactly as pre-phase."""
root = tmp_path / "pgw"
root.mkdir()
_build_ikea_like_pdf(root / "ikea.pdf")
llm = _PictorialLLM()
llm.settings = _settings(tmp_path)
original_write = Path.write_bytes
written_paths: list[Path] = []
def boom(self: Path, data: bytes) -> int:
written_paths.append(self)
if len(written_paths) > 1:
raise OSError("simulated disk full (test sentinel)")
return original_write(self, data)
monkeypatch.setattr(Path, "write_bytes", boom) # pyright: ignore[reportArgumentType]
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 len(written_paths) == 2, "p1 landed, p2's write failed"
doc = _doc(db, "pgw", "ikea.pdf")
assert doc.page_count is None
assert not list(_pages_dir(tmp_path).glob(f"{doc.id}-p*.png")), (
"the partial write was cleaned up — no orphans"
)
assert not written_paths[0].exists(), "the landed p1 copy was removed"
assert summary.summaries == 1, "the document is indexed exactly as pre-phase"
assert any("PDF page render failed" in m for m in caplog.messages), (
caplog.messages
)
finally:
_cleanup_source(db, "pgw")
# ---------------------------------------------------------------------------
# The shared cleanup helper (the _delete_image_copy convention)
# ---------------------------------------------------------------------------
def test_delete_page_copies_helper_is_best_effort(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The helper (the ``_delete_image_copy`` convention): the row
knows its own extent — copies 1..``page_count`` are deleted, a
NULL/zero count is a no-op, missing files are a SILENT no-op
(already gone — e.g. the owner cleaned the pages dir), and an
unreadable file is logged, never raised (copy cleanup must not
break the sync)."""
page_dir = tmp_path / "pages"
page_dir.mkdir()
doc_id = uuid.uuid4()
doc = Document(
id=doc_id,
source="s",
path="p.pdf",
full_path="/p.pdf",
title="t",
content="c",
content_hash="x",
page_count=3,
)
p1 = page_dir / f"{doc_id}-p1.png"
p3 = page_dir / f"{doc_id}-p3.png"
p1.write_bytes(b"png1")
p3.write_bytes(b"png3") # p2 is missing (the silent no-op)
original_unlink = Path.unlink
def boom(self: Path, **kwargs: Any) -> None:
if self == p3:
raise OSError("simulated unreadable file (test sentinel)")
return original_unlink(self, **kwargs)
monkeypatch.setattr(Path, "unlink", boom) # pyright: ignore[reportArgumentType]
with caplog.at_level(logging.WARNING, logger="app.importer"):
_delete_page_copies(doc, page_dir)
assert not p1.exists(), "the existing copy was deleted"
assert p3.exists(), "the unreadable file survived (cleanup never raises)"
assert any(
"could not delete PDF page copy" in m for m in caplog.messages
), caplog.messages
# A NULL count is a no-op (fresh docs, non-qualifying docs, every
# fail-soft miss) — no exception, no attempt.
doc.page_count = None
_delete_page_copies(doc, page_dir)
assert p3.exists()
# ---------------------------------------------------------------------------
# Prune
# ---------------------------------------------------------------------------
def test_prune_deletes_page_copies(db, tmp_path: Path) -> None:
"""A qualifying doc is PRUNED (its file removed from the source):
the row goes AND its page copies go with it — no orphans in the
pages dir (the ``_prune`` hook beside the image-copy deletion)."""
root = tmp_path / "pgz"
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 == 1
doc = _doc(db, "pgz", "ikea.pdf")
assert doc.page_count == 10
assert len(list(_pages_dir(tmp_path).glob(f"{doc.id}-p*.png"))) == 10
pdf.unlink()
s2 = asyncio.run(import_sources([root], llm, session=db, prune=True))
assert s2.pruned == 1
assert (
db.scalar(
select(Document).where(
Document.source == "pgz", Document.path == "ikea.pdf"
)
)
is None
), "the row is pruned"
assert not list(_pages_dir(tmp_path).glob("*.png")), (
"no orphaned page copies after the prune"
)
finally:
_cleanup_source(db, "pgz")
+13 -8
View File
@@ -13,10 +13,12 @@ without a browser:
* NO ``@media (min-width: 1500px)`` block anywhere in the file — the
phase-58 wide-desktop doubling is deleted in full (the token is
72rem at every viewport);
* the four reading-column selectors — ``.chat-shell``,
``.shared-shell``, ``.doc-md``, ``.doc-summary:has(+ .doc-md)`` —
each capped with ``max-width: var(--chat-column)`` and NOTHING else
in the file uses the token for a max-width (exactly four rules);
* the six reading-column selectors — ``.chat-shell``,
``.shared-shell``, ``.doc-md``, ``.doc-image`` (phase 122, task 04),
``.doc-pages`` (phase 148, task 03 — the page strip), and
``.doc-summary:has(+ .doc-md)`` — each capped with
``max-width: var(--chat-column)`` and NOTHING else in the file uses
the token for a max-width (exactly six rules);
* the flipped negative pin — ZERO literal ``max-width: 46rem`` rules
remain (the phase-27/59/91 form-column caps are retired): the form
shells (``.tuning-shell``, ``.theme-shell``, ``.doc-edit-shell``)
@@ -117,21 +119,24 @@ def test_no_literal_46rem_width_remains() -> None:
def test_the_reading_columns_use_the_token() -> None:
""".chat-shell, .shared-shell, .doc-md, .doc-image (phase 122,
task 04 — the viewer's image block rides the SAME reading column
as its .doc-md sibling) and .doc-summary:has(+ .doc-md) each cap
with max-width: var(--chat-column) — and exactly those five rules
use the token for a max-width (no other selector)."""
as its .doc-md sibling), .doc-pages (phase 148, task 03 — the
viewer's page strip rides the SAME reading column in the shared
#doc-content slot) and .doc-summary:has(+ .doc-md) each cap with
max-width: var(--chat-column) — and exactly those six rules use
the token for a max-width (no other selector)."""
css = _css()
for selector in (
".chat-shell",
".shared-shell",
".doc-md",
".doc-image",
".doc-pages",
".doc-summary:has(+ .doc-md)",
):
assert "max-width: var(--chat-column)" in _rule_block(css, selector), (
f"{selector} must cap with max-width: var(--chat-column)"
)
assert css.count("max-width: var(--chat-column)") == 5, (
assert css.count("max-width: var(--chat-column)") == 6, (
"exactly the reading-column selectors use the token"
)