From 6ba46e735a2b4649398fbc5e223da3636eb39dc1 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Wed, 7 Oct 2026 13:57:30 -0400 Subject: [PATCH] phase: 163_pdf_strip_additive_selfheal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All gates verified. Final report: **Phase 163 — final verification pass (all 5 tasks already complete; verification-only run)** - Verified D1–D4 in code: `page_strip_mode` column + migration 0027, `DocContent` absent-never-null rule keyed off `page_count`; `_persist_pdf_pages` render → atomic tmp+`os.replace` writes → stale-tail-last + `strip_class` stamp; `_regate_pdf_strip` on both unchanged paths (mirror gate, heal, repair, fail-soft, one INFO line only when acting); viewer mode branch + Text/Pages toggle (Text default, `aria-pressed`, no innerHTML, lazy imgs). - `uv run pytest --cov=app --cov-report=term-missing` → **3796 tests, exit 0, coverage 99%** (>90%) - `uv run pytest tests/e2e/test_pdf_strip_additive_selfheal.py -v --no-cov` → **5 passed** in isolation - `test_pdf_pages_in_sources + test_pictorial_pdf + test_garbage_pdf` → 10 passed; `test_document_viewer(_context) + test_image_documents` → 20 passed - `uv run ruff check .` clean; `uv run pyright` → 0 errors; alembic **0027 downgrade→upgrade CLI round-trip clean** (+ integration pins) - Completion criteria: additive view w/ toggle ✅ · primary DOM byte-identity ✅ · write-first order/mid-write-failure/atomic pins ✅ · heal + repair E2E ✅ · zero vision/LLM on fast path (mock-captured) ✅ · non-qualifying payload byte-identity ✅ · docs pass ✅ - **Documentation**: README.md + AGENTS.md reviewed in full — the phase's updates are accurate; all config-table defaults cross-checked against `app/config.py` (no drift), every page route, script, and E2E command referenced exists, every `.env.example` var documented — **verified consistent, no changes needed**. - Notable: live `Letumdei` repair is owner-runtime post-deploy per D5 (next production Sync self-heals; no live changes made); the modified `.agents/screenshots/doc_modal_scrollbar_1280.png` is an E2E-regenerated artifact left for the pipeline commit. - **Next pending phase:** `164_chat_error_honesty` --- .../00_phase.md | 0 .../01_schema_wire_strip_mode.md | 0 .../02_persist_write_first.md | 0 .../03_fast_path_regate.md | 0 .../04_viewer_additive_toggle.md | 0 .../05_e2e_selfheal_pins.md | 0 ...f_strip_additive_selfheal__00_phase.a1.err | 0 ...df_strip_additive_selfheal__00_phase.a1.md | 13 + ...p_additive_selfheal__00_phase.a1.md.status | 1 + ...ip_additive_selfheal__00_phase.a1.validate | 124 ++ ...selfheal__01_schema_wire_strip_mode.a1.err | 0 ..._selfheal__01_schema_wire_strip_mode.a1.md | 21 + ...al__01_schema_wire_strip_mode.a1.md.status | 1 + ...eal__01_schema_wire_strip_mode.a1.validate | 124 ++ ...ve_selfheal__02_persist_write_first.a1.err | 0 ...ive_selfheal__02_persist_write_first.a1.md | 13 + ...fheal__02_persist_write_first.a1.md.status | 1 + ...lfheal__02_persist_write_first.a1.validate | 124 ++ ...itive_selfheal__03_fast_path_regate.a1.err | 0 ...ditive_selfheal__03_fast_path_regate.a1.md | 10 + ...selfheal__03_fast_path_regate.a1.md.status | 1 + ..._selfheal__03_fast_path_regate.a1.validate | 124 ++ ...selfheal__04_viewer_additive_toggle.a1.err | 0 ..._selfheal__04_viewer_additive_toggle.a1.md | 18 + ...al__04_viewer_additive_toggle.a1.md.status | 1 + ...eal__04_viewer_additive_toggle.a1.validate | 124 ++ ...tive_selfheal__05_e2e_selfheal_pins.a1.err | 0 ...itive_selfheal__05_e2e_selfheal_pins.a1.md | 16 + ...elfheal__05_e2e_selfheal_pins.a1.md.status | 1 + ...selfheal__05_e2e_selfheal_pins.a1.validate | 124 ++ .../screenshots/doc_modal_scrollbar_1280.png | Bin 43426 -> 43743 bytes AGENTS.md | 6 +- README.md | 43 +- .../0027_documents_page_strip_mode.py | 68 ++ app/api/docs.py | 20 + app/models.py | 18 + app/rag/importer.py | 480 ++++++-- app/schemas.py | 18 + frontend/assets/document.js | 109 +- frontend/assets/styles.css | 53 + tests/e2e/test_pdf_pages_in_sources.py | 131 +- tests/e2e/test_pdf_strip_additive_selfheal.py | 1084 +++++++++++++++++ tests/e2e/test_pictorial_pdf.py | 108 +- tests/integration/test_docs_api.py | 86 +- tests/integration/test_migration_0027.py | 370 ++++++ tests/unit/test_document_viewer.py | 117 +- .../unit/test_importer_unchanged_fast_path.py | 527 ++++++++ tests/unit/test_models.py | 76 ++ tests/unit/test_pdf_page_copies.py | 330 ++++- tests/unit/test_summary_edit_ui.py | 5 +- tests/unit/test_viewer_additive_toggle.py | 431 +++++++ tests/unit/test_wide_column_css.py | 20 +- 52 files changed, 4729 insertions(+), 212 deletions(-) rename .agents/phases/{todo => complete}/163_pdf_strip_additive_selfheal/00_phase.md (100%) rename .agents/phases/{todo => complete}/163_pdf_strip_additive_selfheal/01_schema_wire_strip_mode.md (100%) rename .agents/phases/{todo => complete}/163_pdf_strip_additive_selfheal/02_persist_write_first.md (100%) rename .agents/phases/{todo => complete}/163_pdf_strip_additive_selfheal/03_fast_path_regate.md (100%) rename .agents/phases/{todo => complete}/163_pdf_strip_additive_selfheal/04_viewer_additive_toggle.md (100%) rename .agents/phases/{todo => complete}/163_pdf_strip_additive_selfheal/05_e2e_selfheal_pins.md (100%) create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__00_phase.a1.err create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__00_phase.a1.md create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__00_phase.a1.md.status create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__00_phase.a1.validate create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__01_schema_wire_strip_mode.a1.err create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__01_schema_wire_strip_mode.a1.md create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__01_schema_wire_strip_mode.a1.md.status create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__01_schema_wire_strip_mode.a1.validate create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__02_persist_write_first.a1.err create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__02_persist_write_first.a1.md create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__02_persist_write_first.a1.md.status create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__02_persist_write_first.a1.validate create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__03_fast_path_regate.a1.err create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__03_fast_path_regate.a1.md create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__03_fast_path_regate.a1.md.status create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__03_fast_path_regate.a1.validate create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__04_viewer_additive_toggle.a1.err create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__04_viewer_additive_toggle.a1.md create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__04_viewer_additive_toggle.a1.md.status create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__04_viewer_additive_toggle.a1.validate create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__05_e2e_selfheal_pins.a1.err create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__05_e2e_selfheal_pins.a1.md create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__05_e2e_selfheal_pins.a1.md.status create mode 100644 .agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__05_e2e_selfheal_pins.a1.validate create mode 100644 alembic/versions/0027_documents_page_strip_mode.py create mode 100644 tests/e2e/test_pdf_strip_additive_selfheal.py create mode 100644 tests/integration/test_migration_0027.py create mode 100644 tests/unit/test_viewer_additive_toggle.py diff --git a/.agents/phases/todo/163_pdf_strip_additive_selfheal/00_phase.md b/.agents/phases/complete/163_pdf_strip_additive_selfheal/00_phase.md similarity index 100% rename from .agents/phases/todo/163_pdf_strip_additive_selfheal/00_phase.md rename to .agents/phases/complete/163_pdf_strip_additive_selfheal/00_phase.md diff --git a/.agents/phases/todo/163_pdf_strip_additive_selfheal/01_schema_wire_strip_mode.md b/.agents/phases/complete/163_pdf_strip_additive_selfheal/01_schema_wire_strip_mode.md similarity index 100% rename from .agents/phases/todo/163_pdf_strip_additive_selfheal/01_schema_wire_strip_mode.md rename to .agents/phases/complete/163_pdf_strip_additive_selfheal/01_schema_wire_strip_mode.md diff --git a/.agents/phases/todo/163_pdf_strip_additive_selfheal/02_persist_write_first.md b/.agents/phases/complete/163_pdf_strip_additive_selfheal/02_persist_write_first.md similarity index 100% rename from .agents/phases/todo/163_pdf_strip_additive_selfheal/02_persist_write_first.md rename to .agents/phases/complete/163_pdf_strip_additive_selfheal/02_persist_write_first.md diff --git a/.agents/phases/todo/163_pdf_strip_additive_selfheal/03_fast_path_regate.md b/.agents/phases/complete/163_pdf_strip_additive_selfheal/03_fast_path_regate.md similarity index 100% rename from .agents/phases/todo/163_pdf_strip_additive_selfheal/03_fast_path_regate.md rename to .agents/phases/complete/163_pdf_strip_additive_selfheal/03_fast_path_regate.md diff --git a/.agents/phases/todo/163_pdf_strip_additive_selfheal/04_viewer_additive_toggle.md b/.agents/phases/complete/163_pdf_strip_additive_selfheal/04_viewer_additive_toggle.md similarity index 100% rename from .agents/phases/todo/163_pdf_strip_additive_selfheal/04_viewer_additive_toggle.md rename to .agents/phases/complete/163_pdf_strip_additive_selfheal/04_viewer_additive_toggle.md diff --git a/.agents/phases/todo/163_pdf_strip_additive_selfheal/05_e2e_selfheal_pins.md b/.agents/phases/complete/163_pdf_strip_additive_selfheal/05_e2e_selfheal_pins.md similarity index 100% rename from .agents/phases/todo/163_pdf_strip_additive_selfheal/05_e2e_selfheal_pins.md rename to .agents/phases/complete/163_pdf_strip_additive_selfheal/05_e2e_selfheal_pins.md diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__00_phase.a1.err b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__00_phase.a1.md b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__00_phase.a1.md new file mode 100644 index 0000000..06c8d37 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__00_phase.a1.md @@ -0,0 +1,13 @@ +All gates verified. Final report: + +**Phase 163 — final verification pass (all 5 tasks already complete; verification-only run)** + +- Verified D1–D4 in code: `page_strip_mode` column + migration 0027, `DocContent` absent-never-null rule keyed off `page_count`; `_persist_pdf_pages` render → atomic tmp+`os.replace` writes → stale-tail-last + `strip_class` stamp; `_regate_pdf_strip` on both unchanged paths (mirror gate, heal, repair, fail-soft, one INFO line only when acting); viewer mode branch + Text/Pages toggle (Text default, `aria-pressed`, no innerHTML, lazy imgs). +- `uv run pytest --cov=app --cov-report=term-missing` → **3796 tests, exit 0, coverage 99%** (>90%) +- `uv run pytest tests/e2e/test_pdf_strip_additive_selfheal.py -v --no-cov` → **5 passed** in isolation +- `test_pdf_pages_in_sources + test_pictorial_pdf + test_garbage_pdf` → 10 passed; `test_document_viewer(_context) + test_image_documents` → 20 passed +- `uv run ruff check .` clean; `uv run pyright` → 0 errors; alembic **0027 downgrade→upgrade CLI round-trip clean** (+ integration pins) +- Completion criteria: additive view w/ toggle ✅ · primary DOM byte-identity ✅ · write-first order/mid-write-failure/atomic pins ✅ · heal + repair E2E ✅ · zero vision/LLM on fast path (mock-captured) ✅ · non-qualifying payload byte-identity ✅ · docs pass ✅ +- **Documentation**: README.md + AGENTS.md reviewed in full — the phase's updates are accurate; all config-table defaults cross-checked against `app/config.py` (no drift), every page route, script, and E2E command referenced exists, every `.env.example` var documented — **verified consistent, no changes needed**. +- Notable: live `Letumdei` repair is owner-runtime post-deploy per D5 (next production Sync self-heals; no live changes made); the modified `.agents/screenshots/doc_modal_scrollbar_1280.png` is an E2E-regenerated artifact left for the pipeline commit. +- **Next pending phase:** `164_chat_error_honesty` diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__00_phase.a1.md.status b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__00_phase.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__00_phase.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__00_phase.a1.validate b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__00_phase.a1.validate new file mode 100644 index 0000000..481cc35 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__00_phase.a1.validate @@ -0,0 +1,124 @@ +........................................................................ [ 1%] +........................................................................ [ 3%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 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 277 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 204 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 136 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 454 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 841 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 78 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 156 0 100% +app/rag/retriever.py 252 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 473 0 100% +-------------------------------------------------- +TOTAL 5869 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/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__01_schema_wire_strip_mode.a1.err b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__01_schema_wire_strip_mode.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__01_schema_wire_strip_mode.a1.md b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__01_schema_wire_strip_mode.a1.md new file mode 100644 index 0000000..e5c4224 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__01_schema_wire_strip_mode.a1.md @@ -0,0 +1,21 @@ +All green. Final state: + +**Implemented (task 01 — phase 163 D1, LOCKED A2/A3)** +- `app/models.py`: `Document.page_strip_mode` (VARCHAR(16), nullable, no server default, unindexed) right after `page_count`, docstringed as the strip CLASS (`primary` = fallback class, `additive` = pictorial class, NULL = no strip). +- `alembic/versions/0027_documents_page_strip_mode.py`: additive add/drop migration mirroring 0026; `upgrade head → downgrade -1 → upgrade head` round-trips clean (DB now at `0027 (head)`). +- `app/schemas.py`: `DocContent.page_strip_mode` + the `image_url`-style omission in the existing `model_serializer` (ABSENT, never `null`). +- `app/api/docs.py`: content payload passes `page_strip_mode=doc.page_strip_mode if doc.page_count is not None else None` — the whole strip block keys off `page_count`, so a stray mode never travels alone; `/pages/{n}` untouched; endpoint docstring + README "Which kind of strip it is (phase 163)" section updated. No AGENTS.md change (its viewer bullets are the final pass's). +- Tests: `tests/integration/test_migration_0027.py` (new, house pattern: contract + pre-phase `page_count=36`/mode-NULL backfill + ORM round-trip both classes + tested downgrade + round-trip), `tests/unit/test_models.py` (column contract + ORM defaults), `tests/unit/test_document_viewer.py` (model omission rule; endpoint: stamped primary/additive, pre-phase mode-NULL row → key absent with `page_urls` kept, stray mode + `page_count` NULL → neither key, exact non-qualifying key-set), `tests/unit/test_pdf_page_copies.py` (importer leaves the class unstamped until task 02), `tests/integration/test_docs_api.py` (`_seed_page_doc` gains `page_strip_mode`; real-DB class pins + never-alone + exact key set). + +**Gates** +- `uv run pytest` → **3772 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **3772 passed, app TOTAL 99%** (`app/schemas.py` 100%). +- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors. +- `uv run alembic upgrade head && downgrade -1 && upgrade head` → clean, `current` = `0027 (head)`. +- E2E regression (isolation, `--no-cov`): `test_pdf_pages_in_sources.py` 4 passed, `test_pictorial_pdf.py`+`test_garbage_pdf.py` 6 passed, `test_smoke.py` 3 passed. + +**Notes / decisions** +- Non-qualifying payloads stay byte-identical (both strip keys dropped when `page_count` is NULL); pre-phase rows serialize the class ABSENT and the viewer will read that as `primary` (task 04) — pinned, no data backfill. +- Column carries no CHECK constraint (purely additive per A13); the class values are enforced by the writers (tasks 02/03). +- No live-infrastructure changes needed for this task. + +**Next pending task:** `163_pdf_strip_additive_selfheal/02_persist_write_first.md`. diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__01_schema_wire_strip_mode.a1.md.status b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__01_schema_wire_strip_mode.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__01_schema_wire_strip_mode.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__01_schema_wire_strip_mode.a1.validate b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__01_schema_wire_strip_mode.a1.validate new file mode 100644 index 0000000..9a8ee95 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__01_schema_wire_strip_mode.a1.validate @@ -0,0 +1,124 @@ +........................................................................ [ 1%] +........................................................................ [ 3%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 83%] +........................................................................ [ 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 277 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 204 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 136 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 454 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 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 78 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 156 0 100% +app/rag/retriever.py 252 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 473 0 100% +-------------------------------------------------- +TOTAL 5792 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/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__02_persist_write_first.a1.err b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__02_persist_write_first.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__02_persist_write_first.a1.md b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__02_persist_write_first.a1.md new file mode 100644 index 0000000..1497282 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__02_persist_write_first.a1.md @@ -0,0 +1,13 @@ +**Task 02 complete — `_persist_pdf_pages` write-first atomic persist + `strip_class` stamp (D2, A4/A7).** + +- `app/rag/importer.py`: `_persist_pdf_pages` rewritten — render → per-file atomic write (`.png.tmp` + `os.replace`, never a direct final-path write) → stale-tail delete LAST (only old-extent files not rewritten; a file about to be overwritten is never deleted); failure path unlinks this call's `.tmp`s and files it **created** (an overwritten copy keeps standing — deleting it would re-open the live-loss hole); `strip_class="fallback"|"pictorial"|None` replaces `eligible`, stamping `page_strip_mode` `primary`/`additive` exactly with `page_count`; the None arm and no-pages guard delete-then-null (helper reads the count); module + function docstrings carry the write-first rationale and the verbatim phase-148 truth table. +- Call site (`_index_pdf_file`): `strip_class = "fallback" if fallback_fired else ("pictorial" if pictorial_described else None)`; fail-soft handler nulls `page_strip_mode` too; comments updated. +- `tests/unit/test_pdf_page_copies.py`: re-cut task-01 pin → additive stamp; fallback→`primary`, ineligible/qualifier-loss/no-pages→NULL pins; new pins — order (event log: all `os.replace`s strictly before the first stale `unlink`, new bytes win), mid-write failure (prior 10-file set intact, zero `.tmp` orphans, count/mode NULL, doc indexed), atomic tmp+replace writer (every write to `.png.tmp`, `dst`/`src` pairing), best-effort stale-tail OSError branch; writer-exception test re-cut for tmp semantics. + +Gates: `uv run pytest --cov=app --cov-report=term-missing` → **3776 passed, app coverage 99%** (importer 99%; remaining misses pre-existing); `uv run ruff check . && uv run pyright` → clean; E2E `test_pdf_pages_in_sources` (4), `test_pictorial_pdf` (3), `test_garbage_pdf` (3) all pass in isolation. + +Docs: no further doc impact — README's phase-163 strip-class section (task 01) already covers the user-visible contract; deletion ordering is internal. + +Notable decision: the partial-write cleanup keeps an **overwritten** final (keeps only newly-created files) — strictly satisfies "a failed call never leaves more files than before, and never removes a live copy" (LOCKED A4). + +Next pending task: `163_pdf_strip_additive_selfheal/03_fast_path_regate.md`. diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__02_persist_write_first.a1.md.status b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__02_persist_write_first.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__02_persist_write_first.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__02_persist_write_first.a1.validate b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__02_persist_write_first.a1.validate new file mode 100644 index 0000000..97bdfb6 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__02_persist_write_first.a1.validate @@ -0,0 +1,124 @@ +........................................................................ [ 1%] +........................................................................ [ 3%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 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 277 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 204 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 136 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 454 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 788 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 78 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 156 0 100% +app/rag/retriever.py 252 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 473 0 100% +-------------------------------------------------- +TOTAL 5816 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/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__03_fast_path_regate.a1.err b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__03_fast_path_regate.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__03_fast_path_regate.a1.md b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__03_fast_path_regate.a1.md new file mode 100644 index 0000000..2273c45 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__03_fast_path_regate.a1.md @@ -0,0 +1,10 @@ +**Task 03 complete — all green.** + +- **`app/rag/importer.py`**: new `_regate_pdf_strip(session, doc, *, full_path, llm, summary)` — mirrors the changed-path gate verbatim from current bytes + settings (phase-133 `is_usable_pdf_text` → phase-140 `pictorial_pages` + `pdf_pictorial` kill switch), **zero LLM/vision calls**; heals any missing `-p{N}.png` (full re-render), stamps `page_strip_mode` (silent NULL→stamp, `mode old->new` on a set mode), clears a strip that no longer qualifies + deletes its copies, one greppable INFO only when it **acts**, whole body fail-soft (`re-gate failed` / `re-render produced no pages (state kept)` / `page_count drift a->b` warnings). +- `_unchanged_doc` gained `full_path: Path | None = None`; the **guarded** re-gate (`page_count` set + `.pdf`) runs at **both** exits (incl. the D1 manual-date return) and commits what it wrote; all 4 call sites (fast path, text/PDF/image content-digest hits) pass their `full_path`. Docstrings: the phase-147 "the copies persist" assumption is replaced (module doc + `_unchanged_doc` step 7 + `_index_pdf_file`). +- De-duplicated task 02's writer: extracted `_write_page_copies` (tmp + `os.replace`, orphan cleanup) and `_delete_stale_page_tail`; `_persist_pdf_pages` and the heal now share them (behavior unchanged). +- **Tests**: 11 new pins in `tests/unit/test_importer_unchanged_fast_path.py` — heal (pictorial, live NULL-mode), fallback heal (`primary`), healthy no-op (zero writes, zero re-gate log), mode re-stamp, repair arm, kill-switch clear, render-raise fail-soft, empty-render keeps row, count drift, content-digest-hit path, non-strip zero-parse guard (spy). +- `uv run pytest tests/unit/test_importer_unchanged_fast_path.py tests/unit/test_pdf_page_copies.py --no-cov` → exit 0 (24 + 15 tests); `uv run pytest --cov=app` → **3787 passed**, `app/rag/importer.py` **99%** (only pre-existing misses: 883, 3506-3507, 3793), TOTAL 99%; `uv run ruff check .` → clean; `uv run pyright` → **0 errors**; PDF E2E files pass in isolation (`test_pdf_pages_in_sources`, `test_pictorial_pdf`, `test_garbage_pdf`, `test_sync_changed_only`, exit 0). +- **Docs**: README — new "The strip repairs itself (phase 163)" viewer bullet, the "Updating the documents" fast-path qualifier, and the `BOR_PDF_PAGE_DIR` env-row note (dedup avoided). **AGENTS.md: no change** (per the phase overview its viewer bullet lands in the phase's final pass). +- **Notable**: (1) running all of `tests/e2e` in one process fails on a **pre-existing** design artifact — `tests/e2e/test_extensionless_import.py:70` mutates `os.environ["BOR_IMPORT_EXTENSIONS"]` at import, so other suites' 13-doc seed drops to 5; unaffected by this task, and the project's gate is per-file isolation (flagging for the owner/final pass). (2) Restored `.agents/screenshots/doc_modal_scrollbar_1280.png` (a tracked artifact my E2E runs overwrote) from HEAD so the phase commit stays clean. +- **Next pending task**: `163_pdf_strip_additive_selfheal/04_viewer_additive_toggle.md`. diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__03_fast_path_regate.a1.md.status b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__03_fast_path_regate.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__03_fast_path_regate.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__03_fast_path_regate.a1.validate b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__03_fast_path_regate.a1.validate new file mode 100644 index 0000000..9940158 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__03_fast_path_regate.a1.validate @@ -0,0 +1,124 @@ +........................................................................ [ 1%] +........................................................................ [ 3%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 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 277 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 204 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 136 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 454 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 841 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 78 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 156 0 100% +app/rag/retriever.py 252 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 473 0 100% +-------------------------------------------------- +TOTAL 5869 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/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__04_viewer_additive_toggle.a1.err b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__04_viewer_additive_toggle.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__04_viewer_additive_toggle.a1.md b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__04_viewer_additive_toggle.a1.md new file mode 100644 index 0000000..304c2ac --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__04_viewer_additive_toggle.a1.md @@ -0,0 +1,18 @@ +**Task 04 complete — report** + +**Implemented** +- `frontend/assets/document.js`: `renderDocument` now branches on `doc.page_strip_mode` — absent/`"primary"` keeps the phase-148 strip-only branch verbatim (byte-identical); `"additive"` renders the text doc (Summary panel + content) inside a `.doc-views` wrapper with a `Text`/`Pages` toggle (`role=group`, `aria-pressed`, Text default per LOCKED A6); strip mounts once, hidden via CSS `data-view` swap (lazy imgs never fetch while hidden, no refetch on flip-flop); never-build-HTML rule kept (module still has exactly one `innerHTML`); module docstring gained the phase-163 paragraph and re-scoped the phase-148 "INSTEAD of" sentence to the primary class. +- `frontend/assets/styles.css`: `.doc-view-toggle` ghost-pill pair (house `.auth-link`/`.share-chat-btn` idiom, chat-column cap, ≥44px targets, global 3px focus ring, pressed = brand fill + weight 700 — text + style, never color alone) + `.doc-views[data-view]` show/hide rules. +- New `tests/unit/test_viewer_additive_toggle.py`: 9 pins (source-contract + node behavioral runs of the real core: additive shape, primary/absent byte-identity, text-doc untouched, flip semantics with strip-node persistence, lazy imgs, XSS, CSS contract). +- Re-cut stale/newly-affected pins: `test_summary_edit_ui.py` (slot mount), `test_wide_column_css.py` (7th reading-column selector), E2E additive-view contracts in `test_pdf_pages_in_sources.py` and `test_pictorial_pdf.py` (text-first default, Pages flip reveals loaded strip, description rides the shown text). + +**Gates** +- `uv run pytest` → **3796 passed**, 0 failed; coverage `app/` **99%** (>90%). +- `uv run ruff check .` → clean; `uv run pyright` → 0 errors. +- E2E in isolation: `test_pdf_pages_in_sources.py` 4✓, `test_pictorial_pdf.py` 3✓, `test_garbage_pdf.py` 3✓, plus `test_pdf_support.py` 6✓, `test_document_viewer.py` 8✓, `test_summary_in_viewer.py` 4✓. + +**Docs**: README — phase-148 viewer bullet scope-fixed to "primary class only" (the phase-163 bullets from tasks 01–03 already describe the toggle); AGENTS.md — no doc impact (viewer bullet note is reserved for the phase final pass). + +**Decisions**: additive E2E witnesses flipped from "descriptions not displayed" to "descriptions ride the shown text" (intended consequence of LOCKED A1/A6); toggle lives in a `.doc-views` wrapper only on additive docs — all other DOM untouched. + +**Next pending task**: `163_pdf_strip_additive_selfheal/05_e2e_selfheal_pins.md`. diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__04_viewer_additive_toggle.a1.md.status b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__04_viewer_additive_toggle.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__04_viewer_additive_toggle.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__04_viewer_additive_toggle.a1.validate b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__04_viewer_additive_toggle.a1.validate new file mode 100644 index 0000000..481cc35 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__04_viewer_additive_toggle.a1.validate @@ -0,0 +1,124 @@ +........................................................................ [ 1%] +........................................................................ [ 3%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 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 277 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 204 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 136 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 454 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 841 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 78 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 156 0 100% +app/rag/retriever.py 252 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 473 0 100% +-------------------------------------------------- +TOTAL 5869 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/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__05_e2e_selfheal_pins.a1.err b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__05_e2e_selfheal_pins.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__05_e2e_selfheal_pins.a1.md b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__05_e2e_selfheal_pins.a1.md new file mode 100644 index 0000000..98adf62 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__05_e2e_selfheal_pins.a1.md @@ -0,0 +1,16 @@ +**Task 05 complete — report** + +**Implemented** +- New `tests/e2e/test_pdf_strip_additive_selfheal.py` (5 tests, phase-163 A16 suite): fixture `strip_fixture` = `manual.pdf` (3 dense pages + 1 sparse map page → additive), `scanned.pdf` (XML-garbage layer → primary), `plain.pdf` (no strip); asserts 1 additive view (payload stamp, Text default, Pages flip loads all 4 imgs, flip-back keeps the mounted strip node — dataset-probed, zero refetch); 2 primary/plain DOM byte-identity incl. the key-omission payload pins; 3 heal (wipe copies → route 404 → one unchanged-byte sync → files restored, route 200, viewer complete); 4 repair (direct-DB stale `page_count`/mode + fake copies → cleared + deleted); 5 zero-LLM (mock `/v1/e2e/captured`: index burned both vision passes, heal/repair captured zero content calls — the sync prelude's fixed `ping` health probe is the one documented exemption). +- Docs pass: README verified in full (viewer + importer + env-table claims cross-checked against code; defaults script-audited vs `Settings` — no drift); AGENTS.md rule 5 gained the phase-163 viewer-class note. + +**Gates** (all run this session, DB up) +- `uv run pytest tests/e2e/test_pdf_strip_additive_selfheal.py -v --no-cov` → **5 passed** in isolation. +- `test_pdf_pages_in_sources / test_pictorial_pdf / test_garbage_pdf / test_document_viewer / test_document_viewer_context / test_image_documents` → all exit 0, unmodified. +- `uv run pytest --cov=app --cov-report=term-missing` → **3796 passed**, coverage **99%** (>90%); `uv run ruff check .` clean; `uv run pyright` 0 errors; alembic **0027 downgrade→upgrade round-trip clean**. + +**Docs**: README — no further edits needed (phase bullets from tasks 01–03 verified accurate); AGENTS.md — viewer-class note added. + +**Decisions**: heal/repair zero-call pins exempt only `check_models`' `ping` (every sync pays it, predates the phase); anything else fails the pin. Pre-existing `.agents/screenshots/doc_modal_scrollbar_1280.png` modification (earlier E2E run) left for the pipeline commit. + +**Next pending task**: `164_chat_error_honesty/01_caps_long_answer_chain.md`. diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__05_e2e_selfheal_pins.a1.md.status b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__05_e2e_selfheal_pins.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__05_e2e_selfheal_pins.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__05_e2e_selfheal_pins.a1.validate b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__05_e2e_selfheal_pins.a1.validate new file mode 100644 index 0000000..481cc35 --- /dev/null +++ b/.agents/reports/163_pdf_strip_additive_selfheal/163_pdf_strip_additive_selfheal__05_e2e_selfheal_pins.a1.validate @@ -0,0 +1,124 @@ +........................................................................ [ 1%] +........................................................................ [ 3%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 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 277 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 204 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 136 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 454 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 841 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 78 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 156 0 100% +app/rag/retriever.py 252 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 473 0 100% +-------------------------------------------------- +TOTAL 5869 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 a719bc1b919c1cf9fb35e7ea8223f3e29817b0f7..d438404338db1043742efbb90d6b893d5ef8a8d7 100644 GIT binary patch literal 43743 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≥2bLV#QjmT%UYT#vin}O` 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 diff --git a/AGENTS.md b/AGENTS.md index 14e2f27..c46e2fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,11 @@ panels — `max-height: min(70vh, 64rem)` — with a sticky header row; a clipped action label shows an ellipsis hint (`.cell-clip`) with the full text kept reachable via the control's aria-label - (phase 156)) and + (phase 156); the document viewer's page strip is CLASS-scoped — + the primary class shows the strip in place of the text, the + additive class never hides the text: Text/Pages toggle group + (`aria-pressed`, Text default) with the strip behind it + (phase 163)) and meets WCAG 2.1 AA basics (semantic landmarks, labels, contrast ≥4.5:1, focus-visible, aria-live for the stream). diff --git a/README.md b/README.md index a900e0a..6564ccb 100644 --- a/README.md +++ b/README.md @@ -239,9 +239,11 @@ idempotent and delta-based: each file's RAW BYTES are hashed (sha256, streamed) and checked against the last index's stamp *before* any extraction or vision work — an unchanged file costs one hash + one row lookup (no re-extract, no vision, no re-embed), while a changed file -re-indexes fully. A refresh after a normal editing session takes -seconds — pictorial PDFs included (their expensive first-index vision -burns only once per file version): +re-indexes fully. The one exception is a PDF row that still claims a +page strip: it is re-gated from its current bytes (no vision, no LLM — +see "The strip repairs itself" below). A refresh after a normal editing +session takes seconds — pictorial PDFs included (their expensive +first-index vision burns only once per file version): ```bash # After editing/adding/removing notes: @@ -844,11 +846,42 @@ uploaded archives): shows the PAGES: a vertical strip of lazy page images with "Page N" captions, in place of the text content and the Summary panel (the per-page descriptions stay in the DB for Q&A — the - embeddings and summary are untouched, just not displayed). A lost + embeddings and summary are untouched, just not displayed). Since + phase 163 this strip-only shape is the PRIMARY class only — the + pictorial class keeps its text behind a Text/Pages toggle, per the + next bullet. A lost copy shows a "Page N unavailable" note for that page only. Text- heavy PDFs (usable layer, zero pictorial candidates — and the whole sparse class while `BOR_PDF_PICTORIAL=0`) keep the text view, byte-identical to pre-phase. +- **Which kind of strip it is (phase 163).** The two qualifying classes + are not the same problem: the screenshot class has no usable text to + show, while a book with one sparse page (an illustration map page in a + 36-page text) does. The document row therefore records the strip's + CLASS beside the page count (`documents.page_strip_mode` — + `primary` = the screenshots ARE the content, so the strip is the view; + `additive` = a usable text layer, so the text + Summary view stays and + the pages sit behind a Text/Pages toggle), and the document-content + payload carries it as `page_strip_mode`. The key is ABSENT — never + `null` — when there is no strip, which is also how a row written before + this phase serializes; the viewer treats an absent class as `primary`, + so every existing document renders exactly as before until the next + sync stamps it (the sync's self-heal pass, phase 163). +- **The strip repairs itself (phase 163).** An unchanged PDF is not + assumed to still have its page images on disk. On every sync, a row + that claims a strip is re-gated deterministically from the current + file bytes and the current settings — the same gate the changed path + uses (`BOR_PDF_PICTORIAL` and `BOR_PDF_PICTORIAL_MIN_CHARS` + included), with **zero vision or LLM calls** (rendering is CPU-only): + the class is re-stamped, any `-p{N}.png` missing from + `BOR_PDF_PAGE_DIR` is re-rendered (write-before-delete, atomically per + file), and a strip that no longer qualifies — the kill switch turned + off, the sparse-page bar lowered past it — is cleared along with its + copies. So a wiped pages directory, or a copy set lost to an + interrupted sync, repairs itself on the next sync instead of leaving + "Page N unavailable" forever. It logs one `PDF strip re-gate …` line + only when it actually acts, and a failure there never fails the sync. + PDFs without a strip pay nothing extra (no extra read at all). --- @@ -975,7 +1008,7 @@ to `.env` and adjust. | `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_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. Clearing the directory is self-healing — the next sync re-gates any strip that still claims pages and re-renders what is missing, with zero vision calls (phase 163) | | `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 | | `BOR_UPLOAD_DIR` | `~/bor-sources/uploads` | Where uploaded source archives are unpacked | diff --git a/alembic/versions/0027_documents_page_strip_mode.py b/alembic/versions/0027_documents_page_strip_mode.py new file mode 100644 index 0000000..6791d06 --- /dev/null +++ b/alembic/versions/0027_documents_page_strip_mode.py @@ -0,0 +1,68 @@ +"""documents.page_strip_mode: the strip CLASS behind page_count (phase 163, task 01) + +Revision ID: 0027 +Revises: 0026 +Create Date: 2026-10-08 + +Phase 163 (the PDF page strip is ADDITIVE for a text-bearing book — +task 01, storage only): + +* ``documents.page_strip_mode`` — VARCHAR(16) NULLABLE, NO server + default, NO index: the CLASS of the page strip a non-NULL + ``page_count`` describes, so the viewer knows whether the strip + REPLACES the text or sits one toggle behind it. + + ``"primary"`` — the phase-133 FALLBACK class (an unusable text layer: + the screenshots ARE the content), i.e. exactly the pre-phase view: the + strip is the document's view and no text is shown. ``"additive"`` — + the phase-140 PICTORIAL class (a usable text layer with ≥1 sparse page + described): the document keeps its text view and its Summary panel, + and the strip is one Text/Pages toggle away. + + NULL means NO strip (every non-qualifying document — text documents, + images, text-heavy PDFs — and every qualifying PDF whose render/write + failed). A row written before this phase keeps NULL while its + ``page_count`` stands; the wire omits the key in that case and the + viewer treats an absent mode as ``"primary"`` (today's DOM), so + existing rows render exactly as before until the fast-path re-gate + (task 03) stamps them. Stamped and cleared EXACTLY with + ``page_count`` — the importer's persist (task 02) and the unchanged- + path re-gate (task 03) are the only writers. + + A display fact, never a query surface, like the 0026 column it + accompanies: the content endpoint reads it from the ROW after the + ``(source, path)`` lookup, so it carries no index of its own (the + ``file_hash`` convention, 0025). + +One additive, fully reversible migration (A13); no other schema change. +The ordering of the copies on disk (task 02) and the re-gate itself +(task 03) are importer code — this revision only carries the column. +""" +from __future__ import annotations + +import sqlalchemy as sa + +from alembic import op + +revision = "0027" +down_revision = "0026" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # No index (a display fact, never a query surface — the content + # endpoint reads it from the row) and no server default (NULL is the + # meaningful state for every non-qualifying document, every fail-soft + # miss, and every pre-phase row). + op.add_column( + "documents", sa.Column("page_strip_mode", sa.String(length=16), nullable=True) + ) + + +def downgrade() -> None: + # The page_strip_mode column is the only 0027 artefact — dropping it + # leaves 0026's schema byte-identical (A13, fully reversible). The + # class is re-derivable from the source file on the next sync (the + # deterministic re-gate of task 03), so no data is lost by the drop. + op.drop_column("documents", "page_strip_mode") diff --git a/app/api/docs.py b/app/api/docs.py index c75848f..ad3da4f 100644 --- a/app/api/docs.py +++ b/app/api/docs.py @@ -206,6 +206,16 @@ def get_document_content( basename matches). ``None`` — a non-git source, no row, or an ambiguous basename — keeps the payload byte-identical to pre-phase (both fields are dropped by the serializer, never ``null``). + + Phase 163 (task 01): a page-qualifying PDF's payload additionally + carries ``page_strip_mode`` — ``"primary"`` (the phase-133 fallback + class: the strip IS the view) or ``"additive"`` (the phase-140 + pictorial class: the text + Summary view with the strip behind a + Text/Pages toggle). Both strip keys are built off ``page_count`` and + dropped when it is NULL, so every non-qualifying payload stays + byte-identical to pre-phase (and a pre-phase row, whose mode column + is NULL, keeps the strip-only DOM the viewer assumes for an absent + mode). """ row = db.execute( select(Document, func.count(Chunk.id).label("chunks")) @@ -254,6 +264,16 @@ def get_document_content( if doc.page_count is not None else None ), + # Phase 163 (task 01): the strip CLASS (``"primary"`` = the + # fallback class, the strip IS the view; ``"additive"`` = the + # pictorial class, text + a Text/Pages toggle). The whole strip + # block keys off ``page_count`` — a row with a stray mode but no + # strip serializes NEITHER key — and a pre-phase row (mode NULL) + # serializes the key ABSENT (never null), which the viewer reads + # as ``"primary"`` (task 04). + page_strip_mode=( + doc.page_strip_mode if doc.page_count is not None else None + ), ) diff --git a/app/models.py b/app/models.py index 4b5b455..0d099d1 100644 --- a/app/models.py +++ b/app/models.py @@ -198,6 +198,24 @@ class Document(Base): #: cleanup deletes the copies with the row. No index: a display #: fact, never a query surface (the ``file_hash`` convention, 0025). page_count: Mapped[int | None] = mapped_column(Integer, default=None) + #: The STRIP CLASS behind a non-NULL :py:attr:`page_count` (phase 163, + #: LOCKED A1/A3) — the signal the viewer branches on so a text-bearing + #: book never loses its text: + #: + #: * ``"primary"`` — the phase-133 FALLBACK class (unusable text layer: + #: the screenshots ARE the content): the strip IS the view, exactly + #: as phase 148 shipped it; + #: * ``"additive"`` — the phase-140 PICTORIAL class (usable text layer, + #: ≥1 sparse page described): the text view + the Summary panel, with + #: the strip one Text/Pages toggle away (Text is the default). + #: + #: NULL = no strip (every non-qualifying document) — and a pre-phase-163 + #: row keeps NULL while its ``page_count`` stands, which the wire treats + #: as ``"primary"`` (today's DOM) until the fast-path re-gate stamps it. + #: Stamped/cleared EXACTLY with ``page_count``: written by the importer's + #: persist (task 02) and the unchanged-path re-gate (task 03) only. Like + #: ``page_count`` it is a display fact, never a query surface — no index. + page_strip_mode: Mapped[str | None] = mapped_column(String(16), default=None) chunks: Mapped[list[Chunk]] = relationship( back_populates="document", cascade="all, delete-orphan" diff --git a/app/rag/importer.py b/app/rag/importer.py index ce39042..07e88f6 100644 --- a/app/rag/importer.py +++ b/app/rag/importer.py @@ -190,15 +190,37 @@ dpi, every page) and persisted to ``settings.pdf_page_dir`` ``-p{N}.png`` (N 1-based), with ``documents.page_count`` (migration 0026) recording the page count — a per-page render failure leaves a GAP (no file — the route 404s that N, the viewer's -note covers it). The existing copies are deleted before replacement -(a shrunk PDF's old ``p{N}`` tail cannot leak), a document that -STOPS qualifying is cleared (``page_count`` NULL + copies deleted), -and the prune deletes the copies with the row +note covers it). WRITE-FIRST (phase 163, task 02, LOCKED A4): the +fresh render's bytes land ATOMICALLY per file (``.png.tmp`` + +``os.replace``) BEFORE any old copy is removed — only the old-extent +files the fresh render did NOT write (a shrunk PDF's ``p{N}`` tail) +go, so a rollback or crash can never restore a stale ``page_count`` +over ZERO files. The row is stamped with the strip's CLASS beside the +count (``documents.page_strip_mode``, migration 0027 — ``primary`` for +the fallback class, ``additive`` for the pictorial class, phase 163); +a document that STOPS qualifying is cleared (both columns NULL + +copies deleted — deletion-first is correct where there is no +replacement set), and the prune deletes the copies with the row (:func:`_delete_page_copies`). FAIL-SOFT: a render or write failure -leaves ``page_count`` NULL and logs a warning — the document indexes -EXACTLY as pre-phase (the render is a display concern, it never -gates the indexing). The UNCHANGED fast path renders nothing (the -copies persist — zero render work, zero orphans). +leaves ``page_count``/``page_strip_mode`` NULL and logs a warning — +the document indexes EXACTLY as pre-phase (the render is a display +concern, it never gates the indexing). + +PDF strip re-gate (phase 163, task 03, LOCKED A5): the UNCHANGED paths +never assume "the copies persist" any more — a row that still claims a +strip (``page_count`` set, a ``.pdf`` path) is re-gated +deterministically from the CURRENT bytes + CURRENT settings +(:func:`_regate_pdf_strip`, run at the tail of +:func:`_unchanged_doc`): the class is re-stamped (``primary`` for the +unusable-layer fallback class, ``additive`` for the sparse-page +class), any missing ``-p{N}.png`` is RE-RENDERED through the +same write-first atomic writer, and a strip that no longer qualifies +(lowered ``pdf_pictorial_min_chars``, the kill switch off) is cleared +with its copies. ZERO vision/LLM calls (the render is pure CPU, PLAN +A21-safe), one greppable INFO line only when the re-gate ACTS, and +fail-soft on every exception — the loss state (an external wipe, a +pre-phase delete-first rollback) cannot survive one sync. Every other +file's fast path still performs ZERO PDF parsing (the guard). Image-anchor context (phase 125, task 03, LOCKED A1/A2/A3): BEFORE the description, the same source's importable markdown files are scanned for @@ -298,6 +320,7 @@ from __future__ import annotations import hashlib import logging +import os import posixpath import re import uuid @@ -1021,6 +1044,7 @@ async def _unchanged_doc( summary: ImportSummary, raw_date: datetime, file_digest: str, + full_path: Path | None = None, ) -> None: """Phase 147 (task 02): the ONE shared unchanged path — the three branches' near-identical unchanged blocks, one code site, reached @@ -1064,7 +1088,22 @@ async def _unchanged_doc( the date block (the stamp set ``file_hash`` and the date did not change): quiet bookkeeping — no count, no log (the ``unchanged`` line already fired; a date-change run already - committed in step 5). + committed in step 5); + 7. the phase-163 (task 03) PDF strip RE-GATE — a row that still + claims a strip (``page_count`` set) whose path ends in ``.pdf`` + goes through :func:`_regate_pdf_strip`: the strip is re-derived + from the CURRENT bytes + CURRENT settings (deterministic, ZERO + LLM/vision calls) — a missing/changed ``page_strip_mode`` is + re-stamped, copies that vanished from disk are RE-RENDERED, a + strip that no longer qualifies is cleared — and whatever it + wrote commits in this helper's transaction. Unchanged files + re-gate the PDF strip deterministically (phase 163): the + pre-phase "the copies persist" assumption is gone, so a wipe of + ``pdf_page_dir`` or a pre-phase delete-first rollback cannot + outlive one sync. Every other file (``page_count`` NULL) pays + ZERO extra read — the guard keeps the fast path's contract, and + BOTH exits (the D1 manual-date return and the normal tail) run + the re-gate. """ summary.unchanged += 1 logger.info("import: unchanged source=%s path=%s", source, rel) @@ -1078,10 +1117,32 @@ async def _unchanged_doc( session, doc=doc, source=source, rel=rel, content=doc.content, llm=llm, summary=summary, backfill=True, ) + + def _regate_strip() -> None: + """Step 7 (phase 163, task 03): the GUARDED strip re-gate — only + a row that carries a strip (``page_count`` set) on a ``.pdf`` + path reaches :func:`_regate_pdf_strip`, so every other file's + fast path stays byte-identical (zero extra read, zero PDF + parse). The re-gate never raises (fail-soft inside); its writes + ride this helper's transaction.""" + if doc.page_count is None or not str(doc.path).lower().endswith(".pdf"): + return + _regate_pdf_strip( + session, + doc, + full_path=full_path if full_path is not None else Path(doc.full_path), + llm=llm, + summary=summary, + ) + if session.is_modified(doc): + session.commit() + # 4 — the phase-106 (D1) manual-date lock: no date write; the # stamp, if dirty, is the only write (committed — see step 2's - # "next sync takes the fast path"). + # "next sync takes the fast path") — plus whatever the phase-163 + # re-gate repaired. if doc.created_at_manual: + _regate_strip() if session.is_modified(doc): session.commit() return @@ -1103,6 +1164,165 @@ async def _unchanged_doc( # 6 — the stamp's commit (the date did not move): quiet # bookkeeping — the ``unchanged`` line already fired. session.commit() + # 7 — the phase-163 (task 03) strip re-gate (see the local helper). + _regate_strip() + + +def _regate_pdf_strip( + session: Session, + doc: Document, + *, + full_path: Path, + llm: Embedder, + summary: ImportSummary, +) -> None: + """Phase 163 (task 03, LOCKED A5): the DETERMINISTIC re-gate of a + PDF's display strip — run from the unchanged paths + (:func:`_unchanged_doc`) whenever a row still claims a strip + (``page_count`` set), so a strip can never die silently. + + WHY (the 2026-10-07 live state, owner report): the ``Letumdei`` row + carried ``page_count = 36`` over ZERO files — the pre-phase-163 + :func:`_persist_pdf_pages` deleted the old copies BEFORE rendering, + OUTSIDE the DB transaction, so a later per-file rollback (the + import loop's ``EmbeddingError`` handler) restored the stale count + over an empty directory; an external wipe of ``pdf_page_dir`` lands + exactly the same state. The phase-147 fast path believed "the + copies persist" and never re-rendered, so every + ``GET /api/documents//pages/N`` 404'd and the viewer showed + "Page N unavailable." on all 36 pages — on every sync, forever. + + WHAT IT DOES — the changed-path gate mirrored exactly, from the + CURRENT file bytes + CURRENT settings, with ZERO vision/LLM calls + (:func:`app.rag.pdf.render_pdf_pages` is pure CPU — PLAN A21-safe, + no model, no budget): + + * ``not is_usable_pdf_text(extract_pdf_text(full_path))`` → the + phase-133 fallback class → mode ``"primary"`` (the strip IS the + view — the same doc as today); + * else ``settings.pdf_pictorial`` ON and + :func:`app.rag.pdf.pictorial_pages` NON-EMPTY over + :func:`app.rag.pdf.extract_pdf_page_texts` → the phase-140 + pictorial class → mode ``"additive"`` (the text view + the strip + behind a toggle). The candidates are the existing gate's + VERBATIM (text-carrying pages under ``min_chars``; a page with no + text has no key in the map and is not a candidate) — the re-gate + adds NO new eligibility semantics; + * else the strip no longer qualifies (the owner lowered + ``pdf_pictorial_min_chars``, the kill switch went off, the layer + gained substance) → the REPAIR arm: the copies go + (``_delete_page_copies`` reads ``doc.page_count``, so it runs + while the count still describes the extent) and BOTH columns are + cleared. + + For a re-gated-ELIGIBLE doc the copies are VERIFIED: any missing + ``-p{N}.png`` in 1..``page_count`` re-renders EVERY page + through the changed path's own write-first atomic writer + (:func:`_write_page_copies` — tmp + ``os.replace``), then the + stale tail beyond the new count goes and ``page_count`` follows the + fresh render's length (a drift names both counts in a WARNING — on + unchanged bytes a drift is pathological). A fresh render producing + NO pages (unopenable right after the fast path hashed the file) + leaves the row exactly as it was + a WARNING: the re-gate never + clears a strip on a transient. + + The mode re-stamp is SILENT when it only fills a NULL (a pre-phase + row learning its class is beneficial bookkeeping — it rides the + same INFO line only when real work followed); a change of an + ALREADY-SET mode (the kill switch, a class flip) says so + (``mode ->``). The ONE greppable INFO line fires only + when the re-gate ACTS — never per unchanged doc. + + FAIL-SOFT: the whole body is wrapped — any exception is a WARNING + and the sync continues (the strip is a display concern). A repair's + DB writes ride the caller's transaction, so an exception raised + after them rolls back with the file like any other per-file + failure. *summary* keeps the unchanged path's signature shape (and + a future counter site); the re-gate books nothing there — its stats + are log-only (the phase-133/140 convention). + """ + source, rel = doc.source, doc.path + try: + page_dir = Path(llm.settings.pdf_page_dir).expanduser() + # The gate, mirrored from the changed path (the phase-133 + # precedence over the phase-140 pass, the kill switch included). + if not is_usable_pdf_text(extract_pdf_text(full_path)): + strip_class: str | None = "fallback" + elif llm.settings.pdf_pictorial and pictorial_pages( + extract_pdf_page_texts(full_path), + min_chars=llm.settings.pdf_pictorial_min_chars, + ): + strip_class = "pictorial" + else: + strip_class = None + if strip_class is None: + # The REPAIR arm — the strip no longer qualifies: delete + # while the count still names the extent, then clear both + # columns (the same clearing the changed path does for a + # doc that stopped qualifying). + _delete_page_copies(doc, page_dir) + doc.page_count = None + doc.page_strip_mode = None + logger.info( + "import: PDF strip re-gate source=%s path=%s — cleared stale strip", + source, rel, + ) + return + mode = "primary" if strip_class == "fallback" else "additive" + old_mode = doc.page_strip_mode + old_count = doc.page_count or 0 + actions: list[str] = [] + missing = [ + n + for n in range(1, old_count + 1) + if not (page_dir / f"{doc.id}-p{n}.png").is_file() + ] + if missing: + pages = render_pdf_pages(full_path) + if not pages or not any(pages): + # Unopenable (or every page a placeholder) — right + # after the fast path hashed the file this is + # pathological: KEEP the row as-is (never clear a strip + # on a transient) and let the next sync try again. + logger.warning( + "import: PDF strip re-gate source=%s path=%s — re-render " + "produced no pages (state kept)", + source, rel, + ) + return + page_dir.mkdir(parents=True, exist_ok=True) + written_ns = _write_page_copies(doc, page_dir, pages) + _delete_stale_page_tail( + doc_id=doc.id, + page_dir=page_dir, + old_extent=old_count, + written_ns=written_ns, + ) + doc.page_count = len(pages) + actions.append(f"healed {len(missing)} pages") + if doc.page_count != old_count: + logger.warning( + "import: PDF strip re-gate source=%s path=%s — page_count " + "drift %s->%s on unchanged bytes", + source, rel, old_count, doc.page_count, + ) + if doc.page_strip_mode != mode: + # NULL -> stamped stays SILENT (it rides the line only when + # work followed); a change of an already-set mode is news. + doc.page_strip_mode = mode + if old_mode is not None: + actions.append(f"mode {old_mode}->{mode}") + if actions: + logger.info( + "import: PDF strip re-gate source=%s path=%s — %s", + source, rel, " | ".join(actions), + ) + except Exception as e: + # Fail-soft: the strip never breaks a sync. + logger.warning( + "import: PDF strip re-gate failed source=%s path=%s — %s", + source, rel, e, + ) async def _index_file( @@ -1186,7 +1406,7 @@ async def _index_file( # helper, nothing else does. await _unchanged_doc( session, doc, source=source, rel=rel, llm=llm, summary=summary, - raw_date=raw_date, file_digest=file_digest, + raw_date=raw_date, file_digest=file_digest, full_path=full_path, ) return # Phase 122 (task 02): the image branch FIRST. Only reachable while @@ -1223,7 +1443,7 @@ async def _index_file( # file hash, so the NEXT sync takes the fast path above. await _unchanged_doc( session, doc, source=source, rel=rel, llm=llm, summary=summary, - raw_date=raw_date, file_digest=file_digest, + raw_date=raw_date, file_digest=file_digest, full_path=full_path, ) return @@ -1506,19 +1726,24 @@ async def _index_pdf_file( (:func:`_unchanged_doc`) — no re-extract, no re-embed, no image pass, the backfill/manual-date/date-refresh semantics in one code site — and the helper stamps the new file hash (the next - sync takes the fast path). This path renders NO pages and - touches NO ``page_count`` (the copies persist — zero render - work). + sync takes the fast path). This path renders NO pages through + THIS branch, but a row that carries a strip is re-gated by the + helper's phase-163 tail (:func:`_regate_pdf_strip` — the class + re-stamped, missing copies healed, a stale strip cleared; zero + vision/LLM work). * Phase 148 (task 01): in this NEW/CHANGED path, RIGHT AFTER the row's flush and BEFORE the chunk pipeline, a page-QUALIFYING PDF (the phase-133 fallback fired, or the phase-140 pictorial pass described ≥1 page — the signals recorded above) gets its every page rendered to ``settings.pdf_page_dir`` as - ``-p{N}.png`` and ``documents.page_count`` set - (:func:`_persist_pdf_pages` — stale copies deleted first, a - stopped-qualifier cleared, a per-page ``b""`` a gap). FAIL-SOFT - — a render/write exception leaves ``page_count`` NULL + a - warning; the document indexes exactly as pre-phase. + ``-p{N}.png`` and ``documents.page_count`` + + ``documents.page_strip_mode`` set + (:func:`_persist_pdf_pages` — WRITE-FIRST (phase 163, LOCKED + A4): the new copies land atomically per file BEFORE any old copy + is removed, a stopped-qualifier cleared, a per-page ``b""`` a + gap). FAIL-SOFT — a render/write exception leaves + ``page_count``/``page_strip_mode`` NULL + a warning; the + document indexes exactly as pre-phase. * THEN the embedded images: :func:`_index_pdf_embedded_images` (A3 — the phase-122 pipeline, gated by the ``images`` toggle). @@ -1631,7 +1856,7 @@ async def _index_pdf_file( # fast path). await _unchanged_doc( session, doc, source=source, rel=rel, llm=llm, summary=summary, - raw_date=raw_date, file_digest=file_digest, + raw_date=raw_date, file_digest=file_digest, full_path=full_path, ) return @@ -1673,23 +1898,34 @@ async def _index_pdf_file( # its id — the copy names need it) and BEFORE the chunk pipeline # (the count commits ATOMICALLY with the doc + chunks). Runs ONLY # in this new/changed path: the phase-147 fast path returned - # before the branch (an unchanged PDF re-renders NOTHING — the - # copies persist) and the content-digest hit above took the shared - # unchanged path (no page work there either). FAIL-SOFT: the - # render is a DISPLAY concern — any exception leaves - # ``page_count`` NULL, the warning is the signal, and the document - # proceeds to chunk/embed/summary EXACTLY as pre-phase. + # before the branch and the content-digest hit above took the + # shared unchanged path — neither renders here; the phase-163 + # task-03 re-gate (:func:`_regate_pdf_strip`, run by + # :func:`_unchanged_doc`) owns the repair of an unchanged doc's + # strip. FAIL-SOFT: the render is a DISPLAY + # concern — any exception leaves ``page_count``/``page_strip_mode`` + # NULL, the warning is the signal, and the document proceeds to + # chunk/embed/summary EXACTLY as pre-phase. + # Phase 163 (task 02): the eligibility decision carried as a CLASS + # (LOCKED A7 — the truth table is the phase-148 rule verbatim: a + # non-None class ⇔ fallback_fired or pictorial_described). The + # class decides the stamped `page_strip_mode` ("fallback" → + # "primary", "pictorial" → "additive"), nothing else. + strip_class = ( + "fallback" if fallback_fired else ("pictorial" if pictorial_described else None) + ) try: _persist_pdf_pages( doc, full_path=full_path, page_dir=Path(llm.settings.pdf_page_dir).expanduser(), - eligible=fallback_fired or pictorial_described, + strip_class=strip_class, source=source, rel=rel, ) except Exception as e: doc.page_count = None + doc.page_strip_mode = None logger.warning( "import: PDF page render failed source=%s path=%s — %s", source, rel, e, @@ -1991,84 +2227,186 @@ def _persist_pdf_pages( *, full_path: Path, page_dir: Path, - eligible: bool, + strip_class: str | None, source: str, rel: str, ) -> None: - """Phase 148 (task 01): the persistent page copies of one NEW/CHANGED - PDF — the display strip's bytes (task 02's public route serves them). + """Phase 148 (task 01) + phase 163 (task 02): the persistent page + copies of one NEW/CHANGED PDF — the display strip's bytes (task 02's + public route serves them) — written WRITE-FIRST and stamped with the + strip's CLASS. - * ELIGIBLE (the phase-133 fallback fired, or the phase-140 - pictorial pass described ≥1 page — the sync's own decisions, see - the caller's signals): EVERY page of the PDF is rendered (150 dpi, + *strip_class* is the sync's eligibility decision carried as a CLASS + (phase 163, LOCKED A7): ``"fallback"`` (the phase-133 fallback fired + — the screenshots ARE the content → stamped mode ``"primary"``), + ``"pictorial"`` (the phase-140 pass described ≥1 sparse page → + stamped mode ``"additive"``), or ``None`` (no strip). The truth + table is the phase-148 eligibility rule VERBATIM: a non-None class + ⇔ the old ``fallback_fired or pictorial_described`` — this phase + changes only what the class DOES (the stamp), never who qualifies. + + * ELIGIBLE: EVERY page of the PDF is rendered (150 dpi, :func:`render_pdf_pages`' default — the phase-133 constant; the ``pages=None`` "list index *i* is page *i*+1" contract) and each - NON-EMPTY PNG is written to *page_dir* as - ``-p{N}.png`` (N 1-based), then ``doc.page_count`` is set - to the PDF's page count (``len(pages)`` — one slot per page, so - the list IS the page count for an openable file). A per-page + NON-EMPTY PNG lands in *page_dir* as ``-p{N}.png`` + (N 1-based) through an ATOMIC per-file write — the shared writer + :func:`_write_page_copies` puts the bytes on ``.png.tmp`` + first and ``os.replace`` moves them onto the final name, so a + concurrent reader of the route NEVER sees a torn PNG (the phase + 163 task-03 re-gate heals a strip through THE SAME writer) — and + the success tail stamps ``doc.page_count = len(pages)`` + (one slot per page — the list IS the page count for an openable + file) and ``doc.page_strip_mode`` from the class. A per-page ``b""`` (an unreadable page) is a GAP, not an error: no file is written for it (the route 404s that N, the viewer's note covers it), and the count still covers the full range. - * the STALE-REPLACEMENT convention (phase 122's image copies): the - doc's EXISTING copies (1..``doc.page_count`` — the row knows its - own extent, read BEFORE any change) are deleted FIRST, so a - changed PDF's old page set (e.g. 30 pages → 24) cannot leak - ``p25..p30``; a fresh doc's NULL count is a no-op. - * NOT eligible, or an unopenable file (:func:`render_pdf_pages` → - ``[]``), or a render with ZERO non-empty pages: ``page_count`` is - None + the existing copies deleted — a document that STOPPED - qualifying loses its strip (e.g. a new version gained a real text - layer). - * FAIL-SOFT: a write failure removes the copies THIS CALL wrote - (no partial-write orphans) and re-raises — the caller - (:func:`_index_pdf_file`) catches, leaves ``page_count`` NULL, - logs the warning, and the document indexes exactly as pre-phase - (the render is a display concern — it never gates indexing). + * WRITE-FIRST (phase 163, LOCKED A4 — the 2026-10-07 live loss): the + OLD copies are deleted LAST, never first. The pre-phase order + (blanket delete → render → write) ran OUTSIDE the DB transaction, + so any later per-file rollback (the import loop's + ``EmbeddingError`` handler) — or a crash between delete and write — + restored the stale ``page_count`` over ZERO files and the strip + 404ed forever. Now only the old-extent files (1..the count read + BEFORE any change) that the fresh render did NOT write — a shrunk + PDF's ``p{N}`` tail, a new gap — are removed, and only once every + replacement byte is durably in place; a file about to be + overwritten is never deleted at all. + * NOT eligible (``strip_class is None``), an unopenable file + (:func:`render_pdf_pages` → ``[]``), or a render with ZERO + non-empty pages: the existing copies are deleted (the helper reads + ``doc.page_count``, so it runs BEFORE the nulling) and BOTH + ``page_count`` and ``page_strip_mode`` are cleared — the + deletion-first order is correct HERE, there is no replacement set + to protect; a document that STOPPED qualifying loses its strip + (e.g. a new version gained a real text layer). + * FAIL-SOFT: a write failure removes this call's ``.tmp`` files and + the final-name files it CREATED — but NEVER a file it OVERWROTE + (the replaced copy keeps standing as the durable new bytes; a + failed call may change a copy's bytes, it may never remove a + copy) — and re-raises: the caller (:func:`_index_pdf_file`) + catches, leaves ``page_count`` AND ``page_strip_mode`` NULL, logs + the warning, and the document indexes exactly as pre-phase (the + render is a display concern — it never gates indexing). *page_dir* is ``Path(settings.pdf_page_dir).expanduser()`` (the ``image_dir`` convention — raw string, expanduser'd by the importer); the directory is created on demand, only when a page is actually written (an ineligible doc never creates it). """ - # 1 — the stale-replacement: this doc's EXISTING copies go first — - # a changed PDF's old page set must not leak, and a doc that - # stopped qualifying loses its strip (a fresh doc's NULL count is - # a no-op; missing files are a silent no-op). - _delete_page_copies(doc, page_dir) - if not eligible: + if strip_class is None: + # No strip — the DELETION-first order is correct here (there is + # no replacement set to protect); the helper reads + # doc.page_count, so it runs BEFORE the nulling (a fresh doc's + # NULL count is a no-op; missing files are a silent no-op). + _delete_page_copies(doc, page_dir) doc.page_count = None + doc.page_strip_mode = None return pages = render_pdf_pages(full_path) if not pages or not any(pages): # Unopenable file (the render's ``[]``) or every page a - # placeholder: no strip — fail-soft, the index is untouched - # (the render helper already logged the per-page failures). + # placeholder: no strip and NO replacement set — the existing + # copies go with the cleared row (helper BEFORE the nulling), + # fail-soft, the index is untouched (the render helper already + # logged the per-page failures). logger.warning( "import: PDF page render produced no pages source=%s path=%s", source, rel, ) + _delete_page_copies(doc, page_dir) doc.page_count = None + doc.page_strip_mode = None return + # The row's OWN old extent, read BEFORE any change — the stale-tail + # delete below walks it (the write-first contract never deletes a + # file outside this extent, and never before its replacement is + # durable). + old_extent = doc.page_count or 0 + # 1 — WRITE FIRST, atomically per file (the shared writer, phase + # 163 task 02 — the phase-163 task-03 re-gate heals through it). + written_ns = _write_page_copies(doc, page_dir, pages) + # 2 — the stale tail goes LAST. + _delete_stale_page_tail( + doc_id=doc.id, + page_dir=page_dir, + old_extent=old_extent, + written_ns=written_ns, + ) + doc.page_count = len(pages) + doc.page_strip_mode = "primary" if strip_class == "fallback" else "additive" + + +def _write_page_copies(doc: Document, page_dir: Path, pages: Sequence[bytes]) -> set[int]: + """Phase 163 (task 02, LOCKED A4; extracted for the task-03 re-gate, + which heals a strip through the SAME writer): write every NON-EMPTY + PNG of *pages* to ``page_dir/-p{N}.png`` (N 1-based) and + return the set of page numbers written. + + * ATOMIC per file — the bytes land on ``.png.tmp`` and + ``os.replace`` moves them onto the final name, so a concurrent + reader of the ``/pages/{n}`` route never sees a torn PNG. + * A per-page ``b""`` (the render's failure placeholder) is the GAP: + no file, no entry in the returned set. + * The directory is created on demand (a doc that never writes never + creates it). + * A failure leaves NO orphan: this call's ``.tmp`` files and the + finals it CREATED are removed — an OVERWRITTEN final keeps + standing (deleting it would re-open the 2026-10-07 hole with one + page instead of the whole strip) — and the exception re-raises so + the caller keeps its fail-soft contract. + """ page_dir.mkdir(parents=True, exist_ok=True) - written: list[Path] = [] + written_tmps: list[Path] = [] + created_finals: list[Path] = [] # finals that did NOT exist before + written_ns: set[int] = set() try: for n, png in enumerate(pages, start=1): if not png: # A per-page render failure: the GAP (no file — the # route 404s that N, the viewer's note covers it). continue - path = page_dir / f"{doc.id}-p{n}.png" - path.write_bytes(png) - written.append(path) + final = page_dir / f"{doc.id}-p{n}.png" + tmp = page_dir / f"{doc.id}-p{n}.png.tmp" + # Track the tmp BEFORE the write: a failure mid-write may + # leave it on disk, and the cleanup must find it either way. + written_tmps.append(tmp) + existed = final.exists() + tmp.write_bytes(png) + os.replace(tmp, final) # atomic per file — no torn reads + written_ns.add(n) + if not existed: + created_finals.append(final) except Exception: - # A partial write leaves no orphan: the copies this call wrote - # go, then the re-raise hands the failure to the caller's - # fail-soft handler (page_count NULL + the warning). - for path in written: + # A partial write leaves no orphan: this call's tmp files and + # the finals it CREATED go (an OVERWRITTEN final keeps standing + # — deleting it would re-open the 2026-10-07 hole with one page + # instead of the whole strip), then the re-raise hands the + # failure to the caller's fail-soft handler (page_count + + # page_strip_mode NULL + the warning). + for path in [*written_tmps, *created_finals]: path.unlink(missing_ok=True) raise - doc.page_count = len(pages) + return written_ns + + +def _delete_stale_page_tail( + *, doc_id: uuid.UUID, page_dir: Path, old_extent: int, written_ns: set[int] +) -> None: + """Phase 163 (task 02, LOCKED A4 — the LAST step of the write-first + order): remove the files of the row's OLD extent (1..*old_extent*) + the fresh render did NOT write — a shrunk PDF's ``p{N}`` tail, a new + gap. Runs only once every replacement byte is durable, never touches + a file outside the old extent, and never touches a file that was just + overwritten. Best-effort like :func:`_delete_page_copies`: an + unreadable file is logged, never raised (the sync survives).""" + for n in range(1, old_extent + 1): + if n in written_ns: + continue + path = page_dir / f"{doc_id}-p{n}.png" + try: + path.unlink(missing_ok=True) + except OSError as e: + logger.warning("import: could not delete PDF page copy %s — %s", path, e) async def _index_pdf_embedded_images( @@ -3271,7 +3609,7 @@ async def _index_image_file( # ``_store_summary`` backfill re-stores it verbatim. await _unchanged_doc( session, doc, source=source, rel=rel, llm=llm, summary=summary, - raw_date=raw_date, file_digest=file_digest, + raw_date=raw_date, file_digest=file_digest, full_path=full_path, ) return diff --git a/app/schemas.py b/app/schemas.py index 0331f63..e98395e 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -636,6 +636,19 @@ class DocContent(BaseModel): #: ``null``, the :attr:`image_url` omission precedent), so a #: non-qualifying doc's payload is byte-identical to pre-phase. page_urls: list[str] | None = None + #: Phase 163 (task 01, LOCKED A2/A3) — the STRIP CLASS behind + #: :attr:`page_urls`: ``"primary"`` = the phase-133 fallback class + #: (an unusable text layer — the screenshots ARE the content, so the + #: strip IS the view, exactly as phase 148 shipped it); ``"additive"`` + #: = the phase-140 pictorial class (a usable text layer with ≥1 sparse + #: page described — the text view + the Summary panel, the strip one + #: Text/Pages toggle away). ABSENT from the wire whenever there is no + #: strip (``None`` → dropped by the serializer — never ``null``, the + #: :attr:`image_url` omission rule), which is ALSO how a pre-phase row + #: (``page_count`` set, the column NULL) serializes: the viewer treats + #: an absent mode as ``"primary"`` (task 04), so every existing row + #: keeps today's DOM until the fast-path re-gate stamps it. + page_strip_mode: str | None = None @model_serializer(mode="wrap") def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any: @@ -648,6 +661,11 @@ class DocContent(BaseModel): data.pop("origin_branch", None) if self.page_urls is None: data.pop("page_urls", None) + # Phase 163 (task 01): the strip class rides the strip — ABSENT + # (never null) with no strip, so a non-qualifying doc's payload is + # byte-identical to pre-phase. + if self.page_strip_mode is None: + data.pop("page_strip_mode", None) return data diff --git a/frontend/assets/document.js b/frontend/assets/document.js index efd6e89..e5d5663 100644 --- a/frontend/assets/document.js +++ b/frontend/assets/document.js @@ -144,8 +144,9 @@ * wire for every other document, so they render the EXACT pre-phase * DOM on BOTH surfaces — the load-bearing byte-identity property), * #doc-content renders a .doc-pages strip INSTEAD of the text - * content AND the .doc-summary panel: one
- * per URL — a lazy (alt = "Page N of ", the WCAG alt + * content AND the .doc-summary panel FOR THE PRIMARY CLASS ONLY + * (phase 163 — the absent/`primary` mode; see the next paragraph): + * one <figure class="doc-page"> per URL — a lazy <img> (alt = "Page N of <title>", the WCAG alt * contract — the title as the fallback context) + a "Page N" * <figcaption> — while the meta badge row renders exactly as today. * The owner's contract: the per-page descriptions stay in the DB for @@ -160,6 +161,26 @@ * per-surface copy); createElement + properties/textContent only (the * never-build-HTML rule — no innerHTML). * + * Phase 163 (task 04, LOCKED A1/A2/A6): the strip's CLASS decides the + * view. page_strip_mode === "additive" (the pictorial class — a USABLE + * text layer, so the pages are a convenience, not the content) keeps + * the TEXT doc EXACTLY as a non-qualifying doc renders it (Summary + * panel + content — the pre-148 branch reused), and the strip sits one + * Text/Pages toggle away: a .doc-view-toggle group (role=group, + * aria-label="Document view", two <button aria-pressed>) above a + * .doc-views wrapper holding the text view + the strip; Text is + * pressed by DEFAULT (A6 — never a sparse-ratio flip), Pages flips the + * wrapper's data-view (CSS shows the strip / hides the text — the + * strip node stays MOUNTED, its lazy imgs never refetch on flip-flop). + * The text is NEVER hidden for this class. The absent/"primary" mode + * (the fallback class — the screenshots ARE the content, and every + * pre-163 stamped row) keeps the strip-only DOM above byte-identically + * (the tolerant read: only the exact "additive" string switches on the + * toggle); falsy page_urls stay untouched. The wrapper exists ONLY on + * additive docs — every other document's DOM is unchanged. Toggle + * labels are fixed strings (no doc-derived text); createElement + + * properties/textContent only. + * * Phase 153 (task 02): the meta row's "Copy path" control — the * read-tool `source/path` form in the clipboard with one click * (ASSUMPTION A1 — the exact `source/path` the read tool, the @@ -361,7 +382,9 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) { // Phase 148 (task 03): the page strip — the payload's page_urls // (task 02's endpoint: the exact 1-based route list for a // page-qualifying PDF; ABSENT on the wire for every other - // document) is the ONE branch that replaces the content slot: the + // document) is the ONE branch that replaces the content slot (phase + // 163: the PRIMARY class only — an "additive" doc falls through to + // the text rendering with the strip behind a toggle, D4/A1): the // markdown/.doc-raw content block AND the labeled Summary panel // (including its admin edit wiring — moot without the panel) are // NOT built. The per-page descriptions stay in the DB for Q&A (the @@ -372,16 +395,40 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) { // below (byte-identical for every other doc — the load-bearing // property). const pageUrls = Array.isArray(doc.page_urls) ? doc.page_urls : null; - if (pageUrls && pageUrls.length > 0) { + const stripEligible = pageUrls !== null && pageUrls.length > 0; + // Phase 163 (task 04, LOCKED A2): the strip's CLASS picks the view. + // Only the exact "additive" string switches on the toggle — absent + // (pre-163 rows, and the omission rule's non-qualifying docs) or + // "primary" read as primary (the tolerant-reading house rule), so + // the fallback class keeps the strip-only DOM byte-identically. + const additive = stripEligible && doc.page_strip_mode === "additive"; + if (stripEligible && !additive) { contentEl.appendChild(docPagesStrip(doc, pageUrls)); return; } + // Phase 163 (task 04, LOCKED A1/A6): an ADDITIVE doc renders the + // text doc EXACTLY as a non-qualifying one does (Summary panel + + // content below, reused unchanged) INSIDE a .doc-views wrapper — + // the Text/Pages toggle above it flips the wrapper's data-view + // (CSS-driven show/hide, never a re-render); the strip mounts once + // AFTER the text view and stays mounted across flips (lazy imgs, + // display:none while hidden, never fetch). Every non-additive doc + // keeps slot === contentEl — its DOM is byte-identical to pre-phase. + let views = null; + let slot = contentEl; + if (additive) { + views = document.createElement("div"); + views.className = "doc-views"; + views.setAttribute("data-view", "text"); // LOCKED A6: Text is the default view + contentEl.append(docViewToggle(views), views); + slot = views; + } // Phase 122 (task 04): an image document — the persistent bytes // render as the <img> block FIRST; the description (= doc.content) // follows in the normal content slot below (the plain-content path // — the last branch in this function). if (doc.is_image) { - contentEl.appendChild(docImageBlock(doc)); + slot.appendChild(docImageBlock(doc)); } // Phase 36: the summary panel — labeled section ABOVE the original // content, on BOTH surfaces (page + modal) through this one core. @@ -405,7 +452,7 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) { body.className = "doc-summary-text"; body.textContent = doc.summary; // text node — XSS contract unchanged section.append(title, body); - contentEl.appendChild(section); + slot.appendChild(section); // Phase 57 (task 02, D4): the section above is the phase-36 shape // for EVERYONE — only an authenticated admin (docAdminReady(), the // module-cached whoami promise) then gains the header row + Edit @@ -419,13 +466,59 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) { const wrap = document.createElement("div"); wrap.className = "doc-md"; wrap.innerHTML = renderMarkdown(doc.content); // escape-first: XSS-safe - contentEl.appendChild(wrap); + slot.appendChild(wrap); } else { const pre = document.createElement("pre"); pre.className = "doc-raw"; pre.textContent = doc.content; // text node: never parsed as HTML - contentEl.appendChild(pre); + slot.appendChild(pre); } + // Phase 163 (task 04): the additive strip mounts once, AFTER the + // text view, inside the wrapper — hidden by [data-view="text"] + // (the lazy imgs never fetch until Pages is chosen; the node + // persists across flips — no refetch). + if (views !== null) { + views.appendChild(docPagesStrip(doc, pageUrls)); + } +} + +/* ---------- view toggle (phase 163, task 04 — LOCKED A1/A6) ---------- + * The Text/Pages toggle of an ADDITIVE strip doc: one + * <div class="doc-view-toggle" role="group" aria-label="Document + * view"> with two <button type="button" class="doc-view-btn" + * aria-pressed>. Text is pressed initially (A6 — no sparse-ratio + * default flip). A click mutates ONLY the two aria-pressed states and + * the .doc-views wrapper's data-view attribute — the CSS shows/hides + * the text view and the strip (never a re-render, never a refetch). + * The labels are FIXED strings (no doc-derived text inside the + * toggle); createElement + properties/textContent only (the + * never-build-HTML rule — no innerHTML). */ +function docViewToggle(viewsEl) { + const group = document.createElement("div"); + group.className = "doc-view-toggle"; + group.setAttribute("role", "group"); + group.setAttribute("aria-label", "Document view"); + const textBtn = viewToggleButton("Text"); + const pagesBtn = viewToggleButton("Pages"); + const select = (view) => { + viewsEl.setAttribute("data-view", view); + textBtn.setAttribute("aria-pressed", view === "text" ? "true" : "false"); + pagesBtn.setAttribute("aria-pressed", view === "pages" ? "true" : "false"); + }; + textBtn.setAttribute("aria-pressed", "true"); // LOCKED A6: Text pressed by default + pagesBtn.setAttribute("aria-pressed", "false"); + textBtn.addEventListener("click", () => select("text")); + pagesBtn.addEventListener("click", () => select("pages")); + group.append(textBtn, pagesBtn); + return group; +} + +function viewToggleButton(label) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "doc-view-btn"; + btn.textContent = label; // fixed string — never doc-derived + return btn; } /* ---------- image block (phase 122, task 04) ---------- diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 6dc3d60..3a3c78f 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -5311,6 +5311,59 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und padding: 0.35rem 0 0; } +/* Additive strip view (phase 163, task 04): the pictorial-class PDF + — a USABLE text layer — keeps the text doc (Summary panel + + content) with the page strip one Text/Pages toggle away (LOCKED + A1/A6). The toggle is the house ghost-pill idiom (the .auth-link + family): the two pills sit adjacent in the labeled group, ink-soft + on the transparent pill (AA on --bg/--surface), hover the + brand-soft/brand-ink pair; the PRESSED button is the brand fill + + heavier weight — text + style, never color alone (B5) — with --bg + ink on --brand (5.2:1, the .share-chat-btn pair). ≥44px targets + (§7.1); :focus-visible rides the global 3px ring. The view swap is + CSS-driven off the .doc-views wrapper's data-view: the strip is + display:none by default (its lazy imgs never fetch while hidden) + and shows on [data-view="pages"], which hides the text view instead + — the nodes stay mounted across flips (no re-render, no refetch). + Phase-08 tokens only (no new hue — the monochrome invariant). */ +.doc-view-toggle { + display: flex; + gap: 0.4rem; + width: 100%; + max-width: var(--chat-column); + margin-inline: auto; + margin-bottom: 0.75rem; +} +.doc-view-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0.5rem 0.9rem; + border-radius: 999px; + border: 1px solid var(--line); + background: transparent; + color: var(--ink-soft); + font: inherit; + font-weight: 600; + font-size: 0.95rem; + white-space: nowrap; + cursor: pointer; +} +.doc-view-btn:hover { background: var(--brand-soft); color: var(--brand-ink); } +.doc-view-btn[aria-pressed="true"] { + background: var(--brand); + border-color: var(--brand); + color: var(--bg); /* dark ink on brand: 5.2:1 (AA) */ + font-weight: 700; /* pressed = weight + fill, never color alone (B5) */ +} +.doc-views .doc-pages { display: none; } /* Text default — hidden lazy imgs never fetch */ +.doc-views[data-view="pages"] .doc-summary, +.doc-views[data-view="pages"] .doc-md, +.doc-views[data-view="pages"] .doc-raw, +.doc-views[data-view="pages"] .doc-image { display: none; } +.doc-views[data-view="pages"] .doc-pages { display: grid; } + /* Designed not-found state (no emoji — plain SVG mark, phase 08 rule). */ .doc-not-found { width: 100%; diff --git a/tests/e2e/test_pdf_pages_in_sources.py b/tests/e2e/test_pdf_pages_in_sources.py index c8106db..2a46867 100644 --- a/tests/e2e/test_pdf_pages_in_sources.py +++ b/tests/e2e/test_pdf_pages_in_sources.py @@ -36,33 +36,38 @@ token (the phase-126 convention — the owner's local ``.env`` may lack it, and without it the walk is blind to the fixture). MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the described -blocks (and the seed's DB witness that the descriptions ARE indexed -but NOT displayed) are exact only against the mock's deterministic -``PICTORIAL_PAGE_MODE`` canned reply. +blocks (and the seed's DB witness that the descriptions ARE indexed) +are exact only against the mock's deterministic +``PICTORIAL_PAGE_MODE`` canned reply. (Phase 163 re-cut: the viewer +assertions moved from the phase-148 suppression contract to the +ADDITIVE contract — the pictorial class shows its text INCLUDING the +page descriptions, with the strip behind the Text/Pages toggle.) Test → observable mapping (Playwright Mapping Rule): 1. ``test_pictorial_pdf_shows_pages_in_modal`` — the seed (module) asserts the DB + filesystem first: the pictorial row has ``page_count == 3`` and the scratch page dir holds EXACTLY ``<id>-p{1..3}.png`` (real PNG magic, non-empty — the textheavy - doc's copies do not exist); then the Sources-tree doc modal shows - the ``.doc-pages`` strip: exactly three ``.doc-page`` figures, - each a LOADED lazy ``<img>`` (``complete && naturalWidth > 0`` — - the route served a real PNG, not a 404) with the - ``Page N of <title>`` alt, the ``Page N`` caption, the NO - ``.doc-raw`` / NO ``.doc-md`` / NO ``.doc-summary`` suppression - contract (the mock's page descriptions sit in the DB content but - nowhere in the rendered modal), the meta badge row (source / - Created / Indexed) untouched. + doc's copies do not exist); then the Sources-tree doc modal opens + the ADDITIVE view (phase 163 — a usable text layer: TEXT first, + the strip one toggle away): the Text/Pages group with ``Text`` + pressed, the Summary panel + content visible (the mock's page + descriptions RIDE the shown text — LOCKED A1/A6), the + ``.doc-pages`` strip mounted but hidden; pressing ``Pages`` shows + exactly three ``.doc-page`` figures, each a LOADED lazy ``<img>`` + (``complete && naturalWidth > 0`` — the route served a real PNG, + not a 404) with the ``Page N of <title>`` alt and the ``Page N`` + caption while the text view is hidden (mounted for the flip- + back), the meta badge row (source / Created / Indexed) untouched. 2. ``test_textheavy_pdf_unchanged`` — the textheavy modal renders the PRE-PHASE shape: the content in ``pre.doc-raw`` (both pages), the Summary panel (the mock's lite digest), NO ``.doc-pages`` — and the content payload's ``page_urls`` KEY is ABSENT (never ``null`` — the omission pin the viewer's strip gate rides on). -3. ``test_full_page_surface`` — the same strip on the dedicated - ``/document.html`` page (admin-logged-in, the phase-79 gate - satisfied): one ``renderDocument`` core — no per-surface drift — - same figures, same suppression. +3. ``test_full_page_surface`` — the same additive view + strip on the + dedicated ``/document.html`` page (admin-logged-in, the phase-79 + gate satisfied): one ``renderDocument`` core — no per-surface + drift — same toggle, same figures, same hidden-while-Text shape. 4. ``test_page_route_serves_png`` — an ANONYMOUS (no-login) direct request on ``/api/documents/<id>/pages/1``: 200, ``image/png``, the ``Cache-Control: private, max-age=3600`` header, a non-empty @@ -597,15 +602,25 @@ def _wait_img_loaded(img: Locator) -> None: def _assert_page_strip( content: Locator, title: str, doc_id: str, page_count: int = 3 ) -> None: - """The phase-148 strip contract inside a content container (the - ONE ``renderDocument`` core — modal and full page, no - per-surface drift): a ``.doc-pages`` strip with exactly - *page_count* ``.doc-page`` figures; each figure's lazy ``<img>`` - is LOADED (the route served a real PNG) with the ``Page N of - <title>`` alt and the exact 1-based route src, and the - figcaption reads ``Page N``; the text content block AND the - Summary panel are SUPPRESSED (the descriptions stay in the DB, - not displayed).""" + """The strip contract inside a content container (the ONE + ``renderDocument`` core — modal and full page, no per-surface + drift): a ``.doc-pages`` strip with exactly *page_count* + ``.doc-page`` figures; each figure's lazy ``<img>`` is LOADED (the + route served a real PNG) with the ``Page N of <title>`` alt and + the exact 1-based route src, and the figcaption reads ``Page N``. + + Phase 163 (task 04) re-cut: this fixture is the PICTORIAL class — + an ADDITIVE doc (``page_strip_mode == "additive"``) that opens on + its TEXT view, so the strip is revealed by pressing the toggle's + ``Pages`` button first (a primary/absent doc has no toggle — the + strip already IS the content). The text content block and the + Summary panel are HIDDEN while the strip shows (they stay mounted + for the flip back — LOCKED A1); a primary-class doc has no such + nodes at all, which the same ``not_to_be_visible`` assertion + covers.""" + toggle = content.locator(".doc-view-toggle") + if toggle.count() > 0: + toggle.get_by_role("button", name="Pages").click() strip = content.locator(".doc-pages") expect(strip).to_have_count(1) figures = strip.locator(".doc-page") @@ -624,12 +639,12 @@ def _assert_page_strip( ) _wait_img_loaded(img) expect(fig.locator("figcaption.doc-page-num")).to_have_text(f"Page {n}") - # The suppression contract (the owner's TODO L5): no text content - # block, no Summary panel — the meta badge row is asserted by the - # caller (it renders exactly as today). - expect(content.locator("pre.doc-raw")).to_have_count(0) - expect(content.locator(".doc-md")).to_have_count(0) - expect(content.locator(".doc-summary")).to_have_count(0) + # While the strip shows, the text view is not (phase 163: hidden, + # not unmounted — the additive flip-back; a primary doc has no + # such nodes at all). The meta badge row is asserted by the caller. + expect(content.locator("pre.doc-raw")).not_to_be_visible() + expect(content.locator(".doc-md")).not_to_be_visible() + expect(content.locator(".doc-summary")).not_to_be_visible() # --------------------------------------------------------------------------- @@ -640,15 +655,18 @@ def _assert_page_strip( def test_pictorial_pdf_shows_pages_in_modal( page: Page, app_url: str, seeded: dict[str, Any] ) -> None: - """The IKEA-shaped PDF opens in the doc modal as a strip of its - REAL pages (the owner's L5 ask): ``.doc-pages`` with exactly three + """The IKEA-shaped PDF opens in the doc modal as an ADDITIVE doc + (phase 163 — a usable text layer: the pictorial class): the TEXT + view first (Summary panel + content visible, the toggle's ``Text`` + pressed — LOCKED A6, the mock's page descriptions ride the shown + text), with the strip of its REAL pages (the owner's L5 ask) one + ``Pages`` click away: ``.doc-pages`` with exactly three ``.doc-page`` figures — each a loaded lazy ``<img>`` (naturalWidth > 0 proves the full pipeline: sync → render → persist → route → viewer) with the ``Page N of <title>`` alt and the ``Page N`` - caption — and the suppression contract: NO text content - (``.doc-raw``), NO Summary panel (the mock's page descriptions are - in the DB content, nowhere in the rendered modal); the meta badge - row (source / Created / Indexed) renders as today.""" + caption — while the text view is hidden (mounted for the + flip-back); the meta badge row (source / Created / Indexed) + renders as today.""" login(page, app_url) # lands on /sources.html _drill_to_source(page, SOURCE_NAME) expect(page.locator("#docs-tbody tr")).to_have_count(2) @@ -658,6 +676,29 @@ def test_pictorial_pdf_shows_pages_in_modal( ) content = page.locator("#doc-modal-content") + # Phase 163 (task 04, LOCKED A1/A6): the pictorial class is an + # ADDITIVE doc — it opens on its TEXT view: Summary panel + + # content visible, the toggle's Text pressed, the strip mounted + # but hidden (its lazy imgs never fetched while hidden). + toggle = content.locator(".doc-view-toggle") + expect(toggle).to_have_count(1) + expect(toggle.get_by_role("button", name="Text")).to_have_attribute( + "aria-pressed", "true" + ) + expect(toggle.get_by_role("button", name="Pages")).to_have_attribute( + "aria-pressed", "false" + ) + expect(content.locator("pre.doc-raw")).to_be_visible() + expect(content.locator(".doc-summary")).to_be_visible() + expect(content.locator(".doc-pages")).to_be_hidden() + # The text view carries the indexed content — the phase-140 page + # descriptions ride the SHOWN text for an additive doc (the text + # is never hidden: A1). + assert PICTORIAL_PAGE_ANSWER in content.inner_text(), ( + "the additive text view shows the indexed content including " + "the described sparse pages" + ) + # …and the strip is one Pages click away (the helper flips). _assert_page_strip(content, seeded["title"], seeded["pictorial_id"]) # The meta badge row is untouched by the strip (the pre-phase row). @@ -666,13 +707,6 @@ def test_pictorial_pdf_shows_pages_in_modal( expect(meta.locator(".doc-created")).to_contain_text("Created") expect(meta.locator(".doc-indexed")).to_contain_text("Indexed") - # The suppression witness: the mock's canned page descriptions sit - # in the DB (the seed asserted it) but are NOT displayed. - assert PICTORIAL_PAGE_ANSWER not in content.inner_text(), ( - "the described pages' text is in the DB for Q&A — it must not " - "be shown to the user (the owner's contract)" - ) - # --------------------------------------------------------------------------- # 2. The text-heavy PDF: byte-identical to pre-phase @@ -730,10 +764,11 @@ def test_textheavy_pdf_unchanged( def test_full_page_surface(page: Page, app_url: str, seeded: dict[str, Any]) -> None: """The dedicated ``/document.html`` page (admin-logged-in — the - phase-79 gate satisfied) renders the SAME strip through the ONE - shared ``renderDocument`` core: the same three loaded figures with - captions/alt texts, the same suppression (no text content, no - Summary panel), the meta row as today.""" + phase-79 gate satisfied) renders the SAME additive view through + the ONE shared ``renderDocument`` core (no per-surface drift): the + text view first, the strip one ``Pages`` click away with the same + three loaded figures + captions/alt texts and the text view + hidden while it shows, the meta row as today.""" login(page, app_url, next="/") page.goto( f"{app_url}/document.html?source={quote(SOURCE_NAME)}" diff --git a/tests/e2e/test_pdf_strip_additive_selfheal.py b/tests/e2e/test_pdf_strip_additive_selfheal.py new file mode 100644 index 0000000..d46c7aa --- /dev/null +++ b/tests/e2e/test_pdf_strip_additive_selfheal.py @@ -0,0 +1,1084 @@ +"""Phase 163 task 05 E2E (Playwright, mock-only): the additive PDF +strip, the byte-identical primary/plain classes, and the fast-path +self-heal — the ``Letumdei`` failure mode, reproduced and made +impossible. + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_pdf_strip_additive_selfheal.py -v --no-cov + +The dedicated suite for ``163_pdf_strip_additive_selfheal`` (A16 — one +Playwright file per phase, run in isolation). Pre-fix evidence +(2026-10-07, brain.reeseapps.com): ``Letumdei_ Heirs to the Imperium.pdf`` +— 36 ``page_urls`` in the payload, ALL 36 page routes 404, 114 389 +chars of genuine extracted text with exactly ONE sparse map page +(the pictorial class) — the viewer showed "Page N unavailable." on +every page with the text hidden behind the strip. Two stacked defects: +the pictorial class rendered the strip INSTEAD of its text (fixed by +the additive view, LOCKED A1/A6), and the copies were deleted before +the replacements were written, outside the DB transaction, so a later +rollback (or an external wipe) left the row over zero files with the +phase-147 fast path never re-rendering (fixed by write-first persist ++ the deterministic re-gate, LOCKED A4/A5). One local-directory source +(``strip_fixture``, the API-registered ``kind=local`` pattern) carries +three synthetic PyMuPDF PDFs — the live shape in miniature: + +* ``manual.pdf`` — the PICTORIAL class (the Letumdei shape): 3 dense + prose pages + ONE sparse map-style page (<100 stripped chars, a + vector-drawn figure) → usable text layer, one gate candidate → + ``page_strip_mode == "additive"``, ``page_count == 4``; +* ``scanned.pdf`` — the FALLBACK class (the phase-133 shape): two + pages whose ONLY text is the owner's XML-garbage line repeated 20× → + unusable layer → ``page_strip_mode == "primary"``, ``page_count == 2``; +* ``plain.pdf`` — clean dense text, ZERO sparse pages → no strip at + all (``page_count`` NULL, both strip keys absent from the payload). + +App env: the conftest pattern with the phase-140 opt-in (``BOR_PDF_ +PICTORIAL`` pinned EXPLICITLY ``1``) + the phase-148 ``BOR_PDF_PAGE_DIR`` +pointed at a suite-private scratch dir (the heal deletes and re-creates +files THERE — the owner's ``~/bor-sources/pages`` default is never +touched) + ``BOR_IMPORT_EXTENSIONS`` carrying the ``pdf`` token (the +phase-126 convention). The page-directory homes are module-scoped so +the heal/repair tests mutate the exact dir the module app reads. + +MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported for the +zero-LLM-call pin (assert 5 reads the mock's ``/v1/e2e/captured`` +request log — the phase-123 house pattern for "ZERO chat calls"). + +Test → observable mapping (Playwright Mapping Rule): +1. ``test_additive_view`` — the ``manual.pdf`` modal: the content + payload stamps the class (``page_strip_mode == "additive"`` beside + the 4 ``page_urls``), the TEXT view opens by default (Summary panel + + content visible — the map page's canned description RIDES the + shown text, LOCKED A1; the toggle's ``Text`` pressed, LOCKED A6) + with the strip mounted but hidden; pressing ``Pages`` shows the + 4-figure strip and EVERY ``<img>`` loads (naturalWidth > 0 — the + route serves; no "unavailable" note anywhere); pressing ``Text`` + back shows the text, hides the strip, and the strip NODE persists + (the same DOM element — probed by a dataset marker — with the same + 4 loaded imgs: no re-render, no refetch). +2. ``test_primary_and_plain_doms`` — the ``scanned.pdf`` modal keeps + the phase-148 strip-only DOM byte-identically (payload + ``page_strip_mode == "primary"``; NO ``.doc-view-toggle``, NO + ``.doc-summary``, NO content block; 2 loaded ``.doc-page`` figures + with the ``Page N`` captions); the ``plain.pdf`` modal is the plain + text doc (raw content + Summary panel, ZERO ``.doc-pages`` / + ``.doc-view-toggle`` nodes) and its payload carries NEITHER + ``page_urls`` NOR ``page_strip_mode`` (the omission rule — the + non-qualifying payload is byte-identical to pre-phase). +3. ``test_heal_restores_deleted_copies`` — the live-loss state + reproduced: delete ALL ``pdf_page_dir/<manual-id>-p*.png`` (the row + still claims 4) → a page route 404s → run a sync over UNCHANGED + bytes (the fast path) → the four copies exist again (real PNGs), + the route serves 200 with PNG bytes, the row keeps ``page_count`` + 4 / mode ``additive``, the payload still carries ``page_urls`` + + ``additive``, and the viewer's Pages view loads every img — the + exact D5 promise (a wipe cannot outlive one sync). +4. ``test_repair_clears_stale_strip`` — the stale-eligible state NO + current index produces: direct-DB seed sets ``plain.pdf``'s row to + ``page_count`` 2 / mode ``additive`` and writes two stale copies on + disk → sync (still unchanged bytes) → both columns NULL (read + through the fixture session), the stale copies GONE, and the modal + shows the plain text doc (the strip is finished). +5. ``test_zero_llm_calls_on_heal_and_repair`` — the mock's request + log: the INITIAL index burned the vision passes it owes (≥1 + ``PICTORIAL_PAGE_MODE`` + ≥1 ``IMAGE_DESCRIPTION_MODE`` request — + the capture works and the premise is real), while the heal sync and + the repair sync each captured ZERO content/vision chat requests — + the re-gate is deterministic (render is CPU, LOCKED A5). The one + exempted request is the sync prelude's fixed ``ping`` model-health + probe (``app.rag.llm.check_models`` — every sync ever run pays it, + unchanged by this phase); anything else in the capture is a + violation. + +House pattern: one module app over the shared E2E Postgres (its own +port, module env — the conftest session app is never started in this +isolated run, so no port clash). The five tests SHARE the module seed +(one source, three documents, one sync) and deliberately MUTATE shared +state (the page dir, then one row) in file order, so the shared +registry + KB is truncated ONCE, after the last test (a +module-granularity autouse — a per-test truncate would wipe the seed +from under its siblings), with a no-running-sync wait so a leaked run +cannot race it. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from collections.abc import Iterator +from datetime import datetime +from pathlib import Path +from typing import Any + +import httpx +import pymupdf as fitz # PyMuPDF — the synthetic-fixture builder (the house recipe) +import pytest +from playwright.sync_api import Locator, 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 ( + extract_pdf_page_texts, + extract_pdf_text, + is_usable_pdf_text, + pictorial_pages, + render_pdf_pages, +) +from e2e.auth_helpers import login +from e2e.conftest import ( + ADMIN_PASSWORD, + MOCK_PORT, + SESSION_SECRET, + USE_REAL_LLM, + _wait_http, +) +from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER, PICTORIAL_PAGE_ANSWER + +REPO = Path(__file__).resolve().parents[2] + +#: The local directory's NAME — the source name the registry stores for +#: a ``kind='local'`` source (the directory basename, the +#: test_local_directory_sources convention). The task's fixture name. +SOURCE_NAME = "strip_fixture" +MANUAL_PATH = "manual.pdf" +SCANNED_PATH = "scanned.pdf" +PLAIN_PATH = "plain.pdf" +MANUAL_TITLE = "Campaign Manual" # the metadata title (the Letumdei stand-in) +SCANNED_TITLE = "Scanned Plates" # the metadata title +PLAIN_TITLE = "Plain Notes" # the metadata title + +#: The dense prose paragraph (500+ chars — over the 100-char sparseness +#: bar, so text pages are never gate candidates; the +#: test_pdf_pages_in_sources recipe). +DENSE_PARAGRAPH = ( + "This manual covers the complete campaign, its muster rules, and the " + "supply checks a first-time commander should run before marching. The " + "train holds the two supply wagons, the field forge, the water cart, " + "eight cam locks for the tent pegs, fourteen spare stakes, two spare " + "axle bolts, and the signal-horn case. Work from a flat ridge with dry " + "ground, keep the squads sorted by banner, and read every dispatch " + "through before you begin so you know which standard is which. The " + "column marches roughly ten leagues a day once supplied, weighs under " + "twelve tonnes, and needs no further provision beyond the local levy." +) +assert len(DENSE_PARAGRAPH) > 500, "the dense paragraph must be 500+ chars" + +#: The sparse map page's marker — ALL page 4's text layer carries (21 +#: stripped chars, under the 100-char bar — the ONE gate candidate; the +#: figure is vector-drawn, exactly like the live page-36 map). +MAP_MARKER = "Map of the Imperium" + +#: The phase-133 garbage line (the owner's exact shape) — 100% of the +#: scanned pages' text looks like tags, so ``is_usable_pdf_text`` fails +#: deterministically (the fallback class). +GARBAGE_LINE = "<illustrator:StartupProfile>Web</illustrator:StartupProfile>" +GARBAGE_LINES_PER_PAGE = 20 +N_SCANNED_PAGES = 2 + +#: The stored map-page block (the phase-140 LOCKED A4 shape) — the +#: mock's canned ``PICTORIAL_PAGE_MODE`` reply, deterministic. +MAP_BLOCK = f"[Page 4 illustration: {PICTORIAL_PAGE_ANSWER}]" + +#: The PNG signature (the route/seed byte assertions' magic number). +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + +#: Phase 79 (task 04, full inventory): the conftest session app owns +#: its port in a combined run — this module app binds its own port +#: instead (a same-port second uvicorn dies on bind and would drive the +#: wrong server). Env-overridable. +APP_PORT = int(os.environ.get("E2E_APP_PORT_STRIPHEAL", "8177")) +APP_URL = f"http://127.0.0.1:{APP_PORT}" +MOCK_URL = f"http://127.0.0.1:{MOCK_PORT}" + +#: The suite's LLM-request ledger (assert 5): every sync stores the +#: mock's chat-completions capture (``/v1/e2e/captured`` — the phase-123 +#: house pattern) taken AROUND its own run under its name, so the +#: heal/repair syncs' ZERO-call pins read from the log the app itself +#: produced, not from a spy that never saw the real request. +LLM_CAPTURES: dict[str, list[dict[str, Any]]] = {} + + +# --------------------------------------------------------------------------- +# Fixture builders (the house PyMuPDF recipe — the three strip classes) +# --------------------------------------------------------------------------- + + +def _draw_map_figure(page: Any) -> None: + """The vector map figure: a rectangle (the province) + a road line + with a two-stroke head — drawn, not embedded, so the text layer + stays the ONLY text and the embedded-image pipeline sees nothing.""" + page.draw_rect(fitz.Rect(100, 120, 300, 220)) + page.draw_line(fitz.Point(320, 170), fitz.Point(470, 170)) + page.draw_line(fitz.Point(470, 170), fitz.Point(450, 158)) + page.draw_line(fitz.Point(470, 170), fitz.Point(450, 182)) + + +def _build_manual(path: Path) -> None: + """The Letumdei shape in MINATURE (the pictorial class): three dense + prose pages + ONE sparse map-style page. Usable text layer with + exactly one gate candidate (page 4) → the ADDITIVE strip class.""" + doc = fitz.open() + for i in range(1, 4): + p = doc.new_page() + rc = p.insert_textbox( + fitz.Rect(72, 72, 523, 772), f"Book {i}. {DENSE_PARAGRAPH}", fontsize=11 + ) + assert rc >= 0, f"the page-{i} paragraph must fit its text box" + p4 = doc.new_page() + p4.insert_text((72, 72), MAP_MARKER) + _draw_map_figure(p4) + doc.set_metadata({"title": MANUAL_TITLE}) + doc.save(path) + doc.close() + + +def _build_scanned(path: Path) -> None: + """The phase-133 fallback class: two pages whose ONLY text is the + XML-garbage line ×20 — the gate fails, so the page SCREENSHOTS are + the content (the PRIMARY strip class).""" + doc = fitz.open() + for page_num in range(1, N_SCANNED_PAGES + 1): + page = doc.new_page() + for i in range(GARBAGE_LINES_PER_PAGE): + page.insert_text((72, 72 + i * 14), f"{GARBAGE_LINE} p{page_num} l{i}") + doc.set_metadata({"title": SCANNED_TITLE}) + doc.save(path) + doc.close() + + +def _build_plain(path: Path) -> None: + """The negative class: two dense text pages, ZERO sparse candidates + — no strip ever (until the repair test seeds a stale one).""" + doc = fitz.open() + for i in (1, 2): + p = doc.new_page() + rc = p.insert_textbox( + fitz.Rect(72, 72, 523, 772), f"Note {i}. {DENSE_PARAGRAPH}", fontsize=11 + ) + assert rc >= 0, f"the page-{i} paragraph must fit its text box" + doc.set_metadata({"title": PLAIN_TITLE}) + doc.save(path) + doc.close() + + +@pytest.fixture(scope="module") +def pdfs_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: + """The local directory source's root: the three fixture PDFs, built + ONCE (module-scoped). The premise pins (deterministic — computed + from the fixtures THEMSELVES, no hardcoded page lists): ``manual`` + is usable-layer with candidates ``[4]`` (4 pages — the seed's + ``page_count`` target, the Letumdei shape), ``scanned`` is + UNUSABLE-layer (the fallback class), ``plain`` is usable-layer with + ZERO candidates (no strip). Every page renders to a real PNG (the + pipeline's byte premise).""" + root = tmp_path_factory.mktemp("bor_p163_strip") / SOURCE_NAME + root.mkdir(parents=True) + min_chars = Settings.model_fields["pdf_pictorial_min_chars"].default + manual = root / MANUAL_PATH + scanned = root / SCANNED_PATH + plain = root / PLAIN_PATH + _build_manual(manual) + _build_scanned(scanned) + _build_plain(plain) + manual_texts = extract_pdf_page_texts(manual) + assert is_usable_pdf_text(extract_pdf_text(manual)), ( + "the manual fixture must PASS the usability gate (the pictorial class)" + ) + assert pictorial_pages(manual_texts, min_chars=min_chars) == [4], ( + "the manual fixture's sparse-page premise broke: candidates=" + f"{pictorial_pages(manual_texts, min_chars=min_chars)}" + ) + assert not is_usable_pdf_text(extract_pdf_text(scanned)), ( + "the scanned fixture's text layer must FAIL the usability gate " + "(the fallback class)" + ) + assert is_usable_pdf_text(extract_pdf_text(plain)) + assert pictorial_pages( + extract_pdf_page_texts(plain), min_chars=min_chars + ) == [], "the plain fixture must carry ZERO sparse pages (the negative class)" + assert len(render_pdf_pages(manual)) == 4 + assert len(render_pdf_pages(scanned)) == N_SCANNED_PAGES + assert len(render_pdf_pages(plain)) == 2 + return root + + +@pytest.fixture(scope="module") +def scratch(tmp_path_factory: pytest.TempPathFactory) -> Path: + """The suite-private app homes (pages dir, upload dir, checkouts, + images) — disposable with the module.""" + return tmp_path_factory.mktemp("bor_p163_scratch") + + +@pytest.fixture(scope="module") +def page_dir(scratch: Path) -> Path: + """The app's ``pdf_page_dir`` (``BOR_PDF_PAGE_DIR``) for this suite + — the heal test DELETES the manual doc's copies here and the re-gate + re-creates them; the owner's ``~/bor-sources/pages`` default is + never touched.""" + return scratch / "pages" + + +# --------------------------------------------------------------------------- +# Module app (the conftest env pattern + the phase-140/148 knobs) +# --------------------------------------------------------------------------- + + +def _app_env(mock_llm: int, scratch: Path, page_dir: Path) -> dict[str, str]: + """The conftest app env (leak guards included) with the phase-140 + opt-in (``BOR_PDF_PICTORIAL`` EXPLICITLY on — the conftest pins it + off in ``os.environ`` for the pre-phase suites; this suite IS the + pictorial class + its kill-switch-mirroring re-gate) and the + phase-148 ``BOR_PDF_PAGE_DIR`` (suite-private — the heal mutates + this dir, and the app under test reads the same one).""" + 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 gates (conftest pattern). + 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 — the owner's local gitignored + # .env cannot leak the scope). + 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 126: the import scope WITH the ``pdf`` token (the walk is + # blind to the fixtures without it). + env["BOR_IMPORT_EXTENSIONS"] = "md,markdown,txt,pdf" + # Phase 140: the pictorial toggle EXPLICITLY on (the opt-in — + # without it the whole premise is void). + env["BOR_PDF_PICTORIAL"] = "1" + # Phase 148: the rendered-page copies' home (the heal's delete/ + # re-render target). + env["BOR_PDF_PAGE_DIR"] = str(page_dir) + # The embedded-image pipeline is out of scope (the figures are + # vector-drawn — no image docs either way). + env["BOR_IMAGES"] = "false" + env["BOR_UPLOAD_DIR"] = str(scratch / "uploads") + env["BOR_SOURCES_DIR"] = str(scratch / "checkouts") + env["BOR_IMAGE_DIR"] = str(scratch / "images") + return env + + +@pytest.fixture(scope="module") +def app_server( + mock_llm: int, + pdfs_dir: Path, # noqa: ARG001 — the dir's existence is the seed's job + scratch: Path, + page_dir: Path, +) -> Iterator[str]: + """The app under test (all five tests): one module app with the + phase-140/148/163 env (see :func:`_app_env`).""" + proc = subprocess.Popen( + [ + sys.executable, + "-m", + "uvicorn", + "app.main:app", + "--host", + "127.0.0.1", + "--port", + str(APP_PORT), + "--log-level", + "warning", + ], + cwd=REPO, + env=_app_env(mock_llm, scratch, page_dir), + ) + try: + _wait_http(f"{APP_URL}/api/health") + yield APP_URL + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +@pytest.fixture(scope="module") +def app_url(app_server: str) -> str: + return app_server + + +# --------------------------------------------------------------------------- +# DB + sync + LLM-capture helpers (the house patterns) +# --------------------------------------------------------------------------- + + +def _truncate_all() -> None: + """Fresh KB + registry (the E2E isolation pattern): the suites + share one Postgres, and a leftover row would corrupt the counts. + ``sources_meta`` (the KB generation counter) resets with the KB.""" + with SessionLocal() as db: + db.execute( + text( + "TRUNCATE chunks, documents, query_log, steering_notes, " + "kb_overview, git_sources, folder_summaries, sources_meta" + ) + ) + db.commit() # without the commit the TRUNCATE rolls back (the house pattern) + + +def _health_db(app_url: str) -> bool: + try: + return httpx.get(f"{app_url}/api/health", timeout=5).json()["db"] == "up" + except Exception: # noqa: BLE001 — unreachable is the skip case + return False + + +def _wait_sync_settled(base_url: str, timeout_s: float = 60.0) -> None: + """No background sync may leak across tests (the run state lives in + the app's memory): wait for the app's sync status to be non-running + BEFORE the truncate (the phase-96 pattern).""" + with httpx.Client(base_url=base_url, timeout=5.0) as client: + r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204, r.text + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + r = client.get("/api/sync/status") + assert r.status_code == 200, r.text + if r.json()["state"] != "running": + return + time.sleep(0.2) + raise AssertionError(f"a sync was still running at a test boundary: {base_url}") + + +@pytest.fixture(scope="module", autouse=True) +def _clean_kb(app_url: str) -> Iterator[None]: + """Module-end KB cleanup (the phase-96 pattern, module granularity): + the five tests SHARE the module seed's one source + three documents + and MUTATE shared state (the page dir, then one row) in file order, + so there is NO per-test truncate; the shared KB is flushed ONCE, + after the last test. The pre-wait keeps a leaked sync from racing + the truncate.""" + yield + _wait_sync_settled(app_url) + _truncate_all() + + +def _register_source(base_url: str, path: Path) -> None: + """Register the local directory through the authenticated API (the + ``test_local_directory_sources`` pattern — a plain directory is an + API/DB-only operation).""" + with httpx.Client(base_url=base_url, timeout=30.0) as client: + r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204, r.text + r = client.post("/api/git-sources", json={"kind": "local", "path": str(path)}) + assert r.status_code == 201, r.text + + +def _started_at(body: dict[str, Any]) -> float: + """The status body's ``started_at`` as a wall-clock epoch (the + server stamps it in UTC; same host, same clock).""" + started = body.get("started_at") + if not started: + return 0.0 + try: + return datetime.fromisoformat(str(started)).timestamp() + except ValueError: + return 0.0 + + +def _capture_reset() -> None: + """Clear the mock's chat-completions capture (the phase-123 house + endpoint) so the NEXT sync's LLM requests are exactly what the + capture holds afterwards. The MOCK-ONLY pin is skipped under + ``E2E_REAL_LLM=1`` (no capture to read — assert 5 skips too).""" + if USE_REAL_LLM: + return + r = httpx.post(f"{MOCK_URL}/v1/e2e/captured/reset", timeout=5.0) + assert r.status_code == 200, r.text + + +def _capture_read(name: str) -> list[dict[str, Any]]: + """Store the mock's current capture in the suite ledger under + *name* (see :data:`LLM_CAPTURES`).""" + if USE_REAL_LLM: + LLM_CAPTURES[name] = [] + return [] + r = httpx.get(f"{MOCK_URL}/v1/e2e/captured", timeout=5.0) + assert r.status_code == 200, r.text + captured = r.json() + LLM_CAPTURES[name] = captured + return captured + + +def _run_sync( + base_url: str, capture_name: str | None = None, timeout_s: float = 120.0 +) -> dict[str, Any]: + """Login + ``POST /api/sync`` + poll the status endpoint until THIS + run reaches a terminal state (the ``test_pdf_pages_in_sources`` + pattern — with the stale-terminal freshness gate). When + *capture_name* is given, the mock's request capture is reset before + the POST and stored under that name once the run settles — the + sync's ENTIRE LLM footprint, bracketed (assert 5's lens).""" + _capture_reset() + with httpx.Client(base_url=base_url, timeout=30.0) as client: + r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204, r.text + t0 = time.time() + r = client.post("/api/sync") + assert r.status_code == 202, r.text + saw_running = False + deadline = time.monotonic() + timeout_s + body: dict[str, Any] = {} + while time.monotonic() < deadline: + r = client.get("/api/sync/status") + assert r.status_code == 200, r.text + body = r.json() + state = body["state"] + if state == "running": + saw_running = True + elif state in ("success", "failed") and ( + saw_running + or _started_at(body) >= t0 - 1.0 # same-host clock; 1 s tolerance + ): + if capture_name is not None: + _capture_read(capture_name) + return body + time.sleep(0.1) + if capture_name is not None: + _capture_read(capture_name) + raise AssertionError(f"sync did not reach a terminal state: {body}") + + +def _doc_row(path: str) -> Document: + """The fixture session's view of one source row (the DB-lens the + heal/repair asserts read through — ``expire_on_commit`` off means + the values are the row's, freshly queried per call).""" + with SessionLocal() as db: + doc = db.scalar( + select(Document).where( + Document.source == SOURCE_NAME, Document.path == path + ) + ) + assert doc is not None, f"the {path} row must exist" + db.expunge(doc) + return doc + + +def _payload(app_url: str, path: str) -> dict[str, Any]: + """The document-content payload (the surface the viewer renders + from) as a dict — the ``page_urls`` / ``page_strip_mode`` key + presence rules are asserted on THIS JSON, not the DB.""" + with httpx.Client(base_url=app_url, timeout=30.0) as client: + r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204, r.text + r = client.get("/api/documents/content", params={"source": SOURCE_NAME, "path": path}) + assert r.status_code == 200, r.text + return r.json() + + +# --------------------------------------------------------------------------- +# The module seed: register the local dir + one sync (all three PDFs), +# then assert the three-class DB + filesystem state +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def seeded(app_url: str, pdfs_dir: Path, page_dir: Path) -> Iterator[dict[str, Any]]: + """Register the local directory source and run ONE real sync (the + index pass, its capture stored as ``LLM_CAPTURES["index"]``). + Asserts the post-index three-class contract: ``manual`` is the + pictorial class (``page_count`` 4, mode ``additive``, the canned + map block IN the content — the Letumdei shape), ``scanned`` the + fallback class (``page_count`` 2, mode ``primary``, the canned + screenshot descriptions, the garbage NOT stored), ``plain`` + unqualified (both columns NULL) — and the page dir holds EXACTLY + the two qualifying docs' six copies (real PNGs, no plain copies, no + orphans). Yields the ids + the page dir.""" + if not _health_db(app_url): + pytest.skip("Postgres not reachable — run `podman compose up -d db` first") + + _truncate_all() + _register_source(app_url, pdfs_dir) + body = _run_sync(app_url, capture_name="index") + assert body["state"] == "success", body + detail = body["detail"] + assert detail["files"] == 3, detail + assert detail["added"] == 3, detail + assert detail["pruned"] == 0, detail + assert detail["errors"] == 0, detail + + manual = _doc_row(MANUAL_PATH) + assert manual.title == MANUAL_TITLE + assert manual.page_count == 4, ( + f"the pictorial-class row must carry its page count: {manual.page_count}" + ) + assert manual.page_strip_mode == "additive", ( + "the pictorial class (usable layer + sparse candidate) must stamp " + f"additive: {manual.page_strip_mode!r}" + ) + # The phase-140 descriptions ride the stored content (Q&A parity) — + # the ONE map block is the live Letumdei shape in miniature. + assert "---PAGE-1---" in manual.content and "---PAGE-4---" in manual.content + assert MAP_BLOCK in manual.content, ( + "the map page's canned description must be in the stored content" + ) + assert manual.summary + + scanned = _doc_row(SCANNED_PATH) + assert scanned.page_count == N_SCANNED_PAGES + assert scanned.page_strip_mode == "primary", ( + "the fallback class (unusable layer) must stamp primary: " + f"{scanned.page_strip_mode!r}" + ) + assert scanned.content.startswith("---PAGE-1---") + assert IMAGE_DESCRIPTION_ANSWER in scanned.content, ( + "the fallback's content must be the canned page-screenshot descriptions" + ) + assert GARBAGE_LINE not in scanned.content, ( + "the garbage text layer must NOT be the stored content" + ) + + plain = _doc_row(PLAIN_PATH) + assert plain.page_count is None, "a zero-candidate PDF must not qualify" + assert plain.page_strip_mode is None + + manual_id, scanned_id, plain_id = str(manual.id), str(scanned.id), str(plain.id) + + # The filesystem contract: EXACTLY the two qualifying docs' copies + # (no plain copies, no orphans) — real, non-empty PNGs. + expected = sorted( + [f"{manual_id}-p{n}.png" for n in range(1, 5)] + + [f"{scanned_id}-p{n}.png" for n in range(1, N_SCANNED_PAGES + 1)] + ) + pngs = sorted(p.name for p in page_dir.glob("*.png")) + assert pngs == expected, f"the page dir holds {pngs}, expected {expected}" + for name in pngs: + data = (page_dir / name).read_bytes() + assert data[:8] == PNG_MAGIC and data, f"{name} is not a real PNG" + assert not list(page_dir.glob(f"{plain_id}-*")), ( + "the non-qualifying PDF must not have page copies" + ) + yield { + "manual_id": manual_id, + "scanned_id": scanned_id, + "plain_id": plain_id, + "page_dir": page_dir, + } + + +# --------------------------------------------------------------------------- +# Shared UI helpers +# --------------------------------------------------------------------------- + + +def _drill_to_source(page: Page, name: str) -> None: + """Sources tree: top level → the source (the file table then holds + its direct files — all three fixtures sit at the directory 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 _open_doc_modal(page: Page, doc_path: str) -> Locator: + """The house doc-modal idiom: the row's path link opens the + same-page modal (no new tab). Returns the content slot locator.""" + before = len(page.context.pages) + page.locator(f'#docs-tbody a.doc-link:text-is("{doc_path}")').click() + assert len(page.context.pages) == before, "the row link must not open a new tab" + modal = page.locator("#doc-modal") + expect(modal).to_be_visible(timeout=30_000) + return page.locator("#doc-modal-content") + + +def _wait_img_loaded(img: Locator) -> None: + """Wait until a LAZY ``<img>`` really loaded pixels: scroll it into + the viewport, await the browser's own ``decode()`` promise (it + rejects on a failed fetch — a 404 route), and assert + ``naturalWidth > 0`` (a real PNG, not a zero-size error image).""" + img.scroll_into_view_if_needed() + img.evaluate("el => el.decode()") + natural = img.evaluate("el => el.naturalWidth") + assert natural > 0, f"the page image did not load (naturalWidth={natural})" + + +def _assert_figures(strip: Locator, title: str, doc_id: str, page_count: int) -> None: + """The phase-148 figure contract inside a (visible) strip: exactly + *page_count* ``.doc-page`` figures, each with a LOADED lazy + ``<img>`` (the route served a real PNG — not the "Page N + unavailable" note), the ``Page N of <title>`` alt, the exact 1-based + route src, and the ``Page N`` caption.""" + figures = strip.locator(".doc-page") + expect(figures).to_have_count(page_count) + for i in range(page_count): + n = i + 1 + fig = figures.nth(i) + img = fig.locator("img") + expect(img).to_have_count(1) + expect(img).to_have_attribute("loading", "lazy") + expect(img).to_have_attribute("alt", f"Page {n} of {title}") + expect(img).to_have_attribute("src", f"/api/documents/{doc_id}/pages/{n}") + _wait_img_loaded(img) + expect(fig.locator("figcaption.doc-page-num")).to_have_text(f"Page {n}") + + +# --------------------------------------------------------------------------- +# 1. The additive view: text first, the strip one toggle away, no +# refetch on flip-flop +# --------------------------------------------------------------------------- + + +def test_additive_view(page: Page, app_url: str, seeded: dict[str, Any]) -> None: + """The ``manual.pdf`` modal — the ADDITIVE class (LOCKED A1/A6): + the content payload stamps the class (``page_strip_mode == + "additive"`` beside the 4 ``page_urls``); the TEXT view opens by + default — Summary panel + content visible, the toggle's ``Text`` + ``aria-pressed="true"``, the strip mounted but hidden, the map + page's canned description RIDING the shown text (the text is never + hidden for the pictorial class); pressing ``Pages`` shows the + 4-figure strip with EVERY img loaded (naturalWidth > 0 — the route + serves; no "unavailable" note anywhere) and hides the text view; + pressing ``Text`` back shows the text, hides the strip, and the + strip node is the SAME mounted DOM element (a dataset probe survives + the flip — no re-render) with its 4 imgs still loaded (no + refetch).""" + # The wire pin: the class is stamped next to the page_urls. + payload = _payload(app_url, MANUAL_PATH) + assert payload.get("page_strip_mode") == "additive", payload + assert payload.get("page_urls") == [ + f"/api/documents/{seeded['manual_id']}/pages/{n}" for n in range(1, 5) + ], payload + + login(page, app_url) # lands on /sources.html + _drill_to_source(page, SOURCE_NAME) + expect(page.locator("#docs-tbody tr")).to_have_count(3) + content = _open_doc_modal(page, MANUAL_PATH) + expect(page.locator("#doc-modal-title")).to_have_text(MANUAL_TITLE) + + # Default view: TEXT (LOCKED A6) — Summary panel + content visible, + # the strip mounted but hidden (its lazy imgs never fetched yet). + toggle = content.locator(".doc-view-toggle") + expect(toggle).to_have_count(1) + expect(toggle.get_by_role("button", name="Text")).to_have_attribute( + "aria-pressed", "true" + ) + expect(toggle.get_by_role("button", name="Pages")).to_have_attribute( + "aria-pressed", "false" + ) + expect(content.locator("pre.doc-raw")).to_be_visible() + expect(content.locator(".doc-summary")).to_be_visible() + expect(content.locator(".doc-pages")).to_be_hidden() + assert MAP_MARKER in content.inner_text(), "the text view shows the book's text" + assert PICTORIAL_PAGE_ANSWER in content.inner_text(), ( + "the additive text view shows the indexed content INCLUDING the " + "map page's description (LOCKED A1 — the text is never hidden)" + ) + + # Pages: the strip shows, every copy loads, the text view hides. + toggle.get_by_role("button", name="Pages").click() + expect(content.locator(".doc-pages")).to_be_visible() + _assert_figures(content.locator(".doc-pages"), MANUAL_TITLE, seeded["manual_id"], 4) + assert "unavailable" not in content.inner_text(), ( + "no page may show the 'Page N unavailable' note once the strip shows" + ) + expect(content.locator("pre.doc-raw")).not_to_be_visible() + expect(content.locator(".doc-summary")).not_to_be_visible() + # Tag the strip's FIRST img so the flip-back can prove the node is + # the same DOM element (a re-render would build a fresh one). + first_img = content.locator(".doc-pages .doc-page img").first + first_img.evaluate("el => { el.dataset.p163probe = 'mounted'; }") + + # Text: the reversal — text visible, strip hidden, the strip node + # STILL MOUNTED with its 4 loaded imgs (zero refetch). + toggle.get_by_role("button", name="Text").click() + expect(toggle.get_by_role("button", name="Text")).to_have_attribute( + "aria-pressed", "true" + ) + expect(content.locator("pre.doc-raw")).to_be_visible() + expect(content.locator(".doc-pages")).to_be_hidden() + expect(content.locator(".doc-pages .doc-page img")).to_have_count(4) + assert ( + content.locator(".doc-pages .doc-page img").first.evaluate( + "el => el.dataset.p163probe" + ) + == "mounted" + ), "flipping back must reuse the mounted strip (no re-render, no refetch)" + assert content.locator(".doc-pages .doc-page img").first.evaluate( + "el => el.complete && el.naturalWidth > 0" + ), "the mounted imgs stay decoded across the flip (no reset)" + + +# --------------------------------------------------------------------------- +# 2. The primary class (byte-identical strip-only DOM) + the plain doc +# --------------------------------------------------------------------------- + + +def test_primary_and_plain_doms(page: Page, app_url: str, seeded: dict[str, Any]) -> None: + """The two untouched shapes, pinned side by side. ``scanned.pdf`` + (the fallback class, payload ``page_strip_mode == "primary"``) + keeps the phase-148 strip-only DOM byte-identically: NO + ``.doc-view-toggle``, NO ``.doc-summary``, NO content block — just + the 2-figure strip, every img loaded with the ``Page N`` captions. + ``plain.pdf`` (no strip) renders the plain text doc — the raw + content + the Summary panel, ZERO ``.doc-pages`` / + ``.doc-view-toggle`` nodes — and its payload carries NEITHER + ``page_urls`` NOR ``page_strip_mode`` (the ABSENT-never-null + omission rule: the non-qualifying payload is byte-identical to + pre-phase).""" + # The wire pins: primary stamped; both keys absent for plain. + payload = _payload(app_url, SCANNED_PATH) + assert payload.get("page_strip_mode") == "primary", payload + assert payload.get("page_urls") == [ + f"/api/documents/{seeded['scanned_id']}/pages/{n}" + for n in range(1, N_SCANNED_PAGES + 1) + ], payload + plain_payload = _payload(app_url, PLAIN_PATH) + assert "page_urls" not in plain_payload, plain_payload + assert "page_strip_mode" not in plain_payload, plain_payload + + login(page, app_url) # lands on /sources.html + _drill_to_source(page, SOURCE_NAME) + + # The primary class: the strip IS the view — no toggle, no Summary + # panel, no content block (the phase-148 DOM, byte-for-byte). + content = _open_doc_modal(page, SCANNED_PATH) + expect(page.locator("#doc-modal-title")).to_have_text(SCANNED_TITLE) + expect(content.locator(".doc-view-toggle")).to_have_count(0) + expect(content.locator(".doc-summary")).to_have_count(0) + expect(content.locator("pre.doc-raw")).to_have_count(0) + strip = content.locator(".doc-pages") + expect(strip).to_have_count(1) + expect(strip).to_be_visible() + _assert_figures(strip, SCANNED_TITLE, seeded["scanned_id"], N_SCANNED_PAGES) + + # The plain doc: the pre-phase text rendering — no strip artifacts. + page.goto(f"{app_url}/sources.html") + _drill_to_source(page, SOURCE_NAME) + content = _open_doc_modal(page, PLAIN_PATH) + expect(page.locator("#doc-modal-title")).to_have_text(PLAIN_TITLE) + expect(content.locator(".doc-pages")).to_have_count(0) + expect(content.locator(".doc-view-toggle")).to_have_count(0) + expect(content.locator("pre.doc-raw")).to_have_count(1) + expect(content.locator("pre.doc-raw")).to_contain_text("---PAGE-1---") + expect(content.locator("pre.doc-raw")).to_contain_text("Note 2.") + expect(content.locator(".doc-summary")).to_have_count(1) + + +# --------------------------------------------------------------------------- +# 3. The heal: copies deleted under a stamped page_count → one +# unchanged-byte sync restores everything +# --------------------------------------------------------------------------- + + +def test_heal_restores_deleted_copies( + page: Page, app_url: str, seeded: dict[str, Any] +) -> None: + """The live ``Letumdei`` failure mode, reproduced and healed: delete + ALL ``pdf_page_dir/<manual-id>-p*.png`` (the row still claims 4) → + a page route 404s (the exact live evidence) → run a sync over + UNCHANGED bytes (the phase-147 fast path) → the four copies exist + again (real PNGs), the route serves 200 with PNG bytes, the row + keeps ``page_count`` 4 / mode ``additive``, the payload still + carries ``page_urls`` + ``additive``, and the viewer's Pages view + loads EVERY img — the disk-level loss the owner saw cannot outlive + one sync (LOCKED A5, the D5 promise).""" + manual_id = seeded["manual_id"] + page_dir: Path = seeded["page_dir"] + copies = sorted(page_dir.glob(f"{manual_id}-p*.png")) + assert len(copies) == 4, f"the seed must leave 4 copies: {[p.name for p in copies]}" + for p in copies: + p.unlink() + assert not list(page_dir.glob(f"{manual_id}-p*.png")), "the wipe must be complete" + # The live-loss state on the wire: the row claims 4, the route 404s. + r = page.request.get(f"{app_url}/api/documents/{manual_id}/pages/1") + assert r.status == 404, f"the wiped copy must 404 before the heal: {r.status}" + + # One sync over unchanged bytes — the fast path + the re-gate. The + # run's whole LLM footprint is captured (assert 5 consumes it). + body = _run_sync(app_url, capture_name="heal") + assert body["state"] == "success", body + detail = body["detail"] + assert detail["unchanged"] == 3, detail + assert detail["added"] == 0, detail + assert detail["errors"] == 0, detail + + # The disk: the four copies exist again — real PNGs. + restored = sorted(page_dir.glob(f"{manual_id}-p*.png")) + assert [p.name for p in restored] == [ + f"{manual_id}-p{n}.png" for n in range(1, 5) + ], f"the heal left {[p.name for p in restored]}" + for p in restored: + data = p.read_bytes() + assert data[:8] == PNG_MAGIC and data, f"{p.name} is not a real PNG after heal" + + # The route serves again; the payload keeps the strip + the class. + r = page.request.get(f"{app_url}/api/documents/{manual_id}/pages/1") + assert r.status == 200, f"the healed copy must serve: {r.status}" + assert r.headers["content-type"] == "image/png", r.headers + assert r.body()[:8] == PNG_MAGIC, "the healed route served non-PNG bytes" + payload = _payload(app_url, MANUAL_PATH) + assert payload.get("page_strip_mode") == "additive", payload + assert len(payload.get("page_urls", [])) == 4, payload + + # The row (the fixture session's view): count + class intact. + row = _doc_row(MANUAL_PATH) + assert row.page_count == 4 + assert row.page_strip_mode == "additive" + + # The viewer, complete: every page image loads through the route. + login(page, app_url) + _drill_to_source(page, SOURCE_NAME) + content = _open_doc_modal(page, MANUAL_PATH) + content.locator(".doc-view-toggle").get_by_role("button", name="Pages").click() + expect(content.locator(".doc-pages")).to_be_visible() + _assert_figures(content.locator(".doc-pages"), MANUAL_TITLE, manual_id, 4) + assert "unavailable" not in content.inner_text(), ( + "after the heal the viewer must show zero 'Page N unavailable' notes" + ) + + +# --------------------------------------------------------------------------- +# 4. The repair arm: a stale page_count on a now-ineligible doc is +# cleared, with its copies +# --------------------------------------------------------------------------- + + +def test_repair_clears_stale_strip( + page: Page, app_url: str, seeded: dict[str, Any] +) -> None: + """The stale-eligible state NO current index produces, seeded + direct-DB: ``plain.pdf``'s row gets ``page_count`` 2 + mode + ``additive`` and two stale copies land on disk (the strip-shape a + pre-phase bug or a lowered ``pdf_pictorial_min_chars`` leaves + behind). One sync (the bytes stay unchanged) → the REPAIR arm: both + columns NULL again (read through the fixture session), the stale + copies GONE, and the modal shows the plain text doc — the strip is + finished. The other docs' strips are untouched.""" + plain_id = seeded["plain_id"] + page_dir: Path = seeded["page_dir"] + # The seed: the row claims a strip its current gate would never set. + with SessionLocal() as db: + doc = db.scalar( + select(Document).where( + Document.source == SOURCE_NAME, Document.path == PLAIN_PATH + ) + ) + assert doc is not None + doc.page_count = 2 + doc.page_strip_mode = "additive" + db.commit() + stale = [page_dir / f"{plain_id}-p{n}.png" for n in (1, 2)] + for p in stale: + p.write_bytes(b"STALE-PRE-PHASE-COPY") # not even a real PNG + + body = _run_sync(app_url, capture_name="repair") + assert body["state"] == "success", body + detail = body["detail"] + assert detail["unchanged"] == 3, detail + assert detail["errors"] == 0, detail + + # The row (the fixture session's view): both columns cleared. + row = _doc_row(PLAIN_PATH) + assert row.page_count is None, ( + f"the repair arm must clear a stale page_count: {row.page_count}" + ) + assert row.page_strip_mode is None, ( + f"the repair arm must clear the mode with the count: {row.page_strip_mode}" + ) + # The stale copies are gone; the other docs' copies untouched. + assert not list(page_dir.glob(f"{plain_id}-*")), ( + "the repair arm must delete the stale copies" + ) + assert len(list(page_dir.glob(f"{seeded['manual_id']}-p*.png"))) == 4, ( + "the repair of one doc must not touch another doc's copies" + ) + assert len(list(page_dir.glob(f"{seeded['scanned_id']}-p*.png"))) == 2 + + # The wire + the viewer: the strip is gone, the plain text doc is + # back (the same payload shape as test 2 pinned). + assert "page_urls" not in _payload(app_url, PLAIN_PATH) + login(page, app_url) + _drill_to_source(page, SOURCE_NAME) + content = _open_doc_modal(page, PLAIN_PATH) + expect(content.locator(".doc-pages")).to_have_count(0) + expect(content.locator(".doc-view-toggle")).to_have_count(0) + expect(content.locator("pre.doc-raw")).to_be_visible() + + +# --------------------------------------------------------------------------- +# 5. Zero LLM calls on the fast-path repair (LOCKED A5) +# --------------------------------------------------------------------------- + + +def test_zero_llm_calls_on_heal_and_repair(seeded: dict[str, Any]) -> None: + """The mock's request log (the house capture pattern): the INITIAL + index burned the vision passes it owes — at least one + ``PICTORIAL_PAGE_MODE`` request (the map page) and at least one + ``IMAGE_DESCRIPTION_MODE`` request (the fallback screenshots), the + witnesses that the capture really sees the app's calls — while the + HEAL sync and the REPAIR sync each captured EXACTLY ZERO + chat-completions requests. The re-gate mirrors the gate + deterministically from the current bytes + settings: rendering is + CPU-only, so the self-heal burns zero LLM/vision calls (LOCKED A5).""" + if USE_REAL_LLM: + pytest.skip("the zero-call pin reads the mock's request capture") + index = LLM_CAPTURES.get("index") + heal = LLM_CAPTURES.get("heal") + repair = LLM_CAPTURES.get("repair") + assert index is not None and heal is not None and repair is not None, ( + "the suite ledger must hold all three syncs' captures — run the tests in order" + ) + dumped = json.dumps(index) + assert "PICTORIAL_PAGE_MODE" in dumped, ( + "the initial index must have burned the pictorial vision pass — " + "without this witness the zero-call pin below is vacuous" + ) + assert "IMAGE_DESCRIPTION_MODE" in dumped, ( + "the initial index must have burned the fallback screenshot pass" + ) + heal_content = [b for b in heal if not _is_health_probe(b)] + repair_content = [b for b in repair if not _is_health_probe(b)] + assert heal_content == [], f"the heal sync burned LLM calls: {_summarize(heal_content)}" + assert repair_content == [], ( + f"the repair sync burned LLM calls: {_summarize(repair_content)}" + ) + + +def _is_health_probe(body: dict[str, Any]) -> bool: + """The sync prelude's fixed model-health ping (``app.rag.llm + .check_models`` — one non-streaming ``ping`` chat every sync ever + run pays, unchanged by phase 163). Exempted from the zero-call + pins; NOTHING else may appear in a heal/repair capture.""" + msgs = body.get("messages") or [] + return ( + not body.get("stream") + and len(msgs) == 1 + and msgs[0].get("content") == "ping" + ) + + +def _summarize(captured: list[dict[str, Any]]) -> str: + """A compact one-liner per captured request (its system-prompt + marker) for the failure messages — keeps a violation readable + without dumping whole prompt bodies.""" + out = [] + for body in captured: + msgs = body.get("messages", []) + first = str(msgs[0].get("content", ""))[:80] if msgs else "?" + out.append(f"chat(stream={bool(body.get('stream'))}): {first}") + return " | ".join(out) diff --git a/tests/e2e/test_pictorial_pdf.py b/tests/e2e/test_pictorial_pdf.py index 185f04f..653dacf 100644 --- a/tests/e2e/test_pictorial_pdf.py +++ b/tests/e2e/test_pictorial_pdf.py @@ -24,11 +24,14 @@ format — the upload gate admits it) and arriving through the REAL admin upload flow → the RAG page's Sync button: * (a) the document appears on the Sources page and the document viewer - shows the PDF's ACTUAL pages (phase 148 — a described sparse page - makes the doc page-QUALIFYING: the page strip REPLACES the text - content AND the Summary panel, the owner's TODO L5 contract): two - ``.doc-page`` figures with loaded lazy images + ``Page N`` captions, - NO ``.doc-raw``, NO ``.doc-summary``; the described content itself + opens the PDF in its ADDITIVE view (phase 163 — a described sparse + page makes the doc page-QUALIFYING, and a usable text layer makes + the strip ADDITIVE: the text stays, the pages sit behind a + Text/Pages toggle — LOCKED A1/A6): the Text view first (``.doc-raw`` + + ``.doc-summary`` visible, ``Text`` pressed), the strip one + ``Pages`` click away — two ``.doc-page`` figures with loaded lazy + images + ``Page N`` captions, the text view hidden while it shows; + the described content itself (page 1's prose WITHOUT an illustration block, page 2 WITH ``[Page 2 illustration: <the mock's canned PICTORIAL_PAGE_ANSWER>]`` — the task-02 ``PICTORIAL_PAGE_MODE`` branch, deterministic) stays in @@ -83,14 +86,17 @@ Test → observable mapping (Playwright Mapping Rule): Sources page reads 1 doc / 3 chunks (2 page-anchored content chunks + the summary chunk — no image doc), the drill-down table lists the PDF as a plain text row (LOCKED A4 — a text document, no thumbnail), and - the same-page document viewer (the row's path link) shows the - PHASE-148 PAGE STRIP under the METADATA title (the fixture is - page-qualifying — the pictorial pass described its sparse page): - exactly two ``.doc-page`` figures, each a LOADED lazy ``<img>`` - (naturalWidth > 0) with the ``Page N of <title>`` alt + the - ``Page N`` caption, NO ``.doc-raw`` / NO ``.doc-summary`` (the - page descriptions stay in the DB for Q&A, not displayed — the - owner's contract). + the same-page document viewer (the row's path link) opens the + PHASE-163 ADDITIVE VIEW under the METADATA title (the fixture is + page-qualifying with a usable text layer — the pictorial pass + described its sparse page): the TEXT view first — ``.doc-raw`` + + ``.doc-summary`` visible with the ``[Page 2 illustration: …]`` + block among them, the toggle's ``Text`` pressed, the strip mounted + but hidden; pressing ``Pages`` shows exactly two ``.doc-page`` + figures, each a LOADED lazy ``<img>`` (naturalWidth > 0) with the + ``Page N of <title>`` alt + the ``Page N`` caption while the text + view hides (mounted for the flip-back — the text is never + destroyed). 2. ``test_chat_question_grounds_and_echoes_the_illustration`` — the raw SSE over the scripted seeded-read question (httpx — the ``done`` event is the wire lens the task pins; the UI flow's premise is the @@ -578,8 +584,8 @@ def seeded( ) # The non-markdown doc got its lite summary (the mock's # SUMMARY_MODE digest) — the summary chunk exists (the - # viewer's Summary panel is suppressed by the phase-148 - # strip — the summary itself stays in the DB). + # additive view shows the Summary panel; the summary + # itself stays in the DB regardless). assert doc.summary # Phase 148: the described sparse page makes this doc # page-QUALIFYING — the strip's payload (page_urls) rides @@ -602,18 +608,19 @@ def test_sources_page_and_viewer_show_the_described_page( content chunks + the phase-30 summary chunk — no image doc; the vector figure is invisible to the embedded-image pipeline). The drill-down table lists the PDF as a plain text row (LOCKED A4), and - the same-page document viewer (the row's path link, phase 26) shows - the PHASE-148 PAGE STRIP under the METADATA title: the fixture is - page-qualifying (the pictorial pass described its sparse page), so - the strip REPLACES the text content AND the Summary panel — two - ``.doc-page`` figures, each a loaded lazy ``<img>`` with the - ``Page N of <title>`` alt + the ``Page N`` caption, NO - ``.doc-raw``, NO ``.doc-summary``. The described content (page 1's - prose WITHOUT an illustration block, page 2 WITH - ``[Page 2 illustration: <canned>]`` — LOCKED A4) stays in the DB - for Q&A: the seed asserted it byte-exact, the chat test proves it - reaches the model — it is simply not displayed (the owner's - contract).""" + the same-page document viewer (the row's path link, phase 26) + opens the PHASE-163 ADDITIVE VIEW (LOCKED A1/A6): the TEXT view + first — Summary panel + ``.doc-raw`` visible, the Text/Pages + toggle's ``Text`` pressed — with the ``[Page 2 illustration: + <canned>]`` block riding the SHOWN text (the text is never hidden + for the pictorial class). Pressing ``Pages`` reveals the strip — + two ``.doc-page`` figures, each a loaded lazy ``<img>`` with the + ``Page N of <title>`` alt + the ``Page N`` caption — while the + text view is hidden (mounted, not removed: flipping back shows it + with zero refetch). The described content (page 1's prose WITHOUT + an illustration block, page 2 WITH the block — LOCKED A4) also + stays in the DB for Q&A: the seed asserted it byte-exact, the chat + test proves it reaches the model.""" login(page, app_url) # lands on /sources.html expect(page.locator("#stat-docs")).to_have_text("1") expect(page.locator("#stat-chunks")).to_have_text("3") @@ -630,13 +637,31 @@ def test_sources_page_and_viewer_show_the_described_page( expect(modal).to_be_visible(timeout=30_000) expect(page.locator("#doc-modal-title")).to_have_text(PDF_TITLE) - # Phase 148: the page-qualifying fixture shows its ACTUAL pages — - # a .doc-pages strip with exactly two .doc-page figures (the - # 2-page fixture), each a LOADED lazy <img> (naturalWidth > 0 — - # the route served a real PNG) with the "Page N of <title>" alt + - # the "Page N" caption. The text content AND the Summary panel are - # SUPPRESSED (the owner's contract — the descriptions stay in the - # DB for Q&A, not displayed). + # Phase 163 (task 04, LOCKED A1/A6): the page-qualifying fixture + # with a usable text layer is an ADDITIVE doc — it opens on its + # TEXT view: Summary panel + content visible, Text pressed, the + # strip mounted but hidden (its lazy imgs never fetched). + toggle = modal.locator(".doc-view-toggle") + expect(toggle).to_have_count(1) + expect(toggle.get_by_role("button", name="Text")).to_have_attribute( + "aria-pressed", "true" + ) + expect(toggle.get_by_role("button", name="Pages")).to_have_attribute( + "aria-pressed", "false" + ) + expect(modal.locator("pre.doc-raw")).to_be_visible() + expect(modal.locator(".doc-summary")).to_be_visible() + expect(modal.locator(".doc-pages")).to_be_hidden() + # The canned page description RIDES the shown text (phase-140 + # blocks are part of the content — the additive view never hides + # the text). The node stays in the DOM, so a visibility-scoped + # inner_text() check would miss it — use the DOM text. + assert PICTORIAL_PAGE_ANSWER in modal.evaluate("el => el.textContent"), ( + "the additive text view shows the indexed content including " + "the described sparse page" + ) + # …and the strip is one Pages click away (phase 148's ask): + toggle.get_by_role("button", name="Pages").click() strip = modal.locator(".doc-pages") expect(strip).to_have_count(1) figures = strip.locator(".doc-page") @@ -657,17 +682,12 @@ def test_sources_page_and_viewer_show_the_described_page( expect(figures.nth(i).locator("figcaption.doc-page-num")).to_have_text( f"Page {n}" ) - # The suppression contract: no text content block, no Summary - # panel, no image block (the PDF is not an image document). - expect(modal.locator("pre.doc-raw")).to_have_count(0) - expect(modal.locator(".doc-summary")).to_have_count(0) + # While the strip shows, the text view is HIDDEN — mounted for + # the flip back (LOCKED A1), never removed. No image block (the + # PDF is not an image document). + expect(modal.locator("pre.doc-raw")).not_to_be_visible() + expect(modal.locator(".doc-summary")).not_to_be_visible() expect(modal.locator(".doc-image-img")).to_have_count(0) - # The canned description is in the DB (the seed asserted it) - # but nowhere in the rendered modal. - assert PICTORIAL_PAGE_ANSWER not in modal.inner_text(), ( - "the described page's text is in the DB for Q&A — it must not " - "be shown to the user (the owner's contract)" - ) # --------------------------------------------------------------------------- diff --git a/tests/integration/test_docs_api.py b/tests/integration/test_docs_api.py index b554184..22ddacb 100644 --- a/tests/integration/test_docs_api.py +++ b/tests/integration/test_docs_api.py @@ -10,7 +10,10 @@ per phase 124 A4) and the phase-148 (task 02) PDF page route (``GET /api/documents/{id}/pages/{n}`` — the same trust model and 404 posture for a page-qualifying document's rendered page PNGs) + the content endpoint's ``image_url`` / ``page_urls`` keys (present for the -qualifying row, ABSENT otherwise — never ``null``). +qualifying row, ABSENT otherwise — never ``null``), plus the phase-163 +(task 01) ``page_strip_mode`` class signal (``"primary"`` /``"additive"`` +for a stamped strip, ABSENT for a pre-phase row and for every +non-qualifying document). Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient. """ @@ -1381,7 +1384,8 @@ def test_content_endpoint_exposes_the_image_affordance( # --------------------------------------------------------------------------- # Phase 148 (task 02) — the PDF page BYTES route + the content payload's -# ``page_urls`` affordance +# ``page_urls`` affordance (phase 163 task 01 adds the ``page_strip_mode`` +# class signal beside it) # --------------------------------------------------------------------------- @@ -1404,6 +1408,7 @@ def _seed_page_doc( path: str = "besta.pdf", page_count: int | None = 2, write_copies: bool = True, + page_strip_mode: str | None = None, ) -> Document: """One PDF document row with ``page_count`` + its rendered page copies (the importer's ``pdf_page_dir`` layout — @@ -1411,7 +1416,8 @@ def _seed_page_doc( sentinel bytes, so a page mix-up between the two copies is caught. ``page_count=None`` is a non-qualifying PDF (no copies); ``write_copies=False`` leaves a qualifying row without its files - (the lost-copy corner).""" + (the lost-copy corner); ``page_strip_mode`` (phase 163) stamps the + strip CLASS — ``None`` is the pre-phase row.""" doc = Document( id=uuid.uuid4(), source=source, @@ -1424,6 +1430,7 @@ def _seed_page_doc( created_at=datetime(2026, 1, 1, tzinfo=UTC), summary="IKEA BESTA frame assembly manual (page descriptions).", page_count=page_count, + page_strip_mode=page_strip_mode, ) db.add(doc) db.flush() @@ -1610,6 +1617,79 @@ def test_content_endpoint_exposes_the_page_urls_affordance( ) assert r.status_code == 200 assert "page_urls" not in r.json() # absent — never null (non-qualifying PDF) + # Phase 163 (task 01): the class key follows the same rule — a + # pre-phase row (mode NULL) omits it while keeping page_urls. + assert "page_strip_mode" not in r.json() + finally: + _cleanup_kb(db) + + +@pytest.mark.parametrize("mode", ["primary", "additive"]) +def test_content_endpoint_carries_the_strip_class( + admin_client: TestClient, db, tmp_path: Path, mode: str +) -> None: + """Phase 163 (task 01, LOCKED A2/A3) — the real-DB round-trip of the + strip CLASS: a stamped row (``page_strip_mode`` = ``"primary"`` — the + phase-133 fallback class, the strip IS the view — or ``"additive"`` — + the phase-140 pictorial class, text + a Text/Pages toggle) serves the + class VERBATIM beside the untouched ``page_urls`` list (the viewer's + branch signal, task 04); every other key stands where phase 148 left + it.""" + _cleanup_kb(db) + try: + pages_dir = tmp_path / "pages" + _seed_page_doc(db, pages_dir, page_count=3, page_strip_mode=mode) + r = admin_client.get( + "/api/documents/content", params={"source": "PdfSrc", "path": "besta.pdf"} + ) + assert r.status_code == 200 + body = r.json() + assert body["page_strip_mode"] == mode + assert len(body["page_urls"]) == 3 # the strip rides along, unchanged + finally: + _cleanup_kb(db) + + +def test_content_strip_class_never_travels_without_the_strip( + admin_client: TestClient, db, tmp_path: Path +) -> None: + """Phase 163 (task 01) — the two strip keys are ONE block keyed off + ``page_count``: a row with a stray ``page_strip_mode`` and no strip + (``page_count`` NULL — the corner a partial repair could leave) + serializes NEITHER key, byte-identically to a plain non-qualifying + document (the viewer must never be handed a class for pages that do + not exist).""" + _cleanup_kb(db) + try: + pages_dir = tmp_path / "pages" + _seed_page_doc( + db, + pages_dir, + path="stray.pdf", + page_count=None, + write_copies=False, + page_strip_mode="additive", + ) + r = admin_client.get( + "/api/documents/content", params={"source": "PdfSrc", "path": "stray.pdf"} + ) + assert r.status_code == 200 + body = r.json() + assert "page_strip_mode" not in body + assert "page_urls" not in body + # The exact pre-phase key set (the byte-identity pin). + assert set(body) == { + "source", + "path", + "title", + "format", + "summary", + "created_at", + "content", + "indexed_at", + "chunks", + "is_image", + } finally: _cleanup_kb(db) diff --git a/tests/integration/test_migration_0027.py b/tests/integration/test_migration_0027.py new file mode 100644 index 0000000..bc2a786 --- /dev/null +++ b/tests/integration/test_migration_0027.py @@ -0,0 +1,370 @@ +"""Integration: migration 0027 (documents.page_strip_mode) schema contract +(phase 163, task 01). + +Drives the **real Alembic engine** against the live dev database +(``podman compose up -d db``), mirroring the house pattern of +``test_migration_0026.py`` (information_schema assertions on the state +the migration must leave). The tests target the 0026 → 0027 step +explicitly so later migrations cannot break the pins: + +* upgrade 0026 → 0027 → ``page_strip_mode`` exists with the full + contract — VARCHAR(16), NULLABLE, NO server default (NULL = "no strip" + for every pre-phase row, every non-qualifying document and every + fail-soft miss) and UNINDEXED (a display fact, never a query surface — + the content endpoint reads it from the ROW, the ``file_hash`` / + ``page_count`` convention) — while the 0026 ``documents`` schema + survives, in particular ``page_count`` keeping its 0026 contract and + ``content_hash`` staying NOT NULL and INDEXED; +* rows inserted while the DB is at 0026 backfill ``page_strip_mode`` to + NULL — including a row whose ``page_count`` IS set (the pre-phase strip + with no stamped class: the wire omits the key and the viewer keeps + today's "primary" DOM until the task-03 re-gate stamps it); +* the ORM contract agrees: a freshly inserted ``Document`` with no mode + reads ``page_strip_mode is None`` through a fresh session, and each of + the two classes (``"primary"`` / ``"additive"``) round-trips verbatim; +* downgrade to 0026 → the column is GONE (A13) while the row + its + content + its ``page_count`` survive; upgrade back to 0027 → the column + is back (round-trip). + +The ``alembic`` fixture guarantees the DB ends at head even if a test +fails or the process is interrupted. +""" +from __future__ import annotations + +import hashlib +import uuid +from collections.abc import Iterator +from typing import Any + +import pytest +from alembic.config import Config +from sqlalchemy import text +from sqlalchemy.orm import Session + +from alembic import command +from app.db import SessionLocal, db_available +from app.models import Document + +SOURCE = "mig0027" +CONTENT = "content here" +CONTENT_HASH = hashlib.sha256(CONTENT.encode()).hexdigest() + + +@pytest.fixture() +def alembic(db: Session) -> Iterator[Config]: + """Real Alembic config bound to the dev DB (URL from app settings). + + Starts at head (repairs an interrupted earlier run); teardown + upgrades to head no matter what happened, so the dev DB is never + left below head. + """ + if not db_available(): + pytest.skip("Postgres not reachable — run `podman compose up -d db` first") + cfg = Config() # no alembic.ini file — env.py gets the URL from app config + cfg.set_main_option("script_location", "alembic") + command.upgrade(cfg, "head") + try: + yield cfg + finally: + # Release the test session's open transaction BEFORE the repair + # DDL: an idle-in-transaction SELECT holds an ACCESS SHARE lock + # on ``documents``, which would deadlock the repair's + # ``ALTER TABLE`` forever. + db.rollback() + command.upgrade(cfg, "head") + + +def _version(db: Session) -> str | None: + return db.execute(text("SELECT version_num FROM alembic_version")).scalar() + + +def _column(db: Session, column: str) -> tuple[Any, ...] | None: + """(data_type, is_nullable, column_default, character_maximum_length) + for one documents column.""" + row = db.execute( + text( + "SELECT data_type, is_nullable, column_default, character_maximum_length" + " FROM information_schema.columns" + " WHERE table_name = 'documents' AND column_name = :c" + ), + {"c": column}, + ).fetchone() + return tuple(row) if row is not None else None + + +def _index_covering(db: Session, column: str) -> str | None: + """The name of any documents index whose definition mentions the + column (None = UNINDEXED).""" + return db.execute( + text( + "SELECT indexname FROM pg_indexes" + " WHERE tablename = 'documents' AND indexdef ILIKE :like" + ), + {"like": f"%{column}%"}, + ).scalar() + + +def _insert_sql(db: Session, path: str, page_count: int | None = None) -> uuid.UUID: + """Insert one documents row with the PRE-0027 column set (the 0026 + shape — the mode column, when present, is omitted so a NULL backfill + is what the row reads; ``page_count`` is 0026's own column and may be + set to model a pre-phase strip).""" + doc_id = uuid.uuid4() + db.execute( + text( + "INSERT INTO documents (id, source, path, full_path, title, content," + " content_hash, page_count)" + " VALUES (:id, :s, :p, :fp, :t, :c, :h, :pc)" + ), + { + "id": doc_id, + "s": SOURCE, + "p": path, + "fp": f"/tmp/{path}", + "t": f"Doc {path}", + "c": CONTENT, + "h": CONTENT_HASH, + "pc": page_count, + }, + ) + db.commit() + return doc_id + + +def _delete(db: Session, doc_id: uuid.UUID) -> None: + db.execute(text("DELETE FROM documents WHERE id = :id"), {"id": doc_id}) + db.commit() + + +def _assert_0026_schema_survives(db: Session) -> None: + """The 0026 documents contract must survive the additive upgrade — + ``page_count`` keeps its INTEGER / NULLABLE / NO-default / UNINDEXED + shape (the sibling column this one rides) and ``content_hash`` stays + NOT NULL and indexed (the retriever's identity).""" + page_count = _column(db, "page_count") + assert page_count is not None, "documents.page_count (0026) must survive the upgrade" + assert page_count[0] == "integer", "page_count must stay INTEGER" + assert page_count[1] == "YES", "page_count must stay NULLABLE" + assert page_count[2] is None, "page_count must keep NO server default" + assert _index_covering(db, "page_count") is None, "page_count must stay UNINDEXED" + + content = _column(db, "content_hash") + assert content is not None and content[0] == "character varying" and content[1] == "NO", ( + "documents.content_hash (0001) must keep its contract" + ) + assert ( + _index_covering(db, "content_hash") is not None + ), "the ix_documents_content_hash index must survive the upgrade" + + indexed = _column(db, "indexed_at") + assert indexed is not None and indexed[0] == "timestamp with time zone", ( + "documents.indexed_at (0001) must survive the upgrade" + ) + is_image = _column(db, "is_image") + assert is_image is not None and is_image[0] == "boolean" and is_image[1] == "NO", ( + "documents.is_image (0022) must survive the upgrade" + ) + + +def test_upgrade_to_0027_adds_page_strip_mode(db: Session, alembic: Config) -> None: + """Upgrade 0026 → 0027: ``page_strip_mode`` exists with the full + contract (VARCHAR(16), NULLABLE, NO server default — NULL = "no + strip" — and UNINDEXED: a display fact, never a query surface), is + ABSENT at 0026, and every pre-0027 row backfills it to NULL — + including the row whose ``page_count`` IS set (the pre-phase strip + with no stamped class), while the 0026 table contract (the + ``page_count`` sibling, the indexed ``content_hash``) survives. + """ + command.downgrade(alembic, "0026") # start from the pre-0027 state + assert _version(db) == "0026" + assert _column(db, "page_strip_mode") is None, "page_strip_mode must be absent at 0026" + + plain_id = _insert_sql(db, "plain.pdf") # the 0026 column set, no strip + pre_strip_id = _insert_sql(db, "prephase.pdf", page_count=36) # a live pre-phase strip + + try: + command.upgrade(alembic, "0027") + assert _version(db) == "0027", "alembic_version must be at 0027" + + col = _column(db, "page_strip_mode") + assert col is not None, "documents.page_strip_mode is missing" + assert col[0] == "character varying", "page_strip_mode must be VARCHAR" + assert col[3] == 16, "page_strip_mode must be VARCHAR(16) — the two class names" + assert col[1] == "YES", ( + "page_strip_mode must be NULLABLE (NULL = no strip, and the pre-phase shape)" + ) + assert col[2] is None, ( + "page_strip_mode must carry NO server default — NULL is the meaningful" + " pre-phase state" + ) + assert _index_covering(db, "page_strip_mode") is None, ( + "page_strip_mode must be UNINDEXED — a display fact, never a query" + " surface (the content endpoint reads it from the row)" + ) + + # Every pre-0027 row backfills to NULL — no backfill, no default. + assert ( + db.execute( + text("SELECT page_count, page_strip_mode FROM documents WHERE id = :id"), + {"id": plain_id}, + ).fetchone() + == (None, None) + ), "a pre-0027 row without a strip must read both columns NULL" + assert ( + db.execute( + text("SELECT page_count, page_strip_mode FROM documents WHERE id = :id"), + {"id": pre_strip_id}, + ).fetchone() + == (36, None) + ), ( + "a pre-phase strip (page_count set) must backfill page_strip_mode to" + " NULL — the wire omits the key and the viewer keeps today's DOM" + ) + + _assert_0026_schema_survives(db) + finally: + _delete(db, plain_id) + _delete(db, pre_strip_id) + + +@pytest.mark.parametrize("mode", ["primary", "additive"]) +def test_orm_page_strip_mode_round_trips(db: Session, alembic: Config, mode: str) -> None: + """The ORM contract agrees with the column contract: each class + (``"primary"`` = the fallback class, ``"additive"`` = the pictorial + class) round-trips VERBATIM through a fresh session, and a row + created with no mode reads ``page_strip_mode is None`` (the + pre-phase shape — NULL until the importer stamps it).""" + command.upgrade(alembic, "head") + stamped = Document( + source=SOURCE, + path="stamped.pdf", + full_path="/tmp/stamped.pdf", + title="stamped", + content=CONTENT, + content_hash=CONTENT_HASH, + page_count=36, + page_strip_mode=mode, + ) + unstamped = Document( + source=SOURCE, + path="unstamped.md", + full_path="/tmp/unstamped.md", + title="unstamped", + content=CONTENT, + content_hash=CONTENT_HASH, + ) + db.add(stamped) + db.add(unstamped) + db.commit() + try: + with SessionLocal() as fresh: + reloaded = fresh.get(Document, stamped.id) + assert reloaded is not None, "the stamped row must be readable" + assert reloaded.page_strip_mode == mode, ( + f"the {mode!r} class must round-trip through the DB verbatim" + ) + assert reloaded.page_count == 36, "the sibling page_count must round-trip too" + reloaded_unstamped = fresh.get(Document, unstamped.id) + assert reloaded_unstamped is not None, "the unstamped row must be readable" + assert reloaded_unstamped.page_strip_mode is None, ( + "a row created without a mode must read NULL (the pre-phase shape)" + ) + assert reloaded_unstamped.content_hash == CONTENT_HASH, ( + "content_hash must be untouched by the page_strip_mode addition" + ) + finally: + _delete(db, stamped.id) + _delete(db, unstamped.id) + + +def test_downgrade_to_0026_drops_the_column(db: Session, alembic: Config) -> None: + """Downgrade 0027 → 0026: the page_strip_mode column is gone (A13 — + fully reversible, the class is re-derivable from the source file by + the deterministic re-gate) while the row + its content + its 0026 + ``page_count`` survive, and the rest of the table contract is intact.""" + command.upgrade(alembic, "head") + row = Document( + source=SOURCE, + path="down.pdf", + full_path="/tmp/down.pdf", + title="down", + content=CONTENT, + content_hash=CONTENT_HASH, + page_count=36, + page_strip_mode="additive", + ) + db.add(row) + db.commit() + try: + command.downgrade(alembic, "0026") + assert _version(db) == "0026" + assert _column(db, "page_strip_mode") is None, "page_strip_mode must be dropped" + + surviving = db.execute( + text( + "SELECT source, path, content, content_hash, page_count, is_image" + " FROM documents WHERE id = :id" + ), + {"id": row.id}, + ).fetchone() + assert surviving is not None and surviving[0] == SOURCE, ( + "the row must survive the column drop" + ) + assert surviving[1] == "down.pdf" and surviving[2] == CONTENT, ( + "path + content must survive the drop" + ) + assert surviving[3] == CONTENT_HASH, ( + "content_hash (the retriever's identity) must survive the drop" + ) + assert surviving[4] == 36, ( + "page_count (the 0026 sibling) must survive the drop — the strip" + " itself is untouched by this migration's reversal" + ) + assert surviving[5] is False, "is_image must survive the drop" + assert _index_covering(db, "content_hash") is not None, ( + "the content_hash index must survive the downgrade" + ) + finally: + _delete(db, row.id) + # Repair: the fixture teardown re-upgrades to head. + + +def test_upgrade_round_trip_restores_the_column(db: Session, alembic: Config) -> None: + """Downgrade to 0026, then upgrade back to 0027: ``page_strip_mode`` + is back with the full contract (VARCHAR(16), NULLABLE, NO server + default, UNINDEXED), a row seeded at 0026 — strip included — reads + the mode as NULL (the pre-phase shape on the round-trip), and the + 0026 schema (the ``page_count`` sibling, the indexed + ``content_hash``) is intact.""" + command.downgrade(alembic, "0026") + pre_id = _insert_sql(db, "round.pdf", page_count=36) + try: + command.upgrade(alembic, "0027") + assert _version(db) == "0027", "round-trip upgrade must land at 0027" + + col = _column(db, "page_strip_mode") + assert col is not None, "documents.page_strip_mode must be back" + assert col[0] == "character varying", "page_strip_mode must be VARCHAR after the round-trip" + assert col[3] == 16, "page_strip_mode must be VARCHAR(16) after the round-trip" + assert col[1] == "YES", "page_strip_mode must be NULLABLE after the round-trip" + assert col[2] is None, ( + "page_strip_mode must still carry NO server default after the round-trip" + ) + assert _index_covering(db, "page_strip_mode") is None, ( + "page_strip_mode must stay UNINDEXED after the round-trip" + ) + + assert ( + db.execute( + text("SELECT page_count, page_strip_mode FROM documents WHERE id = :id"), + {"id": pre_id}, + ).fetchone() + == (36, None) + ), ( + "a row seeded at 0026 must read page_strip_mode NULL after the" + " round-trip while its page_count stands" + ) + + _assert_0026_schema_survives(db) + finally: + _delete(db, pre_id) diff --git a/tests/unit/test_document_viewer.py b/tests/unit/test_document_viewer.py index 5af5cf4..df64a31 100644 --- a/tests/unit/test_document_viewer.py +++ b/tests/unit/test_document_viewer.py @@ -30,6 +30,17 @@ page-qualifying PDF row (``page_count`` set) adds ``page_urls`` = the exact 1-based ``/api/documents/<id>/pages/<i>`` list over ``1..page_count``. +Phase 163 (task 01) — the content payload's STRIP CLASS signal +(``page_strip_mode``: ``"primary"`` = the fallback class, the strip IS +the view; ``"additive"`` = the pictorial class, text + a Text/Pages +toggle): the model-level omission rule (ABSENT when ``None``, never +``null``), and the endpoint (stubbed session) — the key rides +``page_urls`` for a qualifying row, a PRE-PHASE row (``page_count`` set, +mode NULL — the exact live ``Letumdei`` state) serializes it ABSENT while +keeping ``page_urls``, and the whole strip block keys off +``page_count``, so a row with a stray mode and no strip carries neither +key (every non-qualifying payload byte-identical to pre-phase). + Phase 145 (task 03) — the content payload's git origin fields + the ``renderDocument`` "Original" anchor (modal AND /document.html, one site): @@ -316,10 +327,11 @@ def _text_doc_content( origin_url: str | None = None, origin_branch: str | None = None, page_urls: list[str] | None = None, + page_strip_mode: str | None = None, ) -> DocContent: """One text doc's content payload (the pre-phase shape) with the - phase-145 origin fields + the phase-148 ``page_urls`` optionally - set.""" + phase-145 origin fields + the phase-148 ``page_urls`` + the + phase-163 ``page_strip_mode`` optionally set.""" return DocContent( source="S", path="a.md", @@ -332,6 +344,7 @@ def _text_doc_content( origin_url=origin_url, origin_branch=origin_branch, page_urls=page_urls, + page_strip_mode=page_strip_mode, ) @@ -367,9 +380,29 @@ def test_doc_content_page_urls_absent_on_the_wire_when_unset() -> None: assert dumped["page_urls"] == urls # the exact 1-based route list -def _page_pdf_doc() -> Document: +def test_doc_content_page_strip_mode_absent_on_the_wire_when_unset() -> None: + """Phase 163 (task 01, LOCKED A2) — the model-level omission rule + (the ``image_url`` / ``page_urls`` precedent): no strip class + (``page_strip_mode`` ``None`` — every non-qualifying doc AND every + pre-phase row) serializes the key ABSENT — never ``null`` — so those + payloads are byte-identical to pre-phase. A stamped row carries the + class verbatim, beside ``page_urls``.""" + dumped = _text_doc_content().model_dump() + assert "page_strip_mode" not in dumped # absent — never null + for mode in ("primary", "additive"): + dumped = _text_doc_content( + page_urls=["/api/documents/d/pages/1"], page_strip_mode=mode + ).model_dump() + assert dumped["page_strip_mode"] == mode # the class verbatim + + +def _page_pdf_doc( + page_count: int | None = 2, page_strip_mode: str | None = None +) -> Document: """A page-QUALIFYING PDF row (phase 148): ``page_count`` set — the - endpoint derives ``page_urls`` from it (the exact 1-based list).""" + endpoint derives ``page_urls`` from it (the exact 1-based list). + ``page_strip_mode`` (phase 163) is the strip CLASS; ``None`` is the + PRE-PHASE row shape (the column NULL while the strip stands).""" doc = Document( id=uuid.uuid4(), source="Homelab", @@ -379,7 +412,8 @@ def _page_pdf_doc() -> Document: content="[Page 1 illustration: the parts tray]…", content_hash="e" * 64, is_image=False, # phase 122: the stub session never applies defaults - page_count=2, + page_count=page_count, + page_strip_mode=page_strip_mode, ) doc.indexed_at = datetime(2026, 8, 22, 1, 2, 3, tzinfo=UTC) doc.created_at = datetime(2026, 8, 20, 9, 0, 0, tzinfo=UTC) @@ -410,6 +444,79 @@ database): a page-qualifying PDF row (``page_count`` set) carries ] # the exact 1-based list, count == page_count assert body["is_image"] is False assert "image_url" not in body # absent — never null (not an image doc) + # Phase 163 (task 01): the pre-phase row (mode NULL) keeps the strip + # and serializes the class ABSENT — the viewer treats that as + # "primary", i.e. today's DOM. + assert "page_strip_mode" not in body + + +@pytest.mark.parametrize("mode", ["primary", "additive"]) +def test_content_page_strip_mode_carried_for_a_stamped_strip(mode: str) -> None: + """Phase 163 (task 01, LOCKED A2/A3) — the endpoint (stubbed +session, no +database): a page-qualifying PDF whose row carries the class + (``"primary"`` = the fallback class, ``"additive"`` = the pictorial + class) carries ``page_strip_mode`` VERBATIM next to ``page_urls`` — + the signal the viewer branches on so an additive strip never hides + the text (task 04). Nothing else in the payload moves.""" + doc = _page_pdf_doc(page_strip_mode=mode) + with _client_with_row((doc, 4)) as client: + r = client.get( + "/api/documents/content", + params={"source": "Homelab", "path": "ikea/besta.pdf"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["page_strip_mode"] == mode # the class verbatim + assert body["page_urls"] == [ + f"/api/documents/{doc.id}/pages/1", + f"/api/documents/{doc.id}/pages/2", + ] # the strip rides along, unchanged + + +def test_content_strip_block_keys_off_page_count_never_mode_alone() -> None: + """Phase 163 (task 01) — the class never appears alone: the whole + strip block keys off ``page_count``, so a row whose column somehow + holds a mode while ``page_count`` is NULL serializes NEITHER + ``page_strip_mode`` nor ``page_urls`` — the payload is byte-identical + to a plain non-qualifying document (there is no strip to show, and + the viewer must never be told a class for pages that do not exist).""" + doc = _page_pdf_doc(page_count=None, page_strip_mode="additive") + with _client_with_row((doc, 1)) as client: + r = client.get( + "/api/documents/content", + params={"source": "Homelab", "path": "ikea/besta.pdf"}, + ) + assert r.status_code == 200 + body = r.json() + assert "page_strip_mode" not in body # absent — the strip block is off + assert "page_urls" not in body # absent — never null + + +def test_content_non_qualifying_payload_key_set_is_byte_identical() -> None: + """Phase 163 (task 01) — the byte-identity pin: a non-qualifying PDF + row (``page_count`` NULL, mode NULL) carries EXACTLY the pre-phase + key set — neither strip key joins it (``is_image`` always present, + ``image_url`` / the origin keys / both strip keys absent).""" + doc = _page_pdf_doc(page_count=None) + with _client_with_row((doc, 1)) as client: + r = client.get( + "/api/documents/content", + params={"source": "Homelab", "path": "ikea/besta.pdf"}, + ) + assert r.status_code == 200 + assert set(r.json()) == { + "source", + "path", + "title", + "format", + "summary", + "created_at", + "content", + "indexed_at", + "chunks", + "is_image", + } def test_content_origin_url_carried_for_a_matching_git_row() -> None: diff --git a/tests/unit/test_importer_unchanged_fast_path.py b/tests/unit/test_importer_unchanged_fast_path.py index 532c5be..1a9fda5 100644 --- a/tests/unit/test_importer_unchanged_fast_path.py +++ b/tests/unit/test_importer_unchanged_fast_path.py @@ -31,6 +31,16 @@ the second sync): * the streamed hash is transparent (a >1 MiB file hashes identically to ``hashlib.sha256(bytes).hexdigest()``). +Phase 163 (task 03) extends this file with the PDF strip RE-GATE the +unchanged paths now run (``_regate_pdf_strip``) — the pre-phase "the +copies persist" assumption is what broke in production (a row claiming +``page_count = 36`` over ZERO files, every page 404ing forever): the +re-gate re-derives the strip from the current bytes + settings (ZERO +vision/LLM calls), heals missing copies, re-stamps a changed class, +clears a strip that no longer qualifies (kill switch included), stays +fail-soft, logs only when it ACTS, and costs a non-strip file zero PDF +parsing (spy-pinned). + The E2E surface (the idempotent second sync through the real UI + mock LLM) is pinned in ``tests/e2e/test_sync_changed_only.py`` (task 03). @@ -43,6 +53,7 @@ import logging import os from datetime import UTC, datetime, timedelta from pathlib import Path +from typing import Any import pymupdf as fitz import pytest @@ -50,6 +61,7 @@ from sqlalchemy import select from app.config import Settings from app.models import Document +from app.rag import importer from app.rag.importer import _file_hash, import_sources from tests.fakes import FakeEmbedder from tests.unit.test_importer import ( @@ -60,6 +72,7 @@ from tests.unit.test_importer import ( _VisionLLM, ) from tests.unit.test_pictorial_import import ( + _build_dense_pdf, _build_ikea_like_pdf, _PictorialLLM, _settings, @@ -454,6 +467,520 @@ def test_pre_phase_null_file_hash_image_runs_branch_and_stamps( _cleanup_source(db, root.name) +# ---------- phase 163 (task 03): the deterministic PDF strip re-gate ---------- +# +# The live defect (owner report, 2026-10-07): the `Letumdei` row carried +# `page_count = 36` over ZERO files on disk (the pre-phase-163 persist +# deleted the old copies BEFORE rendering, outside the DB transaction — +# a later per-file rollback restored the stale count over an empty +# directory; an external wipe of `pdf_page_dir` lands the same state). +# The phase-147 fast path believed "the copies persist" and never +# re-rendered, so the strip 404'd forever. `_regate_pdf_strip` now runs +# at the tail of `_unchanged_doc`: the changed-path gate mirrored from +# the current bytes + settings (ZERO vision/LLM calls), missing copies +# healed, a stale strip cleared. + +#: The PNG signature (a healed copy is a real render, not a placeholder). +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + + +def _regate_settings(tmp_path: Path, **kwargs: Any) -> Settings: + """Settings for the re-gate matrix: the ``pdf`` token admitted, the + phase-140 knobs at their CODE DEFAULTS (``pdf_pictorial`` ON), and + the strip's home ``pdf_page_dir`` pinned under *tmp_path* (never the + real home).""" + kwargs.setdefault("_env_file", None) + kwargs.setdefault("import_extensions", "md,txt,pdf") + kwargs.setdefault("images", False) + kwargs.setdefault("image_dir", str(tmp_path / "images")) + kwargs.setdefault("pdf_page_dir", str(tmp_path / "pages")) + return Settings(**kwargs) # pyright: ignore[reportCallIssue] + + +def _pages_dir(tmp_path: Path) -> Path: + return tmp_path / "pages" + + +def _strip_copies(doc: Document, page_dir: Path, count: int) -> list[Path]: + return [page_dir / f"{doc.id}-p{n}.png" for n in range(1, count + 1)] + + +def test_regate_heals_deleted_copies_of_a_pictorial_strip( + db, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """THE live-loss reproduction (heal arm): a pictorial-class PDF is + indexed (``page_count`` set, copies written), its copies are deleted + out from under the row (the exact live state — mode NULL on a + pre-phase row), and an UNCHANGED re-sync restores every file with + real PNG bytes, stamps the mode ``additive``, keeps ``page_count`` + (no drift), burns ZERO llm/vision calls, and logs the one greppable + INFO line. The NULL-mode stamp is SILENT (no ``mode`` segment — it + rides the line only when work followed).""" + root = tmp_path / "rgheal" + root.mkdir() + _build_ikea_like_pdf(root / "ikea.pdf") + llm = _PictorialLLM() + llm.settings = _regate_settings(tmp_path) + try: + asyncio.run(import_sources([root], llm, session=db)) + doc = _doc(db, "rgheal", "ikea.pdf") + assert (doc.page_count, doc.page_strip_mode) == (10, "additive") + page_dir = _pages_dir(tmp_path) + doc.page_strip_mode = None # the pre-phase row (the live shape) + db.commit() + for path in _strip_copies(doc, page_dir, 10): + path.unlink() + assert not list(page_dir.glob(f"{doc.id}-*.png")) + embeds1, chats1, vision1 = len(llm.calls), len(llm.chat_calls), llm.pictorial_calls + + with caplog.at_level(logging.INFO, logger="app.importer"): + second = asyncio.run(import_sources([root], llm, session=db)) + + assert (second.added, second.updated, second.unchanged) == (0, 0, 1) + # ZERO llm work of any kind (the re-gate is deterministic, A5). + assert len(llm.calls) == embeds1, "the re-gate never re-embeds" + assert len(llm.chat_calls) == chats1, "the re-gate never chats" + assert llm.pictorial_calls == vision1, "the re-gate issues ZERO vision calls" + + db.expire_all() + doc2 = _doc(db, "rgheal", "ikea.pdf") + assert doc2.page_count == 10, "no drift — the count is untouched" + assert doc2.page_strip_mode == "additive", "the mode is stamped" + for path in _strip_copies(doc2, page_dir, 10): + assert path.is_file(), f"copy {path.name} was not healed" + assert path.read_bytes()[:8] == PNG_MAGIC, f"{path.name} is not a PNG" + assert not list(page_dir.glob("*.tmp")), "no .tmp orphan from the heal" + messages = [r.getMessage() for r in caplog.records] + assert ( + "import: PDF strip re-gate source=rgheal path=ikea.pdf — " + "healed 10 pages" + ) in messages, messages + assert not any("mode " in m for m in messages if "re-gate" in m), ( + "a NULL->stamp mode re-stamp is silent-beneficial " + str(messages) + ) + finally: + _cleanup_source(db, "rgheal") + + +def test_regate_heals_a_fallback_strip_as_primary( + db, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Heal arm, the FALLBACK class (phase 133's unusable text layer): + the copies of a garbage-layer PDF's strip are restored on an + unchanged re-sync and the mode lands ``primary`` (the strip IS the + view) — and the phase-133 fallback vision does NOT re-fire (the + class is re-derived from the bytes, no vision call).""" + root = tmp_path / "rgfall" + root.mkdir() + _build_garbage_pdf(root / "garbage.pdf") # 2 pages + llm = _PageVisionLLM() + llm.settings = _regate_settings(tmp_path) + try: + asyncio.run(import_sources([root], llm, session=db)) + doc = _doc(db, "rgfall", "garbage.pdf") + assert (doc.page_count, doc.page_strip_mode) == (2, "primary") + page_dir = _pages_dir(tmp_path) + for path in _strip_copies(doc, page_dir, 2): + path.unlink() + vision1, chats1, embeds1 = llm.vision_calls, len(llm.chat_calls), len(llm.calls) + + with caplog.at_level(logging.INFO, logger="app.importer"): + second = asyncio.run(import_sources([root], llm, session=db)) + + assert (second.added, second.updated, second.unchanged) == (0, 0, 1) + assert second.pdfs_screenshotted == 0, "the fallback gate did not re-run" + assert llm.vision_calls == vision1, "ZERO vision calls on the re-gate" + assert len(llm.chat_calls) == chats1 and len(llm.calls) == embeds1 + + db.expire_all() + doc2 = _doc(db, "rgfall", "garbage.pdf") + assert doc2.page_strip_mode == "primary", "the unusable layer is primary" + assert doc2.page_count == 2 + for path in _strip_copies(doc2, page_dir, 2): + assert path.is_file() and path.read_bytes()[:8] == PNG_MAGIC + assert any( + "healed 2 pages" in m and "re-gate" in m for m in caplog.messages + ), caplog.messages + finally: + _cleanup_source(db, "rgfall") + + +def test_regate_noop_when_the_strip_is_healthy( + db, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """No-op arm (the IKEA-class doc whose copies all stand): an + unchanged re-sync writes NOTHING (every copy's mtime and bytes are + untouched), renders NOTHING, and logs NO re-gate line — the INFO is + never per-unchanged-doc — while the pre-existing ``import: + unchanged`` line still fires.""" + root = tmp_path / "rgnoop" + root.mkdir() + _build_ikea_like_pdf(root / "ikea.pdf") + llm = _PictorialLLM() + llm.settings = _regate_settings(tmp_path) + try: + asyncio.run(import_sources([root], llm, session=db)) + doc = _doc(db, "rgnoop", "ikea.pdf") + page_dir = _pages_dir(tmp_path) + before = { + p: (p.stat().st_mtime_ns, p.read_bytes()) + for p in _strip_copies(doc, page_dir, 10) + } + assert len(before) == 10 + + with caplog.at_level(logging.INFO, logger="app.importer"): + second = asyncio.run(import_sources([root], llm, session=db)) + + assert (second.added, second.updated, second.unchanged) == (0, 0, 1) + db.expire_all() + doc2 = _doc(db, "rgnoop", "ikea.pdf") + assert (doc2.page_count, doc2.page_strip_mode) == (10, "additive") + after = { + p: (p.stat().st_mtime_ns, p.read_bytes()) + for p in _strip_copies(doc2, page_dir, 10) + } + assert after == before, "a healthy strip is not written at all" + messages = [r.getMessage() for r in caplog.records] + assert any(m.startswith("import: unchanged ") for m in messages), messages + assert not any("re-gate" in m for m in messages), ( + f"the re-gate logged without acting: {messages}" + ) + finally: + _cleanup_source(db, "rgnoop") + + +def test_regate_restamps_a_changed_mode( + db, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Mode re-stamp (the class flip): a row whose stamped mode no + longer matches the gate's class (here a pictorial-class row carrying + ``primary`` — a legacy/mis-stamped row) is re-stamped to + ``additive``, and because the mode was ALREADY SET the change is + reported (``mode primary->additive``) — the only thing the re-gate + did, so that is the whole INFO line (no copies were missing, none + were written).""" + root = tmp_path / "rgmode" + root.mkdir() + _build_ikea_like_pdf(root / "ikea.pdf") + llm = _PictorialLLM() + llm.settings = _regate_settings(tmp_path) + try: + asyncio.run(import_sources([root], llm, session=db)) + doc = _doc(db, "rgmode", "ikea.pdf") + assert doc.page_strip_mode == "additive" + doc.page_strip_mode = "primary" # the mis-stamp the re-gate fixes + db.commit() + page_dir = _pages_dir(tmp_path) + before = { + p: p.stat().st_mtime_ns for p in _strip_copies(doc, page_dir, 10) + } + + with caplog.at_level(logging.INFO, logger="app.importer"): + second = asyncio.run(import_sources([root], llm, session=db)) + + assert (second.added, second.unchanged) == (0, 1) + db.expire_all() + doc2 = _doc(db, "rgmode", "ikea.pdf") + assert doc2.page_strip_mode == "additive", "the class is re-stamped" + assert doc2.page_count == 10 + assert { + p: p.stat().st_mtime_ns for p in _strip_copies(doc2, page_dir, 10) + } == before, "a mode fix writes no files" + assert ( + "import: PDF strip re-gate source=rgmode path=ikea.pdf — " + "mode primary->additive" + ) in [r.getMessage() for r in caplog.records], caplog.messages + finally: + _cleanup_source(db, "rgmode") + + +def test_regate_clears_a_strip_that_no_longer_qualifies( + db, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """REPAIR arm: a row claims a strip (``page_count`` set, copies on + disk) on a PDF whose GATE now says ineligible (a clean, dense text + layer — zero sparse pages). The unchanged re-sync clears BOTH + columns and deletes the copies (the strip must not linger over a + document the viewer shows as text) + the ``cleared stale strip`` + INFO line, with zero llm calls.""" + root = tmp_path / "rgclear" + root.mkdir() + pdf = root / "clean.pdf" + _build_dense_pdf(pdf) # usable layer, zero candidates — never qualifies + llm = _PictorialLLM() + llm.settings = _regate_settings(tmp_path) + try: + asyncio.run(import_sources([root], llm, session=db)) + doc = _doc(db, "rgclear", "clean.pdf") + assert (doc.page_count, doc.page_strip_mode) == (None, None) + # The stale state the re-gate must repair (a strip stamped by an + # older gate/settings, copies still on disk). + page_dir = _pages_dir(tmp_path) + page_dir.mkdir(parents=True, exist_ok=True) + for path in _strip_copies(doc, page_dir, 2): + path.write_bytes(b"STALE-COPY") + doc.page_count = 2 + doc.page_strip_mode = "additive" + db.commit() + embeds1, chats1 = len(llm.calls), len(llm.chat_calls) + + with caplog.at_level(logging.INFO, logger="app.importer"): + second = asyncio.run(import_sources([root], llm, session=db)) + + assert (second.added, second.updated, second.unchanged) == (0, 0, 1) + assert len(llm.calls) == embeds1 and len(llm.chat_calls) == chats1 + + db.expire_all() + doc2 = _doc(db, "rgclear", "clean.pdf") + assert doc2.page_count is None, "the stale strip is cleared" + assert doc2.page_strip_mode is None, "the class is cleared with it" + assert not list(page_dir.glob(f"{doc2.id}-p*.png")), "the copies are deleted" + assert ( + "import: PDF strip re-gate source=rgclear path=clean.pdf — " + "cleared stale strip" + ) in [r.getMessage() for r in caplog.records], caplog.messages + finally: + _cleanup_source(db, "rgclear") + + +def test_regate_kill_switch_clears_the_pictorial_strip( + db, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """REPAIR arm via the kill switch (``pdf_pictorial=False``): the + changed-path semantics mirrored — with the pass off a sparse-page + PDF is NOT eligible, so its existing strip is cleared with its + copies on the next (unchanged) sync. The bytes never moved: the + setting alone decided.""" + root = tmp_path / "rgkill" + root.mkdir() + _build_ikea_like_pdf(root / "ikea.pdf") + llm = _PictorialLLM() + llm.settings = _regate_settings(tmp_path) + try: + asyncio.run(import_sources([root], llm, session=db)) + doc = _doc(db, "rgkill", "ikea.pdf") + assert (doc.page_count, doc.page_strip_mode) == (10, "additive") + page_dir = _pages_dir(tmp_path) + assert len(list(page_dir.glob(f"{doc.id}-p*.png"))) == 10 + vision1 = llm.pictorial_calls + + llm.settings = _regate_settings(tmp_path, pdf_pictorial=False) + with caplog.at_level(logging.INFO, logger="app.importer"): + second = asyncio.run(import_sources([root], llm, session=db)) + + assert (second.added, second.updated, second.unchanged) == (0, 0, 1) + assert llm.pictorial_calls == vision1, "ZERO vision calls (the pass is off)" + db.expire_all() + doc2 = _doc(db, "rgkill", "ikea.pdf") + assert doc2.page_count is None and doc2.page_strip_mode is None + assert not list(page_dir.glob(f"{doc2.id}-p*.png")), "the copies went too" + assert any( + "cleared stale strip" in m for m in caplog.messages + ), caplog.messages + finally: + _cleanup_source(db, "rgkill") + + +def test_regate_render_exception_is_fail_soft( + db, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Fail-soft: the heal's render RAISES — the sync completes + (``unchanged``, zero errors), the row keeps its prior state + (``page_count`` + mode untouched — the re-gate never clears a strip + on a failure), no copies are touched, and the WARNING is the only + signal.""" + root = tmp_path / "rgboom" + root.mkdir() + _build_ikea_like_pdf(root / "ikea.pdf") + llm = _PictorialLLM() + llm.settings = _regate_settings(tmp_path) + try: + asyncio.run(import_sources([root], llm, session=db)) + doc = _doc(db, "rgboom", "ikea.pdf") + page_dir = _pages_dir(tmp_path) + for path in _strip_copies(doc, page_dir, 10): + path.unlink() + + def boom(*args: object, **kwargs: object) -> list[bytes]: + raise RuntimeError("simulated render failure (test sentinel)") + + monkeypatch.setattr(importer, "render_pdf_pages", boom) + with caplog.at_level(logging.WARNING, logger="app.importer"): + second = asyncio.run(import_sources([root], llm, session=db)) + + assert (second.added, second.updated, second.unchanged, second.errors) == ( + 0, 0, 1, 0, + ) + db.expire_all() + doc2 = _doc(db, "rgboom", "ikea.pdf") + assert (doc2.page_count, doc2.page_strip_mode) == (10, "additive"), ( + "the row keeps its pre-re-gate state" + ) + assert not list(page_dir.glob(f"{doc.id}-p*.png")), "no copies were touched" + assert any( + "PDF strip re-gate failed" in m and "test sentinel" in m + for m in caplog.messages + ), caplog.messages + finally: + _cleanup_source(db, "rgboom") + + +def test_regate_empty_render_keeps_the_row( + db, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Fail-soft on the no-pages render (unopenable right after the fast + path hashed the file — pathological): the row is kept EXACTLY as it + was (never cleared on a transient), nothing is written, and the + WARNING names the no-pages state.""" + root = tmp_path / "rgempty" + root.mkdir() + _build_ikea_like_pdf(root / "ikea.pdf") + llm = _PictorialLLM() + llm.settings = _regate_settings(tmp_path) + try: + asyncio.run(import_sources([root], llm, session=db)) + doc = _doc(db, "rgempty", "ikea.pdf") + page_dir = _pages_dir(tmp_path) + for path in _strip_copies(doc, page_dir, 10): + path.unlink() + monkeypatch.setattr(importer, "render_pdf_pages", lambda *a, **k: []) + + with caplog.at_level(logging.WARNING, logger="app.importer"): + second = asyncio.run(import_sources([root], llm, session=db)) + + assert (second.added, second.unchanged, second.errors) == (0, 1, 0) + db.expire_all() + doc2 = _doc(db, "rgempty", "ikea.pdf") + assert (doc2.page_count, doc2.page_strip_mode) == (10, "additive") + assert not page_dir.exists() or not list(page_dir.glob(f"{doc.id}-*")) + assert any( + "re-render produced no pages (state kept)" in m + for m in caplog.messages + ), caplog.messages + finally: + _cleanup_source(db, "rgempty") + + +def test_regate_reports_a_page_count_drift_on_the_heal( + db, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Drift (pathological on unchanged bytes, so it is LOUD): the heal + renders 6 pages for a row that claimed 10 — ``page_count`` follows + the fresh render, the ``p7..p10`` tail beyond it is deleted, and a + WARNING names both counts beside the ``healed`` INFO line.""" + root = tmp_path / "rgdrift" + root.mkdir() + _build_ikea_like_pdf(root / "ikea.pdf") + llm = _PictorialLLM() + llm.settings = _regate_settings(tmp_path) + try: + asyncio.run(import_sources([root], llm, session=db)) + doc = _doc(db, "rgdrift", "ikea.pdf") + page_dir = _pages_dir(tmp_path) + for path in _strip_copies(doc, page_dir, 10): + path.unlink() + monkeypatch.setattr( + importer, + "render_pdf_pages", + lambda *a, **k: [PNG_MAGIC + f"{i}".encode() for i in range(6)], + ) + + with caplog.at_level(logging.INFO, logger="app.importer"): + second = asyncio.run(import_sources([root], llm, session=db)) + + assert (second.added, second.unchanged) == (0, 1) + db.expire_all() + doc2 = _doc(db, "rgdrift", "ikea.pdf") + assert doc2.page_count == 6, "the count follows the fresh render" + for n in range(1, 7): + assert (page_dir / f"{doc2.id}-p{n}.png").is_file() + for n in range(7, 11): + assert not (page_dir / f"{doc2.id}-p{n}.png").exists(), f"p{n} leaked" + messages = [r.getMessage() for r in caplog.records] + assert any("page_count drift 10->6" in m for m in messages), messages + assert any("healed 10 pages" in m for m in messages), messages + finally: + monkeypatch.undo() # pyright: ignore[reportUnknownMemberType] + _cleanup_source(db, "rgdrift") + + +def test_regate_fires_on_the_content_digest_hit_path( + db, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """The SECOND unchanged path (the PDF branch's content-digest hit — + a pre-phase row with ``file_hash`` NULL): the branch re-runs its own + gate (the byte-change's honest cost) and lands unchanged, and the + shared helper's tail still heals the strip deleted out from under + the row — the re-gate is not fast-path-only.""" + root = tmp_path / "rgbranch" + root.mkdir() + _build_ikea_like_pdf(root / "ikea.pdf") + llm = _PictorialLLM() + llm.settings = _regate_settings(tmp_path) + try: + asyncio.run(import_sources([root], llm, session=db)) + doc = _doc(db, "rgbranch", "ikea.pdf") + doc.file_hash = None # the pre-phase row: the fast path cannot fire + db.commit() + page_dir = _pages_dir(tmp_path) + for path in _strip_copies(doc, page_dir, 10): + path.unlink() + + with caplog.at_level(logging.INFO, logger="app.importer"): + second = asyncio.run(import_sources([root], llm, session=db)) + + assert (second.added, second.updated, second.unchanged) == (0, 0, 1) + db.expire_all() + doc2 = _doc(db, "rgbranch", "ikea.pdf") + assert doc2.file_hash is not None, "the helper stamped the hash" + for path in _strip_copies(doc2, page_dir, 10): + assert path.is_file(), f"copy {path.name} was not healed" + assert any("healed 10 pages" in m for m in caplog.messages), caplog.messages + finally: + _cleanup_source(db, "rgbranch") + + +def test_regate_guard_costs_a_non_strip_file_nothing( + db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The GUARD (A5): a non-PDF unchanged file and a PDF whose + ``page_count`` is NULL reach the tail of ``_unchanged_doc`` and + ``extract_pdf_text`` is NEVER called for either — every non-strip + file's fast path keeps its zero-extra-read contract (one sync of + each class, then the spy covers the second sync only).""" + root = tmp_path / "rgguard" + root.mkdir() + (root / "note.md").write_text("# Note\n\nplain text body\n") + _build_dense_pdf(root / "clean.pdf") # usable layer, no strip + llm = _PictorialLLM() + llm.settings = _regate_settings(tmp_path) + seen: list[str] = [] + real_extract = importer.extract_pdf_text + + def spy(path: Path) -> str: + seen.append(str(path)) + return real_extract(path) + + try: + asyncio.run(import_sources([root], llm, session=db)) + monkeypatch.setattr(importer, "extract_pdf_text", spy) + second = asyncio.run(import_sources([root], llm, session=db)) + assert (second.added, second.updated, second.unchanged) == (0, 0, 2) + assert seen == [], f"the guard let a PDF parse through: {seen}" + finally: + monkeypatch.undo() # pyright: ignore[reportUnknownMemberType] + _cleanup_source(db, "rgguard") + + # ---------- the preserved semantics on the fast path -------------------------- diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 9ca8591..aceec1d 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -293,6 +293,82 @@ def test_git_source_python_default_include_hidden_false() -> None: assert col.default.arg is False, "the default must resolve to False" +def test_documents_page_strip_mode_column_contract() -> None: + """Phase 163 (task 01): ``documents.page_strip_mode`` — the CLASS of + the page strip a non-NULL ``page_count`` describes (``"primary"`` = + the phase-133 fallback class, the strip IS the view; ``"additive"`` + = the phase-140 pictorial class, text + a Text/Pages toggle): + VARCHAR(16), NULLABLE (NULL = no strip — and the pre-phase shape for + a row whose strip stands), NO server default, and NO index (the + content endpoint reads it from the ROW — a display fact, never a + query surface, the ``file_hash`` / ``page_count`` convention). + ``page_count`` (0026) stays exactly as phase 148 left it.""" + from sqlalchemy import String as SAString + + documents = Base.metadata.tables["documents"] + assert "page_strip_mode" in documents.c, ( + "documents must have the page_strip_mode column (phase 163)" + ) + col = documents.c["page_strip_mode"] + assert col.nullable is True, ( + "page_strip_mode must be NULLABLE — NULL means no strip (and the" + " pre-phase row whose strip stands)" + ) + assert isinstance(col.type, SAString), "page_strip_mode must be a String column" + assert col.type.length == 16, ( + "page_strip_mode must be String(16) — the two class names, bounded" + ) + assert col.server_default is None, ( + "page_strip_mode must carry NO server default — NULL is the" + " meaningful pre-phase state" + ) + assert not col.index, ( + "page_strip_mode must be UNINDEXED — the content endpoint reads it" + " from the row, no query filters on it" + ) + covering = [ + idx + for idx in documents.indexes + if any(c.name == "page_strip_mode" for c in idx.columns) + ] + assert not covering, "no table-level index may cover page_strip_mode" + + # page_count is untouched: still the nullable, unindexed sibling. + page_count = documents.c["page_count"] + assert page_count.nullable is True, "page_count must stay NULLABLE (0026)" + assert not page_count.index, "page_count must stay UNINDEXED (0026)" + + +def test_document_page_strip_mode_orm_defaults_to_none() -> None: + """A freshly constructed ``Document`` (no DB) exposes + ``page_strip_mode``: an omitted kwarg and an explicit ``None`` both + read NULL (no strip — the shape every non-qualifying document keeps, + and the shape a pre-phase-163 row keeps WHILE its ``page_count`` + stands, which the wire serializes ABSENT), and each class name + round-trips verbatim as a plain string (the real DB round-trip is + pinned in ``test_migration_0027.py``).""" + from app.models import Document + + base = dict( + source="s", + path="p.pdf", + full_path="/tmp/s/p.pdf", + title="p", + content="c", + content_hash="1" * 64, + page_count=36, + ) + for mode in ("primary", "additive"): + assert Document(**base, page_strip_mode=mode).page_strip_mode == mode + + assert Document(**base, page_strip_mode=None).page_strip_mode is None + omitted = Document(**base) + assert omitted.page_strip_mode is None, ( + "an omitted page_strip_mode must read NULL while page_count stands" + " (the pre-phase strip the viewer keeps showing as 'primary')" + ) + + def test_doc_drafts_column_contract() -> None: """Phase 59: the editable triple (title/path/body) + status + timestamps are NOT NULL; ``branch`` / ``commit_sha`` are NULL diff --git a/tests/unit/test_pdf_page_copies.py b/tests/unit/test_pdf_page_copies.py index 8cdeff8..4717acf 100644 --- a/tests/unit/test_pdf_page_copies.py +++ b/tests/unit/test_pdf_page_copies.py @@ -21,14 +21,20 @@ builder for the negative; the copies land in a tmp ``pdf_page_dir``): layer, ≥1 sparse page) and the fallback class (unusable layer) get ``<doc-id>-p{N}.png`` for EVERY page (not just the described candidates — the strip shows the whole manual) + ``page_count`` = - the page count + real PNG bytes; a text-heavy PDF gets neither - (no copies, NULL count, ZERO render calls, byte-identical content); + the page count + real PNG bytes + the phase-163 CLASS stamp + (``page_strip_mode`` ``additive`` / ``primary``); a text-heavy PDF + gets neither (no copies, NULL count, NULL mode, ZERO render calls, + byte-identical content); * the phase-147 fast path — an unchanged qualifying PDF's second sync re-renders NOTHING (zero render calls — the copies persist, ``page_count`` and the bytes untouched); -* stale replacement — a changed (shrunk) PDF's old copy set is - replaced: the new set covers the new pages, the old ``p{N}`` tail - is GONE (no orphans), ``page_count`` follows the new count; +* WRITE-FIRST replacement (phase 163, task 02, LOCKED A4) — a changed + (shrunk) PDF's new copies land atomically (tmp + ``os.replace``, + pinned) BEFORE any old copy is removed; the old ``p{N}`` tail is + GONE only after the replacement bytes are durable (no orphans), + ``page_count`` follows the new count; a MID-WRITE failure leaves the + prior copy set intact (the rollback-orphan state — the row over + zero files — is structurally unrepresentable); * qualifier loss — a qualifying PDF whose new version gained a dense text layer loses its strip: ``page_count`` NULL + copies deleted; * unopenable — a render that yields no pages (``render_pdf_pages`` → @@ -40,9 +46,10 @@ builder for the negative; the copies land in a tmp ``pdf_page_dir``): the FULL count (the gap is a display-level 404 — task 02 pins the route's side); * fail-soft — the render raising (warning is the signal, ``page_count`` - NULL, no copies, the doc indexed fully — chunks + summary) and the - WRITER raising (the copies this call wrote are removed — no partial - orphans, ``page_count`` NULL, the doc indexed fully); + + ``page_strip_mode`` NULL, no copies, the doc indexed fully — chunks + + summary) and the WRITER raising (this call's ``.tmp`` files and the + finals it CREATED are removed — no partial orphans, ``page_count`` + + ``page_strip_mode`` NULL, the doc indexed fully); * prune — a qualifying doc's file removed from the source: the row goes and its page copies go with it (no orphans in the tmp dir). @@ -54,6 +61,7 @@ from __future__ import annotations import asyncio import logging +import os import uuid from collections.abc import Callable, Sequence from pathlib import Path @@ -161,6 +169,35 @@ def _render_spy( return calls +# --------------------------------------------------------------------------- +# Phase 163 (task 01) — the strip-class column beside page_count +# --------------------------------------------------------------------------- + + +def test_pictorial_index_stamps_the_additive_class(db, tmp_path: Path) -> None: + """Phase 163 (task 02, re-cut of the task-01 pin) — the STAMP: a + qualifying PDF indexed through the PICTORIAL class gets + ``page_strip_mode == "additive"`` beside ``page_count`` (the text + + toggle view); the strip's copies themselves are unchanged by the + phase (task 01's shape, mode stamped exactly with the count). + """ + root = tmp_path / "pgm" + root.mkdir() + _build_ikea_like_pdf(root / "ikea.pdf") + llm = _PictorialLLM() + llm.settings = _settings(tmp_path) + try: + summary = asyncio.run(import_sources([root], llm, session=db)) + assert (summary.files, summary.added, summary.errors) == (1, 1, 0) + doc = _doc(db, "pgm", "ikea.pdf") + assert doc.page_count == 10, "the strip itself is unchanged by task 02" + assert doc.page_strip_mode == "additive", ( + "the pictorial class stamps the additive mode (LOCKED A1/A3)" + ) + finally: + _cleanup_source(db, "pgm") + + # --------------------------------------------------------------------------- # Eligibility × new index # --------------------------------------------------------------------------- @@ -219,6 +256,10 @@ def test_fallback_class_garbage_pdf_writes_every_page_copy(db, tmp_path: Path) - assert summary.pdfs_screenshotted == 1 # the fallback fired (the class) doc = _doc(db, "pgf", "garbage.pdf") assert doc.page_count == 2 + assert doc.page_strip_mode == "primary", ( + "the fallback class stamps the primary mode — the strip IS the " + "view, byte-identical to phase 148 otherwise (LOCKED A1)" + ) page_dir = _pages_dir(tmp_path) for n in (1, 2): path = page_dir / f"{doc.id}-p{n}.png" @@ -253,6 +294,7 @@ def test_text_heavy_pdf_keeps_the_text_view( assert summary.pdfs_pictorial == 0 # zero candidates — the pass no-ops doc = _doc(db, "pgd", "clean.pdf") assert doc.page_count is None, "a text-heavy PDF keeps the text view" + assert doc.page_strip_mode is None, "no strip — no class either" assert not _pages_dir(tmp_path).exists(), "no copies — the dir is never created" assert calls == [], "zero render calls (no fallback, zero candidates)" assert doc.content == expected, "byte-identical to the extraction" @@ -315,9 +357,10 @@ def test_changed_pdf_replaces_the_copy_set_without_orphans( ) -> None: """A CHANGED (shrunk, 10 → 6 pages) qualifying PDF: the re-index replaces the copy set — the new set covers p1..p6, the old - version's ``p7..p10`` tail is GONE (the stale-replacement runs - before the write), the doc keeps its id, and ``page_count`` - follows the new page count.""" + version's ``p7..p10`` tail is GONE (the stale-tail delete runs + LAST, after the write-first loop — phase 163, task 02), the doc + keeps its id, ``page_count`` follows the new page count, and + ``page_strip_mode`` is (re-)stamped.""" root = tmp_path / "pgc" root.mkdir() pdf = root / "ikea.pdf" @@ -336,6 +379,7 @@ def test_changed_pdf_replaces_the_copy_set_without_orphans( doc2 = _doc(db, "pgc", "ikea.pdf") assert doc2.id == doc1.id, "the doc keeps its id (the copy path is stable)" assert doc2.page_count == 6, "page_count follows the new page count" + assert doc2.page_strip_mode == "additive", "the class is (re-)stamped" page_dir = _pages_dir(tmp_path) for n in range(1, 7): assert (page_dir / f"{doc2.id}-p{n}.png").is_file(), f"p{n} is missing" @@ -379,6 +423,7 @@ def test_qualifier_loss_clears_page_count_and_copies( doc2 = _doc(db, "pgl", "ikea.pdf") assert doc2.id == doc1.id assert doc2.page_count is None, "a doc that stopped qualifying loses its strip" + assert doc2.page_strip_mode is None, "the class is cleared with the count" assert not list(_pages_dir(tmp_path).glob(f"{doc2.id}-p*.png")), ( "the copies must be deleted on qualifier loss" ) @@ -411,6 +456,7 @@ def test_unopenable_pdf_page_render_is_fail_soft( assert (summary.files, summary.added, summary.errors) == (1, 1, 0) doc = _doc(db, "pgn", "garbage.pdf") assert doc.page_count is None, "no pages rendered — no strip" + assert doc.page_strip_mode is None, "the class is cleared with the count" assert not _pages_dir(tmp_path).exists(), "no copies — the dir is never created" assert doc.content == "", "the fallback's all-failed render lands empty content" assert summary.summaries == 1, "the document is indexed exactly as pre-phase" @@ -493,10 +539,11 @@ def test_write_exception_is_fail_soft_and_leaves_no_orphans( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: - """The PNG WRITER raises (a full disk) after the FIRST copy - landed: the copies this call wrote are removed (no partial - orphans), ``page_count`` NULL, the warning is the signal, and the - document indexes exactly as pre-phase.""" + """The PNG WRITER raises (a full disk) after the FIRST atomic copy + landed: this call's ``.tmp`` files and the final-name files it + CREATED are removed (no partial orphans — phase 163, task 02), + ``page_count`` AND ``page_strip_mode`` NULL, the warning is the + signal, and the document indexes exactly as pre-phase.""" root = tmp_path / "pgw" root.mkdir() _build_ikea_like_pdf(root / "ikea.pdf") @@ -507,22 +554,29 @@ def test_write_exception_is_fail_soft_and_leaves_no_orphans( def boom(self: Path, data: bytes) -> int: written_paths.append(self) + n = original_write(self, data) # the tmp file really lands first if len(written_paths) > 1: raise OSError("simulated disk full (test sentinel)") - return original_write(self, data) + return n monkeypatch.setattr(Path, "write_bytes", boom) # pyright: ignore[reportArgumentType] try: with caplog.at_level(logging.WARNING, logger="app.importer"): summary = asyncio.run(import_sources([root], llm, session=db)) assert (summary.files, summary.added, summary.errors) == (1, 1, 0) - assert len(written_paths) == 2, "p1 landed, p2's write failed" + assert len(written_paths) == 2, "p1's tmp landed + replaced, p2's write failed" + assert all(p.name.endswith(".png.tmp") for p in written_paths), ( + "the writer only ever touches .tmp paths (atomic replace)" + ) doc = _doc(db, "pgw", "ikea.pdf") assert doc.page_count is None + assert doc.page_strip_mode is None, "the class is cleared with the count" assert not list(_pages_dir(tmp_path).glob(f"{doc.id}-p*.png")), ( "the partial write was cleaned up — no orphans" ) - assert not written_paths[0].exists(), "the landed p1 copy was removed" + assert not list(_pages_dir(tmp_path).glob("*.tmp")), ( + "the failed call's .tmp file was cleaned up too" + ) assert summary.summaries == 1, "the document is indexed exactly as pre-phase" assert any("PDF page render failed" in m for m in caplog.messages), ( caplog.messages @@ -531,6 +585,246 @@ def test_write_exception_is_fail_soft_and_leaves_no_orphans( _cleanup_source(db, "pgw") +# --------------------------------------------------------------------------- +# Phase 163 (task 02) — WRITE-FIRST, atomic per file (LOCKED A4) +# --------------------------------------------------------------------------- + + +def test_write_first_new_bytes_are_durable_before_any_stale_delete( + db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """THE order pin (LOCKED A4 — the 2026-10-07 live loss): re-rendering + a CHANGED (shrunk, 10 → 6) PDF writes every replacement page FIRST + (tmp + ``os.replace``) and only then deletes the stale tail — the + event log shows ALL replaces of the fresh bytes before the FIRST + unlink of an old-extent file, the overwritten pages keep the NEW + bytes, the shrunk tail (p7..p10) is gone, and nothing else is + deleted.""" + root = tmp_path / "pgord" + root.mkdir() + pdf = root / "ikea.pdf" + _build_ikea_like_pdf(pdf) + llm = _PictorialLLM() + llm.settings = _settings(tmp_path) + asyncio.run(import_sources([root], llm, session=db)) + doc1 = _doc(db, "pgord", "ikea.pdf") + page_dir = _pages_dir(tmp_path) + sentinel = b"OLD-COPY-SENTINEL" + for n in range(1, 11): # make the old copies unmistakably distinguishable + (page_dir / f"{doc1.id}-p{n}.png").write_bytes(sentinel) + + events: list[tuple[str, str]] = [] + real_replace = os.replace + + def replace_spy(src, dst, *args, **kwargs): # pyright: ignore[reportUnknownParameterType] + events.append(("replace", Path(os.fspath(dst)).name)) + return real_replace(src, dst, *args, **kwargs) + + real_unlink = Path.unlink + + def unlink_spy(self: Path, **kwargs: Any) -> None: + if self.parent == page_dir and self.name.startswith(str(doc1.id)): + events.append(("unlink", self.name)) + return real_unlink(self, **kwargs) + + monkeypatch.setattr(importer.os, "replace", replace_spy) + monkeypatch.setattr(Path, "unlink", unlink_spy) # pyright: ignore[reportArgumentType] + try: + _build_ikea_shrunk(pdf) # same path, 6 pages — still qualifying + s2 = asyncio.run(import_sources([root], llm, session=db)) + assert (s2.added, s2.updated, s2.unchanged) == (0, 1, 0) + finally: + monkeypatch.undo() # pyright: ignore[reportUnknownMemberType] + _cleanup_source(db, "pgord") + replaces = [name for kind, name in events if kind == "replace"] + unlinks = [name for kind, name in events if kind == "unlink"] + assert len(replaces) == 6, "the 6 fresh pages are written via os.replace" + assert set(unlinks) == {f"{doc1.id}-p{n}.png" for n in range(7, 11)}, ( + "the stale-tail delete touches ONLY the old extent the render did " + "not rewrite — a file about to be overwritten is never deleted" + ) + first_unlink = next(i for i, e in enumerate(events) if e[0] == "unlink") + last_replace = max(i for i, e in enumerate(events) if e[0] == "replace") + assert last_replace < first_unlink, ( + f"write-first violated — event order: {events}" + ) + for n in range(1, 7): + blob = (page_dir / f"{doc1.id}-p{n}.png").read_bytes() + assert blob != sentinel and blob[:8] == PNG_MAGIC, ( + f"p{n} keeps the NEW rendered bytes" + ) + for n in range(7, 11): + assert not (page_dir / f"{doc1.id}-p{n}.png").exists(), f"stale p{n} leaked" + assert not list(page_dir.glob("*.tmp")), "no .tmp orphans" + + +def test_mid_write_failure_keeps_the_prior_copies_intact( + db, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """THE failure pin (LOCKED A4): the writer raises on the SECOND + page's tmp write of a changed re-render — the prior copy set stays + on disk INTACT (the rollback-orphan state — the row over zero + files — is structurally unrepresentable): every one of the 10 prior + files still exists (p1 with the new bytes already atomically + replaced before the failure, p2..p10 byte-for-byte the prior + copies), zero ``.tmp`` orphans from this call, and the caller's + fail-soft fires (``page_count``/``page_strip_mode`` NULL + the + warning, the doc indexed fully).""" + root = tmp_path / "pgfail" + root.mkdir() + pdf = root / "ikea.pdf" + _build_ikea_like_pdf(pdf) + llm = _PictorialLLM() + llm.settings = _settings(tmp_path) + asyncio.run(import_sources([root], llm, session=db)) + doc1 = _doc(db, "pgfail", "ikea.pdf") + page_dir = _pages_dir(tmp_path) + prior = { + n: (page_dir / f"{doc1.id}-p{n}.png").read_bytes() for n in range(1, 11) + } + # Tag the re-render's bytes so the new p1 is recognizable, then + # blow up the SECOND tmp write (page 2). + _render_spy( + monkeypatch, + mutate_full=lambda pages: [ + PNG_MAGIC + f"NEW-{i}".encode() for i in range(len(pages)) + ], + ) + original_write = Path.write_bytes + tmp_writes: list[Path] = [] + + def boom(self: Path, data: bytes) -> int: + if self.suffix == ".tmp": + tmp_writes.append(self) + if len(tmp_writes) > 1: + raise OSError("simulated disk full (test sentinel)") + return original_write(self, data) + + monkeypatch.setattr(Path, "write_bytes", boom) # pyright: ignore[reportArgumentType] + _build_ikea_shrunk(pdf) + try: + with caplog.at_level(logging.WARNING, logger="app.importer"): + s2 = asyncio.run(import_sources([root], llm, session=db)) + finally: + monkeypatch.undo() # pyright: ignore[reportUnknownMemberType] + assert (s2.added, s2.updated, s2.unchanged) == (0, 1, 0) + assert len(tmp_writes) == 2, "p1's tmp wrote, p2's tmp write raised" + doc2 = _doc(db, "pgfail", "ikea.pdf") + assert doc2.page_count is None, "the caller's fail-soft nulled the count" + assert doc2.page_strip_mode is None, "the caller's fail-soft nulled the class" + assert any("PDF page render failed" in m for m in caplog.messages), caplog.messages + # The prior set is INTACT: exactly the 10 names, none lost, none + # orphaned on top. + assert {p.name for p in page_dir.iterdir()} == { + f"{doc1.id}-p{n}.png" for n in range(1, 11) + }, "files on disk exactly the prior set" + assert ( + page_dir / f"{doc1.id}-p1.png" + ).read_bytes() == PNG_MAGIC + b"NEW-0", ( + "p1's replacement bytes were durable before the failure (atomic " + "replace) and were NOT removed by the cleanup" + ) + for n in range(2, 11): + assert ( + page_dir / f"{doc1.id}-p{n}.png" + ).read_bytes() == prior[n], f"prior copy p{n} was disturbed" + assert not list(page_dir.glob("*.tmp")), "zero .tmp orphans from this call" + assert doc2.chunks, "the document is indexed fully (fail-soft)" + + +def test_page_copies_are_written_atomically_tmp_then_replace( + db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """THE atomic pin (LOCKED A4): the writer NEVER writes a final + ``<doc-id>-p{N}.png`` path directly — every page's bytes land on + ``<name>.png.tmp`` and ``os.replace`` moves them onto the final + name in order (a reader opening the final path never sees truncated + bytes), and no ``.tmp`` survives the successful call.""" + root = tmp_path / "pgatom" + root.mkdir() + _build_ikea_like_pdf(root / "ikea.pdf") + llm = _PictorialLLM() + llm.settings = _settings(tmp_path) + written: list[Path] = [] + original_write = Path.write_bytes + + def write_spy(self: Path, data: bytes) -> int: + written.append(self) + return original_write(self, data) + + replaced: list[tuple[str, str]] = [] + real_replace = os.replace + + def replace_spy(src, dst, *args, **kwargs): # pyright: ignore[reportUnknownParameterType] + replaced.append((os.fspath(src), os.fspath(dst))) + return real_replace(src, dst, *args, **kwargs) + + monkeypatch.setattr(Path, "write_bytes", write_spy) # pyright: ignore[reportArgumentType] + monkeypatch.setattr(importer.os, "replace", replace_spy) + try: + asyncio.run(import_sources([root], llm, session=db)) + finally: + monkeypatch.undo() # pyright: ignore[reportUnknownMemberType] + doc = _doc(db, "pgatom", "ikea.pdf") + page_dir = _pages_dir(tmp_path) + assert len(written) == 10 and len(replaced) == 10 + assert all(p.name.endswith(".png.tmp") for p in written), ( + "no final path is ever written directly" + ) + for n, (src, dst) in enumerate(replaced, start=1): + assert dst == str(page_dir / f"{doc.id}-p{n}.png") + assert src == f"{dst}.tmp", "the tmp name is <final>.tmp" + assert not list(page_dir.glob("*.tmp")), "no .tmp survives the success path" + + +def test_stale_tail_delete_failure_is_best_effort( + db, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """An unreadable STALE-TAIL copy (the write-first tail pass, the + ``_delete_page_copies`` convention): the failure is logged, never + raised — the sync completes, the row is stamped with the fresh + count, and the reachable tail files still go.""" + root = tmp_path / "pgtail" + root.mkdir() + pdf = root / "ikea.pdf" + _build_ikea_like_pdf(pdf) + llm = _PictorialLLM() + llm.settings = _settings(tmp_path) + asyncio.run(import_sources([root], llm, session=db)) + doc1 = _doc(db, "pgtail", "ikea.pdf") + page_dir = _pages_dir(tmp_path) + p10 = page_dir / f"{doc1.id}-p10.png" + real_unlink = Path.unlink + + def boom(self: Path, **kwargs: Any) -> None: + if self == p10: + raise OSError("simulated unreadable file (test sentinel)") + return real_unlink(self, **kwargs) + + monkeypatch.setattr(Path, "unlink", boom) # pyright: ignore[reportArgumentType] + _build_ikea_shrunk(pdf) + try: + with caplog.at_level(logging.WARNING, logger="app.importer"): + s2 = asyncio.run(import_sources([root], llm, session=db)) + finally: + monkeypatch.undo() # pyright: ignore[reportUnknownMemberType] + assert (s2.added, s2.updated, s2.unchanged) == (0, 1, 0) + doc2 = _doc(db, "pgtail", "ikea.pdf") + assert doc2.page_count == 6 and doc2.page_strip_mode == "additive" + assert p10.exists(), "the unreadable tail file survived (never raised)" + for n in range(7, 10): + assert not (page_dir / f"{doc1.id}-p{n}.png").exists(), f"p{n} still leaked" + assert any( + "could not delete PDF page copy" in m for m in caplog.messages + ), caplog.messages + + # --------------------------------------------------------------------------- # The shared cleanup helper (the _delete_image_copy convention) # --------------------------------------------------------------------------- diff --git a/tests/unit/test_summary_edit_ui.py b/tests/unit/test_summary_edit_ui.py index bda1341..9de15f7 100644 --- a/tests/unit/test_summary_edit_ui.py +++ b/tests/unit/test_summary_edit_ui.py @@ -117,7 +117,10 @@ def test_gate_runs_after_the_phase36_base_construction() -> None: label = js.find('title.textContent = "Summary"') text_node = js.find('body.className = "doc-summary-text"') append = js.find("section.append(title, body)") - mount = js.find("contentEl.appendChild(section)") + # Phase 163 (task 04): the mount rides the `slot` variable — + # slot === contentEl for every non-additive doc (the byte-identity + # property), views only for the additive strip class. + mount = js.find("slot.appendChild(section)") gate = js.find("void docAdminReady().then(", mount) wiring = js.find("if (admin) wireSummaryEdit(section, doc);") assert 0 < base < label < text_node < append < mount < gate < wiring, ( diff --git a/tests/unit/test_viewer_additive_toggle.py b/tests/unit/test_viewer_additive_toggle.py new file mode 100644 index 0000000..ca519a0 --- /dev/null +++ b/tests/unit/test_viewer_additive_toggle.py @@ -0,0 +1,431 @@ +"""Unit: the additive PDF strip view (phase 163, task 04 — D4, LOCKED A1/A6). + +Frontend source-contract pins + node behavioral runs (the +``test_document_viewer.py`` house pattern) over ``document.js``'s +``renderDocument`` — the ONE shared core (Sources modal AND +/document.html): + +* the MODE branch — ``page_urls`` + ``page_strip_mode === "additive"`` + falls through to the text-doc rendering (Summary panel + content, + the pre-148 branch reused) wrapped in a ``.doc-views`` container + with the ``.doc-view-toggle`` group above it (Text pressed by + default, LOCKED A6); ``page_urls`` with the mode ABSENT or + ``"primary"`` keeps the phase-148 strip-only branch VERBATIM (append + the strip to ``contentEl`` + return — no toggle, no wrapper, no + Summary panel); falsy ``page_urls`` is untouched; +* the toggle semantics — ``role="group"`` + ``aria-label="Document + view"``, two ``<button type="button">`` labeled ``Text`` / ``Pages`` + (fixed strings, no doc-derived text), ``aria-pressed`` true/false + initial state, and a click mutating ONLY the two ``aria-pressed`` + states + the wrapper's ``data-view`` attribute (CSS-driven show/ + hide — the strip node stays mounted, no re-render, no refetch); +* the never-build-HTML rule — createElement + properties/textContent + only in the new code path (the module's ONE innerHTML stays the + escape-first markdown render); the strip imgs keep + ``loading = "lazy"`` (the hidden strip never fetches); +* the ``.doc-view-toggle`` CSS — the house ghost-pill idiom, the + pressed state as text + style (never color alone, B5), ≥44px + targets, and the ``data-view`` show/hide contract. +""" +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +FRONTEND = Path(__file__).resolve().parents[2] / "frontend" +DOCUMENT_JS = FRONTEND / "assets" / "document.js" +STYLES_CSS = FRONTEND / "assets" / "styles.css" + +HAVE_NODE = shutil.which("node") is not None + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _fn(js: str, name: str) -> str: + """The source of a top-level function via balanced-brace counting + (the ``test_document_viewer.py`` pattern).""" + start = js.find(f"function {name}(") + assert start != -1, f"{name}() must exist in document.js" + i = js.find("(", start) + depth = 0 + close = i + while i < len(js): + if js[i] == "(": + depth += 1 + elif js[i] == ")": + depth -= 1 + if depth == 0: + close = i + break + i += 1 + brace = js.find("{", close) + depth = 0 + for j in range(brace, len(js)): + if js[j] == "{": + depth += 1 + elif js[j] == "}": + depth -= 1 + if depth == 0: + return js[start : j + 1] + raise AssertionError(f"unbalanced braces in {name}()") + + +def _code(js: str) -> str: + """The source with comments stripped (the never-build-HTML pins).""" + return re.sub(r"//.*?$|/\*.*?\*/", "", js, flags=re.S | re.M) + + +def _run_node(script: str) -> str: + proc = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, timeout=60 + ) + assert proc.returncode == 0, f"node failed: {proc.stderr}" + return proc.stdout + + +# --------------------------------------------------------------------------- +# Source pins — the renderDocument mode branch +# --------------------------------------------------------------------------- + + +def test_render_document_branches_on_strip_mode() -> None: + """The mode branch (LOCKED A2 — the tolerant read): the additive + flag keys on the EXACT ``doc.page_strip_mode === "additive"`` + string (absent / "primary" / anything else ⇒ primary), and the + primary/absent strip branch stays the phase-148 shape — append + ``docPagesStrip`` to ``contentEl`` and RETURN — evaluated BEFORE + any wrapper/toggle construction.""" + render = _fn(_read(DOCUMENT_JS), "renderDocument") + assert 'doc.page_strip_mode === "additive"' in render, ( + "only the exact additive string switches on the toggle" + ) + gate_i = render.find('const additive = stripEligible && doc.page_strip_mode === "additive";') + primary_i = render.find("if (stripEligible && !additive) {") + strip_i = render.find("contentEl.appendChild(docPagesStrip(doc, pageUrls));") + return_i = render.find("return;", strip_i) + wrapper_i = render.find('views.className = "doc-views";') + assert -1 < gate_i < primary_i < strip_i < return_i < wrapper_i, ( + "the primary/absent branch (strip into contentEl + return) precedes " + "the additive wrapper — its DOM stays the phase-148 shape verbatim" + ) + # The wrapper + toggle: ONE .doc-views container, the toggle BEFORE + # it, both into contentEl; the text view lands in the wrapper. + append_i = render.find("contentEl.append(docViewToggle(views), views);") + assert wrapper_i < append_i, "the wrapper is built, then appended after its toggle" + assert 'views.setAttribute("data-view", "text");' in render, ( + "LOCKED A6 — Text is the default view" + ) + # The strip mounts ONCE inside the wrapper, after the text view — + # never rebuilt on a flip (no docPagesStrip call outside the two + # branches). + assert render.count("docPagesStrip(doc, pageUrls)") == 2 + mount_i = render.rfind("views.appendChild(docPagesStrip(doc, pageUrls));") + assert mount_i > render.find('slot.appendChild(pre);') or mount_i > render.rfind( + "slot.appendChild(wrap);" + ), "the strip mounts AFTER the text view inside the wrapper" + # The non-additive path is untouched: slot === contentEl (the + # pre-148 appends keep their exact shape through the slot var). + assert "let slot = contentEl;" in render + + +def test_primary_absent_branch_builds_no_toggle() -> None: + """The primary/absent branch is toggle-free by construction: the + strip-branch block (from the ``pageUrls`` read to its ``return``) + contains NO ``doc-view-toggle`` / ``doc-views`` construction — the + strip-only DOM stays byte-identical to pre-phase-163.""" + render = _fn(_read(DOCUMENT_JS), "renderDocument") + primary_start = render.find("if (stripEligible && !additive)") + branch = render[render.find("const pageUrls =") : render.find("return;", primary_start)] + assert "doc-view-toggle" not in branch + assert "doc-views" not in branch + assert branch.count("appendChild") == 1, ( + "the primary branch is exactly the phase-148 append + return" + ) + + +def test_toggle_never_builds_html_and_labels_are_fixed() -> None: + """The never-build-HTML rule: the toggle builders use + createElement + properties/textContent only — no innerHTML / + outerHTML — and the labels are the FIXED strings ``Text`` / + ``Pages`` (no doc-derived text inside the toggle). The module's + ONE innerHTML (the escape-first markdown render) survives.""" + js = _read(DOCUMENT_JS) + toggle = _fn(js, "docViewToggle") + btn = _fn(js, "viewToggleButton") + for body in (toggle, btn): + code = _code(body) + assert "innerHTML" not in code and "outerHTML" not in code + assert 'document.createElement("div")' in toggle + assert 'group.setAttribute("role", "group")' in toggle + assert 'group.setAttribute("aria-label", "Document view")' in toggle + assert 'document.createElement("button")' in btn + assert 'btn.type = "button"' in btn + assert 'btn.className = "doc-view-btn"' in btn + assert 'viewToggleButton("Text")' in toggle + assert 'viewToggleButton("Pages")' in toggle + # The module-wide XSS contract: exactly one innerHTML, the + # renderMarkdown line. + whole = _code(js) + assert whole.count("innerHTML") == 1 + assert "outerHTML" not in whole + + +def test_strip_imgs_stay_lazy() -> None: + """The phase-148 lazy-img pin re-runs green: the strip builder is + UNMODIFIED — every page ``<img>`` keeps ``loading = "lazy"`` (the + additive default view never fetches the strip).""" + strip = _fn(_read(DOCUMENT_JS), "docPagesStrip") + assert 'img.loading = "lazy";' in strip + + +def test_view_toggle_css() -> None: + """styles.css: the ``.doc-view-toggle`` group (the chat-column + reading width, the pill pair adjacent) + the ``.doc-view-btn`` + ghost pill (≥44px target, ink-soft text — AA) + the PRESSED state + (``[aria-pressed="true"]`` — brand fill + font-weight 700: text + + style, never color alone, B5) + the CSS-driven view swap + (``.doc-views .doc-pages`` hidden by default; ``[data-view= + "pages"]`` hides the Summary panel / .doc-md / .doc-raw / + .doc-image and shows the strip as the grid). Provenance comment + above the block (house style).""" + css = _read(STYLES_CSS) + toggle_i = css.find(".doc-view-toggle {") + assert toggle_i != -1, "styles.css must style .doc-view-toggle" + comment_start = css.rfind("/*", 0, toggle_i) + assert -1 < comment_start < toggle_i and "phase 163" in css[comment_start:toggle_i] + btn_i = css.find(".doc-view-btn {") + pressed_i = css.find('.doc-view-btn[aria-pressed="true"]') + assert toggle_i < btn_i < pressed_i + btn_rule = css[btn_i : css.find("}", btn_i)] + for decl in ("min-height: 44px", "color: var(--ink-soft)", "border: 1px solid var(--line)"): + assert decl in btn_rule + pressed_rule = css[pressed_i : css.find("}", pressed_i)] + assert "background: var(--brand)" in pressed_rule + assert "font-weight: 700" in pressed_rule, ( + "the pressed state is text weight + fill — never color alone (B5)" + ) + # The CSS-driven swap: hidden strip by default; pages hides the + # text view and shows the strip. + assert '.doc-views .doc-pages { display: none; }' in css + for sel in (".doc-summary", ".doc-md", ".doc-raw", ".doc-image"): + assert f'.doc-views[data-view="pages"] {sel}' in css + assert '.doc-views[data-view="pages"] .doc-pages { display: grid; }' in css + + +# --------------------------------------------------------------------------- +# Node behavioral runs — the real renderDocument over a minimal fake DOM +# --------------------------------------------------------------------------- + +_FAKE_DOM = """ +function makeEl(tag) { + return { + tag, className: "", textContent: "", attrs: {}, children: [], handlers: {}, + setAttribute(k, v) { this.attrs[k] = String(v); }, + getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }, + appendChild(c) { this.children.push(c); return c; }, + append(...cs) { this.children.push(...cs); }, + replaceChildren(...cs) { this.children = cs; }, + addEventListener(ev, fn) { this.handlers[ev] = fn; }, + click() { this.handlers.click(); }, + }; +} +globalThis.document = { createElement: (t) => makeEl(t) }; +function fmtDate(x) { return "DATE:" + x; } +function metaBadge(cls, text, title) { + const s = makeEl("span"); + s.className = cls; + s.textContent = text; + if (title !== undefined) s.setAttribute("title", title); + return s; +} +function copyPathButton() { return makeEl("button"); } +function originAnchor() { return makeEl("a"); } +async function docAdminReady() { return false; } +function wireDateEdit() {} +function wireSummaryEdit() {} +function renderMarkdown(s) { return s; } +function docImageBlock() { return makeEl("div"); } +function describe(el) { + return { + tag: el.tag, + className: el.className, + attrs: el.attrs, + text: el.textContent, + children: el.children.map(describe), + }; +} +""" + + +def _render_script() -> str: + js = _read(DOCUMENT_JS) + parts = [ + _fn(js, n) + for n in ("renderDocument", "docViewToggle", "viewToggleButton", "docPagesStrip") + ] + return _FAKE_DOM + "\n".join(parts) + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_additive_doc_renders_text_views_and_toggle() -> None: + """Behavioral (node, fake DOM): an ADDITIVE doc renders the TEXT + doc exactly as a non-qualifying one — Summary panel + content — + inside ``.doc-views`` (data-view="text"), with the toggle group + FIRST in the content slot: role=group, aria-label="Document + view", the two ``doc-view-btn`` buttons labeled Text/Pages, Text + aria-pressed="true" (LOCKED A6), and the strip MOUNTED inside the + wrapper (figures + lazy imgs) after the text view.""" + out = _run_node( + _render_script() + + """ +const doc = { + title: "Letumdei", path: "book.pdf", source: "Library", format: "pdf", + summary: "A book.", content: "BODY TEXT", created_at: "c", indexed_at: "i", + chunks: 9, page_urls: ["/api/documents/x/pages/1", "/api/documents/x/pages/2"], + page_strip_mode: "additive", +}; +const contentEl = makeEl("div"); +renderDocument(doc, { titleEl: makeEl("h1"), metaEl: makeEl("div"), contentEl }); +console.log(JSON.stringify(describe(contentEl))); +""" + ) + content = json.loads(out) + assert [c["className"] for c in content["children"]] == [ + "doc-view-toggle", + "doc-views", + ], "the toggle group sits above the .doc-views wrapper" + toggle, views = content["children"] + assert toggle["attrs"]["role"] == "group" + assert toggle["attrs"]["aria-label"] == "Document view" + labels = [c["text"] for c in toggle["children"]] + assert labels == ["Text", "Pages"] + assert [c["attrs"]["aria-pressed"] for c in toggle["children"]] == ["true", "false"], ( + "Text pressed by default (LOCKED A6)" + ) + for c in toggle["children"]: + assert c["tag"] == "button" + assert views["attrs"]["data-view"] == "text" + assert [c["className"] for c in views["children"]] == [ + "doc-summary", + "doc-raw", + "doc-pages", + ], "additive = Summary panel + text content + the mounted strip" + # pre.textContent — a text node, never parsed as HTML + assert views["children"][1]["text"] == "BODY TEXT" + strip = views["children"][2] + assert len(strip["children"]) == 2, "one figure per page_urls entry" + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_primary_and_absent_modes_keep_the_strip_only_dom() -> None: + """Behavioral: mode "primary" AND an absent mode (the pre-163 + stamped row — the live Letumdei pre-sync shape) keep the phase-148 + DOM byte-identically: the content slot holds EXACTLY the + ``.doc-pages`` strip — no wrapper, no toggle, no Summary panel, + no text block.""" + out = _run_node( + _render_script() + + """ +const results = []; +for (const mode of ["primary", undefined]) { + const doc = { + title: "Manual", path: "m.pdf", source: "Library", format: "pdf", + summary: "A manual.", content: "SHOULD NOT SHOW", created_at: "c", + indexed_at: "i", chunks: 4, + page_urls: ["/api/documents/y/pages/1"], + }; + if (mode !== undefined) doc.page_strip_mode = mode; + const contentEl = makeEl("div"); + renderDocument(doc, { titleEl: makeEl("h1"), metaEl: makeEl("div"), contentEl }); + results.push(describe(contentEl)); +} +console.log(JSON.stringify(results)); +""" + ) + primary, absent = json.loads(out) + for content in (primary, absent): + assert len(content["children"]) == 1 + assert content["children"][0]["className"] == "doc-pages" + classes = json.dumps(content) + assert "doc-view-toggle" not in classes + assert "doc-views" not in classes + assert "doc-summary" not in classes + assert "doc-raw" not in classes, "the strip replaces the text block (primary class)" + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_text_doc_dom_is_untouched() -> None: + """Behavioral: a doc with no page_urls is UNTOUCHED — Summary + panel + content appended straight to the content slot, no + wrapper, no toggle (the byte-identity property for every + non-PDF).""" + out = _run_node( + _render_script() + + """ +const doc = { + title: "Note", path: "n.pdf", source: "Library", format: "pdf", + summary: "S.", content: "TEXT", created_at: "c", indexed_at: "i", chunks: 1, +}; +const contentEl = makeEl("div"); +renderDocument(doc, { titleEl: makeEl("h1"), metaEl: makeEl("div"), contentEl }); +console.log(JSON.stringify(describe(contentEl))); +""" + ) + content = json.loads(out) + assert [c["className"] for c in content["children"]] == ["doc-summary", "doc-raw"] + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_toggle_flips_view_without_rerender_or_refetch() -> None: + """Behavioral: the toggle handler mutates ONLY the two + aria-pressed states + the wrapper's data-view. Clicking Pages + flips data-view to "pages"; clicking back to Text reverses; the + strip node STAYS MOUNTED the whole time (same object, no + re-append — the flip-flop fetches nothing) and the wrapper's + children never change count.""" + out = _run_node( + _render_script() + + """ +const doc = { + title: "Book", path: "b.pdf", source: "Library", format: "pdf", + summary: "S.", content: "TEXT", created_at: "c", indexed_at: "i", chunks: 2, + page_urls: ["/api/documents/z/pages/1", "/api/documents/z/pages/2"], + page_strip_mode: "additive", +}; +const contentEl = makeEl("div"); +renderDocument(doc, { titleEl: makeEl("h1"), metaEl: makeEl("div"), contentEl }); +const toggle = contentEl.children[0]; +const views = contentEl.children[1]; +const [textBtn, pagesBtn] = toggle.children; +const stripBefore = views.children[2]; +const countBefore = views.children.length; +pagesBtn.click(); +const afterPages = { + view: views.getAttribute("data-view"), + pressed: toggle.children.map((b) => b.getAttribute("aria-pressed")), +}; +textBtn.click(); +const afterText = { + view: views.getAttribute("data-view"), + pressed: toggle.children.map((b) => b.getAttribute("aria-pressed")), + stripSame: views.children[2] === stripBefore, + countSame: views.children.length === countBefore, +}; +console.log(JSON.stringify({ afterPages, afterText })); +""" + ) + res = json.loads(out) + assert res["afterPages"] == {"view": "pages", "pressed": ["false", "true"]} + assert res["afterText"] == { + "view": "text", + "pressed": ["true", "false"], + "stripSame": True, + "countSame": True, + }, "the strip node persists across flips — no re-render, no refetch" diff --git a/tests/unit/test_wide_column_css.py b/tests/unit/test_wide_column_css.py index cbbf2a2..557b256 100644 --- a/tests/unit/test_wide_column_css.py +++ b/tests/unit/test_wide_column_css.py @@ -13,12 +13,13 @@ without a browser: * NO ``@media (min-width: 1500px)`` block anywhere in the file — the phase-58 wide-desktop doubling is deleted in full (the token is 72rem at every viewport); -* the six reading-column selectors — ``.chat-shell``, +* the reading-column selectors — ``.chat-shell``, ``.shared-shell``, ``.doc-md``, ``.doc-image`` (phase 122, task 04), - ``.doc-pages`` (phase 148, task 03 — the page strip), and - ``.doc-summary:has(+ .doc-md)`` — each capped with - ``max-width: var(--chat-column)`` and NOTHING else in the file uses - the token for a max-width (exactly six rules); + ``.doc-pages`` (phase 148, task 03 — the page strip), + ``.doc-view-toggle`` (phase 163, task 04 — the Text/Pages toggle + above the additive views), and ``.doc-summary:has(+ .doc-md)`` — + each capped with ``max-width: var(--chat-column)`` and NOTHING else + in the file uses the token for a max-width (exactly seven rules); * the flipped negative pin — ZERO literal ``max-width: 46rem`` rules remain (the phase-27/59/91 form-column caps are retired): the form shells (``.tuning-shell``, ``.theme-shell``, ``.doc-edit-shell``) @@ -121,8 +122,10 @@ def test_the_reading_columns_use_the_token() -> None: task 04 — the viewer's image block rides the SAME reading column as its .doc-md sibling), .doc-pages (phase 148, task 03 — the viewer's page strip rides the SAME reading column in the shared - #doc-content slot) and .doc-summary:has(+ .doc-md) each cap with - max-width: var(--chat-column) — and exactly those six rules use + #doc-content slot), .doc-view-toggle (phase 163, task 04 — the + Text/Pages toggle sits above the additive views, aligned with + them) and .doc-summary:has(+ .doc-md) each cap with + max-width: var(--chat-column) — and exactly those seven rules use the token for a max-width (no other selector).""" css = _css() for selector in ( @@ -131,12 +134,13 @@ def test_the_reading_columns_use_the_token() -> None: ".doc-md", ".doc-image", ".doc-pages", + ".doc-view-toggle", ".doc-summary:has(+ .doc-md)", ): assert "max-width: var(--chat-column)" in _rule_block(css, selector), ( f"{selector} must cap with max-width: var(--chat-column)" ) - assert css.count("max-width: var(--chat-column)") == 6, ( + assert css.count("max-width: var(--chat-column)") == 7, ( "exactly the reading-column selectors use the token" )