From c7cfcd26b543d5dcf6fd86e15e531b0b919fbdf6 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Fri, 2 Oct 2026 07:16:08 -0400 Subject: [PATCH] phase: 151_pdf_image_extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 149/150 CSS changes touch the sources-page sync UI visible behind the modal, so the regenerated screenshot is the accurate record of the current UI (house convention — the suite rewrites it each run; it was re-committed in phases 144/145/148 the same way). I'll keep it in the working tree. All verification is complete. Final state: **Phase 151 — final verification pass: complete, project 100% green.** - **Verified (all 6 task files in `complete/`):** dedupe (xref + digest), SMask→alpha-PNG compositing (raw-format gate first, fail-soft), pure `is_blank_image` gate + log-only counters, `BOR_PDF_IMAGE_SKIP_BLANK` knob (default on), unit + real-file pins, dedicated Playwright suite - **Real-file pins pass:** `podman-systemd.pdf` → 48 placements, 39 deduped, 9 blank-skipped, **0 image docs out**; `pdf_horse_test.pdf` → masked JPEG composited to alpha PNG, kept; `players_handbook.pdf` → 586 `jpx` images untouched (raw mime, ext-gate skipped) - **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → **3511 passed, TOTAL 99%** (pdf.py 100%, importer.py 99%, config.py 100%); dedicated E2E `tests/e2e/test_pdf_image_extraction.py` in isolation → **5 passed**; **full E2E sweep: all 135 suites run in isolation, 684 tests, 0 failures**; `uv run ruff check .` clean; `uv run pyright` → 0 errors - **Completion criteria:** all 7 met (sync `detail` schema untouched — `app/api/sync.py`/`app/schemas.py` unmodified; counters in the shared `import_sources` → `summary.log()` path; commit left to the harness per instructions) - **Docs:** README.md and AGENTS.md reviewed in full — verified consistent, **no changes needed** (env-var coverage claim, page/route table, production-deploy claims all check out; the phase's README/`.env.example` additions match the code). Note: PLAN.md §7.1's 46rem chat column is superseded by phase 100 (AGENTS.md rule 5 states this correctly; PLAN.md is out of my edit scope) - **Flagged deviations (documented in-code per rule 3, not silent):** tolerance ±17 vs pinned ±12 — empirically verified ±12 lets the podman page-47 underlay (uniform 0.954) survive, violating criterion #1; ±17 is the smallest band keeping all real-file pins (mean 235 / fraction 0.98 unchanged); gate measures the base stream, not the premultiplied composite (which darkens transparent regions and would miss the underlays) - **Next pending phase:** `152_sync_progress_image_pass` --- .../151_pdf_image_extraction/00_phase.md | 0 .../01_dedupe_image_occurrences.md | 0 .../02_composite_soft_masks.md | 0 .../03_blank_image_gate.md | 0 .../04_settings_knob.md | 0 .../151_pdf_image_extraction/05_unit_tests.md | 0 .../06_e2e_pdf_image_extraction.md | 0 .../151_pdf_image_extraction__00_phase.a1.err | 0 .../151_pdf_image_extraction__00_phase.a1.md | 13 + ...pdf_image_extraction__00_phase.a1.validate | 120 ++ ...action__01_dedupe_image_occurrences.a1.err | 0 ...raction__01_dedupe_image_occurrences.a1.md | 17 + ...n__01_dedupe_image_occurrences.a1.validate | 120 ++ ...extraction__02_composite_soft_masks.a1.err | 0 ..._extraction__02_composite_soft_masks.a1.md | 19 + ...ction__02_composite_soft_masks.a1.validate | 120 ++ ...age_extraction__03_blank_image_gate.a1.err | 0 ...mage_extraction__03_blank_image_gate.a1.md | 11 + ...xtraction__03_blank_image_gate.a1.validate | 120 ++ ..._image_extraction__04_settings_knob.a1.err | 0 ...f_image_extraction__04_settings_knob.a1.md | 11 + ...e_extraction__04_settings_knob.a1.validate | 120 ++ ...pdf_image_extraction__05_unit_tests.a1.err | 0 ..._pdf_image_extraction__05_unit_tests.a1.md | 11 + ...mage_extraction__05_unit_tests.a1.validate | 120 ++ ...action__06_e2e_pdf_image_extraction.a1.err | 0 ...raction__06_e2e_pdf_image_extraction.a1.md | 10 + ...n__06_e2e_pdf_image_extraction.a1.validate | 120 ++ .../screenshots/doc_modal_scrollbar_1280.png | Bin 42368 -> 42559 bytes .env.example | 12 + README.md | 11 +- app/config.py | 17 + app/rag/importer.py | 72 +- app/rag/pdf.py | 416 ++++++- tests/e2e/test_garbage_pdf.py | 57 +- tests/e2e/test_pdf_image_context.py | 34 +- tests/e2e/test_pdf_image_extraction.py | 802 +++++++++++++ tests/e2e/test_pdf_support.py | 30 +- tests/unit/test_importer.py | 12 +- tests/unit/test_pdf.py | 581 ++++++++- tests/unit/test_pdf_image_filter.py | 1061 +++++++++++++++++ 41 files changed, 3952 insertions(+), 85 deletions(-) rename .agents/phases/{todo => complete}/151_pdf_image_extraction/00_phase.md (100%) rename .agents/phases/{todo => complete}/151_pdf_image_extraction/01_dedupe_image_occurrences.md (100%) rename .agents/phases/{todo => complete}/151_pdf_image_extraction/02_composite_soft_masks.md (100%) rename .agents/phases/{todo => complete}/151_pdf_image_extraction/03_blank_image_gate.md (100%) rename .agents/phases/{todo => complete}/151_pdf_image_extraction/04_settings_knob.md (100%) rename .agents/phases/{todo => complete}/151_pdf_image_extraction/05_unit_tests.md (100%) rename .agents/phases/{todo => complete}/151_pdf_image_extraction/06_e2e_pdf_image_extraction.md (100%) create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__00_phase.a1.err create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__00_phase.a1.md create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__00_phase.a1.validate create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__01_dedupe_image_occurrences.a1.err create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__01_dedupe_image_occurrences.a1.md create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__01_dedupe_image_occurrences.a1.validate create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__02_composite_soft_masks.a1.err create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__02_composite_soft_masks.a1.md create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__02_composite_soft_masks.a1.validate create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__03_blank_image_gate.a1.err create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__03_blank_image_gate.a1.md create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__03_blank_image_gate.a1.validate create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__04_settings_knob.a1.err create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__04_settings_knob.a1.md create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__04_settings_knob.a1.validate create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__05_unit_tests.a1.err create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__05_unit_tests.a1.md create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__05_unit_tests.a1.validate create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__06_e2e_pdf_image_extraction.a1.err create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__06_e2e_pdf_image_extraction.a1.md create mode 100644 .agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__06_e2e_pdf_image_extraction.a1.validate create mode 100644 tests/e2e/test_pdf_image_extraction.py create mode 100644 tests/unit/test_pdf_image_filter.py diff --git a/.agents/phases/todo/151_pdf_image_extraction/00_phase.md b/.agents/phases/complete/151_pdf_image_extraction/00_phase.md similarity index 100% rename from .agents/phases/todo/151_pdf_image_extraction/00_phase.md rename to .agents/phases/complete/151_pdf_image_extraction/00_phase.md diff --git a/.agents/phases/todo/151_pdf_image_extraction/01_dedupe_image_occurrences.md b/.agents/phases/complete/151_pdf_image_extraction/01_dedupe_image_occurrences.md similarity index 100% rename from .agents/phases/todo/151_pdf_image_extraction/01_dedupe_image_occurrences.md rename to .agents/phases/complete/151_pdf_image_extraction/01_dedupe_image_occurrences.md diff --git a/.agents/phases/todo/151_pdf_image_extraction/02_composite_soft_masks.md b/.agents/phases/complete/151_pdf_image_extraction/02_composite_soft_masks.md similarity index 100% rename from .agents/phases/todo/151_pdf_image_extraction/02_composite_soft_masks.md rename to .agents/phases/complete/151_pdf_image_extraction/02_composite_soft_masks.md diff --git a/.agents/phases/todo/151_pdf_image_extraction/03_blank_image_gate.md b/.agents/phases/complete/151_pdf_image_extraction/03_blank_image_gate.md similarity index 100% rename from .agents/phases/todo/151_pdf_image_extraction/03_blank_image_gate.md rename to .agents/phases/complete/151_pdf_image_extraction/03_blank_image_gate.md diff --git a/.agents/phases/todo/151_pdf_image_extraction/04_settings_knob.md b/.agents/phases/complete/151_pdf_image_extraction/04_settings_knob.md similarity index 100% rename from .agents/phases/todo/151_pdf_image_extraction/04_settings_knob.md rename to .agents/phases/complete/151_pdf_image_extraction/04_settings_knob.md diff --git a/.agents/phases/todo/151_pdf_image_extraction/05_unit_tests.md b/.agents/phases/complete/151_pdf_image_extraction/05_unit_tests.md similarity index 100% rename from .agents/phases/todo/151_pdf_image_extraction/05_unit_tests.md rename to .agents/phases/complete/151_pdf_image_extraction/05_unit_tests.md diff --git a/.agents/phases/todo/151_pdf_image_extraction/06_e2e_pdf_image_extraction.md b/.agents/phases/complete/151_pdf_image_extraction/06_e2e_pdf_image_extraction.md similarity index 100% rename from .agents/phases/todo/151_pdf_image_extraction/06_e2e_pdf_image_extraction.md rename to .agents/phases/complete/151_pdf_image_extraction/06_e2e_pdf_image_extraction.md diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__00_phase.a1.err b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__00_phase.a1.md b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__00_phase.a1.md new file mode 100644 index 0000000..f7a9d7e --- /dev/null +++ b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__00_phase.a1.md @@ -0,0 +1,13 @@ +The 149/150 CSS changes touch the sources-page sync UI visible behind the modal, so the regenerated screenshot is the accurate record of the current UI (house convention — the suite rewrites it each run; it was re-committed in phases 144/145/148 the same way). I'll keep it in the working tree. + +All verification is complete. Final state: + +**Phase 151 — final verification pass: complete, project 100% green.** + +- **Verified (all 6 task files in `complete/`):** dedupe (xref + digest), SMask→alpha-PNG compositing (raw-format gate first, fail-soft), pure `is_blank_image` gate + log-only counters, `BOR_PDF_IMAGE_SKIP_BLANK` knob (default on), unit + real-file pins, dedicated Playwright suite +- **Real-file pins pass:** `podman-systemd.pdf` → 48 placements, 39 deduped, 9 blank-skipped, **0 image docs out**; `pdf_horse_test.pdf` → masked JPEG composited to alpha PNG, kept; `players_handbook.pdf` → 586 `jpx` images untouched (raw mime, ext-gate skipped) +- **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → **3511 passed, TOTAL 99%** (pdf.py 100%, importer.py 99%, config.py 100%); dedicated E2E `tests/e2e/test_pdf_image_extraction.py` in isolation → **5 passed**; **full E2E sweep: all 135 suites run in isolation, 684 tests, 0 failures**; `uv run ruff check .` clean; `uv run pyright` → 0 errors +- **Completion criteria:** all 7 met (sync `detail` schema untouched — `app/api/sync.py`/`app/schemas.py` unmodified; counters in the shared `import_sources` → `summary.log()` path; commit left to the harness per instructions) +- **Docs:** README.md and AGENTS.md reviewed in full — verified consistent, **no changes needed** (env-var coverage claim, page/route table, production-deploy claims all check out; the phase's README/`.env.example` additions match the code). Note: PLAN.md §7.1's 46rem chat column is superseded by phase 100 (AGENTS.md rule 5 states this correctly; PLAN.md is out of my edit scope) +- **Flagged deviations (documented in-code per rule 3, not silent):** tolerance ±17 vs pinned ±12 — empirically verified ±12 lets the podman page-47 underlay (uniform 0.954) survive, violating criterion #1; ±17 is the smallest band keeping all real-file pins (mean 235 / fraction 0.98 unchanged); gate measures the base stream, not the premultiplied composite (which darkens transparent regions and would miss the underlays) +- **Next pending phase:** `152_sync_progress_image_pass` diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__00_phase.a1.validate b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__00_phase.a1.validate new file mode 100644 index 0000000..8774a40 --- /dev/null +++ b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__00_phase.a1.validate @@ -0,0 +1,120 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +....................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 748 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5682 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__01_dedupe_image_occurrences.a1.err b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__01_dedupe_image_occurrences.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__01_dedupe_image_occurrences.a1.md b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__01_dedupe_image_occurrences.a1.md new file mode 100644 index 0000000..be9914d --- /dev/null +++ b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__01_dedupe_image_occurrences.a1.md @@ -0,0 +1,17 @@ +All green. Final report: + +**Implemented (phase 151, task 01 — dedupe image occurrences)** +- `app/rag/pdf.py`: `extract_pdf_images()` now returns one `PdfImage` per DISTINCT image — `seen_xrefs` (repeated placements) then `seen_digests` (sha256 of stored bytes under a different xref), first occurrence's `page`/`index` wins; one INFO line per PDF (`pdf: dropped N duplicate image occurrence(s) from … (M unique)`) + DEBUG per drop; `import hashlib` added; module + function docstrings updated +- `app/rag/importer.py`: `_index_pdf_embedded_images()` docstring gained the dedupe note (no counter plumbing — that's task 03's) +- `tests/unit/test_pdf.py`: 6 new tests (3×-placement→1 first-wins, 2 distinct kept, digest path across xrefs + negative control, INFO once per PDF / DEBUG per drop, no-dup no-line) + real-file podman pin; existing order test updated to dedupe semantics + +**Tests / lint / coverage** +- `uv run pytest tests/unit/test_pdf.py -q` → green (96 passed) +- `uv run pytest --cov=app` (full suite) → **3470 passed, 0 failed** in 93s; `app/` coverage **99%** (`app/rag/pdf.py` 100% — both dedupe branches + no-dup path covered) +- `uv run ruff check . && uv run pyright` → clean (0 errors) + +**Docs**: no doc impact (README/AGENTS.md make no per-occurrence claims; the README line lands with task 04's knob) + +**Notable deviation**: task pinned the podman file at 15 entries — measured reality: 48 placements (phase doc said 46) over 15 unique xrefs, but 6 of those xrefs are **byte-identical copies** (same base AND SMask bytes), so the task's own digest set ("catches byte-identical images under different xrefs") merges them → **9** truly distinct images. I implemented the specified algorithm and pinned 9 with a documented rationale; the phase objective ("one document per genuinely distinct image", "no duplicate image documents") requires the merge — the "15" was the unique-xref count. + +**Next pending task**: `02_composite_soft_masks.md` diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__01_dedupe_image_occurrences.a1.validate b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__01_dedupe_image_occurrences.a1.validate new file mode 100644 index 0000000..5a89625 --- /dev/null +++ b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__01_dedupe_image_occurrences.a1.validate @@ -0,0 +1,120 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 97%] +........................................................................ [ 99%] +.............. [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 265 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 744 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 141 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5626 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__02_composite_soft_masks.a1.err b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__02_composite_soft_masks.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__02_composite_soft_masks.a1.md b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__02_composite_soft_masks.a1.md new file mode 100644 index 0000000..1a23978 --- /dev/null +++ b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__02_composite_soft_masks.a1.md @@ -0,0 +1,19 @@ +All green. Task 02 is complete. + +**Implemented (phase 151, task 02 — /SMask compositing):** +- `app/rag/pdf.py` `extract_pdf_images()`: after the raw-format decision, non-zero `smask` + supported raw mime → `fitz.Pixmap(base, mask)` composite → `tobytes("png")` + `mime="image/png"`; raw-format gate FIRST (owner-locked A4: `application/octet-stream`/`jpx` left byte-identical, never newly admitted); fail-soft `try/except` → raw bytes + warning `pdf: could not composite smask=… — …; using raw bytes`; DEBUG line on success; width/height unchanged +- Docstrings: module `extract_pdf_images` bullet, `PdfImage.data`/`mime` comments, function contract extended +- `app/rag/importer.py`: digest-skip consequence noted in `_index_pdf_embedded_images` docstring (masked rows re-describe once post-phase — expected, self-limiting, no migration) +- Tests: 6 new in `tests/unit/test_pdf.py` — stdlib-zlib RGBA-PNG fixture (insert_image writes the /SMask), composite core pin (raw≠data, mime, alpha round-trip), unmasked byte-identical pin, DEBUG-line pin, fail-soft pin, `jpx`+mask untouched pin (combiner never called), plus the real-file pin: all 9 podman distinct underlays returned as composited alpha PNGs differing from every raw base digest + +**Results:** +- `uv run pytest tests/unit/test_pdf.py -q` → 102 passed +- `uv run pytest` → **3476 passed** (100% green) +- `uv run pytest --cov=app` → `app/rag/pdf.py` 100%, `importer.py` 99%, TOTAL 99% (>90%) +- `uv run ruff check . && uv run pyright` → clean, 0 errors + +**Docs:** no doc impact (no new env vars/commands; the knob + README entry belongs to task 04) + +**Notable:** fail-soft test uses the `_FakeDoc` convention — PyMuPDF's real `Pixmap.__init__` resolves the module-global `Pixmap` internally, so replacing that attribute breaks the real constructors; the fake doc isolates the composite block as the only Pixmap call. + +**Next pending task:** `03_blank_image_gate.md` diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__02_composite_soft_masks.a1.validate b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__02_composite_soft_masks.a1.validate new file mode 100644 index 0000000..58ea0b9 --- /dev/null +++ b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__02_composite_soft_masks.a1.validate @@ -0,0 +1,120 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 97%] +........................................................................ [ 99%] +.................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 265 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 744 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 152 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5637 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__03_blank_image_gate.a1.err b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__03_blank_image_gate.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__03_blank_image_gate.a1.md b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__03_blank_image_gate.a1.md new file mode 100644 index 0000000..2e36a0a --- /dev/null +++ b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__03_blank_image_gate.a1.md @@ -0,0 +1,11 @@ +All checks pass. The task is complete. + +**Report — phase 151, task 03 (blank image gate)** + +- `app/rag/pdf.py`: pure `is_blank_image()` predicate (+ `BLANK_IMAGE_*` constants), `PdfImageStats` dataclass, `extract_pdf_images_with_stats()` (gate after dedupe+mask: base samples, alpha dropped first, skip with INFO lines, fail-soft keep), thin `extract_pdf_images()` wrapper with `skip_blank: bool = True` kill switch. +- `app/rag/importer.py`: `ImportSummary.pdf_images_deduped` / `.pdf_images_blank_skipped` log-only counters in the `import: summary` line; `_index_pdf_embedded_images()` calls the `_with_stats` variant. Sync `detail` API shape untouched (verified explicit construction). +- Tests: new `tests/unit/test_pdf_image_filter.py` (22 tests — predicate boundaries pinned exactly at mean/fraction/tolerance, extractor-level skip/keep/kill-switch/fail-soft/alpha-drop, real-file pins: podman → `[]` + stats (39 dup, 9 blank, 48 found), horse photo kept, 586 jpx unchanged; importer integration: 1 blank + 1 real → exactly 1 doc + 1 vision call + summary line). Updated 3 existing pins (2 podman tests → `skip_blank=False` per task-02's own anticipation; jpx sentinel now allows the gate's base read; `test_importer.py` log-line format). +- Full suite: `uv run pytest --cov=app` → **3498 passed, 0 failed**; `app/` coverage **99%** (`pdf.py` 100%, `importer.py` 99%); `uv run ruff check .` clean; `uv run pyright` 0 errors. +- Docs: README PDF embedded-images bullet updated (dedupe + compositing + blank gate + new log counters). AGENTS.md: no impact. +- **Notable deviations (flagged per AGENTS.md rule 3):** (1) tolerance pinned at **17**, not the overview's ±12 — the podman file's 238/255-blend underlay (xref 1633) has its 255 level 16.2 above its mean, so ±12 leaves it in (0.954 < 0.98) and violates the binding "returns no images" criterion; ±17 is the smallest fix, all other pins unchanged. (2) Gate measures **base** samples, not the composited pixmap — PyMuPDF's `Pixmap(base, mask)` is premultiplied (composite mean ≈231–234 / uniform ≈0.95, misses every underlay; base mean ≈238 / ≈0.99, catches all). (3) Real-file stats are 39/9 (shipped digest-level dedupe over 48 placements), not the task file's 31/15 (which assumed xref-only dedupe of 46) — outcome identical: 0 out. +- Next pending task: `04_settings_knob.md` (`BOR_PDF_IMAGE_SKIP_BLANK`). diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__03_blank_image_gate.a1.validate b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__03_blank_image_gate.a1.validate new file mode 100644 index 0000000..a199317 --- /dev/null +++ b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__03_blank_image_gate.a1.validate @@ -0,0 +1,120 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +.......................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 265 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 748 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5681 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__04_settings_knob.a1.err b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__04_settings_knob.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__04_settings_knob.a1.md b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__04_settings_knob.a1.md new file mode 100644 index 0000000..4baf119 --- /dev/null +++ b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__04_settings_knob.a1.md @@ -0,0 +1,11 @@ +**Report — phase 151, task 04 (`BOR_PDF_IMAGE_SKIP_BLANK` knob)** + +- `app/config.py`: `pdf_image_skip_blank: bool = True` added next to `pdf_pictorial`/`pdf_pictorial_min_chars`, `#:` docstring naming `BOR_PDF_IMAGE_SKIP_BLANK`, default, and the off-behavior (dedupe/compositing unaffected). +- `app/rag/importer.py`: `_index_pdf_embedded_images()` now reads the knob in the one place and passes `skip_blank=settings.pdf_image_skip_blank` to `extract_pdf_images_with_stats`; pass docstring bullet updated (gate + knob). +- Tests: new Settings default/env-override unit test + an end-to-end kill-switch integration test (same blank+real PDF, knob off → BOTH image docs, 2 vision calls, `pdf_images_blank_skipped=0`) in `tests/unit/test_pdf_image_filter.py`. +- Fixed a defect this wiring exposed: `tests/unit/test_pdf.py::test_pdf_embedded_image_unsupported_mime_is_skipped` patched the extractor with `lambda _path:` (no `skip_blank` kwarg) — now accepts `**_kwargs`. +- `uv run pytest --cov=app`: **3500 passed, 0 failed**; `app/` coverage **99%** (>90% gate). +- `uv run ruff check .`: clean; `uv run pyright`: **0 errors, 0 warnings**. +- Docs: `README.md` gained the `BOR_PDF_IMAGE_SKIP_BLANK` row in the env-var table; `.env.example` gained the documented PDF-embedded-images block; `AGENTS.md`: no impact. +- Deviations: none — knob is a plain bool exactly per the `pdf_pictorial` pattern, no admin-UI toggle (task ASSUMPTION). +- Next pending task: `05_unit_tests.md` (PyMuPDF fixtures + real-PDF regression pins), then `06_e2e_pdf_image_extraction.md`. diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__04_settings_knob.a1.validate b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__04_settings_knob.a1.validate new file mode 100644 index 0000000..241443c --- /dev/null +++ b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__04_settings_knob.a1.validate @@ -0,0 +1,120 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +............................................ [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 748 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5682 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__05_unit_tests.a1.err b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__05_unit_tests.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__05_unit_tests.a1.md b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__05_unit_tests.a1.md new file mode 100644 index 0000000..e19d59e --- /dev/null +++ b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__05_unit_tests.a1.md @@ -0,0 +1,11 @@ +All gates green. Task 05 complete. + +**Report — phase 151, task 05 (unit + integration tests for dedupe, masks, blank gate)** + +- **Gap audit:** tasks 01–04 had already landed most of the matrix; I added what task 05's spec still assigned: + - `tests/unit/test_pdf_image_filter.py` (+8 tests): dedupe matrix with `stats.duplicates` counting (3 placements → 1 at first page/index + INFO line; 2 distinct → 2; byte-identical bytes under two xrefs → 1, digest path); soft-mask pins (inserted RGBA → `image/png`, bytes ≠ raw base, `fitz.Pixmap(...).alpha == 1`; unmasked → byte-identical to raw; `fitz.Pixmap` raising → raw bytes + warning, fail-soft); owner-locked A4 pin (`ext="jpx"` + smask → raw bytes + unmapped mime, combiner never called); integration: blank + real + **duplicate placement** → exactly 1 image doc, 1 vision call, `pdf_images_deduped=1`/`pdf_images_blank_skipped=1` in the `import: summary` line. Module docstring now covers the full phase-151 matrix. + - `tests/unit/test_pdf.py` (+3 real-file pins, skip-if-missing idiom): `podman-systemd.pdf` → `extract_pdf_images(...) == []` (the bug's tombstone); `pdf_horse_test.pdf` → exactly 1 non-blank image kept; `players_handbook.pdf` → 586 images, all unmapped `jpx` mime (importer's ext gate unchanged). Stale module docstring line ("original encoded bytes") fixed. +- **Results:** `uv run pytest tests/unit/test_pdf_image_filter.py tests/unit/test_pdf.py -v` → 137 passed (0 skipped — real fixtures present, pins executed); `uv run pytest --cov=app --cov-report=term-missing` → **3511 passed, 0 failed**, TOTAL 99% (`app/rag/pdf.py` 100%, `app/rag/importer.py` 99%, `app/config.py` 100%); `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings. +- **Docs:** no doc impact (test-only task; the `BOR_PDF_IMAGE_SKIP_BLANK` README/`.env.example` lines landed in task 04). +- **Deviations (pre-existing, documented in code + task reports):** podman stats pinned at measured 39 dup / 9 blank of 48 placements, not the task file's 31/15 of 46 — the shipped digest-level dedupe (task 01's own spec) merges 6 byte-identical xrefs; binding outcome identical (0 out). Tolerance ±17 (calibrated in task 03, owner-locked formula/floor/fraction unchanged). +- **Next pending task:** `06_e2e_pdf_image_extraction.md` (the dedicated Playwright suite). diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__05_unit_tests.a1.validate b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__05_unit_tests.a1.validate new file mode 100644 index 0000000..8774a40 --- /dev/null +++ b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__05_unit_tests.a1.validate @@ -0,0 +1,120 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +....................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 748 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5682 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__06_e2e_pdf_image_extraction.a1.err b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__06_e2e_pdf_image_extraction.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__06_e2e_pdf_image_extraction.a1.md b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__06_e2e_pdf_image_extraction.a1.md new file mode 100644 index 0000000..ed56ae4 --- /dev/null +++ b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__06_e2e_pdf_image_extraction.a1.md @@ -0,0 +1,10 @@ +Task 06 is complete. Everything is green. + +**Report — phase 151, task 06 (E2E: no blank, no duplicate image docs)** + +- **Implemented:** `tests/e2e/test_pdf_image_extraction.py` — 2 module apps (knob ON default + `BOR_PDF_IMAGE_SKIP_BLANK=false` negative control), PyMuPDF-built fixture (near-white 200×200 underlay + same real image placed 3×, cross-page duplicate = the podman case), seeded through the real upload + Sync UI path; 5 tests: exactly one image doc (first placement `#page=1#img=1`, no duplicate/blank rows), stored bytes checked with the app's own `is_blank_image` (imported), exactly 1 vision call via the mock's `/v1/e2e/captured` window, idempotent re-sync (`0 added · 1 unchanged`, 0 vision calls), knob-off app yields the blank doc again (2 vision calls, dedupe still on) +- **Defect fix (step 8):** phase-151's gate correctly skips the house 1×1-transparent-PNG fixture (its PyMuPDF base is a near-white 1-sample base → blank), which had broken `test_pdf_support.py`, `test_pdf_image_context.py`, `test_garbage_pdf.py`; swapped their embedded fixture to a small non-blank noise PNG (garbage PDF: distinct image per page — same-stream placements now dedupe by design); all behavioral pins untouched +- **Results:** dedicated E2E green in isolation (`uv run pytest tests/e2e/test_pdf_image_extraction.py -v --no-cov` → 5 passed); all six required PDF/image E2Es green in isolation (6+4+3+4+4+5 passed) + `test_garbage_pdf`/`test_pdf_pages_in_sources` green; `uv run pytest --cov=app` → 3511 passed, `app/` coverage 99% (>90%); `uv run ruff check . && uv run pyright` clean +- **Docs:** no doc impact (the `BOR_PDF_IMAGE_SKIP_BLANK` README/`.env.example` entries landed with task 04; task 06 is test-only) +- **Deviation:** the task's "six pre-existing E2Es green unedited" was unsatisfiable — the gate (owner-locked, unit-pinned) legitimately drops the 1×1 transparent fixture, so the three suites' fixture image was updated (behavior pins unchanged); the task's "(c) genuinely distinct colorful image" is realized as the single real image (distinct from the underlay), since the task's own pins fix exactly one image doc and one vision call per PDF +- **Next pending task:** none in `151_pdf_image_extraction/` — phase 151 is complete (harness commits + moves the dir) diff --git a/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__06_e2e_pdf_image_extraction.a1.validate b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__06_e2e_pdf_image_extraction.a1.validate new file mode 100644 index 0000000..8774a40 --- /dev/null +++ b/.agents/reports/151_pdf_image_extraction/151_pdf_image_extraction__06_e2e_pdf_image_extraction.a1.validate @@ -0,0 +1,120 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +....................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 748 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5682 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/screenshots/doc_modal_scrollbar_1280.png b/.agents/screenshots/doc_modal_scrollbar_1280.png index f282e911e06502d944c5ac0426c964cc10e84f94..1c506d87bedda02f7847c34f62b7454d43f94767 100644 GIT binary patch literal 42559 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 I51s%1AF8lqd;kCd diff --git a/.env.example b/.env.example index b0c9d64..2a2bdd7 100644 --- a/.env.example +++ b/.env.example @@ -167,6 +167,18 @@ BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume, # 0 = every page (A21); negative # fails startup loudly +# --- PDF embedded images (phase 151: the blank/decorative gate) --- +# Gated by the SAME BOR_IMAGES toggle as the image docs above (the +# embedded-image pass runs only while it is on). While on (the +# DEFAULT), the pass skips near-white, nearly-uniform embedded images +# entirely — the table-shading "blank white" underlays (TODO L4): +# no row, no copy, no vision call. 0/false = off: EVERY extracted +# image is indexed again (the pre-phase-151 behavior — for a KB of +# scanned/decorative pages where a near-white image still matters). +# Dedupe + soft-mask compositing are unaffected (correctness fixes, +# not toggled). +# BOR_PDF_IMAGE_SKIP_BLANK=1 # 0 = index blank images too + # --- Chat image questions (phase 123: attach an image to a question) --- # Gated by the SAME BOR_IMAGES toggle above (enable only when the chat # model supports vision). The question image is stored on the server — diff --git a/README.md b/README.md index 338378a..dcaee4d 100644 --- a/README.md +++ b/README.md @@ -636,7 +636,15 @@ uploaded archives): persistent copy under `BOR_IMAGE_DIR`, retrievable + displayable); with the toggle off the pass is skipped and the PDF text indexes normally. Embedded images in a format outside `BOR_IMAGE_EXTENSIONS` - are skipped with a warning. + are skipped with a warning. The extraction is de-duplicated and + viewer-faithful (phase 151): repeated placements of the same image + yield ONE document (first placement wins), an image with a soft + mask (`/SMask`) is stored as the composited image a PDF viewer + shows (alpha PNG — not the blank base stream), and near-white, + nearly-uniform images (the table-shading "blank white" underlays) + are skipped entirely — no row, no copy, no vision call. The run + counts the drops log-only on the import summary line + (`pdf_images_deduped=N` `pdf_images_blank_skipped=N`). - **Document context (phase 137).** Every embedded image is described *with* the document it came from: the vision prompt — and the stored `image_context` + enriched content — carry an @@ -830,6 +838,7 @@ to `.env` and adjust. | `BOR_IMPORT_EXTENSIONS` | A9 family | Importable text formats — the A9 default is `md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2`; any well-formed extension is allowed. Add the `pdf` token to make PDFs first-class, retrievable documents (phase 126, opt-in). Images are **not** added here — see `BOR_IMAGES` | | `BOR_PDF_PICTORIAL` | `1` | Master switch for the pictorial PDF pass (phase 140, on by default): a usable-text PDF's sparse pages (stripped text under `BOR_PDF_PICTORIAL_MIN_CHARS`) are rendered + vision-described into the indexed content (`[Page N illustration: …]`); `0` ⇒ content byte-identical to pre-phase-140 (no render, no vision call) | | `BOR_PDF_PICTORIAL_MIN_CHARS` | `100` | Sparseness bar for the pictorial pass (phase 140): a page whose STRIPPED text is STRICTLY shorter than this many chars is a candidate (a page with exactly this many is not); `0` = every page (A21); a negative value fails startup loudly | +| `BOR_PDF_IMAGE_SKIP_BLANK` | `1` | Skip blank/decorative images embedded in PDFs (phase 151, on by default): near-white, nearly-uniform images (the table-shading "blank white" underlays) are dropped from the embedded-image pass — no row, no copy, no vision call; `0` ⇒ every extracted image is indexed again (pre-phase behavior; dedupe + soft-mask compositing unaffected) | | `BOR_PDF_PAGE_DIR` | `~/bor-sources/pages` | Persistent home for the rendered PDF page PNGs (phase 148): a page-qualifying PDF (the phase-133 screenshot class or the phase-140 pictorial class) gets every page rendered at index time as `-p{N}.png` here, so the document viewer shows the actual pages (the descriptions stay embedded for Q&A, just not displayed); text-heavy PDFs keep the text view | | `BOR_GIT_SOURCES` | — (empty) | CSV of git repo URLs — **fallback while the admin Git sources page's list is empty** | | `BOR_SOURCES_DIR` | `~/bor-sources` | Where git repos are cloned/pulled | diff --git a/app/config.py b/app/config.py index 82555f0..f88fddd 100644 --- a/app/config.py +++ b/app/config.py @@ -442,6 +442,23 @@ class Settings(BaseSettings): #: validator fails startup loudly (the ``agent_max_rounds`` pattern). pdf_pictorial_min_chars: int = 100 + # --- PDF embedded images (phase 151: the blank/decorative gate) --- + #: Skip blank/decorative PDF-embedded images (phase 151, task 04, + #: ``BOR_PDF_IMAGE_SKIP_BLANK``; ``0``/``false`` = off — the kill + #: switch, the ``pdf_pictorial``/``images`` naming pattern). While + #: on (the DEFAULT) the parser's blank/decorative gate + #: (``app.rag.pdf.is_blank_image`` — near-white AND nearly uniform, + #: owner-locked A2) drops the near-white underlays (the table- + #: shading "blank white" images, TODO L4) from the embedded-image + #: pass: no row, no copy, no vision call. While off, EVERY + #: extracted image is indexed again — the pre-phase-151 behavior + #: (a KB of scanned/decorative pages where a near-white image + #: still matters). Dedupe (task 01) and soft-mask compositing + #: (task 02) are UNAFFECTED by this knob — they are correctness + #: fixes. A plain bool like ``pdf_pictorial``/``images`` — no + #: validator. + pdf_image_skip_blank: bool = True + # --- Chat image questions (phase 123: attach an image to a question) --- #: Where a user's question image bytes are stored (phase 123, #: ``BOR_CHAT_IMAGE_DIR`` — the ``image_dir`` convention: a sibling of diff --git a/app/rag/importer.py b/app/rag/importer.py index 723970d..c05083d 100644 --- a/app/rag/importer.py +++ b/app/rag/importer.py @@ -317,7 +317,7 @@ from app.rag.md_images import ImageRef, extract_image_refs from app.rag.pdf import ( PAGE_SEPARATOR, PdfImage, - extract_pdf_images, + extract_pdf_images_with_stats, extract_pdf_page_texts, extract_pdf_text, is_usable_pdf_text, @@ -433,6 +433,24 @@ class ImportSummary: #: — the one-time best-effort pass; a success never repeats, a #: failure retries next sync. image_context_backfilled: int = 0 + #: Image OCCURRENCES the PDF parser's dedupe dropped (phase 151, + #: tasks 01/03) — repeated placements + byte-identical copies + #: stored under distinct xrefs (one :class:`app.rag.pdf.PdfImage` + #: per DISTINCT image, first placement wins). Log-only (the sync + #: ``detail`` API shape is untouched — the stats log-only + #: convention, phase 133): the owner reads it in the sync log. + pdf_images_deduped: int = 0 + #: DISTINCT PDF-embedded images the parser's blank/decorative gate + #: dropped (phase 151, task 03 — + #: :func:`app.rag.pdf.is_blank_image` on the base samples): a + #: near-white, nearly-uniform underlay (the TODO L4 "blank white + #: images") is skipped ENTIRELY — no row, no ``image_dir`` copy, + #: no vision call (ASSUMPTION A1). Log-only (the sync ``detail`` + #: API shape is untouched — the stats log-only convention, + #: phase 133); while ``Settings.pdf_image_skip_blank`` (phase 151 + #: task 04, the kill switch) is ``False`` the gate is off and this + #: count stays 0 (pre-phase behavior). + pdf_images_blank_skipped: int = 0 #: Documents this run ADDED, UPDATED, or PRUNED, as (source, path) #: tuples in encounter order (added/updated in walk order, pruned in #: _prune order). Drives the sync's incremental folder-summary @@ -455,7 +473,8 @@ class ImportSummary: "errors=%d chunks=%d embed_batches=%d summaries=%d summary_errors=%d " "summary_backfilled=%d dates_updated=%d images_failed=%d " "image_context_backfilled=%d pdfs_screenshotted=%d " - "pdfs_pictorial=%d pdf_pictorial_pages=%d formats=%s", + "pdfs_pictorial=%d pdf_pictorial_pages=%d pdf_images_deduped=%d " + "pdf_images_blank_skipped=%d formats=%s", self.files, self.added, self.updated, @@ -473,6 +492,8 @@ class ImportSummary: self.pdfs_screenshotted, self.pdfs_pictorial, self.pdf_pictorial_pages, + self.pdf_images_deduped, + self.pdf_images_blank_skipped, self.format_counts(), ) @@ -1934,10 +1955,21 @@ async def _index_pdf_embedded_images( """The phase-126 (A3) embedded-image pass — one call per (re)indexed PDF, after its document commit. - Each embedded image (page order, 0-based index on the page — the - parser's order) is indexed as a STANDALONE image document through - the phase-122 mechanics, inline (no temp file, no - ``_index_image_file`` refactor): + Each DISTINCT embedded image (the parser dedupes repeated + placements and byte-identical copies — phase 151 task 01 — first + placement wins; page order, 0-based index on the page — the + parser's order; the parser's blank/decorative gate — phase 151 + task 03, :func:`app.rag.pdf.is_blank_image` on the base samples — + drops near-white underlays BEFORE they reach this pass: no row, + no copy, no vision call) is indexed as a STANDALONE image document + through the phase-122 mechanics, inline (no temp file, no + ``_index_image_file`` refactor): the gate is ON by default and + ``Settings.pdf_image_skip_blank`` (``BOR_PDF_IMAGE_SKIP_BLANK`` — + phase 151 task 04, the kill switch, read HERE — the one place — + and passed down as the extractor's ``skip_blank`` keyword) is its + single knob: ``false`` restores indexing every extracted image + (the pre-phase-151 behavior); dedupe and soft-mask compositing are + UNAFFECTED by the knob (they are correctness fixes). * gated by the phase-122 ``images`` toggle (the single knob, LOCKED A3): while it is OFF the whole pass is skipped (one debug line — @@ -1947,12 +1979,19 @@ async def _index_pdf_embedded_images( (:data:`_MIME_TO_EXT`) is skipped with a warning (the unsupported- extension rule, same as a standalone image's); * the document's synthetic ``path`` is - ``#page=#img=.`` — unique per image - occurrence, stable across re-indexes (the upsert key); the title - is ``Embedded image from page ``; + ``#page=#img=.`` — N/index are the + FIRST placement's (the parser's dedupe, phase 151 task 01), + unique per distinct image, stable across re-indexes (the upsert + key); the title is ``Embedded image from page ``; * the digest is sha256 over the image BYTES (the digest rule is content identity); an image doc whose digest matches is skipped - (no re-describe, no re-embed — unchanged); + (no re-describe, no re-embed — unchanged). Phase 151 (task 02) + consequence, verified: a MASKED image's bytes change with the + smask compositing (base stream → alpha PNG), so pre-phase rows + for masked images re-describe ONCE on the first post-phase sync + (their sha256 digest differs) — expected, self-limiting, no + migration; unmasked images' bytes are untouched, so their rows + stay digest-skipped as before; * the description comes through the SAME single seam (:func:`_describe_or_skip` — the bytes as a data URL whose mime comes from the copy path's extension); a failed/empty description @@ -2018,7 +2057,18 @@ async def _index_pdf_embedded_images( # task-03 backfill probes the parent with the SAME helper when it # is still in the source, so the two paths can never drift. page_texts, text_usable = _pdf_image_context_inputs(full_path) - images = extract_pdf_images(full_path) + # Phase 151 (task 03): the stats-carrying variant — the parser's + # per-PDF drop counts (dedupe + blank gate) land on the run's + # summary (log-only: the sync ``detail`` API shape is untouched). + # Phase 151 (task 04): the knob is read HERE — the one place — + # and passed down as the extractor's `skip_blank` keyword + # (default True; `false` = every extracted image indexed, the + # pre-phase behavior; dedupe + compositing are unaffected). + images, stats = extract_pdf_images_with_stats( + full_path, skip_blank=settings.pdf_image_skip_blank + ) + summary.pdf_images_deduped += stats.duplicates + summary.pdf_images_blank_skipped += stats.blank_skipped for img in images: await _index_one_embedded_image( session, img, source=source, rel=rel, full_path=full_path, diff --git a/app/rag/pdf.py b/app/rag/pdf.py index a64da1d..cda8512 100644 --- a/app/rag/pdf.py +++ b/app/rag/pdf.py @@ -16,10 +16,39 @@ package, imported as ``fitz`` — its stable API name). value for page N is exactly that page's separator-block body). The phase-137 image-context block is built from this map (page N's entry is the document context for an image embedded on page N). -* :func:`extract_pdf_images` — one :class:`PdfImage` per embedded-image - occurrence, in page order then 0-based index on the page. ``data`` is - the image's original encoded bytes as stored in the PDF (no - re-encoding), ``mime`` is derived via :func:`_mime_from_image_info`. +* :func:`extract_pdf_images` — one :class:`PdfImage` per DISTINCT + embedded image (page order, first placement wins) — repeated + placements and byte-identical copies are deduplicated (phase 151 + task 01). ``data`` is the image's original encoded bytes as stored in + the PDF (no re-encoding), ``mime`` is derived via + :func:`_mime_from_image_info` — EXCEPT an image stored with a soft + mask (``/SMask``): ``extract_image`` returns the BASE sample stream + only (the mask carries the visible shape), so masked images are + composited to the visible alpha PNG (``mime="image/png"``) — the + bytes a PDF viewer would show (phase 151 task 02); an image whose + raw format is unsupported (the ``application/octet-stream`` + fallback, e.g. JPEG2000 ``jpx``) is left untouched (raw bytes, raw + mime) so the importer's extension gate still decides. THEN the + blank/decorative gate (phase 151 task 03, ``skip_blank=True`` by + default — the ``skip_blank=False`` keyword is the function-level + kill switch): a distinct image whose base samples are near-white AND + nearly uniform (:func:`is_blank_image`) is SKIPPED entirely — no + row, no copy, no vision call (ASSUMPTION A1). +* :func:`extract_pdf_images_with_stats` — the stats-carrying variant + the importer calls (phase 151 task 03): the same extraction plus a + :class:`PdfImageStats` (``duplicates`` — occurrences dropped by the + dedupe, ``blank_skipped`` — distinct images dropped by the gate, + ``total_found`` — occurrences found) so the run's + ``ImportSummary`` can record them log-only (``pdf_images_deduped`` / + ``pdf_images_blank_skipped`` — the phase-133 stats convention, the + sync ``detail`` API shape untouched). +* :func:`is_blank_image` — the phase-151 (task 03) blank/decorative + predicate: pure, deterministic, no PDF, no I/O (the + :func:`is_usable_pdf_text` / :func:`pictorial_pages` house style) + — near-white (mean ≥ 235) AND nearly uniform (≥98% of the samples + within ±17 of the mean). A dark uniform block is NOT blank (the gate + targets near-white page underlays); empty samples are not blank + (never blank a thing we cannot measure). * :func:`_mime_from_image_info` — maps a PyMuPDF image-info dict to a MIME type: the embedded file extension (``ext``) first, then the image type code (``type``: 2=JPEG, 3=PNG, 4=GIF, 8=TIFF, 9=WebP), @@ -50,6 +79,7 @@ never raises for malformed input (the importer owns the loud failures). """ from __future__ import annotations +import hashlib import logging import re from collections.abc import Iterator, Sequence @@ -86,6 +116,45 @@ _EXT_TO_MIME: dict[str, str] = { "tiff": "image/tiff", } +#: Phase 151 (task 03) — the blank/decorative image gate's thresholds +#: (ASSUMPTION A2, owner-locked): a PDF-embedded image is +#: blank/decorative when its COLOR samples are NEAR-WHITE AND NEARLY +#: UNIFORM — :func:`is_blank_image`'s three numbers. +#: Measured basis (probe: ``fitz.Pixmap(...).samples`` on the image's +#: BASE sample stream, the three real PDFs in ``test_sources/``): +# +#: * ``podman-systemd.pdf``'s 9 table-shading underlays: mean 238, +#: uniq 8, uniform(±8) 0.95–0.995 → blank (the TODO L4 bug — the +#: "blank white images" the owner saw in the KB); +#: * ``pdf_horse_test.pdf``'s photo: mean 123, uniq 256, uniform ~0.20 +#: → kept (the mean gate); +#: * ``players_handbook.pdf``'s page art: mean 24–221, uniq ≥188, +#: uniform 0.10–0.59 → kept (the mean gate). +BLANK_IMAGE_MEAN = 235 #: near-white floor (owner-locked A2) +#: "same as the mean" band (owner-locked A2 — see the CALIBRATED +#: DEVIATION note below: the phase overview's ±12 misses the podman +#: PDF's 238/255-blend underlay, ±17 is the smallest fix). +BLANK_IMAGE_TOLERANCE = 17 +#: fraction of samples that must sit in the band (owner-locked A2). +BLANK_IMAGE_FRACTION = 0.98 + +# CALIBRATED DEVIATION (flagged per AGENTS.md rule 3 — never silent): +# the phase overview pinned the band at ±12, but the podman PDF's +# smallest underlay (page 47, 5 300 samples) is a 238/255 blend whose +# 255 level sits 16.2 above its mean (238.81) — at ±12, 4.5% of its +# samples fall out-of-band (uniform 0.954 < 0.98) and the image +# SURVIVES the gate, violating the phase's completion criterion +# (the extractor must return NO images for that file — the owner's +# file is the phase's reason for existing; a 238/255 strip is the +# very "blank white" document the TODO reported, the table text a +# viewer shows is real PDF text drawn on top). ±17 is the smallest +# integer band that keeps every measured real-file pin: all 9 podman +# underlays blank (99.4–99.9% in-band at ±17), the horse photo kept +# (mean 123 < 235 — the mean gate, tolerance irrelevant), and all 586 +# players_handbook images kept (means 17–225 — the mean gate). The +# predicate's formula, the mean floor (235), and the fraction (0.98) +# are the owner-locked values, unchanged. + @dataclass class PdfImage: @@ -95,8 +164,33 @@ class PdfImage: index: int #: image index on the page (0-based, occurrence order) width: int #: native pixel width height: int #: native pixel height - data: bytes #: original encoded bytes as stored in the PDF - mime: str #: MIME type (e.g. ``image/png``) + data: bytes #: encoded bytes — the original stored bytes, or the + #: composited (base + soft mask) alpha PNG for masked images + #: (phase 151 task 02) + mime: str #: MIME type (e.g. ``image/png`` — always for composited + #: masked images, phase 151 task 02) + + +@dataclass(frozen=True) +class PdfImageStats: + """The per-PDF drop counts of one + :func:`extract_pdf_images_with_stats` call (phase 151, task 03). + + The importer records :attr:`duplicates` / :attr:`blank_skipped` + on the run's :class:`app.rag.importer.ImportSummary` (the + phase-133 stats log-only convention — the sync ``detail`` API + shape is untouched). + """ + + duplicates: int #: image OCCURRENCES dropped by the dedupe (task + #: 01) — repeated placements + byte-identical copies under + #: distinct xrefs; per run: sum across the PDFs + blank_skipped: int #: DISTINCT images dropped by the + #: blank/decorative gate (task 03) — no row, no image_dir copy, + #: no vision call + total_found: int #: image occurrences FOUND in the PDF (one per + #: placement — before dedupe and gate); hence + #: ``len(images) == total_found - duplicates - blank_skipped`` def _mime_from_image_info(info: dict[str, Any]) -> str: @@ -308,6 +402,54 @@ def pictorial_pages(page_texts: dict[int, str], *, min_chars: int) -> list[int]: ) +def is_blank_image( + samples: bytes, + *, + mean_min: int = BLANK_IMAGE_MEAN, + tolerance: int = BLANK_IMAGE_TOLERANCE, + fraction: float = BLANK_IMAGE_FRACTION, +) -> bool: + """Whether *samples* (raw image sample bytes — one byte per sample, + COLOR channels only, no alpha) are a BLANK/DECORATIVE near-white + image (phase 151, task 03). + + In the house style of :func:`is_usable_pdf_text` / + :func:`pictorial_pages`: pure, deterministic, NO PDF, NO I/O, NO + LLM — unit-testable with hand-built sample buffers. + + Blank when ALL of: + + * ``samples`` is non-empty — empty samples → ``False`` (never blank + a thing we cannot measure); + * ``sum(samples) / len(samples) >= mean_min`` — the near-white + floor (default :data:`BLANK_IMAGE_MEAN` = 235). A DARK uniform + block is NOT blank by this rule: the gate targets near-white + PAGE UNDERLAYS (the table-shading rectangles the TODO L4 bug + indexed) — a solid dark block is unusual and cheaper to keep + than to explain away; + * at least ``fraction`` of the samples lie within ``±tolerance`` + of the mean (defaults :data:`BLANK_IMAGE_FRACTION` = 0.98, + :data:`BLANK_IMAGE_TOLERANCE` = 17) — the "carries no visible + content" test: a real photo scatters its samples far outside + the band, a decorative underlay sits on one (near-white) level. + + Measured basis (the constants' docstring carries the full numbers): + the podman underlays (mean 238, uniq 8, uniform(±8) 0.95–0.995) + are blank; the horse photo (mean 123, uniq 256, uniform ~0.20) and + the handbook page art (mean 24–221, uniq ≥188, uniform 0.10–0.59) + are kept — the mean gate alone carries both real-file "kept" pins, + the fraction+tolerance carry the "near-white but textured" class. + """ + if not samples: + return False + n = len(samples) + mean = sum(samples) / n + if mean < mean_min: + return False + uniform = sum(1 for s in samples if abs(s - mean) <= tolerance) / n + return uniform >= fraction + + def render_pdf_pages( pdf_path: Path, dpi: int = 150, pages: Sequence[int] | None = None ) -> list[bytes]: @@ -362,23 +504,111 @@ def render_pdf_pages( return rendered -def extract_pdf_images(pdf_path: Path) -> list[PdfImage]: - """Extract all embedded images from *pdf_path*. +def extract_pdf_images_with_stats( + pdf_path: Path, *, skip_blank: bool = True +) -> tuple[list[PdfImage], PdfImageStats]: + """Extract all DISTINCT embedded images from *pdf_path*, with the + per-PDF drop counts (phase 151, task 03 — the stats-carrying + variant the importer calls; :func:`extract_pdf_images` is the thin + list-only wrapper for every other caller). - Returns one :class:`PdfImage` per image occurrence, in page order - then 0-based index on the page. ``data`` is the original encoded - bytes as stored in the PDF; ``mime`` is derived from the image's - extension/type via :func:`_mime_from_image_info`. + Returns ``(images, stats)`` — one :class:`PdfImage` per DISTINCT + embedded image, in page order then 0-based index on the page, plus + the :class:`PdfImageStats` of the drops (``len(images) == + stats.total_found - stats.duplicates - stats.blank_skipped``). The + FIRST occurrence wins: the kept entry carries the first + placement's ``page``/``index`` (the importer's synthetic path + ``…#page=N#img=i.`` is therefore stable for the first + placement). Two kinds of duplicate are dropped (phase 151, task + 01) — PyMuPDF reports one info entry per PLACEMENT, so a PDF that + places the same image N× yields N entries: - Fail-soft: an unreadable file returns ``[]``; an image without an - ``xref`` or that cannot be extracted is logged and skipped. + * the same ``xref`` seen again (a repeated placement) — the + ``seen_xrefs`` set; + * byte-identical stored bytes under a DIFFERENT ``xref`` (the same + sha256 of ``data``) — the ``seen_digests`` set (real documents DO + store the same image under several xrefs — the digest set is what + catches those; unique-xref count overstates the distinct-image + count). + + When anything was dropped, ONE INFO line per PDF after the page + loop (``pdf: dropped N duplicate image occurrence(s) from + (M unique)``) plus one DEBUG line per drop (``pdf: duplicate image + xref=… page=… index=… of — skipped``). Dedupe is PER-PDF — + the seen-sets are local to this call, never across files (two PDFs + sharing a logo each keep their own image doc: the synthetic path is + per-PDF, so cross-PDF dedupe would break the upsert key). + + ``data`` is the original encoded bytes as stored in the PDF; + ``mime`` is derived from the image's extension/type via + :func:`_mime_from_image_info` — with one exception (phase 151, + task 02): an image stored with a soft mask (``/SMask``). PyMuPDF's + ``extract_image`` returns the BASE sample stream only — the mask + (which carries the visible shape — e.g. the table-shading underlay + the owner's podman PDF stores as a near-white base) is dropped — so + for such images the extractor composites base + mask into the + VISIBLE image (``fitz.Pixmap(base, mask)`` → ``data = + visible.tobytes("png")``, ``mime = "image/png"``): the bytes a PDF + viewer shows, not the blank base. Two rules: + + * the RAW-format gate runs FIRST (owner-locked A4): an image whose + raw format the importer's ``_MIME_TO_EXT`` gate rejects (the + ``application/octet-stream`` fallback — e.g. JPEG2000 ``jpx``, + the 586 images in ``players_handbook.pdf``) is NOT composited — + raw bytes + raw mime, byte-identical to pre-phase; compositing + must never newly admit an unsupported format (a ``jpx`` image + with a mask stays skipped); + * the compositing is fail-soft: any exception (a colorspace the + combiner cannot merge, a broken mask xref) falls back to the raw + bytes + raw mime with a warning — the image is still indexed, + exactly as pre-phase (a decode problem never loses a real + image). ``width``/``height`` are unchanged either way (the + composited pixmap has the base's dimensions). + + THEN the blank/decorative gate (phase 151, task 03, gated by + *skip_blank* — the kill switch at the function level; task 04's + ``BOR_PDF_IMAGE_SKIP_BLANK`` setting lands here, dedupe and mask + compositing are NOT knobed): a distinct image whose samples are + near-white AND nearly uniform (:func:`is_blank_image`) is SKIPPED + entirely — not returned (ASSUMPTION A1: never indexed, never + described — no row, no ``image_dir`` copy, no vision call) — with + one INFO line per skipped image (``pdf: skipped blank/decorative + image page=… index=… of — mean=… uniform=…``) and one INFO + line per PDF when anything was skipped (``pdf: skipped N + blank/decorative image(s) in ``). The gate measures the + image's BASE sample stream (``fitz.Pixmap(doc, xref)``, the alpha + channel dropped first when present — the mean is over COLOR + samples only, an alpha channel would drag the mean and produce + false blanks) for every image — masked or not: for a masked image + the base is the correct "visible content" signal, because the + ``/SMask`` only modulates the base's VISIBILITY (a near-white base + renders near-white over the white page under ANY mask, so a + near-white base is decorative no matter what the mask says) — and + the stored composite's color channels are PyMuPDF's + PREMULTIPLIED base×mask/255, which darkens the transparent regions + and would miss the very near-white underlays this gate targets + (measured on the podman underlays: alpha-stripped composite + mean ≈ 231–234 / uniform ≈ 0.95 vs base mean ≈ 238 / uniform + ≈ 0.99 — only the base clears the pinned thresholds on every + underlay). The gate is fail-soft: a pixmap that cannot be built + skips the gate (the image is KEPT, DEBUG why) — a decode problem + must never delete a real image. + + Fail-soft: an unreadable file returns ``([], PdfImageStats(0, 0, + 0))``; an image without an ``xref`` or that cannot be extracted is + logged and skipped. """ try: doc = fitz.open(str(pdf_path)) except Exception as exc: logger.warning("pdf: could not open %s for image extraction — %s", pdf_path, exc) - return [] + return [], PdfImageStats(duplicates=0, blank_skipped=0, total_found=0) + seen_xrefs: set[int] = set() + seen_digests: set[str] = set() + dropped = 0 + blank_skipped = 0 + total_found = 0 images: list[PdfImage] = [] with doc: for page_num in range(len(doc)): @@ -413,10 +643,133 @@ def extract_pdf_images(pdf_path: Path) -> list[PdfImage]: page_num + 1, index, pdf_path, ) continue + # One image occurrence found (valid xref, extracted, + # non-empty data) — the input to dedupe + gate (task + # 03's total_found; len(images) = total_found - + # duplicates - blank_skipped). + total_found += 1 + # Phase 151 (task 01): one PdfImage per DISTINCT image. + # Repeated placements share the xref (the first check); + # byte-identical bytes stored under a different xref are + # caught by the digest of the stored bytes (the second). + if xref in seen_xrefs: + dropped += 1 + logger.debug( + "pdf: duplicate image xref=%d page=%d index=%d of %s — skipped", + xref, page_num + 1, index, pdf_path, + ) + continue + digest = hashlib.sha256(data).hexdigest() + if digest in seen_digests: + dropped += 1 + logger.debug( + "pdf: duplicate image xref=%d page=%d index=%d of %s — skipped", + xref, page_num + 1, index, pdf_path, + ) + continue + seen_xrefs.add(xref) + seen_digests.add(digest) # The image info's own `ext` is unreliable in some # PyMuPDF versions (None); `extract_image` reports the # actual format of the returned bytes, so it wins. mime = _mime_from_image_info({**info, "ext": extracted.get("ext") or ""}) + # Phase 151 (task 02): a /SMask carries the visible + # shape — `extract_image` returns the BASE sample + # stream only (the mask is dropped), so the raw bytes + # are not what a PDF viewer shows. Composite base + + # mask to the visible alpha PNG. + # + # Ordering rule (owner-locked A4): the raw-format gate + # runs FIRST — an image whose raw format the importer's + # `_MIME_TO_EXT` gate will reject (the + # `application/octet-stream` fallback — e.g. the 586 + # JPEG2000 `jpx` images in players_handbook.pdf) is + # left EXACTLY as today (raw bytes, raw mime, skipped + # downstream): compositing must never newly admit an + # unsupported format. + smask = int(extracted.get("smask") or 0) + # The image's BASE pixmap, kept for the blank gate + # (task 03) when the compositing built it (a second + # decode of the same xref for the gate would be wasted + # work; when the compositing never ran — no mask or an + # unsupported raw format — the gate builds it itself). + base_pixmap: Any = None + if smask and mime != "application/octet-stream": + try: + base = fitz.Pixmap(doc, xref) + base_pixmap = base + mask = fitz.Pixmap(doc, smask) + visible = fitz.Pixmap(base, mask) # base + alpha-from-mask + data = visible.tobytes("png") + mime = "image/png" + logger.debug( + "pdf: composited smask=%d for image page=%d index=%d of %s", + smask, page_num + 1, index, pdf_path, + ) + except Exception as exc: + # Fail-soft: `data`/`mime` keep their raw + # values — the image is still indexed, exactly + # as pre-phase (a decode problem never loses a + # real image). + logger.warning( + "pdf: could not composite smask=%d for image page=%d " + "index=%d of %s — %s; using raw bytes", + smask, page_num + 1, index, pdf_path, exc, + ) + # Phase 151 (task 03): the blank/decorative gate — a + # near-white, nearly-uniform image (a table-shading + # underlay) carries no visible content: skip it + # entirely (ASSUMPTION A1 — no row, no copy, no vision + # call). Gated by `skip_blank` (the function-level kill + # switch; task 04's settings knob lands here) — dedupe + # and mask compositing above are NOT knobed. + if skip_blank: + try: + # The BASE sample stream (the docstring's gate- + # input rule): the base's color content is what + # the image SHOWS (a mask only modulates its + # visibility), and PyMuPDF's stored composite + # is premultiplied (base×mask/255) — darkened + # in the transparent regions, where the gate + # would miss the very underlays it targets. + gate_pixmap = ( + base_pixmap + if base_pixmap is not None + else fitz.Pixmap(doc, xref) + ) + if gate_pixmap.alpha: + # Drop the alpha FIRST: the mean is over + # COLOR samples only (an alpha channel + # would drag the mean and produce false + # blanks). + gate_pixmap = fitz.Pixmap(gate_pixmap, 0) + samples = gate_pixmap.samples + if is_blank_image(samples): + blank_skipped += 1 + mean = sum(samples) / len(samples) + uniform = ( + sum( + 1 + for s in samples + if abs(s - mean) <= BLANK_IMAGE_TOLERANCE + ) + / len(samples) + ) + logger.info( + "pdf: skipped blank/decorative image page=%d " + "index=%d of %s — mean=%.1f uniform=%.3f", + page_num + 1, index, pdf_path, mean, uniform, + ) + continue + except Exception as exc: + # Fail-soft: a pixmap that cannot be built + # skips the gate (the image is KEPT — a decode + # problem must never delete a real image). + logger.debug( + "pdf: blank gate could not build pixmap page=%d " + "index=%d of %s — %s; keeping image", + page_num + 1, index, pdf_path, exc, + ) images.append( PdfImage( page=page_num + 1, @@ -427,4 +780,35 @@ def extract_pdf_images(pdf_path: Path) -> list[PdfImage]: mime=mime, ) ) - return images + if dropped: + logger.info( + "pdf: dropped %d duplicate image occurrence(s) from %s (%d unique)", + dropped, pdf_path, len(images), + ) + if blank_skipped: + logger.info( + "pdf: skipped %d blank/decorative image(s) in %s", + blank_skipped, pdf_path, + ) + return images, PdfImageStats( + duplicates=dropped, blank_skipped=blank_skipped, total_found=total_found + ) + + +def extract_pdf_images( + pdf_path: Path, *, skip_blank: bool = True +) -> list[PdfImage]: + """Extract all DISTINCT embedded images from *pdf_path*. + + Thin wrapper over :func:`extract_pdf_images_with_stats` (phase 151, + task 03) returning the image list only — the pre-task-03 contract + for every other caller. *skip_blank* (default ON) passes straight + through: the kill switch at the function level — + ``extract_pdf_images(path, skip_blank=False)`` restores the + pre-phase result (every distinct image returned, gate off; the + dedupe and mask compositing stay on — they are correctness fixes, + not knobed). The importer calls the ``_with_stats`` variant so the + run's :class:`~app.rag.importer.ImportSummary` can record the drop + counts (the phase-133 stats log-only convention). + """ + return extract_pdf_images_with_stats(pdf_path, skip_blank=skip_blank)[0] diff --git a/tests/e2e/test_garbage_pdf.py b/tests/e2e/test_garbage_pdf.py index ae8c6ba..e0878ec 100644 --- a/tests/e2e/test_garbage_pdf.py +++ b/tests/e2e/test_garbage_pdf.py @@ -64,9 +64,9 @@ Fixture provenance: garbage line repeated 20× (``Web`` — 100% of the lines look like tags, so the task-02 gate fails deterministically), plus ONE small embedded raster image per page - (a 1×1 PNG via ``insert_image`` — the fallback's embedded-image leg - runs too: the screenshots are the CONTENT, the extracted images are - the citation figures — both, no conflict). + (a colorful noise PNG via ``insert_image`` — the fallback's + embedded-image leg runs too: the screenshots are the CONTENT, the + extracted images are the citation figures — both, no conflict). * ``cleandoc/`` — ONE small clean PDF (PyMuPDF, real prose text, no images) — the gate's no-false-negative subject. @@ -101,7 +101,6 @@ Test → observable mapping (Playwright Mapping Rule): """ from __future__ import annotations -import base64 import json import os import re @@ -181,13 +180,33 @@ CLEAN_PROSE = ( "The clean reference page covers the homelab firewall rule audit." ) -#: A real 1×1 transparent PNG (the E2E suites' fixture — the pipeline -#: is content-agnostic; the well-formed bytes keep the extraction + -#: copy + serve path honest). -PNG_1X1 = base64.b64decode( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ" - "AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=" -) +def _embedded_png(seed: int) -> bytes: + """A small deterministic colorful noise PNG (channel means ~97 — + the "real image" stand-in that CLEARS the phase-151 blank/ + decorative gate's near-white floor with room to spare). The old + 1×1 transparent fixture decomposed in PyMuPDF to a near-white + 1-sample base — blank by the owner-locked gate — and is now + (correctly) skipped as a decorative underlay, so the suite's + pipeline pins ride on a real non-blank image instead (the + pipeline is content-agnostic; the well-formed bytes keep the + extraction + copy + serve path honest). *seed* offsets the pixel + formula — the two pages carry DISTINCT images (the same stream + twice would be one placement pair: the phase-151 dedupe keeps the + first and drops the repeat, and this suite pins one image DOC PER + PAGE).""" + pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, 24, 18)) + for y in range(18): + for x in range(24): + pix.set_pixel( + x, y, + ((x * 7 + seed) % 256, (y * 13 + seed) % 256, ((x + y) * 5 + seed) % 256), + ) + return pix.tobytes("png") + + +#: The two embedded fixture images (24×18 colorful noise — non-blank +#: by the phase-151 gate, distinct per page — see :func:`_embedded_png`). +EMBED_PNGS = (_embedded_png(0), _embedded_png(1)) def _build_garbage_pdf(path: Path) -> None: @@ -196,16 +215,18 @@ def _build_garbage_pdf(path: Path) -> None: = the owner's exact garbage line × 20 (the gate's XML signal fails deterministically — 100% of the non-empty lines look like tags; the ``---PAGE-N---`` separators are not tag-shaped), plus ONE small - embedded raster image per page (the 1×1 PNG — the fallback's - embedded-image leg runs too). The text layer is real EXTRACTABLE - text — this is the FINDING's class (the pre-126 raw-XMP soup), not - the empty-layer class.""" + embedded raster image per page (a DISTINCT noise PNG per page — + the fallback's embedded-image leg runs too). The text layer is real + EXTRACTABLE text — this is the FINDING's class (the pre-126 raw-XMP + soup), not the empty-layer class.""" doc = fitz.open() - for _page_num in range(1, N_GARBAGE_PAGES + 1): + for page_num in range(1, N_GARBAGE_PAGES + 1): page = doc.new_page() for i in range(GARBAGE_LINES_PER_PAGE): page.insert_text((72, 72 + i * 14), GARBAGE_LINE) - page.insert_image(fitz.Rect(72, 500, 73, 501), stream=PNG_1X1) + page.insert_image( + fitz.Rect(72, 500, 96, 518), stream=EMBED_PNGS[page_num - 1] + ) doc.save(path) doc.close() @@ -648,7 +669,7 @@ def test_garbage_pdf_takes_the_screenshot_path( assert " str: def _build_pdf(root: Path) -> Path: """The fixture PDF (the phase-126 recipe, built LOCALLY): two - text pages + one embedded 1×1 PNG on page 2 + the metadata + text pages + one embedded noise PNG on page 2 + the metadata title.""" pdf_path = root / DOC_PATH doc = fitz.open() doc.new_page().insert_text((72, 72), PAGE_ONE) page2 = doc.new_page() page2.insert_text((72, 72), PAGE_TWO) - page2.insert_image(fitz.Rect(72, 150, 73, 151), stream=PNG_1X1) + page2.insert_image(fitz.Rect(72, 150, 96, 168), stream=EMBED_PNG) doc.set_metadata({"title": PDF_TITLE}) doc.save(pdf_path) doc.close() diff --git a/tests/e2e/test_pdf_image_extraction.py b/tests/e2e/test_pdf_image_extraction.py new file mode 100644 index 0000000..6767c08 --- /dev/null +++ b/tests/e2e/test_pdf_image_extraction.py @@ -0,0 +1,802 @@ +"""Phase 151 E2E (Playwright): a PDF sync produces no blank, no duplicate +image documents — the owner's complaint, proven fixed in the browser +(TODO L4: "it extracted a series of blank white images … there don't +appear to be any blank images in the document, is this a bug?"). + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_pdf_image_extraction.py -v --no-cov + +MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the vision call +counts are read from the mock's request capture (the +``/v1/e2e/captured`` pair), which only the deterministic mock exposes. + +Two module apps (the ``test_pdf_support.py`` per-suite-app pattern — +the conftest env with the leak guards, ``BOR_IMAGES=true`` + the +``pdf`` import token pinned EXPLICITLY on both, the mock LLM only): + +* **the story app** (``BOR_PDF_IMAGE_SKIP_BLANK=true`` — the default, + pinned so an operator's local gitignored ``.env`` cannot leak the + premise off): the fixture PDF (below) arrives through the REAL admin + upload flow + the RAG page's **Sync** button (the same user path the + owner used). Through the product path: + - the catalog carries EXACTLY ONE image document for the PDF — the + distinct real image at its FIRST placement (``…#page=1#img=1.png`` + — the dedupe's stability contract: the same-page duplicate at + ``#img=2`` and the cross-page duplicate on page 2 — the podman + case — never produced their own documents); + - NO image document's stored bytes are a blank white image — the + stored copy is checked with the APP'S OWN predicate + (``app.rag.pdf.is_blank_image`` imported, not re-derived, so the + test cannot drift from the product); + - the mock's vision call count for image description is 1 (the + duplicate placements and the blank underlay never reached the + model — the cost the owner paid in "several minutes"); + - a follow-up re-sync (nothing changed) settles on + ``0 added · 1 unchanged`` and creates nothing new — idempotence + after the fix. +* **the knob-off app** (``BOR_PDF_IMAGE_SKIP_BLANK=false`` — the + negative control): the SAME PDF through the SAME user path now + yields the blank underlay document too (the knob is proven live in + the product, not just in unit tests): two image documents, the + underlay's stored bytes blank per the app's own predicate — the + exact class the story app skipped — and two vision calls (the + pre-phase cost). The knob only gates the blank gate: the duplicate + placements still produce no documents (dedupe is a correctness + fix, not knobed). + +The fixture PDF (built with PyMuPDF in the fixture — the house +convention, no binary committed): two text pages; page 1 carries the +near-white 200×200 "decorative underlay" (image index 0 — the blank +gate's target), then the real image — genuinely distinct (colorful +noise) from the underlay — inserted TWICE (indices 1 and 2, the +same-page duplicate placement); page 2 carries one more placement of +the real image (index 0 — the cross-page duplicate, the podman case, +where each image was placed 2–4 times). Four placements, two distinct +images, so the extractor must index exactly ONE image document and +make exactly ONE vision call (the task's "exactly one image document +for the PDF" and "vision call count is 1" pins fix the distinct +non-blank total at one — the colorful real image IS the genuinely +distinct image the task's fixture list names). + +Test-order note: both apps share the E2E Postgres — the story seed +runs first, the knob-off seed truncates and re-seeds (the phase-126 +isolation pattern), so every story test runs before the knob-off +test, which truncates at the end. +""" +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import zipfile +from collections.abc import Iterator +from pathlib import Path + +import httpx +import pymupdf as fitz # PyMuPDF — the fixture-PDF builder (house convention) +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 is_blank_image +from e2e.auth_helpers import login +from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http +from e2e.mock_llm import IMAGE_DESCRIPTION_CONTEXT_ANSWER + +REPO = Path(__file__).resolve().parents[2] +SOURCE_ON = "e2e-pdf151" # the story app's archive stem (archive_source_name) +SOURCE_OFF = "e2e-pdf151-off" # the knob-off app's archive stem +DOC_PATH = "guide.pdf" + +#: The synthetic embedded-image paths (the importer's +#: ``#page=#img=.`` rule). The underlay sits +#: at page 1 index 0; the real image's FIRST placement at page 1 +#: index 1; the duplicates at page 1 index 2 and page 2 index 0 (they +#: must never appear as documents). +BLANK_REL = f"{DOC_PATH}#page=1#img=0.png" # the underlay (skipped by default) +REAL_REL = f"{DOC_PATH}#page=1#img=1.png" # the real image, first placement +DUP_RELS = ( + f"{DOC_PATH}#page=1#img=2.png", # the same-page duplicate + f"{DOC_PATH}#page=2#img=0.png", # the cross-page duplicate (podman case) +) + +PDF_TITLE = "Podman Systemd Guide" # the metadata title + +#: The fixture PDF's page texts (the image's page — page 1 — carries +#: the document context the phase-137 block stores on the image doc). +PAGE_ONE = "The podman systemd guide covers container unit files and their restart policies." +PAGE_TWO = "The guide's appendix lists the journal log locations and the default timeouts." + +#: The kept image's stored document block (no markdown reference exists +#: in the source — the doc block alone: provenance origin line + the +#: page's text). +DOC_BLOCK = f"Extracted from {SOURCE_ON}/{DOC_PATH} (page 1):\n{PAGE_ONE}" + +SYNCED_LABEL = re.compile(r"^Synced \d{1,2}:\d{2}$") +UPLOAD_TIMEOUT_MS = 30_000 +SYNC_TIMEOUT_MS = 60_000 + + +# --------------------------------------------------------------------------- +# Fixture PDF: the images (stdlib-free, PyMuPDF-built — deterministic) +# --------------------------------------------------------------------------- + + +def _solid_png(width: int, height: int, value: int) -> bytes: + """A uniform RGB PNG (one ``value`` per channel). At 250 the + underlay's samples are mean 250 (≥ the 235 near-white floor) and + 100% within any tolerance band — blank by + :func:`app.rag.pdf.is_blank_image` with room to spare.""" + pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, width, height)) + pix.clear_with(value) + return pix.tobytes("png") + + +def _noise_png(width: int, height: int) -> bytes: + """A deterministic colorful noise PNG (channel means ~127 — the + "real photo" stand-in: it clears the mean gate with room to spare, + and its scattered samples clear the uniformity gate). Genuinely + distinct from the underlay in both channels AND structure.""" + pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, width, height)) + for y in range(height): + for x in range(width): + pix.set_pixel(x, y, ((x * 7) % 256, (y * 13) % 256, ((x + y) * 5) % 256)) + return pix.tobytes("png") + + +#: The two fixture images: the near-white 200×200 decorative underlay +#: (the TODO L4 "blank white" class) and the genuinely distinct +#: colorful real image. +BLANK_PNG = _solid_png(200, 200, 250) +REAL_PNG = _noise_png(96, 64) + + +def _build_fixture_pdf(path: Path) -> None: + """The phase-151 fixture: two text pages; page 1 carries the + underlay (index 0), the real image (index 1), and a SECOND + placement of the same real image (index 2 — the same-page + duplicate); page 2 carries one more placement of the real image + (index 0 — the cross-page duplicate, the podman case). PyMuPDF + stores the repeated insertions under one xref (the + repeated-placement path — the digest set is the backstop for + byte-identical copies under distinct xrefs).""" + doc = fitz.open() + page1 = doc.new_page() + page1.insert_text((72, 72), PAGE_ONE) + y = 150 + for data in (BLANK_PNG, REAL_PNG, REAL_PNG): + pix = fitz.Pixmap(data) + page1.insert_image( + fitz.Rect(72, y, 72 + pix.width, y + pix.height), stream=data + ) + y += pix.height + 10 + page2 = doc.new_page() + page2.insert_text((72, 72), PAGE_TWO) + pix = fitz.Pixmap(REAL_PNG) + page2.insert_image( + fitz.Rect(72, 150, 72 + pix.width, 150 + pix.height), stream=REAL_PNG + ) + doc.set_metadata({"title": PDF_TITLE}) + doc.save(path) + doc.close() + + +# --------------------------------------------------------------------------- +# Module apps: the story (knob ON — the default) and the knob-off +# negative control. Each owns its port + its scratch upload/source/ +# image dirs. Both pin BOR_IMPORT_EXTENSIONS WITH the ``pdf`` token +# and BOR_IMAGES=true explicitly; the knob under test is pinned per +# app (a process-wide lru_cached setting — the two apps are the only +# way to prove it live in the product). +# --------------------------------------------------------------------------- + + +def _app_env( + mock_llm: int, app_port: int, *, skip_blank: bool, scratch: Path +) -> dict[str, str]: + """The conftest app env (leak guards included) with the phase-151 + knobs: ``BOR_IMPORT_EXTENSIONS`` pinned to a set carrying the + ``pdf`` token, ``BOR_IMAGES`` pinned true (the embedded-image pass + must run on BOTH apps), ``BOR_PDF_IMAGE_SKIP_BLANK`` pinned per + app (true for the story app, false for the negative control — + the contract under test cannot leak in either direction), and the + image/upload homes in the suite's scratch dir (the app under test + must not write copies into the owner's real ``~/bor-sources``). + ``BOR_PDF_PICTORIAL`` rides at the conftest pin (``0``) through + ``dict(os.environ)`` — the fixture's sparse pages stay pre-phase- + 140 content, byte-stable across the re-sync.""" + 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 suite drives no + # chat turn, but the app boots with the same values every other + # module app gets (the startup validator's premise). + 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 151: the import scope WITH the ``pdf`` token, the images + # toggle ON (the embedded-image pass runs on both apps), the knob + # under test pinned per app, and the suite-private homes. + env["BOR_IMPORT_EXTENSIONS"] = "md,markdown,txt,pdf" + env["BOR_IMAGES"] = "true" + env["BOR_PDF_IMAGE_SKIP_BLANK"] = "true" if skip_blank else "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 story app — ``BOR_PDF_IMAGE_SKIP_BLANK=true`` (the default + ON gate; the module env override — the conftest session app is + never started in this isolated run, so no port clash).""" + scratch = tmp_path_factory.mktemp("bor_pdf151_on") + port = int(os.environ.get("E2E_APP_PORT_PDF151", "8168")) + proc = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "app.main:app", + "--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"], + cwd=REPO, + env=_app_env(mock_llm, port, skip_blank=True, scratch=scratch), + ) + try: + _wait_http(f"http://127.0.0.1:{port}/api/health") + yield f"http://127.0.0.1:{port}" + 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 + + +@pytest.fixture(scope="module") +def off_app_server( + mock_llm: int, tmp_path_factory: pytest.TempPathFactory +) -> Iterator[str]: + """The negative-control app — ``BOR_PDF_IMAGE_SKIP_BLANK=false`` + (the kill switch OFF: every extracted image is indexed again — + the pre-phase-151 behavior; only the knob-off test starts it).""" + scratch = tmp_path_factory.mktemp("bor_pdf151_off") + port = int(os.environ.get("E2E_APP_PORT_PDF151_OFF", "8169")) + proc = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "app.main:app", + "--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"], + cwd=REPO, + env=_app_env(mock_llm, port, skip_blank=False, scratch=scratch), + ) + try: + _wait_http(f"http://127.0.0.1:{port}/api/health") + yield f"http://127.0.0.1:{port}" + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +@pytest.fixture(scope="module") +def off_app_url(off_app_server: str) -> str: + return off_app_server + + +# --------------------------------------------------------------------------- +# The fixture archives: ONE PDF, two archive stems (two source names — +# a leftover row from either seed is then visibly foreign in the other +# app's catalog) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def fixture_pdf(tmp_path_factory: pytest.TempPathFactory) -> Path: + """The fixture PDF (two text pages; the underlay + the real image + twice on page 1, the real image once more on page 2 — built with + PyMuPDF, the house convention).""" + root = tmp_path_factory.mktemp("bor_pdf151_pdf") + pdf_path = root / DOC_PATH + _build_fixture_pdf(pdf_path) + return pdf_path + + +def _zip(fixture_pdf: Path, tmp_path_factory: pytest.TempPathFactory, stem: str) -> Path: + root = tmp_path_factory.mktemp(f"bor_pdf151_zip_{stem}") + archive = root / f"{stem}.zip" + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf: + zf.write(fixture_pdf, arcname=DOC_PATH) + return archive + + +@pytest.fixture(scope="module") +def zip_on(fixture_pdf: Path, tmp_path_factory: pytest.TempPathFactory) -> Path: + """Source ``e2e-pdf151`` — the story app's archive (ONE PDF at the + source root, document path ``guide.pdf``).""" + return _zip(fixture_pdf, tmp_path_factory, SOURCE_ON) + + +@pytest.fixture(scope="module") +def zip_off(fixture_pdf: Path, tmp_path_factory: pytest.TempPathFactory) -> Path: + """Source ``e2e-pdf151-off`` — the SAME PDF for the knob-off app.""" + return _zip(fixture_pdf, tmp_path_factory, SOURCE_OFF) + + +# --------------------------------------------------------------------------- +# DB + UI + mock-capture helpers +# --------------------------------------------------------------------------- + + +def _truncate_all() -> None: + """Fresh KB + registry per test group (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, kb_overview, " + "git_sources, sources_meta, folder_summaries" + )) + db.commit() + + +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 _upload_zip(page: Page, app_url: str, archive: Path, source_name: str) -> None: + """The real admin upload flow: form login → the git-sources page → + pick the archive → submit → the terminal result line (phase 90: + "Uploaded — 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 — the same user + path the owner used): click → "Syncing…" → the terminal + "Synced HH:MM" label + the counts line settled in + ``#sync-progress-text`` (the phase-150 single sync text surface — + the wait barrier, no sleeps).""" + 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-progress-text")).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 are 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 _reset_capture(mock_port: int) -> None: + """Clear the mock's request capture (the phase-123 pair) so the + next sync's window holds ONLY that sync's LLM traffic.""" + httpx.post(f"http://127.0.0.1:{mock_port}/v1/e2e/captured/reset", timeout=5) + + +def _image_description_calls(mock_port: int) -> int: + """The vision describe calls in the mock's current capture — the + ``IMAGE_DESCRIPTION_MODE`` marker is the app's fixed + ``DESCRIBE_PROMPT`` head (``app.rag.summarizer``), a marker a real + question is never expected to type, so it identifies the image + describe calls exactly (no other sync traffic carries it).""" + bodies = httpx.get(f"http://127.0.0.1:{mock_port}/v1/e2e/captured", timeout=5).json() + return sum(1 for body in bodies if "IMAGE_DESCRIPTION_MODE" in json.dumps(body)) + + +def _stored_image_is_blank(image_path: str) -> bool: + """The APP'S OWN blank predicate (``app.rag.pdf.is_blank_image`` — + imported, not re-derived, so the test cannot drift from the + product) over the stored copy's samples: the exact gate input the + extractor measures — alpha dropped first when present, so the mean + runs over COLOR samples only.""" + pm = fitz.Pixmap(Path(image_path).read_bytes()) + if pm.alpha: + pm = fitz.Pixmap(pm, 0) + return is_blank_image(pm.samples) + + +# --------------------------------------------------------------------------- +# The module seeds: upload + sync through the real UI, once per app +# (the story E2E truncate/re-import-per-module pattern) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def seeded( + app_url: str, + browser: Browser, + zip_on: Path, + mock_llm: int, +) -> Iterator[dict[str, object]]: + """Upload the fixture zip through the admin UI on the story app + (knob ON), then scan it with the RAG page's Sync button (the + mock's vision model describes the embedded image in-process). + Yields the kept image doc's identity + the first sync's vision + call count. The module app's ``/api/config`` flag is asserted + first — the env override must have reached the app under test, or + the whole premise of the suite is void.""" + if not _health_db(app_url): + pytest.skip("Postgres not reachable — run `podman compose up -d db` first") + cfg = httpx.get(f"{app_url}/api/config", timeout=5).json() + assert cfg["images"] is True, "the story app must run with BOR_IMAGES=true" + + _truncate_all() + page = browser.new_page(viewport={"width": 1280, "height": 800}) + try: + _upload_zip(page, app_url, zip_on, SOURCE_ON) + # The capture window: ONLY the first sync's LLM traffic. + _reset_capture(mock_llm) + # The PDF + its ONE embedded image (the underlay and both + # duplicate placements are dropped by the parser — the gate + # and the dedupe are pre-importer): both count added. + _sync_via_ui(page, app_url, expected_result="2 added") + first_sync_vision = _image_description_calls(mock_llm) + _reset_capture(mock_llm) # a clean window for the re-sync test + finally: + page.close() + + with SessionLocal() as db: + image_docs = db.scalars( + select(Document).where( + Document.source == SOURCE_ON, Document.is_image.is_(True) + ) + ).all() + # The seed's sanity pin (the rest — first placement, the + # no-blank bytes, the vision count — is asserted in the tests + # below against this state). + assert [doc.path for doc in image_docs] == [REAL_REL] + img = image_docs[0] + assert img.is_image is True and img.image_path is not None + pdf = db.scalar( + select(Document).where( + Document.source == SOURCE_ON, Document.path == DOC_PATH + ) + ) + assert pdf is not None and pdf.is_image is False + assert pdf.title == PDF_TITLE + assert "---PAGE-1---" in pdf.content and PAGE_ONE in pdf.content + yield { + "image_url": f"/api/documents/{img.id}/image", + "image_doc_id": img.id, + "image_doc_hash": img.content_hash, + "first_sync_vision_calls": first_sync_vision, + } + + +@pytest.fixture(scope="module") +def seeded_off( + off_app_url: str, + browser: Browser, + zip_off: Path, + mock_llm: int, +) -> Iterator[dict[str, object]]: + """The SAME fixture PDF through the knob-off app (the negative + control): truncate + re-seed (the shared-Postgres pattern — this + runs LAST, after every story test has consumed the story seed), + upload + sync via the same user path. Yields the sync's vision + call count (2 — the blank underlay IS described now, the + pre-phase cost).""" + if not _health_db(off_app_url): + pytest.skip("Postgres not reachable — run `podman compose up -d db` first") + cfg = httpx.get(f"{off_app_url}/api/config", timeout=5).json() + assert cfg["images"] is True, "the knob-off app must run with BOR_IMAGES=true" + + _truncate_all() + page = browser.new_page(viewport={"width": 1280, "height": 800}) + try: + _upload_zip(page, off_app_url, zip_off, SOURCE_OFF) + _reset_capture(mock_llm) + # The PDF + BOTH embedded images (the blank gate is OFF — the + # underlay is indexed again; the duplicate placements are + # STILL dropped — dedupe is a correctness fix, not knobed): + # three count added. + _sync_via_ui(page, off_app_url, expected_result="3 added") + sync_vision = _image_description_calls(mock_llm) + finally: + page.close() + + with SessionLocal() as db: + image_docs = db.scalars( + select(Document).where( + Document.source == SOURCE_OFF, Document.is_image.is_(True) + ) + ).all() + assert sorted(doc.path for doc in image_docs) == sorted([BLANK_REL, REAL_REL]) + yield {"vision_calls": sync_vision} + + +# --------------------------------------------------------------------------- +# 1. Sources page (story app): exactly one image doc, no blank row +# --------------------------------------------------------------------------- + + +def test_sources_page_lists_exactly_one_image_doc( + page: Page, app_url: str, seeded: dict[str, object] +) -> None: + """The owner's complaint, the browser lens: the KB total is 2 docs + / 5 chunks (the PDF: 2 page-anchored content chunks + the summary + chunk; the one image: its description chunk + summary chunk — the + underlay contributes NOTHING, no row, no chunk) and the + drill-down table lists the PDF (a plain text row, no thumbnail) + + the ONE image document (the thumbnail served from the persistent + copy) — and NO row for the underlay's synthetic path + (``#page=1#img=0``).""" + login(page, app_url) # lands on /sources.html + expect(page.locator("#stat-docs")).to_have_text("2") + expect(page.locator("#stat-chunks")).to_have_text("5") + expect(page.locator("#sources-empty")).to_be_hidden() + + _drill_to_source(page, SOURCE_ON) + expect(page.locator("#docs-tbody tr")).to_have_count(2) + + # The PDF — a plain text row (no thumbnail box at all). Pinned by + # the EXACT doc-link text (the image row's path carries + # ``guide.pdf`` as a prefix — a substring match would hit both). + pdf_row = page.locator( + "#docs-tbody tr", + has=page.locator(f'a.doc-link:text-is("{DOC_PATH}")'), + ) + expect(pdf_row).to_have_count(1) + expect(pdf_row.locator(".kb-doc-thumb")).to_have_count(0) + + # The ONE image document — the real image's thumbnail row (the + # bytes route's lazy , alt = the description; visible = the + # served copy resolved). + img_row = page.locator( + "#docs-tbody tr", + has=page.locator(f'a.doc-link:text-is("{REAL_REL}")'), + ) + expect(img_row).to_have_count(1) + img = img_row.locator(".kb-doc-thumb-img") + expect(img).to_have_count(1) + expect(img).to_be_visible(timeout=15_000) # the fetch resolves — no glyph + expect(img).to_have_attribute("src", str(seeded["image_url"])) + + # The blank underlay's row — the TODO L4 artifact — is ABSENT. + blank_rows = page.locator( + "#docs-tbody tr", + has=page.locator(f'a.doc-link:text-is("{BLANK_REL}")'), + ) + expect(blank_rows).to_have_count(0) + + +# --------------------------------------------------------------------------- +# 2. The kept doc is the FIRST placement's; exactly ONE vision call +# --------------------------------------------------------------------------- + + +def test_kept_doc_is_the_first_placement_with_one_vision_call( + app_url: str, seeded: dict[str, object] +) -> None: + """The dedupe's stability contract + the cost pin: the kept image + doc's synthetic path is the FIRST placement's + (``…#page=1#img=1.png`` — the real image's first insertion; the + underlay consumed index 0 and was skipped), the duplicate + placements' paths (``#page=1#img=2`` and ``#page=2#img=0``) exist + for NO document, and the first sync's capture held EXACTLY ONE + vision describe call — the duplicate placements and the blank + underlay never reached the model (the cost the owner paid in + "several minutes" — pre-phase, every placement was described).""" + with SessionLocal() as db: + img = db.scalar( + select(Document).where( + Document.source == SOURCE_ON, Document.path == REAL_REL + ) + ) + assert img is not None, "the real image's first placement must be a document" + # Phase 137 regression pin (no behavior change): the document + # context (provenance origin line + page 1's text) reaches the + # vision model (the mock's context-aware reply is the stored + # content), summary === content (the image convention). + assert img.title == "Embedded image from guide page 1" + assert img.image_context == DOC_BLOCK + assert img.content == f"{DOC_BLOCK}\n\n{IMAGE_DESCRIPTION_CONTEXT_ANSWER}" + assert img.summary == img.content + for dup_rel in DUP_RELS: + assert ( + db.scalar( + select(Document.id).where( + Document.source == SOURCE_ON, Document.path == dup_rel + ) + ) + is None + ), f"the duplicate placement {dup_rel} must not be a document" + # ONE vision call in the first sync's window (the PDF's text + # summary and the KB overview are different markers — the + # IMAGE_DESCRIPTION_MODE count is the describe calls exactly). + assert seeded["first_sync_vision_calls"] == 1 + + +# --------------------------------------------------------------------------- +# 3. No image document's stored bytes are a blank white image +# --------------------------------------------------------------------------- + + +def test_no_stored_image_doc_is_blank(app_url: str, seeded: dict[str, object]) -> None: + """The bytes lens on "no blank white documents": every image + document's PERSISTENT copy (the row's ``image_path`` — the same + bytes the bytes route serves) is checked with the app's own + ``is_blank_image`` predicate (imported — the test cannot drift + from the product): NOT blank. The single doc's bytes are the + colorful real image — the underlay's near-white bytes never made + it into the KB (no row, no copy).""" + with SessionLocal() as db: + image_docs = db.scalars( + select(Document).where( + Document.source == SOURCE_ON, Document.is_image.is_(True) + ) + ).all() + assert len(image_docs) == 1 # the catalog lens (test 1) says the same + for doc in image_docs: + assert doc.image_path is not None and doc.image_path != "" + assert _stored_image_is_blank(doc.image_path) is False, ( + f"image doc {doc.path}'s stored copy is blank by the app's own " + "predicate — the TODO L4 artifact is back" + ) + + +# --------------------------------------------------------------------------- +# 4. Re-sync (nothing changed): `unchanged`, creates nothing new +# --------------------------------------------------------------------------- + + +def test_resync_is_idempotent( + page: Page, app_url: str, seeded: dict[str, object], mock_llm: int +) -> None: + """Idempotence after the fix: a second Sync (the PDF is + byte-identical — the fast path, no image pass, no vision) settles + on ``0 added · 1 unchanged`` (the PDF doc only — the image doc is + a synthetic path the walk never sees), burns ZERO vision calls in + its window, and the KB is exactly what it was before — the same + two documents, the same image doc row (id + content hash + path + unchanged), the same counts.""" + login(page, app_url) # the test's fresh page is a fresh context (no session) + _reset_capture(mock_llm) + _sync_via_ui(page, app_url, expected_result="0 added · 1 unchanged") + # The re-sync's window: no describe call (the image pass never ran + # — the PDF's digest matched), no vision at all. + assert _image_description_calls(mock_llm) == 0 + + with SessionLocal() as db: + docs = db.scalars(select(Document).where(Document.source == SOURCE_ON)).all() + assert len(docs) == 2 # nothing new, nothing pruned + img = db.scalar( + select(Document).where( + Document.source == SOURCE_ON, Document.path == REAL_REL + ) + ) + assert img is not None + assert img.id == seeded["image_doc_id"] + assert img.content_hash == seeded["image_doc_hash"] + # The counts surface is unchanged. + expect(page.locator("#stat-docs")).to_have_text("2") + expect(page.locator("#stat-chunks")).to_have_text("5") + + +# --------------------------------------------------------------------------- +# 5. Negative control (knob OFF): the blank underlay is indexed again +# --------------------------------------------------------------------------- + + +def test_knob_off_indexes_the_blank_underlay( + page: Page, off_app_url: str, seeded_off: dict[str, object] +) -> None: + """The kill switch, proven live in the product (not just in unit + tests): the SAME PDF through the SAME user path on + ``BOR_PDF_IMAGE_SKIP_BLANK=false`` yields the blank underlay + document TOO — 3 docs / 7 chunks, the underlay's row present in + the drill-down table (its near-white thumbnail served), TWO vision + calls (the pre-phase cost — the blank IS described now), and the + underlay's stored bytes blank per the app's own predicate — the + exact class the story app's gate dropped. The knob gates ONLY the + blank gate: the duplicate placements still produce no documents + (dedupe is a correctness fix), so the catalog is the real image + + the underlay — not the four placements.""" + try: + login(page, off_app_url) # lands on /sources.html + expect(page.locator("#stat-docs")).to_have_text("3") + expect(page.locator("#stat-chunks")).to_have_text("7") + + _drill_to_source(page, SOURCE_OFF) + expect(page.locator("#docs-tbody tr")).to_have_count(3) + + # The underlay's row — absent on the story app (test 1) — is + # BACK: the knob off restored its indexing (the pre-phase-151 + # behavior the owner's bug report describes). + blank_row = page.locator( + "#docs-tbody tr", + has=page.locator(f'a.doc-link:text-is("{BLANK_REL}")'), + ) + expect(blank_row).to_have_count(1) + blank_img = blank_row.locator(".kb-doc-thumb-img") + expect(blank_img).to_have_count(1) + expect(blank_img).to_be_visible(timeout=15_000) # the near-white copy serves + # The duplicate placements: still no documents (not knobed). + for dup_rel in DUP_RELS: + expect( + page.locator( + "#docs-tbody tr", + has=page.locator(f'a.doc-link:text-is("{dup_rel}")'), + ) + ).to_have_count(0) + + # The bytes lens: the underlay's stored copy is blank by the + # app's own predicate — the exact class the story app skipped + # (test 3 proved its absence there); the real image's copy is + # not blank (unchanged by the knob). + with SessionLocal() as db: + blank = db.scalar( + select(Document).where( + Document.source == SOURCE_OFF, Document.path == BLANK_REL + ) + ) + real = db.scalar( + select(Document).where( + Document.source == SOURCE_OFF, Document.path == REAL_REL + ) + ) + assert blank is not None and blank.image_path is not None + assert _stored_image_is_blank(blank.image_path) is True + assert real is not None and real.image_path is not None + assert _stored_image_is_blank(real.image_path) is False + # The pre-phase cost: BOTH images described (the blank too). + assert seeded_off["vision_calls"] == 2 + finally: + _truncate_all() diff --git a/tests/e2e/test_pdf_support.py b/tests/e2e/test_pdf_support.py index b979107..a104948 100644 --- a/tests/e2e/test_pdf_support.py +++ b/tests/e2e/test_pdf_support.py @@ -38,7 +38,6 @@ either direction, and without the token the walk is blind to the file): """ from __future__ import annotations -import base64 import json import os import re @@ -87,13 +86,26 @@ QUESTION = ( "what does the homelab report say about the backup schedule?" ) -#: A real 1×1 transparent PNG (the unit/integration suites' fixture — -#: the pipeline is content-agnostic; the well-formed bytes keep the -#: extraction + copy + serve + render path honest). -PNG_1X1 = base64.b64decode( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ" - "AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=" -) +def _embedded_png() -> bytes: + """A small deterministic colorful noise PNG (channel means ~97 — + the "real image" stand-in that CLEARS the phase-151 blank/ + decorative gate's near-white floor with room to spare). The old + 1×1 transparent fixture decomposed in PyMuPDF to a near-white + 1-sample base — blank by the owner-locked gate — and is now + (correctly) skipped as a decorative underlay, so the suite's + pipeline pins ride on a real non-blank image instead (the + pipeline is content-agnostic; the well-formed bytes keep the + extraction + copy + serve + render path honest).""" + pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, 24, 18)) + for y in range(18): + for x in range(24): + pix.set_pixel(x, y, ((x * 7) % 256, (y * 13) % 256, ((x + y) * 5) % 256)) + return pix.tobytes("png") + + +#: The embedded fixture image (24×18 colorful noise — non-blank by the +#: phase-151 gate; see :func:`_embedded_png`). +EMBED_PNG = _embedded_png() UPLOAD_TIMEOUT_MS = 30_000 SYNC_TIMEOUT_MS = 60_000 @@ -231,7 +243,7 @@ def zip_path(tmp_path_factory: pytest.TempPathFactory) -> Path: doc.new_page().insert_text((72, 72), PAGE_ONE) page2 = doc.new_page() page2.insert_text((72, 72), PAGE_TWO) - page2.insert_image(fitz.Rect(72, 150, 73, 151), stream=PNG_1X1) + page2.insert_image(fitz.Rect(72, 150, 96, 168), stream=EMBED_PNG) doc.set_metadata({"title": PDF_TITLE}) doc.save(pdf_path) doc.close() diff --git a/tests/unit/test_importer.py b/tests/unit/test_importer.py index e2c2960..a9444ce 100644 --- a/tests/unit/test_importer.py +++ b/tests/unit/test_importer.py @@ -990,9 +990,12 @@ def test_import_summary_log_line_includes_summary_counters( phase-106 date-refresh counter, the phase-122 ``images_failed`` counter, the phase-137 ``image_context_backfilled`` counter (next 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``.""" + counter, the phase-140 ``pdfs_pictorial`` / + ``pdf_pictorial_pages`` counters (next to ``pdfs_screenshotted``), + and the phase-151 ``pdf_images_deduped`` / ``pdf_images_blank_skipped`` + counters (the parser's per-PDF drop counts, next to + ``pdf_pictorial_pages``) 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 @@ -1003,6 +1006,8 @@ def test_import_summary_log_line_includes_summary_counters( s.pdfs_screenshotted = 1 s.pdfs_pictorial = 2 s.pdf_pictorial_pages = 5 + s.pdf_images_deduped = 3 + s.pdf_images_blank_skipped = 1 s.formats = {"md": 1, "yaml": 2} with caplog.at_level(logging.INFO, logger="app.importer"): s.log() @@ -1012,6 +1017,7 @@ def test_import_summary_log_line_includes_summary_counters( "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 pdfs_pictorial=2 pdf_pictorial_pages=5 " + "pdf_images_deduped=3 pdf_images_blank_skipped=1 " "formats=yaml:2,md:1" ) diff --git a/tests/unit/test_pdf.py b/tests/unit/test_pdf.py index 825e48e..0cbb851 100644 --- a/tests/unit/test_pdf.py +++ b/tests/unit/test_pdf.py @@ -3,11 +3,17 @@ Covers ``app/rag/pdf.py``: text extraction with ``---PAGE-N---`` separators (A2), the per-page text map (phase 137 task 01 — ``extract_pdf_page_texts``, the document-context raw material), -embedded image extraction (original encoded bytes), -MIME mapping (extension first, then image type code, else the +embedded image extraction (one per DISTINCT image — phase 151 task +01's dedupe, first placement wins; a masked image is composited to +the visible alpha PNG — task 02; the raw-format gate runs first, +owner-locked A4 — and the real-file regression pins), MIME mapping +(extension first, then image type code, else the ``application/octet-stream`` fallback), and the fail-soft contract — unreadable files and broken pages/images return empty results, never -raise. Temp PDFs are built with PyMuPDF itself (no binary fixtures). +raise. The blank/decorative gate (phase 151 task 03), its kill +switch (task 04), and the importer's image-pass integration live in +tests/unit/test_pdf_image_filter.py. Temp PDFs are built with +PyMuPDF itself (no binary fixtures). """ from __future__ import annotations @@ -15,7 +21,9 @@ import asyncio import hashlib import logging import re +import struct import tarfile +import zlib from collections.abc import Callable, Sequence from datetime import timedelta from pathlib import Path @@ -43,6 +51,7 @@ from app.rag.pdf import ( PAGE_SEPARATOR, XMLISH_LINE_FRACTION, PdfImage, + PdfImageStats, _mime_from_image_info, _page_image_entries, extract_pdf_images, @@ -602,12 +611,20 @@ def test_pdf_embedded_image_unsupported_mime_is_skipped( root = tmp_path / "pdfmime" root.mkdir() _build_pdf(root / "report.pdf", [("Text page.", [])]) + # Phase 151 (task 03): the importer now calls the stats-carrying + # variant — patch it (the tuple shape: images + PdfImageStats). + # Phase 151 (task 04): the knob rides as the `skip_blank` + # keyword — the stand-in accepts it (its value is irrelevant here: + # the tiff mime is rejected by the extension gate either way). monkeypatch.setattr( importer, - "extract_pdf_images", - lambda _path: [ - PdfImage(page=1, index=0, width=5, height=5, data=b"fakedata", mime="image/tiff") - ], + "extract_pdf_images_with_stats", + lambda _path, **_kwargs: ( + [ + PdfImage(page=1, index=0, width=5, height=5, data=b"fakedata", mime="image/tiff") + ], + PdfImageStats(duplicates=0, blank_skipped=0, total_found=1), + ), ) llm = _pdf_llm(tmp_path) try: @@ -1176,6 +1193,39 @@ def blank_middle_page_pdf(tmp_path: Path) -> Path: return path +@pytest.fixture() +def same_image_three_pages_pdf(tmp_path: Path) -> Path: + """3 pages, EACH carrying the SAME image (phase 151 task 01 — the + dedupe fixture: one distinct image, three placements).""" + path = tmp_path / "triple.pdf" + data = _png_bytes(40, 30) + doc = fitz.open() + for n in (1, 2, 3): + page = doc.new_page() + page.insert_text((72, 72), f"Text on page {n}.") + page.insert_image(fitz.Rect(72, 150, 112, 180), stream=data) + doc.save(path) + doc.close() + return path + + +@pytest.fixture() +def two_distinct_images_pdf(tmp_path: Path) -> Path: + """Two pages, each with its OWN image (different size AND pixels — + genuinely distinct: the dedupe must NOT merge them).""" + path = tmp_path / "two.pdf" + doc = fitz.open() + p1 = doc.new_page() + p1.insert_image(fitz.Rect(72, 150, 112, 180), stream=_png_bytes(40, 30)) + p2 = doc.new_page() + pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, 15, 15)) + pix.clear_with(50) # different pixels (the shared helper uses 200) + p2.insert_image(fitz.Rect(72, 150, 87, 165), stream=pix.tobytes("png")) + doc.save(path) + doc.close() + return path + + # --------------------------------------------------------------------------- # extract_pdf_text # --------------------------------------------------------------------------- @@ -1255,22 +1305,26 @@ def test_pdf_without_images_returns_empty_list(single_page_pdf: Path) -> None: def test_embedded_images_extracted_in_order(multi_page_pdf: Path) -> None: images = extract_pdf_images(multi_page_pdf) + # Phase 151 (task 01): page 3's SECOND placement of the same JPEG + # (index 1 — the same embedded xref) is deduped: one PdfImage per + # DISTINCT image, first occurrence wins (page/index stay the first + # placement's). assert [(img.page, img.index) for img in images] == [ - (2, 0), (3, 0), (3, 1), (3, 2), + (2, 0), (3, 0), (3, 2), ] # page 2: 40x30 PNG img = images[0] assert (img.width, img.height) == (40, 30) assert img.mime == "image/png" assert img.data.startswith(PNG_MAGIC) - # page 3: 40x30 JPEG, then the same JPEG again (distinct index) - for img in images[1:3]: - assert (img.width, img.height) == (40, 30) - assert img.mime == "image/jpeg" - assert img.data.startswith(JPEG_MAGIC) - assert images[1].data == images[2].data # same embedded xref - # page 3: 15x15 PNG - img = images[3] + # page 3: the 40x30 JPEG at its FIRST placement (index 0 — index 1 + # is the deduped repeat of the same embedded xref) + img = images[1] + assert (img.width, img.height) == (40, 30) + assert img.mime == "image/jpeg" + assert img.data.startswith(JPEG_MAGIC) + # page 3: 15x15 PNG at index 2 + img = images[2] assert (img.width, img.height) == (15, 15) assert img.mime == "image/png" assert img.data.startswith(PNG_MAGIC) @@ -1407,6 +1461,448 @@ def test_extracted_ext_wins_over_info_ext( assert images[0].mime == "image/jpeg" # ext beats the PNG type code +# --------------------------------------------------------------------------- +# phase 151, task 01 — one PdfImage per DISTINCT image (the dedupe) +# --------------------------------------------------------------------------- + + +def test_same_image_three_placements_yields_one_first_wins( + same_image_three_pages_pdf: Path, +) -> None: + """The task's core pin: the SAME image on 3 pages → ONE PdfImage, + carrying the FIRST occurrence's page/index (page 1, index 0) — the + synthetic path stays stable for the first placement.""" + images = extract_pdf_images(same_image_three_pages_pdf) + assert len(images) == 1 + assert (images[0].page, images[0].index) == (1, 0) + assert (images[0].width, images[0].height) == (40, 30) + assert images[0].mime == "image/png" + assert images[0].data.startswith(PNG_MAGIC) + + +def test_two_distinct_images_are_both_kept( + two_distinct_images_pdf: Path, +) -> None: + """The no-duplicate path: genuinely distinct images (different size + AND pixels) are NOT merged — two entries in page order.""" + images = extract_pdf_images(two_distinct_images_pdf) + assert [(img.page, img.index) for img in images] == [(1, 0), (2, 0)] + assert images[0].data != images[1].data + assert (images[0].width, images[0].height) == (40, 30) + assert (images[1].width, images[1].height) == (15, 15) + + +def test_byte_identical_images_under_distinct_xrefs_dedupe_to_one( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """The DIGEST path: two DIFFERENT xrefs holding byte-identical + bytes → one entry (the first), one drop — the xref check alone + cannot catch this, the sha256 of the stored bytes can.""" + path = tmp_path / "fake.pdf" + path.write_bytes(b"%PDF-1.4 fake") + + def _extract(xref: int) -> dict[str, Any]: + return {"image": b"identical-bytes", "ext": "png", "width": 5, "height": 5} + + doc = _FakeDoc( + [ + _fake_image_page( + [ + {"xref": 7, "width": 5, "height": 5}, + {"xref": 9, "width": 5, "height": 5}, + ] + ) + ], + _extract, + ) + monkeypatch.setattr(pdf_mod.fitz, "open", lambda _p: doc) + images = extract_pdf_images(path) + assert len(images) == 1 + assert (images[0].page, images[0].index) == (1, 0) + + +def test_different_bytes_under_distinct_xrefs_are_both_kept( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """The digest path's negative control: distinct xrefs with + DIFFERENT bytes are distinct images — both kept.""" + path = tmp_path / "fake.pdf" + path.write_bytes(b"%PDF-1.4 fake") + + def _extract(xref: int) -> dict[str, Any]: + return { + "image": b"first-bytes" if xref == 7 else b"second-bytes", + "ext": "png", "width": 5, "height": 5, + } + + doc = _FakeDoc( + [ + _fake_image_page( + [ + {"xref": 7, "width": 5, "height": 5}, + {"xref": 9, "width": 5, "height": 5}, + ] + ) + ], + _extract, + ) + monkeypatch.setattr(pdf_mod.fitz, "open", lambda _p: doc) + images = extract_pdf_images(path) + assert [(img.page, img.index) for img in images] == [(1, 0), (1, 1)] + + +def test_duplicate_drop_logs_one_info_line_per_pdf( + same_image_three_pages_pdf: Path, caplog: pytest.LogCaptureFixture +) -> None: + """One INFO line per PDF that dropped anything (the task's exact + format), one DEBUG line per drop; 3 placements → 2 dropped, + 1 unique.""" + with caplog.at_level(logging.DEBUG, logger="app.rag.pdf"): + assert len(extract_pdf_images(same_image_three_pages_pdf)) == 1 + infos = [ + r + for r in caplog.records + if r.levelno == logging.INFO + and "duplicate image occurrence(s)" in r.getMessage() + ] + assert len(infos) == 1 # ONE line per PDF, not one per drop + assert infos[0].getMessage() == ( + f"pdf: dropped 2 duplicate image occurrence(s) " + f"from {same_image_three_pages_pdf} (1 unique)" + ) + debugs = [ + r + for r in caplog.records + if r.levelno == logging.DEBUG and "duplicate image" in r.getMessage() + ] + assert len(debugs) == 2 # one per dropped occurrence + assert all(r.getMessage().endswith("— skipped") for r in debugs) + + +def test_no_duplicates_no_info_line( + two_distinct_images_pdf: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A PDF with no duplicates never logs the INFO line — the line is + the duplicate signal, its absence means nothing was dropped.""" + with caplog.at_level(logging.INFO, logger="app.rag.pdf"): + assert len(extract_pdf_images(two_distinct_images_pdf)) == 2 + assert not [ + r + for r in caplog.records + if "duplicate image occurrence(s)" in r.getMessage() + ] + + +#: The owner's real 50-page fixture (untracked — ``test_sources/`` is +#: gitignored): the podman + systemd guide. THE phase-151 reproducer — +#: its table-shading underlays are the "blank white images" the TODO +#: reported. +PODMAN_PDF = Path(__file__).resolve().parents[2] / "test_sources" / "podman-systemd.pdf" + + +@pytest.mark.skipif(not PODMAN_PDF.is_file(), reason="podman fixture missing") +def test_podman_pdf_yields_one_entry_per_distinct_image() -> None: + """THE real-file pin: 48 placements (one per ``get_image_info`` + entry — the phase docs measured 46) spanning 15 unique xrefs — but + 6 of those xrefs are BYTE-IDENTICAL copies (same base AND same + SMask bytes stored under a different xref), so there are only 9 + truly DISTINCT images. Task 01's digest set is spec'd for exactly + the "byte-identical images stored under different xrefs" case, so + it merges the 6 copies: 48 placements → 9 entries (the task + file's "15" figure is the unique-XREF count — unreachable while + keeping the digest set AND the phase's "one document per genuinely + distinct image" objective, since those 6 xrefs are genuine + duplicates a viewer cannot tell apart). First-occurrence order is + pinned too: every kept entry carries its FIRST placement's + page/index. + + Phase 151 (task 03): the dedupe pin runs with the blank gate OFF + (``skip_blank=False``) — the gate (default on) drops all 9 distinct + images (they are the decorative underlays the phase exists to + filter), so the default now returns ``[]`` for this file (pinned + in tests/unit/test_pdf_image_filter.py).""" + images = extract_pdf_images(PODMAN_PDF, skip_blank=False) + assert len(images) == 9 + assert [(img.page, img.index) for img in images] == [ + (7, 0), (22, 0), (26, 0), (30, 0), (33, 0), + (36, 0), (41, 0), (45, 0), (47, 0), + ] + # no two surviving entries share bytes (the dedupe's contract, on + # the real file) + digests = {hashlib.sha256(img.data).hexdigest() for img in images} + assert len(digests) == 9 + + +# --------------------------------------------------------------------------- +# phase 151, task 02 — the /SMask is applied (the visible image, not +# the blank base stream extract_image() returns) +# --------------------------------------------------------------------------- + + +def _png_chunk(tag: bytes, payload: bytes) -> bytes: + """One PNG chunk (4-byte length + tag + payload + CRC32).""" + return ( + struct.pack(">I", len(payload)) + + tag + + payload + + struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF) + ) + + +def _make_png( + width: int, height: int, rgba: bool, pixel: Callable[[int, int], tuple[int, ...]] +) -> bytes: + """A deterministic PNG built with stdlib only (``zlib`` + + ``struct``). PyMuPDF's Pixmap exposes no sample mutation in this + release, so the fixture is hand-rolled: a genuine RGBA + (color-type-6) PNG is exactly what makes ``insert_image`` store a + ``/SMask`` (base image + separate mask xref — the shape this task + composites).""" + color_type = 6 if rgba else 2 + n = 4 if rgba else 3 + raw = bytearray() + for y in range(height): + raw.append(0) # filter type: none + for x in range(width): + raw += bytes(pixel(x, y)[:n]) + ihdr = struct.pack(">IIBBBBB", width, height, 8, color_type, 0, 0, 0) + return ( + b"\x89PNG\r\n\x1a\n" + + _png_chunk(b"IHDR", ihdr) + + _png_chunk(b"IDAT", zlib.compress(bytes(raw))) + + _png_chunk(b"IEND", b"") + ) + + +def _insert_image_pdf(path: Path, png: bytes) -> Path: + """A one-page PDF carrying *png* (40x30, top-left at 72,150).""" + doc = fitz.open() + doc.new_page().insert_image(fitz.Rect(72, 150, 112, 180), stream=png) + doc.save(path) + doc.close() + return path + + +def _raw_extract(path: Path) -> dict[str, Any]: + """The page's first image as ``extract_image`` reports it — the + raw reference (what the PRE-phase extractor returned verbatim).""" + doc = fitz.open(str(path)) + with doc: + info = doc[0].get_image_info(xrefs=True)[0] + return doc.extract_image(int(info["xref"])) + + +def _masked_png() -> bytes: + """A 40x30 RGBA PNG with varying pixels and a non-uniform alpha + (top half opaque, bottom half fully transparent — the mask must + survive the round trip).""" + return _make_png( + 40, 30, True, lambda x, y: (x % 256, y % 256, (x + y) % 256, 255 if y < 15 else 0) + ) + + +def test_masked_image_is_composited_to_the_visible_alpha_png(tmp_path: Path) -> None: + """THE task's core pin: an RGBA PNG inserted via ``insert_image`` + (PyMuPDF stores it as a BASE image + a ``/SMask``) is extracted as + the composited VISIBLE image: (a) the raw ``extract_image`` bytes ≠ + the extractor's ``data``, (b) ``mime == "image/png"``, (c) + re-opening the extractor's bytes as a ``fitz.Pixmap`` reports + ``alpha == 1`` (the mask survived) at the base's dimensions — and + ``width``/``height`` come from ``extract_image`` (unchanged).""" + path = _insert_image_pdf(tmp_path / "masked.pdf", _masked_png()) + raw = _raw_extract(path) + # Precondition (the bug this task fixes): the /SMask is stored + # separately, and the raw extract is the maskless base. + assert int(raw.get("smask") or 0) != 0 + assert fitz.Pixmap(raw["image"]).alpha == 0 + + images = extract_pdf_images(path) + assert len(images) == 1 + img = images[0] + assert (img.page, img.index) == (1, 0) + assert img.data != raw["image"] # (a) the composite, not the raw base + assert img.mime == "image/png" # (b) + assert img.data.startswith(PNG_MAGIC) + vis = fitz.Pixmap(img.data) + assert vis.alpha == 1 # (c) the mask survived + assert (vis.width, vis.height) == (40, 30) + assert (img.width, img.height) == (40, 30) # from extract_image + + +def test_unmasked_image_is_byte_identical_to_pre_phase( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """The common case is unchanged: an image WITHOUT a mask is + returned exactly as the raw ``extract_image`` bytes (no composite, + no re-encode — pre-phase behavior) and no composite line is + logged.""" + png = _make_png(40, 30, False, lambda x, y: (x % 256, y % 256, (x + y) % 256)) + path = _insert_image_pdf(tmp_path / "plain.pdf", png) + raw = _raw_extract(path) + assert int(raw.get("smask") or 0) == 0 + + with caplog.at_level(logging.DEBUG, logger="app.rag.pdf"): + images = extract_pdf_images(path) + assert len(images) == 1 + assert images[0].data == raw["image"] # the raw bytes, verbatim + assert images[0].mime == "image/png" # the raw mime (ext = png) + assert not [ + r for r in caplog.records if "composited smask=" in r.getMessage() + ] + + +def test_mask_composite_logs_one_debug_line( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """One DEBUG line (the task's exact format) when a mask was + applied.""" + path = _insert_image_pdf(tmp_path / "masked.pdf", _masked_png()) + smask = int(_raw_extract(path).get("smask") or 0) + with caplog.at_level(logging.DEBUG, logger="app.rag.pdf"): + extract_pdf_images(path) + debugs = [ + r + for r in caplog.records + if r.levelno == logging.DEBUG and "composited smask=" in r.getMessage() + ] + assert len(debugs) == 1 + assert debugs[0].getMessage() == ( + f"pdf: composited smask={smask} for image page=1 index=0 of {path}" + ) + + +def test_composite_failure_falls_back_to_raw_bytes_with_warning( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Fail-soft: a compositing that RAISES (the monkeypatched + ``fitz.Pixmap``) returns the raw bytes + raw mime, logs the + warning (the task's exact format), and never loses the image. + No exception escapes the extractor. The ``_FakeDoc`` stand-in + (the task-01 convention) is used so the extractor's compositing + block is the ONLY ``fitz.Pixmap`` call in flight (PyMuPDF's real + constructor resolves the module-global ``Pixmap`` internally, so + patching it would break the real base/mask reads too).""" + path = tmp_path / "fake.pdf" + path.write_bytes(b"%PDF-1.4 fake") + raw_bytes = b"raw-base-bytes" + doc = _FakeDoc( + [_fake_image_page([{"xref": 7, "width": 5, "height": 5}])], + lambda xref: { + "image": raw_bytes, "ext": "png", "width": 5, "height": 5, "smask": 8, + }, + ) + monkeypatch.setattr(pdf_mod.fitz, "open", lambda _p: doc) + + attempts: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + + def _boom(*args: Any, **kwargs: Any) -> None: + attempts.append((args, kwargs)) + raise RuntimeError("simulated combine failure") + + monkeypatch.setattr(pdf_mod.fitz, "Pixmap", _boom) + with caplog.at_level(logging.WARNING, logger="app.rag.pdf"): + images = extract_pdf_images(path) + assert attempts, "the composite path was attempted (a Pixmap was read)" + assert len(images) == 1 # the image is NOT lost + assert images[0].data == raw_bytes # raw bytes, verbatim + assert images[0].mime == "image/png" # the raw mime + + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and "could not composite smask=" in r.getMessage() + ] + assert len(warnings) == 1 + assert warnings[0].getMessage() == ( + f"pdf: could not composite smask=8 for image page=1 index=0 " + f"of {path} — simulated combine failure; using raw bytes" + ) + + +def test_unsupported_raw_format_with_mask_stays_untouched( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Owner-locked A4 — the raw-format gate runs FIRST: an image + whose raw format the importer's ``_MIME_TO_EXT`` gate will reject + (``jpx``/JPEG2000 → the ``application/octet-stream`` fallback — the + 586 images in ``players_handbook.pdf``) is NOT composited even + when it carries a ``/SMask``: raw bytes + raw mime, and the + ``fitz.Pixmap`` combiner is never called (compositing must never + newly admit an unsupported format).""" + path = tmp_path / "fake.pdf" + path.write_bytes(b"%PDF-1.4 fake") + raw_bytes = b"jpx-base-bytes" + doc = _FakeDoc( + [_fake_image_page([{"xref": 7, "width": 5, "height": 5}])], + lambda xref: { + "image": raw_bytes, "ext": "jpx", "width": 5, "height": 5, "smask": 8, + }, + ) + monkeypatch.setattr(pdf_mod.fitz, "open", lambda _p: doc) + + def _no_composite(*args: Any, **kwargs: Any) -> None: + # Phase 151 (task 03): the blank gate also READS the base + # pixmap (Pixmap(doc, xref) — a fail-soft probe that keeps the + # image when it raises, as it does on this fake doc) — that is + # not compositing. Compositing is the Pixmap(Pixmap, Pixmap) + # call (first arg a Pixmap): the raw-format gate must still run + # before it, so that shape alone is the failure sentinel. + if args and isinstance(args[0], fitz.Pixmap): + pytest.fail("the raw-format gate must run before any compositing") + raise ValueError("fake doc — no real pixmap (test sentinel)") + + monkeypatch.setattr(pdf_mod.fitz, "Pixmap", _no_composite) + images = extract_pdf_images(path) + assert len(images) == 1 + assert images[0].data == raw_bytes # raw bytes, verbatim + assert images[0].mime == "application/octet-stream" # raw mime (→ .bin → skipped) + + +@pytest.mark.skipif(not PODMAN_PDF.is_file(), reason="podman fixture missing") +def test_podman_pdf_masked_images_are_composited() -> None: + """Real-file pin: EVERY distinct image of the owner's podman PDF + carries a ``/SMask`` pre-phase (``has-mask=True`` in + ``get_image_info``, a non-zero ``smask`` in ``extract_image``), so + post-phase the extractor's output for it is the composited visible + image: each entry decodes as an alpha PNG (``alpha == 1``, the + base's dimensions) whose bytes DIFFER from the raw base stream + ``extract_image`` returns (the maskless DeviceGray base — the + "blank white image" this phase fixes). The stronger per-entry + form of the task's "at least one xref" check. Phase 151 (task 03) + landed: the blank gate (default on) removes these entries + entirely, so this compositing pin runs with the gate OFF + (``skip_blank=False``) — the default's ``[]`` result for this + file is pinned in tests/unit/test_pdf_image_filter.py.""" + images = extract_pdf_images(PODMAN_PDF, skip_blank=False) + assert len(images) == 9 # the task-01 dedupe pin still holds + + # The pre-phase property (every placement masked) + the raw base + # digests (the "blank" bytes the pre-phase extractor stored). + raw_digests: set[str] = set() + doc = fitz.open(str(PODMAN_PDF)) + with doc: + for page in doc: + for info in page.get_image_info(xrefs=True): + xref = int(info.get("xref") or 0) + if not xref: + continue + assert info.get("has-mask") is True + raw = doc.extract_image(xref) + assert int(raw.get("smask") or 0) != 0 + raw_digests.add(hashlib.sha256(raw["image"]).hexdigest()) + + for img in images: + assert img.mime == "image/png" + assert img.data.startswith(PNG_MAGIC) + vis = fitz.Pixmap(img.data) + assert vis.alpha == 1 + assert (vis.width, vis.height) == (img.width, img.height) + # the composite differs from the raw base (every entry) + assert hashlib.sha256(img.data).hexdigest() not in raw_digests + + # --------------------------------------------------------------------------- # _mime_from_image_info # --------------------------------------------------------------------------- @@ -1903,3 +2399,56 @@ def test_extract_pdf_text_regression_pin_horse() -> None: text = extract_pdf_text(HORSE_PDF) assert text == _HORSE_EXACT assert hashlib.sha256(text.encode("utf-8")).hexdigest() == _HORSE_SHA256 + + +# --------------------------------------------------------------------------- +# phase 151 — real-file regression pins (skip-if-missing, the +# ``HANDBOOK`` / ``HORSE_PDF`` idiom; test_sources/ is gitignored) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not PODMAN_PDF.is_file(), reason="podman fixture missing") +def test_podman_pdf_returns_no_image_documents_by_default() -> None: + """THE reported bug, pinned (the owner's exact file — the bug's + tombstone test): the 50-page podman + systemd guide places its + table-shading underlays 48 times (``get_image_info`` reports one + entry per PLACEMENT); post-phase the extractor returns NONE of + them — task 01's dedupe collapses the placements to 9 distinct + images (39 dropped — 15 unique xrefs of which 6 are byte-identical + copies, so the digest set merges them; the task file's 31/15 + figure assumed xref-only dedupe over 46 placements — the binding + outcome is identical: 0 out) and task 03's blank gate skips all 9 + (near-white underlays — ASSUMPTION A1: never indexed, never + described). The full drop accounting and the log lines are pinned + in tests/unit/test_pdf_image_filter.py.""" + assert extract_pdf_images(PODMAN_PDF) == [] + + +@pytest.mark.skipif(not HORSE_PDF.is_file(), reason="horse fixture missing") +def test_horse_pdf_returns_exactly_one_non_blank_image() -> None: + """The real-file "kept" pin: the horse photo (mean 123, uniq 256, + uniform ~0.20 — the phase overview's measured basis) clears the + blank gate's mean floor — exactly ONE image out, NOT blank-skipped + (being a masked JPEG, it is stored as the task-02 composite: + alpha PNG).""" + images = extract_pdf_images(HORSE_PDF) + assert len(images) == 1 + assert images[0].mime == "image/png" + assert images[0].data.startswith(PNG_MAGIC) + assert fitz.Pixmap(images[0].data).alpha == 1 + + +@pytest.mark.skipif(not HANDBOOK.is_file(), reason="handbook fixture missing") +def test_handbook_pdf_images_keep_the_unmapped_jpx_mime() -> None: + """The real-file "no behavior change" pin: the 293-page handbook's + 586 JPEG2000 page-art images are STILL returned by the parser + (no gate drop, no compositing — owner-locked A4: an unsupported + raw format is left exactly as pre-phase) with the UNMAPPED raw + mime (``application/octet-stream`` — ``jpx`` is not in the + importer's ``_MIME_TO_EXT``), so the importer's extension gate + skips them exactly as pre-phase (assert the mime mapping here, + not the importer — its gate behavior is pinned in test_importer. + py).""" + images = extract_pdf_images(HANDBOOK) + assert len(images) == 586 + assert all(img.mime == "application/octet-stream" for img in images) diff --git a/tests/unit/test_pdf_image_filter.py b/tests/unit/test_pdf_image_filter.py new file mode 100644 index 0000000..3749113 --- /dev/null +++ b/tests/unit/test_pdf_image_filter.py @@ -0,0 +1,1061 @@ +"""Unit tests: the PDF image filter — the phase-151 matrix (task 05). + +Covers every new branch of the extractor, pinned without a browser +(PyMuPDF-built temp PDFs, the ``test_pdf.py`` conventions — no binary +fixtures committed): the DEDUPE (task 01 — one ``PdfImage`` per +DISTINCT image: the same image on 3 pages → 1 result with the FIRST +page/index, two distinct images → 2, byte-identical bytes under two +xrefs → 1 (the digest path), ``stats.duplicates`` counts the drops, one +INFO line per PDF), the SOFT-MASK compositing (task 02 — an inserted +RGBA PNG is stored as base + ``/SMask`` and extracted as the composited +VISIBLE alpha PNG: ``mime == "image/png"``, bytes ≠ the raw base, +the mask survives the ``fitz.Pixmap`` round trip; an unmasked image is +byte-identical to the raw extract; a compositing failure falls back to +the raw bytes with a warning — fail-soft), the BLANK/DECORATIVE GATE +(task 03 — ``app.rag.pdf.is_blank_image`` as a pure predicate, the +``is_usable_pdf_text`` / ``pictorial_pages`` house style: no PDF, no +I/O; extractor-level: a near-white image is skipped entirely with its +INFO lines and the ``PdfImageStats`` counts, a real image is kept, a +dark-uniform image is kept, a MASKED near-white image is still skipped +(the gate measures the base, not PyMuPDF's premultiplied composite), +``skip_blank=False`` is the function-level kill switch, and a +gate-pixmap failure keeps the image), the UNMAPPED-RAW-FORMAT rule +(owner-locked A4 — a ``jpx`` image with a ``/SMask`` is left raw: no +compositing, the importer's extension gate still skips it), the +task-04 knob (``Settings.pdf_image_skip_blank`` — default ON, +``BOR_PDF_IMAGE_SKIP_BLANK`` env override, the +``pdf_pictorial``/``images`` naming pattern), the real-file pins +(``podman-systemd.pdf`` → NO images (the reported bug); +``pdf_horse_test.pdf`` → its photo kept; ``players_handbook.pdf`` → +its 586 ``jpx`` images untouched), and the importer's embedded-image +pass (one blank + one real image + a duplicate placement → exactly ONE +image document and ONE vision call; ``import: summary … +pdf_images_deduped=1 pdf_images_blank_skipped=1``; with the knob OFF +the blank image is indexed again — the kill switch is proven end to +end, not assumed). +""" +from __future__ import annotations + +import asyncio +import logging +import struct +import zlib +from collections.abc import Callable, Sequence +from pathlib import Path +from types import SimpleNamespace +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 pdf as pdf_mod +from app.rag.importer import import_sources +from app.rag.pdf import ( + PdfImageStats, + extract_pdf_images, + extract_pdf_images_with_stats, + is_blank_image, +) +from tests.fakes import FakeEmbedder + +PNG_MAGIC = b"\x89PNG" + + +# --------------------------------------------------------------------------- +# is_blank_image — the pure predicate (hand-built sample buffers) +# --------------------------------------------------------------------------- + + +def test_blank_all_near_white_is_blank() -> None: + """The happy blank: uniform near-white (the underlay's signature).""" + assert is_blank_image(b"\xfa" * 1000) is True + + +def test_blank_noise_is_not_blank() -> None: + """A real photo scatters its samples — the mean gate alone carries + this one (mean 127.5 < 235).""" + assert is_blank_image(bytes(i % 256 for i in range(1000))) is False + + +def test_blank_dark_uniform_is_not_blank() -> None: + """A DARK uniform block is NOT blank by this rule: the gate targets + near-white PAGE UNDERLAYS — a solid dark block is unusual and + cheaper to keep than to explain away (the mean gate).""" + assert is_blank_image(b"\x00" * 1000) is False + + +def test_blank_empty_samples_is_not_blank() -> None: + """Never blank a thing we cannot measure.""" + assert is_blank_image(b"") is False + + +def test_blank_97_percent_uniform_is_not_blank() -> None: + """Just under the fraction (0.97 < 0.98): 300 dark pixels in a + 250-level field break the uniformity bar even though the mean is + near-white (242.5).""" + samples = b"\xfa" * 9700 + b"\x00" * 300 + assert len(samples) == 10000 + assert sum(samples) / len(samples) == 242.5 # the mean gate passes + assert is_blank_image(samples) is False + + +def test_blank_mean_boundary_pinned_exactly() -> None: + """The mean floor is INCLUSIVE: mean == 235 is blank, 234 is not.""" + assert is_blank_image(b"\xeb" * 100) is True # 235 == mean_min + assert is_blank_image(b"\xea" * 100) is False # 234 < mean_min + + +def test_blank_fraction_boundary_pinned_exactly() -> None: + """The fraction bar is INCLUSIVE: exactly 0.98 in-band is blank, + 0.978 is not (the out-of-band pixels sit 49/48.9 from the mean — + far outside any tolerance).""" + at_boundary = bytes([250] * 490 + [200] * 10) # 490/500 == 0.98 + assert sum(at_boundary) / len(at_boundary) == 249.0 + assert is_blank_image(at_boundary) is True + just_under = bytes([250] * 489 + [200] * 11) # 489/500 == 0.978 + assert sum(just_under) / len(just_under) == 248.9 + assert is_blank_image(just_under) is False + + +def test_blank_tolerance_boundary_pinned_exactly() -> None: + """The band is INCLUSIVE (``abs(s - mean) <= tolerance``): samples + sitting EXACTLY at mean±17 are in-band, at mean±18 are not + (mean 237: 220/254 at ±17, 219/255 at ±18).""" + at_edge = bytes([220] * 250 + [254] * 250) # mean 237, ±17 exactly + assert sum(at_edge) / len(at_edge) == 237.0 + assert is_blank_image(at_edge) is True + past_edge = bytes([219] * 250 + [255] * 250) # mean 237, ±18 + assert sum(past_edge) / len(past_edge) == 237.0 + assert is_blank_image(past_edge) is False + + +def test_blank_kwargs_override_the_pinned_defaults() -> None: + """The keyword parameters are the test seam (the importer passes + none — the defaults are the production rule).""" + # mean_min: a dark block becomes blank when the floor is lowered. + assert is_blank_image(b"\x00" * 100, mean_min=0) is True + # fraction: the exactly-0.98 buffer fails a 0.99 bar. + assert is_blank_image(bytes([250] * 490 + [200] * 10), fraction=0.99) is False + # tolerance: the mean±17 buffer fails a ±16 band. + assert is_blank_image(bytes([220] * 250 + [254] * 250), tolerance=16) is False + + +def test_blank_measured_basis_buffers() -> None: + """The docstring's measured basis, hand-built from the REAL + distribution of the podman PDF's smallest underlay (xref 1633, the + literal 5 300-sample histogram — the real-file pin below carries + the same signature on the owner's actual PDF): + + * that 238/255 blend: blank at the pinned band… + * …and NOT at the phase overview's ±12 (the calibration deviation + documented on the constants — this is the pin for it: the 255 + level sits 16.2 above the mean, out-of-band at ±12, in-band at + ±17). + * the horse photo's signature (mean ~127, scattered): kept. + """ + podman_blend = bytes( + [0] * 3 + [237] * 8 + [238] * 4594 + [239] * 223 + [240] * 4 + [241] * 227 + + [245] * 1 + [255] * 240 + ) + assert len(podman_blend) == 5300 + assert sum(podman_blend) / len(podman_blend) == pytest.approx(238.81, abs=0.01) + assert is_blank_image(podman_blend) is True # ±17 (pinned default) + assert is_blank_image(podman_blend, tolerance=12) is False # the overview's ±12 + horse_like = bytes((x * 7) % 256 for x in range(1000)) + assert is_blank_image(horse_like) is False + + +# --------------------------------------------------------------------------- +# extractor-level gate (PyMuPDF-built PDFs — the phase-126 convention) +# --------------------------------------------------------------------------- + + +def _png_chunk(tag: bytes, payload: bytes) -> bytes: + """One PNG chunk (4-byte length + tag + payload + CRC32).""" + return ( + struct.pack(">I", len(payload)) + + tag + + payload + + struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF) + ) + + +def _make_png( + width: int, height: int, rgba: bool, pixel: Callable[[int, int], tuple[int, ...]] +) -> bytes: + """A deterministic PNG built with stdlib only (``zlib`` + + ``struct``) — the task-02 helper, re-rolled here (the test files + stay self-contained).""" + color_type = 6 if rgba else 2 + n = 4 if rgba else 3 + raw = bytearray() + for y in range(height): + raw.append(0) # filter type: none + for x in range(width): + raw += bytes(pixel(x, y)[:n]) + ihdr = struct.pack(">IIBBBBB", width, height, 8, color_type, 0, 0, 0) + return ( + b"\x89PNG\r\n\x1a\n" + + _png_chunk(b"IHDR", ihdr) + + _png_chunk(b"IDAT", zlib.compress(bytes(raw))) + + _png_chunk(b"IEND", b"") + ) + + +def _solid_png(width: int, height: int, value: int) -> bytes: + """A uniform RGB PNG (one ``value`` per channel — the blank's and + the dark-block's shape).""" + pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, width, height)) + pix.clear_with(value) + return pix.tobytes("png") + + +#: A deterministic colorful noise PNG (mean ~127 — the "real photo" +#: stand-in; clears the mean gate with room to spare). +_NOISE_PNG: bytes = _make_png( + 60, 40, False, lambda x, y: ((x * 7) % 256, (y * 13) % 256, ((x + y) * 5) % 256) +) + + +def _pdf_with_images( + path: Path, pages: Sequence[tuple[str, Sequence[bytes]]] +) -> Path: + """One temp PDF: per page (page text, embedded-image bytes) — + built with PyMuPDF itself (the phase-126 convention, no binary + fixtures).""" + doc = fitz.open() + for text, imgs in pages: + page = doc.new_page() + if text: + page.insert_text((72, 72), text) + y = 150 + for data in imgs: + pix = fitz.Pixmap(data) + page.insert_image( + fitz.Rect(72, y, 72 + pix.width, y + pix.height), stream=data + ) + y += pix.height + 10 + doc.save(path) + doc.close() + return path + + +def test_near_white_image_is_skipped_with_stats_and_info_lines( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """THE task's core pin: a near-white 200×200 image → 0 results, + ``stats.blank_skipped == 1``, one INFO line per skipped image (the + task's exact format) and one INFO line per PDF.""" + path = _pdf_with_images( + tmp_path / "blank.pdf", [("Text page one.", [_solid_png(200, 200, 250)])] + ) + with caplog.at_level(logging.INFO, logger="app.rag.pdf"): + images, stats = extract_pdf_images_with_stats(path) + assert images == [] + assert stats == PdfImageStats(duplicates=0, blank_skipped=1, total_found=1) + per_image = [ + r.getMessage() + for r in caplog.records + if "skipped blank/decorative image page=" in r.getMessage() + ] + assert per_image == [ + f"pdf: skipped blank/decorative image page=1 index=0 of {path} " + f"— mean=250.0 uniform=1.000" + ] + per_pdf = [ + r.getMessage() + for r in caplog.records if "blank/decorative image(s) in" in r.getMessage() + ] + assert per_pdf == [f"pdf: skipped 1 blank/decorative image(s) in {path}"] + + +def test_noise_image_is_kept_and_logs_no_blank_line( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A colorful noise image → 1 result, no blank counter, and NOT + even the per-PDF blank line (the line is the skip signal).""" + path = _pdf_with_images(tmp_path / "noise.pdf", [("Text page one.", [_NOISE_PNG])]) + with caplog.at_level(logging.INFO, logger="app.rag.pdf"): + images, stats = extract_pdf_images_with_stats(path) + assert len(images) == 1 + assert stats == PdfImageStats(duplicates=0, blank_skipped=0, total_found=1) + assert images[0].mime == "image/png" + assert images[0].data.startswith(PNG_MAGIC) + assert not [r for r in caplog.records if "blank/decorative" in r.getMessage()] + + +def test_dark_uniform_image_is_kept(tmp_path: Path) -> None: + """The mean gate's pin at the extractor level: a solid DARK block + (mean 10) is unusual — it is KEPT, never blanked.""" + path = _pdf_with_images(tmp_path / "dark.pdf", [("Text page one.", [_solid_png(100, 80, 10)])]) + images, stats = extract_pdf_images_with_stats(path) + assert len(images) == 1 + assert stats == PdfImageStats(duplicates=0, blank_skipped=0, total_found=1) + + +def test_skip_blank_false_returns_every_distinct_image(tmp_path: Path) -> None: + """The kill switch at the function level: a blank + a noise image + on one page — ``skip_blank=False`` returns BOTH (gate off; dedupe + and compositing stay on), the default (gate on) returns only the + noise image.""" + path = _pdf_with_images( + tmp_path / "both.pdf", [("Text page one.", [_solid_png(200, 200, 250), _NOISE_PNG])] + ) + images, stats = extract_pdf_images_with_stats(path, skip_blank=False) + assert len(images) == 2 + assert stats == PdfImageStats(duplicates=0, blank_skipped=0, total_found=2) + assert len(extract_pdf_images(path, skip_blank=False)) == 2 # the wrapper + assert len(extract_pdf_images(path)) == 1 # the default: gate on + + +def test_masked_near_white_image_is_skipped(tmp_path: Path) -> None: + """The gate-input pin for the MASKED path: a white base + a + varying alpha (PyMuPDF stores it as base + ``/SMask``) is skipped + — the gate measures the BASE samples (mean 255 → blank). If it + measured PyMuPDF's stored composite instead, its color channels + would be the PREMULTIPLIED base×mask/255 (alpha-stripped mean ≈ + mean(alpha) ≈ 127) and this underlay would survive — exactly the + miss the podman PDF (base mean ≈ 238 / composite mean ≈ 231–234) + demonstrates: only the base clears the pinned thresholds on every + underlay.""" + png = _make_png(40, 30, True, lambda x, y: (255, 255, 255, (3 * x + 5 * y) % 256)) + path = _pdf_with_images(tmp_path / "maskedblank.pdf", [("Text page one.", [png])]) + # Premise (the task-02 shape): the raw extract is a base + a + # SEPARATE smask xref. + doc = fitz.open(str(path)) + info = doc[0].get_image_info(xrefs=True)[0] + raw = doc.extract_image(int(info["xref"])) + doc.close() + assert int(raw.get("smask") or 0) != 0 + images, stats = extract_pdf_images_with_stats(path) + assert images == [] + assert stats == PdfImageStats(duplicates=0, blank_skipped=1, total_found=1) + + +class _FakeDoc: + """A stand-in fitz Document: given pages, stubbed extract_image + (test_pdf.py's task-01 convention — PyMuPDF's real constructors + resolve the module-global ``Pixmap`` internally, so a real PDF + a + patched ``Pixmap`` would break the raw reads too; the fake makes + the gate's base read the ONLY ``fitz.Pixmap`` call in flight). + """ + + def __init__(self, pages: list[Any], extract: Any) -> None: + self._pages = pages + self._extract = extract + + def __len__(self) -> int: + return len(self._pages) + + def __getitem__(self, index: int) -> Any: + return self._pages[index] + + def extract_image(self, xref: int) -> Any: + return self._extract(xref) + + def __enter__(self) -> _FakeDoc: + return self + + def __exit__(self, *args: object) -> bool: + return False + + +def _fake_image_page(page_entries: list[dict[str, Any]]) -> Any: + return SimpleNamespace(get_image_info=lambda xrefs: list(page_entries)) + + +def test_gate_pixmap_failure_keeps_the_image( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Fail-soft: a gate pixmap that cannot be built (the monkeypatched + ``fitz.Pixmap``) SKIPS the gate — the near-white image is KEPT + (a decode problem must never delete a real image), with one DEBUG + line (the task's format) and no skip counter.""" + path = tmp_path / "fake.pdf" + path.write_bytes(b"%PDF-1.4 fake") + doc = _FakeDoc( + [_fake_image_page([{"xref": 7, "width": 5, "height": 5}])], + lambda xref: {"image": b"\xff" * 100, "ext": "png", "width": 5, "height": 5}, + ) + monkeypatch.setattr(pdf_mod.fitz, "open", lambda _p: doc) + + def _boom(*args: Any, **kwargs: Any) -> None: + raise RuntimeError("simulated decode failure") + + monkeypatch.setattr(pdf_mod.fitz, "Pixmap", _boom) + with caplog.at_level(logging.DEBUG, logger="app.rag.pdf"): + images, stats = extract_pdf_images_with_stats(path) + assert len(images) == 1 # kept + assert stats == PdfImageStats(duplicates=0, blank_skipped=0, total_found=1) + debugs = [ + r.getMessage() + for r in caplog.records + if "blank gate could not build pixmap" in r.getMessage() + ] + assert len(debugs) == 1 + assert debugs[0].endswith("— simulated decode failure; keeping image") + assert not [r for r in caplog.records if "skipped blank" in r.getMessage()] + + +def test_gate_drops_the_alpha_channel_before_measuring( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """The mean is over COLOR samples only: a gate pixmap WITH alpha + gets ``fitz.Pixmap(pixmap, 0)`` (the alpha drop) BEFORE the samples + are measured (an alpha channel would drag the mean and produce + false blanks). The fake-pixmap stand-in (the fail-soft test's + shape) makes the alpha drop the second ``fitz.Pixmap`` call — the + assertion pins that it happens, on the base read, with ``0``.""" + path = tmp_path / "fake.pdf" + path.write_bytes(b"%PDF-1.4 fake") + doc = _FakeDoc( + [_fake_image_page([{"xref": 7, "width": 5, "height": 5}])], + lambda xref: {"image": b"raw-bytes", "ext": "png", "width": 5, "height": 5}, + ) + monkeypatch.setattr(pdf_mod.fitz, "open", lambda _p: doc) + + class _FakePix: + def __init__(self, alpha: bool, samples: bytes) -> None: + self.alpha = 1 if alpha else 0 + self.samples = samples + + calls: list[tuple[Any, ...]] = [] + + def _pix(*args: Any, **kwargs: Any) -> _FakePix: + calls.append(args) + if args and isinstance(args[0], _FakePix): + # Pixmap(pixmap, 0) — the alpha drop: color samples only. + return _FakePix(False, args[0].samples) + # Pixmap(doc, xref) — the base read: alpha present. + return _FakePix(True, b"\xff" * 50) + + monkeypatch.setattr(pdf_mod.fitz, "Pixmap", _pix) + with caplog.at_level(logging.INFO, logger="app.rag.pdf"): + images, stats = extract_pdf_images_with_stats(path) + assert images == [] # 255-only color samples → blank + assert stats == PdfImageStats(duplicates=0, blank_skipped=1, total_found=1) + # The base read happened, and it was FOLLOWED by the alpha drop + # (the second call takes the base read's pixmap, with 0). + base_reads = [c for c in calls if c and isinstance(c[0], _FakeDoc)] + drops = [c for c in calls if c and isinstance(c[0], _FakePix)] + assert len(base_reads) == 1 and base_reads[0][1] == 7 + assert len(drops) == 1 and drops[0][1] == 0 + + +# --------------------------------------------------------------------------- +# dedupe (phase 151, task 01) — one PdfImage per DISTINCT image +# --------------------------------------------------------------------------- + + +def test_dedupe_same_image_three_placements_one_first_wins( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """The task's core pin: the SAME image on 3 pages → ONE result + carrying the FIRST occurrence's page/index (page 1, index 0 — the + synthetic path stays stable for the first placement), + ``stats.duplicates == 2``, and ONE INFO line per PDF (the task's + exact format).""" + path = _pdf_with_images( + tmp_path / "triple.pdf", + [(f"Text on page {n}.", [_NOISE_PNG]) for n in (1, 2, 3)], + ) + with caplog.at_level(logging.INFO, logger="app.rag.pdf"): + images, stats = extract_pdf_images_with_stats(path) + assert len(images) == 1 + assert (images[0].page, images[0].index) == (1, 0) + assert stats == PdfImageStats(duplicates=2, blank_skipped=0, total_found=3) + infos = [ + r.getMessage() + for r in caplog.records + if "duplicate image occurrence(s)" in r.getMessage() + ] + assert infos == [ + f"pdf: dropped 2 duplicate image occurrence(s) from {path} (1 unique)" + ] + + +def test_dedupe_two_distinct_images_are_both_kept(tmp_path: Path) -> None: + """The no-duplicate path: genuinely distinct images (different size + AND pixels) are NOT merged — two results in page order, + ``stats.duplicates == 0``, and NO INFO line (the line is the + duplicate signal; its absence means nothing was dropped).""" + path = _pdf_with_images( + tmp_path / "two.pdf", + [("Text one.", [_NOISE_PNG]), ("Text two.", [_solid_png(100, 80, 10)])], + ) + images, stats = extract_pdf_images_with_stats(path) + assert [(img.page, img.index) for img in images] == [(1, 0), (2, 0)] + assert images[0].data != images[1].data + assert stats == PdfImageStats(duplicates=0, blank_skipped=0, total_found=2) + + +def test_dedupe_byte_identical_bytes_under_two_xrefs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The DIGEST path: two DIFFERENT xrefs holding byte-identical + bytes → one result (the first), ``stats.duplicates == 1`` — the + xref check alone cannot catch this, the sha256 of the stored + bytes can (real PDFs DO store the same image under several xrefs + — 6 of podman's 15 unique xrefs are byte-identical copies). The + fake doc is the only way to force two xrefs for identical bytes — + PyMuPDF reuses one xref when *insert_image* sees the same stream + (that is the repeated-placement path, pinned above).""" + path = tmp_path / "fake.pdf" + path.write_bytes(b"%PDF-1.4 fake") + + def _extract(xref: int) -> dict[str, Any]: + return {"image": b"identical-bytes", "ext": "png", "width": 5, "height": 5} + + doc = _FakeDoc( + [ + _fake_image_page( + [{"xref": 7, "width": 5, "height": 5}, {"xref": 9, "width": 5, "height": 5}] + ) + ], + _extract, + ) + monkeypatch.setattr(pdf_mod.fitz, "open", lambda _p: doc) + images, stats = extract_pdf_images_with_stats(path) + assert len(images) == 1 + assert (images[0].page, images[0].index) == (1, 0) + assert stats == PdfImageStats(duplicates=1, blank_skipped=0, total_found=2) + + +# --------------------------------------------------------------------------- +# soft masks (phase 151, task 02) — the /SMask is applied: the image a +# viewer shows, not the blank base stream extract_image() returns +# --------------------------------------------------------------------------- + + +def _masked_rgba_png() -> bytes: + """A 40x30 RGBA PNG with varying color AND non-uniform alpha (top + half opaque, bottom half fully transparent) — the task's fixture: + ``insert_image`` stores exactly this shape as a BASE image + a + separate ``/SMask`` xref.""" + return _make_png( + 40, 30, True, + lambda x, y: (x % 256, y % 256, (x + y) % 256, 255 if y < 15 else 0), + ) + + +def _raw_extract_first_image(path: Path) -> dict[str, Any]: + """The page's first image as ``extract_image`` reports it — the + raw reference (what the PRE-phase extractor returned verbatim).""" + doc = fitz.open(str(path)) + with doc: + info = doc[0].get_image_info(xrefs=True)[0] + return doc.extract_image(int(info["xref"])) + + +def test_masked_rgba_image_is_composited_to_the_visible_alpha_png( + tmp_path: Path, +) -> None: + """The task's core pin: an RGBA PNG inserted via + ``page.insert_image(rect, stream=…)`` (PyMuPDF stores it as BASE + + ``/SMask``) is extracted as the composited VISIBLE image: + ``mime == "image/png"``, its bytes ≠ ``doc.extract_image(xref)`` + ``["image"]`` (the maskless base), and re-opening the extracted + bytes as a ``fitz.Pixmap`` reports ``alpha == 1`` (the mask + survived).""" + path = _pdf_with_images(tmp_path / "masked.pdf", [("Text page one.", [_masked_rgba_png()])]) + raw = _raw_extract_first_image(path) + # Premise (the bug this task fixes): the /SMask is stored + # separately and the raw extract is the maskless base. + assert int(raw.get("smask") or 0) != 0 + images, stats = extract_pdf_images_with_stats(path) + assert len(images) == 1 + img = images[0] + assert img.mime == "image/png" + assert img.data != raw["image"] # the composite, not the blank base + assert img.data.startswith(PNG_MAGIC) + assert fitz.Pixmap(img.data).alpha == 1 # the mask survived + assert stats == PdfImageStats(duplicates=0, blank_skipped=0, total_found=1) + + +def test_unmasked_image_is_byte_identical_to_the_raw_extract( + tmp_path: Path, +) -> None: + """The common case (no ``/SMask``) is unchanged: the extractor + returns the raw ``extract_image`` bytes + raw mime verbatim — + byte-identical to pre-phase (no composite, no re-encode).""" + png = _make_png(40, 30, False, lambda x, y: (x % 256, y % 256, (x + y) % 256)) + path = _pdf_with_images(tmp_path / "plain.pdf", [("Text page one.", [png])]) + raw = _raw_extract_first_image(path) + assert int(raw.get("smask") or 0) == 0 + images, _ = extract_pdf_images_with_stats(path) + assert len(images) == 1 + assert images[0].data == raw["image"] # the raw bytes, verbatim + assert images[0].mime == "image/png" # the raw mime (ext = png) + + +def test_composite_failure_falls_back_to_raw_bytes_with_warning( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Fail-soft (the task's pin): a monkeypatched ``fitz.Pixmap`` that + RAISES makes the compositing fail — the extractor returns the raw + bytes + the raw mime, logs the warning (the task's exact format), + and NEVER loses the image (it is still indexed, exactly as + pre-phase). No exception escapes the extractor. The ``_FakeDoc`` + stand-in keeps the extractor's compositing block the ONLY + ``fitz.Pixmap`` call in flight (PyMuPDF's real constructor resolves + the module-global ``Pixmap`` internally, so patching it against a + real PDF would break the raw reads too).""" + path = tmp_path / "fake.pdf" + path.write_bytes(b"%PDF-1.4 fake") + raw_bytes = b"raw-base-bytes" + doc = _FakeDoc( + [_fake_image_page([{"xref": 7, "width": 5, "height": 5}])], + lambda xref: { + "image": raw_bytes, "ext": "png", "width": 5, "height": 5, "smask": 8, + }, + ) + monkeypatch.setattr(pdf_mod.fitz, "open", lambda _p: doc) + + attempts = 0 + + def _boom(*args: Any, **kwargs: Any) -> None: + nonlocal attempts + attempts += 1 + raise RuntimeError("simulated combine failure") + + monkeypatch.setattr(pdf_mod.fitz, "Pixmap", _boom) + with caplog.at_level(logging.WARNING, logger="app.rag.pdf"): + images, stats = extract_pdf_images_with_stats(path) + assert attempts >= 1 # the composite was attempted (a Pixmap was read) + assert len(images) == 1 # the image is NOT lost + assert images[0].data == raw_bytes # raw bytes, verbatim + assert images[0].mime == "image/png" # the raw mime + assert stats == PdfImageStats(duplicates=0, blank_skipped=0, total_found=1) + warnings = [ + r.getMessage() + for r in caplog.records + if r.levelno == logging.WARNING and "could not composite smask=" in r.getMessage() + ] + assert warnings == [ + f"pdf: could not composite smask=8 for image page=1 index=0 " + f"of {path} — simulated combine failure; using raw bytes" + ] + + +# --------------------------------------------------------------------------- +# the raw-format gate (owner-locked A4) — an unsupported raw format is +# never composited, even with a /SMask +# --------------------------------------------------------------------------- + + +def test_unsupported_raw_format_with_smask_stays_raw( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Owner-locked A4 — the raw-format gate runs FIRST: an image whose + raw format the importer's ``_MIME_TO_EXT`` gate will reject + (``jpx``/JPEG2000 → the ``application/octet-stream`` fallback — the + 586 images in ``players_handbook.pdf``) is NOT composited even when + it carries a ``/SMask``: raw bytes + the unmapped mime, and the + ``fitz.Pixmap`` combiner is never called — compositing must never + newly admit an unsupported format (the importer's extension gate + still skips it downstream, exactly as pre-phase).""" + path = tmp_path / "fake.pdf" + path.write_bytes(b"%PDF-1.4 fake") + raw_bytes = b"jpx-base-bytes" + doc = _FakeDoc( + [_fake_image_page([{"xref": 7, "width": 5, "height": 5}])], + lambda xref: { + "image": raw_bytes, "ext": "jpx", "width": 5, "height": 5, "smask": 8, + }, + ) + monkeypatch.setattr(pdf_mod.fitz, "open", lambda _p: doc) + + def _no_composite(*args: Any, **kwargs: Any) -> None: + # The combiner's shape is Pixmap(pixmap, …) (first arg a + # Pixmap). The blank gate's base read Pixmap(doc, xref) is a + # fail-soft probe (it keeps the image when it raises — as it + # does on this fake doc); compositing is the ONLY Pixmap call + # that must not happen for an unsupported raw format. + if args and isinstance(args[0], fitz.Pixmap): + pytest.fail("the raw-format gate must run before any compositing") + raise ValueError("fake doc — no real pixmap (test sentinel)") + + monkeypatch.setattr(pdf_mod.fitz, "Pixmap", _no_composite) + images, stats = extract_pdf_images_with_stats(path) + assert len(images) == 1 + assert images[0].data == raw_bytes # raw bytes, verbatim + assert images[0].mime == "application/octet-stream" # → .bin → skipped downstream + assert stats == PdfImageStats(duplicates=0, blank_skipped=0, total_found=1) + + +# --------------------------------------------------------------------------- +# the knob (phase 151, task 04): Settings.pdf_image_skip_blank +# --------------------------------------------------------------------------- + + +def _settings(**kwargs: Any) -> Settings: + """Build Settings without reading a .env file (deterministic + tests — test_pictorial_gate.py's idiom).""" + kwargs.setdefault("_env_file", None) + return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime) + + +def test_pdf_image_skip_blank_defaults_on_and_env_override( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Task 04: the kill switch is ON by default (the gate is on); a + plain bool like ``pdf_pictorial``/``images`` (no validator) whose + ``BOR_PDF_IMAGE_SKIP_BLANK`` env override (``0``/``false`` = off) + restores the pre-phase-151 behavior — every extracted image + indexed again.""" + monkeypatch.delenv("BOR_PDF_IMAGE_SKIP_BLANK", raising=False) + assert _settings().pdf_image_skip_blank is True + monkeypatch.setenv("BOR_PDF_IMAGE_SKIP_BLANK", "0") + assert _settings().pdf_image_skip_blank is False + monkeypatch.setenv("BOR_PDF_IMAGE_SKIP_BLANK", "false") + assert _settings().pdf_image_skip_blank is False + monkeypatch.setenv("BOR_PDF_IMAGE_SKIP_BLANK", "1") + assert _settings().pdf_image_skip_blank is True + + +# --------------------------------------------------------------------------- +# real-file pins (skip-if-missing — test_sources/ is gitignored) +# --------------------------------------------------------------------------- + +#: The owner's real 50-page fixture: the podman + systemd guide. THE +#: phase-151 reproducer (its table-shading underlays are the "blank +#: white images" the TODO reported). +PODMAN_PDF = Path(__file__).resolve().parents[2] / "test_sources" / "podman-systemd.pdf" + +#: The one-photo control (a real, masked JPEG photo that must be KEPT). +HORSE_PDF = Path(__file__).resolve().parents[2] / "test_sources" / "pdf_horse_test.pdf" + +#: The 586-``jpx``-image control (the unsupported raw format must stay +#: untouched — the importer's extension gate still decides). +HANDBOOK_PDF = Path(__file__).resolve().parents[2] / "test_sources" / "players_handbook.pdf" + + +@pytest.mark.skipif(not PODMAN_PDF.is_file(), reason="podman fixture missing") +def test_podman_pdf_gate_returns_no_images(caplog: pytest.LogCaptureFixture) -> None: + """THE completion criterion: the pre-phase extractor returned 46 + blank-white docs for this file; post-phase (dedupe task 01 + + compositing task 02 + this gate) it returns NONE — every distinct + image is a decorative underlay. The stats pin the full drop + accounting: 48 placements found, 39 dropped by the DIGEST-LEVEL + dedupe (task 01's shipped behavior — 15 unique xrefs of which 6 are + byte-identical copies, so 9 distinct; the task file's 31/15 + figures assumed the phase overview's 46 placements with + xref-only dedupe — the binding outcome is identical: 0 out), 9 + dropped by the gate.""" + with caplog.at_level(logging.INFO, logger="app.rag.pdf"): + images, stats = extract_pdf_images_with_stats(PODMAN_PDF) + assert images == [] + assert stats == PdfImageStats(duplicates=39, blank_skipped=9, total_found=48) + per_image = [ + r.getMessage() + for r in caplog.records + if "skipped blank/decorative image page=" in r.getMessage() + ] + assert len(per_image) == 9 # one INFO per skipped image + assert all("— mean=23" in m for m in per_image) # the near-white floor, in the log + per_pdf = [ + r.getMessage() + for r in caplog.records if "blank/decorative image(s) in" in r.getMessage() + ] + assert per_pdf == [f"pdf: skipped 9 blank/decorative image(s) in {PODMAN_PDF}"] + dedupe = [ + r.getMessage() + for r in caplog.records if "duplicate image occurrence(s)" in r.getMessage() + ] + assert dedupe == [ + f"pdf: dropped 39 duplicate image occurrence(s) from {PODMAN_PDF} (0 unique)" + ] + + +@pytest.mark.skipif(not PODMAN_PDF.is_file(), reason="podman fixture missing") +def test_podman_pdf_kill_switch_still_returns_the_nine() -> None: + """The function-level kill switch: ``skip_blank=False`` restores + the pre-gate result (the task-01/02 pins — 9 distinct composited + images, first placements' page/index).""" + images = extract_pdf_images(PODMAN_PDF, skip_blank=False) + assert len(images) == 9 + assert [(img.page, img.index) for img in images] == [ + (7, 0), (22, 0), (26, 0), (30, 0), (33, 0), + (36, 0), (41, 0), (45, 0), (47, 0), + ] + + +@pytest.mark.skipif(not HORSE_PDF.is_file(), reason="horse fixture missing") +def test_horse_pdf_photo_is_kept() -> None: + """The real-file "kept" pin: the photo (mean 123, uniq 256, + uniform ~0.20) clears the mean gate — and, being a masked JPEG, is + stored as the task-02 composite (alpha PNG).""" + images, stats = extract_pdf_images_with_stats(HORSE_PDF) + assert len(images) == 1 + assert stats == PdfImageStats(duplicates=0, blank_skipped=0, total_found=1) + assert images[0].mime == "image/png" + assert images[0].data.startswith(PNG_MAGIC) + + +@pytest.mark.skipif(not HANDBOOK_PDF.is_file(), reason="handbook fixture missing") +def test_handbook_pdf_jpx_images_are_unchanged() -> None: + """The real-file "no behavior change" pin: all 586 ``jpx`` images + are still extracted as the raw unsupported format (no blank gate + drop, no compositing — owner-locked A4), so the importer's + extension gate skips them exactly as pre-phase.""" + images, stats = extract_pdf_images_with_stats(HANDBOOK_PDF) + assert len(images) == 586 + assert stats == PdfImageStats(duplicates=0, blank_skipped=0, total_found=586) + assert all(img.mime == "application/octet-stream" for img in images) + + +# --------------------------------------------------------------------------- +# the importer's embedded-image pass (the phase-122 image-test idiom) +# --------------------------------------------------------------------------- + + +class _VisionLLM(FakeEmbedder): + """The phase-122 mock VISION client (test_pdf.py's pattern): the + CHAT model answers the multimodal describe call with the fixed + :data:`DESCRIPTION`; every multimodal call is counted + (``describe_calls``). Text (``lite``) calls keep the + ``FakeEmbedder`` behaviour.""" + + DESCRIPTION = ( + "A photo of a horse grazing in a green pasture at golden hour." + ) + + def __init__(self) -> None: + super().__init__() + self.describe_calls = 0 + + 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): + self.describe_calls += 1 + return self.DESCRIPTION + first = user.split() + return "Summary of " + (first[0] if first else "") + + +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 test_importer_pass_indexes_only_the_non_blank_image( + db, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """The importer-level contract: a PDF with one blank + one real + image produces exactly ONE image document (the real one, at its + own ``#page=N#img=i`` position) and exactly ONE vision call — the + blank image never reaches ``_index_one_embedded_image`` (no row, + no copy, no describe) — and the run's summary line carries the new + log-only counters (the sync ``detail`` API shape is untouched).""" + root = tmp_path / "blankgate" + root.mkdir() + _pdf_with_images( + root / "guide.pdf", + [ + ("Text page one.", []), + ("Text page two.", [_solid_png(200, 200, 250), _NOISE_PNG]), + ], + ) + llm = _VisionLLM() + kwargs: dict[str, Any] = { + "_env_file": None, + "images": True, + "image_dir": str(tmp_path / "images"), + "import_extensions": "md,txt,pdf", + # sparse fixture pages: keep the pre-140 shape (test_pdf.py's + # _pdf_llm pattern) + "pdf_pictorial": False, + } + llm.settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] + try: + with caplog.at_level(logging.INFO, logger="app.importer"): + summary = asyncio.run(import_sources([root], llm, session=db)) + # Exactly one image document — the noise image at index 1; the + # blank at index 0 never reached the phase-122 pipeline. + image_rows = db.scalars( + select(Document).where( + Document.source == root.name, Document.is_image.is_(True) + ) + ).all() + assert [row.path for row in image_rows] == ["guide.pdf#page=2#img=1.png"] + img = image_rows[0] + expected_context = ( + f"Extracted from {root.name}/guide.pdf (page 2):\nText page two." + ) + assert img.content == f"{expected_context}\n\n{_VisionLLM.DESCRIPTION}" + assert img.title == "Embedded image from guide page 2" + # ONE vision call — the blank image was never described. + assert llm.describe_calls == 1 + # The PDF + its one image: both count added (the blank counts + # nowhere — not even images_failed, a skip is not a failure). + assert (summary.files, summary.added, summary.images_failed) == (1, 2, 0) + # The new log-only counters (phase 133's stats convention). + assert (summary.pdf_images_deduped, summary.pdf_images_blank_skipped) == (0, 1) + # The RUN summary line (the per-file ``import: summary + # source=…`` lines are a different thing — the ``files=`` token + # is the run line's signature). + summary_lines = [ + r.getMessage() + for r in caplog.records + if r.getMessage().startswith("import: summary files=") + ] + assert len(summary_lines) == 1 + assert "pdf_images_deduped=0" in summary_lines[0] + assert "pdf_images_blank_skipped=1" in summary_lines[0] + # One image_dir copy (the kept image's) — no orphan for the + # blank. + assert len(list(Path(llm.settings.image_dir).expanduser().glob("*"))) == 1 + finally: + _cleanup_source(db, root.name) + + +def test_importer_pass_with_knob_off_indexes_the_blank_image_too( + db, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Task 04 — the kill switch, proven end to end: with + ``pdf_image_skip_blank=False`` (the ``BOR_PDF_IMAGE_SKIP_BLANK=0`` + shape), the SAME one-blank + one-real PDF indexes BOTH images — + two vision calls, two image docs at their own ``#page=N#img=i`` + positions, ``pdf_images_blank_skipped=0`` in the summary line — the + inverse of the default-on pin above (the pre-phase-151 behavior). Dedupe + and soft-mask compositing are UNTOUCHED by the knob (correctness + fixes): the two distinct images still land once each.""" + root = tmp_path / "knoboff" + root.mkdir() + _pdf_with_images( + root / "guide.pdf", + [ + ("Text page one.", []), + ("Text page two.", [_solid_png(200, 200, 250), _NOISE_PNG]), + ], + ) + llm = _VisionLLM() + kwargs: dict[str, Any] = { + "_env_file": None, + "images": True, + "image_dir": str(tmp_path / "images"), + "import_extensions": "md,txt,pdf", + # sparse fixture pages: keep the pre-140 shape (test_pdf.py's + # _pdf_llm pattern) + "pdf_pictorial": False, + # THE task-04 knob, off: the pre-phase-151 behavior. + "pdf_image_skip_blank": False, + } + llm.settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] + try: + with caplog.at_level(logging.INFO, logger="app.importer"): + summary = asyncio.run(import_sources([root], llm, session=db)) + # BOTH images are indexed — the blank at index 0 included + # (the inverse of the default-on pin: it was the ONE that + # never reached the phase-122 pipeline). + image_rows = db.scalars( + select(Document).where( + Document.source == root.name, Document.is_image.is_(True) + ) + ).all() + assert [row.path for row in sorted(image_rows, key=lambda r: r.path)] == [ + "guide.pdf#page=2#img=0.png", + "guide.pdf#page=2#img=1.png", + ] + # TWO vision calls — the blank image IS described (pre-phase). + assert llm.describe_calls == 2 + # PDF + both images: all three count added (the blank is no + # longer skipped — but it is not a failure either). + assert (summary.files, summary.added, summary.images_failed) == (1, 3, 0) + # The gate is OFF: the blank counter stays 0 (dedupe is a + # correctness fix — it still reports its own drops). + assert (summary.pdf_images_deduped, summary.pdf_images_blank_skipped) == (0, 0) + summary_lines = [ + r.getMessage() + for r in caplog.records + if r.getMessage().startswith("import: summary files=") + ] + assert len(summary_lines) == 1 + assert "pdf_images_blank_skipped=0" in summary_lines[0] + # Two image_dir copies — one per indexed image. + assert len(list(Path(llm.settings.image_dir).expanduser().glob("*"))) == 2 + finally: + _cleanup_source(db, root.name) + + +def test_importer_pass_with_duplicate_placement_indexes_one_image_once( + db, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Task 05's integration pin: a PDF with one blank underlay + one + real image + a DUPLICATE placement of the real image (its 2nd + occurrence, page 3) indexes EXACTLY ONE image document (the real + image at its FIRST placement's ``#page=N#img=i`` path) and makes + EXACTLY ONE vision describe call — the duplicate never reaches + ``_index_one_embedded_image`` (no row, no copy, no describe) — and + the run's log-only counters carry BOTH drops (`` + summary.pdf_images_deduped == 1``, ``summary.pdf_images_blank_ + skipped == 1``) in the ``import: summary …`` line (the sync + ``detail`` API shape is untouched — the phase-133 stats convention + on steroids: both counters at once).""" + root = tmp_path / "dupgate" + root.mkdir() + _pdf_with_images( + root / "guide.pdf", + [ + ("Text page one.", []), + ("Text page two.", [_solid_png(200, 200, 250), _NOISE_PNG]), + ("Text page three.", [_NOISE_PNG]), # the duplicate placement + ], + ) + llm = _VisionLLM() + kwargs: dict[str, Any] = { + "_env_file": None, + "images": True, + "image_dir": str(tmp_path / "images"), + "import_extensions": "md,txt,pdf", + # sparse fixture pages: keep the pre-140 shape (test_pdf.py's + # _pdf_llm pattern) + "pdf_pictorial": False, + } + llm.settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] + try: + with caplog.at_level(logging.INFO, logger="app.importer"): + summary = asyncio.run(import_sources([root], llm, session=db)) + # Exactly ONE image document — the real image at its FIRST + # placement (page 2, index 1); the blank (page 2, index 0) and + # the duplicate placement (page 3) never reached the + # phase-122 pipeline. + image_rows = db.scalars( + select(Document).where( + Document.source == root.name, Document.is_image.is_(True) + ) + ).all() + assert [row.path for row in image_rows] == ["guide.pdf#page=2#img=1.png"] + # ONE vision call — the duplicate placement was never described. + assert llm.describe_calls == 1 + # PDF + its one image: both count added (the blank and the + # duplicate count nowhere — a skip is not a failure). + assert (summary.files, summary.added, summary.images_failed) == (1, 2, 0) + # BOTH log-only counters carry their drops (the phase-133 + # stats convention). + assert (summary.pdf_images_deduped, summary.pdf_images_blank_skipped) == (1, 1) + # The RUN summary line carries both counters. + summary_lines = [ + r.getMessage() + for r in caplog.records + if r.getMessage().startswith("import: summary files=") + ] + assert len(summary_lines) == 1 + assert "pdf_images_deduped=1" in summary_lines[0] + assert "pdf_images_blank_skipped=1" in summary_lines[0] + # One image_dir copy (the kept image's) — no orphan for the + # blank or the duplicate. + assert len(list(Path(llm.settings.image_dir).expanduser().glob("*"))) == 1 + finally: + _cleanup_source(db, root.name)