phase: 137_pdf_image_context
All verification complete — everything is 100% green. Final report: **Phase 137 final verification pass — all tasks already complete, all criteria verified** - **Full suite:** `uv run pytest --cov=app --cov-report=term-missing` → 3272 passed, exit 0; `app/` coverage **99%** (>90% ✓) - **Phase E2E in isolation:** `uv run pytest tests/e2e/test_pdf_image_context.py -v --no-cov` → **4 passed** (forward context, display line, standalone parity, both-blocks markdown case) - **Pre-existing suites in isolation:** `test_pdf_support.py` + `test_image_anchor_context.py` + `test_image_documents.py` → **14 passed**; ripple-edited `test_garbage_pdf.py` → **3 passed** - **Lint/types:** `uv run ruff check .` → all passed; `uv run pyright` → 0 errors **Completion criteria:** (1) forward-path context in vision msg + stored `image_context`/summary — ✓ (unit builder/seam + integration fake-LLM + E2E); (2) both blocks document-first + standalone describe byte-identical — ✓ (combiner + standalone pins); (3) backfill with context + zero-vision idempotent re-sync + parent-gone origin-only — ✓ (6 integration tests); (4) suggestion block leads with `From <source>/<pdf> (page N)` — ✓ (renderer units + E2E); (5) all gates green — ✓ above; (6) no behavior change in phases 122/125/126 + sync `detail` shape untouched — ✓ (verified in code: new counter is log-only); (7) ruff+pyright — ✓; (8) commit — left to the harness per pipeline rules (working tree untouched by me). **Documentation:** README.md audited in full — the new phase-137 bullet verified against code (6 000-char cap, log-only `image_context_backfilled=N`, `<document>`-body locator placement), all env tables/defaults cross-checked vs `app/config.py` and `.env.example` — consistent, no changes needed. AGENTS.md: every rule/command verified — consistent, no changes needed. **Notable deviations (pre-existing, documented in task-05 report, forced by design):** `test_pdf_support.py`/`test_garbage_pdf.py` got minimal ripple edits (the "UNEDITED" pin was unsatisfiable once describe calls carry context by design); the unit `test_image_anchor_context.py` CONTEXT_PREFIX pin generalized per ASSUMPTION A5; one extra mock line (`_seed_context_line` accepts `From …`). **Next pending phase:** none — `todo/` holds only this phase.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
All verification complete — everything is 100% green. Final report:
|
||||
|
||||
**Phase 137 final verification pass — all tasks already complete, all criteria verified**
|
||||
|
||||
- **Full suite:** `uv run pytest --cov=app --cov-report=term-missing` → 3272 passed, exit 0; `app/` coverage **99%** (>90% ✓)
|
||||
- **Phase E2E in isolation:** `uv run pytest tests/e2e/test_pdf_image_context.py -v --no-cov` → **4 passed** (forward context, display line, standalone parity, both-blocks markdown case)
|
||||
- **Pre-existing suites in isolation:** `test_pdf_support.py` + `test_image_anchor_context.py` + `test_image_documents.py` → **14 passed**; ripple-edited `test_garbage_pdf.py` → **3 passed**
|
||||
- **Lint/types:** `uv run ruff check .` → all passed; `uv run pyright` → 0 errors
|
||||
|
||||
**Completion criteria:** (1) forward-path context in vision msg + stored `image_context`/summary — ✓ (unit builder/seam + integration fake-LLM + E2E); (2) both blocks document-first + standalone describe byte-identical — ✓ (combiner + standalone pins); (3) backfill with context + zero-vision idempotent re-sync + parent-gone origin-only — ✓ (6 integration tests); (4) suggestion block leads with `From <source>/<pdf> (page N)` — ✓ (renderer units + E2E); (5) all gates green — ✓ above; (6) no behavior change in phases 122/125/126 + sync `detail` shape untouched — ✓ (verified in code: new counter is log-only); (7) ruff+pyright — ✓; (8) commit — left to the harness per pipeline rules (working tree untouched by me).
|
||||
|
||||
**Documentation:** README.md audited in full — the new phase-137 bullet verified against code (6 000-char cap, log-only `image_context_backfilled=N`, `<document>`-body locator placement), all env tables/defaults cross-checked vs `app/config.py` and `.env.example` — consistent, no changes needed. AGENTS.md: every rule/command verified — consistent, no changes needed.
|
||||
|
||||
**Notable deviations (pre-existing, documented in task-05 report, forced by design):** `test_pdf_support.py`/`test_garbage_pdf.py` got minimal ripple edits (the "UNEDITED" pin was unsatisfiable once describe calls carry context by design); the unit `test_image_anchor_context.py` CONTEXT_PREFIX pin generalized per ASSUMPTION A5; one extra mock line (`_seed_context_line` accepts `From …`).
|
||||
|
||||
**Next pending phase:** none — `todo/` holds only this phase.
|
||||
@@ -0,0 +1,117 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 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 268 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 179 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 256 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 133 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 412 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 56 0 100%
|
||||
app/rag/importer.py 671 9 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 119 0 100%
|
||||
app/rag/prompts.py 154 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 49 0 100%
|
||||
app/schemas.py 422 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5430 22 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
|
||||
@@ -0,0 +1,11 @@
|
||||
Everything is verified. The full coverage run (3239 passed) happened after the final code edit, and ruff/pyright ran clean after it too — no changes since.
|
||||
|
||||
**Report — Task 01: `extract_pdf_page_texts` (phase 137)**
|
||||
|
||||
- `app/rag/pdf.py`: new private `_iter_page_texts(doc, pdf_path)` — the shared per-page iteration (per-page try/except + warning logging moved in, unchanged); `extract_pdf_text` now consumes it (signature/output/log lines byte-identical); new public fail-soft `extract_pdf_page_texts(pdf_path) -> dict[int, str]` (unreadable file → `{}` + one `pdf:`-prefixed warning); module docstring function list updated.
|
||||
- `tests/unit/test_pdf.py`: 8 new tests — handbook (291/293 entries; exact page-for-page equality with parsed `---PAGE-N---` blocks; 1-indexed keys, all values stripped; spot-checks of known page bodies), horse PDF (exactly 1 entry, asserted against `extract_pdf_text` whatever it is), missing/garbage path → `{}`, broken-page skip through the new call site, and the pre-refactor byte-identity pins (length + sha256 + separator structure for the 293-page handbook; exact string pin for the horse) captured from the pre-refactor output.
|
||||
- Tests: `uv run pytest tests/unit -k pdf -v --no-cov` → 89 passed. Full suite `uv run pytest --cov=app --cov-report=term-missing` → **3239 passed, TOTAL 99%**, `app/rag/pdf.py` 100%.
|
||||
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings.
|
||||
- Docs: no doc impact — internal parser helper; no env/commands/API/convention changes; README/AGENTS.md don't reference `app/rag/pdf` internals.
|
||||
- Notable deviation: the task says "first key `1`" for the handbook, but its cover (page 1) and page 280 are image-only (verified against the fixture), so the first key is 2 — pinned with an explanatory comment; 1-indexedness is proven via page-for-page agreement with `extract_pdf_text`, and the horse fixture does pin first key `1`.
|
||||
- Next pending task: `02_embedded_image_document_context.md` (same phase).
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 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 268 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 179 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 256 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 133 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 412 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 56 0 100%
|
||||
app/rag/importer.py 595 9 98%
|
||||
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 119 0 100%
|
||||
app/rag/prompts.py 144 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 49 0 100%
|
||||
app/schemas.py 422 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5344 22 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
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
All green. Task 02 is complete.
|
||||
|
||||
**Report — phase 137, task 02 (embedded-image document context, forward path)**
|
||||
|
||||
- `app/rag/importer.py`: `PDF_IMAGE_CONTEXT_MAX_CHARS=6000`, `_pdf_image_context_block` (origin line always; page text stripped, capped + `TRUNCATION_MARKER`; origin-only for text-less page or gate-flagged layer), `_combine_image_contexts` (doc block first — shared with task 03); `_index_pdf_embedded_images` reads `extract_pdf_page_texts` once per PDF + runs the phase-133 gate → `text_usable`; combined context flows to the vision seam, stored `image_context`, and `content`/`summary`.
|
||||
- `app/rag/summarizer.py`: `describe_image` context prefix generalized to neutral wording (A5); `DESCRIBE_PROMPT` byte-identical; `context=None` sends the bare prompt (pinned).
|
||||
- Tests: 10 new unit tests in `tests/unit/test_pdf.py` (builder cap/edges, combining exact strings, capturing-LLM forward path, both-blocks, text-less page, garbage-layer origin-only, standalone byte-identity, unchanged-skip) + updated pins in `test_image_anchor_context.py`, `tests/integration/test_importer.py` (3 PDF tests + prefix constant), `test_pdf_hnb_guide_regression.py` (586-photo guide now asserted with per-page context blocks).
|
||||
- E2E: `tests/e2e/test_pdf_support.py` `seeded` fixture + `tests/e2e/test_garbage_pdf.py` updated to the new content shape (the old `content == ANSWER` pins encode pre-phase behavior this task intentionally changes — see deviations).
|
||||
- Results: `uv run pytest --cov=app` → **3251 passed, TOTAL 99%** (>90%); `uv run ruff check . && uv run pyright` → clean; E2E in isolation: `test_pdf_support.py` 6✓, `test_garbage_pdf.py` 3✓, `test_image_anchor_context.py` 4✓, `test_image_documents.py` 4✓, `test_answer_images.py` 5✓, `test_chat_answer_clusters.py` 4✓.
|
||||
- Docs: README PDF section gained a "Document context (phase 137)" bullet; AGENTS.md no impact.
|
||||
- Deviations: (1) used the phase-133 gate directly — 133 is already complete and the gate is an existing module-level import (the task's defensive-import note predates its landing); (2) `test_pdf_support.py` needed minimal edits despite the "unedited" criterion — its content asserts are incompatible with the phase's own objective.
|
||||
- Next pending task: `03_backfill_image_context.md`.
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 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 268 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 179 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 256 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 133 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 412 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 56 0 100%
|
||||
app/rag/importer.py 612 9 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 119 0 100%
|
||||
app/rag/prompts.py 144 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 49 0 100%
|
||||
app/schemas.py 422 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5361 22 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
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
Everything is green. Final report:
|
||||
|
||||
**Task 03 — backfill image context (phase 137): complete**
|
||||
|
||||
- **Implemented** (`app/rag/importer.py`): `_backfill_image_contexts` — per-sync, idempotent, fail-soft pass run in `import_sources` immediately after `_enrich_pending_images`; candidates = `is_image` rows matching the phase-126 synthetic-path regex (new `_EMBEDDED_IMAGE_PATH_RE`, PDF suffix case-insensitive to match the walk) with `image_context` NULL/markdown-only and an existing `image_dir` copy; per candidate: parent page text re-extracted once per unique `pdf_rel` via new shared `_pdf_image_context_inputs` probe (parent gone → origin line only), combined with stored markdown blocks verbatim (document first) through the task-02 helpers, re-described through the same `_describe_or_skip` seam, row's `content`/`summary`/`image_context`/chunks replaced in its own transaction (`content_hash`/dates/`title`/`image_path` untouched); `ImportSummary.image_context_backfilled` log-only counter + `log()` field next to `images_failed`.
|
||||
- **Tests**: 9 new integration tests in `tests/integration/test_importer.py` (real `import_sources`, fake LLM, seeded rows): happy path, idempotency (zero vision calls + row byte-identical), parent-gone origin-only, markdown-block-verbatim, standalone/copyless never touched (two runs), unreadable-copy skip, describe-fail retry, embed-fail rollback+retry (copy stays), uppercase `.PDF` extension.
|
||||
- **Results**: `uv run pytest tests/integration -k "image or import" --no-cov` → 136 passed; `uv run pytest --cov=app --cov-report=term-missing` → **3260 passed, app/ 99%** (importer.py 99%, no new uncovered lines); `uv run ruff check .` clean; `uv run pyright` 0 errors. Regression: `test_sync_api.py` (sync `detail` shape) unedited 29 passed; E2E `test_pdf_support`/`test_image_documents`/`test_image_anchor_context`/`test_garbage_pdf` + all 8 sync E2E files green unedited.
|
||||
- **Docs**: README phase-137 bullet extended with the backfill behavior (log-only counter); AGENTS.md no impact.
|
||||
- **Decisions**: backfill success counts only `image_context_backfilled` (no `updated`/`changed_paths` — literal task spec; sync detail/version-bump gates untouched); task-02's inline gate probe factored into the shared `_pdf_image_context_inputs` helper (behavior-identical) so forward/backfill probes can't drift; pinned the updated `ImportSummary.log()` line in `tests/unit/test_importer.py`.
|
||||
- **Next pending task**: `04_retrieval_display_line.md` (same phase).
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 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 268 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 179 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 256 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 133 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 412 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 56 0 100%
|
||||
app/rag/importer.py 671 9 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 119 0 100%
|
||||
app/rag/prompts.py 144 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 49 0 100%
|
||||
app/schemas.py 422 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5420 22 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
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
Task 04 complete. Report:
|
||||
|
||||
**Implemented**
|
||||
- `app/rag/prompts.py`: added `_PDF_CTX_HEADER_RE` (`^Extracted from (?P<loc>.+) \(page (?P<page>\d+)\):$`, greedy `loc`) next to the phase-125 regex + phase-137 comment; restructured `_extract_context_line`'s state machine — an `Extracted from` header needs no bullet (clause = `From <loc> (page N)`), flushes a complete pending reference block in stored order, drops a pending one via the existing spurious-header rule; join is `"; "` in stored order with per-clause prefixes in mixed values; markdown-only values keep the single-prefix shape byte-identically (phase-125 pin); updated the docstring (both block kinds, the accepted unguarded spurious-`Extracted from`-in-page-text asymmetry) and the `_document_body` docstring example line.
|
||||
- Verified `_image_context_line`/`_document_body` need NO code changes (the `is_image` guard + renderer call already do the right thing).
|
||||
- `tests/unit/test_prompts.py`: +12 tests, 0 lines removed (phase-125 pins unedited): pdf-alone, text-less header-only, greedy locator with spaces, page-text body + spurious `Referenced in` line skipped, spurious `Extracted from` in page text emits a clause (accepted, pinned), markdown-only 2-block pin (`Referenced in docs/a.md: "alt a"; docs/b.md`), both-blocks document-first, markdown-first stored order, pdf garbage → `None`, `_document_body` combined + text-doc guard, built-prompt block shape.
|
||||
|
||||
**Results**
|
||||
- `uv run pytest --cov=app --cov-report=term-missing`: 3272 passed, `app/` 99% (`prompts.py` 100%)
|
||||
- `uv run pytest tests/unit -k prompt --no-cov`: 148 passed
|
||||
- `uv run ruff check .` + `uv run pyright`: clean (0 errors)
|
||||
|
||||
**Docs**: README's phase-137 bullet extended with the visible display-line contract; AGENTS.md no impact.
|
||||
|
||||
**Decisions**: markdown-only values render exactly pre-task (ONE `Referenced in` prefix — the unedited phase-125 pin); mixed values give every clause its own prefix (`From …; Referenced in …`).
|
||||
|
||||
**Next pending task**: `05_e2e_pdf_image_context.md` (mock `Extracted from` trigger + dedicated Playwright suite).
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 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 268 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 179 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 256 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 133 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 412 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 56 0 100%
|
||||
app/rag/importer.py 671 9 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 119 0 100%
|
||||
app/rag/prompts.py 154 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 49 0 100%
|
||||
app/schemas.py 422 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5430 22 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
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
Task 05 is complete — everything is 100% green.
|
||||
|
||||
**Implemented**
|
||||
- `tests/e2e/mock_llm.py`: new `IMAGE_DESCRIPTION_CONTEXT_ANSWER` (the phase-137 witness — names the document + page, keeps the diagram wording); `IMAGE_DESCRIPTION_MODE` branch returns it only when the text part carries the `Extracted from ` provenance header (bare path byte-identical); `_seed_context_line` also accepts the `From …` first line so the existing `CONTEXT_LINE_TRIGGER` branch echoes the task-04 display rendering; both behaviors documented in the module docstring (house rule).
|
||||
- `tests/e2e/test_pdf_image_context.py` (new, isolated, port 8154): 4 tests — (1) forward path: `image_context` starts with `Extracted from e2e-pdf-imgctx/report.pdf (page 2):` + page text, `content` = context + context-aware reply, bare reply absent; (2) chat display: answer quotes `From e2e-pdf-imgctx/report.pdf (page 2)`, no "stands alone"; (4→3rd, runs pre-reseed) seed-1 KB is exactly PDF + its one image (standalone-parity witness); (3) seed 2 (markdown referencing the synthetic path): both block kinds, document first, display line carries `From … (page 2); Referenced in e2e-pdf-imgctx-md/notes.md`.
|
||||
- Minimal ripple edits (embedded-image describe calls carry the provenance context by phase design): `test_pdf_support.py` seeded-fixture content assertion → context-aware reply; `test_garbage_pdf.py` embedded-image leg same (screenshot-fallback leg untouched — `context=None`, bare reply).
|
||||
|
||||
**Test / lint / coverage (all green)**
|
||||
- `uv run pytest tests/e2e/test_pdf_image_context.py -v --no-cov` → 4 passed
|
||||
- `uv run pytest tests/e2e/test_pdf_support.py tests/e2e/test_image_anchor_context.py tests/e2e/test_image_documents.py -v --no-cov` → 14 passed (anchor + documents UNEDITED)
|
||||
- `uv run pytest tests/e2e/test_garbage_pdf.py -v --no-cov` → 3 passed
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → 3272 passed, `app/` 99% (>90%)
|
||||
- `uv run ruff check .` → all passed; `uv run pyright` → 0 errors
|
||||
|
||||
**Docs:** no doc impact for task 05 (test-only; the feature bullet was added to README by tasks 01–04).
|
||||
|
||||
**Deviations (task-file text was stale, both forced by code reality)**
|
||||
- "UNEDITED" for `test_pdf_support.py` is unsatisfiable: since task 02 its embedded image's describe call carries the `Extracted from` context, so the canned reply necessarily moved to the context-aware one (one assertion + import). The load-bearing bare-path parity stays pinned by the two unedited suites.
|
||||
- One mock line beyond "NO other mock change": `_seed_context_line` had a `Referenced in `-only prefix guard, so without accepting `From …` the test 2/3 display assertions could never pass.
|
||||
|
||||
**Next pending task:** none — task 05 is the last of phase 137; `todo/` holds only this phase.
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 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 268 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 179 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 256 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 133 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 412 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 56 0 100%
|
||||
app/rag/importer.py 671 9 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 119 0 100%
|
||||
app/rag/prompts.py 154 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 49 0 100%
|
||||
app/schemas.py 422 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5430 22 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
|
||||
@@ -590,6 +590,30 @@ uploaded archives):
|
||||
with the toggle off the pass is skipped and the PDF text indexes
|
||||
normally. Embedded images in a format outside `BOR_IMAGE_EXTENSIONS`
|
||||
are skipped with a warning.
|
||||
- **Document context (phase 137).** Every embedded image is described
|
||||
*with* the document it came from: the vision prompt — and the stored
|
||||
`image_context` + enriched content — carry an
|
||||
`Extracted from <source>/<pdf> (page N):` origin line (always
|
||||
present, even for a text-less page) plus the page's own text
|
||||
(capped at 6 000 chars; dropped when the phase-133 gate flags the
|
||||
text layer as garbage — origin line only). Markdown ``
|
||||
references to the image's synthetic path ride along after it
|
||||
(phase 125, document block first), so the description names the
|
||||
document and the page the image illustrates. Image docs a
|
||||
pre-phase-137 sync left without the block (`image_context` NULL or
|
||||
markdown-only) are **backfilled on the next sync** — re-described
|
||||
once with the context (the parent's page text is re-read from the
|
||||
source while the PDF is still there; parent gone → origin line
|
||||
only), idempotent (the following sync burns zero vision calls for
|
||||
them), fail-soft (a failure leaves the row untouched and retries
|
||||
next sync), counted log-only on the import summary line
|
||||
(`image_context_backfilled=N`). The stored context is also VISIBLE
|
||||
in the chat suggestion block: an image doc's `<document>` body leads
|
||||
with a one-line locator — `From <source>/<pdf> (page N)` for a
|
||||
PDF-embedded image (with `; Referenced in …` appended when markdown
|
||||
reference blocks are stored too — document block first),
|
||||
`Referenced in <source>/<path>: "alt"` for a markdown-referenced
|
||||
image (phase 125) — the page-text body is never rendered.
|
||||
- **Garbage text layer → page-screenshot fallback (phase 133).** Before
|
||||
indexing, the extracted text passes a deterministic garbage gate
|
||||
(empty text layer, or ≥ 30% tag-looking lines, or ≥ 5% non-printable
|
||||
|
||||
+443
-25
@@ -163,6 +163,56 @@ with ``image_context`` set is never touched; a markdown doc's record
|
||||
is NOT cleared on success — it is the retrieval display's record
|
||||
also, task 05).
|
||||
|
||||
PDF-embedded image document context (phase 137, task 02): a PDF's
|
||||
embedded images are ALWAYS described with their document context —
|
||||
the ``Extracted from <source>/<pdf path> (page <N>):`` provenance
|
||||
block (see :func:`_pdf_image_context_block`: the origin line is
|
||||
ALWAYS present — even for a text-less page; the page's own text
|
||||
follows, stripped, cut at :data:`PDF_IMAGE_CONTEXT_MAX_CHARS` with
|
||||
the shared :data:`~app.rag.retriever.TRUNCATION_MARKER` on its own
|
||||
line; the page text is dropped entirely for a text-less page OR when
|
||||
the phase-133 garbage gate (:func:`app.rag.pdf.is_usable_pdf_text`)
|
||||
marks the PDF's text layer unusable — origin line only, A7) —
|
||||
combined with any phase-125 markdown reference blocks (document block
|
||||
FIRST — provenance before purpose — the single
|
||||
:func:`_combine_image_contexts` combiner, shared with the task-03
|
||||
backfill so the two paths can never drift). The per-page text map
|
||||
(:func:`app.rag.pdf.extract_pdf_page_texts`) is extracted ONCE per
|
||||
PDF for the whole image pass. The combined context is the vision
|
||||
call's ``context`` (the generalized phase-125 prefix — the model
|
||||
names the document and page in its description), the stored
|
||||
``image_context`` (re-scanned on re-index — the context follows the
|
||||
current source state; the unchanged-image digest skip is untouched),
|
||||
and the ``content``/``summary`` enrichment
|
||||
(``<context>\n\n<description>`` — the same text the embedder indexes).
|
||||
Standalone images (no PDF) are untouched: no document block, the
|
||||
phase-125 markdown shape unchanged, and the reference-free image's
|
||||
bare prompt stays byte-identical.
|
||||
|
||||
Document-context backfill (phase 137, task 03): the per-sync,
|
||||
idempotent, best-effort pass (:func:`_backfill_image_contexts`) runs
|
||||
for each source immediately after the phase-125 enrichment pass, and
|
||||
re-describes every PDF-embedded image doc that is still missing the
|
||||
document block (``image_context`` NULL — a pre-phase-137 row with no
|
||||
context at all — or markdown-only: the phase-125 ``Referenced in …``
|
||||
value with the document block still missing). The bytes come from the
|
||||
``image_dir`` copy, the page text is re-extracted from the parent PDF
|
||||
while it is still in the source (the parent gone for good → the
|
||||
origin line only — the origin is still valuable and needs no disk),
|
||||
the fresh description goes through the SAME seam
|
||||
(:func:`_describe_or_skip`), and the row's ``content``/``summary`` /
|
||||
``image_context`` + chunks are replaced (one transaction per image).
|
||||
A describe ``None`` or an embed failure leaves the row untouched and
|
||||
retries next sync. A success writes the ``Extracted from …`` header
|
||||
into ``image_context`` — the idempotency gate — so the doc never
|
||||
qualifies again (the following sync burns zero vision calls for it).
|
||||
Deliberately unchanged on the row: ``content_hash`` (the image
|
||||
identity), ``created_at``/``created_at_manual`` (the SOURCE did not
|
||||
change — the phase-106 date rules do not apply), ``title``, and
|
||||
``image_path``. The new ``image_context_backfilled`` counter is
|
||||
log-only (the sync ``detail`` shape is untouched — the stats log-only
|
||||
convention, phase 133).
|
||||
|
||||
``import_sources`` accepts an optional per-file ``progress`` callback
|
||||
(phase 64, task 01) reporting the file being processed right now, and
|
||||
an optional ``on_chunk_progress`` callback (phase 129, task 02)
|
||||
@@ -197,10 +247,12 @@ from app.rag.pdf import (
|
||||
PAGE_SEPARATOR,
|
||||
PdfImage,
|
||||
extract_pdf_images,
|
||||
extract_pdf_page_texts,
|
||||
extract_pdf_text,
|
||||
is_usable_pdf_text,
|
||||
render_pdf_pages,
|
||||
)
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
from app.rag.summarizer import (
|
||||
IMAGE_FALLBACK_MIME,
|
||||
IMAGE_MIMES,
|
||||
@@ -289,6 +341,11 @@ class ImportSummary:
|
||||
#: ``---PAGE-N---``-anchored descriptions, or ``""`` when the
|
||||
#: fallback produced nothing — the phase-126 empty-content path).
|
||||
pdfs_screenshotted: int = 0
|
||||
#: PDF-embedded image docs whose stored ``image_context`` was
|
||||
#: backfilled with the document-context block (phase 137, task 03)
|
||||
#: — the one-time best-effort pass; a success never repeats, a
|
||||
#: failure retries next sync.
|
||||
image_context_backfilled: int = 0
|
||||
#: Documents this run ADDED, UPDATED, or PRUNED, as (source, path)
|
||||
#: tuples in encounter order (added/updated in walk order, pruned in
|
||||
#: _prune order). Drives the sync's incremental folder-summary
|
||||
@@ -310,7 +367,7 @@ class ImportSummary:
|
||||
"import: summary files=%d added=%d updated=%d unchanged=%d pruned=%d "
|
||||
"errors=%d chunks=%d embed_batches=%d summaries=%d summary_errors=%d "
|
||||
"summary_backfilled=%d dates_updated=%d images_failed=%d "
|
||||
"pdfs_screenshotted=%d formats=%s",
|
||||
"image_context_backfilled=%d pdfs_screenshotted=%d formats=%s",
|
||||
self.files,
|
||||
self.added,
|
||||
self.updated,
|
||||
@@ -324,6 +381,7 @@ class ImportSummary:
|
||||
self.summary_backfilled,
|
||||
self.dates_updated,
|
||||
self.images_failed,
|
||||
self.image_context_backfilled,
|
||||
self.pdfs_screenshotted,
|
||||
self.format_counts(),
|
||||
)
|
||||
@@ -570,6 +628,23 @@ async def import_sources(
|
||||
``limit`` too (it deletes nothing), and a per-image failure
|
||||
(missing copy, failed re-description, embed error) keeps that image
|
||||
as-is and moves on.
|
||||
|
||||
Phase 137 (task 03): the document-context backfill
|
||||
(:func:`_backfill_image_contexts`) runs for that source immediately
|
||||
AFTER the enrichment pass (enrichment first: it owns the
|
||||
markdown-FIRST case and may have just set a markdown-only context,
|
||||
which the backfill then prepends the document block to). It
|
||||
re-describes the PDF-embedded image docs a pre-phase-137 sync left
|
||||
with ``image_context`` NULL or markdown-only — the bytes from the
|
||||
persistent copy, the page text re-extracted from the parent PDF
|
||||
while it is still in the source (gone → the origin line only), the
|
||||
SAME describe seam, and the row's ``content``/``summary`` /
|
||||
``image_context``/chunks replaced. Idempotent (a success writes the
|
||||
``Extracted from …`` header — the doc never qualifies again, the
|
||||
following sync burns zero vision calls for it) and fail-soft (a
|
||||
failure leaves the row untouched — the next sync retries it).
|
||||
Driven by committed doc rows, so it is safe under ``limit`` too
|
||||
(it deletes nothing).
|
||||
"""
|
||||
if limit is not None and limit <= 0:
|
||||
raise ValueError("limit must be >= 1")
|
||||
@@ -666,6 +741,15 @@ async def import_sources(
|
||||
# markdown file is no longer on disk). Idempotent +
|
||||
# fail-soft (see the function).
|
||||
await _enrich_pending_images(session, source=source, llm=llm, summary=summary)
|
||||
# Phase 137 (task 03): the one-time document-context
|
||||
# backfill — the PDF-embedded image docs a pre-phase-137
|
||||
# sync left WITHOUT the document block (``image_context``
|
||||
# NULL or markdown-only) get it now: re-described with the
|
||||
# context, the row's content/summary/context/chunks
|
||||
# replaced. Idempotent + fail-soft (see the function).
|
||||
await _backfill_image_contexts(
|
||||
session, source=source, root=root, llm=llm, summary=summary
|
||||
)
|
||||
if prune:
|
||||
if limit is not None:
|
||||
logger.warning("import: --prune ignored because --limit was given")
|
||||
@@ -1383,7 +1467,18 @@ async def _index_pdf_embedded_images(
|
||||
the PDF's text index and the other images are already committed.
|
||||
* a new/updated image doc counts ``added``/``updated`` (it really
|
||||
changed the KB — the sources-version bump fires on it, like a
|
||||
standalone image's).
|
||||
standalone image's);
|
||||
* phase 137 (task 02, A2/A7): the document context is built ONCE
|
||||
per PDF — :func:`app.rag.pdf.extract_pdf_page_texts` (task 01)
|
||||
reads the per-page text map a single time for the whole pass, and
|
||||
the phase-133 garbage gate
|
||||
(:func:`app.rag.pdf.is_usable_pdf_text`) is run over the PDF's
|
||||
extracted text layer: a layer the gate flags as unusable yields
|
||||
``text_usable=False`` → every block is the origin line only
|
||||
(the "page text" would be the pre-126 soup — ASSUMPTION A7). An
|
||||
unopenable PDF reads as an empty map + an unusable (empty)
|
||||
layer: origin-line-only blocks, fail-soft like the rest of the
|
||||
parser.
|
||||
|
||||
``raw_date`` is the PDF's resolved source date (the pass runs only
|
||||
for (re)indexed PDFs, so it is always concrete) — the image docs
|
||||
@@ -1400,11 +1495,20 @@ async def _index_pdf_embedded_images(
|
||||
source, rel,
|
||||
)
|
||||
return
|
||||
# Phase 137 (task 02/03): the document context's raw material —
|
||||
# the SHARED probe (task 01's per-page text map extracted ONCE per
|
||||
# PDF — the pass reads it per image — plus the phase-133 garbage
|
||||
# gate's verdict over the layer, A7 — the gate's empty-layer target
|
||||
# makes a text-less PDF's blocks origin-line-only, too). The
|
||||
# task-03 backfill probes the parent with the SAME helper when it
|
||||
# is still in the source, so the two paths can never drift.
|
||||
page_texts, text_usable = _pdf_image_context_inputs(full_path)
|
||||
images = extract_pdf_images(full_path)
|
||||
for img in images:
|
||||
await _index_one_embedded_image(
|
||||
session, img, source=source, rel=rel, full_path=full_path,
|
||||
llm=llm, summary=summary, raw_date=raw_date,
|
||||
page_texts=page_texts, text_usable=text_usable,
|
||||
)
|
||||
|
||||
|
||||
@@ -1418,11 +1522,21 @@ async def _index_one_embedded_image(
|
||||
llm: Embedder,
|
||||
summary: ImportSummary,
|
||||
raw_date: datetime,
|
||||
page_texts: dict[int, str],
|
||||
text_usable: bool = True,
|
||||
) -> None:
|
||||
"""One embedded image of one PDF → the phase-122 image pipeline,
|
||||
with the phase-125 task-03 markdown context scan (parity with the
|
||||
standalone image's :func:`_index_image_file` — see
|
||||
:func:`_index_pdf_embedded_images` for the full contract)."""
|
||||
standalone image's :func:`_index_image_file`), and the phase-137
|
||||
(task 02, A2) document-context block — the combined context
|
||||
(document block FIRST, markdown blocks second) is the vision call's
|
||||
``context``, the stored ``image_context``, and the
|
||||
``content``/``summary`` enrichment (see
|
||||
:func:`_index_pdf_embedded_images` for the full contract).
|
||||
|
||||
*page_texts* / *text_usable* are the pass's per-PDF context inputs
|
||||
(the task-01 page map; the phase-133 gate's verdict — A7).
|
||||
"""
|
||||
settings = llm.settings
|
||||
ext = _MIME_TO_EXT.get(img.mime, ".bin")
|
||||
if ext not in settings.image_extension_set:
|
||||
@@ -1458,16 +1572,26 @@ async def _index_one_embedded_image(
|
||||
# ```` reference in a
|
||||
# same-source markdown file (the synthetic path is the doc's
|
||||
# ``path``, and the reference resolves to it through the shared
|
||||
# :func:`_resolve_ref_path` rule) — or ``None`` (no reference —
|
||||
# the description call and the row stay byte-identical to the
|
||||
# un-enriched shape). It goes to the vision model (a context-aware
|
||||
# description, LOCKED A3) AND is stored on the doc (``image_context``
|
||||
# — the retrieval display's context line, task 05). A re-indexed
|
||||
# image re-scans: the context follows the current source state
|
||||
# (a deleted referencing markdown drops it back to ``None``).
|
||||
image_context = _markdown_image_context(
|
||||
# :func:`_resolve_ref_path` rule) — or ``None`` (no reference).
|
||||
# A re-indexed image re-scans: the context follows the current
|
||||
# source state (a deleted referencing markdown drops it back to
|
||||
# ``None``).
|
||||
md_context = _markdown_image_context(
|
||||
_source_root_for(full_path, rel), llm, source=source, image_rel=image_rel
|
||||
)
|
||||
# Phase 137 (task 02, A2) — the document-context block (the
|
||||
# provenance: WHERE the image came from — ALWAYS present, even for
|
||||
# a text-less page or a garbage-gated text layer — A7) combined
|
||||
# with the markdown reference blocks (document FIRST — provenance
|
||||
# before purpose). The SAME combined value goes to the vision model
|
||||
# (a context-aware description, LOCKED A3), is stored on the doc
|
||||
# (``image_context`` — the retrieval display's context line), and
|
||||
# enriches ``content``/``summary`` below — the text the embedder
|
||||
# indexes carries the context the retrieval needs.
|
||||
image_context = _combine_image_contexts(
|
||||
_pdf_image_context_block(source, rel, img.page, page_texts, text_usable=text_usable),
|
||||
md_context,
|
||||
)
|
||||
# The description through the single seam (the mime resolves from
|
||||
# the copy path's extension — the extension was derived from the
|
||||
# mime, so the round-trip is lossless for the default family). The
|
||||
@@ -1493,12 +1617,13 @@ async def _index_one_embedded_image(
|
||||
image_dir.mkdir(parents=True, exist_ok=True)
|
||||
copy_path.write_bytes(img.data)
|
||||
|
||||
# Phase 125 (task 03): the enriched content — the reference context
|
||||
# FIRST (for retrieval), then the description (the standalone image's
|
||||
# rule; ``summary = content`` below keeps the image convention).
|
||||
content = (
|
||||
f"{image_context}\n\n{description}" if image_context is not None else description
|
||||
)
|
||||
# Phase 125 (task 03) / phase 137 (task 02): the enriched content —
|
||||
# the combined context FIRST (for retrieval), then the description
|
||||
# (the image convention; ``summary = content`` below). For embedded
|
||||
# images the context is ALWAYS present (the document block is the
|
||||
# provenance — never empty), so the bare-description shape is
|
||||
# standalone-only.
|
||||
content = f"{image_context}\n\n{description}"
|
||||
|
||||
title = f"Embedded image from {full_path.stem} page {img.page}"
|
||||
if image_doc is None:
|
||||
@@ -1635,6 +1760,92 @@ def _ref_points_at(ref: ImageRef, md_rel: str, image_rel: str) -> bool:
|
||||
return resolved is not None and resolved == image_rel
|
||||
|
||||
|
||||
#: The page-text cap of one embedded image's document-context block
|
||||
#: (phase 137, task 02, ASSUMPTION A3) — a module constant, no env var.
|
||||
#: A single page rarely runs past it; a runaway page (a wall of dense
|
||||
#: text) is cut exactly at the cap, with the shared
|
||||
#: :data:`~app.rag.retriever.TRUNCATION_MARKER` on its own line (the
|
||||
#: summarizer's existing truncation convention).
|
||||
PDF_IMAGE_CONTEXT_MAX_CHARS = 6000
|
||||
|
||||
|
||||
def _pdf_image_context_block(
|
||||
source: str,
|
||||
pdf_rel: str,
|
||||
page: int,
|
||||
page_texts: dict[int, str],
|
||||
text_usable: bool = True,
|
||||
) -> str:
|
||||
"""The document-context block for one embedded image (phase 137, A2).
|
||||
|
||||
Always non-empty: the origin line is present even for a text-less
|
||||
page (the owner's core complaint — a stock-photo description that
|
||||
names nothing about the document it came from). The page text (the
|
||||
:func:`app.rag.pdf.extract_pdf_page_texts` value for *page* —
|
||||
already stripped; a missing key is the text-less-page case) is cut
|
||||
exactly at :data:`PDF_IMAGE_CONTEXT_MAX_CHARS` (overflow cut at the
|
||||
cap, then :data:`~app.rag.retriever.TRUNCATION_MARKER` on its own
|
||||
line — the summarizer's existing truncation convention) and
|
||||
OMITTED entirely when the page has no text OR when *text_usable*
|
||||
is ``False`` — the caller ran the phase-133 garbage gate
|
||||
(:func:`app.rag.pdf.is_usable_pdf_text`) over the PDF's extracted
|
||||
text layer and it failed, so the "text" is the pre-126 class of
|
||||
garbage and must not be fed to the vision model (ASSUMPTION A7).
|
||||
|
||||
*page* is the 1-indexed page number (the forward path passes
|
||||
``img.page``; the task-03 backfill passes the page parsed from the
|
||||
synthetic path — taking a plain ``int``, deliberately NOT a
|
||||
``PdfImage``, is what lets the backfill need no fake image
|
||||
object). The header is a stable single line with NO page-total
|
||||
(the total is not on the image record, and the task-04 display
|
||||
renderer regexes this exact line).
|
||||
"""
|
||||
header = f"Extracted from {source}/{pdf_rel} (page {page}):"
|
||||
text = page_texts.get(page, "").strip()
|
||||
if not text or not text_usable:
|
||||
return header
|
||||
if len(text) > PDF_IMAGE_CONTEXT_MAX_CHARS:
|
||||
text = text[:PDF_IMAGE_CONTEXT_MAX_CHARS] + "\n" + TRUNCATION_MARKER
|
||||
return f"{header}\n{text}"
|
||||
|
||||
|
||||
def _combine_image_contexts(doc_block: str, md_blocks: str | None) -> str:
|
||||
"""Document block first, markdown reference block(s) second (A2).
|
||||
|
||||
The single combiner of the two context block kinds — provenance
|
||||
(WHERE the image came from) before purpose (WHAT the markdown says
|
||||
it illustrates). Used by BOTH the forward path
|
||||
(:func:`_index_one_embedded_image`) and the task-03 backfill, so
|
||||
the two can never drift. ``md_blocks`` ``None``/empty → the doc
|
||||
block alone; the doc block is ALWAYS present for embedded images
|
||||
(it is the provenance — never empty). Markdown-only values (no doc
|
||||
block) are produced by the standalone path only and never pass
|
||||
through this helper.
|
||||
"""
|
||||
if not md_blocks:
|
||||
return doc_block
|
||||
return f"{doc_block}\n\n{md_blocks}"
|
||||
|
||||
|
||||
def _pdf_image_context_inputs(full_path: Path) -> tuple[dict[int, str], bool]:
|
||||
"""The phase-137 document-context inputs for one PDF on disk — the
|
||||
task-02 probe, shared by the forward path
|
||||
(:func:`_index_pdf_embedded_images`) and the task-03 backfill
|
||||
(:func:`_backfill_image_contexts`) so the two can never drift:
|
||||
|
||||
* the per-page text map (task 01's
|
||||
:func:`app.rag.pdf.extract_pdf_page_texts` — fail-soft: an
|
||||
unreadable file reads as an empty map with one warning);
|
||||
* the phase-133 garbage gate's verdict over the layer (ASSUMPTION
|
||||
A7 — :func:`app.rag.pdf.is_usable_pdf_text` over the whole
|
||||
extracted text; ``False`` → the blocks are origin-line-only, the
|
||||
"page text" is the pre-126 class of garbage).
|
||||
"""
|
||||
page_texts = extract_pdf_page_texts(full_path)
|
||||
text_usable = is_usable_pdf_text("\n\n".join(page_texts.values()))
|
||||
return page_texts, text_usable
|
||||
|
||||
|
||||
def _markdown_image_context(
|
||||
root: Path,
|
||||
llm: Embedder,
|
||||
@@ -1975,6 +2186,209 @@ async def _enrich_pending_images(
|
||||
)
|
||||
|
||||
|
||||
#: The phase-126 embedded-image synthetic ``Document.path`` —
|
||||
#: ``<pdf path>#page=<N>#img=<I>.<ext>`` (the upsert key built by
|
||||
#: :func:`_index_one_embedded_image`: the parent PDF's source-relative
|
||||
#: path, the 1-indexed page, the 0-based index on the page, the
|
||||
#: mime-derived dotted extension). The task-03 backfill gates on this
|
||||
#: exact shape: a non-matching ``is_image`` row (a standalone image's
|
||||
#: plain path) is NEVER touched by the backfill. The ``pdf`` suffix is
|
||||
#: case-insensitive (scoped flag) because the walk matches extensions
|
||||
#: case-insensitively — an on-disk ``report.PDF`` produces a synthetic
|
||||
#: path the backfill must still recognize.
|
||||
_EMBEDDED_IMAGE_PATH_RE = re.compile(
|
||||
r"^(?P<pdf>.+(?i:pdf))#page=(?P<page>\d+)#img=(?P<img>\d+)(?P<ext>\.\w+)$"
|
||||
)
|
||||
|
||||
|
||||
async def _backfill_image_contexts(
|
||||
session: Session, *, source: str, root: Path, llm: Embedder, summary: ImportSummary
|
||||
) -> None:
|
||||
"""The phase-137 (task 03) per-sync document-context backfill — once
|
||||
per source, immediately AFTER the phase-125 enrichment pass
|
||||
(:func:`_enrich_pending_images`) in :func:`import_sources`.
|
||||
|
||||
Closes the one-time gap the forward path (task 02) cannot reach:
|
||||
the PDF-embedded image docs a PRE-phase-137 sync left behind
|
||||
without the document block — ``image_context`` NULL (no context at
|
||||
all) or markdown-only (the phase-125 ``Referenced in …`` value —
|
||||
the document block missing) — are re-described WITH the document
|
||||
context, so the embedder indexes the provenance (the owner's
|
||||
HNB-guide stock-photo complaint: descriptions that name nothing
|
||||
about the document the image came from).
|
||||
|
||||
Candidates — this source's ``is_image`` docs where ALL hold:
|
||||
|
||||
1. the ``path`` parses as the phase-126 embedded-image synthetic
|
||||
path (:data:`_EMBEDDED_IMAGE_PATH_RE` — ``pdf_rel``, page,
|
||||
index, extension); a non-match (a standalone image's plain
|
||||
path) is NEVER touched;
|
||||
2. the stored ``image_context`` is NULL or does NOT start with
|
||||
``Extracted from `` — the idempotency gate: a success writes a
|
||||
context starting with the header, so the doc never qualifies
|
||||
again (the following sync burns zero vision calls for it);
|
||||
3. the ``image_path`` names a file that exists on disk (the
|
||||
``image_dir`` copy — the bytes source; missing → debug log,
|
||||
skip, the doc stays as-is).
|
||||
|
||||
Per candidate (fail-soft, ONE transaction per image — the
|
||||
:func:`_enrich_pending_images` error handling, mirrored): the page
|
||||
text is re-extracted from the parent PDF WHEN IT IS STILL IN THE
|
||||
SOURCE (:func:`_pdf_image_context_inputs` ONCE per unique
|
||||
``pdf_rel`` — a local cache, one parent may own many images; a
|
||||
parent gone for good reads as an empty map + usable → the origin
|
||||
line only, which still needs no disk), the document block
|
||||
(:func:`_pdf_image_context_block`) is combined with the stored
|
||||
markdown blocks VERBATIM when present (:func:`_combine_image_contexts`
|
||||
— document FIRST; the phase-125 pass owns the markdown context and
|
||||
ran earlier this sync, so re-scanning the disk for it is NOT this
|
||||
pass's job), and the fresh description goes through the SAME seam
|
||||
(:func:`_describe_or_skip` — the mime comes from the copy's
|
||||
extension, exactly like the forward path). A ``None`` description
|
||||
leaves the row UNTOUCHED (nothing was written) —
|
||||
``images_failed`` + a warning, and the gate still matches, so the
|
||||
next sync retries it. A success replaces the row's
|
||||
``content``/``summary``/``image_context`` + chunks
|
||||
(:func:`_replace_content_chunks` in the image's own transaction —
|
||||
an embed failure rolls back THIS image only, the copy stays — then
|
||||
the image-convention :func:`_store_summary`, the description IS the
|
||||
summary, verbatim) and counts the log-only
|
||||
``summary.image_context_backfilled`` (the sync ``detail`` shape is
|
||||
untouched — the stats log-only convention, phase 133) with one loud
|
||||
line (PLAN §9).
|
||||
|
||||
Deliberately UNCHANGED on the row: ``content_hash`` (the byte
|
||||
digest — the image identity is untouched), ``created_at`` /
|
||||
``created_at_manual`` (the SOURCE did not change — the phase-106
|
||||
date rules do not apply), ``title``, and ``image_path``.
|
||||
|
||||
Driven by committed doc rows (like the enrichment pass), so it is
|
||||
safe under ``limit`` (it deletes nothing); the common case after
|
||||
the one-time backfill (no candidates) returns with zero vision
|
||||
calls and zero disk reads beyond the candidate query.
|
||||
"""
|
||||
image_docs = session.scalars(
|
||||
select(Document).where(
|
||||
Document.source == source, Document.is_image.is_(True)
|
||||
)
|
||||
).all()
|
||||
if not image_docs:
|
||||
return
|
||||
# The parent PDF's context inputs, cached per unique ``pdf_rel``
|
||||
# (one parent may own many images): a parent still in the source
|
||||
# gets the task-02 probe (the per-page map + the phase-133 gate
|
||||
# verdict — the same helper the forward path uses, A7); a parent
|
||||
# gone for good reads as an empty map + usable — the origin line
|
||||
# only (the origin is still valuable and needs no disk).
|
||||
context_cache: dict[str, tuple[dict[int, str], bool]] = {}
|
||||
|
||||
def _context_for(pdf_rel: str) -> tuple[dict[int, str], bool]:
|
||||
if pdf_rel not in context_cache:
|
||||
parent = root / pdf_rel
|
||||
if parent.is_file():
|
||||
context_cache[pdf_rel] = _pdf_image_context_inputs(parent)
|
||||
else:
|
||||
context_cache[pdf_rel] = ({}, True)
|
||||
logger.debug(
|
||||
"import: backfill parent PDF missing source=%s path=%s "
|
||||
"(origin line only)",
|
||||
source, pdf_rel,
|
||||
)
|
||||
return context_cache[pdf_rel]
|
||||
|
||||
for doc in image_docs:
|
||||
parsed = _EMBEDDED_IMAGE_PATH_RE.match(doc.path)
|
||||
if parsed is None:
|
||||
continue # standalone / non-embedded — NEVER touched
|
||||
if doc.image_context is not None and doc.image_context.startswith(
|
||||
"Extracted from "
|
||||
):
|
||||
continue # already backfilled (the idempotency gate)
|
||||
if not doc.image_path or not Path(doc.image_path).is_file():
|
||||
# No persistent bytes to re-describe — keep the doc as-is
|
||||
# (the next sync retries; a copy gone for good leaves an
|
||||
# unbackfillable doc, and the debug line is the signal).
|
||||
logger.debug(
|
||||
"import: backfill skipped (no persistent copy) source=%s path=%s",
|
||||
source, doc.path,
|
||||
)
|
||||
continue
|
||||
pdf_rel = parsed.group("pdf")
|
||||
page = int(parsed.group("page"))
|
||||
page_texts, text_usable = _context_for(pdf_rel)
|
||||
# The stored markdown blocks (the phase-125 value) are kept
|
||||
# VERBATIM under the document block — document FIRST. A value
|
||||
# that is neither the markdown shape nor NULL contributes
|
||||
# nothing (the doc block alone).
|
||||
existing = doc.image_context
|
||||
md_blocks = (
|
||||
existing
|
||||
if existing is not None and existing.startswith("Referenced in ")
|
||||
else None
|
||||
)
|
||||
combined = _combine_image_contexts(
|
||||
_pdf_image_context_block(
|
||||
source, pdf_rel, page, page_texts, text_usable=text_usable
|
||||
),
|
||||
md_blocks,
|
||||
)
|
||||
copy_path = Path(doc.image_path)
|
||||
try:
|
||||
data = copy_path.read_bytes()
|
||||
except OSError as e:
|
||||
logger.debug(
|
||||
"import: backfill skipped (copy unreadable) source=%s path=%s — %s",
|
||||
source, doc.path, e,
|
||||
)
|
||||
continue
|
||||
description = await _describe_or_skip(
|
||||
llm, data=data, source=source, rel=doc.path,
|
||||
full_path=copy_path, context=combined,
|
||||
)
|
||||
if description is None:
|
||||
# Fail-soft: nothing was written — the row is untouched,
|
||||
# the gate still matches, the next sync retries it.
|
||||
summary.images_failed += 1
|
||||
logger.warning(
|
||||
"import: image context backfill re-description failed "
|
||||
"source=%s path=%s",
|
||||
source, doc.path,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
# The SOURCE did not change — the phase-106 date rules do
|
||||
# not apply: only the index-time and the context-carrying
|
||||
# fields move (``content_hash``/``title``/``image_path`` /
|
||||
# the creation date stay deliberately untouched).
|
||||
doc.image_context = combined
|
||||
doc.content = f"{combined}\n\n{description}"
|
||||
doc.indexed_at = datetime.now(UTC)
|
||||
chunks_n = await _replace_content_chunks(
|
||||
session, doc=doc, llm=llm, content=doc.content, rel=doc.path
|
||||
)
|
||||
session.commit()
|
||||
except EmbeddingError as e:
|
||||
session.rollback()
|
||||
# The description succeeded, but the embed gave up: the row
|
||||
# is untouched (rolled back), the copy stays, the next sync
|
||||
# retries it.
|
||||
summary.errors += 1
|
||||
logger.error(
|
||||
"import: image context backfill failed source=%s path=%s — %s",
|
||||
source, doc.path, e,
|
||||
)
|
||||
continue
|
||||
summary.image_context_backfilled += 1
|
||||
logger.info(
|
||||
"import: backfilled image context source=%s path=%s chunks=%d",
|
||||
source, doc.path, chunks_n,
|
||||
)
|
||||
await _store_summary(
|
||||
session, doc=doc, source=source, rel=doc.path,
|
||||
content=doc.content, llm=llm, summary=summary,
|
||||
)
|
||||
|
||||
|
||||
async def _describe_or_skip(
|
||||
llm: Embedder,
|
||||
*,
|
||||
@@ -2003,13 +2417,17 @@ async def _describe_or_skip(
|
||||
caller (and the patch) reads like the document being described;
|
||||
the failure's doc identity is logged by the caller's warning.
|
||||
|
||||
``context`` (phase 125, task 03) is the optional markdown reference
|
||||
context (:func:`_markdown_image_context`), forwarded verbatim to
|
||||
:func:`app.rag.summarizer.describe_image` — appended AFTER the
|
||||
locked describe prompt so the vision model can write a
|
||||
context-aware description (LOCKED A3: the description remains the
|
||||
primary content). ``None`` (standalone image) keeps the message
|
||||
byte-identical to pre-phase-125.
|
||||
``context`` (phase 125, task 03; generalized in phase 137, task
|
||||
02) is the optional document/reference context built by the
|
||||
importer — the markdown reference blocks
|
||||
(:func:`_markdown_image_context`) for a standalone image, the
|
||||
document block (:func:`_pdf_image_context_block`) + markdown blocks
|
||||
(:func:`_combine_image_contexts`) for a PDF-embedded image —
|
||||
forwarded verbatim to :func:`app.rag.summarizer.describe_image`
|
||||
— appended AFTER the locked describe prompt so the vision model can
|
||||
write a context-aware description (LOCKED A3: the description
|
||||
remains the primary content). ``None`` (a reference-free standalone
|
||||
image) keeps the message byte-identical to the pre-phase shape.
|
||||
"""
|
||||
mime = IMAGE_MIMES.get(full_path.suffix.lower(), IMAGE_FALLBACK_MIME)
|
||||
return await describe_image(llm, data=data, mime=mime, context=context)
|
||||
|
||||
+64
-15
@@ -10,6 +10,12 @@ package, imported as ``fitz`` — its stable API name).
|
||||
these markers so every chunk keeps its page anchor. Pages without
|
||||
text (image-only pages) contribute nothing, so a PDF with no
|
||||
extractable text returns ``""``.
|
||||
* :func:`extract_pdf_page_texts` — one entry per page that has text:
|
||||
1-indexed page number → the page's stripped text (the same page read
|
||||
as :func:`extract_pdf_text`, so the two agree page-for-page; the
|
||||
value for page N is exactly that page's separator-block body). The
|
||||
phase-137 image-context block is built from this map (page N's entry
|
||||
is the document context for an image embedded on page N).
|
||||
* :func:`extract_pdf_images` — one :class:`PdfImage` per embedded-image
|
||||
occurrence, in page order then 0-based index on the page. ``data`` is
|
||||
the image's original encoded bytes as stored in the PDF (no
|
||||
@@ -37,6 +43,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -117,6 +124,32 @@ def _page_image_entries(page: Any) -> list[dict[str, Any]]:
|
||||
return [dict(info) for info in infos]
|
||||
|
||||
|
||||
def _iter_page_texts(doc: Any, pdf_path: Path) -> Iterator[tuple[int, str]]:
|
||||
"""Yield ``(page, text)`` for every page of *doc* that has text.
|
||||
|
||||
``page`` is the 1-indexed page number and ``text`` the page's
|
||||
stripped text (the ``get_text("text")`` read — it always returns a
|
||||
str; the union return type comes from pymupdf's other flavors,
|
||||
dict/words/blocks). A page that cannot be read is logged and
|
||||
skipped; a page without text contributes nothing. The shared page
|
||||
iteration behind :func:`extract_pdf_text` and
|
||||
:func:`extract_pdf_page_texts` — the two callers share the exact
|
||||
same read and cannot drift.
|
||||
"""
|
||||
for page_num in range(len(doc)):
|
||||
try:
|
||||
raw = doc[page_num].get_text("text")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"pdf: could not read text of page %d of %s — %s",
|
||||
page_num + 1, pdf_path, exc,
|
||||
)
|
||||
continue
|
||||
text = raw.strip() if isinstance(raw, str) else ""
|
||||
if text:
|
||||
yield page_num + 1, text
|
||||
|
||||
|
||||
def extract_pdf_text(pdf_path: Path) -> str:
|
||||
"""Extract the text of all pages of *pdf_path*.
|
||||
|
||||
@@ -134,25 +167,41 @@ def extract_pdf_text(pdf_path: Path) -> str:
|
||||
logger.warning("pdf: could not open %s — %s", pdf_path, exc)
|
||||
return ""
|
||||
|
||||
parts: list[str] = []
|
||||
with doc:
|
||||
for page_num in range(len(doc)):
|
||||
try:
|
||||
raw = doc[page_num].get_text("text")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"pdf: could not read text of page %d of %s — %s",
|
||||
page_num + 1, pdf_path, exc,
|
||||
)
|
||||
continue
|
||||
# get_text("text") always returns a str; the union return type
|
||||
# comes from pymupdf's other flavors (dict/words/blocks).
|
||||
text = raw.strip() if isinstance(raw, str) else ""
|
||||
if text:
|
||||
parts.append(f"{PAGE_SEPARATOR.format(page=page_num + 1)}\n{text}")
|
||||
parts = [
|
||||
f"{PAGE_SEPARATOR.format(page=page)}\n{text}"
|
||||
for page, text in _iter_page_texts(doc, pdf_path)
|
||||
]
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def extract_pdf_page_texts(pdf_path: Path) -> dict[int, str]:
|
||||
"""One entry per page of *pdf_path* that has text.
|
||||
|
||||
Keys are the 1-indexed page numbers, values the page's stripped
|
||||
text — the shared :func:`_iter_page_texts` read, so this map agrees
|
||||
with :func:`extract_pdf_text` page-for-page: the value for page N
|
||||
is exactly the text after that page's ``---PAGE-N---`` separator.
|
||||
Pages without text (image-only pages) contribute nothing — no key.
|
||||
|
||||
The phase-137 document context for an embedded image is built from
|
||||
this map: one image on page N gets page N's text as the context
|
||||
block's body.
|
||||
|
||||
Fail-soft: an unreadable file returns ``{}`` (one warning — the
|
||||
:func:`extract_pdf_text` open contract); a page that cannot be
|
||||
read is logged and skipped.
|
||||
"""
|
||||
try:
|
||||
doc = fitz.open(str(pdf_path))
|
||||
except Exception as exc:
|
||||
logger.warning("pdf: could not open %s for page texts — %s", pdf_path, exc)
|
||||
return {}
|
||||
|
||||
with doc:
|
||||
return dict(_iter_page_texts(doc, pdf_path))
|
||||
|
||||
|
||||
#: A line "looks like XML" when it carries a tag-shaped token — the
|
||||
#: pre-126 garbage's signature (raw XMP blocks, PDF-structure noise).
|
||||
_XMLISH_LINE_RE = re.compile(r"</?[A-Za-z_][\w:.-]*[\s/>=]")
|
||||
|
||||
+117
-40
@@ -445,6 +445,22 @@ def build_kb_section(overview: str, max_chars: int | None = None) -> str:
|
||||
#: (graceful degradation, A4).
|
||||
_IMAGE_CTX_HEADER_RE = re.compile(r"^Referenced in (?P<ref>.+):$")
|
||||
|
||||
#: Phase 137 (task 04, ASSUMPTION A6) — the header line of the PDF
|
||||
#: provenance block of a PDF-embedded image doc's stored
|
||||
#: ``image_context`` (the exact shape the importer's document-block
|
||||
#: writer emits, phase 137 tasks 02/03 — written FIRST, before any
|
||||
#: markdown reference blocks, ASSUMPTION A2):
|
||||
#: ``Extracted from <source>/<pdf path> (page <N>):`` — ``loc`` is the
|
||||
#: ``<source>/<pdf path>`` locator (``.+`` greedy to the LAST
|
||||
#: `` (page N):`` — the pdf path may itself contain spaces); the line
|
||||
#: FOLLOWING the header is page-text body (or the next block's header)
|
||||
#: and is never part of the clause — the header line alone IS the
|
||||
#: clause (no bullet is required or expected). A look-alike line inside
|
||||
#: the page-text body emits a clause the same way — accepted: the
|
||||
#: stored value is the context the importer wrote, not free model text
|
||||
#: (the ``Referenced in`` twin IS guarded by its bullet requirement).
|
||||
_PDF_CTX_HEADER_RE = re.compile(r"^Extracted from (?P<loc>.+) \(page (?P<page>\d+)\):$")
|
||||
|
||||
#: The ``- Alt text: "..."`` bullet of the same block. The greedy alt
|
||||
#: field (the block writer does not escape quotes) is captured between
|
||||
#: the opening quote (the pattern's literal) and the CLOSING quote
|
||||
@@ -469,48 +485,96 @@ def _image_ctx_clause(ref: str, alt: str | None) -> str:
|
||||
|
||||
def _extract_context_line(image_context: str) -> str | None:
|
||||
"""The ONE-LINE rendering of an enriched image doc's stored
|
||||
``image_context`` (phase 125, task 05, LOCKED A4) — or ``None``
|
||||
when nothing in the value parses as a reference block (graceful
|
||||
degradation: the ``<document>`` block then shows only the
|
||||
description, exactly pre-phase — the value never raises into the
|
||||
prompt build).
|
||||
``image_context`` (phase 125, task 05, LOCKED A4; phase 137, task
|
||||
04, ASSUMPTION A6) — or ``None`` when nothing in the value parses
|
||||
as a reference block OR a provenance block (graceful degradation:
|
||||
the ``<document>`` block then shows only the description, exactly
|
||||
pre-phase — the value never raises into the prompt build).
|
||||
|
||||
The stored value (tasks 03/04) is one block per referring markdown
|
||||
file, blank-line joined::
|
||||
The stored value is blank-line-joined blocks of two kinds:
|
||||
|
||||
Referenced in <source>/<markdown_path>:
|
||||
- Alt text: "<alt_text>"
|
||||
Context: <context paragraphs — may span many lines>
|
||||
- Alt text: "<a second reference's alt>"
|
||||
* the phase-137 document (provenance) block (tasks 02/03) — written
|
||||
FIRST for a PDF-embedded image (ASSUMPTION A2: provenance before
|
||||
purpose); ONE header line, no bullet::
|
||||
|
||||
The result keeps only what tells the LLM WHY the image exists in
|
||||
the documentation — one clause per referring file (its identity +
|
||||
the FIRST reference's alt), after a single ``Referenced in ``
|
||||
prefix, ``; ``-joined::
|
||||
Extracted from <source>/<pdf path> (page <N>):
|
||||
<page text — never rendered: it already reached the vision
|
||||
model at describe time>
|
||||
|
||||
Referenced in docs/a.md: "alt a"; docs/b.md
|
||||
→ the clause ``From <source>/<pdf path> (page <N>)`` — the
|
||||
header line alone (the page-text body is skipped as context
|
||||
body; a spurious ``Extracted from … (page N):`` line inside the
|
||||
page text emits a clause the same way — it is the stored context
|
||||
the importer wrote, not free model text, accepted).
|
||||
|
||||
The ``Context:`` body is never rendered (the surrounding paragraphs
|
||||
already reached the vision model at enrichment time, LOCKED A3 —
|
||||
the line is the locator + purpose, not a duplicate of the
|
||||
paragraphs), so the line stays short enough not to bloat the
|
||||
prompt. The markdown doc's ``IMAGE_CONTEXT:`` pending record
|
||||
(task 04) carries no ``Referenced in`` header at all and parses to
|
||||
``None`` — and it rides a TEXT doc, which :func:`_document_body`
|
||||
excludes from the line via the ``is_image`` guard anyway.
|
||||
* the phase-125 reference block (tasks 03/04) — one per referring
|
||||
markdown file; the line FOLLOWING the header is always its first
|
||||
``- Alt text: "..."`` bullet (a look-alike header without its
|
||||
bullet is spurious and dropped)::
|
||||
|
||||
Referenced in <source>/<markdown_path>:
|
||||
- Alt text: "<alt_text>"
|
||||
Context: <context paragraphs — may span many lines>
|
||||
- Alt text: "<a second reference's alt>"
|
||||
|
||||
→ the clause ``Referenced in <ref>: "<alt>"`` (identity + the
|
||||
FIRST reference's alt; the ``Context:`` body is never rendered —
|
||||
LOCKED A3, the paragraphs already reached the vision model at
|
||||
enrichment time, so the line stays short enough not to bloat
|
||||
the prompt).
|
||||
|
||||
Clauses join with ``"; "`` IN STORED ORDER:
|
||||
|
||||
* BOTH kinds (the phase-137 combined shape — document block first,
|
||||
then the markdown blocks) render each clause with its own
|
||||
prefix::
|
||||
|
||||
From <source>/<pdf> (page N); Referenced in docs/a.md: "alt"
|
||||
|
||||
* a MARKDOWN-ONLY value keeps the phase-125 shape byte-identically
|
||||
(the regression pin — ONE ``Referenced in `` prefix for the
|
||||
joined referenced clauses)::
|
||||
|
||||
Referenced in docs/a.md: "alt a"; docs/b.md
|
||||
|
||||
A value with NO parsable header of either kind parses to ``None``
|
||||
(pre-phase rows, unparseable text). The markdown doc's
|
||||
``IMAGE_CONTEXT:`` pending record (phase 125, task 04) carries no
|
||||
parsable header of either kind and parses to ``None`` — and it
|
||||
rides a TEXT doc, which :func:`_document_body` excludes from the
|
||||
line via the ``is_image`` guard anyway.
|
||||
"""
|
||||
clauses: list[str] = []
|
||||
clauses: list[tuple[str, str]] = []
|
||||
# (kind, text), in stored order: a ``"pdf"`` clause's text already
|
||||
# carries its ``From …`` prefix; a ``"ref"`` clause's text is the
|
||||
# BARE ``<ref>: "<alt>"`` core — the prefix is applied at the join,
|
||||
# below (a markdown-only value keeps ONE prefix, byte-identically).
|
||||
ref: str | None = None # the current block's "source/markdown_path"
|
||||
alt: str | None = None # the current block's first bullet's alt text
|
||||
expecting_alt = False # the line after a header must be its bullet
|
||||
expecting_alt = False # the line after a "Referenced in" header must be its bullet
|
||||
for line in image_context.splitlines():
|
||||
header = _IMAGE_CTX_HEADER_RE.match(line)
|
||||
if header is not None:
|
||||
if ref is not None and not expecting_alt:
|
||||
clauses.append(_image_ctx_clause(ref, alt))
|
||||
clauses.append(("ref", _image_ctx_clause(ref, alt)))
|
||||
ref, alt = header.group("ref"), None
|
||||
expecting_alt = True
|
||||
continue
|
||||
pdf_header = _PDF_CTX_HEADER_RE.match(line)
|
||||
if pdf_header is not None:
|
||||
# A provenance header requires NO following bullet — the
|
||||
# header line alone is the clause (the next line is
|
||||
# page-text body or the next header; neither changes it).
|
||||
# A pending reference block's bullet never came → dropped
|
||||
# by the same spurious-header rule; a complete one is
|
||||
# kept, in stored order.
|
||||
if ref is not None and not expecting_alt:
|
||||
clauses.append(("ref", _image_ctx_clause(ref, alt)))
|
||||
clauses.append(
|
||||
("pdf", f"From {pdf_header.group('loc')} (page {pdf_header.group('page')})")
|
||||
)
|
||||
ref, alt, expecting_alt = None, None, False
|
||||
continue
|
||||
alt_match = _IMAGE_CTX_ALT_RE.match(line) if ref is not None else None
|
||||
if expecting_alt:
|
||||
if alt_match is None:
|
||||
@@ -521,16 +585,27 @@ def _extract_context_line(image_context: str) -> str | None:
|
||||
alt = alt_match.group("alt")
|
||||
expecting_alt = False
|
||||
continue
|
||||
# Any other line is context body or a second bullet of the
|
||||
# same block — the first reference's alt is already captured,
|
||||
# so the one-line rendering is unaffected (no branch needed:
|
||||
# with a live block and no pending bullet, ``alt`` is never
|
||||
# ``None``).
|
||||
# Any other line is context body — a provenance block's page
|
||||
# text, a reference block's Context: paragraphs, or a second
|
||||
# bullet of the same block — and the one-line rendering is
|
||||
# unaffected (no branch needed: with a live block and no
|
||||
# pending bullet, ``alt`` is never ``None``).
|
||||
if ref is not None and not expecting_alt:
|
||||
clauses.append(_image_ctx_clause(ref, alt))
|
||||
clauses.append(("ref", _image_ctx_clause(ref, alt)))
|
||||
if not clauses:
|
||||
return None
|
||||
return "Referenced in " + "; ".join(clauses)
|
||||
if all(kind == "ref" for kind, _ in clauses):
|
||||
# Markdown-only: the phase-125 shape, byte-identically (the
|
||||
# regression pin) — ONE ``Referenced in `` prefix for the
|
||||
# joined clauses.
|
||||
return "Referenced in " + "; ".join(text for _, text in clauses)
|
||||
# A provenance clause is present: every clause carries its own
|
||||
# prefix (the ``pdf`` ones already do; the referenced clauses gain
|
||||
# theirs) — the stored order (document block first, ASSUMPTION A2)
|
||||
# is the render order.
|
||||
return "; ".join(
|
||||
text if kind == "pdf" else f"Referenced in {text}" for kind, text in clauses
|
||||
)
|
||||
|
||||
|
||||
def _image_context_line(doc: Document) -> str | None:
|
||||
@@ -559,12 +634,14 @@ def _document_body(doc: Document) -> str:
|
||||
or under the cap rides whole, unmarked (nothing was cut). No LLM
|
||||
call at chat time — the preview is deterministic.
|
||||
|
||||
Phase 125 (task 05, LOCKED A4): an ENRICHED image doc (``is_image``
|
||||
with a non-NULL ``image_context`` that parses — see
|
||||
:func:`_image_context_line`) leads its body with the ONE short
|
||||
context line — ``Referenced in <source>/<path>: "<alt>"`` — before
|
||||
the description, so the LLM knows WHY the image exists in the
|
||||
documentation, not only what it shows. Every other doc is
|
||||
Phase 125 (task 05, LOCKED A4; phase 137, task 04, A6): an ENRICHED
|
||||
image doc (``is_image`` with a non-NULL ``image_context`` that
|
||||
parses — see :func:`_image_context_line`) leads its body with the
|
||||
ONE short context line — ``Referenced in <source>/<path>: "<alt>"``
|
||||
(the phase-125 reference shape) or ``From <source>/<pdf> (page N)``
|
||||
(the phase-137 provenance shape) — before the description, so the
|
||||
LLM knows WHY the image exists in the documentation, not only what
|
||||
it shows. Every other doc is
|
||||
byte-identical to the pre-phase body: every text doc (its
|
||||
``image_context`` may carry the task-04 pending record, which never
|
||||
renders) and every non-enriched image doc (NULL ``image_context``
|
||||
|
||||
+23
-10
@@ -227,16 +227,22 @@ async def describe_image(
|
||||
beyond the client's own (SDK-level + the house one-shot empty-
|
||||
content policy) — a description failure must not stall a sync.
|
||||
|
||||
*context* (phase 125, task 03, LOCKED A3): the optional markdown
|
||||
reference context — the alt text + surrounding paragraphs of the
|
||||
```` references that point at THIS image (the importer's
|
||||
same-source scan). When provided, it is appended to the text part
|
||||
AFTER the locked :data:`DESCRIBE_PROMPT` (the marker + instruction
|
||||
stay byte-identical — the ``IMAGE_DESCRIPTION_MODE`` mock key is
|
||||
untouched): the vision model is told WHAT THE IMAGE IS FOR so it
|
||||
*context* (phase 125, task 03, LOCKED A3; generalized in phase 137,
|
||||
task 02): the optional DOCUMENT/REFERENCE context built by the
|
||||
importer — for a PDF-embedded image, the
|
||||
``Extracted from <source>/<pdf> (page N):`` provenance block (the
|
||||
page's text, capped — WHERE the image came from) combined with any
|
||||
phase-125 markdown reference blocks (the ```` alt text
|
||||
+ surrounding paragraphs — WHAT it illustrates; document block
|
||||
first); for a standalone image, the markdown reference context
|
||||
alone (the importer's same-source scan). When provided, it is
|
||||
appended to the text part AFTER the locked :data:`DESCRIBE_PROMPT`
|
||||
(the marker + instruction stay byte-identical — the
|
||||
``IMAGE_DESCRIPTION_MODE`` mock key is untouched): the vision model
|
||||
learns where the image comes from AND what it is used for, so it
|
||||
can write a context-aware description. ``context=None`` (the
|
||||
default — standalone images) sends the bare prompt: the message is
|
||||
byte-identical to the pre-phase-125 shape.
|
||||
default — a reference-free standalone image) sends the bare prompt:
|
||||
the message is byte-identical to the pre-phase shape.
|
||||
|
||||
Returns the stripped reply cut exactly at
|
||||
``(settings or llm.settings).summary_max_chars`` (the phase-30 cap —
|
||||
@@ -248,8 +254,15 @@ async def describe_image(
|
||||
continues (LOCKED A3).
|
||||
"""
|
||||
data_url = image_data_url(data, mime)
|
||||
# Phase 137 (task 02, ASSUMPTION A5): the prefix wording is neutral
|
||||
# — it covers BOTH block kinds (the provenance's ``Extracted from
|
||||
# …`` document context and the phase-125 ``Referenced in …``
|
||||
# markdown references). ``DESCRIBE_PROMPT`` (the marker + the locked
|
||||
# instruction) stays byte-identical; ``context=None`` sends the bare
|
||||
# prompt (byte-identical to the pre-phase-137 message).
|
||||
context_prefix = (
|
||||
f"\n\nThis image is referenced in documentation with the following context:\n{context}"
|
||||
"\n\nThis image is part of a document. The following context "
|
||||
f"describes where it comes from and what it is used for:\n{context}"
|
||||
if context
|
||||
else ""
|
||||
)
|
||||
|
||||
+61
-19
@@ -523,15 +523,22 @@ Implements just enough of the aipi surface:
|
||||
deterministic ZERO-READ answer that QUOTES THE CONTEXT LINE of the
|
||||
seeded image doc's ``<document>`` block (the same image-doc pick
|
||||
as ``DIAGRAM_TRIGGER``): an ENRICHED image doc's block (phase 125
|
||||
task 05, LOCKED A4) leads with the one-line
|
||||
``Referenced in <source>/<path>: "<alt>"`` context line BEFORE
|
||||
the description, so the mock's quote — the block's exact first
|
||||
line — proves the line WAS in the prompt (and that its text is
|
||||
the task-05 one-line rendering, not the stored multi-line block).
|
||||
task 05, LOCKED A4; phase 137 task 04) leads with the one-line
|
||||
context line BEFORE the description — the
|
||||
``Referenced in <source>/<path>: "<alt>"`` reference rendering
|
||||
(phase 125) or the ``From <source>/<pdf> (page N)`` provenance
|
||||
rendering (phase 137 — a PDF-embedded image's block, a combined
|
||||
context joining both with ``; `` in stored order) — so the
|
||||
mock's quote — the block's exact first line — proves the line
|
||||
WAS in the prompt (and that its text is the one-line rendering,
|
||||
not the stored multi-line block).
|
||||
A non-enriched (standalone) image doc's block is the bare
|
||||
description (byte-identical pre-phase): its first line never
|
||||
starts with ``Referenced in `` (the mock's fixed description
|
||||
cannot begin that way), so the mock answers that the image
|
||||
starts with ``Referenced in `` or ``From `` (the mock's fixed
|
||||
description cannot begin that way — the context-aware reply
|
||||
does, but only ever rides a doc whose stored context makes the
|
||||
``From …`` line the block's first line), so the mock answers
|
||||
that the image
|
||||
carries no reference line — the byte-identity criterion's
|
||||
deterministic witness. Checked BEFORE the ``DEFLECT_MODE``
|
||||
branch (the marker lives in the user message — the
|
||||
@@ -914,6 +921,21 @@ IMAGE_DESCRIPTION_ANSWER = (
|
||||
"cable runs. Title: Homelab Network Map."
|
||||
)
|
||||
|
||||
#: The phase-137 witness — the mock's vision reply when the
|
||||
#: ``IMAGE_DESCRIPTION_MODE`` message's text part carries the
|
||||
#: document-context block (its ``Extracted from …`` header). It
|
||||
#: names the document + page (the context reached the model — the
|
||||
#: deterministic proof) and keeps the diagram wording of
|
||||
#: :data:`IMAGE_DESCRIPTION_ANSWER` so the story question's
|
||||
#: token-overlap retrieval still grounds on the image doc.
|
||||
IMAGE_DESCRIPTION_CONTEXT_ANSWER = (
|
||||
"From report.pdf, page 2: a network diagram of the homelab "
|
||||
"server room: a core router on top, a core switch in the "
|
||||
"middle, and three labeled subnets at the bottom — VLAN 10 "
|
||||
"office, VLAN 20 lab, and VLAN 30 storage — with a legend of "
|
||||
"cable runs. Title: Homelab Network Map."
|
||||
)
|
||||
|
||||
TABLE_ANSWER = (
|
||||
"Here's the shape, in a table:\n"
|
||||
"\n"
|
||||
@@ -1966,21 +1988,29 @@ def _seeded_image_doc(system: str) -> tuple[str, str, str] | None:
|
||||
|
||||
|
||||
def _seed_context_line(block_content: str) -> str | None:
|
||||
"""The phase-125 context line of a seeded image doc's block
|
||||
(task 06, LOCKED A4) — or ``None``.
|
||||
"""The context line of a seeded image doc's block (phase 125
|
||||
task 06, LOCKED A4; phase 137 task 04/05) — or ``None``.
|
||||
|
||||
An ENRICHED image doc's ``<document>`` block leads with the
|
||||
one-line ``Referenced in …`` rendering (the task-05
|
||||
one-line context rendering (the task-05/task-04
|
||||
:func:`app.rag.prompts._extract_context_line` output, BEFORE the
|
||||
description): its first line, verbatim, is the line the mock
|
||||
quotes (the prompt-injection echo convention). A non-enriched
|
||||
block is the bare description — its first line never starts with
|
||||
``Referenced in `` (the mock's fixed
|
||||
:data:`IMAGE_DESCRIPTION_ANSWER` cannot begin that way), so
|
||||
``None`` pins the byte-identical pre-phase shape.
|
||||
description): the one-line ``Referenced in …`` reference shape
|
||||
(phase 125) or the one-line ``From <source>/<pdf> (page N)``
|
||||
provenance shape (phase 137 — a PDF-embedded image, whose block
|
||||
leads with the document-context clause, a combined context
|
||||
joining both with ``; ``). Its first line, verbatim, is the line
|
||||
the mock quotes (the prompt-injection echo convention). A
|
||||
non-enriched block is the bare description — its first line never
|
||||
starts with ``Referenced in `` or ``From `` (the mock's fixed
|
||||
:data:`IMAGE_DESCRIPTION_ANSWER` begins "A network diagram" — the
|
||||
context-aware :data:`IMAGE_DESCRIPTION_CONTEXT_ANSWER` begins
|
||||
"From " but only ever rides a doc whose stored context makes the
|
||||
``From …`` CONTEXT LINE the block's first line, so the bare
|
||||
description is never the first line for it), so ``None`` pins the
|
||||
byte-identical pre-phase shape.
|
||||
"""
|
||||
first = block_content.split("\n", 1)[0].strip()
|
||||
if first.startswith("Referenced in "):
|
||||
if first.startswith("Referenced in ") or first.startswith("From "):
|
||||
return first
|
||||
return None
|
||||
|
||||
@@ -2661,8 +2691,20 @@ def compose_answer(body: dict[str, Any]) -> str:
|
||||
# the marker is a fixed app constant, and a real question is
|
||||
# never expected to type it (the other user triggers are owner
|
||||
# phrasings a question might legitimately contain — this one
|
||||
# is an app-to-app marker).
|
||||
answer = IMAGE_DESCRIPTION_ANSWER
|
||||
# is an app-to-app marker). Phase 137 (task 05): the
|
||||
# document-context trigger — when the text part carries the
|
||||
# provenance header (``Extracted from `` — the importer's
|
||||
# block builder, tasks 02/03), the canned reply becomes the
|
||||
# context-aware :data:`IMAGE_DESCRIPTION_CONTEXT_ANSWER`
|
||||
# (the deterministic witness that the context REACHED the
|
||||
# vision model); the bare path (standalone images, the
|
||||
# pre-137 embedded shape) stays on the bare
|
||||
# :data:`IMAGE_DESCRIPTION_ANSWER` — byte-identical.
|
||||
answer = (
|
||||
IMAGE_DESCRIPTION_CONTEXT_ANSWER
|
||||
if "Extracted from " in user
|
||||
else IMAGE_DESCRIPTION_ANSWER
|
||||
)
|
||||
elif TABLE_TRIGGER in user.lower():
|
||||
# Markdown tables (phase 44, TODO.md L6): the story E2E's
|
||||
# deterministic table answer — a 3-column table, the
|
||||
|
||||
@@ -41,10 +41,15 @@ do).
|
||||
|
||||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the fallback's
|
||||
vision surface is the mock's deterministic ``IMAGE_DESCRIPTION_MODE``
|
||||
canned reply (``IMAGE_DESCRIPTION_ANSWER``, the same reply the
|
||||
phase-122 image docs get), which makes the fallback's stored content a
|
||||
PURE FUNCTION of the fixture — the byte-exact content assertions below
|
||||
are exact only against that canned text.
|
||||
canned reply: the BARE ``IMAGE_DESCRIPTION_ANSWER`` for the page
|
||||
screenshots (the fallback's describe calls carry no context —
|
||||
byte-identical to pre-phase-137) and the CONTEXT-AWARE
|
||||
``IMAGE_DESCRIPTION_CONTEXT_ANSWER`` for the fallback PDF's EMBEDDED
|
||||
images (phase 137 task 05 — their describe calls carry the
|
||||
``Extracted from `` provenance block, so the mock's trigger fires),
|
||||
which makes the fallback's stored content a PURE FUNCTION of the
|
||||
fixture — the byte-exact content assertions below are exact only
|
||||
against those canned texts.
|
||||
|
||||
Fixture provenance:
|
||||
* ``hnb/`` — the REAL guide: ``test_sources/hnb_2026_guide.tar.gz``
|
||||
@@ -120,7 +125,7 @@ from app.db import SessionLocal
|
||||
from app.models import Document
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER
|
||||
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER, IMAGE_DESCRIPTION_CONTEXT_ANSWER
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -643,15 +648,26 @@ def test_garbage_pdf_takes_the_screenshot_path(
|
||||
assert "<illustrator" not in content
|
||||
|
||||
# The fallback's embedded-image leg: both extracted images (one per
|
||||
# page, the 1×1 PNG — ``#page=N#img=0`` paths) indexed with the
|
||||
# canned description (the phase-122 image convention).
|
||||
# page, the 1×1 PNG — ``#page=N#img=0`` paths) indexed — and phase
|
||||
# 137 (task 02, A7) gives each the ORIGIN-LINE-ONLY document
|
||||
# context: the garbage text layer failed the gate, so the "page
|
||||
# text" is dropped (the soup never reaches the embedder), while the
|
||||
# provenance line still names the document and the page.
|
||||
imgs = _image_docs(GARBAGE_SOURCE)
|
||||
assert [i.path for i in imgs] == [
|
||||
f"{GARBAGE_PDF}#page=1#img=0.png",
|
||||
f"{GARBAGE_PDF}#page=2#img=0.png",
|
||||
]
|
||||
for img in imgs:
|
||||
assert img.content == IMAGE_DESCRIPTION_ANSWER
|
||||
page = int(img.path.split("#page=")[1].split("#")[0])
|
||||
origin = f"Extracted from {GARBAGE_SOURCE}/{GARBAGE_PDF} (page {page}):"
|
||||
assert img.image_context == origin
|
||||
# Phase 137 task 05: the origin-line context still names the
|
||||
# document (the mock's ``Extracted from `` trigger fires on the
|
||||
# header alone — the page text was dropped by the gate), so the
|
||||
# canned reply is the CONTEXT-AWARE one, not the bare pre-137
|
||||
# answer.
|
||||
assert img.content == f"{origin}\n\n{IMAGE_DESCRIPTION_CONTEXT_ANSWER}"
|
||||
assert img.summary == img.content # the image convention
|
||||
|
||||
# The sync's ``detail`` is the UNCHANGED shape: the garbage PDF is
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
"""Phase 137 E2E (Playwright): PDF-embedded images carry the document
|
||||
context in their summaries — the user path (TODO L3, end to end).
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_pdf_image_context.py -v --no-cov
|
||||
|
||||
TODO L3: "Images extracted from PDFs still don't have any relevant
|
||||
context in their summaries … Image summaries should always include the
|
||||
document context if the images come from a document (this includes
|
||||
markdown files) so the embedder can more effectively retrieve image
|
||||
sources."
|
||||
|
||||
The suite's single module app runs with ``BOR_IMAGES=true`` + the
|
||||
``pdf`` import token (the ``test_pdf_support.py`` per-suite-app
|
||||
pattern — the conftest env with the leak guards):
|
||||
|
||||
* **Seed 1** (source ``e2e-pdf-imgctx``): ONE fixture PDF at the
|
||||
source root (two text pages + one embedded 1×1 PNG on page 2 — the
|
||||
phase-126 PyMuPDF recipe, built in the fixture) arrives through the
|
||||
REAL admin upload flow + the RAG page's Sync button. The forward
|
||||
path (phase 137 task 02) is the story: the embedded image's
|
||||
describe call carries the document-context block (``Extracted from
|
||||
e2e-pdf-imgctx/report.pdf (page 2):`` + the page's text), so the
|
||||
mock's vision model answers the CONTEXT-AWARE canned reply (the
|
||||
mock's ``Extracted from `` trigger — the deterministic witness the
|
||||
context REACHED the vision model), and the stored
|
||||
``image_context``/``content``/``summary`` carry it;
|
||||
* **the chat display line** (task 04): a grounded question carrying
|
||||
the mock's ``CONTEXT_LINE_TRIGGER`` marker makes the mock QUOTE THE
|
||||
EXACT LINE of the image doc's ``<document>`` block — which now
|
||||
LEADS with the one-line ``From <source>/report.pdf (page 2)``
|
||||
rendering — so the answer proves the display line was in the
|
||||
prompt;
|
||||
* **Seed 2** (source ``e2e-pdf-imgctx-md`` — a FRESH KB: the phase
|
||||
re-imports the same PDF plus a ``notes.md`` referencing the
|
||||
embedded image by its synthetic path, the phase-125 reference
|
||||
shape): the stored context carries BOTH block kinds with the
|
||||
document block FIRST (ASSUMPTION A2 — provenance before purpose),
|
||||
and the display line carries both clauses in stored order.
|
||||
|
||||
Test-order note: the standalone-parity witness asserts against SEED
|
||||
1's KB (exactly the PDF + its one image — the suite seeds no
|
||||
standalone image anywhere), so it runs BEFORE seed 2 truncates and
|
||||
re-imports; the mock's BARE describe branch is pinned by
|
||||
``test_pdf_support.py`` running green unedited (the regression run —
|
||||
not duplicated here).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import pymupdf as fitz # PyMuPDF — the fixture-PDF builder (the phase-126 recipe)
|
||||
import pytest
|
||||
from playwright.sync_api import Browser, Page, expect
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import Document
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER, IMAGE_DESCRIPTION_CONTEXT_ANSWER
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures + markers (defined LOCALLY — the suites must stay
|
||||
# standalone; the shapes mirror test_pdf_support's, the values are
|
||||
# this suite's own).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SOURCE = "e2e-pdf-imgctx" # archive 1's stem (archive_source_name)
|
||||
SOURCE_MD = "e2e-pdf-imgctx-md" # archive 2's stem
|
||||
DOC_PATH = "report.pdf"
|
||||
IMAGE_REL = "report.pdf#page=2#img=0.png" # the synthetic embedded-image path
|
||||
MD_PATH = "notes.md"
|
||||
PDF_TITLE = "Homelab Report" # the metadata title
|
||||
|
||||
#: The fixture PDF's page texts. ``PAGE_TWO`` (the embedded image's
|
||||
#: page) carries the document text that must reach the image's
|
||||
#: ``image_context``; the anchor question's tokens overlap these, so
|
||||
#: the mock's token-overlap gate grounds on the image doc (and FTS
|
||||
#: corroborates).
|
||||
PAGE_ONE = "The quarterly homelab report covers the core switch firmware upgrade."
|
||||
PAGE_TWO = "The homelab backup schedule runs nightly at 02:00 and keeps seven rotations."
|
||||
|
||||
#: A real 1×1 transparent PNG (the house fixture — the pipeline is
|
||||
#: content-agnostic; the well-formed bytes keep the extraction + copy
|
||||
#: path honest).
|
||||
PNG_1X1 = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
||||
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
# Seed 1's stored document block (NO markdown reference exists in that
|
||||
# source — the doc block alone: the provenance's origin line + the
|
||||
# page's text).
|
||||
DOC_BLOCK = f"Extracted from {SOURCE}/{DOC_PATH} (page 2):\n{PAGE_TWO}"
|
||||
|
||||
#: The task-04 ONE-LINE rendering of :data:`DOC_BLOCK` — the display
|
||||
#: line that leads the image doc's ``<document>`` block (test 2).
|
||||
FROM_LINE = f"From {SOURCE}/{DOC_PATH} (page 2)"
|
||||
FROM_LINE_MD = f"From {SOURCE_MD}/{DOC_PATH} (page 2)"
|
||||
|
||||
#: Archive 2's markdown — the phase-125 reference shape: the embedded
|
||||
#: image referenced by its SYNTHETIC path (the doc's ``path``), alt
|
||||
#: "network diagram", one paragraph before + one after (the default
|
||||
#: two-paragraph window).
|
||||
NOTES_MD = (
|
||||
"# Homelab notes\n"
|
||||
"\n"
|
||||
"The network diagram in the report shows the server room layout.\n"
|
||||
"\n"
|
||||
f"\n"
|
||||
"\n"
|
||||
"Keep the diagram in sync with the VLAN plan.\n"
|
||||
)
|
||||
MD_BLOCK_HEADER = f"Referenced in {SOURCE_MD}/{MD_PATH}:"
|
||||
|
||||
#: The grounded question — the mock's ``CONTEXT_LINE_TRIGGER`` marker
|
||||
#: (the zero-read context-line echo — the mock quotes the image doc's
|
||||
#: block's exact first line) + the phase-122/125 grounding phrase
|
||||
#: (the diagram wording is in :data:`IMAGE_DESCRIPTION_CONTEXT_ANSWER`,
|
||||
#: so the token-overlap cosine clears the mock-calibrated gate 0.30;
|
||||
#: FTS corroborates).
|
||||
ANCHOR_QUESTION = (
|
||||
"quote the image reference — what is shown in the homelab network diagram?"
|
||||
)
|
||||
|
||||
#: The honest-deflection opening (the gate must NOT fire on the marker
|
||||
#: question — a deflected prompt carries no ``<documents>`` block, so
|
||||
#: the mock would never see the image doc).
|
||||
DEFLECT_OPENING = "I haven't done anything like that"
|
||||
|
||||
UPLOAD_TIMEOUT_MS = 30_000
|
||||
SYNC_TIMEOUT_MS = 60_000
|
||||
SYNCED_LABEL = re.compile(r"^Synced \d{1,2}:\d{2}$")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module app: images ON — the conftest env pattern with the phase-126
|
||||
# knobs + leak guards (the conftest session app is never started in
|
||||
# this isolated run, so no port clash).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _app_env(mock_llm: int, app_port: int, scratch: Path) -> dict[str, str]:
|
||||
"""The conftest app env (leak guards included) with the phase-126
|
||||
knobs: ``BOR_IMPORT_EXTENSIONS`` pinned to a set carrying the
|
||||
``pdf`` token (the feature is gated by the token — an operator's
|
||||
local gitignored ``.env`` cannot leak the scope in either
|
||||
direction), ``BOR_IMAGES`` pinned EXPLICITLY true (the story),
|
||||
and the image/upload homes in the suite's scratch dir (the app
|
||||
under test must not write copies into the owner's real
|
||||
``~/bor-sources``)."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_LLM_BASE_URL"] = (
|
||||
"https://aipi.reeseapps.com/v1"
|
||||
if USE_REAL_LLM
|
||||
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||
)
|
||||
# Mock-calibrated gate (the test_image_anchor_context values) —
|
||||
# the anchor question grounds on these (see ANCHOR_QUESTION).
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
|
||||
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
|
||||
# Phase 67: instant retry waits + the code-default budget.
|
||||
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
|
||||
env.setdefault(
|
||||
"BOR_DATABASE_URL",
|
||||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||
)
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||
# Leak guards (conftest pattern).
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
env["BOR_DOCS_REPO"] = ""
|
||||
env["BOR_SUGGESTIONS"] = json.dumps(
|
||||
Settings.model_fields["suggestions"].default
|
||||
)
|
||||
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
|
||||
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
|
||||
# The import scope WITH the ``pdf`` token + the toggle under test
|
||||
# + the suite-private homes.
|
||||
env["BOR_IMPORT_EXTENSIONS"] = "md,markdown,txt,pdf"
|
||||
env["BOR_IMAGES"] = "true"
|
||||
env["BOR_UPLOAD_DIR"] = str(scratch / "uploads")
|
||||
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
|
||||
env["BOR_IMAGE_DIR"] = str(scratch / "images")
|
||||
return env
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(mock_llm: int, tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
|
||||
"""The module app — ``BOR_IMAGES=true`` + the ``pdf`` token (the
|
||||
module env override)."""
|
||||
scratch = tmp_path_factory.mktemp("bor_pdf_imgctx")
|
||||
port = int(os.environ.get("E2E_APP_PORT_PDF_IMG_CTX", "8154"))
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=_app_env(mock_llm, port, scratch),
|
||||
)
|
||||
try:
|
||||
_wait_http(f"http://127.0.0.1:{port}/api/health")
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture archives (built in tmp dirs — ``test_sources/`` is never
|
||||
# touched, the house rule)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_pdf(root: Path) -> Path:
|
||||
"""The fixture PDF (the phase-126 recipe, built LOCALLY): two
|
||||
text pages + one embedded 1×1 PNG on page 2 + the metadata
|
||||
title."""
|
||||
pdf_path = root / DOC_PATH
|
||||
doc = fitz.open()
|
||||
doc.new_page().insert_text((72, 72), PAGE_ONE)
|
||||
page2 = doc.new_page()
|
||||
page2.insert_text((72, 72), PAGE_TWO)
|
||||
page2.insert_image(fitz.Rect(72, 150, 73, 151), stream=PNG_1X1)
|
||||
doc.set_metadata({"title": PDF_TITLE})
|
||||
doc.save(pdf_path)
|
||||
doc.close()
|
||||
return pdf_path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def zip_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""Seed 1's archive: ONE PDF at the root — source
|
||||
``e2e-pdf-imgctx``, document path ``report.pdf``."""
|
||||
root = tmp_path_factory.mktemp("bor_pdfimgctx_zip")
|
||||
pdf_path = _build_pdf(root)
|
||||
archive = root / f"{SOURCE}.zip"
|
||||
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.write(pdf_path, arcname=DOC_PATH)
|
||||
return archive
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def zip_md_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""Seed 2's archive: the SAME PDF + ``notes.md`` at the root
|
||||
(the markdown references the embedded image by its synthetic
|
||||
path, with a paragraph of context before and after) — source
|
||||
``e2e-pdf-imgctx-md``."""
|
||||
root = tmp_path_factory.mktemp("bor_pdfimgctx_md_zip")
|
||||
pdf_path = _build_pdf(root)
|
||||
(root / MD_PATH).write_text(NOTES_MD, encoding="utf-8")
|
||||
archive = root / f"{SOURCE_MD}.zip"
|
||||
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.write(pdf_path, arcname=DOC_PATH)
|
||||
zf.write(root / MD_PATH, arcname=MD_PATH)
|
||||
return archive
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB + UI helpers (the test_pdf_support pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _truncate_all() -> None:
|
||||
"""Fresh KB + registry per seed (the E2E isolation pattern): the
|
||||
suites share one Postgres, and a leftover row would corrupt the
|
||||
counts. ``sources_meta`` (the KB generation counter) resets with
|
||||
the KB — the sync re-creates the row."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text(
|
||||
"TRUNCATE chunks, documents, query_log, kb_overview, "
|
||||
"git_sources, sources_meta, folder_summaries"
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _health_db(app_url: str) -> bool:
|
||||
try:
|
||||
return httpx.get(f"{app_url}/api/health", timeout=5).json()["db"] == "up"
|
||||
except Exception: # noqa: BLE001 — unreachable is the skip case
|
||||
return False
|
||||
|
||||
|
||||
def _upload_zip(page: Page, app_url: str, archive: Path, source: str) -> None:
|
||||
"""The real admin upload flow: form login → the git-sources page →
|
||||
pick the archive → submit → the terminal result line (phase 90:
|
||||
"Uploaded <source> — press Sync sources to import it.")."""
|
||||
login(page, app_url, next="/git-sources.html")
|
||||
expect(page).to_have_url(app_url + "/git-sources.html", timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
page.set_input_files("#archive-upload-file", str(archive))
|
||||
page.click("#archive-upload-btn")
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(result).to_have_text(
|
||||
f"Uploaded {source} — press Sync sources to import it.",
|
||||
timeout=UPLOAD_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
|
||||
def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
|
||||
"""The RAG page's "Sync sources" button (the scan): click →
|
||||
"Syncing…" → the terminal "Synced HH:MM" label + the fresh counts
|
||||
in #sync-result (the never-stale contract)."""
|
||||
page.goto(app_url + "/sources.html")
|
||||
btn = page.locator("#sync-btn")
|
||||
expect(btn).to_be_visible(timeout=30_000)
|
||||
expect(page.locator("#sync-label")).to_have_text(
|
||||
re.compile(r"^Sync sources$|^Synced \d{1,2}:\d{2}$")
|
||||
)
|
||||
btn.click()
|
||||
expect(btn).to_be_disabled()
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(page.locator("#sync-result")).to_have_text(expected_result)
|
||||
|
||||
|
||||
def _ask(page: Page, app_url: str, question: str) -> None:
|
||||
"""The chat user path: login → the question → Send → the final
|
||||
brain bubble (the Send button re-enabled = the turn is over)."""
|
||||
login(page, app_url, next="/")
|
||||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||
page.fill("#message-input", question)
|
||||
page.click("#send-btn")
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
expect(page.locator("#send-btn")).to_be_enabled(timeout=60_000)
|
||||
expect(page.locator("#send-label")).to_have_text("Send")
|
||||
expect(bubble).to_contain_text("Deterministic mock answer", timeout=30_000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The module seeds: upload + sync the fixture archives through the
|
||||
# real UI, once each (the story E2E truncate/re-import-per-module
|
||||
# pattern).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def seeded(
|
||||
app_url: str,
|
||||
browser: Browser,
|
||||
zip_path: Path,
|
||||
) -> Iterator[dict[str, UUID]]:
|
||||
"""Seed 1: upload the single-PDF zip through the admin UI, then
|
||||
scan it with the RAG page's Sync button (the mock's vision model
|
||||
describes the embedded image in-process, WITH the document
|
||||
context). The module app's ``/api/config`` flag is asserted first
|
||||
— the env override must have reached the app under test, or the
|
||||
whole premise of the suite is void."""
|
||||
if not _health_db(app_url):
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
cfg = httpx.get(f"{app_url}/api/config", timeout=5).json()
|
||||
assert cfg["images"] is True, "the module app must run with BOR_IMAGES=true"
|
||||
|
||||
_truncate_all()
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||||
try:
|
||||
_upload_zip(page, app_url, zip_path, SOURCE)
|
||||
# The PDF + its one embedded image (phase 126 A3): both added.
|
||||
_sync_via_ui(page, app_url, expected_result="2 added")
|
||||
with SessionLocal() as db:
|
||||
img = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == SOURCE, Document.path == IMAGE_REL
|
||||
)
|
||||
)
|
||||
assert img is not None, "the embedded image must become an image doc"
|
||||
finally:
|
||||
page.close()
|
||||
yield {"image_id": img.id}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def seeded_md(
|
||||
app_url: str,
|
||||
browser: Browser,
|
||||
zip_md_path: Path,
|
||||
) -> Iterator[dict[str, UUID]]:
|
||||
"""Seed 2: a FRESH KB (seed 1's rows are truncated — the phase
|
||||
re-imports through the SAME UI) seeded with archive 2: the same
|
||||
PDF plus the markdown that references its embedded image by the
|
||||
synthetic path (the combined-context case)."""
|
||||
_truncate_all()
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||||
try:
|
||||
_upload_zip(page, app_url, zip_md_path, SOURCE_MD)
|
||||
# The PDF + its one embedded image + the markdown doc: three added.
|
||||
_sync_via_ui(page, app_url, expected_result="3 added")
|
||||
with SessionLocal() as db:
|
||||
img = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == SOURCE_MD, Document.path == IMAGE_REL
|
||||
)
|
||||
)
|
||||
assert img is not None, "the embedded image must become an image doc"
|
||||
finally:
|
||||
page.close()
|
||||
yield {"image_id": img.id}
|
||||
_truncate_all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The forward path (the TODO's core complaint): the embedded image's
|
||||
# stored context + summary carry the document context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_embedded_image_doc_stores_the_document_context(
|
||||
app_url: str, seeded: dict[str, UUID]
|
||||
) -> None:
|
||||
"""The owner's complaint, gone, on the ROW: the embedded image's
|
||||
``image_context`` leads with the provenance's origin line (the
|
||||
document it came from — source + PDF path + page) and carries the
|
||||
page's own text; its ``content`` is ``<context>\\n\\n<description>``
|
||||
where the description is the mock's CONTEXT-AWARE canned reply
|
||||
(the ``Extracted from `` trigger — the deterministic witness the
|
||||
describe call carried the context; the bare pre-137 reply must
|
||||
NOT appear), and ``summary`` === ``content`` (the image
|
||||
convention)."""
|
||||
with SessionLocal() as db:
|
||||
img = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == SOURCE, Document.path == IMAGE_REL
|
||||
)
|
||||
)
|
||||
assert img is not None, "the embedded image must be a document"
|
||||
assert img.is_image is True and img.image_path is not None
|
||||
assert img.image_context is not None
|
||||
# The origin line is ALWAYS present (even for a text-less page)
|
||||
# — here with the page's text on the following line(s).
|
||||
assert img.image_context.startswith(
|
||||
f"Extracted from {SOURCE}/{DOC_PATH} (page 2):\n"
|
||||
)
|
||||
assert PAGE_TWO in img.image_context
|
||||
# No markdown references this image in seed 1 — the doc block
|
||||
# alone (the exact value).
|
||||
assert img.image_context == DOC_BLOCK
|
||||
# The context-aware reply is the witness the context reached
|
||||
# the vision model (the diagram wording is kept, so retrieval
|
||||
# still grounds — but it NAMES the document + page).
|
||||
assert img.content == f"{DOC_BLOCK}\n\n{IMAGE_DESCRIPTION_CONTEXT_ANSWER}"
|
||||
assert img.summary == img.content
|
||||
assert IMAGE_DESCRIPTION_ANSWER not in img.content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Chat: the suggestion block for the image leads with the one-line
|
||||
# From … (page N) display line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_suggestion_leads_with_the_from_line(
|
||||
page: Page, app_url: str, seeded: dict[str, UUID]
|
||||
) -> None:
|
||||
"""The mock's ``CONTEXT_LINE_TRIGGER`` (the phase-125 marker — no
|
||||
new display mock branch): the ZERO-READ echo QUOTES THE EXACT
|
||||
LINE of the image doc's ``<document>`` block — which now LEADS
|
||||
with the one-line ``From <source>/report.pdf (page 2)`` rendering
|
||||
(phase 137 task 04) BEFORE the description. The quoted line in
|
||||
the answer proves the display line was in the prompt; the
|
||||
standalone witness ("stands alone") is absent — the block is NOT
|
||||
the bare description."""
|
||||
_ask(page, app_url, ANCHOR_QUESTION)
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
expect(bubble).to_contain_text(FROM_LINE, timeout=30_000)
|
||||
expect(bubble).not_to_contain_text("stands alone")
|
||||
# Grounded (not deflected) — a deflected prompt would carry no
|
||||
# <documents> block at all (the mock would never see the image
|
||||
# doc, and would answer with the deflection opening).
|
||||
expect(bubble).not_to_contain_text(DEFLECT_OPENING)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Standalone parity witness (against seed 1's KB — it must run
|
||||
# BEFORE seed 2 truncates and re-imports)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_kb_is_exactly_the_pdf_and_its_image(
|
||||
app_url: str, seeded: dict[str, UUID]
|
||||
) -> None:
|
||||
"""Seed 1's KB is EXACTLY the PDF + its one embedded image — the
|
||||
suite seeds no standalone image anywhere, so the context-aware
|
||||
reply fired for the EMBEDDED image's describe call alone (no
|
||||
standalone image in this KB to witness the bare branch here). The
|
||||
bare branch's byte-identity is pinned by ``test_pdf_support.py``
|
||||
running green unedited (the regression run) — not duplicated."""
|
||||
with SessionLocal() as db:
|
||||
docs = db.scalars(select(Document)).all()
|
||||
assert len(docs) == 2, "seed 1's KB is exactly the PDF + its image"
|
||||
image_docs = [d for d in docs if d.is_image]
|
||||
assert len(image_docs) == 1
|
||||
assert image_docs[0].source == SOURCE
|
||||
assert image_docs[0].path == IMAGE_REL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Seed 2: a markdown reference COMBINES with the document context —
|
||||
# the document block first (ASSUMPTION A2), both clauses on the
|
||||
# display line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_markdown_reference_combines_the_document_context_first(
|
||||
page: Page, app_url: str, seeded_md: dict[str, UUID]
|
||||
) -> None:
|
||||
"""The same-source markdown references the embedded image by its
|
||||
synthetic path (the phase-125 reference shape) → the stored
|
||||
context carries BOTH block kinds: the document block (the
|
||||
provenance's origin line + the page text) FIRST, then the
|
||||
``Referenced in e2e-pdf-imgctx-md/notes.md:`` block (provenance
|
||||
before purpose — ASSUMPTION A2), and the display line carries
|
||||
both clauses in stored order (the mock quotes the block's exact
|
||||
first line — the task-04 combined rendering)."""
|
||||
with SessionLocal() as db:
|
||||
img = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == SOURCE_MD, Document.path == IMAGE_REL
|
||||
)
|
||||
)
|
||||
assert img is not None, "the embedded image must be a document"
|
||||
assert img.is_image is True
|
||||
ctx = img.image_context
|
||||
assert ctx is not None
|
||||
# STARTS-WITH the document block (origin line + the page text),
|
||||
# the Referenced-in block present, and the document block's
|
||||
# index BEFORE the markdown block's (document first — A2).
|
||||
assert ctx.startswith(
|
||||
f"Extracted from {SOURCE_MD}/{DOC_PATH} (page 2):\n{PAGE_TWO}"
|
||||
)
|
||||
assert MD_BLOCK_HEADER in ctx
|
||||
assert ctx.index("Extracted from ") < ctx.index(MD_BLOCK_HEADER)
|
||||
# The same describe-call witness as seed 1 (the combined
|
||||
# context carries the provenance header): the context-aware
|
||||
# reply, content = context + description, summary === content.
|
||||
assert img.content == f"{ctx}\n\n{IMAGE_DESCRIPTION_CONTEXT_ANSWER}"
|
||||
assert img.summary == img.content
|
||||
|
||||
# The display line: the task-04 combined rendering — the document
|
||||
# clause first, the reference clause after.
|
||||
_ask(page, app_url, ANCHOR_QUESTION)
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
expect(bubble).to_contain_text(FROM_LINE_MD, timeout=30_000)
|
||||
expect(bubble).to_contain_text(MD_BLOCK_HEADER)
|
||||
expect(bubble).not_to_contain_text(DEFLECT_OPENING)
|
||||
@@ -59,7 +59,7 @@ from app.db import SessionLocal
|
||||
from app.models import Document
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER
|
||||
from e2e.mock_llm import IMAGE_DESCRIPTION_CONTEXT_ANSWER
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
SOURCE_NAME = "e2e-pdf" # the archive stem (archive_source_name)
|
||||
@@ -360,16 +360,33 @@ def seeded(
|
||||
)
|
||||
assert img is not None, "the embedded image must become an image doc"
|
||||
assert img.is_image is True and img.image_path is not None
|
||||
# content == summary == the mock's description (LOCKED A3
|
||||
# fail-soft shape; no markdown reference exists — the bare
|
||||
# prompt's answer).
|
||||
assert img.content == IMAGE_DESCRIPTION_ANSWER
|
||||
assert img.summary == IMAGE_DESCRIPTION_ANSWER
|
||||
# Phase 137 (task 02): the document context — the
|
||||
# provenance's origin line + page 2's text (no markdown
|
||||
# reference exists — no ``Referenced in`` block) — is
|
||||
# stored on ``image_context`` and enriches content (context
|
||||
# first, then the mock's description); summary === content
|
||||
# (the image convention — the viewer's Summary-panel
|
||||
# suppression still applies). Task 05: the describe call
|
||||
# carries the ``Extracted from `` context, so the mock's
|
||||
# vision reply is the CONTEXT-AWARE canned description
|
||||
# (it names the document + page — the deterministic
|
||||
# witness the context reached the model), not the bare
|
||||
# pre-137 one.
|
||||
assert img.image_context == (
|
||||
f"Extracted from {SOURCE_NAME}/{DOC_PATH} (page 2):\n{PAGE_TWO}"
|
||||
)
|
||||
assert img.content == (
|
||||
f"{img.image_context}\n\n{IMAGE_DESCRIPTION_CONTEXT_ANSWER}"
|
||||
)
|
||||
assert img.summary == img.content
|
||||
finally:
|
||||
page.close()
|
||||
yield {
|
||||
"image_url": f"/api/documents/{img.id}/image",
|
||||
"description": IMAGE_DESCRIPTION_ANSWER,
|
||||
# The doc's stored text (the document context + the mock's
|
||||
# description) — every render surface (the thumbnail alt, the
|
||||
# viewer's content slot, the figure's caption) settles to it.
|
||||
"description": img.content,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ from sqlalchemy import select
|
||||
from app.config import Settings
|
||||
from app.models import Chunk, Document
|
||||
from app.rag.importer import import_sources
|
||||
from app.rag.llm import EmbeddingError
|
||||
from app.rag.summarizer import DESCRIBE_PROMPT
|
||||
from tests.fakes import FakeEmbedder
|
||||
|
||||
@@ -57,8 +58,14 @@ DESCRIPTION = (
|
||||
)
|
||||
|
||||
#: The exact context prefix the task-03 extension appends to the locked
|
||||
#: prompt (``app.rag.summarizer.describe_image``).
|
||||
CONTEXT_PREFIX = "\n\nThis image is referenced in documentation with the following context:\n"
|
||||
#: prompt (``app.rag.summarizer.describe_image``). Phase 137 (task 02,
|
||||
#: ASSUMPTION A5) generalized the wording — it now covers BOTH the
|
||||
#: provenance's document context and the phase-125 markdown reference
|
||||
#: blocks.
|
||||
CONTEXT_PREFIX = (
|
||||
"\n\nThis image is part of a document. The following context "
|
||||
"describes where it comes from and what it is used for:\n"
|
||||
)
|
||||
|
||||
|
||||
class _MockVisionLLM(FakeEmbedder):
|
||||
@@ -500,9 +507,11 @@ def test_pdf_becomes_document_with_text_chunks_embedding_and_summary(
|
||||
title), page-anchored chunks with 768-dim embeddings, and the
|
||||
phase-30/118 lite summary. With ``images=True`` (A3) the embedded
|
||||
image joins the pipeline through the REAL seam — one vision call on
|
||||
the CHAT model with the bare locked prompt (no markdown reference
|
||||
exists — ``image_context`` stays NULL), the copy in ``image_dir``,
|
||||
and the description as the image doc's content AND summary."""
|
||||
the CHAT model with the locked prompt + the phase-137 document
|
||||
context (the provenance's origin line + page 2's text — no markdown
|
||||
reference exists, so no ``Referenced in`` block), the copy in
|
||||
``image_dir``, and the description (enriched with the context)
|
||||
as the image doc's content AND summary."""
|
||||
source_root = tmp_path / "pdfkb"
|
||||
source_root.mkdir()
|
||||
png = _png_bytes(40, 30)
|
||||
@@ -547,19 +556,28 @@ def test_pdf_becomes_document_with_text_chunks_embedding_and_summary(
|
||||
Document.source == source_root.name, Document.path == image_rel
|
||||
)
|
||||
)
|
||||
# Phase 137 (task 02): the provenance's document context — the
|
||||
# origin line + page 2's text (no markdown reference → no
|
||||
# ``Referenced in`` block) — is stored on ``image_context`` and
|
||||
# enriches the content (context first, then the description).
|
||||
expected_context = (
|
||||
f"Extracted from {source_root.name}/report.pdf (page 2):\n{PAGE_TWO}"
|
||||
)
|
||||
assert img is not None and img.is_image is True
|
||||
assert img.image_context is None, "no markdown reference → bare prompt"
|
||||
assert img.content == DESCRIPTION and img.summary == DESCRIPTION
|
||||
assert img.image_context == expected_context
|
||||
expected_content = f"{expected_context}\n\n{DESCRIPTION}"
|
||||
assert img.content == expected_content and img.summary == expected_content
|
||||
copy = Path(img.image_path)
|
||||
assert copy.parent == Path(llm.settings.image_dir).expanduser()
|
||||
assert copy.name == f"{img.id}.png"
|
||||
assert copy.read_bytes() == png
|
||||
# The vision call went to the CHAT model with the BARE locked
|
||||
# prompt (the PDF's lite summary is the other chat call).
|
||||
# The vision call went to the CHAT model with the locked prompt
|
||||
# + the generalized prefix + the document context (the PDF's
|
||||
# lite summary is the other chat call).
|
||||
assert llm.chat_models == [
|
||||
llm.settings.llm_summary_model, llm.settings.llm_chat_model
|
||||
]
|
||||
assert llm.text_parts == [DESCRIBE_PROMPT]
|
||||
assert llm.text_parts == [DESCRIBE_PROMPT + CONTEXT_PREFIX + expected_context]
|
||||
finally:
|
||||
_cleanup_source(db, source_root.name)
|
||||
|
||||
@@ -804,6 +822,12 @@ def test_pdf_embedded_image_referenced_by_markdown_markdown_first(
|
||||
assert (summary.files, summary.added, summary.images_failed) == (2, 3, 0)
|
||||
|
||||
expected_block = _pdf_expected_block(source_root.name, "guide.md", "report.pdf")
|
||||
# Phase 137 (task 02, A2): the provenance's document block is
|
||||
# FIRST — the combined context the vision call, the stored
|
||||
# ``image_context``, and the enriched content all carry.
|
||||
expected_context = (
|
||||
f"Extracted from {source_root.name}/report.pdf (page 2):\n{PAGE_TWO}"
|
||||
) + "\n\n" + expected_block
|
||||
|
||||
# Work 1 — the markdown doc STORES the reference (the record —
|
||||
# line 3 is the reference, 2 context paragraphs). It is the
|
||||
@@ -830,14 +854,14 @@ def test_pdf_embedded_image_referenced_by_markdown_markdown_first(
|
||||
)
|
||||
)
|
||||
assert img is not None and img.is_image is True
|
||||
assert img.image_context == expected_block
|
||||
expected_content = f"{expected_block}\n\n{DESCRIPTION}"
|
||||
assert img.image_context == expected_context
|
||||
expected_content = f"{expected_context}\n\n{DESCRIPTION}"
|
||||
assert img.content == expected_content
|
||||
assert img.summary == expected_content
|
||||
# The vision call carried the context AFTER the locked prompt
|
||||
# (the multimodal part list's text part — the describe seam's
|
||||
# phase-125 extension).
|
||||
assert llm.text_parts == [f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{expected_block}"]
|
||||
# The vision call carried the combined context AFTER the locked
|
||||
# prompt (the multimodal part list's text part — the describe
|
||||
# seam's phase-125 extension, the phase-137 generalized prefix).
|
||||
assert llm.text_parts == [f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{expected_context}"]
|
||||
# Chat call order (walk order): guide.md's ``lite`` summary,
|
||||
# the PDF's ``lite`` summary, then the image's CHAT-model
|
||||
# description (the ONLY multimodal call).
|
||||
@@ -887,6 +911,12 @@ def test_pdf_embedded_image_referenced_by_markdown_pdf_first(
|
||||
assert (summary.files, summary.added, summary.images_failed) == (2, 3, 0)
|
||||
|
||||
expected_block = _pdf_expected_block(source_root.name, "zguide.md", "alpha.pdf")
|
||||
# Phase 137 (task 02, A2): the provenance's document block is
|
||||
# FIRST (the same combined context as the markdown-first final
|
||||
# state — the enrichment never depends on the walk order).
|
||||
expected_context = (
|
||||
f"Extracted from {source_root.name}/alpha.pdf (page 2):\n{PAGE_TWO}"
|
||||
) + "\n\n" + expected_block
|
||||
|
||||
# The task-03 path owns this case: the image doc existed when
|
||||
# the markdown was indexed → NO record (the field is the
|
||||
@@ -906,11 +936,11 @@ def test_pdf_embedded_image_referenced_by_markdown_pdf_first(
|
||||
Document.source == source_root.name, Document.path == image_rel
|
||||
)
|
||||
)
|
||||
assert img is not None and img.image_context == expected_block
|
||||
expected_content = f"{expected_block}\n\n{DESCRIPTION}"
|
||||
assert img is not None and img.image_context == expected_context
|
||||
expected_content = f"{expected_context}\n\n{DESCRIPTION}"
|
||||
assert img.content == expected_content
|
||||
assert img.summary == expected_content
|
||||
assert llm.text_parts == [f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{expected_block}"]
|
||||
assert llm.text_parts == [f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{expected_context}"]
|
||||
# Chat call order (walk order): the PDF's ``lite`` summary, the
|
||||
# image's CHAT-model description, then zguide.md's summary.
|
||||
assert llm.chat_models == [
|
||||
@@ -920,3 +950,559 @@ def test_pdf_embedded_image_referenced_by_markdown_pdf_first(
|
||||
]
|
||||
finally:
|
||||
_cleanup_source(db, source_root.name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 137, task 03 — the document-context backfill through the REAL
|
||||
# import pipeline (seeded pre-phase-137 image rows, the REAL
|
||||
# ``_backfill_image_contexts`` pass inside ``import_sources``, the REAL
|
||||
# ``_describe_or_skip`` → ``describe_image`` seam — NO seam patch).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The seeded embedded image's phase-126 synthetic path (the parent
|
||||
#: ``report.pdf``, page 2, index 0, ``.png`` — the upsert key the
|
||||
#: forward path would have built).
|
||||
BACKFILL_IMAGE_REL = "report.pdf#page=2#img=0.png"
|
||||
|
||||
|
||||
def _seed_backfill_row(
|
||||
db,
|
||||
*,
|
||||
source: str,
|
||||
image_rel: str,
|
||||
image_path: str,
|
||||
context: str | None = None,
|
||||
title: str = "Embedded image from report page 2",
|
||||
) -> Document:
|
||||
"""One pre-phase-137 PDF-embedded image doc row — the shape a
|
||||
phase-126 sync left behind: ``image_context`` NULL (or the
|
||||
markdown-only phase-125 value), ``content``/``summary`` the bare
|
||||
description (or the phase-125 enriched shape), one content chunk +
|
||||
the ``is_summary`` chunk, and ``image_path`` naming the persistent
|
||||
``image_dir`` copy (the caller controls whether the file exists).
|
||||
"""
|
||||
content = f"{context}\n\n{DESCRIPTION}" if context is not None else DESCRIPTION
|
||||
doc = Document(
|
||||
source=source,
|
||||
path=image_rel,
|
||||
full_path=image_path,
|
||||
title=title,
|
||||
content=content,
|
||||
content_hash=hashlib.sha256(PNG_1X1).hexdigest(),
|
||||
is_image=True,
|
||||
image_path=image_path,
|
||||
image_context=context,
|
||||
summary=content,
|
||||
)
|
||||
db.add(doc)
|
||||
db.flush()
|
||||
packed = "\n".join(content.split("\n\n"))
|
||||
db.add(
|
||||
Chunk(
|
||||
document_id=doc.id, position=0, content=packed,
|
||||
embedding=[0.0] * 768,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
Chunk(
|
||||
document_id=doc.id, position=-1, content=content, is_summary=True,
|
||||
embedding=[0.0] * 768,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return doc
|
||||
|
||||
|
||||
def test_backfill_re_describes_context_free_embedded_image_with_document_context(
|
||||
db, tmp_path
|
||||
) -> None:
|
||||
"""A pre-phase-137 PDF-embedded image doc (``image_context`` NULL,
|
||||
the bare description, the persistent copy) + the parent PDF still
|
||||
in the source (with page text) → the next REAL sync backfills:
|
||||
exactly ONE describe call whose context carries the document block
|
||||
(the origin line + page 2's text); the row's ``image_context``
|
||||
starts with ``Extracted from ``, its ``content``/``summary`` is the
|
||||
combined context + the fresh description; the chunks are replaced;
|
||||
the row's identity fields stay; the log-only counter is 1 (the
|
||||
backfill NEVER counts added/updated — the source did not change)."""
|
||||
source_root = tmp_path / "bkfillkb"
|
||||
source_root.mkdir()
|
||||
_build_pdf(source_root / "report.pdf", [(PAGE_ONE, []), (PAGE_TWO, [])])
|
||||
copy = tmp_path / "copies" / "seed.png"
|
||||
copy.parent.mkdir(parents=True)
|
||||
copy.write_bytes(PNG_1X1)
|
||||
_seed_backfill_row(
|
||||
db, source=source_root.name, image_rel=BACKFILL_IMAGE_REL,
|
||||
image_path=str(copy),
|
||||
)
|
||||
llm = _image_llm(tmp_path, import_extensions="md,txt,pdf")
|
||||
try:
|
||||
summary = asyncio.run(import_sources([source_root], llm, session=db))
|
||||
assert summary.image_context_backfilled == 1
|
||||
assert summary.images_failed == 0 and summary.errors == 0
|
||||
# The parent PDF is the only WALKED file (added); the backfilled
|
||||
# row counts on its own log-only counter, never added/updated.
|
||||
assert (summary.files, summary.added, summary.updated) == (1, 1, 0)
|
||||
|
||||
expected_context = (
|
||||
f"Extracted from {source_root.name}/report.pdf (page 2):\n{PAGE_TWO}"
|
||||
)
|
||||
# The ONE describe call carries the document context (the locked
|
||||
# prompt + the generalized prefix); the parent's ``lite`` summary
|
||||
# is the other chat call, not a describe.
|
||||
assert llm.text_parts == [
|
||||
f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{expected_context}"
|
||||
]
|
||||
assert llm.chat_models == [
|
||||
llm.settings.llm_summary_model, llm.settings.llm_chat_model
|
||||
]
|
||||
|
||||
img = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == source_root.name,
|
||||
Document.path == BACKFILL_IMAGE_REL,
|
||||
)
|
||||
)
|
||||
assert img is not None and img.is_image is True
|
||||
assert img.image_context == expected_context
|
||||
assert img.image_context.startswith("Extracted from ")
|
||||
expected_content = f"{expected_context}\n\n{DESCRIPTION}"
|
||||
assert img.content == expected_content
|
||||
assert img.summary == expected_content
|
||||
# The chunks are REPLACED (the bare-description chunk is gone;
|
||||
# the enriched content chunk + the mirroring summary chunk are
|
||||
# freshly embedded).
|
||||
packed = "\n".join(expected_content.split("\n\n"))
|
||||
chunks = db.scalars(
|
||||
select(Chunk).where(Chunk.document_id == img.id).order_by(Chunk.position)
|
||||
).all()
|
||||
assert [c.content for c in chunks if not c.is_summary] == [packed]
|
||||
summary_chunks = [c for c in chunks if c.is_summary]
|
||||
assert len(summary_chunks) == 1 and summary_chunks[0].position == -1
|
||||
assert summary_chunks[0].content == expected_content
|
||||
for c in chunks:
|
||||
assert c.embedding is not None and len(c.embedding) == 768
|
||||
# Deliberately UNCHANGED on the row: the byte digest (the image
|
||||
# identity), the copy, the title (the source did not change —
|
||||
# the phase-106 date rules do not apply).
|
||||
assert img.content_hash == hashlib.sha256(PNG_1X1).hexdigest()
|
||||
assert img.image_path == str(copy)
|
||||
assert img.title == "Embedded image from report page 2"
|
||||
assert copy.read_bytes() == PNG_1X1
|
||||
finally:
|
||||
_cleanup_source(db, source_root.name)
|
||||
|
||||
|
||||
def test_backfill_is_idempotent_the_next_sync_burns_no_vision_calls(
|
||||
db, tmp_path
|
||||
) -> None:
|
||||
"""A backfilled doc never qualifies again: the next REAL sync over
|
||||
the same source burns ZERO describe calls (the gate no longer
|
||||
matches — ``image_context`` starts with ``Extracted from ``), the
|
||||
row is byte-identical, and the counter is 0."""
|
||||
source_root = tmp_path / "bkfillidempotent"
|
||||
source_root.mkdir()
|
||||
_build_pdf(source_root / "report.pdf", [(PAGE_ONE, []), (PAGE_TWO, [])])
|
||||
copy = tmp_path / "copies" / "seed.png"
|
||||
copy.parent.mkdir(parents=True)
|
||||
copy.write_bytes(PNG_1X1)
|
||||
_seed_backfill_row(
|
||||
db, source=source_root.name, image_rel=BACKFILL_IMAGE_REL,
|
||||
image_path=str(copy),
|
||||
)
|
||||
llm = _image_llm(tmp_path, import_extensions="md,txt,pdf")
|
||||
try:
|
||||
s1 = asyncio.run(import_sources([source_root], llm, session=db))
|
||||
assert s1.image_context_backfilled == 1
|
||||
img1 = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == source_root.name,
|
||||
Document.path == BACKFILL_IMAGE_REL,
|
||||
)
|
||||
)
|
||||
assert img1 is not None
|
||||
snapshot = (
|
||||
img1.content, img1.summary, img1.image_context,
|
||||
img1.content_hash, img1.indexed_at, img1.created_at,
|
||||
img1.title, img1.image_path,
|
||||
)
|
||||
describes = len(llm.text_parts)
|
||||
assert describes == 1
|
||||
|
||||
# The next sync: the parent PDF is unchanged, the image doc no
|
||||
# longer matches the gate.
|
||||
s2 = asyncio.run(import_sources([source_root], llm, session=db))
|
||||
assert s2.image_context_backfilled == 0
|
||||
assert (s2.added, s2.updated, s2.unchanged) == (0, 0, 1)
|
||||
assert len(llm.text_parts) == describes # zero NEW describe calls
|
||||
assert s2.images_failed == 0 and s2.errors == 0
|
||||
|
||||
img2 = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == source_root.name,
|
||||
Document.path == BACKFILL_IMAGE_REL,
|
||||
)
|
||||
)
|
||||
assert img2 is not None
|
||||
assert (
|
||||
img2.content, img2.summary, img2.image_context,
|
||||
img2.content_hash, img2.indexed_at, img2.created_at,
|
||||
img2.title, img2.image_path,
|
||||
) == snapshot
|
||||
finally:
|
||||
_cleanup_source(db, source_root.name)
|
||||
|
||||
|
||||
def test_backfill_parent_gone_gets_the_origin_line_only(db, tmp_path) -> None:
|
||||
"""The parent PDF is gone from the source dir (no disk dependency):
|
||||
the describe call's context is the origin line ONLY (no page-text
|
||||
line), and the row is still backfilled — it succeeds once, so the
|
||||
gate matches again next sync iff it had failed."""
|
||||
source_root = tmp_path / "bkfillgone"
|
||||
source_root.mkdir() # the parent PDF is already gone
|
||||
copy = tmp_path / "copies" / "seed.png"
|
||||
copy.parent.mkdir(parents=True)
|
||||
copy.write_bytes(PNG_1X1)
|
||||
_seed_backfill_row(
|
||||
db, source=source_root.name, image_rel=BACKFILL_IMAGE_REL,
|
||||
image_path=str(copy),
|
||||
)
|
||||
llm = _image_llm(tmp_path, import_extensions="md,txt,pdf")
|
||||
try:
|
||||
summary = asyncio.run(import_sources([source_root], llm, session=db))
|
||||
assert summary.image_context_backfilled == 1
|
||||
assert summary.images_failed == 0 and summary.errors == 0
|
||||
expected_context = (
|
||||
f"Extracted from {source_root.name}/report.pdf (page 2):"
|
||||
)
|
||||
assert llm.text_parts == [
|
||||
f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{expected_context}"
|
||||
]
|
||||
img = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == source_root.name,
|
||||
Document.path == BACKFILL_IMAGE_REL,
|
||||
)
|
||||
)
|
||||
assert img is not None
|
||||
assert img.image_context == expected_context # origin line only
|
||||
assert img.content == f"{expected_context}\n\n{DESCRIPTION}"
|
||||
assert img.summary == img.content
|
||||
finally:
|
||||
_cleanup_source(db, source_root.name)
|
||||
|
||||
|
||||
def test_backfill_keeps_the_markdown_block_verbatim_document_first(
|
||||
db, tmp_path
|
||||
) -> None:
|
||||
"""A markdown-enriched row (the phase-125 ``Referenced in …``
|
||||
value — the document block missing) STILL qualifies: the backfilled
|
||||
``image_context`` is the document block + blank line + the stored
|
||||
markdown block VERBATIM (document first — re-scanning the disk for
|
||||
the markdown context is NOT this pass's job: the phase-125 pass
|
||||
owns it and ran first this sync)."""
|
||||
source_root = tmp_path / "bkfillmd"
|
||||
source_root.mkdir()
|
||||
_build_pdf(source_root / "report.pdf", [(PAGE_ONE, []), (PAGE_TWO, [])])
|
||||
copy = tmp_path / "copies" / "seed.png"
|
||||
copy.parent.mkdir(parents=True)
|
||||
copy.write_bytes(PNG_1X1)
|
||||
md_block = (
|
||||
f"Referenced in {source_root.name}/doc.md:\n"
|
||||
'- Alt text: "x"'
|
||||
)
|
||||
_seed_backfill_row(
|
||||
db, source=source_root.name, image_rel=BACKFILL_IMAGE_REL,
|
||||
image_path=str(copy), context=md_block,
|
||||
)
|
||||
llm = _image_llm(tmp_path, import_extensions="md,txt,pdf")
|
||||
try:
|
||||
summary = asyncio.run(import_sources([source_root], llm, session=db))
|
||||
assert summary.image_context_backfilled == 1
|
||||
doc_block = (
|
||||
f"Extracted from {source_root.name}/report.pdf (page 2):\n{PAGE_TWO}"
|
||||
)
|
||||
expected_context = f"{doc_block}\n\n{md_block}"
|
||||
assert llm.text_parts == [
|
||||
f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{expected_context}"
|
||||
]
|
||||
img = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == source_root.name,
|
||||
Document.path == BACKFILL_IMAGE_REL,
|
||||
)
|
||||
)
|
||||
assert img is not None
|
||||
# Document block FIRST, the markdown block VERBATIM after it.
|
||||
assert img.image_context == expected_context
|
||||
assert img.content == f"{expected_context}\n\n{DESCRIPTION}"
|
||||
assert img.summary == img.content
|
||||
finally:
|
||||
_cleanup_source(db, source_root.name)
|
||||
|
||||
|
||||
def test_backfill_never_touches_standalone_and_copyless_rows(db, tmp_path) -> None:
|
||||
"""A standalone image row (a plain path — no ``#page=``) is NEVER a
|
||||
candidate (the pattern gate) even with a live copy + NULL context,
|
||||
and an embedded-pattern row whose ``image_dir`` copy is gone is
|
||||
skipped (the disk gate): both stay byte-identical across TWO REAL
|
||||
syncs, with zero describe calls."""
|
||||
source_root = tmp_path / "bkfillstandalone"
|
||||
source_root.mkdir()
|
||||
copy = tmp_path / "copies" / "pic.png"
|
||||
copy.parent.mkdir(parents=True)
|
||||
copy.write_bytes(PNG_1X1)
|
||||
standalone = _seed_backfill_row(
|
||||
db, source=source_root.name, image_rel="pic.png",
|
||||
image_path=str(copy), title="pic",
|
||||
)
|
||||
missing_copy = _seed_backfill_row(
|
||||
db, source=source_root.name,
|
||||
image_rel="notes.pdf#page=1#img=0.png",
|
||||
image_path=str(tmp_path / "copies" / "gone.png"),
|
||||
title="Embedded image from notes page 1",
|
||||
)
|
||||
llm = _image_llm(tmp_path, import_extensions="md,txt,pdf")
|
||||
try:
|
||||
s1 = asyncio.run(import_sources([source_root], llm, session=db))
|
||||
s2 = asyncio.run(import_sources([source_root], llm, session=db))
|
||||
for s in (s1, s2):
|
||||
assert s.image_context_backfilled == 0
|
||||
assert s.images_failed == 0 and s.errors == 0
|
||||
assert llm.text_parts == [] # zero describe calls, both runs
|
||||
db.expire(standalone)
|
||||
db.expire(missing_copy)
|
||||
assert standalone.image_context is None
|
||||
assert standalone.content == DESCRIPTION
|
||||
assert missing_copy.image_context is None
|
||||
assert missing_copy.content == DESCRIPTION
|
||||
finally:
|
||||
_cleanup_source(db, source_root.name)
|
||||
|
||||
|
||||
def test_backfill_unreadable_copy_is_skipped_without_touching_the_row(
|
||||
db, tmp_path
|
||||
) -> None:
|
||||
"""A candidate whose copy passes the existence gate but is
|
||||
UNREADABLE (a ``read_bytes`` OSError between the gate and the read)
|
||||
is skipped — the row stays as-is (the next sync retries it), nothing
|
||||
is counted as a failure, and no describe call is made."""
|
||||
source_root = tmp_path / "bkfillunreadable"
|
||||
source_root.mkdir()
|
||||
_build_pdf(source_root / "report.pdf", [(PAGE_ONE, []), (PAGE_TWO, [])])
|
||||
copy = tmp_path / "copies" / "seed.png"
|
||||
copy.parent.mkdir(parents=True)
|
||||
copy.write_bytes(PNG_1X1)
|
||||
_seed_backfill_row(
|
||||
db, source=source_root.name, image_rel=BACKFILL_IMAGE_REL,
|
||||
image_path=str(copy),
|
||||
)
|
||||
llm = _image_llm(tmp_path, import_extensions="md,txt,pdf")
|
||||
try:
|
||||
copy.chmod(0o000) # exists but unreadable (the suite runs non-root)
|
||||
try:
|
||||
summary = asyncio.run(import_sources([source_root], llm, session=db))
|
||||
finally:
|
||||
copy.chmod(0o644)
|
||||
assert summary.image_context_backfilled == 0
|
||||
assert summary.images_failed == 0 and summary.errors == 0
|
||||
assert llm.text_parts == [] # no describe attempt
|
||||
img = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == source_root.name,
|
||||
Document.path == BACKFILL_IMAGE_REL,
|
||||
)
|
||||
)
|
||||
assert img is not None
|
||||
assert img.image_context is None # untouched
|
||||
assert img.content == DESCRIPTION
|
||||
finally:
|
||||
_cleanup_source(db, source_root.name)
|
||||
|
||||
|
||||
def test_backfill_recognizes_uppercase_pdf_extension(db, tmp_path) -> None:
|
||||
"""The walk matches extensions case-insensitively, so an on-disk
|
||||
``REPORT.PDF`` produces a synthetic path with the uppercase suffix —
|
||||
the backfill's pattern gate must still recognize it (and the parent
|
||||
lookup must hit the uppercase file)."""
|
||||
source_root = tmp_path / "bkfillupper"
|
||||
source_root.mkdir()
|
||||
_build_pdf(source_root / "REPORT.PDF", [(PAGE_ONE, []), (PAGE_TWO, [])])
|
||||
copy = tmp_path / "copies" / "seed.png"
|
||||
copy.parent.mkdir(parents=True)
|
||||
copy.write_bytes(PNG_1X1)
|
||||
_seed_backfill_row(
|
||||
db, source=source_root.name, image_rel="REPORT.PDF#page=2#img=0.png",
|
||||
image_path=str(copy),
|
||||
)
|
||||
llm = _image_llm(tmp_path, import_extensions="md,txt,pdf")
|
||||
try:
|
||||
summary = asyncio.run(import_sources([source_root], llm, session=db))
|
||||
assert summary.image_context_backfilled == 1
|
||||
expected_context = (
|
||||
f"Extracted from {source_root.name}/REPORT.PDF (page 2):\n{PAGE_TWO}"
|
||||
)
|
||||
assert llm.text_parts == [
|
||||
f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{expected_context}"
|
||||
]
|
||||
img = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == source_root.name,
|
||||
Document.path == "REPORT.PDF#page=2#img=0.png",
|
||||
)
|
||||
)
|
||||
assert img is not None and img.image_context == expected_context
|
||||
finally:
|
||||
_cleanup_source(db, source_root.name)
|
||||
|
||||
|
||||
class _FirstDescribeFailsLLM(_MockVisionLLM):
|
||||
"""The first multimodal describe reply is EMPTY (``describe_image``
|
||||
→ ``None`` — the seam's fail-soft input); later calls answer
|
||||
normally (the retry-next-sync proof)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._failures_left = 1
|
||||
|
||||
async def chat(self, messages, model=None):
|
||||
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
|
||||
if isinstance(user, list) and self._failures_left:
|
||||
self._failures_left -= 1
|
||||
saved = self.DESCRIPTION
|
||||
self.DESCRIPTION = "" # the empty reply → the seam's None
|
||||
try:
|
||||
return await super().chat(messages, model)
|
||||
finally:
|
||||
self.DESCRIPTION = saved
|
||||
return await super().chat(messages, model)
|
||||
|
||||
|
||||
def test_backfill_describe_failure_leaves_row_untouched_and_retries_next_sync(
|
||||
db, tmp_path
|
||||
) -> None:
|
||||
"""A candidate whose describe call returns ``None`` (an empty vision
|
||||
reply) leaves the row UNTOUCHED — nothing was written, so there is
|
||||
nothing to roll back — ``images_failed`` + the warning, and the next
|
||||
REAL sync (the healthy fake) retries and backfills: the fail-soft
|
||||
contract."""
|
||||
source_root = tmp_path / "bkfillflaky"
|
||||
source_root.mkdir()
|
||||
_build_pdf(source_root / "report.pdf", [(PAGE_ONE, []), (PAGE_TWO, [])])
|
||||
copy = tmp_path / "copies" / "seed.png"
|
||||
copy.parent.mkdir(parents=True)
|
||||
copy.write_bytes(PNG_1X1)
|
||||
_seed_backfill_row(
|
||||
db, source=source_root.name, image_rel=BACKFILL_IMAGE_REL,
|
||||
image_path=str(copy),
|
||||
)
|
||||
llm = _image_llm(
|
||||
tmp_path, llm_cls=_FirstDescribeFailsLLM, import_extensions="md,txt,pdf"
|
||||
)
|
||||
try:
|
||||
s1 = asyncio.run(import_sources([source_root], llm, session=db))
|
||||
assert s1.images_failed == 1
|
||||
assert s1.image_context_backfilled == 0 and s1.errors == 0
|
||||
# The failed call itself carried the right context.
|
||||
assert len(llm.text_parts) == 1
|
||||
assert "Extracted from" in llm.text_parts[0]
|
||||
|
||||
img = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == source_root.name,
|
||||
Document.path == BACKFILL_IMAGE_REL,
|
||||
)
|
||||
)
|
||||
assert img is not None
|
||||
assert img.image_context is None # untouched
|
||||
assert img.content == DESCRIPTION
|
||||
assert img.summary == DESCRIPTION
|
||||
assert img.content_hash == hashlib.sha256(PNG_1X1).hexdigest()
|
||||
|
||||
# The next sync (the fake is healthy now): the gate still
|
||||
# matches — the retry succeeds.
|
||||
s2 = asyncio.run(import_sources([source_root], llm, session=db))
|
||||
assert s2.image_context_backfilled == 1 and s2.images_failed == 0
|
||||
img2 = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == source_root.name,
|
||||
Document.path == BACKFILL_IMAGE_REL,
|
||||
)
|
||||
)
|
||||
assert img2 is not None and img2.image_context is not None
|
||||
assert img2.image_context.startswith("Extracted from ")
|
||||
assert img2.content == f"{img2.image_context}\n\n{DESCRIPTION}"
|
||||
finally:
|
||||
_cleanup_source(db, source_root.name)
|
||||
|
||||
|
||||
class _BackfillEmbedFailsLLM(_MockVisionLLM):
|
||||
"""The first embed of the backfilled content (a text starting with
|
||||
the ``Extracted from`` header) raises :class:`EmbeddingError`; later
|
||||
embeds succeed (the retry-next-sync proof for the embed path)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._failures_left = 1
|
||||
|
||||
async def embed(self, texts, *, on_progress=None):
|
||||
if self._failures_left and any(
|
||||
t.startswith("Extracted from") for t in texts
|
||||
):
|
||||
self._failures_left -= 1
|
||||
raise EmbeddingError("embed boom (backfill test)")
|
||||
return await super().embed(texts, on_progress=on_progress)
|
||||
|
||||
|
||||
def test_backfill_embed_failure_rolls_back_the_image_and_retries_next_sync(
|
||||
db, tmp_path
|
||||
) -> None:
|
||||
"""A candidate whose re-chunk/re-embed gives up (an
|
||||
``EmbeddingError``) rolls back THIS image only — the row is
|
||||
untouched, the copy STAYS, ``errors`` + the error line — and the
|
||||
next REAL sync (the healthy fake) retries and backfills."""
|
||||
source_root = tmp_path / "bkfillembedfail"
|
||||
source_root.mkdir()
|
||||
_build_pdf(source_root / "report.pdf", [(PAGE_ONE, []), (PAGE_TWO, [])])
|
||||
copy = tmp_path / "copies" / "seed.png"
|
||||
copy.parent.mkdir(parents=True)
|
||||
copy.write_bytes(PNG_1X1)
|
||||
_seed_backfill_row(
|
||||
db, source=source_root.name, image_rel=BACKFILL_IMAGE_REL,
|
||||
image_path=str(copy),
|
||||
)
|
||||
llm = _image_llm(
|
||||
tmp_path, llm_cls=_BackfillEmbedFailsLLM, import_extensions="md,txt,pdf"
|
||||
)
|
||||
try:
|
||||
s1 = asyncio.run(import_sources([source_root], llm, session=db))
|
||||
assert s1.errors == 1
|
||||
assert s1.image_context_backfilled == 0 and s1.images_failed == 0
|
||||
# The describe SUCCEEDED (the embed is what failed) — one
|
||||
# context-bearing call.
|
||||
assert len(llm.text_parts) == 1
|
||||
|
||||
img = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == source_root.name,
|
||||
Document.path == BACKFILL_IMAGE_REL,
|
||||
)
|
||||
)
|
||||
assert img is not None
|
||||
assert img.image_context is None # rolled back
|
||||
assert img.content == DESCRIPTION
|
||||
assert img.content_hash == hashlib.sha256(PNG_1X1).hexdigest()
|
||||
assert copy.read_bytes() == PNG_1X1 # the copy stays
|
||||
|
||||
s2 = asyncio.run(import_sources([source_root], llm, session=db))
|
||||
assert s2.image_context_backfilled == 1 and s2.errors == 0
|
||||
img2 = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == source_root.name,
|
||||
Document.path == BACKFILL_IMAGE_REL,
|
||||
)
|
||||
)
|
||||
assert img2 is not None and img2.image_context is not None
|
||||
assert img2.image_context.startswith("Extracted from ")
|
||||
finally:
|
||||
_cleanup_source(db, source_root.name)
|
||||
|
||||
@@ -64,6 +64,7 @@ import asyncio
|
||||
import re
|
||||
import tarfile
|
||||
import time
|
||||
from collections import Counter
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -74,8 +75,9 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import Chunk, Document
|
||||
from app.rag.importer import ImportSummary, import_sources
|
||||
from app.rag.pdf import PAGE_SEPARATOR
|
||||
from app.rag.importer import PDF_IMAGE_CONTEXT_MAX_CHARS, ImportSummary, import_sources
|
||||
from app.rag.pdf import PAGE_SEPARATOR, extract_pdf_page_texts
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
from app.rag.summarizer import DESCRIBE_PROMPT
|
||||
from tests.fakes import FakeEmbedder
|
||||
|
||||
@@ -125,6 +127,33 @@ pytestmark = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
|
||||
#: The phase-137 (task 02, ASSUMPTION A5) generalized context prefix
|
||||
#: (pinned here AND in the summarizer's code) — it covers BOTH the
|
||||
#: provenance's document context and the phase-125 markdown reference
|
||||
#: blocks (this PDF-only source has the former alone).
|
||||
DESCRIBE_CONTEXT_PREFIX = (
|
||||
"\n\nThis image is part of a document. The following context "
|
||||
"describes where it comes from and what it is used for:\n"
|
||||
)
|
||||
|
||||
|
||||
def _expected_context_block(
|
||||
pdf_rel: str, page: int, page_texts: dict[int, str]
|
||||
) -> str:
|
||||
"""The phase-137 (task 02) document-context block the importer
|
||||
builds for an embedded image on *page* of *pdf_rel* (the guide's
|
||||
CLEAN text layer: the origin line + the capped page text — the A7
|
||||
origin-line-only shape belongs to the garbage layer, never this
|
||||
fixture; a text-less page is the origin line alone)."""
|
||||
header = f"Extracted from {pdf_rel} (page {page}):"
|
||||
text = page_texts.get(page, "").strip()
|
||||
if not text:
|
||||
return header
|
||||
if len(text) > PDF_IMAGE_CONTEXT_MAX_CHARS:
|
||||
text = text[:PDF_IMAGE_CONTEXT_MAX_CHARS] + "\n" + TRUNCATION_MARKER
|
||||
return f"{header}\n{text}"
|
||||
|
||||
|
||||
class GuideVisionLLM(FakeEmbedder):
|
||||
"""The task-03-style vision mock the ``fakes.py`` comment
|
||||
anticipates: the base ``chat`` is string-only — the MULTIMODAL user
|
||||
@@ -194,7 +223,7 @@ def _nonprint_frac(content: str) -> float:
|
||||
@pytest.fixture()
|
||||
def guide_kb(
|
||||
db: Session, tmp_path: Path
|
||||
) -> Iterator[tuple[GuideVisionLLM, ImportSummary, float]]:
|
||||
) -> Iterator[tuple[GuideVisionLLM, ImportSummary, float, Path]]:
|
||||
"""The real guide, imported ONCE per test through the REAL importer
|
||||
(the house KB pattern — ``test_read_line_ranges_handbook.py``):
|
||||
``chunks``/``documents``/``folder_summaries`` are truncated around
|
||||
@@ -202,7 +231,7 @@ def guide_kb(
|
||||
source dir, and the deterministic in-process
|
||||
:class:`GuideVisionLLM` stands in for the embed/summary/vision
|
||||
calls (the whole 48-page index build stays fast and offline).
|
||||
Yields ``(llm, summary, elapsed_s)``."""
|
||||
Yields ``(llm, summary, elapsed_s, pdf_path)``."""
|
||||
db.execute(text("TRUNCATE chunks, documents, folder_summaries"))
|
||||
db.commit()
|
||||
root = tmp_path / SOURCE
|
||||
@@ -214,7 +243,7 @@ def guide_kb(
|
||||
started = time.monotonic()
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
elapsed = time.monotonic() - started
|
||||
yield llm, summary, elapsed
|
||||
yield llm, summary, elapsed, root / PDF_NAME
|
||||
db.execute(text("TRUNCATE chunks, documents, folder_summaries"))
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
@@ -222,15 +251,17 @@ def guide_kb(
|
||||
|
||||
def test_hnb_guide_imports_clean_through_the_real_pipeline(
|
||||
db: Session,
|
||||
guide_kb: tuple[GuideVisionLLM, ImportSummary, float],
|
||||
guide_kb: tuple[GuideVisionLLM, ImportSummary, float, Path],
|
||||
) -> None:
|
||||
"""The owner's exact file lands CLEAN through the real pipeline:
|
||||
the walk admits the one file (``pdf`` token — the PDF branch, never
|
||||
the text branch), the stored content is clean + carries the real
|
||||
first-page text, the page-anchored chunks are embedded, the ``lite``
|
||||
summary is stored, and every embedded-image doc got the fake vision
|
||||
description (the multimodal ``chat`` was called)."""
|
||||
llm, summary, elapsed = guide_kb
|
||||
description (the multimodal ``chat`` was called) enriched with the
|
||||
phase-137 (task 02) document context (the provenance block first —
|
||||
the owner's stock photos finally name their document)."""
|
||||
llm, summary, elapsed, pdf_path = guide_kb
|
||||
|
||||
# The task's wall-time guard — the suite must stay fast.
|
||||
assert elapsed < WALL_TIME_BAR_S, (
|
||||
@@ -291,7 +322,19 @@ def test_hnb_guide_imports_clean_through_the_real_pipeline(
|
||||
# The embedded-image docs (the phase-126 A3 ``#page=…#img=…``
|
||||
# rows): every one of them got the FAKE vision description — the
|
||||
# multimodal ``chat`` was called (the task's literal assertion),
|
||||
# exactly once per image doc, with the locked bare prompt.
|
||||
# exactly once per image doc — and phase 137 (task 02) enriched it
|
||||
# with the document context: the provenance block (the origin line
|
||||
# + the page's own text, capped — the guide's layer is CLEAN, so
|
||||
# the gate leaves the page texts IN; the A7 origin-line-only shape
|
||||
# is the garbage layer's, pinned in the unit suite) FIRST, then
|
||||
# the description.
|
||||
page_texts = extract_pdf_page_texts(pdf_path)
|
||||
|
||||
def _page_of(path: str) -> int:
|
||||
m = re.search(r"#page=(\d+)", path)
|
||||
assert m is not None, f"unparseable embedded-image path {path!r}"
|
||||
return int(m.group(1))
|
||||
|
||||
image_docs = db.scalars(
|
||||
select(Document).where(
|
||||
Document.source == SOURCE,
|
||||
@@ -301,7 +344,9 @@ def test_hnb_guide_imports_clean_through_the_real_pipeline(
|
||||
).all()
|
||||
assert image_docs, "the guide's embedded images did not index"
|
||||
for img in image_docs:
|
||||
assert img.content == GuideVisionLLM.VISION_REPLY
|
||||
context = _expected_context_block(PDF_REL, _page_of(img.path), page_texts)
|
||||
assert img.image_context == context
|
||||
assert img.content == f"{context}\n\n{GuideVisionLLM.VISION_REPLY}"
|
||||
assert img.summary == img.content # the image convention
|
||||
assert img.image_path is not None
|
||||
copy = Path(img.image_path)
|
||||
@@ -318,9 +363,16 @@ def test_hnb_guide_imports_clean_through_the_real_pipeline(
|
||||
assert len(llm.vision_texts) == len(image_docs), (
|
||||
"one vision call per embedded image doc (no failures)"
|
||||
)
|
||||
# The bare locked prompt on every call (no markdown reference
|
||||
# exists in this PDF-only source — the phase-126 A3 shape).
|
||||
assert all(t == DESCRIBE_PROMPT for t in llm.vision_texts)
|
||||
# The locked prompt + the generalized prefix + the document context
|
||||
# on EVERY call (no markdown reference exists in this PDF-only
|
||||
# source — the phase-137 A2 shape). The multiset is the
|
||||
# order-independent exact pin (the rows come back unsorted).
|
||||
expected_calls = Counter(
|
||||
DESCRIBE_PROMPT + DESCRIBE_CONTEXT_PREFIX
|
||||
+ _expected_context_block(PDF_REL, _page_of(img.path), page_texts)
|
||||
for img in image_docs
|
||||
)
|
||||
assert Counter(llm.vision_texts) == expected_calls
|
||||
assert summary.images_failed == 0
|
||||
|
||||
# The added accounting (see the module docstring): the walked file
|
||||
|
||||
@@ -84,8 +84,14 @@ PNG_1X1 = base64.b64decode(
|
||||
DESCRIPTION = "A red square on a white background."
|
||||
|
||||
#: The exact context prefix the task-03 extension appends to the
|
||||
#: locked prompt (pinned here AND in the summarizer's docstring).
|
||||
CONTEXT_PREFIX = "\n\nThis image is referenced in documentation with the following context:\n"
|
||||
#: locked prompt (pinned here AND in the summarizer's code). Phase 137
|
||||
#: (task 02, ASSUMPTION A5) generalized the wording — it now covers
|
||||
#: BOTH block kinds (the provenance's ``Extracted from …`` document
|
||||
#: context and the phase-125 ``Referenced in …`` markdown references).
|
||||
CONTEXT_PREFIX = (
|
||||
"\n\nThis image is part of a document. The following context "
|
||||
"describes where it comes from and what it is used for:\n"
|
||||
)
|
||||
|
||||
|
||||
def _settings(**kwargs: Any) -> Settings:
|
||||
|
||||
@@ -988,14 +988,16 @@ def test_import_summary_log_line_includes_summary_counters(
|
||||
``embed_batches`` and ``formats``; the phase-118 backfill counter
|
||||
sits between ``summary_errors`` and ``dates_updated``; the
|
||||
phase-106 date-refresh counter, the phase-122 ``images_failed``
|
||||
counter, and the phase-133 ``pdfs_screenshotted`` counter sit after
|
||||
``dates_updated``, before ``formats``."""
|
||||
counter, the phase-137 ``image_context_backfilled`` counter (next
|
||||
to ``images_failed``), and the phase-133 ``pdfs_screenshotted``
|
||||
counter sit after ``dates_updated``, before ``formats``."""
|
||||
s = ImportSummary()
|
||||
s.files, s.added, s.chunks, s.embed_batches = 3, 3, 5, 4
|
||||
s.summaries, s.summary_errors = 2, 1
|
||||
s.summary_backfilled = 1
|
||||
s.dates_updated = 0
|
||||
s.images_failed = 0
|
||||
s.image_context_backfilled = 2
|
||||
s.pdfs_screenshotted = 1
|
||||
s.formats = {"md": 1, "yaml": 2}
|
||||
with caplog.at_level(logging.INFO, logger="app.importer"):
|
||||
@@ -1004,7 +1006,8 @@ def test_import_summary_log_line_includes_summary_counters(
|
||||
assert line == (
|
||||
"import: summary files=3 added=3 updated=0 unchanged=0 pruned=0 errors=0 "
|
||||
"chunks=5 embed_batches=4 summaries=2 summary_errors=1 summary_backfilled=1 "
|
||||
"dates_updated=0 images_failed=0 pdfs_screenshotted=1 formats=yaml:2,md:1"
|
||||
"dates_updated=0 images_failed=0 image_context_backfilled=2 "
|
||||
"pdfs_screenshotted=1 formats=yaml:2,md:1"
|
||||
)
|
||||
|
||||
|
||||
|
||||
+548
-6
@@ -1,7 +1,9 @@
|
||||
"""Unit tests: PDF parser module (phase 126 task 02, locked A1/A2).
|
||||
|
||||
Covers ``app/rag/pdf.py``: text extraction with ``---PAGE-N---``
|
||||
separators (A2), embedded image extraction (original encoded bytes),
|
||||
separators (A2), the per-page text map (phase 137 task 01 —
|
||||
``extract_pdf_page_texts``, the document-context raw material),
|
||||
embedded image extraction (original encoded bytes),
|
||||
MIME mapping (extension first, then image type code, else the
|
||||
``application/octet-stream`` fallback), and the fail-soft contract —
|
||||
unreadable files and broken pages/images return empty results, never
|
||||
@@ -12,6 +14,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import tarfile
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import timedelta
|
||||
@@ -27,7 +30,12 @@ from app.config import Settings
|
||||
from app.models import Chunk, Document
|
||||
from app.rag import importer
|
||||
from app.rag import pdf as pdf_mod
|
||||
from app.rag.importer import import_sources
|
||||
from app.rag.importer import (
|
||||
PDF_IMAGE_CONTEXT_MAX_CHARS,
|
||||
_combine_image_contexts,
|
||||
_pdf_image_context_block,
|
||||
import_sources,
|
||||
)
|
||||
from app.rag.llm import EmbeddingError, LLMError
|
||||
from app.rag.pdf import (
|
||||
_XMLISH_LINE_RE,
|
||||
@@ -38,10 +46,13 @@ from app.rag.pdf import (
|
||||
_mime_from_image_info,
|
||||
_page_image_entries,
|
||||
extract_pdf_images,
|
||||
extract_pdf_page_texts,
|
||||
extract_pdf_text,
|
||||
is_usable_pdf_text,
|
||||
render_pdf_pages,
|
||||
)
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
from app.rag.summarizer import DESCRIBE_PROMPT
|
||||
from tests.fakes import FakeEmbedder
|
||||
|
||||
PNG_MAGIC = b"\x89PNG"
|
||||
@@ -284,8 +295,9 @@ def test_pdf_embedded_images_become_image_documents(db, tmp_path: Path) -> None:
|
||||
pipeline — the single seam describes it (the CHAT model), the copy
|
||||
lands in ``image_dir`` named by the doc id, and the doc row carries
|
||||
the synthetic ``#page=<N>#img=<index>`` path, ``is_image`` +
|
||||
``image_path``, and the description as content AND summary (the
|
||||
phase-122 convention — the summary chunk mirrors it)."""
|
||||
``image_path``, and the enriched content (phase 137, task 02: the
|
||||
document-context block FIRST, then the description — the summary
|
||||
chunk mirrors it, the phase-122 convention)."""
|
||||
root = tmp_path / "pdfimg"
|
||||
root.mkdir()
|
||||
png = _png_bytes(40, 30)
|
||||
@@ -296,11 +308,21 @@ def test_pdf_embedded_images_become_image_documents(db, tmp_path: Path) -> None:
|
||||
# The PDF + its one embedded image: both count added.
|
||||
assert (summary.files, summary.added, summary.images_failed) == (1, 2, 0)
|
||||
|
||||
# Phase 137 (task 02): the document context — the origin line +
|
||||
# page 2's text — is the stored ``image_context`` and the
|
||||
# ``content``/``summary`` enrichment (context first, then the
|
||||
# description — the text the embedder indexes carries it).
|
||||
expected_context = (
|
||||
f"Extracted from {root.name}/report.pdf (page 2):\nText page two."
|
||||
)
|
||||
expected_content = f"{expected_context}\n\n{DESCRIPTION}"
|
||||
|
||||
image_rel = "report.pdf#page=2#img=0.png"
|
||||
img = _doc(db, "pdfimg", image_rel)
|
||||
assert img.is_image is True and img.image_path is not None
|
||||
assert img.title == "Embedded image from report page 2"
|
||||
assert img.content == DESCRIPTION and img.summary == DESCRIPTION
|
||||
assert img.image_context == expected_context
|
||||
assert img.content == expected_content and img.summary == expected_content
|
||||
copy = Path(img.image_path)
|
||||
assert copy.parent == Path(llm.settings.image_dir).expanduser()
|
||||
assert copy.name == f"{img.id}.png" # row and copy agree on the id
|
||||
@@ -310,7 +332,7 @@ def test_pdf_embedded_images_become_image_documents(db, tmp_path: Path) -> None:
|
||||
).all()
|
||||
assert all(c.embedding is not None and len(c.embedding) == 768 for c in chunks)
|
||||
summary_chunks = [c for c in chunks if c.is_summary]
|
||||
assert len(summary_chunks) == 1 and summary_chunks[0].content == DESCRIPTION
|
||||
assert len(summary_chunks) == 1 and summary_chunks[0].content == expected_content
|
||||
|
||||
# The vision describe call went to the CHAT model (the PDF's own
|
||||
# lite summary is the first chat call — the phase-30 path).
|
||||
@@ -745,6 +767,345 @@ def test_pdf_changed_embedded_image_replaces_the_copy(db, tmp_path: Path) -> Non
|
||||
_cleanup_source(db, "pdfrep")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 137 (task 02) — the embedded image's document context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The exact generalized context prefix phase 137 (task 02, ASSUMPTION
|
||||
#: A5) appends to the locked prompt (pinned here AND in the
|
||||
#: summarizer's code) — it covers BOTH block kinds (the provenance's
|
||||
#: ``Extracted from …`` document context and the phase-125
|
||||
#: ``Referenced in …`` markdown references).
|
||||
NEUTRAL_CONTEXT_PREFIX = (
|
||||
"\n\nThis image is part of a document. The following context "
|
||||
"describes where it comes from and what it is used for:\n"
|
||||
)
|
||||
|
||||
|
||||
class _CapturingVisionLLM(FakeEmbedder):
|
||||
"""The task-02 capturing vision client: the CHAT model answers
|
||||
every multimodal describe call with the fixed :data:`DESCRIPTION`
|
||||
and records the message's TEXT PART (``text_parts`` — the context's
|
||||
assertion surface: the locked prompt + the prefix + the context);
|
||||
text (``lite``) calls keep the ``FakeEmbedder`` behaviour."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.text_parts: list[str] = []
|
||||
|
||||
async def chat(self, messages, model=None):
|
||||
self.chat_calls.append(list(messages))
|
||||
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
|
||||
if isinstance(user, list):
|
||||
self.text_parts.extend(p["text"] for p in user if p.get("type") == "text")
|
||||
return DESCRIPTION
|
||||
first = user.split()
|
||||
return "Summary of " + (first[0] if first else "<empty>")
|
||||
|
||||
|
||||
# --- the block builder — ``_pdf_image_context_block`` -------------------
|
||||
|
||||
|
||||
def test_pdf_image_context_block_header_and_page_text() -> None:
|
||||
"""A page WITH text → the exact two-line shape: the origin line
|
||||
(a stable single line — NO page-total), then the page's
|
||||
(already-stripped) text."""
|
||||
assert _pdf_image_context_block("src", "report.pdf", 2, {2: "The page text."}) == (
|
||||
"Extracted from src/report.pdf (page 2):\nThe page text."
|
||||
)
|
||||
# The value is stripped by the builder too (defensive: a caller's
|
||||
# unstripped map entry cannot leak trailing newlines into the
|
||||
# block).
|
||||
assert _pdf_image_context_block("s", "r.pdf", 1, {1: " Padded. "}) == (
|
||||
"Extracted from s/r.pdf (page 1):\nPadded."
|
||||
)
|
||||
|
||||
|
||||
def test_pdf_image_context_block_caps_page_text_at_the_cap() -> None:
|
||||
"""ASSUMPTION A3's cap: a ~7000-char page is cut EXACTLY at
|
||||
:data:`PDF_IMAGE_CONTEXT_MAX_CHARS` (6000), with the shared
|
||||
:data:`TRUNCATION_MARKER` on its own line (the summarizer's
|
||||
existing truncation convention); a page that fits exactly at the
|
||||
cap is NOT truncated."""
|
||||
page_text = "w" * 7000
|
||||
assert _pdf_image_context_block("src", "report.pdf", 1, {1: page_text}) == (
|
||||
"Extracted from src/report.pdf (page 1):\n"
|
||||
+ "w" * PDF_IMAGE_CONTEXT_MAX_CHARS
|
||||
+ "\n"
|
||||
+ TRUNCATION_MARKER
|
||||
)
|
||||
exact = "x" * PDF_IMAGE_CONTEXT_MAX_CHARS
|
||||
assert _pdf_image_context_block("s", "r.pdf", 1, {1: exact}) == (
|
||||
f"Extracted from s/r.pdf (page 1):\n{exact}"
|
||||
)
|
||||
|
||||
|
||||
def test_pdf_image_context_block_textless_page_is_origin_line_only() -> None:
|
||||
"""A text-less page (no key in the map — and a whitespace-only
|
||||
value) → the origin line ALONE: the provenance never depends on
|
||||
the page having text (the owner's core complaint)."""
|
||||
assert _pdf_image_context_block("s", "r.pdf", 3, {}) == "Extracted from s/r.pdf (page 3):"
|
||||
assert _pdf_image_context_block("s", "r.pdf", 3, {3: " \n\t "}) == (
|
||||
"Extracted from s/r.pdf (page 3):"
|
||||
)
|
||||
|
||||
|
||||
def test_pdf_image_context_block_unusable_layer_is_origin_line_only() -> None:
|
||||
"""ASSUMPTION A7: the phase-133 gate failed (``text_usable=False``)
|
||||
→ the "page text" is the pre-126 soup and is dropped — the origin
|
||||
line only, even with text present."""
|
||||
assert _pdf_image_context_block(
|
||||
"s", "r.pdf", 1, {1: "<xmp:garbage>value</xmp:garbage>"}, text_usable=False
|
||||
) == "Extracted from s/r.pdf (page 1):"
|
||||
|
||||
|
||||
# --- the combiner — ``_combine_image_contexts`` -------------------------
|
||||
|
||||
|
||||
def test_combine_image_contexts_doc_only() -> None:
|
||||
"""No markdown reference (``None`` or empty) → the doc block
|
||||
alone (the provenance is never empty)."""
|
||||
block = "Extracted from s/r.pdf (page 2):\nText."
|
||||
assert _combine_image_contexts(block, None) == block
|
||||
assert _combine_image_contexts(block, "") == block
|
||||
|
||||
|
||||
def test_combine_image_contexts_document_first_markdown_second() -> None:
|
||||
"""The exact combined shape (A2): the doc block FIRST, a blank
|
||||
line, then the markdown block(s) — the markdown block is
|
||||
BYTE-IDENTICAL inside the combined value (the phase-125 shape is
|
||||
never lost) and precedes nothing (provenance before purpose)."""
|
||||
block = "Extracted from s/r.pdf (page 2):\nText."
|
||||
md = 'Referenced in s/guide.md:\n- Alt text: "pic"\n Context: why'
|
||||
assert _combine_image_contexts(block, md) == f"{block}\n\n{md}"
|
||||
assert md in _combine_image_contexts(block, md), "the markdown block survives verbatim"
|
||||
|
||||
|
||||
# --- the forward path — the capturing fake LLM through ``import_sources`` ---
|
||||
|
||||
|
||||
def test_pdf_embedded_image_vision_call_carries_the_document_context(
|
||||
db, tmp_path: Path
|
||||
) -> None:
|
||||
"""THE forward path (the task's core proof): a PDF with page text +
|
||||
one embedded image → the vision message's text part is
|
||||
``DESCRIBE_PROMPT`` + the (now-neutral) prefix + the exact
|
||||
document-context block (the prefix bytes asserted explicitly), the
|
||||
stored ``image_context`` is the SAME block, and
|
||||
``content``/``summary`` are ``<block>\\n\\n<description>`` — the
|
||||
text the embedder indexes carries the context. A second (unchanged)
|
||||
sync re-describes NOTHING (the phase-126 digest skip is
|
||||
untouched)."""
|
||||
root = tmp_path / "pdfctx"
|
||||
root.mkdir()
|
||||
png = _png_bytes(40, 30)
|
||||
_build_pdf(root / "report.pdf", [("Text page one.", []), ("Text page two.", [png])])
|
||||
llm = _pdf_llm(tmp_path, _CapturingVisionLLM)
|
||||
try:
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (summary.files, summary.added, summary.images_failed) == (1, 2, 0)
|
||||
|
||||
expected_block = f"Extracted from {root.name}/report.pdf (page 2):\nText page two."
|
||||
# (a) the vision model received the context — the locked prompt
|
||||
# + the exact generalized prefix + the block.
|
||||
assert llm.text_parts == [DESCRIBE_PROMPT + NEUTRAL_CONTEXT_PREFIX + expected_block]
|
||||
assert llm.text_parts[0].startswith(DESCRIBE_PROMPT), "the locked prompt is untouched"
|
||||
# (b) the stored image_context IS the same combined value.
|
||||
img = _doc(db, root.name, "report.pdf#page=2#img=0.png")
|
||||
assert img.image_context == expected_block
|
||||
# (c) content/summary carry it too — context first, then the
|
||||
# description (the embedder indexes the same text).
|
||||
expected_content = f"{expected_block}\n\n{DESCRIPTION}"
|
||||
assert img.content == expected_content
|
||||
assert img.summary == expected_content
|
||||
chunks = db.scalars(select(Chunk).where(Chunk.document_id == img.id)).all()
|
||||
packed = "\n".join(expected_content.split("\n\n"))
|
||||
assert [c.content for c in chunks if not c.is_summary] == [packed]
|
||||
assert [c.content for c in chunks if c.is_summary] == [expected_content]
|
||||
|
||||
# The unchanged-image digest skip is untouched: a re-sync of the
|
||||
# same PDF re-describes nothing (no new chat call at all — the
|
||||
# unchanged path skips the image pass entirely).
|
||||
calls1 = len(llm.chat_calls)
|
||||
s2 = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (s2.added, s2.updated, s2.unchanged) == (0, 0, 1)
|
||||
assert len(llm.chat_calls) == calls1, "unchanged: no re-describe"
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
def test_pdf_embedded_image_with_markdown_reference_carries_both_blocks(
|
||||
db, tmp_path: Path
|
||||
) -> None:
|
||||
"""A2's combining at the real seam: a same-source markdown
|
||||
references the image by its synthetic path → the combined context
|
||||
is the DOCUMENT block FIRST, a blank line, then the phase-125
|
||||
``Referenced in`` block — the vision message carries exactly that
|
||||
(generalized prefix + the combined value), and the stored
|
||||
``image_context``/``content``/``summary`` do too (exact-string
|
||||
pin)."""
|
||||
root = tmp_path / "pdfboth"
|
||||
root.mkdir()
|
||||
(root / "guide.md").write_text(
|
||||
"The VLAN map explains the subnet layout.\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"Only expand the lab subnet after review.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
png = _png_bytes(40, 30)
|
||||
_build_pdf(root / "report.pdf", [("Text page one.", []), ("Text page two.", [png])])
|
||||
llm = _pdf_llm(tmp_path, _CapturingVisionLLM)
|
||||
try:
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (summary.files, summary.added, summary.images_failed) == (2, 3, 0)
|
||||
|
||||
doc_block = f"Extracted from {root.name}/report.pdf (page 2):\nText page two."
|
||||
md_block = (
|
||||
f"Referenced in {root.name}/guide.md:\n"
|
||||
'- Alt text: "VLAN map"\n'
|
||||
" Context: The VLAN map explains the subnet layout.\n"
|
||||
"\n"
|
||||
"Only expand the lab subnet after review."
|
||||
)
|
||||
combined = f"{doc_block}\n\n{md_block}"
|
||||
assert llm.text_parts == [DESCRIBE_PROMPT + NEUTRAL_CONTEXT_PREFIX + combined]
|
||||
img = _doc(db, root.name, "report.pdf#page=2#img=0.png")
|
||||
assert img.image_context == combined
|
||||
expected_content = f"{combined}\n\n{DESCRIPTION}"
|
||||
assert img.content == expected_content
|
||||
assert img.summary == expected_content
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
def test_pdf_embedded_image_textless_page_still_carries_the_origin_line(
|
||||
db, tmp_path: Path
|
||||
) -> None:
|
||||
"""The owner's core complaint, pinned at the real seam: an image
|
||||
on a TEXT-LESS page (page 1 carries text, so the layer is usable —
|
||||
this is the text-less-PAGE case, not the garbage-LAYER case) → the
|
||||
block is the origin line ONLY, and it is STILL stored + enriched
|
||||
(the provenance never depends on the page having text)."""
|
||||
root = tmp_path / "pdftextless"
|
||||
root.mkdir()
|
||||
png = _png_bytes(40, 30)
|
||||
_build_pdf(root / "report.pdf", [("Text page one.", []), ("", [png])])
|
||||
llm = _pdf_llm(tmp_path, _CapturingVisionLLM)
|
||||
try:
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (summary.files, summary.added, summary.images_failed) == (1, 2, 0)
|
||||
expected_block = f"Extracted from {root.name}/report.pdf (page 2):"
|
||||
assert llm.text_parts == [DESCRIBE_PROMPT + NEUTRAL_CONTEXT_PREFIX + expected_block]
|
||||
img = _doc(db, root.name, "report.pdf#page=2#img=0.png")
|
||||
assert img.image_context == expected_block
|
||||
assert img.content == f"{expected_block}\n\n{DESCRIPTION}"
|
||||
assert img.summary == img.content
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
def test_pdf_embedded_image_garbage_layer_gets_origin_line_only(
|
||||
db, tmp_path: Path
|
||||
) -> None:
|
||||
"""ASSUMPTION A7 at the real seam: a PDF whose text layer fails the
|
||||
phase-133 gate (the XML-soup class) → the pass's gate run yields
|
||||
``text_usable=False`` → the image's block is the origin line ONLY —
|
||||
the garbage "page text" never reaches the vision model (asserted on
|
||||
the captured text part). The document itself takes the
|
||||
page-screenshot fallback (context-free calls — the bare locked
|
||||
prompt)."""
|
||||
root = tmp_path / "pdfgarbage"
|
||||
root.mkdir()
|
||||
doc = fitz.open()
|
||||
page = doc.new_page()
|
||||
for i in range(10):
|
||||
page.insert_text((72, 72 + i * 14), f"<foo:bar>{i}</foo:bar>")
|
||||
pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, 40, 30), 0)
|
||||
pix.clear_with(200)
|
||||
page.insert_image(fitz.Rect(72, 220, 112, 250), stream=pix.tobytes("png"))
|
||||
doc.save(root / "report.pdf")
|
||||
doc.close()
|
||||
llm = _pdf_llm(tmp_path, _CapturingVisionLLM)
|
||||
try:
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert summary.pdfs_screenshotted == 1 # the gate fired on the doc too
|
||||
assert (summary.added, summary.images_failed) == (2, 0)
|
||||
expected_block = f"Extracted from {root.name}/report.pdf (page 1):"
|
||||
# The fallback's page-screenshot call is context-free (the bare
|
||||
# locked prompt); the IMAGE's call carries the origin-line-only
|
||||
# block (A7 — the soup never reaches the model as "text").
|
||||
assert llm.text_parts == [
|
||||
DESCRIBE_PROMPT,
|
||||
DESCRIBE_PROMPT + NEUTRAL_CONTEXT_PREFIX + expected_block,
|
||||
]
|
||||
img = _doc(db, root.name, "report.pdf#page=1#img=0.png")
|
||||
assert img.image_context == expected_block
|
||||
assert img.content == f"{expected_block}\n\n{DESCRIPTION}"
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
# --- the regression pins — the standalone path stays byte-identical ----
|
||||
|
||||
|
||||
def test_standalone_image_vision_call_stays_byte_identical(db, tmp_path: Path) -> None:
|
||||
"""The phase-122 regression pin: a standalone image (no markdown
|
||||
reference, no PDF) → the describe message's text part is the BARE
|
||||
``DESCRIBE_PROMPT`` — byte-identical to pre-phase-137 (the
|
||||
generalized prefix touches ONLY the context-carrying message),
|
||||
``image_context`` stays NULL, and the content is the bare
|
||||
description."""
|
||||
root = tmp_path / "pdfstandalone"
|
||||
root.mkdir()
|
||||
(root / "notes.md").write_text("# Notes\n\nNo references here.\n", encoding="utf-8")
|
||||
(root / "pic.png").write_bytes(_png_bytes(40, 30))
|
||||
llm = _pdf_llm(tmp_path, _CapturingVisionLLM)
|
||||
try:
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (summary.files, summary.added, summary.images_failed) == (2, 2, 0)
|
||||
assert llm.text_parts == [DESCRIBE_PROMPT], "standalone: the bare prompt"
|
||||
img = _doc(db, root.name, "pic.png")
|
||||
assert img.image_context is None, "standalone: the context stays NULL"
|
||||
assert img.content == DESCRIPTION and img.summary == DESCRIPTION
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
def test_standalone_markdown_referenced_image_uses_the_neutral_prefix(
|
||||
db, tmp_path: Path
|
||||
) -> None:
|
||||
"""The phase-125 path under the generalized prefix: a standalone
|
||||
image with a markdown reference → the message carries the markdown
|
||||
block with the (now-neutral) prefix, and the STORED values are
|
||||
byte-identical to the phase-125 shape (only the prompt's prefix
|
||||
wording moved — the stored data did not)."""
|
||||
root = tmp_path / "pdfstandmd"
|
||||
root.mkdir()
|
||||
(root / "guide.md").write_text(
|
||||
"Why this diagram exists.\n\n\n\nWhat follows.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "pic.png").write_bytes(_png_bytes(40, 30))
|
||||
llm = _pdf_llm(tmp_path, _CapturingVisionLLM)
|
||||
try:
|
||||
asyncio.run(import_sources([root], llm, session=db))
|
||||
md_block = (
|
||||
f"Referenced in {root.name}/guide.md:\n"
|
||||
'- Alt text: "The red square"\n'
|
||||
" Context: Why this diagram exists.\n"
|
||||
"\n"
|
||||
"What follows."
|
||||
)
|
||||
assert llm.text_parts == [DESCRIBE_PROMPT + NEUTRAL_CONTEXT_PREFIX + md_block]
|
||||
img = _doc(db, root.name, "pic.png")
|
||||
assert img.image_context == md_block, "the stored value is the unchanged data"
|
||||
assert img.content == f"{md_block}\n\n{DESCRIPTION}"
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
def _png_bytes(width: int, height: int, alpha: bool = False) -> bytes:
|
||||
pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, width, height), 1 if alpha else 0)
|
||||
pix.clear_with(200)
|
||||
@@ -1355,3 +1716,184 @@ def test_render_pdf_pages_broken_page_is_zero_bytes_at_its_index(
|
||||
assert render_pdf_pages(path) == [
|
||||
b"\x89PNG-fake-page-1", b"", b"\x89PNG-fake-page-3",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_pdf_page_texts (phase 137 task 01 — the per-page text map)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The owner's real multi-page fixture (untracked — ``test_sources/``
|
||||
#: is gitignored; unpacked from ``test_sources/pdf_tests.tar``): the
|
||||
#: D&D player's handbook, 293 pages.
|
||||
HANDBOOK = Path(__file__).resolve().parents[2] / "test_sources" / "players_handbook.pdf"
|
||||
|
||||
#: The owner's single-page test PDF (same provenance).
|
||||
HORSE_PDF = Path(__file__).resolve().parents[2] / "test_sources" / "pdf_horse_test.pdf"
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def handbook_texts() -> tuple[str, dict[int, str]]:
|
||||
"""The handbook's ``extract_pdf_text`` output AND page map, each
|
||||
captured ONCE (module-scoped — a full 293-page extraction is ~0.5 s
|
||||
and two tests need the same pair)."""
|
||||
return extract_pdf_text(HANDBOOK), extract_pdf_page_texts(HANDBOOK)
|
||||
|
||||
|
||||
def _parse_page_blocks(joined: str) -> dict[int, str]:
|
||||
"""Parse :func:`extract_pdf_text`'s output back into
|
||||
``{1-indexed page: block body}`` (the page-for-page comparison's
|
||||
reference: the blocks are ``---PAGE-N---\n<body>`` joined with
|
||||
``"\\n\\n"``, the body is stripped — so the body ends 2 chars
|
||||
before the next separator)."""
|
||||
matches = [m for m in re.finditer(r"---PAGE-(\d+)---\n", joined)]
|
||||
blocks: dict[int, str] = {}
|
||||
for i, m in enumerate(matches):
|
||||
end = matches[i + 1].start() - 2 if i + 1 < len(matches) else len(joined)
|
||||
blocks[int(m.group(1))] = joined[m.end():end]
|
||||
return blocks
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HANDBOOK.is_file(), reason="handbook fixture missing")
|
||||
def test_handbook_page_texts_agree_with_extract_pdf_text_page_for_page(
|
||||
handbook_texts: tuple[str, dict[int, str]],
|
||||
) -> None:
|
||||
"""THE page-for-page agreement (the task's core proof): parsing
|
||||
``extract_pdf_text``'s ``---PAGE-N---`` blocks back out and
|
||||
comparing with ``extract_pdf_page_texts`` — the two functions must
|
||||
agree EXACTLY (same keys, same stripped values)."""
|
||||
text, page_texts = handbook_texts
|
||||
assert page_texts == _parse_page_blocks(text)
|
||||
# 293 or fewer entries, 1-indexed keys: the handbook has 293 pages
|
||||
# and two of them (the cover, page 1, and page 280) are image-only
|
||||
# → 291 entries. The keys are the REAL 1-indexed page numbers, so
|
||||
# the first key is 2 (the cover carries no text — no key).
|
||||
assert 0 < len(page_texts) <= 293
|
||||
assert len(page_texts) == 291
|
||||
assert min(page_texts) == 2
|
||||
assert max(page_texts) == 293
|
||||
assert all(isinstance(k, int) and 1 <= k <= 293 for k in page_texts)
|
||||
# every value is non-empty and stripped
|
||||
for value in page_texts.values():
|
||||
assert isinstance(value, str) and value == value.strip() and value
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HANDBOOK.is_file(), reason="handbook fixture missing")
|
||||
def test_handbook_page_texts_spot_check_known_page_body(
|
||||
handbook_texts: tuple[str, dict[int, str]],
|
||||
) -> None:
|
||||
"""Spot-check against the CURRENT (pre-refactor) output: page 2's
|
||||
body (the first text page — the Contents list) and page 293's body
|
||||
(the index tail) carry the known substrings captured from the
|
||||
phase-126 output."""
|
||||
text, page_texts = handbook_texts
|
||||
assert text.startswith(f"{PAGE_SEPARATOR.format(page=2)}\nC o n t e n t s")
|
||||
assert "P r e f a c e" in page_texts[2] # the TOC renders letter-spaced
|
||||
assert "School of Abjuration (wizard)" in page_texts[293]
|
||||
# the page-2 map value is byte-identical to the parsed block body
|
||||
assert page_texts[2] == _parse_page_blocks(text)[2]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HORSE_PDF.is_file(), reason="horse fixture missing")
|
||||
def test_horse_page_texts_match_extract_pdf_text_whatever_it_is() -> None:
|
||||
"""The one-page fixture: asserted AGAINST ``extract_pdf_text``'s
|
||||
output for the same file, whatever it is — a text page means
|
||||
exactly one entry (key 1), a text-less page means ``{}``."""
|
||||
text = extract_pdf_text(HORSE_PDF)
|
||||
page_texts = extract_pdf_page_texts(HORSE_PDF)
|
||||
parsed = _parse_page_blocks(text)
|
||||
assert page_texts == parsed
|
||||
if not parsed:
|
||||
assert page_texts == {}
|
||||
else:
|
||||
assert list(page_texts) == [1] # exactly one entry, 1-indexed
|
||||
assert "Below is a picture of my horse" in page_texts[1]
|
||||
assert page_texts[1] == page_texts[1].strip()
|
||||
|
||||
|
||||
def test_missing_file_page_texts_returns_empty_dict_no_raise(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Fail-soft: an unreadable file → ``{}`` (no raise, one warning —
|
||||
the ``extract_pdf_text`` open contract, the ``pdf:`` house prefix)."""
|
||||
with caplog.at_level(logging.WARNING, logger="app.rag.pdf"):
|
||||
assert extract_pdf_page_texts(tmp_path / "nope.pdf") == {}
|
||||
warnings = [
|
||||
r for r in caplog.records if "could not open" in r.getMessage()
|
||||
]
|
||||
assert len(warnings) == 1 and warnings[0].levelno == logging.WARNING
|
||||
|
||||
|
||||
def test_garbage_file_page_texts_returns_empty_dict_no_raise(tmp_path: Path) -> None:
|
||||
bad = tmp_path / "garbage.pdf"
|
||||
bad.write_bytes(b"this is not a pdf at all")
|
||||
assert extract_pdf_page_texts(bad) == {}
|
||||
|
||||
|
||||
def test_page_texts_broken_page_is_skipped(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
||||
) -> None:
|
||||
"""The shared helper's per-page failure branch, through the NEW
|
||||
call site: a page whose get_text raises is logged/skipped (no key);
|
||||
the other pages keep their (stripped) text."""
|
||||
path = tmp_path / "fake.pdf"
|
||||
path.write_bytes(b"%PDF-1.4 fake")
|
||||
|
||||
class _Page:
|
||||
def __init__(self, text: str | None) -> None:
|
||||
self._text = text
|
||||
|
||||
def get_text(self, flavor: str) -> str:
|
||||
if self._text is None:
|
||||
raise RuntimeError("boom: page unreadable")
|
||||
return self._text
|
||||
|
||||
monkeypatch.setattr(
|
||||
pdf_mod.fitz, "open",
|
||||
lambda _p: _FakeDoc([_Page(None), _Page(" Fine text. ")]),
|
||||
)
|
||||
assert extract_pdf_page_texts(path) == {2: "Fine text."}
|
||||
|
||||
|
||||
# --- the phase-126 regression pin (the shared-iteration refactor) -------
|
||||
# Captured from the PRE-refactor output NOW (phase 137 task 01): the
|
||||
# pins assert the separator structure + length + sha256 — byte-identity
|
||||
# without hardcoding a 300-page string.
|
||||
|
||||
_HANDBOOK_SHA256 = "a0832fc585d4ecf41579198acb3a45761a0f51b0d60d5a282ea8f4d7a84cbeb6"
|
||||
_HANDBOOK_LEN = 1_268_411
|
||||
_HANDBOOK_BLOCKS = 291 # 293 pages minus the two image-only pages (1, 280)
|
||||
|
||||
_HORSE_SHA256 = "ab998617803b71fbd814000f66d0dde83c2b6eb7f679f82b3da7ca06b4d2ecca"
|
||||
_HORSE_EXACT = (
|
||||
f"{PAGE_SEPARATOR.format(page=1)}\nBelow is a picture of my horse. "
|
||||
"It may look like a cat but it\u2019s my horse. I\u2019ve just always "
|
||||
"called it that."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HANDBOOK.is_file(), reason="handbook fixture missing")
|
||||
def test_extract_pdf_text_regression_pin_handbook() -> None:
|
||||
"""``extract_pdf_text`` on the 293-page handbook is BYTE-IDENTICAL
|
||||
to the pre-refactor (phase-126) output: same separator structure
|
||||
(291 blocks, ``---PAGE-2---`` first — the cover is image-only —
|
||||
``---PAGE-293---`` last, ascending), same length, same sha256."""
|
||||
text = extract_pdf_text(HANDBOOK)
|
||||
assert len(text) == _HANDBOOK_LEN
|
||||
assert hashlib.sha256(text.encode("utf-8")).hexdigest() == _HANDBOOK_SHA256
|
||||
headers = [line for line in text.splitlines() if line.startswith("---PAGE-")]
|
||||
assert len(headers) == _HANDBOOK_BLOCKS
|
||||
assert headers[0] == PAGE_SEPARATOR.format(page=2)
|
||||
assert headers[-1] == PAGE_SEPARATOR.format(page=293)
|
||||
assert [int(h[len("---PAGE-"):-3]) for h in headers] == sorted(
|
||||
int(h[len("---PAGE-"):-3]) for h in headers
|
||||
)
|
||||
# the known substring from the first text page (captured pre-refactor)
|
||||
assert text.startswith(f"{PAGE_SEPARATOR.format(page=2)}\nC o n t e n t s")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HORSE_PDF.is_file(), reason="horse fixture missing")
|
||||
def test_extract_pdf_text_regression_pin_horse() -> None:
|
||||
"""The one-page fixture, exact byte pin (short enough to pin in
|
||||
full): the pre-refactor string, character for character."""
|
||||
text = extract_pdf_text(HORSE_PDF)
|
||||
assert text == _HORSE_EXACT
|
||||
assert hashlib.sha256(text.encode("utf-8")).hexdigest() == _HORSE_SHA256
|
||||
|
||||
@@ -1447,3 +1447,195 @@ def test_high_prompt_image_doc_without_context_is_byte_identical_to_pre_phase()
|
||||
twin = _doc("cluster-diagram.png", "A diagram of the cluster.", "cluster-diagram")
|
||||
assert build_high_prompt([img]) == build_high_prompt([twin])
|
||||
assert "Referenced in" not in build_high_prompt([img])
|
||||
|
||||
|
||||
# ---------- phase 137 (task 04, ASSUMPTION A6): the provenance clause ----------
|
||||
# The stored value of a PDF-embedded image doc (tasks 02/03) can carry
|
||||
# the ``Extracted from <source>/<pdf path> (page N):`` provenance block
|
||||
# — written FIRST (A2: provenance before purpose) — ahead of the
|
||||
# markdown reference blocks. The renderer turns that header line into
|
||||
# the one clause ``From <source>/<pdf> (page N)`` (the page-text body
|
||||
# is NEVER rendered), joins clauses in stored order, and keeps the
|
||||
# markdown-only values byte-identical (the phase-125 pin, unedited).
|
||||
|
||||
#: The exact stored provenance block shape with page text (tasks
|
||||
#: 02/03): the header + the page text (capped, stripped).
|
||||
PDF_CTX_SINGLE = (
|
||||
"Extracted from Homelab/docs/guide.pdf (page 2):\n"
|
||||
"The second page of the guide, with the diagram.\n"
|
||||
"More page text on the next line."
|
||||
)
|
||||
#: The tasks 02/03 COMBINED shape (A2): the document block first, then
|
||||
#: the markdown reference blocks, blank-line joined.
|
||||
PDF_CTX_BOTH = (
|
||||
"Extracted from Homelab/docs/guide.pdf (page 2):\n"
|
||||
"The second page of the guide, with the diagram.\n"
|
||||
"\n"
|
||||
"Referenced in Homelab/ops/runbook.md:\n"
|
||||
'- Alt text: "alt"'
|
||||
)
|
||||
|
||||
|
||||
def test_extract_context_line_pdf_block_alone() -> None:
|
||||
"""One provenance block → the clause EXACTLY: the locator + page
|
||||
number — the page-text body never renders."""
|
||||
line = _extract_context_line(PDF_CTX_SINGLE)
|
||||
assert line == "From Homelab/docs/guide.pdf (page 2)"
|
||||
assert "The second page of the guide" not in line
|
||||
assert "More page text" not in line
|
||||
assert line.count("\n") == 0 # genuinely ONE line
|
||||
|
||||
|
||||
def test_extract_context_line_pdf_block_textless_page_is_header_only() -> None:
|
||||
"""A text-less page stores the header line alone (tasks 02/03) →
|
||||
the same single clause."""
|
||||
assert _extract_context_line(
|
||||
"Extracted from Homelab/docs/guide.pdf (page 2):"
|
||||
) == "From Homelab/docs/guide.pdf (page 2)"
|
||||
|
||||
|
||||
def test_extract_context_line_pdf_locator_greedy_to_the_last_page_marker() -> None:
|
||||
"""The pdf path may itself contain spaces — ``loc`` is greedy to
|
||||
the LAST `` (page N):`` on the line."""
|
||||
assert _extract_context_line(
|
||||
"Extracted from Sources/My Docs/guide v2.pdf (page 10):"
|
||||
) == "From Sources/My Docs/guide v2.pdf (page 10)"
|
||||
|
||||
|
||||
def test_extract_context_line_pdf_block_page_text_body_is_skipped() -> None:
|
||||
"""The page-text body (multi-line, with a blank line and a
|
||||
look-alike ``Referenced in …:`` line WITHOUT the alt bullet) is
|
||||
skipped / dropped (the existing spurious-header rule) — the clause
|
||||
stays the single one."""
|
||||
text = (
|
||||
"Extracted from Homelab/docs/guide.pdf (page 2):\n"
|
||||
"Page text line one.\n"
|
||||
"\n"
|
||||
"Referenced in the appendix: it goes on like this.\n"
|
||||
"More page text after the look-alike header."
|
||||
)
|
||||
assert _extract_context_line(text) == "From Homelab/docs/guide.pdf (page 2)"
|
||||
|
||||
|
||||
def test_extract_context_line_spurious_pdf_header_in_page_text_emits_a_clause() -> None:
|
||||
"""The accepted asymmetry (documented in the docstring): a
|
||||
page-text line that LOOKS like a provenance header is NOT guarded
|
||||
(unlike the ``Referenced in`` look-alikes, which demand their
|
||||
bullet) — it emits a clause. The stored value is the context the
|
||||
importer wrote, not free model text."""
|
||||
text = (
|
||||
"Extracted from Homelab/guide.pdf (page 1):\n"
|
||||
"Page text carrying a look-alike line:\n"
|
||||
"Extracted from Somewhere/other.pdf (page 9):\n"
|
||||
"more page text"
|
||||
)
|
||||
assert _extract_context_line(text) == (
|
||||
"From Homelab/guide.pdf (page 1); From Somewhere/other.pdf (page 9)"
|
||||
)
|
||||
|
||||
|
||||
def test_extract_context_line_markdown_only_two_blocks_pin_unchanged() -> None:
|
||||
"""The phase-125 byte-identity pin (the task's shape): a
|
||||
MARKDOWN-ONLY value renders exactly as pre-task — ONE
|
||||
``Referenced in `` prefix over the joined clauses (the second
|
||||
block's empty alt renders bare); a single block is unchanged too."""
|
||||
text = (
|
||||
'Referenced in docs/a.md:\n- Alt text: "alt a"\n'
|
||||
"\n"
|
||||
'Referenced in docs/b.md:\n- Alt text: ""'
|
||||
)
|
||||
assert _extract_context_line(text) == 'Referenced in docs/a.md: "alt a"; docs/b.md'
|
||||
assert _extract_context_line('Referenced in docs/a.md:\n- Alt text: "alt a"') == (
|
||||
'Referenced in docs/a.md: "alt a"'
|
||||
)
|
||||
|
||||
|
||||
def test_extract_context_line_both_blocks_document_first() -> None:
|
||||
"""The tasks 02/03 combined shape (A2: the document block first,
|
||||
then the markdown blocks) renders the clauses IN STORED ORDER —
|
||||
each with its own prefix; the page-text body stays out."""
|
||||
line = _extract_context_line(PDF_CTX_BOTH)
|
||||
assert line == (
|
||||
'From Homelab/docs/guide.pdf (page 2); '
|
||||
'Referenced in Homelab/ops/runbook.md: "alt"'
|
||||
)
|
||||
assert "The second page of the guide" not in line
|
||||
|
||||
|
||||
def test_extract_context_line_markdown_first_then_pdf_keeps_stored_order() -> None:
|
||||
"""The reverse stored order (a markdown block BEFORE the document
|
||||
block — an owner-edited value) renders in stored order too: the
|
||||
complete reference block flushes when the provenance header
|
||||
arrives, and the mixed join gives each clause its own prefix."""
|
||||
text = (
|
||||
"Referenced in Homelab/ops/runbook.md:\n"
|
||||
'- Alt text: "alt"\n'
|
||||
"\n"
|
||||
"Extracted from Homelab/docs/guide.pdf (page 2):\n"
|
||||
"The second page of the guide."
|
||||
)
|
||||
assert _extract_context_line(text) == (
|
||||
'Referenced in Homelab/ops/runbook.md: "alt"; '
|
||||
"From Homelab/docs/guide.pdf (page 2)"
|
||||
)
|
||||
|
||||
|
||||
def test_extract_context_line_pdf_garbage_is_none() -> None:
|
||||
"""No parsable header of either kind → ``None`` (unchanged
|
||||
degradation): the provenance keyword without the exact header
|
||||
shape is not a block."""
|
||||
# No trailing colon ...
|
||||
assert _extract_context_line("Extracted from Homelab/guide.pdf (page 2)") is None
|
||||
# ... the lowercase keyword ...
|
||||
assert _extract_context_line("extracted from Homelab/guide.pdf (page 2):") is None
|
||||
# ... the missing parentheses.
|
||||
assert _extract_context_line("Extracted from Homelab/guide.pdf page 2:") is None
|
||||
|
||||
|
||||
def test_document_body_pdf_image_doc_leads_with_the_from_line() -> None:
|
||||
"""End-to-end: a PDF-embedded image doc's ``<document>`` body
|
||||
leads with the one-line rendering (provenance first, then the
|
||||
reference), a blank line, then the description — the page-text
|
||||
body never renders."""
|
||||
body = _document_body(_image_doc(image_context=PDF_CTX_BOTH))
|
||||
assert body == (
|
||||
'From Homelab/docs/guide.pdf (page 2); '
|
||||
'Referenced in Homelab/ops/runbook.md: "alt"\n'
|
||||
"\n"
|
||||
"A diagram of the cluster."
|
||||
)
|
||||
assert "The second page of the guide" not in body
|
||||
|
||||
|
||||
def test_document_body_text_doc_with_pdf_context_is_unchanged() -> None:
|
||||
"""The guard is ``is_image``: a TEXT doc carrying a provenance
|
||||
context keeps the byte-identical pre-phase body — the context
|
||||
never renders."""
|
||||
doc = _doc("guide.pdf", "CONTENT", "Guide", summary="Guide summary.",
|
||||
image_context=PDF_CTX_SINGLE)
|
||||
assert _document_body(doc) == "Guide summary."
|
||||
assert "Extracted from" not in build_high_prompt([doc])
|
||||
assert "From Homelab/docs/guide.pdf (page 2)" not in build_high_prompt([doc])
|
||||
|
||||
|
||||
def test_high_prompt_pdf_image_doc_block_exact_shape() -> None:
|
||||
"""The full block in the built prompt: the ``From`` line leads the
|
||||
body (provenance first) before the description — the pre-phase
|
||||
block shape with the line spliced in; the page-text body stays out
|
||||
of the whole prompt."""
|
||||
doc = _image_doc(image_context=PDF_CTX_SINGLE)
|
||||
prompt = build_high_prompt([doc])
|
||||
assert (
|
||||
'<document source="Homelab" path="cluster-diagram.png" '
|
||||
'title="cluster-diagram" date="2024-06-15">\n'
|
||||
"From Homelab/docs/guide.pdf (page 2)\n"
|
||||
"\n"
|
||||
"A diagram of the cluster.\n"
|
||||
"</document>"
|
||||
) in prompt
|
||||
line = "From Homelab/docs/guide.pdf (page 2)"
|
||||
block_start = prompt.index('<document source="Homelab"')
|
||||
assert prompt.index("<documents>") < prompt.index(line)
|
||||
assert prompt.index(line) < prompt.index("A diagram of the cluster.")
|
||||
assert block_start < prompt.index(line) < prompt.index("</document>")
|
||||
assert "The second page of the guide" not in prompt
|
||||
|
||||
Reference in New Issue
Block a user