From b233c8d7144d73d13bc335fe12f27680f155ecf3 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Thu, 1 Oct 2026 16:58:01 -0400 Subject: [PATCH] phase: 148_pdf_pages_in_sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Phase 148 — final verification pass: complete, 100% green.** - **Verified (tasks 01–04 already implemented):** page render/persist at index (`_persist_page_copies`, fail-soft, stale-replace, qualifier-loss, prune hook via `_delete_page_copies`); public `GET /api/documents/{id}/pages/{n}` (uuid4-credential, 404-all-non-servable, `image/png` + cache header); `DocContent.page_urls` additive-omitted; shared `renderDocument` strip (lazy imgs, "Page N" captions, text + Summary suppressed, onerror note, createElement-only); migration 0026; `pdf_page_dir` setting; `.env.example` entry - **Completion criteria:** (1) pictorial + fallback classes get `p{N}.png` + `page_count`, text-heavy gets neither — pinned, pass; (2) unchanged re-sync renders zero (fast path) — pass; (3) changed re-index replaces copies, qualifier-loss/prune delete — pass; (4) route 404 matrix + payload key absent-never-null — pass; (5) modal AND /document.html strip with suppression, text-heavy byte-identical — E2E pass; (6) see below; (7) no completed-phase drift — full suite green - **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → **3461 passed, 0 failed**, `app/` coverage **99%** (>90%); `uv run pytest tests/e2e/test_pdf_pages_in_sources.py -v --no-cov` → **4 passed** in isolation; `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings - **Documentation:** README had **1 pre-existing inconsistency** — the Production Deployment section's standalone `podman run` example (and compose note) omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the fail-loud boot gate the README's own Admin section documents — the container would refuse to start); fixed with the two `-e` flags + a note for the compose path. All else verified consistent: 70/70 `.env.example` vars documented, all defaults match `config.py`, pages table matches routes, Containerfile/esbuild/uid-10001/healthcheck/entrypoint claims accurate, QA commands match pytest config. **AGENTS.md: verified consistent — no changes needed** - **Notable:** repo uses `Containerfile` (Podman) — README's `podman build` example is valid as-is; no live infrastructure touched - **Next pending phase:** `149_sync_progress_box` --- .../148_pdf_pages_in_sources/00_phase.md | 0 .../01_page_render_at_index.md | 0 .../02_page_route_and_payload.md | 0 .../03_viewer_page_strip.md | 0 .../04_e2e_pdf_pages.md | 0 .../148_pdf_pages_in_sources__00_phase.a1.err | 0 .../148_pdf_pages_in_sources__00_phase.a1.md | 8 + ...pdf_pages_in_sources__00_phase.a1.validate | 120 +++ ...in_sources__01_page_render_at_index.a1.err | 0 ..._in_sources__01_page_render_at_index.a1.md | 19 + ...urces__01_page_render_at_index.a1.validate | 119 +++ ..._sources__02_page_route_and_payload.a1.err | 0 ...n_sources__02_page_route_and_payload.a1.md | 18 + ...ces__02_page_route_and_payload.a1.validate | 120 +++ ...es_in_sources__03_viewer_page_strip.a1.err | 0 ...ges_in_sources__03_viewer_page_strip.a1.md | 19 + ..._sources__03_viewer_page_strip.a1.validate | 120 +++ ..._pages_in_sources__04_e2e_pdf_pages.a1.err | 0 ...f_pages_in_sources__04_e2e_pdf_pages.a1.md | 15 + ...s_in_sources__04_e2e_pdf_pages.a1.validate | 120 +++ .../screenshots/doc_modal_scrollbar_1280.png | Bin 41225 -> 42368 bytes .env.example | 12 + README.md | 27 +- alembic/versions/0026_documents_page_count.py | 60 ++ app/api/docs.py | 89 ++ app/config.py | 18 + app/models.py | 15 + app/rag/importer.py | 202 ++++- app/schemas.py | 15 + frontend/assets/document.js | 97 ++- frontend/assets/styles.css | 45 +- tests/e2e/test_pdf_pages_in_sources.py | 775 ++++++++++++++++++ tests/e2e/test_pictorial_pdf.py | 117 ++- tests/integration/test_docs_api.py | 244 ++++++ tests/integration/test_migration_0026.py | 351 ++++++++ tests/unit/test_config.py | 12 + tests/unit/test_document_viewer.py | 76 +- tests/unit/test_pdf_page_copies.py | 628 ++++++++++++++ tests/unit/test_wide_column_css.py | 21 +- 39 files changed, 3426 insertions(+), 56 deletions(-) rename .agents/phases/{todo => complete}/148_pdf_pages_in_sources/00_phase.md (100%) rename .agents/phases/{todo => complete}/148_pdf_pages_in_sources/01_page_render_at_index.md (100%) rename .agents/phases/{todo => complete}/148_pdf_pages_in_sources/02_page_route_and_payload.md (100%) rename .agents/phases/{todo => complete}/148_pdf_pages_in_sources/03_viewer_page_strip.md (100%) rename .agents/phases/{todo => complete}/148_pdf_pages_in_sources/04_e2e_pdf_pages.md (100%) create mode 100644 .agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__00_phase.a1.err create mode 100644 .agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__00_phase.a1.md create mode 100644 .agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__00_phase.a1.validate create mode 100644 .agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__01_page_render_at_index.a1.err create mode 100644 .agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__01_page_render_at_index.a1.md create mode 100644 .agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__01_page_render_at_index.a1.validate create mode 100644 .agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__02_page_route_and_payload.a1.err create mode 100644 .agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__02_page_route_and_payload.a1.md create mode 100644 .agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__02_page_route_and_payload.a1.validate create mode 100644 .agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__03_viewer_page_strip.a1.err create mode 100644 .agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__03_viewer_page_strip.a1.md create mode 100644 .agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__03_viewer_page_strip.a1.validate create mode 100644 .agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__04_e2e_pdf_pages.a1.err create mode 100644 .agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__04_e2e_pdf_pages.a1.md create mode 100644 .agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__04_e2e_pdf_pages.a1.validate create mode 100644 alembic/versions/0026_documents_page_count.py create mode 100644 tests/e2e/test_pdf_pages_in_sources.py create mode 100644 tests/integration/test_migration_0026.py create mode 100644 tests/unit/test_pdf_page_copies.py diff --git a/.agents/phases/todo/148_pdf_pages_in_sources/00_phase.md b/.agents/phases/complete/148_pdf_pages_in_sources/00_phase.md similarity index 100% rename from .agents/phases/todo/148_pdf_pages_in_sources/00_phase.md rename to .agents/phases/complete/148_pdf_pages_in_sources/00_phase.md diff --git a/.agents/phases/todo/148_pdf_pages_in_sources/01_page_render_at_index.md b/.agents/phases/complete/148_pdf_pages_in_sources/01_page_render_at_index.md similarity index 100% rename from .agents/phases/todo/148_pdf_pages_in_sources/01_page_render_at_index.md rename to .agents/phases/complete/148_pdf_pages_in_sources/01_page_render_at_index.md diff --git a/.agents/phases/todo/148_pdf_pages_in_sources/02_page_route_and_payload.md b/.agents/phases/complete/148_pdf_pages_in_sources/02_page_route_and_payload.md similarity index 100% rename from .agents/phases/todo/148_pdf_pages_in_sources/02_page_route_and_payload.md rename to .agents/phases/complete/148_pdf_pages_in_sources/02_page_route_and_payload.md diff --git a/.agents/phases/todo/148_pdf_pages_in_sources/03_viewer_page_strip.md b/.agents/phases/complete/148_pdf_pages_in_sources/03_viewer_page_strip.md similarity index 100% rename from .agents/phases/todo/148_pdf_pages_in_sources/03_viewer_page_strip.md rename to .agents/phases/complete/148_pdf_pages_in_sources/03_viewer_page_strip.md diff --git a/.agents/phases/todo/148_pdf_pages_in_sources/04_e2e_pdf_pages.md b/.agents/phases/complete/148_pdf_pages_in_sources/04_e2e_pdf_pages.md similarity index 100% rename from .agents/phases/todo/148_pdf_pages_in_sources/04_e2e_pdf_pages.md rename to .agents/phases/complete/148_pdf_pages_in_sources/04_e2e_pdf_pages.md diff --git a/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__00_phase.a1.err b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__00_phase.a1.md b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__00_phase.a1.md new file mode 100644 index 0000000..9a0e0ba --- /dev/null +++ b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__00_phase.a1.md @@ -0,0 +1,8 @@ +**Phase 148 — final verification pass: complete, 100% green.** + +- **Verified (tasks 01–04 already implemented):** page render/persist at index (`_persist_page_copies`, fail-soft, stale-replace, qualifier-loss, prune hook via `_delete_page_copies`); public `GET /api/documents/{id}/pages/{n}` (uuid4-credential, 404-all-non-servable, `image/png` + cache header); `DocContent.page_urls` additive-omitted; shared `renderDocument` strip (lazy imgs, "Page N" captions, text + Summary suppressed, onerror note, createElement-only); migration 0026; `pdf_page_dir` setting; `.env.example` entry +- **Completion criteria:** (1) pictorial + fallback classes get `p{N}.png` + `page_count`, text-heavy gets neither — pinned, pass; (2) unchanged re-sync renders zero (fast path) — pass; (3) changed re-index replaces copies, qualifier-loss/prune delete — pass; (4) route 404 matrix + payload key absent-never-null — pass; (5) modal AND /document.html strip with suppression, text-heavy byte-identical — E2E pass; (6) see below; (7) no completed-phase drift — full suite green +- **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → **3461 passed, 0 failed**, `app/` coverage **99%** (>90%); `uv run pytest tests/e2e/test_pdf_pages_in_sources.py -v --no-cov` → **4 passed** in isolation; `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings +- **Documentation:** README had **1 pre-existing inconsistency** — the Production Deployment section's standalone `podman run` example (and compose note) omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the fail-loud boot gate the README's own Admin section documents — the container would refuse to start); fixed with the two `-e` flags + a note for the compose path. All else verified consistent: 70/70 `.env.example` vars documented, all defaults match `config.py`, pages table matches routes, Containerfile/esbuild/uid-10001/healthcheck/entrypoint claims accurate, QA commands match pytest config. **AGENTS.md: verified consistent — no changes needed** +- **Notable:** repo uses `Containerfile` (Podman) — README's `podman build` example is valid as-is; no live infrastructure touched +- **Next pending phase:** `149_sync_progress_box` diff --git a/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__00_phase.a1.validate b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__00_phase.a1.validate new file mode 100644 index 0000000..e08e342 --- /dev/null +++ b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__00_phase.a1.validate @@ -0,0 +1,120 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 79%] +........................................................................ [ 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 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 265 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 744 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 124 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5609 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/148_pdf_pages_in_sources/148_pdf_pages_in_sources__01_page_render_at_index.a1.err b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__01_page_render_at_index.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__01_page_render_at_index.a1.md b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__01_page_render_at_index.a1.md new file mode 100644 index 0000000..c2cec16 --- /dev/null +++ b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__01_page_render_at_index.a1.md @@ -0,0 +1,19 @@ +All gates green. Task 01 is complete. + +## Report + +**Implemented** (phase 148, task 01 — page render at index time): +- `app/config.py`: `pdf_page_dir` (`BOR_PDF_PAGE_DIR`, default `~/bor-sources/pages`) — raw-string `image_dir` convention +- `alembic/versions/0026_documents_page_count.py` + `Document.page_count` (nullable Int, no index, tested downgrade) +- `app/rag/importer.py`: eligibility signals in `_index_pdf_file` (phase-133 `fallback_fired` / phase-140 `pictorial_described`); fail-soft `_persist_pdf_pages` after flush (every page → `-p{N}.png`, `page_count` = page count, stale copies deleted before replacement, qualifier-loss/unopenable → NULL + copies deleted, partial-write cleanup); `_delete_page_copies` helper; `_prune` deletes copies with pruned rows (incl. parent-PDF path); `source_removal.py` untouched; fast path renders nothing by construction +- Docs: README env table + `.env.example` gained `BOR_PDF_PAGE_DIR`; AGENTS.md unchanged (no convention impact) + +**Tests / lint / coverage:** +- `uv run pytest` → **3455 passed, 0 failed** (incl. 12 new `tests/unit/test_pdf_page_copies.py` matrix, `tests/integration/test_migration_0026.py` round-trip, `test_config.py` pin; pre-phase PDF/importer/fast-path suites green unedited) +- `uv run pytest --cov=app --cov-report=term-missing` → **99%** on `app/` (gate: >90%) +- `uv run ruff check . && uv run pyright` → clean; `tests/e2e/test_smoke.py --no-cov` → 3 passed (boot sanity) +- Dev DB migrated to head (0026) + +**Notable decisions:** `_prune` takes an optional `page_dir` kwarg (the phase-122 `images` pattern) so pre-phase call sites keep working; `page_count` commits atomically with doc+chunks (block sits between flush and the chunk pipeline). No live-infra changes. + +**Next pending task:** `02_page_route_and_payload.md` diff --git a/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__01_page_render_at_index.a1.validate b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__01_page_render_at_index.a1.validate new file mode 100644 index 0000000..f0a4ec2 --- /dev/null +++ b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__01_page_render_at_index.a1.validate @@ -0,0 +1,119 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 97%] +....................................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 181 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 265 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 744 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 124 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 440 0 100% +-------------------------------------------------- +TOTAL 5591 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/148_pdf_pages_in_sources/148_pdf_pages_in_sources__02_page_route_and_payload.a1.err b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__02_page_route_and_payload.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__02_page_route_and_payload.a1.md b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__02_page_route_and_payload.a1.md new file mode 100644 index 0000000..6f6fcb3 --- /dev/null +++ b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__02_page_route_and_payload.a1.md @@ -0,0 +1,18 @@ +Task 02 complete. All verification green. + +**Implemented** +- `GET /api/documents/{doc_id}/pages/{n}` in `app/api/docs.py` — public page-bytes route (image route's phase-124 A4 trust model verbatim): `image/png` + `Cache-Control: private, max-age=3600`; 404 `document not found` for unparseable/unknown id, `page_count` NULL, `n` outside `1..page_count`, and missing file; path built only from row id + int `n` (no traversal surface); module docstring extended +- `DocContent.page_urls: list[str] | None` in `app/schemas.py` + serializer pop (absent, never null — the `image_url` precedent) +- `GET /documents/content` fills `page_urls` = exact 1-based route list when `page_count` is set +- Tests: serializer omission pin + stub-session endpoint pin (`tests/unit/test_document_viewer.py`); serve-bytes, full 404 matrix (incl. malformed id → 404 not 422, non-int `n` → 422, lost copy, out-of-range), public/anonymous, and payload presence/absence pins (`tests/integration/test_docs_api.py`, beside the image-route tests) + +**Results** +- `uv run pytest` → 3461 passed +- `uv run pytest --cov=app --cov-report=term-missing` → 3461 passed, TOTAL 99% (>90%; `app/schemas.py` 100%, `app/api/docs.py` 99% — sole miss is a pre-existing defensive branch) +- `uv run ruff check .` clean; `uv run pyright` → 0 errors + +**Docs**: no changes needed — `BOR_PDF_PAGE_DIR` already in README/.env.example from task 01; bytes routes aren't individually listed in the README (image-route parity); viewer-strip user-visible behavior lands in task 03. + +**Decisions**: none — followed the task's locked design verbatim. + +**Next pending task**: `03_viewer_page_strip.md` (the `renderDocument` page strip + CSS). diff --git a/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__02_page_route_and_payload.a1.validate b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__02_page_route_and_payload.a1.validate new file mode 100644 index 0000000..e08e342 --- /dev/null +++ b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__02_page_route_and_payload.a1.validate @@ -0,0 +1,120 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 79%] +........................................................................ [ 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 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 265 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 744 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 124 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5609 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/148_pdf_pages_in_sources/148_pdf_pages_in_sources__03_viewer_page_strip.a1.err b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__03_viewer_page_strip.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__03_viewer_page_strip.a1.md b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__03_viewer_page_strip.a1.md new file mode 100644 index 0000000..40d4b8b --- /dev/null +++ b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__03_viewer_page_strip.a1.md @@ -0,0 +1,19 @@ +Everything is complete and verified. Final report: + +**Task 03 — viewer page strip — complete** + +- `frontend/assets/document.js`: `renderDocument` gate (`page_urls` non-empty array → strip + early return — text content AND Summary panel incl. admin-edit wiring not built; meta row untouched; null/absent/empty → exact pre-phase path) + `docPagesStrip` helper (`.doc-pages`, one `
` per URL: lazy `` alt="Page N of \", "Page N" `
`; per-img `onerror` swaps only its `` for a "Page N unavailable." `role=status` note reusing the phase-122 `doc-image-unavailable` class); createElement/textContent only; module docstring gained the phase-148 paragraph + XSS-contract note +- `frontend/assets/styles.css`: `.doc-pages` (grid, 1rem gap, rides the `--chat-column` reading column), `.doc-page img` (surface/border/radius, width 100%), `.doc-page-num` (muted, centered, AA in both themes); no animation +- `tests/unit/test_wide_column_css.py`: `.doc-pages` added as the 6th token consumer (the phase-122 `.doc-image` precedent) +- Verified live (scratch Playwright, both surfaces): strip + suppression + meta intact; lost page → note on its figure only; text doc pre-phase shape + +**Results** +- `uv run pytest --cov=app --cov-report=term-missing` → **3461 passed, 0 failed**; `app/` coverage **99%** (>90%) +- Pre-phase E2E green unedited (isolated): `test_document_viewer` 8, `test_image_documents` 4, `test_summary_in_viewer`+`test_edit_summaries` 8, `test_document_dates`+`test_modal_back_button`+`test_document_back_navigation` 13 +- `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings; emoji guard green + +**Docs**: README PDF section's stale "Served as text" bullet updated (phase-148 strip contract; `BOR_PDF_PAGE_DIR` row already present from task 01); no AGENTS.md impact. + +**Notable**: the tracked `doc_modal_scrollbar_1280.png` visual record was rewritten by the E2E run — pixel diff verified to be ONLY the wall-clock meta-row dates (inherent drift, no visual regression). + +**Next pending task**: `04_e2e_pdf_pages.md` diff --git a/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__03_viewer_page_strip.a1.validate b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__03_viewer_page_strip.a1.validate new file mode 100644 index 0000000..e08e342 --- /dev/null +++ b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__03_viewer_page_strip.a1.validate @@ -0,0 +1,120 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 79%] +........................................................................ [ 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 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 265 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 744 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 124 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5609 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/148_pdf_pages_in_sources/148_pdf_pages_in_sources__04_e2e_pdf_pages.a1.err b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__04_e2e_pdf_pages.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__04_e2e_pdf_pages.a1.md b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__04_e2e_pdf_pages.a1.md new file mode 100644 index 0000000..877481b --- /dev/null +++ b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__04_e2e_pdf_pages.a1.md @@ -0,0 +1,15 @@ +All green. Final report: + +**Implemented** +- `tests/e2e/test_pdf_pages_in_sources.py` (new, task 04): dedicated isolated suite — module app (own port 8161, `BOR_PDF_PICTORIAL=1` opt-in, `BOR_PDF_PAGE_DIR` → scratch, `pdf` import token), two synthetic PyMuPDF PDFs (IKEA-shaped 3-page pictorial + 2-page text-heavy) in a local-dir source, API-registered + one real sync; seed asserts DB (`page_count==3` vs NULL, canned blocks in stored content, summaries) + filesystem (exactly `-p{1..3}.png`, real PNGs, no orphans). Tests: ① modal strip — 3 loaded lazy imgs (`decode()`+`naturalWidth>0`), `Page N of ` alts, `Page N` captions, no `.doc-raw`/`.doc-md`/`.doc-summary`, meta badges intact, canned description absent from the rendered modal; ② text-heavy byte-identical (`.doc-raw` + Summary panel, `page_urls` key ABSENT from the payload); ③ `/document.html` renders the same strip (one core, no drift); ④ anonymous `page_urls[0]` GET → 200 `image/png`, `Cache-Control: private, max-age=3600`, body byte-equal to the stored copy. +- Fixed stale `tests/e2e/test_pictorial_pdf.py` (phase-140 display pins contradicted phase-148's locked strip behavior — its fixture is page-qualifying under its own `BOR_PDF_PICTORIAL=1` opt-in): test 1 now asserts the 2-figure strip + suppression, seed gains `page_count == 2`, docstrings updated. Chat-echo + sync-detail tests unchanged. + +**Results** +- `uv run pytest tests/e2e/test_pdf_pages_in_sources.py -v --no-cov` → 4 passed (3 consecutive runs, reproducible) +- `uv run pytest --cov=app --cov-report=term-missing` → 3461 passed, `app/` 99% (>90%) +- `uv run ruff check .` clean; `uv run pyright` → 0 errors +- Pre-existing PDF/doc-viewer E2Es: test_pictorial_pdf 3 passed, test_pdf_support 6, test_garbage_pdf 3, test_pdf_image_context/test_document_summaries/test_summary_in_viewer/test_sync_changed_only/test_document_dates 19, test_document_viewer 8 — all green + +**Docs:** no doc impact (README/.env.example already carry phase-148 entries from tasks 01–02; task 04 is E2E-only) + +**Next pending task:** none — 04 is the last task of phase 148. diff --git a/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__04_e2e_pdf_pages.a1.validate b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__04_e2e_pdf_pages.a1.validate new file mode 100644 index 0000000..e08e342 --- /dev/null +++ b/.agents/reports/148_pdf_pages_in_sources/148_pdf_pages_in_sources__04_e2e_pdf_pages.a1.validate @@ -0,0 +1,120 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 79%] +........................................................................ [ 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 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 265 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 744 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 124 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5609 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 54fc60ae7a21a285748695a48dc5f88a7838e240..f282e911e06502d944c5ac0426c964cc10e84f94 100644 GIT binary patch literal 42368 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%0A)lPPpG>Pv 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 I51s%1AF8lqd;kCd literal 41225 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ℜ)Luf6DD1hhSpJ>QHy?sNGhp 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 diff --git a/.env.example b/.env.example index 1c655e3..b0c9d64 100644 --- a/.env.example +++ b/.env.example @@ -132,6 +132,18 @@ BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume, # BOR_IMAGE_DIR=~/bor-sources/images # persistent home for the served image bytes # (uploads are replaced, checkouts re-cloned) +# Phase 148 (PDF page copies): where a page-qualifying PDF (the +# phase-133 screenshot class — unusable text layer — or the phase-140 +# pictorial class — the sparse-page pass described >=1 page) gets EVERY +# page rendered at index time as <doc-id>-p{N}.png, so the document +# viewer shows the actual pages (TODO L5 — the IKEA class: the page +# descriptions help the embeddings but not when shown to the user). +# Text-heavy PDFs keep the text view. The copy must outlive the source +# file (uploads are replaced, checkouts re-cloned — the image_dir +# rationale); stale copies are replaced on re-index, deleted on a +# qualifier-loss and on prune. +# BOR_PDF_PAGE_DIR=~/bor-sources/pages # persistent home for the rendered page PNGs + # Phase 124 (answer images): off by default; enable alongside # BOR_IMAGES to let the model show a supporting image in its answer # (independent of it — BOR_IMAGES indexes image docs, this one shows diff --git a/README.md b/README.md index f424cb0..c264e97 100644 --- a/README.md +++ b/README.md @@ -678,8 +678,21 @@ uploaded archives): pages on the import summary line (`pdfs_pictorial=N` `pdf_pictorial_pages=N`). `BOR_PDF_PICTORIAL=0` turns the whole pass off (byte-identical pre-phase-140 content). -- **Served as text.** The document viewer renders the extracted text — - never the binary PDF. +- **Served as text — or as pages (phase 148).** The document viewer + renders the extracted text — never the binary PDF. The exception is + the two page-qualifying classes: a PDF whose text layer fired the + phase-133 screenshot fallback, or a PDF whose phase-140 pictorial + pass described ≥ 1 sparse page. For those, every page is rendered + to a persistent PNG at index time (under `BOR_PDF_PAGE_DIR`) and + the viewer — the doc modal AND `/document.html`, one shared core — + 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 + 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. --- @@ -805,6 +818,7 @@ to `.env` and adjust. | `BOR_IMPORT_EXTENSIONS` | A9 family | Importable text formats — the A9 default is `md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2`; any well-formed extension is allowed. Add the `pdf` token to make PDFs first-class, retrievable documents (phase 126, opt-in). Images are **not** added here — see `BOR_IMAGES` | | `BOR_PDF_PICTORIAL` | `1` | Master switch for the pictorial PDF pass (phase 140, on by default): a usable-text PDF's sparse pages (stripped text under `BOR_PDF_PICTORIAL_MIN_CHARS`) are rendered + vision-described into the indexed content (`[Page N illustration: …]`); `0` ⇒ content byte-identical to pre-phase-140 (no render, no vision call) | | `BOR_PDF_PICTORIAL_MIN_CHARS` | `100` | Sparseness bar for the pictorial pass (phase 140): a page whose STRIPPED text is STRICTLY shorter than this many chars is a candidate (a page with exactly this many is not); `0` = every page (A21); a negative value fails startup loudly | +| `BOR_PDF_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 `<doc-id>-p{N}.png` here, so the document viewer shows the actual pages (the descriptions stay embedded for Q&A, just not displayed); text-heavy PDFs keep the text view | | `BOR_GIT_SOURCES` | — (empty) | CSV of git repo URLs — **fallback while the admin Git sources page's list is empty** | | `BOR_SOURCES_DIR` | `~/bor-sources` | Where git repos are cloned/pulled | | `BOR_UPLOAD_DIR` | `~/bor-sources/uploads` | Where uploaded source archives are unpacked | @@ -954,15 +968,24 @@ podman run -d --name brain-of-reese \ -e BOR_DATABASE_URL=postgresql+psycopg://reese:SECRETPASSWORD@dbhost:5432/brain_of_reese \ -e BOR_LLM_BASE_URL=https://aipi.reeseapps.com/v1 \ -e BOR_LLM_API_KEY=$AIPI_KEY \ + -e BOR_ADMIN_PASSWORD=your-password \ + -e BOR_SESSION_SECRET=<the-hex-from-the-setup-above> \ brain-of-reese/app:latest ``` +`BOR_ADMIN_PASSWORD` and `BOR_SESSION_SECRET` are **required** — the +fail-loud boot gate in [Admin & Sign-in](#admin--sign-in) refuses to +start while either is empty. + The entrypoint runs `alembic upgrade head` automatically on start. Or run the whole stack from compose (app + db): ```bash podman compose --profile prod up -d --build ``` +The `app` service picks up `BOR_ADMIN_PASSWORD` / `BOR_SESSION_SECRET` +from the shell environment (or an env file) — the same fail-loud gate +applies. Production hardening: app runs as non-root (uid 10001), slim image, healthcheck on `/api/health`, debugpy off unless `DEBUGPY=1`, all assets diff --git a/alembic/versions/0026_documents_page_count.py b/alembic/versions/0026_documents_page_count.py new file mode 100644 index 0000000..f17cf0c --- /dev/null +++ b/alembic/versions/0026_documents_page_count.py @@ -0,0 +1,60 @@ +"""documents.page_count: the rendered page-strip's page count (phase 148, task 01) + +Revision ID: 0026 +Revises: 0025 +Create Date: 2026-10-01 + +Phase 148 (pictorial PDFs show their PAGES in the sources — task 01, +storage only): + +* ``documents.page_count`` — INTEGER NULLABLE, NO server default, NO + index: the number of rendered page PNGs persisted in + ``settings.pdf_page_dir`` (``BOR_PDF_PAGE_DIR``) as + ``<doc-id>-p{N}.png`` (N 1-based) for ONE page-qualifying document — + the PDF's page count. Set ONLY by the importer's PDF branch (task 01): + a qualifying PDF (the phase-133 fallback class — unusable text layer, + — or the phase-140 pictorial class — the sparse-page pass described + ≥1 page) gets every page rendered at index time, and this column + records the page count (a per-page render failure leaves a GAP — the + file is simply absent, the route of task 02 404s that N, the viewer's + note covers it). NULL for every other document (text documents, + images, text-heavy PDFs) and for a qualifying PDF whose render/write + failed (fail-soft — the render is a display concern, it never gates + indexing); the prune cleanup deletes the copies with the row. + + A display fact, never a query surface: the page-bytes route (task 02) + looks the document up by its uuid4 id FIRST (the unguessable id is the + credential — the phase-124 image-route trust model) and reads this + column from the ROW, so it carries no index of its own (the + ``file_hash`` convention, 0025). + +One additive, fully reversible migration (A13); no other schema change. +The render/persist/cleanup logic itself (task 01) is importer code — +this revision only carries the column. +""" +from __future__ import annotations + +import sqlalchemy as sa + +from alembic import op + +revision = "0026" +down_revision = "0025" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # No index (a display fact, never a query surface — the page route + # reads it from the row after the uuid4 id lookup) and no server + # default (NULL is the meaningful state for every non-qualifying + # document and every fail-soft miss). + op.add_column("documents", sa.Column("page_count", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + # The page_count column is the only 0026 artefact — dropping it + # leaves 0025's schema byte-identical (A13, fully reversible). The + # rendered copies are re-derivable from the source files on the + # next sync, so no data is lost by the drop. + op.drop_column("documents", "page_count") diff --git a/app/api/docs.py b/app/api/docs.py index 46c9517..c75848f 100644 --- a/app/api/docs.py +++ b/app/api/docs.py @@ -40,6 +40,20 @@ traversal/UUID-guessing has no filesystem surface to hit). The document's TEXT (the content endpoint) stays user-gated — only the bytes are public. +GET /api/documents/{doc_id}/pages/{n} — the phase-148 (task 02) PDF +page BYTES route: ONE rendered page PNG (``<doc-id>-p{N}.png`` in the +importer's ``pdf_page_dir`` copy — task 01) of a page-QUALIFYING +document (the phase-133 fallback class or the phase-140 pictorial +class). PUBLIC — the image route's phase-124 A4 trust model verbatim +(a shared chat can cite a page figure, so the bytes serve +ANONYMOUSLY; the unguessable ``uuid4`` document id IS the credential — +the document's TEXT stays user-gated). ``image/png`` + +``Cache-Control: private, max-age=3600`` (the image route's header); +404 ``document not found`` for EVERY non-servable case (an +unparseable id, an unknown doc, ``page_count`` NULL, ``n`` outside +``1..page_count``, or a copy lost on disk). The path is built from the +ROW's id + ``n`` only — no request value reaches a filesystem path. + PATCH /api/folders/summary — the admin folder-description editor (phase 97, task 03): update / create / clear a stored ``folder_summaries`` row, marking every non-empty save @@ -228,6 +242,18 @@ def get_document_content( ), origin_url=origin[0] if origin else None, origin_branch=origin[1] if origin else None, + # Phase 148 (task 02): the rendered-page BYTES routes for a + # page-qualifying PDF — the exact 1-based list over + # ``1..page_count`` (the viewer's strip source, task 03; a + # per-page render failure leaves a GAP the route 404s). ABSENT + # (never null — the ``image_url`` omission rule) for every + # other document, so its payload is byte-identical to + # pre-phase. + page_urls=( + [f"/api/documents/{doc.id}/pages/{i}" for i in range(1, doc.page_count + 1)] + if doc.page_count is not None + else None + ), ) @@ -290,6 +316,69 @@ def get_document_image( ) +@router.get("/documents/{doc_id}/pages/{n}", response_class=FileResponse) +def get_document_page( + doc_id: str, + n: int, + db: Session = Depends(get_db), # noqa: B008 +) -> FileResponse: + """The phase-148 (task 02) PDF page BYTES route — ONE rendered page + PNG (``<doc-id>-p{N}.png`` in the importer's ``pdf_page_dir`` copy, + task 01) of a page-QUALIFYING document. + + PUBLIC (the phase-124 A4 trust model verbatim — the image route's + precedent): a shared chat can cite a page FIGURE (the viewer's + strip, task 03), so the bytes serve ANONYMOUSLY. The document id + is an unguessable server-minted ``uuid4`` — IT IS the credential, + with no enumeration value — exactly like the image route and the + saved-chat id (phase 55). The document's TEXT stays user-gated (the + content endpoint): only the page bytes are public. + + 404 ``document not found`` (the router's unknown-document shape) in + EVERY non-servable case — an unparseable ``doc_id`` (the + ``uuid.UUID`` try/except maps here, not to a 422 — the image + route's mapping: a guessed id is an unknown document), an unknown + doc, a doc whose ``page_count`` is NULL (a non-qualifying doc — the + route knows nothing of its pages), ``n`` outside ``1..page_count`` + (out of range — a GAP, a page whose render failed, hits the + missing-file arm below), or a file on disk that is missing + (defensive — the row exists, the bytes don't; the viewer's onerror + note covers it). + + ``n`` is an ``int`` path parameter (FastAPI parses it — a non-int + 422s at the router), and the path is built ONLY from the row's id + + ``n`` (``pdf_page_dir/<doc.id>-p{n}.png``) — no request value + reaches a filesystem path (no traversal surface, the image route's + property). + + Servable pages stream the exact stored bytes as ``image/png`` + (``render_pdf_pages``'s ``tobytes("png")`` — a page is always a + PNG) with ``Cache-Control: private, max-age=3600`` (the image + route's header — the bytes are content-addressed by the document + id + page number, bustable by re-index). + """ + try: + uid = uuid.UUID(doc_id) + except ValueError: + raise HTTPException(status_code=404, detail="document not found") from None + doc = db.scalar(select(Document).where(Document.id == uid)) + if doc is None or doc.page_count is None: + raise HTTPException(status_code=404, detail="document not found") + if not 1 <= n <= doc.page_count: + raise HTTPException(status_code=404, detail="document not found") + page_file = Path(get_settings().pdf_page_dir).expanduser() / f"{doc.id}-p{n}.png" + if not page_file.is_file(): + # Defensive: the row qualifies but the copy was lost (the owner + # cleaned the page dir, the disk was wiped) — the viewer's + # onerror note renders "Page N unavailable". + raise HTTPException(status_code=404, detail="document not found") + return FileResponse( + page_file, + media_type="image/png", + headers={"Cache-Control": "private, max-age=3600"}, + ) + + @router.patch("/documents/summary", response_model=SummaryResult) async def update_document_summary( payload: SummaryUpdate, diff --git a/app/config.py b/app/config.py index 51b4503..82555f0 100644 --- a/app/config.py +++ b/app/config.py @@ -395,6 +395,24 @@ class Settings(BaseSettings): #: checkouts, re-cloned) and ``upload_dir`` (replaced on every #: upload): the served copy must outlive the source file. image_dir: str = "~/bor-sources/images" + #: Where rendered PDF PAGE PNGs are copied for serving (phase 148, + #: ``BOR_PDF_PAGE_DIR`` — the ``image_dir`` convention): a + #: page-qualifying PDF (the phase-133 fallback class — unusable + #: text layer — or the phase-140 pictorial class — the sparse-page + #: pass described ≥1 page) gets EVERY page rendered at index time + #: to ``<doc-id>-p{N}.png`` here (N 1-based; ``documents.page_count`` + #: is the page count — a per-page render failure leaves a gap), + #: so the document viewer shows the ACTUAL pages instead of the + #: page descriptions (TODO L5 — the IKEA assembly-instruction + #: class: the descriptions help the embeddings, "but not when + #: shown to the user"). Raw string — ``Path.expanduser()`` is + #: applied by the importer, not here (the ``sources_dir``/ + #: ``upload_dir``/``image_dir`` convention). Deliberately separate + #: from ``sources_dir``/``upload_dir``: git checkouts are + #: re-cloned, uploads are replaced, local dirs are user-edited — + #: the served copy must outlive the source file (the ``image_dir`` + #: rationale). + pdf_page_dir: str = "~/bor-sources/pages" # --- Pictorial PDF pages (phase 140: sparse pages rendered + vision-described) --- #: Master switch for the pictorial PDF pass (phase 140, LOCKED A3, diff --git a/app/models.py b/app/models.py index 8813275..4b5b455 100644 --- a/app/models.py +++ b/app/models.py @@ -183,6 +183,21 @@ class Document(Base): #: embedding model embeds this alongside the vision description when #: the image is enriched (task 03). image_context: Mapped[str | None] = mapped_column(Text, default=None) + #: The number of rendered page PNGs persisted in + #: ``settings.pdf_page_dir`` (phase 148, task 01) as + #: ``<doc-id>-p{N}.png`` (N 1-based): the PDF's page count for one + #: page-QUALIFYING document — the phase-133 fallback class (unusable + #: text layer — the screenshot class) or the phase-140 pictorial + #: class (the sparse-page pass described ≥1 page — the IKEA + #: assembly-instruction class). NULL for every other document + #: (text documents, images, text-heavy PDFs) and for a qualifying + #: PDF whose render/write failed (fail-soft — the render is a + #: display concern, it never gates indexing). Set/cleared ONLY by + #: the importer's PDF branch (a per-page render failure leaves a + #: GAP — the file is absent, the page route 404s that N); the prune + #: 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) 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 355577a..723970d 100644 --- a/app/rag/importer.py +++ b/app/rag/importer.py @@ -177,6 +177,29 @@ counters ``pdfs_pictorial`` / ``pdf_pictorial_pages`` are log-only (the sync ``detail`` API shape is untouched, the stats log-only convention). +Persistent page copies (phase 148, task 01): in the PDF branch's +NEW/CHANGED path — after the final content is decided (the +fallback/pictorial passes have run) and after the row's flush (the +doc has its id) — a page-QUALIFYING PDF (the phase-133 fallback +fired, or the phase-140 pictorial pass described ≥1 page — the +sync's own decisions: text-heavy PDFs stay ineligible, and the +``pdf_pictorial`` kill switch keeps its semantics) gets EVERY page +rendered at index time (:func:`app.rag.pdf.render_pdf_pages` — 150 +dpi, every page) and persisted to ``settings.pdf_page_dir`` +(``BOR_PDF_PAGE_DIR``, the ``image_dir`` convention) as +``<doc-id>-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 +(: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). + Image-anchor context (phase 125, task 03, LOCKED A1/A2/A3): BEFORE the description, the same source's importable markdown files are scanned for ``![alt](path)`` references that name the image (the task-02 @@ -851,6 +874,7 @@ async def import_sources( summary.pruned = _prune( session, source_names, seen, images=llm.settings.images, summary=summary, + page_dir=Path(llm.settings.pdf_page_dir).expanduser(), ) summary.embed_batches = llm.embed_batches summary.log() @@ -1413,7 +1437,19 @@ 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). + sync takes the fast path). This path renders NO pages and + touches NO ``page_count`` (the copies persist — zero render + 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 + ``<doc-id>-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. * THEN the embedded images: :func:`_index_pdf_embedded_images` (A3 — the phase-122 pipeline, gated by the ``images`` toggle). @@ -1435,12 +1471,25 @@ async def _index_pdf_file( # Extract the text (fail-soft: an unopenable PDF yields "" — the # document may still carry embedded images worth indexing). content = extract_pdf_text(full_path) + # Phase 148 (task 01): the page-strip ELIGIBILITY signals — the + # LOCKED rule, recorded where the sync makes the decision: a PDF + # gets rendered pages iff the sync ALREADY decided its text is not + # the useful view — the phase-133 fallback fired (unusable text + # layer — the screenshot class) OR the phase-140 pictorial pass + # described ≥1 page (the sparse-page class — the IKEA fixture). + # Text-heavy PDFs (usable layer, zero candidates) stay ineligible + # (the text view), and ``pdf_pictorial=0`` keeps its kill-switch + # semantics (no pictorial pass → the sparse class stays ineligible + # — the pre-phase-148 viewer). + fallback_fired = False + pictorial_described = False # Phase 133 (task 03): the garbage gate — BEFORE the digest/chunk/ # summary pipeline, so it runs on the FINAL content exactly once. # A clean layer never reaches the fallback (the gate is the only # gate — no fallback vision calls on the happy path). if not is_usable_pdf_text(content): summary.pdfs_screenshotted += 1 + fallback_fired = True content = await _fallback_page_screenshots( session, source=source, rel=rel, full_path=full_path, llm=llm ) @@ -1457,6 +1506,7 @@ async def _index_pdf_file( source=source, rel=rel, full_path=full_path, llm=llm, summary=summary ) if passed is not None: + pictorial_described = True content = passed if not content: logger.warning( @@ -1515,6 +1565,35 @@ async def _index_pdf_file( session.flush() # guarantees doc.id even for brand-new rows + # Phase 148 (task 01): the PERSISTENT PAGE COPIES — a qualifying + # PDF (the eligibility signals above) gets every page rendered to + # ``settings.pdf_page_dir`` (``<doc-id>-p{N}.png``) and + # ``documents.page_count`` set. RIGHT AFTER the flush (the doc has + # 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. + try: + _persist_pdf_pages( + doc, + full_path=full_path, + page_dir=Path(llm.settings.pdf_page_dir).expanduser(), + eligible=fallback_fired or pictorial_described, + source=source, + rel=rel, + ) + except Exception as e: + doc.page_count = None + logger.warning( + "import: PDF page render failed source=%s path=%s — %s", + source, rel, e, + ) + # Phase 1+2 — the shared chunk/replace/embed pipeline (the page- # aware chunker dispatches on the ``.pdf`` suffix; empty content # clears the chunk rows and returns 0), committed atomically with @@ -1757,6 +1836,91 @@ async def _pictorial_pdf_pass( return "\n\n".join(parts) +def _persist_pdf_pages( + doc: Document, + *, + full_path: Path, + page_dir: Path, + eligible: bool, + 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). + + * 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, + :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 + ``<doc-id>-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 + ``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). + + *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: + doc.page_count = 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). + logger.warning( + "import: PDF page render produced no pages source=%s path=%s", + source, rel, + ) + doc.page_count = None + return + page_dir.mkdir(parents=True, exist_ok=True) + written: list[Path] = [] + 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) + 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: + path.unlink(missing_ok=True) + raise + doc.page_count = len(pages) + + async def _index_pdf_embedded_images( session: Session, *, @@ -2800,6 +2964,28 @@ def _delete_image_copy(image_path: str | None) -> None: logger.warning("import: could not delete image copy %s — %s", image_path, e) +def _delete_page_copies(doc: Document, page_dir: Path) -> None: + """Best-effort removal of one document's stale PDF page copies + (phase 148, task 01 — the :func:`_delete_image_copy` convention): + the ``<doc-id>-p{N}.png`` files for N in 1..``doc.page_count`` — + the row knows its own extent (a NULL/zero count is a no-op, which + covers fresh docs, non-qualifying docs, and every fail-soft miss). + + A missing file is a silent no-op (already gone — e.g. the owner + cleaned the pages dir); an unreadable one is logged, never raised — + copy cleanup must not break the sync (the doc row's fate is decided + by the upsert/prune logic, not by filesystem hygiene). + """ + if not doc.page_count: + return + for n in range(1, doc.page_count + 1): + 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_image_file( session: Session, *, @@ -3020,6 +3206,7 @@ def _prune( seen: set[tuple[str, str]], images: bool = False, summary: ImportSummary | None = None, + page_dir: Path | None = None, ) -> int: """Delete documents of *source_names* whose file is no longer in *seen*. @@ -3050,6 +3237,14 @@ def _prune( appended BEFORE the ``session.delete`` — so the sync's incremental folder-summary regeneration (phase 127) sees the pruned docs' folders too. Callers passing nothing keep the pre-phase count-only contract. + + *page_dir* (phase 148, task 01): when given (the sync's + ``Path(settings.pdf_page_dir).expanduser()``), every pruned doc's + PDF page copies (1..``doc.page_count`` — a NULL count is a no-op, + covering non-qualifying docs) are deleted beside the row and the + image copy — no orphans when a page-qualifying PDF (its parent or + a standalone doc) leaves the source. Callers passing nothing keep + the pre-phase copy-cleanup contract. """ if not source_names: return 0 @@ -3069,6 +3264,11 @@ def _prune( # invisible to the walk, not deleted. continue _delete_image_copy(doc.image_path) + # Phase 148 (task 01): the page-qualifying doc's page + # copies go with the row (a pruned parent PDF's copies too + # — the same site as its embedded-image copies' deletion). + if page_dir is not None: + _delete_page_copies(doc, page_dir) if summary is not None: summary.changed_paths.append((doc.source, doc.path)) session.delete(doc) diff --git a/app/schemas.py b/app/schemas.py index 2acdd6e..ba01c7b 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -602,6 +602,19 @@ class DocContent(BaseModel): #: repository's default branch — nothing to show); also dropped by #: the serializer (never ``null``). origin_branch: str | None = None + #: Phase 148 (task 02) — the rendered-page BYTES routes for a + #: page-QUALIFYING PDF (the phase-133 fallback class or the + #: phase-140 pictorial class): ``/api/documents/<id>/pages/<i>`` + #: for ``i`` in 1..``page_count`` (1-based — the importer's + #: ``<doc-id>-p{N}.png`` copy names; a per-page render failure + #: leaves a GAP the route 404s — the viewer's note covers it). The + #: viewer's page strip (task 03) renders from it; the document's + #: text stays in the DB for Q&A, it is simply not displayed. ABSENT + #: from the wire for every OTHER document — text docs, images, + #: text-heavy PDFs (``None`` → dropped by the serializer — never + #: ``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 @model_serializer(mode="wrap") def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any: @@ -612,6 +625,8 @@ class DocContent(BaseModel): data.pop("origin_url", None) if self.origin_branch is None: data.pop("origin_branch", None) + if self.page_urls is None: + data.pop("page_urls", None) return data diff --git a/frontend/assets/document.js b/frontend/assets/document.js index d9fb3e0..0c0df47 100644 --- a/frontend/assets/document.js +++ b/frontend/assets/document.js @@ -36,8 +36,10 @@ * * XSS-safe by construction: markdown is escaped before transform, raw * formats are set via textContent, and every document-derived string - * (title, badges, path) is written with textContent — never innerHTML - * (innerHTML goes through renderMarkdown, which escapes first). + * (title, badges, path — and the phase-148 page strip's alt text and + * captions) is written with textContent or a property — never + * innerHTML (innerHTML goes through renderMarkdown, which escapes + * first). * * Phase 19: the viewer joins the shared header (assets/header.js) — the * whoami fetch is the module's cached promise (one request per page, @@ -134,6 +136,29 @@ * AND /document.html render it (no per-surface copy); createElement * + properties/textContent only (the never-build-HTML rule — no * innerHTML). + * + * Phase 148 (task 03): the page strip — when the payload carries + * page_urls (a page-qualifying PDF — the phase-133 fallback class + * or the phase-140 pictorial class — the task-02 endpoint's exact + * 1-based /api/documents/{id}/pages/{n} route list; ABSENT on the + * 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 <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 + * Q&A (the content/embeddings/summaries are untouched); they are + * simply not shown to the user — so the Summary panel is NOT built + * (its admin edit affordance is moot without it). A page whose copy + * was lost (the route's defensive 404s) swaps ONLY ITS figure's + * <img> for a "Page N unavailable." note (role=status — the + * phase-122 image-unavailable idiom, the same muted class); the rest + * of the strip is untouched (one lost copy never hides the other + * pages). ONE shared core — the modal AND /document.html get it (no + * per-surface copy); createElement + properties/textContent only (the + * never-build-HTML rule — no innerHTML). */ import { bindSharedHeaderControls, fetchIsAdmin, initSharedHeader } from "./header.js"; @@ -257,6 +282,24 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) { }); contentEl.replaceChildren(); + // 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 + // 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 + // importer's content/embeddings/summary are untouched); they are + // simply not shown to the user (the owner's contract). The meta + // badge row above renders exactly as pre-phase. null / absent / + // empty page_urls falls through to the EXACT pre-phase rendering + // 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) { + contentEl.appendChild(docPagesStrip(doc, pageUrls)); + return; + } // 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 @@ -343,6 +386,56 @@ function docImageBlock(doc) { return wrap; } +/* ---------- page strip (phase 148, task 03) ---------- + * The viewer's page strip for a page-qualifying PDF (doc.page_urls + * non-empty — the task-02 payload: one + * /api/documents/{id}/pages/{n} route per rendered page, 1-based). + * A .doc-pages container goes into the content slot INSTEAD of the + * text content and the Summary panel (the renderDocument branch + * above) — the owner's contract: the per-page descriptions stay in + * the DB for Q&A, they are just not displayed. 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, the phase-122 image idiom) + a "Page N" + * <figcaption class="doc-page-num">. A page whose copy was lost + * (the route's defensive 404) swaps ONLY ITS figure's <img> for a + * small "Page N unavailable." note (role=status — the phase-122 + * image-unavailable note class, the same muted styling); the rest of + * the strip is untouched (one lost copy never hides the other pages). + * createElement + properties/textContent only — every document- + * derived string (the title in the alt, the note) lands via a + * property or textContent, never innerHTML (the XSS contract, + * unchanged). */ +function docPagesStrip(doc, pageUrls) { + const wrap = document.createElement("div"); + wrap.className = "doc-pages"; + pageUrls.forEach((url, i) => { + const n = i + 1; // 1-based over page_urls (the route list's own numbering) + const figure = document.createElement("figure"); + figure.className = "doc-page"; + const img = document.createElement("img"); + img.loading = "lazy"; // lazy imgs — a long manual streams down the strip + img.src = url; + img.alt = `Page ${n} of ${doc.title}`; + img.addEventListener("error", () => { + // The phase-122 idiom (its muted note class, reused): the lost + // copy's <img> becomes a small "Page N unavailable." note in + // ITS figure only — the caption and every other page stand. + const note = document.createElement("p"); + note.className = "doc-image-unavailable"; + note.setAttribute("role", "status"); + note.textContent = `Page ${n} unavailable.`; + img.replaceWith(note); + }); + const figcaption = document.createElement("figcaption"); + figcaption.className = "doc-page-num"; + figcaption.textContent = `Page ${n}`; + figure.append(img, figcaption); + wrap.appendChild(figure); + }); + return wrap; +} + /* ---------- summary editing (phase 57, task 02 — D4, admin-only) ---------- * The .doc-summary panel is the ONE place the stored summary is edited * (page + modal through this core). Only an admin (docAdminReady) ever diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 398bf77..d1710fd 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -4778,13 +4778,56 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und } /* The onerror fallback (the route's 404 — the row exists but the copy was lost): a muted note in the card's place-of-image; the - description below still renders, so the doc stays readable. */ + description below still renders, so the doc stays readable. Phase + 148 (task 03) reuses THIS class for the page strip's lost-page + note (the same muted styling — no new pattern). */ .doc-image-unavailable { margin: 1.25rem 1rem; color: var(--ink-soft); /* 5.1:1 on --surface (AA) */ font-size: 0.9rem; } +/* Page strip (phase 148, task 03): the viewer's rendered-page strip + for a page-qualifying PDF — the payload's page_urls (task 02's + exact 1-based route list) replaces the text content AND the + .doc-summary panel (the owner's contract: the per-page + descriptions stay in the DB for Q&A, just not shown to the user). + The core renders it into the SAME #doc-content slot the .doc-raw / + .doc-md paths use — no new column — inside the existing doc + content column: --chat-column (72rem — the .container width, the + .doc-md / .doc-image language) at every viewport. A vertical stack + (one page per row — the natural reading order for instruction + manuals); each figure carries the page PNG on the non-clashing + --surface with the line border + radius (the .doc-image figure + precedent — a page PNG must sit on a surface in both themes) and a + centered muted "Page N" caption. Static content — no animation + (nothing for prefers-reduced-motion to still); the lazy imgs + alt + text (WCAG) carry the accessibility. Phase-08 tokens only (no new + hue — the monochrome invariant). */ +.doc-pages { + display: grid; + gap: 1rem; + width: 100%; + max-width: var(--chat-column); + margin-inline: auto; +} +.doc-page { margin: 0; } +.doc-page img { + display: block; + width: 100%; + max-width: 100%; + height: auto; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); +} +.doc-page-num { + font-size: 0.78rem; + color: var(--ink-soft); /* 8.2:1 on --surface, ~9.7:1 on --bg (AA in both themes) */ + text-align: center; + padding: 0.35rem 0 0; +} + /* 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 new file mode 100644 index 0000000..c8106db --- /dev/null +++ b/tests/e2e/test_pdf_pages_in_sources.py @@ -0,0 +1,775 @@ +"""Phase 148 task 04 E2E (Playwright, mock-only): the pictorial PDF +opens as its PAGES — a text-heavy PDF renders byte-identical to +pre-phase. + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_pdf_pages_in_sources.py -v --no-cov + +The dedicated suite for ``148_pdf_pages_in_sources`` (A16 — one +Playwright file per phase, run in isolation): the owner's TODO L5 +scenario end to end on SYNTHETIC fixtures in the owner's named +``ikea_instructions.tar``'s exact CLASS (the real fixture is pinned at +the integration level — the E2E keeps the deterministic canned-reply +premise). Two PyMuPDF PDFs (the house recipe) land in a LOCAL +DIRECTORY source (``BOR_SOURCES_DIR``-sibling temp dir, API-registered +— local dirs are the owner's named case) and one real sync indexes +them: + +* ``pictorial.pdf`` — the IKEA shape (the pictorial class): page 1 a + dense prose paragraph (over the 100-char sparseness bar — NOT a + candidate; the whole layer passes ``is_usable_pdf_text``), pages + 2–3 SPARSE (a short marker + a drawn VECTOR figure — rectangle + + arrow, <100 stripped chars — the ``pictorial_pages(texts, + min_chars=100)`` candidates the mock's vision describes with the + canned ``PICTORIAL_PAGE_ANSWER``); +* ``textheavy.pdf`` — 2 dense text pages (ZERO candidates — the + negative class: the text view, the pre-phase rendering). + +App env: the conftest pattern with the phase-140 opt-in (``BOR_PDF_ +PICTORIAL`` pinned EXPLICITLY ``1`` — the conftest pins it off in +``os.environ`` for the pre-phase-140 suites) + the phase-148 +``BOR_PDF_PAGE_DIR`` pointed at a suite-private scratch dir (the seed +asserts the copies THERE; the owner's ``~/bor-sources/pages`` default +is never touched). ``BOR_IMPORT_EXTENSIONS`` carries the ``pdf`` +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. + +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. +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. +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 + PNG body byte-equal to the stored copy (the public + uuid4-credential bytes route, from the UI's perspective). + +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 four tests SHARE the module seed +(one source, two documents, one sync), 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 +from urllib.parse import quote + +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, SESSION_SECRET, USE_REAL_LLM, _wait_http +from e2e.mock_llm import PICTORIAL_PAGE_ANSWER + +REPO = Path(__file__).resolve().parents[2] + +#: The local directory's NAME — the source name the registry stores +#: for a local-kind source (the directory basename, the +#: test_local_directory_sources convention). +SOURCE_NAME = "e2e-p148-pdf" +PICTORIAL_PATH = "pictorial.pdf" +TEXTHEAVY_PATH = "textheavy.pdf" +PICTORIAL_TITLE = "Assembly Instructions" # the metadata title +TEXTHEAVY_TITLE = "Dense Notes" # the metadata title + +#: Page 1 — the dense prose paragraph (over the 100-char sparseness +#: bar — NOT a pictorial candidate; the whole layer is USABLE). +PAGE_ONE = ( + "This manual covers the complete flat-pack furniture frame, its " + "shipped hardware kit, and the safety checks a first-time assembler " + "should run before starting. The box holds the two side panels, the " + "top board, the adjustable leg set, eight cam locks, fourteen wood " + "screws, two bolts, and the wall-anchoring strap. Work on a flat " + "surface with soft flooring, keep the hardware sorted by size, and " + "read every step through before you begin so you know which panel " + "is which. The frame stands roughly a metre tall once assembled, " + "weights under twelve kilograms, and needs no further tools beyond " + "the included Allen key." +) +assert len(PAGE_ONE) > 500, "page 1 must be a 500+ char paragraph (the task's recipe)" + +#: The sparse pages' markers — ALL the text layer carries on pages +#: 2–3 (6 stripped chars, under the 100-char bar — the candidates); +#: the figures are drawn vector (no raster image). +PAGE_TWO_MARKER = "Fig. 2" +PAGE_THREE_MARKER = "Fig. 3" + +#: The stored illustration blocks (the phase-140 LOCKED A4 shape) — +#: the mock's canned ``PICTORIAL_PAGE_MODE`` reply, deterministic. +BLOCK_TWO = f"[Page 2 illustration: {PICTORIAL_PAGE_ANSWER}]" +BLOCK_THREE = f"[Page 3 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_PDFPAGES", "8161")) +APP_URL = f"http://127.0.0.1:{APP_PORT}" + + +# --------------------------------------------------------------------------- +# Fixture builder (the house PyMuPDF recipe — the IKEA class: a dense +# prose page + sparse vector-illustrated pages) +# --------------------------------------------------------------------------- + + +def _draw_illustration(page: Any) -> None: + """The IKEA pictogram class: a rectangle (a part) + a line arrow + with a two-stroke head (a join) — vector-drawn, so + ``get_image_info()`` sees ZERO embedded raster images (the + phase-122/137 pipeline is blind to it).""" + 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_pictorial(path: Path) -> None: + """The IKEA shape: page 1 = the dense prose paragraph + (``insert_textbox`` — word-wrapped), pages 2–3 = a short marker + + the drawn vector figure (sparse — the pictorial candidates).""" + doc = fitz.open() + page1 = doc.new_page() + rc = page1.insert_textbox(fitz.Rect(72, 72, 523, 772), PAGE_ONE, fontsize=11) + assert rc >= 0, "the page-1 paragraph must fit its text box" + for marker in (PAGE_TWO_MARKER, PAGE_THREE_MARKER): + p = doc.new_page() + p.insert_text((72, 72), marker) + _draw_illustration(p) + doc.set_metadata({"title": PICTORIAL_TITLE}) + doc.save(path) + doc.close() + + +def _build_textheavy(path: Path) -> None: + """The negative class: two DENSE text pages (zero candidates — the + text view, byte-identical to pre-phase).""" + doc = fitz.open() + for i in (1, 2): + p = doc.new_page() + rc = p.insert_textbox( + fitz.Rect(72, 72, 523, 772), f"Paragraph {i}: {PAGE_ONE}", fontsize=11 + ) + assert rc >= 0, f"the page-{i} paragraph must fit its text box" + doc.set_metadata({"title": TEXTHEAVY_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: both fixture PDFs, built + ONCE (module-scoped). The directory's NAME is the source name + (a ``kind='local'`` registry row indexes under the directory's + basename — the test_local_directory_sources convention), so it + is a named subdirectory, not the mktemp root (whose name carries + a random suffix). The premise pins (deterministic — computed from + the fixtures THEMSELVES, no hardcoded page list): the pictorial + PDF is usable-layer with candidates [2, 3] (3 pages — the seed's + ``page_count`` target); the textheavy PDF is usable-layer with + ZERO candidates.""" + root = tmp_path_factory.mktemp("bor_p148_pdf") / SOURCE_NAME + root.mkdir(parents=True) + min_chars = Settings.model_fields["pdf_pictorial_min_chars"].default + pic = root / PICTORIAL_PATH + heavy = root / TEXTHEAVY_PATH + _build_pictorial(pic) + _build_textheavy(heavy) + pic_texts = extract_pdf_page_texts(pic) + heavy_texts = extract_pdf_page_texts(heavy) + assert is_usable_pdf_text(extract_pdf_text(pic)), ( + "the pictorial fixture must PASS the usability gate (the IKEA " + "class — the fallback class is the other qualifying class, not " + "this one)" + ) + assert pictorial_pages(pic_texts, min_chars=min_chars) == [2, 3], ( + f"the pictorial fixture's sparse-page premise broke: " + f"{pictorial_pages(pic_texts, min_chars=min_chars)}" + ) + assert is_usable_pdf_text(extract_pdf_text(heavy)) + assert pictorial_pages(heavy_texts, min_chars=min_chars) == [], ( + "the textheavy fixture must carry ZERO sparse pages (the " + "negative class)" + ) + # Every page renders to a real PNG (the pipeline's byte premise). + assert len(render_pdf_pages(pic)) == 3 + assert len(render_pdf_pages(heavy)) == 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_p148_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 seed asserts the ``<doc-id>-p{N}.png`` copies here.""" + 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 + IKEA class) and the phase-148 ``BOR_PDF_PAGE_DIR`` (suite-private + — the seed's file assertions target it, the owner's default is + never written).""" + 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) — the app boots with the + # same env shape the story suites use. + 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 fixture without it). + env["BOR_IMPORT_EXTENSIONS"] = "md,markdown,txt,pdf" + # Phase 140: the pictorial toggle EXPLICITLY on (the opt-in — see + # the module docstring; without it the whole premise is void). + env["BOR_PDF_PICTORIAL"] = "1" + # Phase 148: the rendered-page copies' home (the seed asserts the + # files here). + env["BOR_PDF_PAGE_DIR"] = str(page_dir) + # The embedded-image pipeline is out of scope (the fixtures' + # figures are vector-drawn — no image doc 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 env only needs the homes; the dir's existence is the seed's job + scratch: Path, + page_dir: Path, +) -> Iterator[str]: + """The app under test (all four tests): one module app with the + phase-140/148 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 helpers +# --------------------------------------------------------------------------- + + +def _truncate_all() -> None: + """Fresh KB + registry (the E2E isolation pattern): the suites + share one Postgres, and a leftover row would corrupt the counts. + ``sources_meta`` (the KB generation counter) resets with the KB — + the sync re-creates the row.""" + with SessionLocal() as db: + db.execute( + text( + "TRUNCATE chunks, documents, query_log, steering_notes, " + "kb_overview, git_sources, folder_summaries, sources_meta" + ) + ) + db.commit() # without the commit the TRUNCATE rolls back (the house pattern) + + +def _health_db(app_url: str) -> bool: + try: + return httpx.get(f"{app_url}/api/health", timeout=5).json()["db"] == "up" + except Exception: # noqa: BLE001 — unreachable is the skip case + return False + + +def _wait_sync_settled(base_url: str, timeout_s: float = 60.0) -> None: + """No background sync may leak across tests (the run state lives in + the app's memory, and a still-running run would keep importing into + the NEXT test's truncated KB): wait for the app's sync status to be + non-running BEFORE the truncate (the phase-96 pattern).""" + with httpx.Client(base_url=base_url, timeout=5.0) as client: + r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204, r.text + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + r = client.get("/api/sync/status") + assert r.status_code == 200, r.text + if r.json()["state"] != "running": + return + time.sleep(0.2) + raise AssertionError(f"a sync was still running at a test boundary: {base_url}") + + +@pytest.fixture(scope="module", autouse=True) +def _clean_kb(app_url: str) -> Iterator[None]: + """Module-end KB cleanup (the phase-96 pattern, module + granularity): the four tests SHARE the module seed's single source + + two documents, so there is NO per-test truncate (it would wipe + the seed from under the next test — the per-test pattern belongs to + the suites where every test imports its own source); instead the + shared KB is flushed ONCE, after the last test — no rows leak into + the next suite's run. The pre-wait keeps a leaked sync (the module + app is still up here) from racing the truncate.""" + yield + _wait_sync_settled(app_url) + _truncate_all() + + +def _register_source(base_url: str, path: Path) -> None: + """Register the local directory through the authenticated API (the + ``test_local_directory_sources`` / phase-94 pattern — a plain + directory is an API/DB-only operation; the page form is gone).""" + 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; this process runs on the 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 _run_sync(base_url: str, 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_garbage_pdf`` pattern over + plain httpx — with the stale-terminal freshness gate: a terminal + is accepted only after a running tick was observed or its + ``started_at`` post-dates this POST).""" + 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 + ): + return body + time.sleep(0.1) + raise AssertionError(f"sync did not reach a terminal state: {body}") + + +# --------------------------------------------------------------------------- +# The module seed: register the local dir + one sync (both PDFs), then +# assert the DB rows and the rendered page copies +# --------------------------------------------------------------------------- + + +@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 + mock's vision model describes the two sparse pages in-process — + the ``PICTORIAL_PAGE_MODE`` canned reply). Asserts the phase-148 + index-time contract: the pictorial row qualifies (``page_count == + 3``, the canned blocks IN the stored content — the Q&A witness), + the textheavy row does not (``page_count`` NULL, no copies), and + the scratch page dir holds EXACTLY the pictorial doc's + ``<id>-p{1..3}.png`` (real PNGs, non-empty — no orphans, no + textheavy copies). Yields both docs' 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) + assert body["state"] == "success", body + detail = body["detail"] + assert detail["files"] == 2, detail + assert detail["added"] == 2, detail + assert detail["pruned"] == 0, detail + assert detail["errors"] == 0, detail + + with SessionLocal() as db: + pic = db.scalar( + select(Document).where( + Document.source == SOURCE_NAME, Document.path == PICTORIAL_PATH + ) + ) + heavy = db.scalar( + select(Document).where( + Document.source == SOURCE_NAME, Document.path == TEXTHEAVY_PATH + ) + ) + assert pic is not None, "the pictorial PDF must become a document" + assert heavy is not None, "the textheavy PDF must become a document" + # The pictorial class (IKEA shape): qualifies. The metadata + # title (LOCKED A2); a TEXT document (no image doc, no image + # path). + assert pic.title == PICTORIAL_TITLE + assert pic.is_image is False and pic.image_path is None + assert pic.page_count == 3, ( + f"the qualifying PDF's page_count must be its page count: {pic.page_count}" + ) + # The Q&A witness: the described pages ARE in the stored + # content (the embeddings/summary ride on them — the strip + # just does not display them). + assert "---PAGE-1---" in pic.content and "---PAGE-3---" in pic.content + assert BLOCK_TWO in pic.content and BLOCK_THREE in pic.content + assert pic.summary, "the qualifying doc still gets its lite summary" + # The negative class: NO strip (the pre-phase-148 viewer). + assert heavy.title == TEXTHEAVY_TITLE + assert heavy.page_count is None, ( + f"a text-heavy PDF must not qualify: page_count={heavy.page_count}" + ) + assert heavy.summary, "the textheavy doc still gets its lite summary" + pic_id, heavy_id = str(pic.id), str(heavy.id) + + # The filesystem contract: EXACTLY the qualifying doc's three + # copies (no textheavy copies, no orphans) — real, non-empty PNGs. + pngs = sorted(page_dir.glob("*.png")) + assert [p.name for p in pngs] == [ + f"{pic_id}-p1.png", + f"{pic_id}-p2.png", + f"{pic_id}-p3.png", + ], f"the page dir holds {sorted(p.name for p in pngs)}" + for png in pngs: + data = png.read_bytes() + assert data[:8] == PNG_MAGIC and len(data) > 0, f"{png.name} is not a real PNG" + assert not list(page_dir.glob(f"{heavy_id}-*")), ( + "the non-qualifying PDF must not have page copies" + ) + yield { + "pictorial_id": pic_id, + "textheavy_id": heavy_id, + "page_dir": page_dir, + "title": PICTORIAL_TITLE, + } + + +# --------------------------------------------------------------------------- +# 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 — both 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) -> None: + """The house doc-modal idiom: the row's path link opens the + same-page modal (no new tab).""" + 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" + expect(page.locator("#doc-modal")).to_be_visible(timeout=30_000) + + +def _wait_img_loaded(img: Locator) -> None: + """Wait until a LAZY ``<img>`` really loaded pixels: scroll it into + the viewport (a below-the-fold lazy img is not fetched yet), then + 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_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).""" + strip = content.locator(".doc-pages") + expect(strip).to_have_count(1) + 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") + # The figure still carries its <img> (a lost copy would have + # been swapped for the "Page N unavailable" note instead). + 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}") + # 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) + + +# --------------------------------------------------------------------------- +# 1. The pictorial PDF: the modal shows its three rendered pages +# --------------------------------------------------------------------------- + + +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 + ``.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.""" + login(page, app_url) # lands on /sources.html + _drill_to_source(page, SOURCE_NAME) + expect(page.locator("#docs-tbody tr")).to_have_count(2) + _open_doc_modal(page, PICTORIAL_PATH) + expect(page.locator("#doc-modal-title")).to_have_text( + seeded["title"] + ) + + content = page.locator("#doc-modal-content") + _assert_page_strip(content, seeded["title"], seeded["pictorial_id"]) + + # The meta badge row is untouched by the strip (the pre-phase row). + meta = page.locator("#doc-modal-meta") + expect(meta.locator(".doc-source-badge")).to_have_text(SOURCE_NAME) + 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 +# --------------------------------------------------------------------------- + + +def test_textheavy_pdf_unchanged( + page: Page, app_url: str, seeded: dict[str, Any] +) -> None: + """The zero-candidates PDF renders the PRE-PHASE shape: the + content in ``pre.doc-raw`` (both pages, verbatim), the Summary + panel (the mock's lite digest), NO ``.doc-pages`` strip — and the + payload's ``page_urls`` KEY is ABSENT from the content endpoint's + JSON (never ``null`` — the omission pin the viewer's strip gate + rides on; the non-qualifying doc's payload is byte-identical to + pre-phase).""" + login(page, app_url) # lands on /sources.html + _drill_to_source(page, SOURCE_NAME) + _open_doc_modal(page, TEXTHEAVY_PATH) + expect(page.locator("#doc-modal-title")).to_have_text(TEXTHEAVY_TITLE) + + content = page.locator("#doc-modal-content") + # No strip for the negative class… + expect(content.locator(".doc-pages")).to_have_count(0) + # …the pre-phase raw rendering (both pages, the stored content + # verbatim). + pre = content.locator("pre.doc-raw") + expect(pre).to_have_count(1) + expect(pre).to_contain_text("---PAGE-1---") + expect(pre).to_contain_text("Paragraph 1:") + expect(pre).to_contain_text("---PAGE-2---") + expect(pre).to_contain_text("Paragraph 2:") + # …and the Summary panel (a non-empty lite summary). + expect(content.locator(".doc-summary")).to_have_count(1) + expect(content.locator(".doc-summary-text")).not_to_be_empty() + + # The payload pin: the page_urls KEY is ABSENT (the admin cookie + # rides the browser context — the content endpoint is + # require_user-gated). + r = page.request.get( + f"{app_url}/api/documents/content", + params={"source": SOURCE_NAME, "path": TEXTHEAVY_PATH}, + ) + assert r.status == 200, r.text + assert "page_urls" not in r.json(), ( + f"the non-qualifying doc's payload must not carry page_urls: " + f"{r.json().get('page_urls')!r}" + ) + + +# --------------------------------------------------------------------------- +# 3. The full page surface: the SAME strip, no per-surface drift +# --------------------------------------------------------------------------- + + +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.""" + login(page, app_url, next="/") + page.goto( + f"{app_url}/document.html?source={quote(SOURCE_NAME)}" + f"&path={quote(PICTORIAL_PATH)}" + ) + expect(page.locator("#doc-title")).to_have_text(seeded["title"]) + _assert_page_strip( + page.locator("#doc-content"), seeded["title"], seeded["pictorial_id"] + ) + # The meta row on the full page is the pre-phase shape too. + expect(page.locator("#doc-meta .doc-source-badge")).to_have_text(SOURCE_NAME) + expect(page.locator("#doc-meta .doc-indexed")).to_contain_text("Indexed") + + +# --------------------------------------------------------------------------- +# 4. The page bytes route: public PNG with the image route's headers +# --------------------------------------------------------------------------- + + +def test_page_route_serves_png( + page: Page, app_url: str, seeded: dict[str, Any] +) -> None: + """An ANONYMOUS (no-login — this test's fresh context never signs + in) direct request on the first ``page_urls`` entry: 200, + ``image/png``, the ``Cache-Control: private, max-age=3600`` + header (the image route's header), a non-empty PNG body byte-equal + to the stored copy (the public uuid4-credential bytes route — the + document id IS the credential — from the UI's perspective).""" + doc_id = seeded["pictorial_id"] + r = page.request.get(f"{app_url}/api/documents/{doc_id}/pages/1") + assert r.status == 200, r.text + assert r.headers["content-type"] == "image/png", r.headers + assert r.headers["cache-control"] == "private, max-age=3600", r.headers + body = r.body() + assert body, "the page route served an empty body" + assert body[:8] == PNG_MAGIC, "the page route served non-PNG bytes" + # The exact stored bytes (the route streams the persistent copy). + stored = (seeded["page_dir"] / f"{doc_id}-p1.png").read_bytes() + assert body == stored, "the route's bytes are not the stored copy's bytes" diff --git a/tests/e2e/test_pictorial_pdf.py b/tests/e2e/test_pictorial_pdf.py index 2b2a878..5c817b0 100644 --- a/tests/e2e/test_pictorial_pdf.py +++ b/tests/e2e/test_pictorial_pdf.py @@ -24,13 +24,19 @@ format — the upload gate admits it) and arriving through the REAL admin upload flow → the RAG page's Sync button: * (a) the document appears on the Sources page and the document viewer - renders the stored content: page 1's prose WITHOUT an illustration - block, page 2 WITH ``[Page 2 illustration: <the mock's canned - PICTORIAL_PAGE_ANSWER>]`` (the task-02 ``PICTORIAL_PAGE_MODE`` branch - — deterministic; the stored content is asserted BYTE-EXACT against - the fixture's own extraction in the seed — the candidates are computed - from ``extract_pdf_page_texts`` + ``pictorial_pages`` IN the test, no - hardcoded page list); + 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 + (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 + the DB: the seed asserts it BYTE-EXACT against the fixture's own + extraction (the candidates computed from + ``extract_pdf_page_texts`` + ``pictorial_pages`` IN the test, no + hardcoded page list) and the chat test below proves it still reaches + the model; * (b) a chat question that ONLY the illustration answers ("how are the two parts joined on page two?" — the phrase lives NOWHERE in the text layer) grounds (the described page's embedding retrieves — the mock's @@ -77,11 +83,14 @@ 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) renders the - stored content under the METADATA title: the page-1 prose (NO - ``[Page 1 illustration`` block), the page-2 marker + its - ``[Page 2 illustration: <canned>]`` block, and the Summary panel - (a non-image doc with a non-empty summary). + 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). 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 @@ -554,9 +563,14 @@ def seeded( "described sparse page — the pictorial pass misfired" ) # The non-markdown doc got its lite summary (the mock's - # SUMMARY_MODE digest) — the summary chunk + the viewer's - # Summary panel exist. + # 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). assert doc.summary + # Phase 148: the described sparse page makes this doc + # page-QUALIFYING — the strip's payload (page_urls) rides + # on page_count (the 2-page fixture's count). + assert doc.page_count == 2 finally: page.close() yield {"title": PDF_TITLE} @@ -574,12 +588,18 @@ 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) - renders the stored content under the METADATA title: page 1's prose - WITHOUT an illustration block (not a candidate) and page 2 WITH - ``[Page 2 illustration: <canned>]`` (the described sparse page — - LOCKED A4's user surface), plus the Summary panel (a non-image doc - with a non-empty summary).""" + 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).""" 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") @@ -596,27 +616,44 @@ 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) - raw = modal.locator("pre.doc-raw") - # Page 1 — the prose WITHOUT any illustration block. The viewer - # renders the content verbatim (``insert_textbox`` wraps mid- - # sentence, so compare whitespace-normalized). - expect(raw).to_contain_text("---PAGE-1---") - rendered = " ".join(raw.inner_text().split()) - assert " ".join(PAGE_ONE.split()) in rendered, ( - "the page-1 prose is not what the viewer renders" - ) - assert "[Page 1 illustration" not in rendered, ( - "page 1 (over the sparseness bar) must carry NO illustration block" - ) - # Page 2 — the sparse page WITH the described block (a) — the mock's - # canned reply, deterministic. - expect(raw).to_contain_text("---PAGE-2---") - expect(raw).to_contain_text(PAGE_TWO) - expect(raw).to_contain_text(ILLUSTRATION_BLOCK) - # The Summary panel renders (a non-image doc with a non-empty - # summary); no image block (the PDF is not an image document). - expect(modal.locator(".doc-summary")).to_have_count(1) + # 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). + strip = modal.locator(".doc-pages") + expect(strip).to_have_count(1) + figures = strip.locator(".doc-page") + expect(figures).to_have_count(2) + for i in range(2): + n = i + 1 + img = figures.nth(i).locator("img") + # The figure still carries its <img> (a lost copy would have + # been swapped for the "Page N unavailable" note instead). + expect(img).to_have_count(1) + expect(img).to_have_attribute("loading", "lazy") + expect(img).to_have_attribute("alt", f"Page {n} of {PDF_TITLE}") + img.scroll_into_view_if_needed() + img.evaluate("el => el.decode()") + assert img.evaluate("el => el.naturalWidth") > 0, ( + f"page {n} image did not load (the route must serve a real PNG)" + ) + 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) 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 dc9f76a..b554184 100644 --- a/tests/integration/test_docs_api.py +++ b/tests/integration/test_docs_api.py @@ -3,6 +3,15 @@ and GET /api/docs/tree (phase 97, task 02 — the full recursive KB tree the RAG view renders: shape, ordering, counts, summaries, the 403 gate, the superset rule, and the stat-walk equivalence with ``GET /api/docs``). +Also the BYTES routes and their content-payload affordances: the +phase-122 (task 04) image route (``GET /api/documents/{id}/image`` +— exact bytes, the extension's Content-Type, the 404 matrix, public +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``). + Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient. """ from __future__ import annotations @@ -1370,6 +1379,241 @@ def test_content_endpoint_exposes_the_image_affordance( _cleanup_kb(db) +# --------------------------------------------------------------------------- +# Phase 148 (task 02) — the PDF page BYTES route + the content payload's +# ``page_urls`` affordance +# --------------------------------------------------------------------------- + + +def _page_settings(pages_dir: Path) -> Settings: + """Fresh settings (the ``.env`` file ignored) with the phase-148 + ``pdf_page_dir`` pinned to *pages_dir* — the page route reads the + setting at request time, and the copies under test never land in + the real home (the ``get_settings`` monkeypatch pattern).""" + return Settings( + _env_file=None, # pyright: ignore[reportCallIssue] + pdf_page_dir=str(pages_dir), + ) + + +def _seed_page_doc( + db, + pages_dir: Path, + *, + source: str = "PdfSrc", + path: str = "besta.pdf", + page_count: int | None = 2, + write_copies: bool = True, +) -> Document: + """One PDF document row with ``page_count`` + its rendered page + copies (the importer's ``pdf_page_dir`` layout — + ``<doc-id>-p{N}.png``, N 1-based); page N is the ``PNG_1X1 + N`` + 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).""" + doc = Document( + id=uuid.uuid4(), + source=source, + path=path, + full_path=f"/tmp/{path}", + title=path.rsplit(".", 1)[0], + content="[Page 1 illustration: the parts tray] [Page 2 illustration: step 1]", + content_hash=hashlib.sha256(path.encode("utf-8")).hexdigest(), + indexed_at=datetime.now(UTC), + created_at=datetime(2026, 1, 1, tzinfo=UTC), + summary="IKEA BESTA frame assembly manual (page descriptions).", + page_count=page_count, + ) + db.add(doc) + db.flush() + if write_copies and page_count: + pages_dir.mkdir(parents=True, exist_ok=True) + for n in range(1, page_count + 1): + (pages_dir / f"{doc.id}-p{n}.png").write_bytes(PNG_1X1 + str(n).encode("ascii")) + db.add_all( + [ + Chunk( + document_id=doc.id, position=0, content=doc.content, embedding=[0.01] * 768 + ), + Chunk( + document_id=doc.id, + position=-1, + content=doc.summary, + embedding=[0.01] * 768, + is_summary=True, + ), + ] + ) + db.commit() + return doc + + +def test_page_route_serves_exact_bytes_with_png_type( + admin_client: TestClient, db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The serve contract: each page of a qualifying doc streams the + EXACT stored PNG bytes (a per-page sentinel — a page mix-up is + caught) as ``image/png`` (a page is always a PNG — + ``render_pdf_pages``'s ``tobytes("png")``) with + ``Cache-Control: private, max-age=3600`` (the image route's + header — content-addressed by the document id + page number). + The path is built ONLY from the row's id + ``n`` (the row + derives it; ``n`` is the int path parameter) — the copies + written under the tmp ``pdf_page_dir`` are what come back.""" + _cleanup_kb(db) + try: + pages_dir = tmp_path / "pages" + monkeypatch.setattr(docs_api, "get_settings", lambda: _page_settings(pages_dir)) + doc = _seed_page_doc(db, pages_dir, page_count=2) + for n in (1, 2): + r = admin_client.get(f"/api/documents/{doc.id}/pages/{n}") + assert r.status_code == 200, n + assert r.headers["content-type"] == "image/png" + assert r.headers["cache-control"] == "private, max-age=3600" + assert r.content == PNG_1X1 + str(n).encode("ascii") # the exact stored bytes + finally: + _cleanup_kb(db) + + +def test_page_route_404_matrix( + admin_client: TestClient, db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Every non-servable case is 404 ``document not found`` (the + router's unknown-document shape — the same detail string the + content endpoint uses): a missing id, a MALFORMED id (an + unparseable string maps here, not to a 422 — the image route's + mapping: a guessed id is an unknown document), a text doc, an + image doc, a PDF row whose ``page_count`` is NULL (a + non-qualifying doc — the route knows nothing of its pages), + ``n`` outside ``1..page_count`` (``n=0``/``n=-1`` below the range + — the int parameter makes fractions impossible — and ``n=3`` + above a 2-page doc), and a qualifying row whose copy was lost on + disk (defensive — the row exists, the bytes don't; the viewer's + onerror note covers it). The int parameter is the no-traversal + pin: a NON-int ``n`` 422s at the router, and no request value + ever reaches a filesystem path (the out-of-range 404s show the + path is bounded by the ROW's extent).""" + _cleanup_kb(db) + try: + pages_dir = tmp_path / "pages" + monkeypatch.setattr(docs_api, "get_settings", lambda: _page_settings(pages_dir)) + _seed_doc(db, "TextSrc", "note.md", "Note", 1, datetime.now(UTC)) + db.commit() # the module's _seed_doc leaves the row uncommitted + text_doc_id = db.scalar( + select(Document.id).where(Document.source == "TextSrc", Document.path == "note.md") + ) + img = _seed_image_doc(db, tmp_path, path="pic.png") + plain = _seed_page_doc(db, pages_dir, path="texty.pdf", page_count=None) + lost = _seed_page_doc(db, pages_dir, path="lost.pdf") + assert (pages_dir / f"{lost.id}-p1.png").is_file() # the copies were written + (pages_dir / f"{lost.id}-p1.png").unlink() # the copy is lost (the row remains) + qualifying = _seed_page_doc(db, pages_dir, path="besta.pdf") + + for doc_id, n in ( + (str(uuid.uuid4()), 1), # missing id + ("abc", 1), # malformed id → 404, not 422 + (str(text_doc_id), 1), # text doc + (str(img.id), 1), # image doc + (str(plain.id), 1), # PDF, page_count NULL (non-qualifying) + (str(lost.id), 1), # qualifying doc, copy lost on disk + (str(qualifying.id), 0), # below range + (str(qualifying.id), -1), # negative — the int parse allows it, the range rejects it + (str(qualifying.id), 3), # above range (page_count 2) + ): + r = admin_client.get(f"/api/documents/{doc_id}/pages/{n}") + assert r.status_code == 404, (doc_id, n) + assert r.json() == {"detail": "document not found"}, (doc_id, n) + + # The int parameter (the no-traversal pin): a NON-int n is a + # router-level 422 — no other ``n`` shape reaches the handler. + r = admin_client.get(f"/api/documents/{qualifying.id}/pages/abc") + assert r.status_code == 422 + finally: + _cleanup_kb(db) + + +def test_page_route_is_public_like_the_shared_chat_content( + admin_client: TestClient, db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The image route's phase-124 A4 trust model verbatim: a shared + chat can cite a page FIGURE (the viewer's strip, task 03), so the + BYTES serve ANONYMOUSLY — the unguessable ``uuid4`` document id IS + the credential, no enumeration value (PLAN A10: the shared chats + are the app's ONLY anonymous surface). An ANONYMOUS caller (a + FRESH client — no session) gets the page PNG, and a signed-in + caller gets the same. The document's TEXT (the content endpoint) + stays user-gated — only the page bytes are public.""" + _cleanup_kb(db) + try: + pages_dir = tmp_path / "pages" + monkeypatch.setattr(docs_api, "get_settings", lambda: _page_settings(pages_dir)) + doc = _seed_page_doc(db, pages_dir) + anonymous = TestClient(fastapi_app) + r = anonymous.get(f"/api/documents/{doc.id}/pages/1") + assert r.status_code == 200 + assert r.headers["content-type"] == "image/png" + assert r.content == PNG_1X1 + b"1" + # The signed-in client (admin) gets the same bytes. + r_admin = admin_client.get(f"/api/documents/{doc.id}/pages/1") + assert r_admin.status_code == 200 + assert r_admin.content == PNG_1X1 + b"1" + finally: + _cleanup_kb(db) + + +def test_content_endpoint_exposes_the_page_urls_affordance( + admin_client: TestClient, db, tmp_path: Path +) -> None: + """The content endpoint (the viewer's data source — task 03's + strip reads from it): a page-qualifying PDF (``page_count`` set) + carries ``page_urls`` = the EXACT 1-based route list over + ``1..page_count`` (the count == ``page_count`` — a per-page render + gap is still listed; the route 404s that N); text docs, image + docs, and non-qualifying PDFs (``page_count`` NULL) keep the key + ABSENT from the JSON — never ``null`` (the ``image_url`` + omission rule — a non-qualifying doc's payload is byte-identical + to pre-phase).""" + _cleanup_kb(db) + try: + pages_dir = tmp_path / "pages" + doc = _seed_page_doc(db, pages_dir, page_count=3) + r = admin_client.get( + "/api/documents/content", params={"source": "PdfSrc", "path": "besta.pdf"} + ) + assert r.status_code == 200 + body = r.json() + assert body["page_urls"] == [ + f"/api/documents/{doc.id}/pages/1", + f"/api/documents/{doc.id}/pages/2", + f"/api/documents/{doc.id}/pages/3", + ] # the exact 1-based list, count == page_count + + _seed_doc(db, "TextSrc", "note.md", "Note", 1, datetime.now(UTC)) + db.commit() # the app's endpoint session reads committed data only + r = admin_client.get( + "/api/documents/content", params={"source": "TextSrc", "path": "note.md"} + ) + assert r.status_code == 200 + assert "page_urls" not in r.json() # absent — never null (text doc) + + _seed_image_doc(db, tmp_path, source="ImgSrc", path="pic.png") + r = admin_client.get( + "/api/documents/content", params={"source": "ImgSrc", "path": "pic.png"} + ) + assert r.status_code == 200 + assert "page_urls" not in r.json() # absent — never null (image doc) + + _seed_page_doc(db, pages_dir, path="texty.pdf", page_count=None) + r = admin_client.get( + "/api/documents/content", params={"source": "PdfSrc", "path": "texty.pdf"} + ) + assert r.status_code == 200 + assert "page_urls" not in r.json() # absent — never null (non-qualifying PDF) + finally: + _cleanup_kb(db) + + def _tree_file_nodes_all(sources) -> list[dict]: """Every file node of a tree response, walked recursively (all sources — env-registered 0-document sources may join the response diff --git a/tests/integration/test_migration_0026.py b/tests/integration/test_migration_0026.py new file mode 100644 index 0000000..d9e3d56 --- /dev/null +++ b/tests/integration/test_migration_0026.py @@ -0,0 +1,351 @@ +"""Integration: migration 0026 (documents.page_count) schema contract +(phase 148, task 01). + +Drives the **real Alembic engine** against the live dev database +(``podman compose up -d db``), mirroring the house pattern of +``test_migration_0025.py`` (information_schema assertions on the state +the migration must leave). The tests target the 0025 → 0026 step +explicitly so later migrations cannot break the pins: + +* upgrade 0025 → 0026 → ``page_count`` exists with the full contract — + INTEGER, NULLABLE, NO server default (NULL = "not page-qualifying" + for every pre-phase row and every fail-soft miss) — and is UNINDEXED + (a display fact, never a query surface — the page route of task 02 + reads it from the ROW after the uuid4 id lookup, the ``file_hash`` + convention of 0025) while the 0025 ``documents`` schema survives — + in particular ``content_hash`` stays NOT NULL and INDEXED (the + retriever's dedupe/caching identity, untouched) and ``file_hash`` + keeps its 0025 contract; +* rows inserted while the DB is at 0025 backfill ``page_count`` to + NULL — the pre-phase shape, no backfill, no default; +* the ORM contract agrees: a freshly inserted ``Document`` with no + ``page_count`` reads ``page_count is None`` through a fresh session + (the pre-phase shape), and an explicit count round-trips; +* downgrade to 0025 → the column is GONE (A13) while the row + its + content survive; upgrade back to 0026 → 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 = "mig0026" +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) for one documents + column.""" + row = db.execute( + text( + "SELECT data_type, is_nullable, column_default" + " 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) -> uuid.UUID: + """Insert one documents row with the PRE-0026 column set (the 0025 + shape — the page_count column, when present, is omitted so a NULL + backfill is what the row reads).""" + doc_id = uuid.uuid4() + db.execute( + text( + "INSERT INTO documents (id, source, path, full_path, title, content," + " content_hash) VALUES (:id, :s, :p, :fp, :t, :c, :h)" + ), + { + "id": doc_id, + "s": SOURCE, + "p": path, + "fp": f"/tmp/{path}", + "t": f"Doc {path}", + "c": CONTENT, + "h": CONTENT_HASH, + }, + ) + 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_0025_schema_survives(db: Session) -> None: + """The 0025 documents contract must survive the additive upgrade — + in particular ``content_hash`` stays NOT NULL and indexed (the + retriever's identity — untouched by phase 148) and ``file_hash`` + keeps its 0025 contract (VARCHAR(64), nullable, 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 0025 contract" + ) + assert ( + _index_covering(db, "content_hash") is not None + ), "the ix_documents_content_hash index must survive the upgrade" + + file_hash = _column(db, "file_hash") + assert file_hash is not None and file_hash[0] == "character varying", ( + "documents.file_hash (0025) must survive the upgrade" + ) + assert file_hash[1] == "YES", "file_hash must stay NULLABLE" + assert file_hash[2] is None, "file_hash must keep NO server default" + assert _index_covering(db, "file_hash") is None, ( + "file_hash must stay UNINDEXED" + ) + + 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" + ) + image_path = _column(db, "image_path") + assert image_path is not None and image_path[1] == "YES", ( + "documents.image_path (0022) must survive the upgrade" + ) + + +def test_upgrade_to_0026_adds_page_count(db: Session, alembic: Config) -> None: + """Upgrade 0025 → 0026: ``page_count`` exists with the full + contract (INTEGER, NULLABLE, NO server default — NULL = "not + page-qualifying" for every pre-phase row — and UNINDEXED: a + display fact, never a query surface), is ABSENT at 0025, and every + pre-0026 row backfills ``page_count`` to NULL (no backfill + possible — a migration cannot know which documents qualified) — + while the 0025 table contract (incl. the INDEXED ``content_hash`` + and the unindexed ``file_hash``) survives.""" + command.downgrade(alembic, "0025") # start from the pre-0026 state + assert _version(db) == "0025" + assert _column(db, "page_count") is None, "page_count must be absent at 0025" + + pre_id = _insert_sql(db, "pre.md") # the 0025 column set + try: + command.upgrade(alembic, "0026") + assert _version(db) == "0026", "alembic_version must be at 0026" + + col = _column(db, "page_count") + assert col is not None, "documents.page_count is missing" + assert col[0] == "integer", "page_count must be INTEGER" + assert col[1] == "YES", ( + "page_count must be NULLABLE (NULL = not page-qualifying)" + ) + assert col[2] is None, ( + "page_count must carry NO server default — NULL is the meaningful" + " pre-phase state" + ) + assert _index_covering(db, "page_count") is None, ( + "page_count must be UNINDEXED — a display fact, never a query" + " surface (the page route reads it from the row after the id lookup)" + ) + + # The pre-0026 row backfilled to NULL (the pre-phase shape). + assert db.execute( + text( + "SELECT content_hash, page_count FROM documents WHERE id = :id" + ), + {"id": pre_id}, + ).fetchone() == (CONTENT_HASH, None), ( + "every pre-0026 row must backfill page_count to NULL — no" + " backfill, no default — while content_hash survives" + ) + + _assert_0025_schema_survives(db) + finally: + _delete(db, pre_id) + + +def test_orm_page_count_round_trips(db: Session, alembic: Config) -> None: + """The ORM contract agrees with the column contract: a freshly + inserted ``Document`` with NO ``page_count`` reads + ``page_count is None`` through a FRESH session (the pre-phase + shape — NULL until the importer's PDF branch sets it), and an + explicit count round-trips the value.""" + command.upgrade(alembic, "head") + row_counted = Document( + source=SOURCE, + path="counted.pdf", + full_path="/tmp/counted.pdf", + title="counted", + content=CONTENT, + content_hash=CONTENT_HASH, + page_count=24, + ) + row_uncounted = Document( + source=SOURCE, + path="uncounted.md", + full_path="/tmp/uncounted.md", + title="uncounted", + content=CONTENT, + content_hash=CONTENT_HASH, + ) + db.add(row_counted) + db.add(row_uncounted) + db.commit() + try: + with SessionLocal() as fresh: + reloaded_counted = fresh.get(Document, row_counted.id) + assert reloaded_counted is not None, "the counted row must be readable" + assert reloaded_counted.page_count == 24, ( + "the explicit page_count must round-trip through the DB" + ) + reloaded_uncounted = fresh.get(Document, row_uncounted.id) + assert reloaded_uncounted is not None, "the uncounted row must be readable" + assert reloaded_uncounted.page_count is None, ( + "a row created without page_count must read NULL (the pre-phase" + " shape)" + ) + assert reloaded_uncounted.content_hash == CONTENT_HASH, ( + "content_hash must be untouched by the page_count addition" + ) + finally: + _delete(db, row_counted.id) + _delete(db, row_uncounted.id) + + +def test_downgrade_to_0025_drops_the_column(db: Session, alembic: Config) -> None: + """Downgrade 0026 → 0025: the page_count column is gone (A13 — + fully reversible, the copies are re-derivable from the source + files on the next sync) while the row + its content (incl. the + 0025 ``file_hash`` sibling) survive, and the rest of the 0025 + 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=24, + ) + db.add(row) + db.commit() + try: + command.downgrade(alembic, "0025") + assert _version(db) == "0025" + assert _column(db, "page_count") is None, "page_count must be dropped" + + surviving = db.execute( + text( + "SELECT source, path, content, content_hash, file_hash, is_image," + " image_path 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] is None and surviving[5] is False and surviving[6] is None, ( + "file_hash + is_image + image_path 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 0025, then upgrade back to 0026: ``page_count`` is + back with the full contract (INTEGER, NULLABLE, NO server default, + UNINDEXED), a row seeded at 0025 reads ``page_count is None`` (the + pre-phase shape on the round-trip), and the 0025 schema (incl. the + indexed ``content_hash``) is intact.""" + command.downgrade(alembic, "0025") + pre_id = _insert_sql(db, "round.pdf") + try: + command.upgrade(alembic, "0026") + assert _version(db) == "0026", "round-trip upgrade must land at 0026" + + col = _column(db, "page_count") + assert col is not None, "documents.page_count must be back" + assert col[0] == "integer", "page_count must be INTEGER after the round-trip" + assert col[1] == "YES", "page_count must be NULLABLE after the round-trip" + assert col[2] is None, ( + "page_count must still carry NO server default after the round-trip" + ) + assert _index_covering(db, "page_count") is None, ( + "page_count must stay UNINDEXED after the round-trip" + ) + + assert db.execute( + text("SELECT page_count FROM documents WHERE id = :id"), + {"id": pre_id}, + ).scalar() is None, ( + "a row seeded at 0025 must read page_count NULL after the round-trip" + ) + + _assert_0025_schema_survives(db) + finally: + _delete(db, pre_id) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index ee4f73c..db55311 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -547,6 +547,18 @@ def test_sources_dir_env_override_is_raw_string(monkeypatch: pytest.MonkeyPatch) assert s.sources_dir == "/data/bor/sources" +def test_pdf_page_dir_default_and_env_override(monkeypatch: pytest.MonkeyPatch) -> None: + """Phase 148 (task 01): ``pdf_page_dir`` — the persistent home for + a page-qualifying PDF's rendered page PNGs — defaults to + ``~/bor-sources/pages`` and, like ``sources_dir``/``upload_dir``/ + ``image_dir``, is a RAW string (``Path.expanduser()`` is applied by + the importer, not here) that ``BOR_PDF_PAGE_DIR`` overrides.""" + monkeypatch.delenv("BOR_PDF_PAGE_DIR", raising=False) + assert _settings().pdf_page_dir == "~/bor-sources/pages" + monkeypatch.setenv("BOR_PDF_PAGE_DIR", "/data/bor/pages") + assert _settings().pdf_page_dir == "/data/bor/pages" + + def test_suggestions_default_is_three_plus_real_questions() -> None: s = _settings() assert len(s.suggestions) >= 3 diff --git a/tests/unit/test_document_viewer.py b/tests/unit/test_document_viewer.py index 3585f2d..4753dab 100644 --- a/tests/unit/test_document_viewer.py +++ b/tests/unit/test_document_viewer.py @@ -21,6 +21,15 @@ Frontend side: import-safe page guard, the modal skeleton on chat + Sources, and the modal module's close/focus/URL contract. +Phase 148 (task 02) — the content payload's rendered-page routes +(the viewer strip, task 03, reads from them): the ``DocContent`` +omission rule — ``page_urls`` ABSENT on the wire when ``None`` (never +``null``; a non-qualifying doc's payload is byte-identical to +pre-phase) — and the content endpoint (stubbed session) — a +page-qualifying PDF row (``page_count`` set) adds ``page_urls`` = the +exact 1-based ``/api/documents/<id>/pages/<i>`` list over +``1..page_count``. + Phase 145 (task 03) — the content payload's git origin fields + the ``renderDocument`` "Original" anchor (modal AND /document.html, one site): @@ -46,6 +55,7 @@ import json import re import shutil import subprocess +import uuid from datetime import UTC, datetime from pathlib import Path @@ -279,10 +289,13 @@ def _text_doc() -> Document: def _text_doc_content( - origin_url: str | None = None, origin_branch: str | None = None + origin_url: str | None = None, + origin_branch: str | None = None, + page_urls: list[str] | None = None, ) -> DocContent: """One text doc's content payload (the pre-phase shape) with the - phase-145 origin fields optionally set.""" + phase-145 origin fields + the phase-148 ``page_urls`` optionally + set.""" return DocContent( source="S", path="a.md", @@ -294,6 +307,7 @@ def _text_doc_content( chunks=1, origin_url=origin_url, origin_branch=origin_branch, + page_urls=page_urls, ) @@ -316,6 +330,64 @@ def test_doc_content_origin_fields_absent_on_the_wire_when_unset() -> None: assert dumped["origin_branch"] == "docs" +def test_doc_content_page_urls_absent_on_the_wire_when_unset() -> None: + """Phase 148 (task 02) — the model-level omission rule (the + ``image_url`` precedent): a non-qualifying document (``page_urls`` + ``None`` — a text doc, an image, a text-heavy PDF) serializes with + the key ABSENT — never ``null`` — so its payload is byte-identical + to pre-phase. A page-qualifying PDF keeps the exact route list.""" + dumped = _text_doc_content().model_dump() + assert "page_urls" not in dumped # absent — never null + urls = ["/api/documents/d/pages/1", "/api/documents/d/pages/2"] + dumped = _text_doc_content(page_urls=urls).model_dump() + assert dumped["page_urls"] == urls # the exact 1-based route list + + +def _page_pdf_doc() -> Document: + """A page-QUALIFYING PDF row (phase 148): ``page_count`` set — the + endpoint derives ``page_urls`` from it (the exact 1-based list).""" + doc = Document( + id=uuid.uuid4(), + source="Homelab", + path="ikea/besta.pdf", + full_path="/tmp/besta.pdf", + title="BESTA frame (white) assembly", + content="[Page 1 illustration: the parts tray]…", + content_hash="e" * 64, + is_image=False, # phase 122: the stub session never applies defaults + page_count=2, + ) + doc.indexed_at = datetime(2026, 8, 22, 1, 2, 3, tzinfo=UTC) + doc.created_at = datetime(2026, 8, 20, 9, 0, 0, tzinfo=UTC) + return doc + + +def test_content_page_urls_carried_for_a_page_qualifying_pdf() -> None: + """Phase 148 (task 02) — the endpoint (stubbed session, no +database): a page-qualifying PDF row (``page_count`` set) carries + ``page_urls`` = the EXACT 1-based + ``/api/documents/<id>/pages/<i>`` list over ``1..page_count`` + (the viewer strip's source, task 03 — a per-page render gap is + still listed; the route 404s that N). Every pre-phase key stands + untouched (``is_image`` false, ``image_url`` ABSENT — the PDF is + not an image doc; the document's TEXT stays user-gated, only the + page bytes are public).""" + doc = _page_pdf_doc() + with _client_with_row((doc, 4)) as client: + r = client.get( + "/api/documents/content", + params={"source": "Homelab", "path": "ikea/besta.pdf"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["page_urls"] == [ + f"/api/documents/{doc.id}/pages/1", + f"/api/documents/{doc.id}/pages/2", + ] # the exact 1-based list, count == page_count + assert body["is_image"] is False + assert "image_url" not in body # absent — never null (not an image doc) + + def test_content_origin_url_carried_for_a_matching_git_row() -> None: """The endpoint (stubbed session, no database): a ``kind='git'`` row whose URL basename matches the document's source adds diff --git a/tests/unit/test_pdf_page_copies.py b/tests/unit/test_pdf_page_copies.py new file mode 100644 index 0000000..8cdeff8 --- /dev/null +++ b/tests/unit/test_pdf_page_copies.py @@ -0,0 +1,628 @@ +"""Phase 148, task 01 — render qualifying PDF pages at index time (unit +matrix). + +The owner's TODO L5: "For primarily pictographic PDFs it's not super +helpful to see the summaries of the pages. I want to see the PDF pages +in the sources." The locked rule: a PDF gets rendered pages iff the +sync ALREADY decided its text is not the useful view — the phase-133 +fallback fired (unusable text layer — the screenshot class) OR the +phase-140 pictorial pass described ≥1 page (the sparse-page class — +the IKEA fixture). Text-heavy PDFs (usable layer, zero candidates) +keep the text view, and ``BOR_PDF_PICTORIAL=0`` keeps its kill-switch +semantics (no pictorial descriptions → no page strip for the sparse +class). + +Pinned here (the house PyMuPDF fixture recipes — the +``test_pictorial_import.py`` builder for the pictorial class, the +``test_importer.py`` garbage builder for the fallback class, the dense +builder for the negative; the copies land in a tmp ``pdf_page_dir``): + +* eligibility × new index — the pictorial class (IKEA shape: usable + layer, ≥1 sparse page) and the fallback class (unusable layer) get + ``<doc-id>-p{N}.png`` for EVERY page (not just the described + candidates — the strip shows the whole manual) + ``page_count`` = + the page count + real PNG bytes; a text-heavy PDF gets neither + (no copies, NULL count, ZERO render calls, byte-identical content); +* the phase-147 fast path — an unchanged qualifying PDF's second sync + re-renders NOTHING (zero render calls — the copies persist, + ``page_count`` and the bytes untouched); +* stale replacement — a changed (shrunk) PDF's old copy set is + replaced: the new set covers the new pages, the old ``p{N}`` tail + is GONE (no orphans), ``page_count`` follows the new count; +* qualifier loss — a qualifying PDF whose new version gained a dense + text layer loses its strip: ``page_count`` NULL + copies deleted; +* unopenable — a render that yields no pages (``render_pdf_pages`` → + ``[]``): ``page_count`` NULL, no copies, NO exception, the doc is + still indexed exactly as pre-phase (the row lands, the summary + runs); +* per-page gap — one unreadable page (the render's ``b""`` + placeholder): the other copies are written, ``page_count`` keeps + the FULL count (the gap is a display-level 404 — task 02 pins the + route's side); +* fail-soft — the render raising (warning is the signal, ``page_count`` + NULL, no copies, the doc indexed fully — chunks + summary) and the + WRITER raising (the copies this call wrote are removed — no partial + orphans, ``page_count`` NULL, the doc indexed fully); +* prune — a qualifying doc's file removed from the source: the row + goes and its page copies go with it (no orphans in the tmp dir). + +The route + payload (task 02) and the viewer strip (task 03) are +pinned in their own task files; the E2E surface is +``tests/e2e/test_pdf_pages_in_sources.py`` (task 04). +""" +from __future__ import annotations + +import asyncio +import logging +import uuid +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Any + +import pymupdf as fitz +import pytest +from sqlalchemy import select + +from app.config import Settings +from app.models import Document +from app.rag import importer +from app.rag.importer import _delete_page_copies, import_sources +from app.rag.pdf import extract_pdf_text +from tests.unit.test_importer import _build_garbage_pdf, _PageVisionLLM +from tests.unit.test_pictorial_import import ( + TITLE, + _build_dense_pdf, + _build_ikea_like_pdf, + _insert_dense, + _PictorialLLM, +) + +#: The PNG signature (``render_pdf_pages``' output is ``tobytes("png")``). +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + + +def _settings(tmp_path: Path, **kwargs: Any) -> Settings: + """Settings admitting the ``pdf`` import token with the phase-140 + knobs at their CODE DEFAULTS (``pdf_pictorial`` ON — LOCKED A3) and + the phase-148 ``pdf_page_dir`` pinned to *tmp_path* (the copies + under test never land in the real home); ``images`` off (the + embedded-image pass is not in play — the fixture class is + vector-drawn).""" + kwargs.setdefault("_env_file", None) + kwargs.setdefault("import_extensions", "md,txt,pdf") + kwargs.setdefault("images", False) + kwargs.setdefault("image_dir", str(tmp_path / "images")) + kwargs.setdefault("pdf_page_dir", str(tmp_path / "pages")) + return Settings(**kwargs) # pyright: ignore[reportCallIssue] + + +def _pages_dir(tmp_path: Path) -> Path: + return tmp_path / "pages" + + +def _doc(db, source: str, rel: str) -> Document: + doc = db.scalar( + select(Document).where(Document.source == source, Document.path == rel) + ) + assert doc is not None, f"no documents row for ({source!r}, {rel!r})" + return doc + + +def _cleanup_source(db, source: str) -> None: + for doc in db.scalars(select(Document).where(Document.source == source)).all(): + db.delete(doc) + db.commit() + + +def _build_ikea_shrunk(path: Path) -> None: + """A SHRUNK IKEA-class PDF (the stale-replacement target): 6 pages + — the title page, two dense warnings, three sparse ``Step N`` + markers — still qualifying (candidates: 1 and 4–6), but four pages + shorter than :func:`_build_ikea_like_pdf`'s 10 (the old ``p7..p10`` + tail must not leak).""" + doc = fitz.open() + doc.new_page().insert_text((72, 72), "BESTA") + _insert_dense(doc.new_page()) + _insert_dense(doc.new_page()) + for n in range(4, 7): + doc.new_page().insert_text((72, 72), f"Step {n}") + doc.set_metadata({"title": TITLE}) + doc.save(path) + doc.close() + + +def _render_spy( + monkeypatch: pytest.MonkeyPatch, + *, + mutate_full: Callable[[list[bytes]], list[bytes]] | None = None, + raise_full: bool = False, +) -> list[Any]: + """Count + steer the ``render_pdf_pages`` calls the importer makes + (the pictorial pass's candidate render AND the phase-148 + full-page render both resolve the module global at call time): + returns the list of *pages* arguments seen (a candidate list for + the pass, ``None`` for the full render). *mutate_full* transforms + the full-render result (e.g. blanks one page's slot); + *raise_full* makes the full render raise. The candidate render + always runs the real helper.""" + real = importer.render_pdf_pages + calls: list[Any] = [] + + def spy(pdf_path: Path, dpi: int = 150, pages: Sequence[int] | None = None) -> list[bytes]: + calls.append(pages) + if pages is not None: + return real(pdf_path, dpi=dpi, pages=pages) + if raise_full: + raise RuntimeError("simulated render failure (test sentinel)") + out = real(pdf_path, dpi=dpi, pages=pages) + return mutate_full(out) if mutate_full is not None else out + + monkeypatch.setattr(importer, "render_pdf_pages", spy) + return calls + + +# --------------------------------------------------------------------------- +# Eligibility × new index +# --------------------------------------------------------------------------- + + +def test_pictorial_pdf_new_index_writes_every_page_copy(db, tmp_path: Path) -> None: + """Eligible — the PICTORIAL class (IKEA shape: usable layer, ≥1 + sparse page): the first index writes EVERY page of the PDF (not + just the described candidates — the strip shows the whole manual) + as ``<doc-id>-p{N}.png`` under ``pdf_page_dir`` (N 1-based), sets + ``page_count`` to the page count, and the files carry real PNG + bytes — while the indexed content is unchanged by this phase (the + phase-140 illustration blocks stay: the descriptions help the + embeddings and stay in the DB for Q&A — they are simply not + displayed, task 03).""" + root = tmp_path / "pgp" + root.mkdir() + _build_ikea_like_pdf(root / "ikea.pdf") + llm = _PictorialLLM() + llm.settings = _settings(tmp_path) + try: + summary = asyncio.run(import_sources([root], llm, session=db)) + assert (summary.files, summary.added, summary.errors) == (1, 1, 0) + assert summary.pdfs_pictorial == 1 # the pass ran (the eligibility class) + doc = _doc(db, "pgp", "ikea.pdf") + assert doc.page_count == 10, "page_count = the PDF's page count" + page_dir = _pages_dir(tmp_path) + for n in range(1, 11): + path = page_dir / f"{doc.id}-p{n}.png" + assert path.is_file(), f"copy p{n} is missing" + assert path.read_bytes()[:8] == PNG_MAGIC, f"p{n} is not a real PNG" + # No behavior change: the final content still carries the + # phase-140 illustration blocks on the sparse pages (1, 6–10) + # and not on the dense ones (2–5). + assert "[Page 1 illustration:" in doc.content + assert "[Page 6 illustration:" in doc.content + assert "[Page 2 illustration:" not in doc.content + finally: + _cleanup_source(db, "pgp") + + +def test_fallback_class_garbage_pdf_writes_every_page_copy(db, tmp_path: Path) -> None: + """Eligible — the FALLBACK class (unusable text layer — the + screenshot class): the garbage PDF takes the phase-133 screenshot + fallback UNCHANGED and now gets the page strip too — one copy per + page + ``page_count`` (the viewer can show the actual pages of the + screenshot class as well).""" + root = tmp_path / "pgf" + root.mkdir() + _build_garbage_pdf(root / "garbage.pdf") # 2 pages + llm = _PageVisionLLM() + llm.settings = _settings(tmp_path) + try: + summary = asyncio.run(import_sources([root], llm, session=db)) + assert (summary.files, summary.added, summary.errors) == (1, 1, 0) + assert summary.pdfs_screenshotted == 1 # the fallback fired (the class) + doc = _doc(db, "pgf", "garbage.pdf") + assert doc.page_count == 2 + page_dir = _pages_dir(tmp_path) + for n in (1, 2): + path = page_dir / f"{doc.id}-p{n}.png" + assert path.is_file(), f"copy p{n} is missing" + assert path.read_bytes()[:8] == PNG_MAGIC + # No behavior change: the content is still the fallback's + # vision descriptions (the page-anchored separator shape). + assert doc.content.startswith("---PAGE-1---") + finally: + _cleanup_source(db, "pgf") + + +def test_text_heavy_pdf_keeps_the_text_view( + db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ineligible — a TEXT-HEAVY PDF (usable layer, zero candidates): + the text view stays — no copies, NULL ``page_count``, ZERO render + calls (the pictorial pass no-ops before any render), and the + content is byte-identical to ``extract_pdf_text`` (the + pre-phase-140/148 pin).""" + root = tmp_path / "pgd" + root.mkdir() + pdf = root / "clean.pdf" + _build_dense_pdf(pdf) + expected = extract_pdf_text(pdf) + llm = _PictorialLLM() + llm.settings = _settings(tmp_path) + calls = _render_spy(monkeypatch) + try: + summary = asyncio.run(import_sources([root], llm, session=db)) + assert (summary.files, summary.added, summary.errors) == (1, 1, 0) + assert summary.pdfs_pictorial == 0 # zero candidates — the pass no-ops + doc = _doc(db, "pgd", "clean.pdf") + assert doc.page_count is None, "a text-heavy PDF keeps the text view" + assert not _pages_dir(tmp_path).exists(), "no copies — the dir is never created" + assert calls == [], "zero render calls (no fallback, zero candidates)" + assert doc.content == expected, "byte-identical to the extraction" + finally: + _cleanup_source(db, "pgd") + + +# --------------------------------------------------------------------------- +# Lifecycle: unchanged / changed / qualifier loss +# --------------------------------------------------------------------------- + + +def test_unchanged_second_sync_renders_nothing( + db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The phase-147 fast path + phase 148: an UNCHANGED qualifying + PDF's second sync costs ZERO render calls (the copies persist — no + re-render, no orphans), and ``page_count`` and every copy's bytes + are untouched. The fast path returns before the branch, so the + render block is skipped by construction.""" + root = tmp_path / "pgu" + root.mkdir() + _build_ikea_like_pdf(root / "ikea.pdf") + llm = _PictorialLLM() + llm.settings = _settings(tmp_path) + try: + calls = _render_spy(monkeypatch) + s1 = asyncio.run(import_sources([root], llm, session=db)) + assert (s1.added, s1.unchanged) == (1, 0) + doc1 = _doc(db, "pgu", "ikea.pdf") + assert doc1.page_count == 10 + assert len([c for c in calls if c is None]) == 1, ( + "exactly one full-page render on the first index" + ) + page_dir = _pages_dir(tmp_path) + copies = { + n: (page_dir / f"{doc1.id}-p{n}.png").read_bytes() for n in range(1, 11) + } + assert all(b[:8] == PNG_MAGIC for b in copies.values()) + embeds1 = len(llm.calls) + + s2 = asyncio.run(import_sources([root], llm, session=db)) + assert (s2.added, s2.updated, s2.unchanged) == (0, 0, 1) + assert len(calls) == 2, "second sync: ZERO render calls (the fast path)" + assert len(llm.calls) == embeds1, "no re-embed (the fast path's zero-work)" + + doc2 = _doc(db, "pgu", "ikea.pdf") + assert doc2.id == doc1.id + assert doc2.page_count == 10, "page_count untouched" + for n in range(1, 11): + assert (page_dir / f"{doc2.id}-p{n}.png").read_bytes() == copies[n], ( + f"copy p{n} moved on an unchanged sync" + ) + finally: + _cleanup_source(db, "pgu") + + +def test_changed_pdf_replaces_the_copy_set_without_orphans( + db, tmp_path: Path, +) -> None: + """A CHANGED (shrunk, 10 → 6 pages) qualifying PDF: the re-index + replaces the copy set — the new set covers p1..p6, the old + version's ``p7..p10`` tail is GONE (the stale-replacement runs + before the write), the doc keeps its id, and ``page_count`` + follows the new page count.""" + root = tmp_path / "pgc" + root.mkdir() + pdf = root / "ikea.pdf" + _build_ikea_like_pdf(pdf) + llm = _PictorialLLM() + llm.settings = _settings(tmp_path) + try: + s1 = asyncio.run(import_sources([root], llm, session=db)) + assert s1.added == 1 + doc1 = _doc(db, "pgc", "ikea.pdf") + assert doc1.page_count == 10 + + _build_ikea_shrunk(pdf) # same path, 6 pages — still qualifying + s2 = asyncio.run(import_sources([root], llm, session=db)) + assert (s2.added, s2.updated, s2.unchanged) == (0, 1, 0) + doc2 = _doc(db, "pgc", "ikea.pdf") + assert doc2.id == doc1.id, "the doc keeps its id (the copy path is stable)" + assert doc2.page_count == 6, "page_count follows the new page count" + page_dir = _pages_dir(tmp_path) + for n in range(1, 7): + assert (page_dir / f"{doc2.id}-p{n}.png").is_file(), f"p{n} is missing" + for n in range(7, 11): + assert not (page_dir / f"{doc2.id}-p{n}.png").exists(), ( + f"stale p{n} from the 10-page version leaked" + ) + leftovers = sorted(p.name for p in page_dir.iterdir()) + assert len(leftovers) == 6, "exactly the new copy set, no orphans" + finally: + _cleanup_source(db, "pgc") + + +def test_qualifier_loss_clears_page_count_and_copies( + db, tmp_path: Path, +) -> None: + """A qualifying PDF whose NEW VERSION gained a dense text layer + (zero candidates, ``pdf_pictorial`` ON): the document STOPPED + qualifying — ``page_count`` is NULL, the copies are deleted, and + the text view returns (the content is the extraction, + byte-identical).""" + root = tmp_path / "pgl" + root.mkdir() + pdf = root / "ikea.pdf" + _build_ikea_like_pdf(pdf) + llm = _PictorialLLM() + llm.settings = _settings(tmp_path) + try: + s1 = asyncio.run(import_sources([root], llm, session=db)) + assert s1.added == 1 + doc1 = _doc(db, "pgl", "ikea.pdf") + assert doc1.page_count == 10 + assert all( + _pages_dir(tmp_path).joinpath(f"{doc1.id}-p{n}.png").is_file() + for n in range(1, 11) + ) + + _build_dense_pdf(pdf) # same path: dense text layer, zero candidates + s2 = asyncio.run(import_sources([root], llm, session=db)) + assert (s2.added, s2.updated, s2.unchanged) == (0, 1, 0) + doc2 = _doc(db, "pgl", "ikea.pdf") + assert doc2.id == doc1.id + assert doc2.page_count is None, "a doc that stopped qualifying loses its strip" + assert not list(_pages_dir(tmp_path).glob(f"{doc2.id}-p*.png")), ( + "the copies must be deleted on qualifier loss" + ) + assert doc2.content == extract_pdf_text(pdf), "the text view returns" + finally: + _cleanup_source(db, "pgl") + + +# --------------------------------------------------------------------------- +# Fail-soft: unopenable / gap / render exception / write exception +# --------------------------------------------------------------------------- + + +def test_unopenable_pdf_page_render_is_fail_soft( + db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A render that yields NO pages (an unopenable file — + ``render_pdf_pages`` → ``[]``): ``page_count`` NULL, no copies, + NO exception — the document still indexes exactly as pre-phase + (the fallback's all-failed path: an empty-content row lands, the + summary runs).""" + root = tmp_path / "pgn" + root.mkdir() + _build_garbage_pdf(root / "garbage.pdf") # the fallback class + llm = _PageVisionLLM() + llm.settings = _settings(tmp_path) + monkeypatch.setattr(importer, "render_pdf_pages", lambda *a, **k: []) + try: + summary = asyncio.run(import_sources([root], llm, session=db)) + assert (summary.files, summary.added, summary.errors) == (1, 1, 0) + doc = _doc(db, "pgn", "garbage.pdf") + assert doc.page_count is None, "no pages rendered — no strip" + assert not _pages_dir(tmp_path).exists(), "no copies — the dir is never created" + assert doc.content == "", "the fallback's all-failed render lands empty content" + assert summary.summaries == 1, "the document is indexed exactly as pre-phase" + finally: + _cleanup_source(db, "pgn") + + +def test_per_page_gap_writes_the_other_copies_and_keeps_the_full_count( + db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """One unreadable page (the render's ``b""`` placeholder — page 4 + here): a per-page failure is NOT an exception — the other copies + are written, ``page_count`` keeps the FULL count, and the gap's + file is simply absent (a display-level 404 — the route of task 02 + pins its side).""" + root = tmp_path / "pgg" + root.mkdir() + _build_ikea_like_pdf(root / "ikea.pdf") + llm = _PictorialLLM() + llm.settings = _settings(tmp_path) + + def gap_page_four(pages: list[bytes]) -> list[bytes]: + pages[3] = b"" # index 3 = page 4 (the helper's list-index contract) + return pages + + _render_spy(monkeypatch, mutate_full=gap_page_four) + try: + asyncio.run(import_sources([root], llm, session=db)) + doc = _doc(db, "pgg", "ikea.pdf") + assert doc.page_count == 10, "the gap does not shrink the count" + page_dir = _pages_dir(tmp_path) + for n in range(1, 11): + path = page_dir / f"{doc.id}-p{n}.png" + if n == 4: + assert not path.exists(), "the gap has no file" + else: + assert path.is_file(), f"copy p{n} is missing" + finally: + _cleanup_source(db, "pgg") + + +def test_render_exception_is_fail_soft( + db, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """The full-page render RAISES: the warning is the signal, + ``page_count`` NULL, no copies — and the document indexes EXACTLY + as pre-phase (the pictorial content, the chunks, the summary) — + the render is a display concern, it never gates the indexing.""" + root = tmp_path / "pge" + root.mkdir() + _build_ikea_like_pdf(root / "ikea.pdf") + llm = _PictorialLLM() + llm.settings = _settings(tmp_path) + _render_spy(monkeypatch, raise_full=True) + try: + with caplog.at_level(logging.WARNING, logger="app.importer"): + summary = asyncio.run(import_sources([root], llm, session=db)) + assert (summary.files, summary.added, summary.errors) == (1, 1, 0) + doc = _doc(db, "pge", "ikea.pdf") + assert doc.page_count is None + assert not _pages_dir(tmp_path).exists(), "no copies — no partial orphans" + assert doc.chunks, "the chunk pipeline ran (fail-soft)" + assert summary.summaries == 1, "the summary ran (fail-soft)" + assert "[Page 6 illustration:" in doc.content, ( + "the pictorial pass's content is untouched by the render failure" + ) + assert any("PDF page render failed" in m for m in caplog.messages), ( + caplog.messages + ) + finally: + _cleanup_source(db, "pge") + + +def test_write_exception_is_fail_soft_and_leaves_no_orphans( + db, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """The PNG WRITER raises (a full disk) after the FIRST copy + landed: the copies this call wrote are removed (no partial + orphans), ``page_count`` NULL, the warning is the signal, and the + document indexes exactly as pre-phase.""" + root = tmp_path / "pgw" + root.mkdir() + _build_ikea_like_pdf(root / "ikea.pdf") + llm = _PictorialLLM() + llm.settings = _settings(tmp_path) + original_write = Path.write_bytes + written_paths: list[Path] = [] + + def boom(self: Path, data: bytes) -> int: + written_paths.append(self) + if len(written_paths) > 1: + raise OSError("simulated disk full (test sentinel)") + return original_write(self, data) + + monkeypatch.setattr(Path, "write_bytes", boom) # pyright: ignore[reportArgumentType] + try: + with caplog.at_level(logging.WARNING, logger="app.importer"): + summary = asyncio.run(import_sources([root], llm, session=db)) + assert (summary.files, summary.added, summary.errors) == (1, 1, 0) + assert len(written_paths) == 2, "p1 landed, p2's write failed" + doc = _doc(db, "pgw", "ikea.pdf") + assert doc.page_count is None + assert not list(_pages_dir(tmp_path).glob(f"{doc.id}-p*.png")), ( + "the partial write was cleaned up — no orphans" + ) + assert not written_paths[0].exists(), "the landed p1 copy was removed" + assert summary.summaries == 1, "the document is indexed exactly as pre-phase" + assert any("PDF page render failed" in m for m in caplog.messages), ( + caplog.messages + ) + finally: + _cleanup_source(db, "pgw") + + +# --------------------------------------------------------------------------- +# The shared cleanup helper (the _delete_image_copy convention) +# --------------------------------------------------------------------------- + + +def test_delete_page_copies_helper_is_best_effort( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """The helper (the ``_delete_image_copy`` convention): the row + knows its own extent — copies 1..``page_count`` are deleted, a + NULL/zero count is a no-op, missing files are a SILENT no-op + (already gone — e.g. the owner cleaned the pages dir), and an + unreadable file is logged, never raised (copy cleanup must not + break the sync).""" + page_dir = tmp_path / "pages" + page_dir.mkdir() + doc_id = uuid.uuid4() + doc = Document( + id=doc_id, + source="s", + path="p.pdf", + full_path="/p.pdf", + title="t", + content="c", + content_hash="x", + page_count=3, + ) + p1 = page_dir / f"{doc_id}-p1.png" + p3 = page_dir / f"{doc_id}-p3.png" + p1.write_bytes(b"png1") + p3.write_bytes(b"png3") # p2 is missing (the silent no-op) + + original_unlink = Path.unlink + + def boom(self: Path, **kwargs: Any) -> None: + if self == p3: + raise OSError("simulated unreadable file (test sentinel)") + return original_unlink(self, **kwargs) + + monkeypatch.setattr(Path, "unlink", boom) # pyright: ignore[reportArgumentType] + with caplog.at_level(logging.WARNING, logger="app.importer"): + _delete_page_copies(doc, page_dir) + assert not p1.exists(), "the existing copy was deleted" + assert p3.exists(), "the unreadable file survived (cleanup never raises)" + assert any( + "could not delete PDF page copy" in m for m in caplog.messages + ), caplog.messages + + # A NULL count is a no-op (fresh docs, non-qualifying docs, every + # fail-soft miss) — no exception, no attempt. + doc.page_count = None + _delete_page_copies(doc, page_dir) + assert p3.exists() + + +# --------------------------------------------------------------------------- +# Prune +# --------------------------------------------------------------------------- + + +def test_prune_deletes_page_copies(db, tmp_path: Path) -> None: + """A qualifying doc is PRUNED (its file removed from the source): + the row goes AND its page copies go with it — no orphans in the + pages dir (the ``_prune`` hook beside the image-copy deletion).""" + root = tmp_path / "pgz" + root.mkdir() + pdf = root / "ikea.pdf" + _build_ikea_like_pdf(pdf) + llm = _PictorialLLM() + llm.settings = _settings(tmp_path) + try: + s1 = asyncio.run(import_sources([root], llm, session=db)) + assert s1.added == 1 + doc = _doc(db, "pgz", "ikea.pdf") + assert doc.page_count == 10 + assert len(list(_pages_dir(tmp_path).glob(f"{doc.id}-p*.png"))) == 10 + + pdf.unlink() + s2 = asyncio.run(import_sources([root], llm, session=db, prune=True)) + assert s2.pruned == 1 + assert ( + db.scalar( + select(Document).where( + Document.source == "pgz", Document.path == "ikea.pdf" + ) + ) + is None + ), "the row is pruned" + assert not list(_pages_dir(tmp_path).glob("*.png")), ( + "no orphaned page copies after the prune" + ) + finally: + _cleanup_source(db, "pgz") diff --git a/tests/unit/test_wide_column_css.py b/tests/unit/test_wide_column_css.py index d441a00..cbbf2a2 100644 --- a/tests/unit/test_wide_column_css.py +++ b/tests/unit/test_wide_column_css.py @@ -13,10 +13,12 @@ without a browser: * NO ``@media (min-width: 1500px)`` block anywhere in the file — the phase-58 wide-desktop doubling is deleted in full (the token is 72rem at every viewport); -* the four reading-column selectors — ``.chat-shell``, - ``.shared-shell``, ``.doc-md``, ``.doc-summary:has(+ .doc-md)`` — - each capped with ``max-width: var(--chat-column)`` and NOTHING else - in the file uses the token for a max-width (exactly four rules); +* the six reading-column selectors — ``.chat-shell``, + ``.shared-shell``, ``.doc-md``, ``.doc-image`` (phase 122, task 04), + ``.doc-pages`` (phase 148, task 03 — the page strip), and + ``.doc-summary:has(+ .doc-md)`` — each capped with + ``max-width: var(--chat-column)`` and NOTHING else in the file uses + the token for a max-width (exactly six rules); * the flipped negative pin — ZERO literal ``max-width: 46rem`` rules remain (the phase-27/59/91 form-column caps are retired): the form shells (``.tuning-shell``, ``.theme-shell``, ``.doc-edit-shell``) @@ -117,21 +119,24 @@ def test_no_literal_46rem_width_remains() -> None: def test_the_reading_columns_use_the_token() -> None: """.chat-shell, .shared-shell, .doc-md, .doc-image (phase 122, task 04 — the viewer's image block rides the SAME reading column - as its .doc-md sibling) and .doc-summary:has(+ .doc-md) each cap - with max-width: var(--chat-column) — and exactly those five rules - use the token for a max-width (no other selector).""" + as its .doc-md sibling), .doc-pages (phase 148, task 03 — the + viewer's page strip rides the SAME reading column in the shared + #doc-content slot) and .doc-summary:has(+ .doc-md) each cap with + max-width: var(--chat-column) — and exactly those six rules use + the token for a max-width (no other selector).""" css = _css() for selector in ( ".chat-shell", ".shared-shell", ".doc-md", ".doc-image", + ".doc-pages", ".doc-summary:has(+ .doc-md)", ): assert "max-width: var(--chat-column)" in _rule_block(css, selector), ( f"{selector} must cap with max-width: var(--chat-column)" ) - assert css.count("max-width: var(--chat-column)") == 5, ( + assert css.count("max-width: var(--chat-column)") == 6, ( "exactly the reading-column selectors use the token" )