phase: 181_svg_document_preview
Build and Push Containers / build-and-push-app (push) Successful in 1m57s
Build and Push Containers / build-and-push-db (push) Successful in 15s

**Phase 181 — final verification pass (all tasks already complete; verified + docs pass)**

- Verified: `app/rag/svg_text.py` (100 % cov), vector branch in `_index_image_file`, `_row_kind_mismatch` text→image migration, `IMAGE_MIMES[".svg"]`, sandboxed CSP on `GET /api/documents/{id}/image`, `.doc-image-img--vector` in `document.js`/`styles.css`, figure/thumbnail deliberate non-changes.
- Tests/lint (exact commands): `uv run pytest --cov=app --cov-report=term-missing` → **4265 passed, TOTAL 99 %** (exit 0) · `uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov` → **5 passed in isolation** · `uv run ruff check .` → clean · `uv run pyright` → **0 errors**.
- Regression (each run in isolation, `--no-cov`, all rc=0): `test_image_documents`, `test_chat_image_questions`, `test_answer_images`, `test_chat_answer_clusters`, `test_image_anchor_context`, `test_image_upload_feedback`, `test_markdown_inline_links_images`, `test_prose_word_wrap`, `test_pdf_support`, `test_pictorial_pdf`, `test_garbage_pdf`, `test_pdf_image_extraction`, `test_pdf_image_context`, `test_pdf_pages_in_sources`, `test_pdf_strip_additive_selfheal`, `test_sync_image_progress`.
- Completion criteria: ①SVG→`is_image`+`image_path`, no vision call, own `<title>/<desc>/<text>` — **met**; ②`image/svg+xml` + `default-src 'none'; sandbox` + `private, max-age=3600`, raster headers byte-unchanged — **met**; ③chat figure → modal `<img>`, no XML — **met**; ④`/document.html` full-width vector in `.doc-image` + text below — **met**; ⑤`![alt](/api/documents/<id>/image)` paints — **met**; ⑥raster/PDF unchanged — **met**; ⑦all gates green — **met**; ⑧commit + phase-dir move — harness-owned.
- Documentation: found and fixed README inconsistencies — stale forward reference "left to phase 181 / is phase 181's job" → completed-tense sizing note; "Updating the documents" standalone-image list missing `svg`; new **Raster only** bullet for question uploads (`.svg` → 422) documenting the `chat_images` guard; missing `svg_indexed=N` / `import: svg indexed` log contract; env-table heading `122/123/124` → `122/123/124/181`; new troubleshooting entry for an `.svg` showing raw XML. Full audit: every `Settings` field and every `.env.example` var is documented; pages/commands/scripts all exist. AGENTS.md verified accurate (phase-181 §5 clause present) — no changes needed beyond what the phase wrote.
- Notable deviation (kept from task 02): `app/api/chat_images.py` subtracts `_VECTOR_IMAGE_EXTENSIONS` from the question-image accept set — a required consequence of LOCKED A7 (`svg` in the default image set) since `_CHAT_IMAGE_FILENAME_RE` refuses to *serve* `.svg`; the regex itself is untouched, as the phase specified.
- Note for the commit: changes include `README.md`, `AGENTS.md` and `.env.example` — the phase file's `git add app/ frontend/ tests/ .env.example .agents/phases/` line omits the two markdown files; stage them too.
- Next pending phase: `182_verbatim_show_text`.
This commit is contained in:
2026-10-14 22:54:43 -04:00
parent 6880bd0866
commit 814a400e6d
70 changed files with 4460 additions and 52 deletions
@@ -0,0 +1,10 @@
**Phase 181 — final verification pass (all tasks already complete; verified + docs pass)**
- Verified: `app/rag/svg_text.py` (100 % cov), vector branch in `_index_image_file`, `_row_kind_mismatch` text→image migration, `IMAGE_MIMES[".svg"]`, sandboxed CSP on `GET /api/documents/{id}/image`, `.doc-image-img--vector` in `document.js`/`styles.css`, figure/thumbnail deliberate non-changes.
- Tests/lint (exact commands): `uv run pytest --cov=app --cov-report=term-missing` → **4265 passed, TOTAL 99 %** (exit 0) · `uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov` → **5 passed in isolation** · `uv run ruff check .` → clean · `uv run pyright` → **0 errors**.
- Regression (each run in isolation, `--no-cov`, all rc=0): `test_image_documents`, `test_chat_image_questions`, `test_answer_images`, `test_chat_answer_clusters`, `test_image_anchor_context`, `test_image_upload_feedback`, `test_markdown_inline_links_images`, `test_prose_word_wrap`, `test_pdf_support`, `test_pictorial_pdf`, `test_garbage_pdf`, `test_pdf_image_extraction`, `test_pdf_image_context`, `test_pdf_pages_in_sources`, `test_pdf_strip_additive_selfheal`, `test_sync_image_progress`.
- Completion criteria: ①SVG→`is_image`+`image_path`, no vision call, own `<title>/<desc>/<text>` — **met**; ②`image/svg+xml` + `default-src 'none'; sandbox` + `private, max-age=3600`, raster headers byte-unchanged — **met**; ③chat figure → modal `<img>`, no XML — **met**; ④`/document.html` full-width vector in `.doc-image` + text below — **met**; ⑤`![alt](/api/documents/<id>/image)` paints — **met**; ⑥raster/PDF unchanged — **met**; ⑦all gates green — **met**; ⑧commit + phase-dir move — harness-owned.
- Documentation: found and fixed README inconsistencies — stale forward reference "left to phase 181 / is phase 181's job" → completed-tense sizing note; "Updating the documents" standalone-image list missing `svg`; new **Raster only** bullet for question uploads (`.svg` → 422) documenting the `chat_images` guard; missing `svg_indexed=N` / `import: svg indexed` log contract; env-table heading `122/123/124` → `122/123/124/181`; new troubleshooting entry for an `.svg` showing raw XML. Full audit: every `Settings` field and every `.env.example` var is documented; pages/commands/scripts all exist. AGENTS.md verified accurate (phase-181 §5 clause present) — no changes needed beyond what the phase wrote.
- Notable deviation (kept from task 02): `app/api/chat_images.py` subtracts `_VECTOR_IMAGE_EXTENSIONS` from the question-image accept set — a required consequence of LOCKED A7 (`svg` in the default image set) since `_CHAT_IMAGE_FILENAME_RE` refuses to *serve* `.svg`; the regex itself is untouched, as the phase specified.
- Note for the commit: changes include `README.md`, `AGENTS.md` and `.env.example` — the phase file's `git add app/ frontend/ tests/ .env.example .agents/phases/` line omits the two markdown files; stage them too.
- Next pending phase: `182_verbatim_show_text`.
@@ -0,0 +1,139 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 289 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6600 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,18 @@
**Implemented** (task 01 — repo-side only, no live infra touched):
- `app/rag/svg_text.py` (new, pure, stdlib-only, `md_images.py` shape): `MAX_SVG_BYTES=2 MiB`, `MAX_SVG_TEXT_CHARS=4000`, frozen `SvgText(text, structural)`, `extract_svg_text(data, *, stem="")`, `local_name(tag)`.
- Pre-parse guards (LOCKED A3 sibling): empty/whitespace-only, `>MAX_SVG_BYTES`, case-insensitive `<!doctype`/`<!entity` refusal (stdlib otherwise *expands* internal entities — pinned by test), `ParseError` → `None` (never raises).
- Extraction (D2): `title`/`desc` (concat) + `text` (space-joined, covers `<tspan>`) in **document order** by local name (Clark-notation / declared-prefix agnostic), whitespace-normalised, case-insensitive dedupe keeping first, `\n`-joined, entity-decoded, capped at the last newline/period before 4000 (hard cut only if no boundary).
- LOCKED A2 fallback: `Vector diagram "<stem>" — <N> shapes (<top-3 kinds>), viewBox <w>×<h>`; every clause omitted when unstated, `1 shape`/`2 shapes` plural, top-3 by count with document-order tie-break, viewBox requires 4 finite numbers, integral values print without `.0`, empty stem → no empty quotes.
- `tests/unit/test_svg_text_extraction.py` — 45 pure-function tests (guards, order, tspans, namespaces, `xlink:` ignored, dedupe, entities, cap variants, all fallback clause/plural/omission shapes, comments, non-`svg` root).
**Gates (all 100% green):**
- `uv run pytest tests/unit/test_svg_text_extraction.py -q` → 45 passed; `--cov=app.rag.svg_text` → **100%**
- `uv run pytest --cov=app --cov-report=term-missing` → **4243 passed**, TOTAL **99%** (svg_text 111/111)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
- Completion-criteria one-liners verified: `SvgText(text='Top\na', structural=False)`, `Vector diagram "topo" — 1 shape (path)`, DOCTYPE case → `None`.
**Docs:** no doc impact — the module is not yet wired (no user-visible surface); README/AGENTS updates belong to tasks 02–04.
**Decisions:** (1) per-kind count in the fallback listing is shown only when it is >1 (`3 shapes (path×3)` vs `1 shape (path)`), so the task's pinned literal holds while D3's "with counts" still reads off multi-kind files; (2) added a non-finite guard (`NaN`/`inf` viewBox → clause omitted) so the line never states a size the file doesn't; (3) viewBox clause sits after the shape clause joined by `, `, and uses ` — ` as the single separator when shapes are absent (`Vector diagram "canvas" — viewBox 16×9`).
**Next pending task:** `.agents/phases/todo/181_svg_document_preview/02_svg_index_as_image.md`
@@ -0,0 +1,138 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 96%]
........................................................................ [ 98%]
................................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 289 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6579 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,24 @@
All checks green. Report:
**Implemented** (task 02; work was largely staged by earlier context-killed attempts — I verified every step against the task file, ran the gates, and confirmed no gaps):
- `app/config.py`: `image_extensions` default → `png,jpg,jpeg,webp,gif,bmp,svg` + doc-comment on SVG as the one VECTOR member (A1) and `BOR_IMAGES` as the single knob (A7).
- `app/rag/importer.py`: vector branch in `_index_image_file` (`extract_svg_text` replaces `_describe_or_skip` for `.svg`, everything else shared), `svg_indexed` log-only counter + `import: svg indexed … structural=… chars=…` line (AGENTS rule 10), module/branch docstrings, and the D4 fast-path guard `_row_kind_mismatch()` (text-SVG rows convert on the next sync; toggle-off direction untouched per A7).
- Necessary companion: the image branch's own content-digest skip now also requires `doc.is_image` (an ASCII file's text digest equals its byte digest — without it the migration would report `unchanged` forever); documented + pinned by test.
- `app/api/chat_images.py`: question uploads = `image_extension_set − _VECTOR_IMAGE_EXTENSIONS` (`.svg` 422s — a vision endpoint rejects `image/svg+xml` and the serve route's regex would never serve it); `_CHAT_IMAGE_FILENAME_RE` left untouched per phase "NOT touched".
- Docs: `.env.example` (+ vector/BOR_IMAGES=0/paperclip notes), `README.md` formats paragraph, new "SVG is the one vector member" paragraph, settings-table row.
- Tests: new `tests/unit/test_svg_image_ingest.py` (10 tests: no-vision-call proof via sentinel seam, content/summary/chunks/copy/`svg_indexed`, structural-line textless SVG, over-cap + malformed fail-soft (no row, no copy), phase-125 `image_context` prepend, text→image conversion then fast path resumes, raster/vector fast-path regression, `_row_kind_mismatch` truth table); updated `test_image_documents.py`, `test_importer.py` (log line), `test_chat_image_questions.py`.
**Gates**
- `uv run pytest --cov=app --cov-report=term-missing` → **4253 passed**, TOTAL **99%** (`app/rag/svg_text.py` 100%, `app/rag/importer.py` 99% — only pre-existing misses).
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors/0 warnings.
- Targeted: `pytest tests/unit/test_svg_*.py tests/unit/test_image_documents.py tests/unit/test_chat_image_questions.py tests/integration/test_importer{,_dates}.py tests/integration/test_import_docs_git.py --no-cov` → 240 passed.
- E2E regression (image surfaces touched by the default change): `tests/e2e/test_image_documents.py` → 4 passed; `test_chat_image_questions.py` + `test_answer_images.py` → 10 passed.
**Docs**: `README.md` + `.env.example` updated as above; `AGENTS.md` no doc impact (no extension list there).
**Decisions / deviations**
1. Added `doc.is_image` to `_index_image_file`'s unchanged guard (beyond "keep everything after the seam as-is") — required for D4 to work; explained inline and pinned by `test_text_indexed_svg_row_is_converted_on_the_next_sync`.
2. Fail-soft log now reads `import: svg text extraction failed` for the vector case (raster wording byte-identical) for auditability; counter/behaviour unchanged.
3. **Owner action (I did not touch the user-owned live `.env`)**: `.env:63` still pins `BOR_IMAGE_EXTENSIONS=png,jpg,jpeg,webp,gif,bmp`, so the new default has no effect on the running app until `svg` is added (or the line removed) — LOCKED A7 / phase says a live `.env` keeps its value.
**Next pending task**: `.agents/phases/todo/181_svg_document_preview/03_svg_bytes_route_csp.md`
@@ -0,0 +1,139 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................................................ [ 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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 289 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6595 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1 @@
(no final assistant message — see the .err log)
@@ -0,0 +1 @@
error: assistant error: 400: {"message":"litellm.BadRequestError: OpenAIException - prompt exceeds the 131072-token context; increase --context or shorten the convers…
@@ -0,0 +1,139 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................................................ [ 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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 289 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6595 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1 @@
(no final assistant message — see the .err log)
@@ -0,0 +1 @@
error: assistant error: 400: {"message":"litellm.BadRequestError: OpenAIException - prompt exceeds the 131072-token context; increase --context or shorten the convers…
@@ -0,0 +1,139 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................................................ [ 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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 289 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6595 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,13 @@
**Implemented (task 03 — SVG bytes route + CSP)**
- `app/rag/summarizer.py`: `IMAGE_MIMES[".svg"] = "image/svg+xml"` + doc-comment (the one *vector* entry, bytes-route only — the vector import branch never reaches `image_data_url`).
- `app/api/docs.py`: new `SVG_MEDIA_TYPE` (derived from `IMAGE_MIMES[".svg"]`, so guard ≡ Content-Type) and `SVG_SANDBOX_CSP = "default-src 'none'; sandbox"`; `get_document_image` adds that one header **only** when the resolved media type is SVG. Lookup, public id-as-credential posture, four 404 shapes and `Cache-Control: private, max-age=3600` untouched; raster header set byte-identical. Route + module docstrings give the rationale (navigable URL → stored-XSS seam, `sandbox` = opaque origin, middleware preserves an inner CSP, global `nosniff` still applies, no `Content-Disposition`).
- New `tests/integration/test_svg_document_image.py` (6 tests: SVG mime+sandbox CSP+exact bytes incl. an unsanitized `<script>` sentinel; pinned header key-set, no `content-disposition`; guard keyed on the map not the client (`.SVG`, unknown-ext fallback → global CSP); PNG response unchanged (`CSP` constant, no `sandbox`); anonymous/public posture; all 404 shapes incl. non-uuid→404 and deleted copy).
- `tests/unit/test_image_documents.py`: `IMAGE_MIMES` identity pin extended to `.svg` (six raster entries unchanged); dropped the now-unused `_VECTOR_IMAGE_EXTENSIONS` import (still pinned in `test_chat_image_questions.py`).
**Gates** — `uv run pytest --cov=app --cov-report=term-missing`: **4259 passed**, TOTAL **99%** (`app/api/docs.py` 99%, `app/rag/summarizer.py` 100%); `uv run pytest tests/integration/test_svg_document_image.py tests/integration/test_docs_api.py -v` → **65 passed**; `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors. Live check against a real uvicorn: SVG → `content-type: image/svg+xml` + `content-security-policy: default-src 'none'; sandbox`; PNG → `image/png` with no sandbox (seeded rows/copies removed afterwards).
**Docs** — README: SVG paragraph in *Image documents* gained the "served sandboxed" sentence; the middleware-stack area (where A20 headers are described) gained a **Security headers (A20)** note covering the preserved inner CSP. AGENTS.md: no impact (it documents no header behaviour).
**Decisions/deviations** — (1) Constants instead of inline literals (values verbatim per the task) so the guard can't drift from the map. (2) The task's "raster → NO `content-security-policy` key" is unachievable: `SecurityHeadersMiddleware` adds the global A20 CSP to every response, so the raster test pins `CSP == app-wide CSP constant`, no `sandbox`, and an identical header key set — same intent (nothing route-specific added). No live-infra changes.
**Next pending task:** `181_svg_document_preview/04_svg_preview_render.md`.
@@ -0,0 +1,139 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................................................ [ 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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 289 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6600 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,13 @@
All gates green. Final report:
**Implemented (task 04, phase 181):**
- `frontend/assets/document.js::docImageBlock` — adds the `doc-image-img--vector` modifier (base class kept) when `doc.format === "svg"`; full rationale comment (no-intrinsic-size → 300×150 default box, `doc.format` is the only type carrier per D7, LOCKED A3 never-inline, legacy-TEXT XML honesty).
- `frontend/assets/styles.css` — new `.doc-image-img--vector { width: 100%; height: auto; object-fit: contain; }` beside `.doc-image-img` with house comment; palette-neutral, base rule untouched.
- `frontend/assets/app.js::appendSourceImageFigure` — deliberate-non-change comment (96px cap scales size-less SVGs; modal is the preview; no `SourceRef.format` field). **Finding:** no code gap — figure keys off `image_url` only.
- `frontend/assets/sources.js` — verified-non-change comment on the `d.is_image` thumbnail branch. **Finding:** no gap found without needing an E2E: `.kb-doc-thumb-img` pins the img to the fixed 48px box (`width/height: 100%` + `object-fit: cover`), and the KB-tree row schema carries `image_url`, so an SVG thumb fills the box like a raster.
- `tests/unit/test_document_viewer.py` — docstring + 4 new tests: format-gate source pins, node behavioral run (svg → compound class, `src`=`image_url`, alt=summary/title-fallback incl. blank-summary; png → base class byte-identical), CSS pins (decls, zero colour literals, `object-fit: contain`, base rule intact), and D7 non-change pins for figure/thumbnail.
- Docs: README SVG section gains the viewer-sizing sentence + the stale "left to phase 181" forward reference in the phase-180 section updated; AGENTS.md §5 gains the phase-181 layout clause.
**Gates:** `uv run pytest` → 4263 passed; `uv run pytest --cov=app` → TOTAL 99% (>90%); `uv run ruff check .` → clean; `uv run pyright` → 0 errors; raster suites unedited & green: `tests/e2e/test_image_documents.py` (4 passed), `test_prose_word_wrap.py`+`test_pdf_support.py`+`test_pictorial_pdf.py` (14 passed), `test_chat_answer_clusters.py` (4 passed) — all `--no-cov`, in isolation. Phase E2E file itself is task 05's deliverable.
**Next pending task:** `181_svg_document_preview/05_unit_integration_e2e.md`.
@@ -0,0 +1,139 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 289 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6600 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,23 @@
**Phase 181 task 05 complete — all gates green.**
**Implemented**
- `tests/e2e/test_svg_document_preview.py` (new, dedicated module app with `BOR_IMAGES=true` + suite-pinned `BOR_IMAGE_DIR`, phase-138 truncate-then-`import_sources` seeding, `E2E_REAL_LLM` module skip): `test_viewer_renders_svg_not_xml`, `test_chat_figure_and_modal_preview`, `test_blank_svg_is_indexed_and_searchable`, `test_markdown_image_ref_renders` (two-stage seeding), `test_raster_surfaces_unchanged`
- `tests/fixtures/svg_docs/` (new dedicated tree): `diagrams/topology.svg` (viewBox-only, `<title>`/`<desc>`/2 `<text>`, 17 shapes, 2.7 KB), `notes/infra.md` (text co-citation), `diagrams/blank.svg` (pure paths, LOCKED A2 subject)
- Extended `tests/integration/test_svg_document_image.py`: content-endpoint SVG contract (`is_image:true`, `image_url` = `/api/documents/<uuid>/image`, `format:"svg"`, `content` = extracted text, serve round trip) + the wire-contract pin that the literal `image_url` passes phase-180's `allowImageUrl` (regex extracted verbatim from `markdown.js`, with rejecting probes)
- Tasks 01–04's unit files (`test_svg_text_extraction.py`, `test_svg_image_ingest.py`) verified against the task-05 matrix — all items already pinned; no gaps found
**Gates (exact results)**
- `uv run pytest` → **4265 passed** (re-run after audit DB writes: still 4265 passed)
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%**, `app/rag/svg_text.py` **100%** (>90% / ≥95% met)
- `uv run pytest tests/e2e/test_svg_document_preview.py --no-cov` → **5 passed in isolation**
- `uv run ruff check .` → clean; `uv run pyright` → **0 errors**
- `uv run python -m scripts.api_payload_audit` (live seeded app) → rc 0, no LOOK rows; SVG route pinned: `:: api method=GET path=/api/documents/<id>/image status=200 bytes=2763 enc=gzip` — far under the 256 KB budget
**Docs**: no doc impact from this task (README/AGENTS/.env.example were updated by tasks 02/03 and re-verified accurate).
**Decisions/deviations**
- Chat-figure assertion scoped by the figure's `source/path` aria-label: the seed legitimately auto-cites blank.svg too (phase-124 auto-cite), so count-1 is asserted on the topology figure specifically.
- Fixture XML comment must avoid `--` (illegal in comments) — caught and fixed; keeps the fixture honestly parseable.
- No commit/move performed: per the harness's binding rule the pipeline makes the one atomic commit (`feat(kb): index SVGs as image documents and preview them instead of raw XML`, `app/ frontend/ tests/ .env.example .agents/`) and gates the phase-dir move; everything is left staged-ready in the working tree.
**Next pending task**: none in phase 181 (05 was the last); next todo phase is `182_verbatim_show_text`.
@@ -0,0 +1,139 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 289 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6600 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1 @@
(no final assistant message — see the .err log)
@@ -0,0 +1 @@
error: assistant error: 400: {"message":"litellm.BadRequestError: OpenAIException - prompt exceeds the 131072-token context; increase --context or shorten the convers…
@@ -0,0 +1,139 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 289 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6600 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1 @@
(no final assistant message — see the .err log)
@@ -0,0 +1 @@
error: assistant error: 400: {"message":"litellm.BadRequestError: OpenAIException - prompt exceeds the 131072-token context; increase --context or shorten the convers…
@@ -0,0 +1,139 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 289 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6600 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+9 -1
View File
@@ -164,7 +164,15 @@ BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume,
# ignored by the import walks and a sync never prunes existing image
# documents.
# BOR_IMAGES=0 # 1 = index standalone images
# BOR_IMAGE_EXTENSIONS=png,jpg,jpeg,webp,gif,bmp # comma-separated, case-insensitive
# BOR_IMAGE_EXTENSIONS=png,jpg,jpeg,webp,gif,bmp,svg # comma-separated, case-insensitive
# # svg is the one VECTOR member: an .svg indexes from its own
# # <title>/<desc>/<text> (or a structural line when it carries no
# # words) and is NEVER sent to the vision model; it is served as
# # image/svg+xml so the chat figure, the modal and the document
# # viewer show the picture instead of the XML. With BOR_IMAGES=0 an
# # .svg stays a plain text document (raw XML in the viewer).
# # Question images (the paperclip) stay raster-only: an .svg upload
# # is a 422.
# BOR_IMAGE_DIR=~/bor-sources/images # persistent home for the served image bytes
# (uploads are replaced, checkouts re-cloned)
+8 -1
View File
@@ -30,7 +30,14 @@
`javascript:`/`data:` element), a container-capped
`img.md-img` (`max-width: 100%` — no horizontal overflow at 375px)
and an `h5`/`h6` sized into the existing type scale — never printed
reference text (phase 180); the document viewer's page strip is
reference text (phase 180); an SVG document paints as a
graphic — the viewer's image block adds
`.doc-image-img--vector` (`width: 100%`, `object-fit: contain`)
when `format` is `svg` so a `viewBox`-only drawing spans the
`.doc-image` card instead of the 300×150 default box, while the
chat figure and Sources thumbnail keep their fixed caps and the
bytes stay an `<img>` src (never inlined) (phase 181); the
document viewer's page strip is
CLASS-scoped —
the primary class shows the strip in place of the text, the
additive class never hides the text: Text/Pages toggle group
+70 -7
View File
@@ -141,8 +141,10 @@ string, so it carries no inline `onerror` handler — the JS fallbacks live
in `document.js`/`app.js`). `.md-img` carries the app's bordered-image card
treatment and is capped to its container — `max-width: 100%`, `height: auto`
and `object-fit: contain` — so a wide screenshot never produces a horizontal
scrollbar at 375px or at 1440px. Sizing an SVG that declares no `width` or
`height` is deliberately left to phase 181.
scrollbar at 375px or at 1440px. An SVG document that declares no `width`
or `height` is sized by phase 181's `.doc-image-img--vector` class in the
document viewer instead (see "Image documents"); an inline answer image
keeps the container cap above.
**The heading ladder reaches six levels** (phase 180, TODO.md L5): a
`#### x` line in an answer, the thinking block, a saved/shared chat, the
@@ -436,7 +438,9 @@ reflect the new files.
- Non-markdown files get format-aware chunking (YAML keys, JSON keys,
Python classes/defs).
- With `BOR_IMAGES=1`, standalone images (`png, jpg, jpeg, webp, gif,
bmp`) are indexed as documents too — see
bmp, svg`) are indexed as documents too — `svg` is the one **vector**
member, indexed from its own markup text with no vision call and
previewed as a graphic (phase 181) — see
[Image support](#image-support).
- A run that changed the KB also regenerates the **KB overview** and the
**folder summaries** (the drill-down catalog's stored per-folder
@@ -858,10 +862,46 @@ first-class document:
row, no copy), logs a warning, and counts in the import summary line
(`images_failed=N`) — the sync continues.
Accepted formats are `png, jpg, jpeg, webp, gif, bmp`
Accepted formats are `png, jpg, jpeg, webp, gif, bmp, svg`
(`BOR_IMAGE_EXTENSIONS`, case-insensitive) — a separate set from
`BOR_IMPORT_EXTENSIONS`; images are never added via that variable, the
toggle is the single knob.
toggle is the single knob. With `BOR_IMAGES=0` an `.svg` is therefore a
plain **text** document — raw XML in the viewer, no image copy, no
thumbnail — and the walks never admit it as an image (no special-casing
of the off state).
**SVG is the one vector member (phase 181).** An `.svg` takes the same
image path — its bytes are copied to `BOR_IMAGE_DIR` and served as
`image/svg+xml`, so the chat figure, the document modal and the
`/document.html` viewer show the *picture* instead of the XML — but it
is **never sent to the vision model** (vision endpoints reject
`image/svg+xml`). Its searchable text comes out of the file itself: the
`<title>`, `<desc>` and `<text>` words, or — for a drawing that carries
no words at all — one honest structural line built from the file (its
name, its shape census, its `viewBox`). A file the extractor refuses
(over 2 MiB, carrying a `<!DOCTYPE`/`<!ENTITY` declaration, or malformed
XML) fails soft like any other image: no row, no copy, `images_failed`.
A successful one logs one `import: svg indexed source=… path=…
structural=… chars=…` line and adds to the log-only `svg_indexed=N` on
the import summary line (`added`/`updated` count it like any other image
doc). The next sync also **converts** an `.svg` that an earlier run
indexed as plain text (its bytes hash is unchanged, so a hash-only fast
path would freeze it as XML forever) — existing libraries need no
re-import by hand. In the viewer the graphic **spans the image card**: a
`viewBox`-only SVG
carries no intrinsic size, so `doc.format === "svg"` adds
`.doc-image-img--vector` (`width: 100%` + `object-fit: contain`) instead
of letting the drawing sit in the browser's 300×150 default box; the
chat figure and the Sources thumbnail keep their fixed caps (96px /
48px) and the modal is the full-size preview. Question images (the
paperclip) stay raster-only: an `.svg` upload is a 422.
Those bytes are served **sandboxed**: because the
`/api/documents/<id>/image` URL is directly navigable (not just an
`<img>` source, which can never run SVG script), an SVG response carries
`Content-Security-Policy: default-src 'none'; sandbox` so a stored
drawing can never execute script or act with the app's origin — raster
image responses keep their headers exactly as they were.
### Question images
@@ -873,6 +913,12 @@ image:
to `POST /api/chat-images` (capped at `BOR_CHAT_IMAGE_MAX_MB`,
default 10 MiB — over is a 413) and stored under `BOR_CHAT_IMAGE_DIR`.
Saved and shared chats carry the returned *path* — never base64.
- **Raster only (phase 181).** The accepted upload extensions are the
**raster** members of `BOR_IMAGE_EXTENSIONS` — an `.svg` is a 422. The
vector family belongs to indexing, not to question attachments: a
vision endpoint rejects `image/svg+xml`, and the question-image serve
route deliberately never hands out a `.svg`, so accepting one would
store an unanswerable, unservable file.
- **Visible while uploading.** While the upload is in flight the
preview strip shows a spinner + *"Uploading image…"* (its remove ✕
disabled) and the Send button is disabled with the *"Uploading…"*
@@ -1068,6 +1114,16 @@ Outermost to innermost, the stack is security-headers → gzip (only when
security headers land on every response and `bytes=` in the budget line is the
route's own, uncompressed payload (see *Operational notes*):
> **Security headers (A20).** Every response carries the app-wide
> `Content-Security-Policy` (`default-src 'self'` plus the phase-123
> `img-src 'self' data:` carve-out), `X-Frame-Options: DENY` and
> `X-Content-Type-Options: nosniff` — and the middleware *preserves* a
> CSP an inner layer already set. One route uses that deliberately: SVG
> document bytes (phase 181) ship `default-src 'none'; sandbox` instead,
> because a directly-navigated stored `.svg` must never become a
> script-capable same-origin document. Raster/PDF image responses gain
> nothing — their header set is unchanged.
- **HTML pages** ship `Cache-Control: no-cache`, **no `etag`**, **no
`last-modified`** — each visit always gets a fresh 200 body.
- **Assets are versioned and cached for a year.** Pages reference their
@@ -1299,12 +1355,12 @@ startup (the DB rows shadow the env, `.env` is never rewritten).
| `BOR_UPLOAD_MAX_MB` | `512` | Cap (MiB) for uploaded source archives (zip-bomb guard) |
| `BOR_SUGGESTIONS` | built-in list | JSON seed for onboarding chips (shown before the first saved question) |
### Image support (phases 122/123/124)
### Image support (phases 122/123/124/181)
| Env | Default | Meaning |
|-----|---------|---------|
| `BOR_IMAGES` | `0` | Master switch for image support — **off by default; enable only when `BOR_LLM_CHAT_MODEL` supports vision**. Gates both image documents and question images |
| `BOR_IMAGE_EXTENSIONS` | `png,jpg,jpeg,webp,gif,bmp` | Extensions indexed as image documents (case-insensitive; separate from `BOR_IMPORT_EXTENSIONS`) |
| `BOR_IMAGE_EXTENSIONS` | `png,jpg,jpeg,webp,gif,bmp,svg` | Extensions indexed as image documents (case-insensitive; separate from `BOR_IMPORT_EXTENSIONS`). `svg` is the one **vector** member: indexed from its own `<title>`/`<desc>`/`<text>` with **no vision call**, served as `image/svg+xml` for a real preview |
| `BOR_IMAGE_DIR` | `~/bor-sources/images` | Persistent home for the served image bytes (uploads are replaced, checkouts re-cloned) |
| `BOR_CHAT_IMAGE_DIR` | `~/bor-sources/chat-images` | Where question images are stored (saved/shared chats carry the path, never base64) |
| `BOR_CHAT_IMAGE_MAX_MB` | `10` | Question-image upload cap (MiB; must be > 0) |
@@ -1539,6 +1595,13 @@ instead of reaching the app.
off (the default). Set `BOR_IMAGES=1` in `.env` and restart — only
when `BOR_LLM_CHAT_MODEL` supports vision. Turning the toggle off
never destroys existing image documents (syncs prune-guard them).
- **An `.svg` shows raw XML instead of the drawing** — image support is
off, so the file is a plain **text** document (phase 181). Set
`BOR_IMAGES=1` and re-sync: the next run converts the row in place
(bytes copied, `image_url` live, no re-import by hand). An `.svg` the
extractor refuses — over 2 MiB, carrying a `<!DOCTYPE`/`<!ENTITY`
declaration, or malformed XML — stays a text document and is counted
in `images_failed`.
- **Honest deflection** (the amber deflection bubble) — every question
passes the honesty gate: deflection happens
when the best cosine similarity is below `BOR_RELEVANCE_THRESHOLD`
+26 -7
View File
@@ -22,7 +22,9 @@ unguessable ``uuid4().hex`` — no enumeration value).
The extension is the source of truth (the Content-Type header is a
hint — the archive-uploader precedent): it must be in the phase-122
image set (``settings.image_extension_set`` — the same frozenset the
image-document walk uses), lowercased; total bytes are streamed with a
image-document walk uses) minus the vector members
(:data:`_VECTOR_IMAGE_EXTENSIONS` — a question image is RASTER-only,
phase 181), lowercased; total bytes are streamed with a
``chat_image_max_mb`` cap (413, fixed detail naming the cap — never
echoing the filename).
"""
@@ -55,6 +57,20 @@ _STREAM_CHUNK = 1 << 20
#: upload endpoint's own naming).
_CHAT_IMAGE_FILENAME_RE = re.compile(r"^[0-9a-fA-F]{32}\.(png|jpe?g|webp|gif|bmp)$")
#: The VECTOR members of ``settings.image_extension_set`` — excluded
#: from the question-image accept set (phase 181: the vector family
#: belongs to INDEXING, not to question attachments). Two reasons, both
#: hard: an OpenAI-compatible vision endpoint rejects
#: ``image/svg+xml`` (the phase-181 LOCKED A1 reason — the vector file
#: never reaches the describer at all), and
#: :data:`_CHAT_IMAGE_FILENAME_RE` above deliberately refuses to SERVE a
#: ``.svg``, so an accepted upload would be stored, unanswerable and
#: unservable (a broken figure in the user's own bubble). Subtracting
#: instead of touching the serve regex keeps question attachments
#: raster-only by design while ``svg`` rides the image set for the
#: document walk.
_VECTOR_IMAGE_EXTENSIONS: frozenset[str] = frozenset({".svg"})
@router.post("/chat-images")
async def upload_chat_image(
@@ -66,10 +82,12 @@ async def upload_chat_image(
Gates, in order:
1. **extension** — the file name's extension (lowercased) must be in
the phase-122 image set (``settings.image_extension_set``); the
Content-Type header is a hint, never a source of truth (the
archive-uploader precedent). A missing/unknown extension is a
422 naming the accepted set — a fixed detail, no filename echo.
the phase-122 image set (``settings.image_extension_set``)
MINUS the vector members (:data:`_VECTOR_IMAGE_EXTENSIONS` —
question images are RASTER-only, phase 181); the Content-Type
header is a hint, never a source of truth (the archive-uploader
precedent). A missing/unknown/vector extension is a 422 naming
the accepted set — a fixed detail, no filename echo.
2. **size** — the bytes are streamed (1 MiB chunks) with the
``chat_image_max_mb`` cap; over-cap is a 413 naming the cap
(fixed detail — never echoing the filename), the temp file is
@@ -85,8 +103,9 @@ async def upload_chat_image(
settings = get_settings()
filename = file.filename or ""
ext = Path(filename).suffix.lower()
if ext not in settings.image_extension_set:
accepted = ", ".join(sorted(settings.image_extension_set))
accepted_extensions = settings.image_extension_set - _VECTOR_IMAGE_EXTENSIONS
if ext not in accepted_extensions:
accepted = ", ".join(sorted(accepted_extensions))
raise HTTPException(
status_code=422, detail=f"only {accepted} images are accepted"
)
+59 -5
View File
@@ -52,10 +52,15 @@ BYTES route: the persistent copy behind an ``is_image`` document's
``image_path``, served with the extension's ``Content-Type`` (the
``app.rag.summarizer.IMAGE_MIMES`` map — one map, one truth) and a
``Cache-Control: private, max-age=3600`` header (the bytes are
content-hashed — long enough, bustable by re-upload). PUBLIC (phase
124 — the phase-123 chat-image precedent: the image is part of a
shared chat's rendered content, and the shared chat — the app's ONLY
anonymous surface — must render the answer figure faithfully for
content-hashed — long enough, bustable by re-upload), plus — for the
vector ``image/svg+xml`` entry only, phase 181 LOCKED A3 — a
``Content-Security-Policy: default-src 'none'; sandbox`` so a DIRECT
navigation to the URL cannot run script from stored SVG markup (the
``SecurityHeadersMiddleware`` preserves that inner CSP; raster
responses gain nothing). PUBLIC (phase 124 — the phase-123 chat-image
precedent: the image is part of a shared chat's rendered content, and
the shared chat — the app's ONLY anonymous surface — must render the
answer figure faithfully for
guests; the unguessable ``uuid4`` document id IS the credential, no
enumeration value): a missing doc, a non-image doc, a doc whose
``image_path`` is NULL, or a row whose copy was lost all map to 404
@@ -132,6 +137,23 @@ from app.schemas import (
router = APIRouter(tags=["kb"])
#: The vector media type (phase 181, task 03) — read out of
#: :data:`app.rag.summarizer.IMAGE_MIMES` rather than restated, so the
#: guard below can never drift from the ``Content-Type`` the route
#: sends for an ``.svg`` (one map, one truth).
SVG_MEDIA_TYPE = IMAGE_MIMES[".svg"]
#: The CSP an SVG bytes response carries (phase 181, task 03, LOCKED
#: A3). ``sandbox`` — no scripting, and the document gets an OPAQUE
#: origin (no same-origin fetch/cookie/form capability);
#: ``default-src 'none'`` — no subresource of any kind. Scoped to the
#: vector type ON PURPOSE: an ``<img>`` never runs SVG script, but this
#: URL is directly navigable, and the bytes come from an untrusted
#: source tree, so a direct visit must not become a script-capable
#: same-origin document. Raster types are untouched (their header set
#: stays byte-identical). See :func:`get_document_image`.
SVG_SANDBOX_CSP = "default-src 'none'; sandbox"
def doc_format(path: str, extensions: frozenset[str] = frozenset()) -> str:
"""Lowercased path suffix without its dot (``kubernetes.md`` → ``md``,
@@ -353,6 +375,33 @@ def get_document_image(
``application/octet-stream``) and ``Cache-Control: private,
max-age=3600`` (the bytes are content-hashed — long enough to be
useful, bustable by re-upload).
Phase 181 (task 03, LOCKED A3): an ``image/svg+xml`` response — the
vector entry of that same map, derived from the ROW's extension and
NEVER from a client value — carries ONE extra header,
:data:`SVG_SANDBOX_CSP` (``default-src 'none'; sandbox``). An
``<img>`` can never execute an SVG's script, which is why the
figure surfaces are safe as they stand; but this URL is also
DIRECTLY NAVIGABLE (a guest who pastes it into the address bar),
and without the policy a stored SVG would render as a
script-capable SAME-ORIGIN document — the ingestion path takes
untrusted files from a source tree, so that is a stored-XSS seam
scoped to the app's origin. ``sandbox`` blocks scripting AND makes
the response an opaque origin (no same-origin fetches, cookies or
form submission from the document); ``default-src 'none'`` blocks
every subresource load, so the SVG cannot pull a tracker or a
remote image either. Deliberately NOT applied to the raster types:
their header set stays byte-identical (a PNG needs no policy
change, and adding one would risk the phase-123/124 image
surfaces), and no ``Content-Disposition: attachment`` is set — the
graphic must render inline, and ``sandbox`` is the stronger, less
intrusive control. The route sets the value on the response
itself; :class:`app.core.security_headers.SecurityHeadersMiddleware`
PRESERVES a CSP an inner layer already set, so this is the policy
the browser sees (the app-wide ``CSP`` string would be wrong here —
``default-src 'self'`` would grant the SVG document the app's own
origins), while the middleware's global
``X-Content-Type-Options: nosniff`` still applies.
"""
try:
uid = uuid.UUID(doc_id)
@@ -368,10 +417,15 @@ def get_document_image(
# onerror fallback renders the "image unavailable" note.
raise HTTPException(status_code=404, detail="document not found")
media_type = IMAGE_MIMES.get(image_file.suffix.lower(), IMAGE_FALLBACK_MIME)
headers = {"Cache-Control": "private, max-age=3600"}
if media_type == SVG_MEDIA_TYPE:
# Phase 181 (LOCKED A3): vector bytes only — see the route
# docstring. Raster responses keep the header set they had.
headers["Content-Security-Policy"] = SVG_SANDBOX_CSP
return FileResponse(
image_file,
media_type=media_type,
headers={"Cache-Control": "private, max-age=3600"},
headers=headers,
)
+12 -1
View File
@@ -433,7 +433,18 @@ class Settings(BaseSettings):
#: knob). The validator rejects an empty list and malformed tokens,
#: exactly like ``import_extensions`` (a typo would otherwise index
#: zero images silently).
image_extensions: str = "png,jpg,jpeg,webp,gif,bmp"
#:
#: ``svg`` (phase 181, LOCKED A1) is the one VECTOR member of the
#: family: an ``.svg`` takes the same image branch (bytes copied to
#: ``image_dir``, ``is_image``/``image_path``, served as
#: ``image/svg+xml``) but NEVER the vision call — its searchable
#: text comes from the file itself
#: (:func:`app.rag.svg_text.extract_svg_text`, the ``<title>`` /
#: ``<desc>`` / ``<text>`` words or an honest structural line).
#: ``images`` remains the SINGLE knob (LOCKED A7): with it off an
#: ``.svg`` stays a plain text document (raw XML in the viewer) and
#: the walk never admits it as an image — no special-casing.
image_extensions: str = "png,jpg,jpeg,webp,gif,bmp,svg"
#: Where ingested image bytes are copied for serving (phase 122,
#: ``BOR_IMAGE_DIR``). Raw string — ``Path.expanduser()`` is applied
#: by the importer, not here (the ``sources_dir``/``upload_dir``
+125 -11
View File
@@ -111,6 +111,20 @@ summary (stored verbatim, no ``lite`` call, no pointer line), so the
``is_summary`` position −1 chunk mirrors ``Document.summary``, which
equals ``Document.content``.
The image branch is RASTER (vision-described, the paragraph above) OR
VECTOR (phase 181, LOCKED A1: an ``.svg``). A vector file takes the
SAME branch — bytes digest, ``image_dir`` copy, ``is_image``/``image_path``,
the ``summary = content`` convention, the phase-125 ``image_context``
enrichment — but NEVER the model call: its searchable text is extracted
from the file itself (:func:`app.rag.svg_text.extract_svg_text` — the
``<title>``/``<desc>``/``<text>`` words, or LOCKED A2's structural line
built from the stem, the shape census and the ``viewBox`` when the
drawing carries no words at all). A file the extractor refuses (over the
size cap, DOCTYPE/entity-bearing, malformed XML) fails soft exactly like
an undescribed raster: ``images_failed`` + a warning, no row, no copy.
Successful vector indexes count ``svg_indexed`` (log-only) and log one
``import: svg indexed`` line.
PDF files (phase 126, LOCKED A1/A2/A3/A4): a ``.pdf`` file (the
``pdf`` token of ``BOR_IMPORT_EXTENSIONS``) takes the PDF index path
(:func:`_index_pdf_file` — the branch sits in :func:`_index_file`
@@ -359,6 +373,7 @@ from app.rag.summarizer import (
describe_pdf_page,
generate_summary,
)
from app.rag.svg_text import SvgText, extract_svg_text
logger = logging.getLogger("app.importer")
@@ -433,6 +448,14 @@ class ImportSummary:
#: Fail-soft: the sync continues, this counter + the warning line
#: are the signal.
images_failed: int = 0
#: Vector image docs (phase 181, task 02 — LOCKED A1) indexed from
#: their OWN extracted text (:func:`app.rag.svg_text.extract_svg_text`)
#: with NO vision call. A subset of the image branch: it does NOT
#: move ``added``/``updated`` (those count as for any other image).
#: Log-only (the sync ``detail`` API shape is untouched — the stats
#: log-only convention, phase 133): the owner reads it in the sync
#: log beside the per-file ``import: svg indexed`` line.
svg_indexed: int = 0
#: PDFs (phase 133, task 03) whose text layer failed the garbage
#: gate (:func:`app.rag.pdf.is_usable_pdf_text` — empty layer or
#: XML/binary soup) and took the page-screenshot vision fallback
@@ -498,7 +521,7 @@ class ImportSummary:
logger.info(
"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 "
"summary_backfilled=%d dates_updated=%d images_failed=%d svg_indexed=%d "
"image_context_backfilled=%d pdfs_screenshotted=%d "
"pdfs_pictorial=%d pdf_pictorial_pages=%d pdf_images_deduped=%d "
"pdf_images_blank_skipped=%d formats=%s",
@@ -515,6 +538,7 @@ class ImportSummary:
self.summary_backfilled,
self.dates_updated,
self.images_failed,
self.svg_indexed,
self.image_context_backfilled,
self.pdfs_screenshotted,
self.pdfs_pictorial,
@@ -1325,6 +1349,38 @@ def _regate_pdf_strip(
)
def _row_kind_mismatch(path: Path, doc: Document, settings: Settings) -> bool:
"""Phase 181 (task 02, design D4): does this row's KIND disagree with
how *path* would be indexed NOW — the ONE case that must fall
THROUGH the phase-147 unchanged fast path?
``True`` only while the ``images`` toggle is ON and the file matches
the image extension set while the row is still a TEXT row
(``is_image`` False). That is exactly the migration this phase needs:
an ``.svg`` imported before ``svg`` joined
:py:attr:`Settings.image_extension_set` is a text document whose
``content`` is the raw XML — and because the text digest of an ASCII
file equals the digest of its bytes, its ``file_hash`` (and even its
``content_hash``) matches perfectly, so a hash-only fast path would
freeze it as XML forever: no bytes route, no preview, no
``image_url`` citation figure — the exact complaint this phase
answers. Falling through runs the image branch, which rewrites the
row (``is_image``/``image_path``/extracted ``content`` + the
``image_dir`` copy) through the normal index write: no alembic
migration, no backfill script, and the next sync is fast again.
The REVERSE direction is deliberately NOT forced: with the toggle
OFF the walk never admits an image file (so an ``is_image`` row never
reaches :func:`_index_file` at all) and the phase-122 prune guard
keeps the row; requiring ``settings.images`` here keeps that
contract byte-identical (``svg`` in ``BOR_IMPORT_EXTENSIONS`` with
the toggle off still means "text document", LOCKED A7).
"""
if not settings.images or doc.is_image:
return False
return match_extension(path, settings.image_extension_set) is not None
async def _index_file(
session: Session,
*,
@@ -1400,10 +1456,17 @@ async def _index_file(
# fast path (and the branches' unchanged path) refresh the
# stored date from the same source.
raw_date = file_mtime_datetime(full_path)
if doc is not None and doc.file_hash == file_digest:
if (
doc is not None
and doc.file_hash == file_digest
and not _row_kind_mismatch(full_path, doc, settings)
):
# The fast path: the raw bytes are what was indexed last time
# — the bookkeeping (backfill / D1 / D4) runs in the shared
# helper, nothing else does.
# helper, nothing else does. Phase 181 (task 02, D4): "the same
# bytes" is only "the same document" when the row's KIND also
# matches how the file would be indexed NOW — see
# :func:`_row_kind_mismatch` (the SVG text→image migration).
await _unchanged_doc(
session, doc, source=source, rel=rel, llm=llm, summary=summary,
raw_date=raw_date, file_digest=file_digest, full_path=full_path,
@@ -3542,7 +3605,17 @@ async def _index_image_file(
on_chunk: Callable[[int, int], None] | None = None,
) -> None:
"""The phase-122 image branch of :func:`_index_file` — a standalone
image is indexed from its BYTES, never its text:
image is indexed from its BYTES, never its text.
Phase 181 (task 02, LOCKED A1) makes the branch RASTER **or**
VECTOR: an ``.svg`` takes this same path (digest, copy, ``is_image``,
``image_path``, chunking, ``summary = content``, the phase-125
``image_context`` enrichment) but its ``content`` comes from
:func:`app.rag.svg_text.extract_svg_text` instead of the vision
seam, and a success counts the log-only ``svg_indexed``. The bullets
and the fail-soft below are shared by both kinds — the only
difference is where the text comes from.
* the sha256 digest is over the raw bytes (the digest rule is
content identity — the same bytes are the same document);
@@ -3600,13 +3673,20 @@ async def _index_image_file(
digest = hashlib.sha256(data).hexdigest()
doc = session.scalar(select(Document).where(Document.source == source, Document.path == rel))
if doc is not None and doc.content_hash == digest:
if doc is not None and doc.is_image and doc.content_hash == digest:
# Phase 147: the shared unchanged path — a pre-phase NULL-hash
# row, or a bytes-changed/content-same image (the byte digest
# still matches). The helper stamps the new file hash (the
# next sync takes the fast path); for an image the summary is
# the stored description (``doc.content``), so the image-aware
# ``_store_summary`` backfill re-stores it verbatim.
# Phase 181 (task 02, D4): ``doc.is_image`` joins the guard — a
# NON-image row routed here by :func:`_row_kind_mismatch` is the
# migration case, not an unchanged one. An ASCII SVG indexed as
# TEXT digests to the SAME value here (the text path hashes
# ``content.encode()``, which for a byte-clean file equals the
# raw-bytes digest), so without it the text→image conversion
# would report ``unchanged`` forever and never produce the copy.
await _unchanged_doc(
session, doc, source=source, rel=rel, llm=llm, summary=summary,
raw_date=raw_date, file_digest=file_digest, full_path=full_path,
@@ -3629,16 +3709,50 @@ async def _index_image_file(
# it is generated BEFORE anything is written, so a failure skips the
# doc with no row and no copy (the copy is only made after a
# successful description — a failure never leaves an orphan).
content = await _describe_or_skip(
llm, data=data, source=source, rel=rel, full_path=full_path,
context=image_context,
)
#
# Phase 181 (task 02, LOCKED A1): the VECTOR branch. An ``.svg`` is
# described by NOBODY — OpenAI-compatible vision endpoints reject
# ``image/svg+xml`` and no rasteriser dependency is in scope — so
# its text comes out of the file itself (its ``<title>``/``<desc>``/
# ``<text>`` words, or LOCKED A2's structural line). Everything else
# in this function is shared: the byte digest, the ``image_dir``
# copy, ``is_image``/``image_path``, the title, the counters, the
# phase-125 ``image_context`` prepend below, the chunk pipeline and
# ``summary = content``. The seam below is not reached, so no vision
# call happens for a vector file (pinned by the unit tests).
is_vector = full_path.suffix.lower() == ".svg"
svg: SvgText | None = None
if is_vector:
svg = extract_svg_text(data, stem=full_path.stem)
content = None if svg is None else svg.text
else:
content = await _describe_or_skip(
llm, data=data, source=source, rel=rel, full_path=full_path,
context=image_context,
)
if content is None:
# LOCKED A3 fail-soft: an undescribed image is unsearchable
# noise — skip the doc entirely (no row, no copy).
# noise — skip the doc entirely (no row, no copy). Phase 181
# (LOCKED A2): the vector case skips ONLY when the extractor
# refused the file (over the size cap, DOCTYPE/entity-bearing,
# malformed XML) — a textless SVG is NOT a failure, it gets the
# structural line.
summary.images_failed += 1
logger.warning("import: image description failed source=%s path=%s", source, rel)
logger.warning(
"import: %s source=%s path=%s",
"svg text extraction failed" if is_vector else "image description failed",
source, rel,
)
return
if svg is not None:
# Phase 181 (task 02): the log-only vector counter + the
# auditability line (AGENTS rule 10) — ``structural=True`` marks
# the docs whose text is the LOCKED A2 fallback line.
summary.svg_indexed += 1
logger.info(
"import: svg indexed source=%s path=%s structural=%s chars=%d",
source, rel, svg.structural, len(svg.text),
)
if image_context is not None:
# Phase 125 (task 03): the enriched content — the reference
# context FIRST (for retrieval), then the description. The
+12
View File
@@ -149,6 +149,17 @@ PICTORIAL_PROMPT = f"{PICTORIAL_PAGE_MODE}: {PICTORIAL_INSTRUCTION}"
#: takes the :data:`IMAGE_FALLBACK_MIME` data-URL mime in the describe
#: call — the vision endpoint may reject it, and the fail-soft skip
#: (``images_failed``) is the honest outcome.
#:
#: Phase 181 adds ``.svg`` — the ONE **vector** entry in the map. It
#: exists for the BYTES route only: the vector import branch
#: (``app/rag/importer.py::_index_image_file``) takes
#: :func:`app.rag.svg_text.extract_svg_text` INSTEAD of the vision
#: describe call (LOCKED A1), so an ``.svg`` never reaches
#: :func:`image_data_url` and this value is never put in a multimodal
#: message. ``app/api/docs.py::get_document_image`` reads the same map
#: for the ``Content-Type`` (one map, one truth), and that route is
#: where the vector value earns its place — plus the sandboxed CSP the
#: vector bytes need and the rasters do not (LOCKED A3, phase 181).
IMAGE_MIMES: dict[str, str] = {
".png": "image/png",
".jpg": "image/jpeg",
@@ -156,6 +167,7 @@ IMAGE_MIMES: dict[str, str] = {
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".svg": "image/svg+xml",
}
#: The data-URL mime for an image extension :data:`IMAGE_MIMES` does not
+293
View File
@@ -0,0 +1,293 @@
"""SVG text extraction — the words a vector drawing carries (phase 181).
Pure functions, no I/O — fully unit-testable. Stdlib only (the
:mod:`app.rag.md_images` precedent, phase 125).
An ``.svg`` joins the image family (LOCKED A1, phase 181): its BYTES are
served to the browser so every citation surface paints the graphic
instead of XML. But OpenAI-compatible vision endpoints reject
``image/svg+xml``, and adding a rasteriser dependency is out of scope —
so the vector branch never calls the describer. The text that makes the
document **retrievable** comes from the file itself:
* ``<title>``, ``<desc>`` and ``<text>`` (LOCKED D2) — collected in
DOCUMENT order by LOCAL name (namespace-agnostic: ``<title>`` and
``{http://www.w3.org/2000/svg}title`` both count, declared ``svg:``
prefixes included), whitespace-normalised, de-duplicated
case-insensitively keeping the FIRST occurrence, joined with ``\\n``.
``<tspan>`` children are joined WITH a space so ``a<tspan>b</tspan>``
reads "a b", not "ab". XML entity references inside the text
(``&amp;``) are decoded by the parser — the correct direction, the
renderer re-escapes on display.
* a textless diagram (pure ``<path>``s — extremely common) still gets
honest, searchable text (LOCKED A2): one structural line built ONLY
from facts the file states, never invented prose.
Guards, applied BEFORE any parse (untrusted markup, the sibling of
LOCKED A3):
* empty / whitespace-only input → ``None``;
* more than :data:`MAX_SVG_BYTES` → ``None`` (memory guard);
* a ``<!DOCTYPE`` or ``<!ENTITY`` declaration anywhere in the bytes →
``None``. Refusing the doctype outright is what makes stdlib
:mod:`xml.etree.ElementTree` safe here: no external entities, no
entity expansion (the billion-laughs class);
* malformed XML (:class:`xml.etree.ElementTree.ParseError`) → ``None``
— never re-raised, the caller's fail-soft is a skip, not a crash.
What is deliberately NOT followed: ``<image>`` elements embedding a
raster inside the SVG (their ``href`` is not text), and
``aria-label``/``role`` attributes — ``<title>``/``<desc>`` are the
SVG-spec carriers of words and the fallback line covers the rest.
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass
from xml.etree import ElementTree
#: Hard input cap for one SVG (phase 181, LOCKED A3's sibling) — bytes
#: over this are refused without a parse attempt, so a hostile or
#: accidentally gigantic file can never be the memory event of a sync.
MAX_SVG_BYTES: int = 2 * 1024 * 1024
#: Cap on the EXTRACTED text (phase 181). It binds on the result, never
#: silently mid-word: an over-cap extraction is cut at the last newline
#: or period before the cap (a hard cut only when the window holds no
#: boundary at all).
MAX_SVG_TEXT_CHARS: int = 4000
#: A document type declaration in the raw bytes (lower-cased marker).
#: Its presence — anywhere — refuses the input (billion-laughs /
#: external-entity class).
_DOCTYPE_MARKER = b"<!doctype"
#: An entity declaration in the raw bytes (lower-cased marker) — same
#: refusal as :data:`_DOCTYPE_MARKER`, checked separately so a stray
#: ``<!ENTITY`` inside a stray doctype-less prolog is still caught.
_ENTITY_MARKER = b"<!entity"
#: Elements whose whole text block is one item, nested markup
#: concatenated (:meth:`ElementTree.Element.itertext`).
_BLOCK_TEXT_NAMES = frozenset({"title", "desc"})
#: Elements whose text is joined with a space so inline children
#: (``<tspan>``) do not weld their neighbours together.
_SPACED_TEXT_NAMES = frozenset({"text"})
#: Local names that are structure, not drawing: never counted as shapes
#: in the fallback line (:func:`_shape_clause`).
_NON_SHAPE_NAMES = frozenset({"svg", "title", "desc"})
#: How many element kinds the fallback line names (LOCKED D3: "the
#: three most common element local names with counts").
_TOP_SHAPE_NAMES = 3
#: ``viewBox`` components: four numbers separated by whitespace and/or
#: commas (the SVG-spec grammar, which allows either).
_VIEWBOX_SPLIT_RE = re.compile(r"[\s,]+")
#: The multiplication sign used between a shape name and its count, and
#: between the viewBox width and height — the app's own typography.
_TIMES = "×"
@dataclass(frozen=True)
class SvgText:
"""The searchable text of one SVG document (phase 181, LOCKED A1/A2).
* ``text`` — what gets stored as the document's ``content`` (and
``summary``, per the phase-122 image convention);
* ``structural`` — ``True`` when the document carried no words and
``text`` is the structural fallback line, so the importer can log
and count it separately from a genuinely worded diagram.
"""
text: str
structural: bool
def local_name(tag: object) -> str:
"""The namespace-agnostic name of an element tag.
``{http://www.w3.org/2000/svg}title`` and ``title`` both give
``"title"`` — ElementTree expands every declared prefix (including
``<svg:title>``) into Clark notation, so stripping the ``{uri}`` is
all "namespace-agnostic" needs. A non-string tag (comment /
processing-instruction marker) has no name → ``""``.
"""
if not isinstance(tag, str):
return ""
return tag.rsplit("}", 1)[-1]
def extract_svg_text(data: bytes, *, stem: str = "") -> SvgText | None:
"""Turn raw SVG bytes into the document's searchable text.
Returns ``None`` when the input is unusable or unsafe: empty /
whitespace-only, larger than :data:`MAX_SVG_BYTES`, carrying a
``<!DOCTYPE``/``<!ENTITY`` declaration, or not well-formed XML. The
caller's fail-soft (skip the document, count it) is the honest
outcome — this function never raises for bad input.
*stem* is the file name without extension: it names the document in
the structural fallback line and is never invented.
Otherwise a :class:`SvgText` whose ``text`` is the document-order
``<title>``/``<desc>``/``<text>`` words (``structural=False``), or —
when the file carries no words at all — the structural line built
from the stem, the shape census and the ``viewBox``
(``structural=True``, LOCKED A2).
"""
if not data or not data.strip():
return None
if len(data) > MAX_SVG_BYTES:
return None
lowered = data.lower()
if _DOCTYPE_MARKER in lowered or _ENTITY_MARKER in lowered:
return None
try:
root = ElementTree.fromstring(data)
except ElementTree.ParseError:
return None
items = _dedupe(_walk_text(root))
if items:
return SvgText(text=_cap_text("\n".join(items)), structural=False)
return SvgText(text=_structural_line(root, stem), structural=True)
def _walk_text(root: ElementTree.Element) -> list[str]:
"""Every worded ``title``/``desc``/``text`` block, in document order.
Items are whitespace-normalised; a block that normalises to nothing
(an empty ``<title></title>``) is dropped so the fallback can still
describe the file.
"""
items: list[str] = []
for el in root.iter():
name = local_name(el.tag)
if name in _BLOCK_TEXT_NAMES:
raw = "".join(el.itertext())
elif name in _SPACED_TEXT_NAMES:
raw = " ".join(el.itertext())
else:
continue
normalised = " ".join(raw.split())
if normalised:
items.append(normalised)
return items
def _dedupe(items: list[str]) -> list[str]:
"""Drop repeats case-insensitively, keeping the FIRST occurrence.
A ``<title>`` mirrored in a ``<desc>`` (exporters do this) is one
sentence in the index, not two; the first position in document
order is the one kept.
"""
seen: set[str] = set()
unique: list[str] = []
for item in items:
key = item.casefold()
if key in seen:
continue
seen.add(key)
unique.append(item)
return unique
def _cap_text(text: str) -> str:
"""Cap *text* at :data:`MAX_SVG_TEXT_CHARS` without cutting a word.
An over-cap text is cut at the LAST newline or period before the cap
(whichever comes later) so the stored text ends on a boundary; only
a window with no boundary at all (one colossal run of characters)
is hard-cut.
"""
if len(text) <= MAX_SVG_TEXT_CHARS:
return text
window = text[:MAX_SVG_TEXT_CHARS]
cut = max(window.rfind("\n"), window.rfind("."))
if cut < 0:
return window
return text[: cut + 1].rstrip()
def _structural_line(root: ElementTree.Element, stem: str) -> str:
"""The LOCKED A2 fallback: one line of facts, nothing invented.
``Vector diagram "<stem>" — <N> shapes (<top-3 kinds>), viewBox
<w>×<h>`` — each fact clause is omitted when the file does not
state it, and the stem clause disappears entirely when empty (no
empty quotes). With no facts and no stem the line is just
``Vector diagram``.
"""
head = f'Vector diagram "{stem}"' if stem else "Vector diagram"
facts = [c for c in (_shape_clause(root), _viewbox_clause(root)) if c]
if not facts:
return head
return f"{head} — {', '.join(facts)}"
def _shape_clause(root: ElementTree.Element) -> str:
"""``<N> shapes (<kinds>)`` — the drawing's element census.
*N* counts every descendant element except ``svg``, ``title`` and
``desc`` (structure, not drawing). The kinds are the
:data:`_TOP_SHAPE_NAMES` most common local names, ordered by count
(document order breaks ties) and annotated with their count — the
count is left off a kind that appears exactly once, which is what
the total already says. ``""`` when there are no shapes at all, so
the clause is omitted rather than claiming "0 shapes".
"""
counts: dict[str, int] = {}
first_seen: dict[str, int] = {}
total = 0
for index, el in enumerate(root.iter()):
if el is root:
continue
name = local_name(el.tag)
if not name or name in _NON_SHAPE_NAMES:
continue
counts[name] = counts.get(name, 0) + 1
first_seen.setdefault(name, index)
total += 1
if not total:
return ""
top = sorted(counts, key=lambda name: (-counts[name], first_seen[name]))
listing = ", ".join(
f"{name}{_TIMES}{counts[name]}" if counts[name] > 1 else name
for name in top[:_TOP_SHAPE_NAMES]
)
noun = "shape" if total == 1 else "shapes"
return f"{total} {noun} ({listing})"
def _viewbox_clause(root: ElementTree.Element) -> str:
"""``viewBox <w>×<h>`` from the root's ``viewBox``, or ``""``.
The spec grammar is four whitespace-and/or-comma separated numbers;
the last two are the width and height. Unparseable (wrong count,
non-numeric, non-finite) → ``""`` — the clause is omitted rather
than reporting a size the file does not state. Integral values
print without a trailing ``.0``.
"""
raw = (root.get("viewBox") or "").strip()
if not raw:
return ""
parts = [part for part in _VIEWBOX_SPLIT_RE.split(raw) if part]
if len(parts) != 4:
return ""
try:
_min_x, _min_y, width, height = (float(part) for part in parts)
except ValueError:
return ""
if not (math.isfinite(width) and math.isfinite(height)):
return ""
return f"viewBox {_format_number(width)}{_TIMES}{_format_number(height)}"
def _format_number(value: float) -> str:
"""``8`` for 8.0, ``8.5`` for 8.5 — no trailing ``.0`` in a size."""
return str(int(value)) if value.is_integer() else str(value)
+11 -1
View File
@@ -2498,7 +2498,17 @@ function appendRelated(wrap, related) {
* replaced, in place, with a plain `.source-chip` anchor for the same
* document — the citation survives a lost byte copy, never a
* broken-image icon (the row's bytes route 404s when the copy was
* lost). */
* lost).
*
* Phase 181 (task 04, design D7): DELIBERATE NON-CHANGE for SVG. The
* figure keys off image_url alone, and a size-less (viewBox-only)
* vector drawing scales INSIDE the fixed cap of .source-image-img
* (max-height: 96px + max-width: 100% + object-fit: contain), so the
* compact chat citation needs no format-aware sizing; the full-size
* preview is the modal this figure/chip opens (doc.format is known
* there — see docImageBlock in document.js). NO format field is
* added to SourceRef — the wire contract stays byte-identical for
* every text doc (the phase-83/122 contract). */
function appendSourceImageFigure(meta, s) {
const label = `${s.source}/${s.path}`;
const fig = document.createElement("a");
+20 -1
View File
@@ -573,7 +573,26 @@ function docImageBlock(doc) {
const wrap = document.createElement("div");
wrap.className = "doc-image";
const img = document.createElement("img");
img.className = "doc-image-img";
// Phase 181 (task 04, design D6/D7): SVG is a VECTOR image doc. A
// drawing carrying only a viewBox has NO intrinsic size, so the
// default replaced-element box (300×150) would sit small inside the
// full-width .doc-image card; the --vector modifier (the base class
// is KEPT so every current rule still applies) lets width: 100%
// span the card — the full-size preview. doc.format is the ONLY
// place in the app that knows the type: the bytes URL is
// extension-less and SourceRef carries no format field by design
// (D7). LOCKED A3: the SVG is NEVER inlined into the DOM —
// img.src = doc.image_url stays createElement + property
// assignment (this file's XSS contract, unchanged). Legacy
// honesty (D6): a row still indexed as TEXT (an .svg under
// BOR_IMAGES=0, or not yet re-synced) keeps rendering its XML in
// .doc-raw — the truth about that row (there are no bytes behind
// it); task 02's kind-mismatch guard converts it on the next sync.
// Nobody "fixes" that by sniffing the content string.
img.className =
doc.format === "svg"
? "doc-image-img doc-image-img--vector"
: "doc-image-img";
img.src = doc.image_url;
img.alt =
typeof doc.summary === "string" && doc.summary.trim() !== ""
+6 -1
View File
@@ -2324,7 +2324,12 @@ export async function mount(root) {
// Phase 122 (task 04): an image row gets the FIXED 48px thumbnail
// box before the path link (the .kb-doc-path flex wrapper — the
// link keeps its ellipsis). Text rows keep the bare-link cell,
// byte-identical to pre-phase (no box at all).
// byte-identical to pre-phase (no box at all). Phase 181 (task
// 04, design D7): verified NON-CHANGE for SVG rows — the thumb
// loads the same extension-less image_url and .kb-doc-thumb-img
// pins the img to the fixed 48px box (width/height 100% +
// object-fit: cover), so a size-less viewBox-only vector fills
// the box like a raster; no format sniffing needed here either.
if (d.is_image) {
const pathWrap = document.createElement("div");
pathWrap.className = "kb-doc-path";
+17
View File
@@ -5742,6 +5742,23 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
border-radius: var(--radius-sm);
background: var(--bg);
}
/* The vector variant (phase 181, task 04, design D6 — applied in
document.js when doc.format === "svg", base class kept). An SVG
carrying only a viewBox has NO intrinsic size, so without an
explicit width the default replaced-element box (300×150) applies
and the drawing sits small inside the full-width .doc-image card;
width: 100% spans the card (the full-size preview the owner
asked for). object-fit: contain protects the drawing from being
squashed when the base rule's max-height: 70vh clamps a tall
vector — the box letterboxes, the graphic keeps its aspect ratio.
Static content — no animation (nothing for prefers-reduced-motion
to still). Palette-neutral: no colour here — the base rule's --bg
shows through (phase-08 tokens only, no new hue). */
.doc-image-img--vector {
width: 100%;
height: auto;
object-fit: contain;
}
/* The onerror fallback (the route's 404 — the row exists but the
copy was lost): a muted note in the card's place-of-image; the
description below still renders, so the doc stays readable. Phase
+607
View File
@@ -0,0 +1,607 @@
"""Phase 181 E2E (Playwright): SVG documents preview as graphics, not XML.
Source: ``TODO.md`` L3 — "Need SVG render support on the front end when
citing and svg source. Also would be nice to click an svg and see a
preview in the RAG tab rather than the xml".
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov
The suite's OWN module app (the phase-122 ``test_image_documents.py``
pattern) runs with ``BOR_IMAGES=true`` and every suite-writable home
(``BOR_IMAGE_DIR`` included) pinned into the suite's tmp dir — process
env ranks above an operator's local gitignored ``.env``, so the toggle
cannot leak in either direction. Seeding is the phase-138 pattern
(``test_markdown_blockquotes`` / ``test_markdown_inline_links_images``):
truncate, then run the REAL importer in-process against a COPY of the
phase's dedicated fixture tree ``tests/fixtures/svg_docs/`` (the shared
fixture set's counts are pinned by other suites and must not move) with
the deterministic mock embeddings — the vector branch of task 02
indexes both SVGs with NO vision call, the PNG half of the last test is
described by the mock's ``IMAGE_DESCRIPTION`` branch.
Test → story mapping (Playwright Mapping Rule):
1. ``test_viewer_renders_svg_not_xml`` — the Sources row for
``diagrams/topology.svg`` opens the modal showing
``img.doc-image-img--vector`` (``naturalWidth > 0``, non-empty alt),
NO ``pre.doc-raw`` and no ``<svg`` XML text anywhere in the modal
body, the extracted ``<title>`` text below the graphic; the "Full
page" escape hatch renders the same graphic at ≥ 600px width at a
1280px viewport (the task-04 vector sizing contract — a
``viewBox``-only SVG would otherwise sit in the 300×150 default box).
2. ``test_chat_figure_and_modal_preview`` — the grounded scripted-read
question cites the SVG doc as the ``.source-image`` figure (the
``<img>`` loads, the caption is the extracted text), and clicking
the figure opens the modal with the full graphic (the "preview in
the RAG tab" contract); Escape closes it.
3. ``test_blank_svg_is_indexed_and_searchable`` — ``diagrams/blank.svg``
(pure paths) exists as an image document whose content is the
structural LOCKED A2 line ``Vector diagram "blank" — …``, is listed
on the Sources page with its thumbnail, and opens in the modal with
that line as its description.
4. ``test_markdown_image_ref_renders`` — the two-stage seeding: import
the tree, read the SVG doc's id from the DB, write
``notes/inline.md`` containing ``![topology](/api/documents/<id>/image)``
and re-import; the viewer for that document shows ``img.md-img``
pointing at the bytes route and LOADING (``naturalWidth > 0``) — the
phase-180 renderer + this route end to end.
5. ``test_raster_surfaces_unchanged`` — the smoke guard: a PNG doc in
the same tree still renders through ``.doc-image-img`` (no
``--vector`` class) and its bytes route carries NO sandbox CSP (the
app-wide policy only) — phase 122/124 behaviour byte-unchanged.
"""
from __future__ import annotations
import asyncio
import base64
import json
import os
import shutil
import subprocess
import sys
from collections.abc import Iterator
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import select, text
from app.config import Settings
from app.db import SessionLocal
from app.models import Chunk, Document
from app.rag.agent import IMAGE_DOC_MARKER
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from app.rag.svg_text import extract_svg_text
from e2e.auth_helpers import login
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
if USE_REAL_LLM:
pytest.skip(
"phase 181 pins the deterministic mock's grounded turn and seeded "
"figures — the real model answers differently",
allow_module_level=True,
)
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "svg_docs"
#: The source name = the imported tree root's basename (the importer's
#: ``archive_source_name``/directory rule — the phase-180 pattern).
SOURCE = "svg_docs"
TOPO_PATH = "diagrams/topology.svg"
BLANK_PATH = "diagrams/blank.svg"
#: The fixture diagram's own words — what the vector branch indexes
#: (LOCKED A1) and what every text assertion below pins. Pinned as a
#: literal here (not re-derived from the extractor) so a change to the
#: extraction contract breaks this suite LOUDLY, next to the product.
TOPO_TEXT = (
"Homelab network topology\n"
"Wiring diagram of the homelab: the edge router uplinks two managed "
"switches, the hypervisor rack and the nas storage shelf.\n"
"rack-1 switch\n"
"nas storage"
)
#: The grounded scripted-read question (phase-118 A1: a zero-read turn
#: chips nothing — the mock emits the scripted ``read`` then echoes the
#: result, the phase-122 figure precedent). Token-overlap cosines
#: against the mock embeddings: topology.svg ≈0.70, the infra.md text
#: co-citation ≈0.72 (both over the mock-calibrated 0.30 gate); the
#: blank.svg line sits at ≈0.04 and never pollutes the citation set.
QUESTION = (
"Read the suggested document: read svg_docs/diagrams/topology.svg — "
"what does the homelab network topology diagram show about the edge "
"router, rack-1 switch and the nas storage?"
)
#: A real 1×1 PNG (the phase-122/124 suites' bytes) — the raster smoke
#: guard of the last test, written into the suite's tree copy only there.
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
)
# ---------------------------------------------------------------------------
# Module app: BOR_IMAGES pinned true, the image dir in the suite's scratch
# (the in-process importer and the app under test SHARE that directory —
# the copies the seed writes are the bytes the route serves).
# ---------------------------------------------------------------------------
def _app_env(mock_llm: int, port: int, scratch: Path) -> dict[str, str]:
"""The conftest app env (leak guards included) with the phase-181
knobs: ``BOR_IMAGES=true`` EXPLICITLY (an operator's local ``.env``
cannot leak the toggle off) and ``BOR_IMAGE_DIR`` pinned into the
suite's scratch (the app must never read or write the owner's real
image home)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = f"http://127.0.0.1:{mock_llm}/v1"
# Mock-calibrated gate (conftest pattern) — QUESTION grounds on these.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# Leak guards (conftest pattern).
env["BOR_GIT_SOURCES"] = ""
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
Settings.model_fields["suggestions"].default
)
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
# Phase 181: the toggle under test + the suite-private homes.
env["BOR_IMAGES"] = "true"
env["BOR_UPLOAD_DIR"] = str(scratch / "uploads")
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
env["BOR_IMAGE_DIR"] = str(_image_dir(scratch))
return env
def _image_dir(scratch: Path) -> Path:
return scratch / "images"
@pytest.fixture(scope="module")
def scratch(tmp_path_factory: pytest.TempPathFactory) -> Path:
return tmp_path_factory.mktemp("bor_svg_preview")
@pytest.fixture(scope="module")
def app_server(
mock_llm: int, scratch: Path
) -> Iterator[str]:
"""The suite's own app — ``BOR_IMAGES=true`` (the conftest session app
is never started in this isolated run, so no port clash)."""
port = int(os.environ.get("E2E_APP_PORT_SVG_PREVIEW", "8196"))
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
# ---------------------------------------------------------------------------
# Seeding: the phase-138 truncate-then-import pattern against a per-test
# COPY of the dedicated fixture tree (the two-stage test appends a file to
# the copy, never to the tracked fixture).
# ---------------------------------------------------------------------------
def _fresh_tree(scratch: Path) -> Path:
"""A pristine copy of the fixture tree at ``<scratch>/src/svg_docs``
(the basename IS the source name). Rmtree-first: the two-stage test
leaves an ``inline.md`` behind, and every other test must not see it."""
tree = scratch / "src" / SOURCE
if tree.exists():
shutil.rmtree(tree)
tree.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(FIXTURES, tree)
return tree
async def _import(tree: Path, mock_llm: int, scratch: Path) -> ImportSummary:
"""The REAL importer over the fixture copy (task 02's vector branch
runs in-process against the mock — the same LLM the app under test
talks to). A coroutine: the caller runs it through
:func:`_run_in_thread` (Playwright owns the test thread's loop)."""
settings = Settings(
# the owner's .env cannot leak the vector knobs (_env_file is a
# runtime-only pydantic-settings kwarg — pyright cannot see it).
_env_file=None, # pyright: ignore[reportCallIssue]
llm_base_url=f"http://127.0.0.1:{mock_llm}/v1",
images=True, # the toggle under test (LOCKED A7's on state)
image_dir=str(_image_dir(scratch)),
image_extensions=Settings.model_fields["image_extensions"].default,
)
return await import_sources([tree], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread — Playwright's sync API keeps an
asyncio loop on the test thread (the house helper, phase 180)."""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _truncate() -> None:
"""Fresh KB per test (the suites share one Postgres). ``sources_meta``
resets with the KB — the re-import's bump UPSERTS the row back
(the phase-122 truncate list, ``bump_sources_version``)."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, steering_notes, "
"kb_overview, folder_summaries, sources_meta"
)
)
db.commit()
def _seed(mock_llm: int, scratch: Path) -> ImportSummary:
"""Truncate + import the fresh fixture copy. Both SVGs arrive through
the VECTOR branch (``svg_indexed == 2`` — no vision call happened),
infra.md as the text co-citation: three docs total."""
_truncate()
summary = _run_in_thread(_import(_fresh_tree(scratch), mock_llm, scratch))
assert (summary.files, summary.added, summary.images_failed) == (3, 3, 0)
assert summary.svg_indexed == 2, summary
return summary
def _doc(source: str, path: str) -> Document:
with SessionLocal() as db:
doc = db.scalar(
select(Document).where(Document.source == source, Document.path == path)
)
assert doc is not None, f"no documents row for ({source!r}, {path!r})"
return doc
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 _skip_without_db(app_url: str) -> None:
if not _health_db(app_url):
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
def _drill(page: Page, name: str) -> None:
"""One level of the KB tree (the phase-122/180 click, made settle-
safe): the drill keeps rendering the CURRENT level while the node
fetch runs (phase 167), so the level is settled exactly when the
clicked link is gone from the replaced table (intermediate levels
with no direct files must not wait on an empty ``#docs-tbody``)."""
link = page.locator(f'#folders-tbody a.folder-link:text-is("{name}")')
expect(link).to_be_visible(timeout=30_000)
link.click()
expect(link).to_have_count(0)
def _open_doc_modal(page: Page, app_url: str, folder: str, doc_path: str) -> Any:
"""Sources → source → folder → the document's row → the same-page
modal. Returns the modal locator (loaded)."""
login(page, app_url) # lands on /sources.html (admin)
_drill(page, SOURCE)
_drill(page, folder)
row = page.locator("#docs-tbody tr", has_text=doc_path)
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
modal = page.locator("#doc-modal")
expect(modal).to_be_visible(timeout=30_000)
return modal
def _seed_page(page: Page, app_url: str, mock_llm: int, scratch: Path) -> None:
_skip_without_db(app_url)
page.set_default_timeout(30_000)
_seed(mock_llm, scratch)
# ---------------------------------------------------------------------------
# 1. The viewer: the SVG paints as a graphic — the XML is nowhere
# ---------------------------------------------------------------------------
def test_viewer_renders_svg_not_xml(
page: Page, app_url: str, mock_llm: int, scratch: Path
) -> None:
"""TODO.md L3's viewer half: the modal shows the RENDERED vector
graphic (the ``--vector`` sizing class, task 04) from the bytes
route, alt = the extracted text; NO raw-XML surface exists — no
``pre.doc-raw``, no ``<svg`` markup text anywhere in the modal body
— and the extracted ``<title>`` sentence reads as the description
below the graphic. The "Full page" escape hatch (the
``/document.html`` viewer) spans the drawing: ≥ 600px wide at a
1280px viewport, where the 300×150 replaced-element default box
would have sat small (the phase's sizing contract)."""
_seed_page(page, app_url, mock_llm, scratch)
doc = _doc(SOURCE, TOPO_PATH)
assert doc.is_image is True # the premise: a vector IMAGE row
modal = _open_doc_modal(page, app_url, "diagrams", TOPO_PATH)
expect(page.locator("#doc-modal-title")).to_have_text("topology")
img = modal.locator("img.doc-image-img--vector")
expect(img).to_have_count(1)
expect(img).to_be_visible(timeout=15_000)
assert img.evaluate("el => el.naturalWidth") > 0, "the SVG bytes did not paint"
assert img.evaluate("el => el.naturalHeight") > 0
alt = img.get_attribute("alt")
assert alt is not None and "Homelab network topology" in alt
assert img.get_attribute("src") == f"/api/documents/{doc.id}/image"
expect(modal.locator(".doc-image-unavailable")).to_have_count(0)
# The honest negative (the owner's complaint, verbatim): NO XML text
# and no raw-code surface for an SVG row.
expect(modal.locator("pre.doc-raw")).to_have_count(0)
body_text = modal.inner_text()
assert "<svg" not in body_text, "raw XML text painted into the modal"
assert "</title>" not in body_text
# The extracted words ARE the document's readable content, below
# the graphic (the phase-165 prose arm — summary === content).
prose = modal.locator(".doc-prose")
expect(prose).to_have_count(1)
expect(prose).to_contain_text("Homelab network topology")
expect(prose).to_contain_text("rack-1 switch")
# The Full page escape hatch: the same graphic, full column width.
href = page.locator("#doc-modal-open").get_attribute("href")
assert href is not None and "/document.html" in href
page.goto(app_url + href)
viewer_img = page.locator(".doc-image-img--vector")
expect(viewer_img).to_be_visible(timeout=15_000)
box = viewer_img.bounding_box()
assert box is not None and box["width"] >= 600, (
f"the viewBox-only SVG did not span the viewer: {box!r}"
)
# ---------------------------------------------------------------------------
# 2. The chat figure + the click-through preview in the RAG tab
# ---------------------------------------------------------------------------
def test_chat_figure_and_modal_preview(
page: Page, app_url: str, mock_llm: int, scratch: Path
) -> None:
"""TODO.md L3's citation half: the grounded question whose answer
cites the SVG doc (the scripted read echoes the read result — the
``IMAGE_DOC_MARKER`` line + the extracted words) shows the compact
``.source-image`` figure whose ``<img>`` LOADS from the bytes route
with the caption settling to the extracted text; clicking the
figure opens the SAME-page modal showing the full graphic (the
"click an svg … see a preview in the RAG tab rather than the xml"
contract) and Escape closes it."""
_seed_page(page, app_url, mock_llm, scratch)
doc = _doc(SOURCE, TOPO_PATH)
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(bubble).to_contain_text(IMAGE_DOC_MARKER, timeout=30_000)
expect(bubble).to_contain_text("Homelab network topology", timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
# The citation figure (phase 130: the figure IS the citation).
# Scoped by the figure's aria-label (the source/path label) — the
# seed also carries blank.svg, and an image doc the seed surfaces
# is auto-cited too (phase 124); the ASSERTED figure is the
# topology doc's.
row = page.locator(".msg.brain .msg-meta.msg-images")
expect(row).to_have_count(1)
fig = row.locator(f'.source-image[aria-label="{SOURCE}/{TOPO_PATH}"]')
expect(fig).to_have_count(1)
fig_img = fig.locator(".source-image-img")
expect(fig_img).to_be_visible(timeout=15_000)
assert fig_img.evaluate("el => el.naturalWidth") > 0, (
"the chat figure did not load the SVG bytes"
)
assert fig_img.get_attribute("src") == f"/api/documents/{doc.id}/image"
caption = fig.locator(".source-image-caption")
expect(caption).to_contain_text("Homelab network topology", timeout=15_000)
# The click → modal preview: the full graphic, no XML, and Escape.
fig.click()
modal = page.locator("#doc-modal")
expect(modal).to_be_visible(timeout=30_000)
modal_img = modal.locator("img.doc-image-img--vector")
expect(modal_img).to_have_count(1)
expect(modal_img).to_be_visible(timeout=15_000)
assert modal_img.evaluate("el => el.naturalWidth") > 0
assert "<svg" not in modal.inner_text()
page.keyboard.press("Escape")
expect(modal).to_be_hidden()
# ---------------------------------------------------------------------------
# 3. The textless drawing: indexed with the structural line, previewable
# ---------------------------------------------------------------------------
def test_blank_svg_is_indexed_and_searchable(
page: Page, app_url: str, mock_llm: int, scratch: Path
) -> None:
"""LOCKED A2: ``diagrams/blank.svg`` carries NO words — pure paths —
yet it is NOT skipped: it exists as an image document whose indexed
text is the structural line built only from facts in the file
(``Vector diagram "blank" — <N> shapes (…), viewBox 640×480``), the
line is embedded (searchable), and the Sources page lists the doc
with its rendered thumbnail; the modal shows the graphic with that
line as the description below it."""
_seed_page(page, app_url, mock_llm, scratch)
doc = _doc(SOURCE, BLANK_PATH)
assert doc.is_image is True and doc.image_path is not None
assert doc.content.startswith('Vector diagram "blank" —')
assert "viewBox 640×480" in doc.content
assert doc.summary == doc.content
# Searchable: the structural line is an EMBEDDED chunk (both the
# content chunk and the is_summary mirror).
with SessionLocal() as db:
chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all()
assert [c.content for c in chunks] and all(
c.content == doc.content for c in chunks
)
assert all(c.embedding is not None and len(c.embedding) == 768 for c in chunks)
login(page, app_url)
_drill(page, SOURCE)
_drill(page, "diagrams")
row = page.locator("#docs-tbody tr", has_text=BLANK_PATH)
expect(row).to_have_count(1)
thumb = row.locator(".kb-doc-thumb-img")
expect(thumb).to_have_count(1)
expect(thumb).to_be_visible(timeout=15_000)
assert thumb.evaluate("el => el.naturalWidth") > 0, "the thumbnail is not painted"
# The modal: the graphic + the honest structural description (the
# file's only possible words).
row.locator("td:nth-child(2) a.doc-link").click()
modal = page.locator("#doc-modal")
expect(modal).to_be_visible(timeout=30_000)
expect(modal.locator("img.doc-image-img--vector")).to_have_count(1)
expect(modal.locator(".doc-prose")).to_contain_text('Vector diagram "blank"')
expect(modal.locator("pre.doc-raw")).to_have_count(0)
# ---------------------------------------------------------------------------
# 4. The phase-180 synergy: ![alt](/api/documents/<id>/image) paints
# ---------------------------------------------------------------------------
def test_markdown_image_ref_renders(
page: Page, app_url: str, mock_llm: int, scratch: Path
) -> None:
"""The two-stage seeding the phases' hand-off needs: import the tree,
read the SVG doc's id from the DB, THEN write ``notes/inline.md``
containing ``![topology](/api/documents/<id>/image)`` and re-import
(one doc added, the SVG row untouched — the fast path). The viewer
for that document renders ``img.md-img`` pointing at the bytes route
and LOADING it (``naturalWidth > 0``) — phase 180's allowlist
accepts the route shape, phase 181's route answers it, and the
reference never prints as text."""
_seed_page(page, app_url, mock_llm, scratch)
topo = _doc(SOURCE, TOPO_PATH)
image_url = f"/api/documents/{topo.id}/image"
assert extract_svg_text # the extractor produced the row's text (premise)
tree = scratch / "src" / SOURCE
(tree / "notes" / "inline.md").write_text(
"# Inline reference notes\n\n"
"The topology drawing, inline:\n\n"
f"![topology]({image_url})\n\n"
"Same route, inside a link: [the diagram](/sources.html).\n",
encoding="utf-8",
)
second = _run_in_thread(_import(tree, mock_llm, scratch))
assert second.added == 1, second
assert second.svg_indexed == 0, "the unchanged SVG must take the fast path"
modal = _open_doc_modal(page, app_url, "notes", "notes/inline.md")
md_img = modal.locator("img.md-img")
expect(md_img).to_have_count(1)
assert md_img.get_attribute("src") == image_url
assert md_img.get_attribute("alt") == "topology"
expect(md_img).to_be_visible(timeout=15_000)
assert md_img.evaluate("el => el.naturalWidth") > 0, (
"the bytes route did not answer the markdown reference"
)
# The reference renders — it never prints.
assert "![topology](" not in modal.inner_text()
# ---------------------------------------------------------------------------
# 5. The smoke guard: raster image docs are byte-unchanged
# ---------------------------------------------------------------------------
def test_raster_surfaces_unchanged(
page: Page, app_url: str, mock_llm: int, scratch: Path
) -> None:
"""Phase 122/124 behaviour survives the vector branch: a PNG doc in
the SAME tree is described through the vision seam (the mock's
fixed answer), renders through the plain ``.doc-image-img`` with NO
``--vector`` class, and its bytes route carries NO sandbox CSP — the
app-wide middleware policy only, the pre-phase header set."""
_seed_page(page, app_url, mock_llm, scratch)
tree = scratch / "src" / SOURCE
(tree / "diagrams" / "raster.png").write_bytes(PNG_1X1)
summary = _run_in_thread(_import(tree, mock_llm, scratch))
assert (summary.files, summary.added) == (4, 1)
assert summary.svg_indexed == 0
doc = _doc(SOURCE, "diagrams/raster.png")
assert doc.is_image is True
image_url = f"/api/documents/{doc.id}/image"
login(page, app_url)
_drill(page, SOURCE)
_drill(page, "diagrams")
row = page.locator("#docs-tbody tr", has_text="diagrams/raster.png")
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
modal = page.locator("#doc-modal")
expect(modal).to_be_visible(timeout=30_000)
img = modal.locator(".doc-image-img")
expect(img).to_have_count(1)
expect(img).to_be_visible(timeout=15_000)
# The raster class string is UNTOUCHED by the vector modifier.
assert (img.get_attribute("class") or "") == "doc-image-img"
expect(modal.locator("img.doc-image-img--vector")).to_have_count(0)
resp = page.request.get(app_url + image_url)
assert resp.status == 200
assert resp.headers["content-type"] == "image/png"
csp = resp.headers.get("content-security-policy", "")
assert "sandbox" not in csp, "the SVG sandbox policy leaked onto a raster route"
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Phase 181 fixture: the TEXTLESS drawing (the LOCKED A2 class) — pure
paths and rects, no <title>, no <desc>, no <text>. The extractor's
structural fallback line is the only text this document can ever
carry, built from facts in the file: the stem, the shape census and
the viewBox. The phase's E2E pins the indexed content to
Vector diagram "blank" — … and the Sources thumbnail to the graphic.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
<rect x="20" y="20" width="600" height="440" fill="none" stroke="#455a64" stroke-width="3"/>
<path d="M80 120 L240 120 L240 240 L80 240 Z" fill="#cfd8dc" stroke="#455a64" stroke-width="3"/>
<path d="M400 120 L560 120 L560 240 L400 240 Z" fill="#cfd8dc" stroke="#455a64" stroke-width="3"/>
<path d="M240 180 H400" stroke="#0288d1" stroke-width="4"/>
<path d="M320 240 V360" stroke="#0288d1" stroke-width="4"/>
<path d="M200 360 H440" stroke="#0288d1" stroke-width="4"/>
<rect x="280" y="340" width="80" height="60" fill="#eceff1" stroke="#455a64" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Phase 181 fixture: a hand-written homelab wiring diagram.
Deliberate properties (the phase's E2E contract, tests/e2e/
test_svg_document_preview.py):
* a viewBox and NO width/height attributes — a vector drawing with no
intrinsic size, the exact class the phase-181 vector sizing class
(`doc-image-img` plus its `vector` modifier) exists for — the
300x150 default box would sit small inside the full-width card;
* its words live in <title>, <desc> and two <text> labels — what
app.rag.svg_text.extract_svg_text indexes (LOCKED A1, no vision call);
* a dozen plain <path>/<rect> shapes give the drawing real geometry
while the title/desc/text carry the searchable words.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
<title>Homelab network topology</title>
<desc>Wiring diagram of the homelab: the edge router uplinks two managed switches, the hypervisor rack and the nas storage shelf.</desc>
<rect x="40" y="40" width="1120" height="720" fill="none" stroke="#37474f" stroke-width="4"/>
<rect x="120" y="96" width="240" height="120" rx="12" fill="#eceff1" stroke="#37474f" stroke-width="3"/>
<path d="M160 156 h160 M240 126 v60" stroke="#546e7a" stroke-width="5"/>
<rect x="480" y="96" width="240" height="120" rx="12" fill="#eceff1" stroke="#37474f" stroke-width="3"/>
<path d="M520 136 h160 M520 166 h160 M520 196 h160" stroke="#546e7a" stroke-width="4"/>
<rect x="840" y="96" width="240" height="120" rx="12" fill="#eceff1" stroke="#37474f" stroke-width="3"/>
<path d="M880 136 h160 M880 166 h160 M880 196 h160" stroke="#546e7a" stroke-width="4"/>
<path d="M360 156 C 420 156 420 156 480 156" fill="none" stroke="#0288d1" stroke-width="5"/>
<path d="M720 156 C 780 156 780 156 840 156" fill="none" stroke="#0288d1" stroke-width="5"/>
<path d="M600 216 C 600 300 600 340 600 400" fill="none" stroke="#0288d1" stroke-width="5"/>
<rect x="360" y="400" width="240" height="140" rx="12" fill="#e8f5e9" stroke="#2e7d32" stroke-width="3"/>
<path d="M400 440 h160 M400 470 h160 M400 500 h160" stroke="#2e7d32" stroke-width="4"/>
<rect x="680" y="400" width="240" height="140" rx="12" fill="#fff3e0" stroke="#ef6c00" stroke-width="3"/>
<path d="M720 440 h160 M720 470 h160 M720 500 h160" stroke="#ef6c00" stroke-width="4"/>
<path d="M480 540 C 480 620 600 620 600 620 M720 540 C 720 620 600 620 600 620" fill="none" stroke="#78909c" stroke-width="4"/>
<path d="M600 620 v100" fill="none" stroke="#78909c" stroke-width="4"/>
<text x="180" y="250" font-family="sans-serif" font-size="34" fill="#37474f">rack-1 switch</text>
<text x="700" y="590" font-family="sans-serif" font-size="34" fill="#ef6c00">nas storage</text>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+14
View File
@@ -0,0 +1,14 @@
# Homelab infrastructure notes
The wiring diagram `diagrams/topology.svg` — titled "Homelab network
topology" — is the current-state reference for the rack layout. When a
question is about the homelab network topology, that diagram is the
picture to open: it shows the edge router uplinking two managed
switches, the hypervisor rack and the nas storage shelf.
Uplink rules, restated in prose so the words are searchable next to the
diagram: the edge router hands one 10Gb link to rack-1 switch and one
to the second switch; the hypervisor VLANs live on rack-1 switch, and
nas storage sits behind the second switch on its own segment. The
diagram carries no other labels — anything else about the network
belongs in this note, not in the drawing.
@@ -0,0 +1,443 @@
"""Integration tests: phase 181 (task 03) — the SVG half of the image
BYTES route ``GET /api/documents/{doc_id}/image``.
The contract this module pins (LOCKED A3):
* an ``is_image`` row whose stored copy is an ``.svg`` serves
``Content-Type: image/svg+xml`` (the ``app.rag.summarizer.IMAGE_MIMES``
map — one map, one truth with the describe call's data-URL mime) plus
ONE extra response header, ``Content-Security-Policy: default-src
'none'; sandbox``. An ``<img>`` can never run an SVG's script, but the
URL is DIRECTLY navigable, and the bytes come from an untrusted source
tree — so a direct visit must not hand the browser a script-capable
SAME-ORIGIN document. The route sets the policy itself and
``SecurityHeadersMiddleware`` PRESERVES a CSP an inner layer already
set, so this is what the browser sees; the middleware's global
``X-Content-Type-Options: nosniff`` and ``X-Frame-Options: DENY`` still
land on the response;
* the SVG bytes are served VERBATIM (no rewriting, no
``Content-Disposition: attachment`` — the graphic must render inline
and the policy is the control, not a sanitizer);
* every RASTER response is byte-identical to its pre-phase shape — the
same header KEY set, the app-wide CSP from the middleware (no
``sandbox``, no route-specific policy), the same ``Cache-Control`` and
the same exact bytes;
* the phase-122 posture is unchanged: PUBLIC (phase 124 LOCKED A4 — the
unguessable ``uuid4`` id IS the credential, the shared chat shows the
figure to a guest) with the four 404 ``document not found`` shapes
(unknown id, unparseable id — 404 and never 422, a text doc, a row
whose copy is gone from disk).
Task 05 of this phase adds the INGESTION-TO-SERVE round trip: the
task-03 content contract on ``GET /api/documents/content`` for an SVG
row (``is_image: true``, an ``image_url`` of the form
``/api/documents/<uuid>/image``, ``format: "svg"`` and the extracted
``<title>``/``<desc>``/``<text>`` words as ``content``) and the
WIRE-CONTRACT PIN that ties the phases together: that literal
``image_url`` is accepted by phase 180's ``allowImageUrl`` renderer
allowlist — the ``![alt](<image_url>)`` synergy (an answer or a
markdown document referencing the bytes route paints the graphic).
Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient.
"""
from __future__ import annotations
import hashlib
import re
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
import app.api.docs as docs_api
from app.core.security_headers import CSP
from app.main import app as fastapi_app
from app.models import Chunk, Document
from app.rag.summarizer import IMAGE_MIMES
from app.rag.svg_text import extract_svg_text
#: The phase-180 shared renderer — the file whose ``allowImageUrl``
#: allowlist decides whether a ``![alt](src)`` reference paints an
#: ``img.md-img`` or stays inert text (the wire-contract pin below).
MARKDOWN_JS = Path(__file__).resolve().parents[2] / "frontend" / "assets" / "markdown.js"
#: The 1×1 PNG the raster control row stores (the ``test_docs_api.py``
#: fixture's bytes — a real PNG header so a Content-Type mix-up is
#: visible in the value, not just in the length).
PNG_1X1 = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000d4944415478da63fcffff3f0300050201000d5ca5e90000000049454e44ae426082"
)
#: One SVG carrying BOTH its own words and a ``<script>`` sentinel. The
#: script is what makes the pin meaningful: the route does NOT sanitize
#: (it streams the stored bytes untouched — the viewer needs the real
#: graphic), so the ONLY thing standing between a stored SVG and a
#: script-capable document is the policy this module pins.
SVG_WITH_SCRIPT = (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">'
b"<title>Topology</title><desc>Two nodes, one link</desc>"
b'<text x="8" y="12">edge router</text>'
b"<path d='M10 60 L110 20'/>"
b"<script>alert('probe')</script></svg>"
)
#: The response-header KEY set both the raster and the vector serve share
#: (FileResponse's own stat headers + the route's Cache-Control + the
#: three the SecurityHeadersMiddleware adds). Pinned so a future change
#: cannot silently drop or add a header on either branch — the vector
#: branch differs from the raster branch in the VALUE of one key, never
#: in the key set.
SERVE_HEADER_KEYS = {
"accept-ranges",
"cache-control",
"content-length",
"content-security-policy",
"content-type",
"etag",
"last-modified",
"x-content-type-options",
"x-frame-options",
}
def _cleanup_kb(db) -> None:
"""Truncate the KB tables (commit first — SQLAlchemy does NOT
autoflush pending ORM objects before a raw ``text()`` statement, and
a pending chunk INSERT flushed after the TRUNCATE would FK-violate)."""
db.commit()
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
@pytest.fixture()
def kb(db) -> Iterator:
"""The real Postgres with the KB tables emptied before and after the
test (the module's own rows are the only ones present)."""
_cleanup_kb(db)
yield db
_cleanup_kb(db)
def _seed_image_row(
db,
tmp_path: Path,
*,
source: str = "SvgSrc",
path: str = "diagram.svg",
data: bytes = SVG_WITH_SCRIPT,
content: str = "Topology — Two nodes, one link — edge router",
image_path: str | None = "auto",
) -> Document:
"""One ``is_image`` document row plus its persistent copy (the
importer's ``image_dir`` layout — ``<image_dir>/<doc-id><ext>``)
under *tmp_path*. ``image_path="auto"`` writes the copy at the
importer's layout; ``None`` leaves the row without a copy path; any
other string pins the column verbatim (the lost-copy corner writes
the copy and unlinks it in the test itself)."""
doc = Document(
id=uuid.uuid4(),
source=source,
path=path,
full_path=f"/tmp/{path}",
title=Path(path).stem,
content=content,
content_hash=hashlib.sha256(data).hexdigest(),
indexed_at=datetime.now(UTC),
created_at=datetime(2026, 1, 1, tzinfo=UTC),
summary=content,
is_image=True,
)
if image_path == "auto":
copy = tmp_path / f"{doc.id}{Path(path).suffix}"
copy.write_bytes(data)
doc.image_path = str(copy)
elif image_path is not None:
doc.image_path = image_path
db.add(doc)
db.flush()
db.add_all(
[
Chunk(
document_id=doc.id, position=0, content=content, embedding=[0.01] * 768
),
Chunk(
document_id=doc.id,
position=-1,
content=content,
embedding=[0.01] * 768,
is_summary=True,
),
]
)
db.commit()
return doc
def _seed_text_row(db, *, source: str = "TextSrc", path: str = "note.md") -> uuid.UUID:
"""One plain TEXT document (never ``is_image``) — a 404 shape."""
doc = Document(
id=uuid.uuid4(),
source=source,
path=path,
full_path=f"/tmp/{path}",
title=Path(path).stem,
content="# Note\n\nBody.",
content_hash=hashlib.sha256(path.encode("utf-8")).hexdigest(),
indexed_at=datetime.now(UTC),
created_at=datetime(2026, 1, 1, tzinfo=UTC),
)
db.add(doc)
db.commit()
return doc.id
# --- the vector serve contract ------------------------------------------
def test_svg_row_serves_the_vector_mime_with_the_sandboxed_csp(
kb, tmp_path: Path, admin_client: TestClient
) -> None:
"""An SVG row → 200 ``image/svg+xml`` + the sandboxed CSP + the
unchanged Cache-Control, and the body is byte-equal to the stored
copy (the script sentinel included — the route is a transport, not a
sanitizer; ``sandbox`` blocks scripting and makes the document an
opaque origin, ``default-src 'none'`` blocks every subresource)."""
doc = _seed_image_row(kb, tmp_path)
assert doc.image_path is not None
r = admin_client.get(f"/api/documents/{doc.id}/image")
assert r.status_code == 200
assert r.headers["content-type"] == "image/svg+xml"
# LOCKED A3, verbatim.
assert r.headers["content-security-policy"] == "default-src 'none'; sandbox"
assert r.headers["cache-control"] == "private, max-age=3600"
assert r.headers["x-content-type-options"] == "nosniff"
assert r.headers["x-frame-options"] == "DENY"
assert r.content == SVG_WITH_SCRIPT # the EXACT stored bytes, nothing stripped
assert Path(doc.image_path).read_bytes() == r.content
def test_svg_route_header_key_set_is_the_serve_surface(
kb, tmp_path: Path, admin_client: TestClient
) -> None:
"""The vector response's header KEY set equals the shared serve
surface — one extra VALUE (the CSP), no extra header, and no
``content-disposition`` (the graphic must render inline)."""
doc = _seed_image_row(kb, tmp_path)
r = admin_client.get(f"/api/documents/{doc.id}/image")
assert set(r.headers) == SERVE_HEADER_KEYS
assert "content-disposition" not in r.headers
def test_svg_mime_guard_is_derived_from_the_map_never_from_the_client(
kb, tmp_path: Path, admin_client: TestClient
) -> None:
"""The guard keys on the resolved ``media_type`` — the extension map
(lower-cased, from the ROW's stored path), never a client value: an
upper-case ``.SVG`` copy is still vector, and an extension the map
does not name still takes the generic fallback with NO sandbox."""
assert docs_api.SVG_MEDIA_TYPE == IMAGE_MIMES[".svg"] == "image/svg+xml"
assert docs_api.SVG_SANDBOX_CSP == "default-src 'none'; sandbox"
upper = _seed_image_row(kb, tmp_path, path="diagram.SVG", source="UpperSrc")
assert upper.image_path is not None
upper_copy = Path(upper.image_path)
upper_copy.unlink()
upper_copy = upper_copy.with_name(f"{upper.id}.SVG")
upper_copy.write_bytes(SVG_WITH_SCRIPT)
upper.image_path = str(upper_copy)
kb.commit()
r = admin_client.get(f"/api/documents/{upper.id}/image")
assert r.status_code == 200
assert r.headers["content-type"] == "image/svg+xml"
assert r.headers["content-security-policy"] == docs_api.SVG_SANDBOX_CSP
unknown = _seed_image_row(
kb, tmp_path, path="mystery.bin", source="UnknownSrc", data=PNG_1X1
)
r = admin_client.get(f"/api/documents/{unknown.id}/image")
assert r.status_code == 200
assert r.headers["content-type"] == "application/octet-stream"
# No sandbox — the policy is scoped to the vector type only.
assert r.headers["content-security-policy"] == CSP
# --- the raster branch is untouched -------------------------------------
def test_raster_row_response_headers_are_unchanged_by_the_svg_rule(
kb, tmp_path: Path, admin_client: TestClient
) -> None:
"""A PNG row is served EXACTLY as before phase 181: ``image/png``,
the same Cache-Control, the same header KEY set, and a CSP equal to
the app-wide A20 string from ``SecurityHeadersMiddleware`` — no
route-specific policy, no ``sandbox`` anywhere in it. The byte
equality of the body is the raster half of "nothing changed"."""
doc = _seed_image_row(
kb,
tmp_path,
source="PngSrc",
path="pic.png",
data=PNG_1X1,
content="A red square on a white background.",
)
r = admin_client.get(f"/api/documents/{doc.id}/image")
assert r.status_code == 200
assert r.headers["content-type"] == "image/png"
assert r.headers["cache-control"] == "private, max-age=3600"
assert r.headers["content-security-policy"] == CSP
assert "sandbox" not in r.headers["content-security-policy"]
assert r.headers["x-content-type-options"] == "nosniff"
assert set(r.headers) == SERVE_HEADER_KEYS
assert r.content == PNG_1X1
# --- the public posture and the 404 shapes (unchanged) ------------------
def test_svg_bytes_are_public_with_the_sandbox(
kb, tmp_path: Path, admin_client: TestClient
) -> None:
"""Phase 124 LOCKED A4 stays intact for the vector type: a FRESH
client (no session — the shared chat's guest) gets the same bytes
and the same sandboxed CSP as a signed-in one. The policy is not an
access control (the unguessable ``uuid4`` id is), so making the
response safe does not make it private."""
doc = _seed_image_row(kb, tmp_path)
anonymous = TestClient(fastapi_app)
r = anonymous.get(f"/api/documents/{doc.id}/image")
assert r.status_code == 200
assert r.headers["content-type"] == "image/svg+xml"
assert r.headers["content-security-policy"] == "default-src 'none'; sandbox"
assert r.content == SVG_WITH_SCRIPT
r_admin = admin_client.get(f"/api/documents/{doc.id}/image")
assert r_admin.status_code == 200
assert r_admin.content == r.content
def test_svg_route_404_shapes(kb, tmp_path: Path, admin_client: TestClient) -> None:
"""Every non-servable case is still 404 ``document not found`` —
including the SVG rows: an unknown uuid, a NON-uuid string (404, not
422 — a guessed id is an unknown document), a TEXT document (no
bytes behind it), an image row whose ``image_path`` is NULL, and an
SVG row whose copy was deleted from disk."""
text_doc_id = _seed_text_row(kb)
no_copy = _seed_image_row(kb, tmp_path, path="nocopy.svg", image_path=None)
lost = _seed_image_row(kb, tmp_path, path="lost.svg", source="LostSrc")
assert lost.image_path is not None
Path(lost.image_path).unlink()
for doc_id in (
str(uuid.uuid4()), # unknown id
"not-a-uuid", # unparseable id → 404, never 422
str(text_doc_id), # text document
str(no_copy.id), # image row, image_path NULL
str(lost.id), # image row, copy gone from disk
):
r = admin_client.get(f"/api/documents/{doc_id}/image")
assert r.status_code == 404, doc_id
assert r.json() == {"detail": "document not found"}, doc_id
# --- the content endpoint on an SVG row (task 05, the ingestion shape) ---
def test_content_endpoint_serves_the_svg_row_as_a_vector_image(
kb, tmp_path: Path, admin_client: TestClient
) -> None:
"""``GET /api/documents/content`` for an SVG row carries the four
keys the viewer's vector branch needs: ``is_image: true``, an
``image_url`` of the exact ``/api/documents/<uuid>/image`` form
(the bytes route above), ``format: "svg"`` — the ONLY place in the
app that knows the type (task 04's ``doc-image-img--vector`` keys
off it) — and ``content`` equal to the SVG's OWN extracted
``<title>``/``<desc>``/``<text>`` words (LOCKED A1: the row's text
is what the importer stored, never raw XML re-derived here). The
``<script>`` sentinel in the stored bytes never reaches this
payload — the DB text IS the extraction."""
extracted = extract_svg_text(SVG_WITH_SCRIPT, stem="diagram")
assert extracted is not None and extracted.structural is False
doc = _seed_image_row(
kb, tmp_path, content=extracted.text, source="VecSrc", path="vector.svg"
)
r = admin_client.get(
"/api/documents/content", params={"source": "VecSrc", "path": "vector.svg"}
)
assert r.status_code == 200
body = r.json()
assert body["is_image"] is True
assert body["image_url"] == f"/api/documents/{doc.id}/image"
assert body["format"] == "svg"
assert body["content"] == extracted.text
assert body["content"] == "Topology\nTwo nodes, one link\nedge router"
assert body["summary"] == extracted.text
# The serve round trip: the image_url the payload advertises answers.
img = admin_client.get(body["image_url"])
assert img.status_code == 200
assert img.headers["content-type"] == "image/svg+xml"
assert img.content == SVG_WITH_SCRIPT
# --- the wire-contract pin: allowImageUrl accepts the route (task 05) ---
def _allow_image_url() -> re.Pattern[str]:
"""The phase-180 ``allowImageUrl`` regex, extracted verbatim from
``frontend/assets/markdown.js`` (the JS literal is valid Python
syntax — one source, no hand-mirrored copy). A failed extraction
fails loudly: the pin only means something if it is THE shipped
allowlist."""
js = MARKDOWN_JS.read_text(encoding="utf-8")
match = re.search(
r"function allowImageUrl\(url\) \{\s*return (/.*?/[a-z]*)\.test\(url\)",
js,
re.S,
)
assert match is not None, "allowImageUrl no longer has the shape this pin reads"
literal = match.group(1)
pattern, _, flags = literal[1:].rpartition("/")
assert set(flags) <= {"i"}, f"unexpected regex flags {flags!r}"
return re.compile(pattern, re.IGNORECASE if "i" in flags else 0)
def test_image_url_shape_passes_the_phase_180_image_allowlist(
kb, tmp_path: Path, admin_client: TestClient
) -> None:
"""The phases-tie-together pin: the literal ``image_url`` an SVG row
advertises passes the renderer's ``allowImageUrl`` — so an answer
(or a markdown document) that carries ``![alt](<image_url>)`` paints
``img.md-img`` from THIS route instead of printing the reference
(phase 180 + phase 181 end to end; the E2E's
``test_markdown_image_ref_renders`` is the browser half). The
sanity probe proves the extracted regex actually discriminates:
the same allowlist rejects a ``javascript:`` src (LOCKED A5)."""
doc = _seed_image_row(kb, tmp_path, source="PinSrc", path="wire.svg")
r = admin_client.get(
"/api/documents/content", params={"source": "PinSrc", "path": "wire.svg"}
)
image_url = r.json()["image_url"]
assert image_url == f"/api/documents/{doc.id}/image"
# The literal path the renderer receives (no query, no fragment).
assert re.fullmatch(r"/api/documents/[0-9a-f-]{36}/image", image_url)
allow = _allow_image_url()
assert allow.search(image_url), f"allowImageUrl rejected its own route: {image_url}"
# The discriminating half: the allowlist is real, not "accept all".
assert allow.search("javascript:alert(1)") is None
assert allow.search("data:text/html,<script>") is None
+39 -7
View File
@@ -68,6 +68,7 @@ from pydantic import ValidationError
from app.api import chat as chat_api
from app.api import chat_images
from app.api.chat_images import _VECTOR_IMAGE_EXTENSIONS
from app.config import Settings
from app.main import app as fastapi_app
from app.models import Document
@@ -354,6 +355,32 @@ def test_upload_rejects_a_non_image_extension_with_the_fixed_detail(
assert not image_dir.exists() # the dir is created on the accepted path only
def test_upload_rejects_a_vector_svg_even_though_it_indexes(
client: TestClient, image_dir: Path
) -> None:
"""Phase 181: ``svg`` joins the DEFAULT image set for the DOCUMENT
walk, and question attachments stay RASTER-only — the upload 422s
with the six-extension detail and writes nothing.
Without the subtraction this would be a silent trap: the stored
``<uuid>.svg`` is refused by the serve route's filename regex (a
broken figure in the user's own bubble) and an OpenAI-compatible
vision endpoint rejects ``image/svg+xml``, so the turn could never
be answered. The vector family belongs to INDEXING, not to
attachments."""
assert ".svg" in Settings(_env_file=None).image_extension_set # pyright: ignore[reportCallIssue]
assert frozenset({".svg"}) == _VECTOR_IMAGE_EXTENSIONS
r = _upload(
client, "diagram.svg", b'<svg xmlns="http://www.w3.org/2000/svg"></svg>',
mime="image/svg+xml",
)
assert r.status_code == 422
assert r.json()["detail"] == (
"only .bmp, .gif, .jpeg, .jpg, .png, .webp images are accepted"
)
assert not image_dir.exists()
def test_upload_rejects_a_name_without_an_extension(
client: TestClient, image_dir: Path
) -> None:
@@ -916,7 +943,10 @@ def test_the_attach_reveal_is_gated_on_the_config_flag() -> None:
def test_the_client_precheck_matches_the_servers_six_extensions() -> None:
"""ONE list of the six, on both sides: the client pre-check (the
file NAME's extension — the accept attribute is advisory) and the
server's phase-122 frozenset (the upload endpoint's authority).
server's phase-122 frozenset MINUS the vector members (the upload
endpoint's authority — phase 181: question images stay RASTER-only,
a vision endpoint rejects ``image/svg+xml`` and the serve route's
filename regex would never serve one).
The client list must stay the code default's six — a drift here
would either block a valid pick or waste a round-trip on a 422."""
js = _app_js()
@@ -931,12 +961,14 @@ def test_the_client_precheck_matches_the_servers_six_extensions() -> None:
"png",
"webp",
}
# and it equals the server's default set (dots stripped — the
# client list is dotless, the server's is dotted).
server_set = {
e.lstrip(".")
for e in Settings(_env_file=None).image_extension_set # pyright: ignore[reportCallIssue]
}
# and it equals the server's default raster set (dots stripped — the
# client list is dotless, the server's is dotted). ``svg`` is in the
# DEFAULT image set for the document walk (phase 181) and
# deliberately NOT attachable here (see
# ``chat_images._VECTOR_IMAGE_EXTENSIONS``).
raw_default = Settings(_env_file=None).image_extension_set # pyright: ignore[reportCallIssue]
assert ".svg" in raw_default # the vector member (phase 181)
server_set = {e.lstrip(".") for e in raw_default - _VECTOR_IMAGE_EXTENSIONS}
assert client_set == server_set
# the file input's accept attribute names the same six (mime form).
m = re.search(r'<input type="file" id="attach-file"\s+accept="([^"]+)"', _index_html())
+191
View File
@@ -94,6 +94,20 @@ format table as node behavioral runs over the real module, and the
``.doc-prose`` CSS (the 72rem reading column + ``pre-wrap`` +
``overflow-wrap: anywhere``, tokens only) beside the ``.doc-raw``
contract that stays locked (phase 116 A1 / phase 165 A3).
Phase 181 (task 04) — the SVG vector preview: ``docImageBlock`` adds
the ``doc-image-img--vector`` modifier ONLY when
``doc.format === "svg"`` (the base class is always kept) — a
``viewBox``-only SVG has NO intrinsic size, so the default 300×150
replaced-element box would sit small inside the full-width
``.doc-image`` card — pinned as a node behavioral run of the real
``docImageBlock`` (svg → the compound class, ``src`` from
``image_url`` never inlined, ``alt`` = summary with the title
fallback; png → the class string byte-identical to the base class)
plus the ``.doc-image-img--vector`` CSS pins (``width: 100%`` +
``height: auto`` + ``object-fit: contain`` under the base rule's
70vh clamp, zero colour literals, the base ``.doc-image-img`` rule
untouched).
"""
from __future__ import annotations
@@ -2102,3 +2116,180 @@ def test_doc_prose_css_wraps_in_the_reading_column() -> None:
raw_rule = css[raw_i : css.find("}", raw_i)]
assert "white-space: pre" in raw_rule
assert "overflow-x: auto" in raw_rule
# ---------------------------------------------------------------------------
# Phase 181 (task 04) — the SVG vector preview: docImageBlock's format
# gate (base class + the doc-image-img--vector modifier, LOCKED A3:
# bytes only ever through <img>) and the .doc-image-img--vector CSS
# (a viewBox-only SVG has no intrinsic size — width: 100% spans the
# .doc-image card, object-fit: contain survives the 70vh clamp,
# palette-neutral). The chat figure (app.js) and the Sources
# thumbnail (sources.js) are VERIFIED non-changes (design D7): both
# key off image_url alone and their boxes pin the img
# (.source-image-img 96px cap, .kb-doc-thumb-img fixed 48px box).
# ---------------------------------------------------------------------------
def test_doc_image_block_gates_the_vector_modifier_on_format() -> None:
"""Source pins (node-free): docImageBlock selects the compound
class on ``doc.format === "svg"`` and the bare base class
otherwise (every current .doc-image-img rule keeps applying);
the bytes stay an <img> src property assignment — never inlined
(LOCKED A3), never innerHTML. The comment records the rationale
(no-intrinsic-size / 300×150) and the legacy-TEXT honesty
(XML-in-.doc-raw is the truth; no content sniffing)."""
js = _read(DOCUMENT_JS)
fn = _fn(js, "docImageBlock")
assert 'doc.format === "svg"' in fn
assert '"doc-image-img doc-image-img--vector"' in fn
assert ': "doc-image-img";' in fn, "the raster path keeps the bare base class"
assert "img.src = doc.image_url;" in fn, "the bytes URL stays a property assignment"
assert "innerHTML" not in fn
# The rationale + honesty comments (house style: why, not what).
for citation in ("Phase 181", "viewBox", "300\u00d7150", "LOCKED A3", ".doc-raw"):
assert citation in fn, f"the comment must cite {citation!r}"
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_doc_image_block_svg_yields_the_vector_class_png_unchanged() -> None:
"""Behavioral (node, the house fake DOM): the REAL docImageBlock —
an SVG doc renders class="doc-image-img doc-image-img--vector"
with src = image_url and alt = the summary (title fallback when
the summary is null/blank); a PNG doc's class string is the base
class ONLY (byte-identical to pre-phase — the phase-122/133
raster surfaces cannot drift); the wrapper stays .doc-image."""
js = _read(DOCUMENT_JS)
# The docs ride in via json.dumps (the summary carries quotes and
# an em-dash — the alt contract must survive verbatim).
cases = {
"svg": {
"format": "svg",
"image_url": "/api/documents/7/image",
"summary": 'Vector diagram "topo" \u2014 3 shapes',
"title": "topo",
},
"svgNoSummary": {
"format": "svg",
"image_url": "/api/documents/9/image",
"summary": None,
"title": "rack",
},
"svgBlankSummary": {
"format": "svg",
"image_url": "/api/documents/10/image",
"summary": " ",
"title": "blank",
},
"png": {
"format": "png",
"image_url": "/api/documents/8/image",
"summary": "A photo",
"title": "t",
},
}
script = (
_CONTENT_BRANCH_FAKE_DOM
+ _fn(js, "docImageBlock")
+ "\nconst cases = "
+ json.dumps(cases)
+ """
;
function imageFor(doc) {
const wrap = docImageBlock(doc);
const img = wrap.children[0];
return {
wrapClass: wrap.className, imgClass: img.className, src: img.src, alt: img.alt,
};
}
const out = {};
for (const [k, doc] of Object.entries(cases)) out[k] = imageFor(doc);
console.log(JSON.stringify(out));
"""
)
out = json.loads(_run_node(script))
assert out["svg"] == {
"wrapClass": "doc-image",
"imgClass": "doc-image-img doc-image-img--vector",
"src": "/api/documents/7/image",
"alt": 'Vector diagram "topo" \u2014 3 shapes',
}
# alt falls back to the title (the WCAG alt contract, unchanged).
assert out["svgNoSummary"]["alt"] == "rack"
assert out["svgBlankSummary"]["alt"] == "blank"
assert out["svgBlankSummary"]["imgClass"] == "doc-image-img doc-image-img--vector"
# The raster class string is byte-identical to pre-phase (base only).
assert out["png"]["imgClass"] == "doc-image-img"
assert out["png"]["src"] == "/api/documents/8/image"
def test_doc_image_vector_css_spans_the_card_without_squashing() -> None:
"""styles.css: ``.doc-image-img--vector`` (immediately after
``.doc-image-img``) is ``width: 100%`` + ``height: auto`` +
``object-fit: contain`` — a viewBox-only SVG (no intrinsic size:
the default replaced-element box is 300\u00d7150) spans the
full-width .doc-image card, and contain letterboxes instead of
squashing when the base rule's max-height: 70vh clamps a tall
drawing. Zero colour literals (palette-neutral — the monochrome
invariant); the base .doc-image-img rule is UNTOUCHED (the
phase-122 raster contract)."""
css = _read(STYLES_CSS)
base_i = css.find(".doc-image-img {")
vec_i = css.find(".doc-image-img--vector {")
assert -1 < base_i < vec_i, ".doc-image-img--vector must sit next to the base rule"
# Provenance comment directly above the rule (house style).
comment_start = css.rfind("/*", 0, vec_i)
provenance = css[comment_start:vec_i]
assert -1 < comment_start < vec_i
for citation in ("phase 181", "viewBox", "300\u00d7150", "object-fit", "doc.format"):
assert citation in provenance, f"the comment must cite {citation!r}"
assert "no animation" in provenance
rule = css[vec_i : css.find("}", vec_i)]
for decl in ("width: 100%", "height: auto", "object-fit: contain"):
assert decl in rule, f".doc-image-img--vector must carry {decl!r}"
# Palette-neutral — zero colour literals (no new hue).
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", rule)
assert "rgb(" not in rule and "hsl(" not in rule and "color-mix(" not in rule
# The untouched base rule (the raster + onerror contract).
base_rule = css[base_i : css.find("}", base_i)]
for decl in ("max-width: 100%", "max-height: 70vh", "height: auto"):
assert decl in base_rule, f"the base rule must keep {decl!r}"
def test_figure_and_thumbnail_key_off_image_url_alone() -> None:
"""Design D7's verified non-changes: the chat figure
(appendSourceImageFigure) and the Sources KB-tree thumbnail
(docThumb) resolve their bytes from ``image_url`` alone — no
format field on the wire, none added — and their CSS boxes pin
the img (.source-image-img: 96px cap + object-fit: contain; the
.kb-doc-thumb-img: the fixed 48px box), so a size-less SVG
scales inside each cap exactly like a raster. Pins: neither
function sniffs a format at all (the deliberate non-change,
design D7), the phase-181 comments sit where they were made,
and the two rules keep their box declarations."""
app_js = _read(APP_JS)
assert app_js.count("function appendSourceImageFigure(") == 1
fig = _fn(app_js, "appendSourceImageFigure")
assert "img.src = s.image_url;" in fig
assert "format" not in fig, "no format sniffing on the chat figure (D7)"
fn_start = app_js.index("function appendSourceImageFigure(")
assert "phase 181" in app_js[max(0, fn_start - 2400) : fn_start].lower(), (
"the deliberate-non-change comment sits above the figure"
)
sources_js = _read(SOURCES_JS)
thumb_start = sources_js.index("function docThumb(d)")
thumb_end = sources_js.index("\n }", thumb_start)
thumb = sources_js[thumb_start:thumb_end]
assert "img.src = d.image_url;" in thumb
assert "format" not in thumb, "no format sniffing on the thumbnail (D7)"
branch_i = sources_js.index("if (d.is_image) {")
assert "phase 181" in sources_js[branch_i - 900 : branch_i].lower(), (
"the verified-non-change comment sits above the is_image branch"
)
css = _read(STYLES_CSS)
cap_i = css.find(".source-image-img {")
cap = css[cap_i : css.find("}", cap_i)]
assert "max-height: 96px" in cap and "object-fit: contain" in cap
box_i = css.find(".kb-doc-thumb-img {")
box = css[box_i : css.find("}", box_i)]
assert "width: 100%" in box and "height: 100%" in box
+29 -8
View File
@@ -106,19 +106,25 @@ def _settings(**kwargs: Any) -> Settings:
def test_image_defaults_match_locked_a3() -> None:
"""LOCKED A3: image support is OFF by default (enable only when the
chat model supports vision), the built-in extension set is the six
common formats, and the image bytes' persistent home is the
"""LOCKED A3 + phase 181 LOCKED A7: image support is OFF by default
(enable only when the chat model supports vision), the built-in
extension set is the six common raster formats PLUS ``svg`` (the one
VECTOR member — A1: it indexes from its own extracted text, never a
vision call), and the image bytes' persistent home is the
``~/bor-sources/images`` raw string (``expanduser`` is the
importer's job — the ``sources_dir``/``upload_dir`` convention)."""
importer's job — the ``sources_dir``/``upload_dir`` convention).
``images`` stays the SINGLE knob (A7): with it off an ``.svg`` is a
plain text document, no special-casing."""
s = _settings()
assert s.images is False
# Dotted, lowercased form — the shape the walk's matcher consumes
# (the ``import_extension_set`` precedent).
assert s.image_extension_set == {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
assert s.image_extension_set == {
".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".svg",
}
assert s.image_dir == "~/bor-sources/images"
# The raw string is stored untouched (no expanduser here).
assert s.image_extensions == "png,jpg,jpeg,webp,gif,bmp"
assert s.image_extensions == "png,jpg,jpeg,webp,gif,bmp,svg"
def test_image_set_is_separate_from_the_import_set() -> None:
@@ -127,7 +133,9 @@ def test_image_set_is_separate_from_the_import_set() -> None:
the import set leaves the image set untouched, and vice versa."""
s = _settings(import_extensions="md")
assert s.import_extension_set == {".md"}
assert s.image_extension_set == {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
assert s.image_extension_set == {
".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".svg",
}
s2 = _settings(image_extensions="md")
assert s2.image_extension_set == {".md"}
assert ".md" in s2.import_extension_set # the default import set is intact
@@ -762,7 +770,16 @@ def test_image_mimes_cover_the_default_family() -> None:
default image family — dotted lowercase keys, the
``image_extension_set`` shape — and the unlisted-token fallback is
the generic mime (a custom ``BOR_IMAGE_EXTENSIONS`` format fails
soft at the vision endpoint, never a guessed type)."""
soft at the vision endpoint, never a guessed type).
The map is pinned by IDENTITY (not membership), so a silent removal
or re-pointing of any entry fails here. Phase 181 (task 03) adds
``.svg`` as the ONE vector entry; the six raster entries are
untouched by it, and the vector import branch never reaches
:func:`image_data_url` (LOCKED A1: no vision call for an SVG), so
the addition cannot change a describe call — the value exists for
the BYTES route (``app.api.docs.get_document_image`` reads the same
map for its ``Content-Type`` and its sandboxed-CSP guard)."""
assert IMAGE_MIMES == {
".png": "image/png",
".jpg": "image/jpeg",
@@ -770,8 +787,12 @@ def test_image_mimes_cover_the_default_family() -> None:
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".svg": "image/svg+xml",
}
assert IMAGE_FALLBACK_MIME == "application/octet-stream"
# Every member of the default image set now has a mime — raster and
# vector alike (the vector one is serve-side only, per above).
assert ".svg" in _settings().image_extension_set
assert all(ext in IMAGE_MIMES for ext in _settings().image_extension_set)
+2 -1
View File
@@ -1008,6 +1008,7 @@ def test_import_summary_log_line_includes_summary_counters(
s.summary_backfilled = 1
s.dates_updated = 0
s.images_failed = 0
s.svg_indexed = 4
s.image_context_backfilled = 2
s.pdfs_screenshotted = 1
s.pdfs_pictorial = 2
@@ -1021,7 +1022,7 @@ 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 image_context_backfilled=2 "
"dates_updated=0 images_failed=0 svg_indexed=4 image_context_backfilled=2 "
"pdfs_screenshotted=1 pdfs_pictorial=2 pdf_pictorial_pages=5 "
"pdf_images_deduped=3 pdf_images_blank_skipped=1 "
"formats=yaml:2,md:1"
+482
View File
@@ -0,0 +1,482 @@
"""Unit: phase 181 task 02 — an ``.svg`` indexes as a VECTOR image document.
The pure extractor (:mod:`app.rag.svg_text`) is pinned in
``tests/unit/test_svg_text_extraction.py``; this module pins what the
IMPORTER does with it (LOCKED A1/A2/A7):
* the vector branch of :func:`app.rag.importer._index_image_file` — an
``.svg`` lands ``is_image=True`` with ``image_path`` under
``image_dir``, its ``content`` is the file's OWN extracted text, its
``summary`` mirrors it (the phase-122 convention), and the vision seam
:func:`app.rag.importer._describe_or_skip` is NEVER called (the seam is
patched with a recording stand-in that would return a sentinel — the
empty recording list + the content assertion are the proof, and a
call-counting seam cannot silently succeed);
* the log-only ``svg_indexed`` counter and the ``import: svg indexed``
auditability line (AGENTS rule 10), including ``structural=True`` for
a drawing whose only words are the LOCKED A2 fallback line;
* the fail-soft (LOCKED A2's boundary): a file the extractor REFUSES
(over the byte cap, malformed XML) skips exactly like an undescribed
raster — ``images_failed``, a warning, no row, no orphan copy — while
a merely textless SVG does NOT skip;
* the phase-125 ``image_context`` enrichment prepends for an SVG exactly
as for a raster;
* the phase-181 fast-path kind guard (:func:`_row_kind_mismatch`): a row
imported as TEXT before ``svg`` joined ``BOR_IMAGE_EXTENSIONS`` is
converted on the NEXT sync (same bytes, same ``file_hash`` — and for an
ASCII file the text digest equals the byte digest too, which is why the
image branch's own content-digest skip now requires ``is_image``), and
a row already of the right kind — raster or vector — keeps the
phase-147 fast path byte-for-byte (no second seam call, no second
``svg_indexed``).
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
from pathlib import Path
from typing import Any
import pytest
from sqlalchemy import select
import app.rag.importer as importer
from app.config import Settings
from app.models import Chunk, Document
from app.rag.importer import import_sources
from app.rag.svg_text import MAX_SVG_BYTES
from tests.fakes import FakeEmbedder
#: A diagram that carries words: ``<title>`` + ``<desc>`` + a ``<text>``
#: inside a group (document order is the extractor's contract).
SVG_TOPO = (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">'
b"<title>Server rack topology</title>"
b"<desc>Two switches uplink to the router.</desc>"
b'<g><path d="M0 0h10"/><text>rack-1</text></g>'
b"</svg>"
)
#: The text :func:`app.rag.svg_text.extract_svg_text` yields for
#: :data:`SVG_TOPO` (pinned literally here so a change to the extraction
#: contract breaks BOTH modules, not silently).
SVG_TOPO_TEXT = "Server rack topology\nTwo switches uplink to the router.\nrack-1"
#: A pure-shape drawing: no words at all (the LOCKED A2 class).
SVG_SHAPES_ONLY = (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
b'<path d="M0 0h10"/><path d="M0 20h10"/><circle cx="1" cy="1" r="2"/>'
b"</svg>"
)
#: The LOCKED A2 structural line for :data:`SVG_SHAPES_ONLY` — built only
#: from facts the file states (stem, shape census, viewBox).
SVG_SHAPES_ONLY_TEXT = (
'Vector diagram "gizmo" — 3 shapes (path×2, circle), viewBox 64×64'
)
#: Over :data:`MAX_SVG_BYTES`: the size guard refuses it BEFORE any parse.
SVG_OVER_CAP = b"<svg>" + b"\n" * (MAX_SVG_BYTES + 1) + b"</svg>"
#: Well-formed bytes, unparseable markup (the second refusal class).
SVG_MALFORMED = b'<svg xmlns="http://www.w3.org/2000/svg"><title>oops'
#: A real 1×1 PNG for the raster fast-path regression pins.
PNG_1X1 = b"\x89PNG\r\n\x1a\n" + b"\x00" * 24
#: What a describe seam would hand back if the vector branch ever reached
#: it — never a plausible diagram text, so it cannot pass unnoticed.
VISION_SENTINEL = "SHOULD NEVER BE DESCRIBED: a vision call ran for an SVG."
def _settings(**kwargs: Any) -> Settings:
"""Build Settings without reading a .env file (deterministic tests)."""
kwargs.setdefault("_env_file", None)
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
def _image_llm(tmp_path: Path, **kwargs: Any) -> FakeEmbedder:
"""A fake LLM whose settings index images into *tmp_path*."""
kwargs.setdefault("_env_file", None)
kwargs.setdefault("images", True)
kwargs.setdefault("image_dir", str(tmp_path / "images"))
llm = FakeEmbedder()
llm.settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return llm
def _record_description(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Patch the vision seam with a recorder (never a real call): the
paths it is asked to describe, always answered with
:data:`VISION_SENTINEL`. An empty list + a non-sentinel ``content``
is the proof that the vector branch bypassed vision entirely."""
handed: list[str] = []
async def _fake(
llm: importer.Embedder,
*,
data: bytes,
source: str,
rel: str,
full_path: Path,
context: str | None = None,
) -> str | None:
handed.append(rel)
return VISION_SENTINEL
monkeypatch.setattr(importer, "_describe_or_skip", _fake)
return handed
def _doc(db, source: str, rel: str) -> Document:
doc = db.scalar(
select(Document).where(Document.source == source, Document.path == rel)
)
assert doc is not None, f"no documents row for ({source!r}, {rel!r})"
return doc
def _cleanup_source(db, source: str) -> None:
for doc in db.scalars(select(Document).where(Document.source == source)).all():
db.delete(doc)
db.commit()
# --- the vector branch ------------------------------------------------------
def test_svg_indexes_as_an_image_document_with_no_vision_call(
db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""LOCKED A1: the ``.svg`` takes the image branch (``is_image``,
``image_path``, the ``<doc-id>.svg`` copy, the ``summary = content``
convention, one embedded content chunk + the ``is_summary`` chunk)
and its searchable text is the file's own ``<title>``/``<desc>``/
``<text>`` — with the vision seam never invoked. ``svg_indexed`` and
the per-file ``import: svg indexed`` line make the run auditable."""
root = tmp_path / "svgsrc"
root.mkdir()
(root / "topo.svg").write_bytes(SVG_TOPO)
llm = _image_llm(tmp_path)
handed = _record_description(monkeypatch)
try:
with caplog.at_level(logging.INFO, logger="app.importer"):
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.files, summary.added, summary.images_failed) == (1, 1, 0)
assert summary.errors == 0
assert summary.formats == {"svg": 1} # counted under its image token
assert summary.svg_indexed == 1
assert handed == [], "an SVG must never reach the vision seam"
doc = _doc(db, root.name, "topo.svg")
assert doc.is_image is True
assert doc.title == "topo" # the non-markdown stem rule (shared with raster)
assert doc.content == SVG_TOPO_TEXT
assert doc.summary == doc.content # the phase-122 image convention
assert doc.image_context is None # standalone: the pre-phase row shape
assert doc.image_path is not None
copy = Path(doc.image_path)
assert copy.parent == Path(llm.settings.image_dir).expanduser()
assert copy.name == f"{doc.id}.svg" # row and copy agree on the id
assert copy.read_bytes() == SVG_TOPO # the exact bytes, unserved here
chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all()
content_chunks = [c for c in chunks if not c.is_summary]
assert [c.content for c in content_chunks] == [doc.content]
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 == doc.content
for c in chunks:
assert c.embedding is not None and len(c.embedding) == 768
indexed = [
r.getMessage() for r in caplog.records if "import: svg indexed" in r.getMessage()
]
assert indexed == [
f"import: svg indexed source={root.name} path=topo.svg "
f"structural=False chars={len(SVG_TOPO_TEXT)}"
]
line = next(
r.getMessage()
for r in caplog.records
if r.getMessage().startswith("import: summary files=")
)
assert "svg_indexed=1" in line and "images_failed=0" in line
finally:
_cleanup_source(db, root.name)
def test_textless_svg_indexes_with_the_structural_line(
db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""LOCKED A2: a diagram of pure ``<path>``s is NOT skipped — it is
indexed with the structural line built from the file (stem, shape
census, viewBox), counted like any other vector doc and logged with
``structural=True`` so the owner can tell it from a worded diagram."""
root = tmp_path / "shapeonly"
root.mkdir()
(root / "gizmo.svg").write_bytes(SVG_SHAPES_ONLY)
llm = _image_llm(tmp_path)
handed = _record_description(monkeypatch)
try:
with caplog.at_level(logging.INFO, logger="app.importer"):
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.files, summary.added, summary.images_failed) == (1, 1, 0)
assert summary.svg_indexed == 1
assert handed == []
doc = _doc(db, root.name, "gizmo.svg")
assert doc.is_image is True
assert doc.content == SVG_SHAPES_ONLY_TEXT
assert doc.summary == doc.content
line = next(
r.getMessage() for r in caplog.records if "import: svg indexed" in r.getMessage()
)
assert "structural=True" in line
finally:
_cleanup_source(db, root.name)
@pytest.mark.parametrize(
("name", "payload"),
[
pytest.param("huge.svg", SVG_OVER_CAP, id="over-the-byte-cap"),
pytest.param("broken.svg", SVG_MALFORMED, id="malformed-xml"),
],
)
def test_unusable_svg_skips_the_doc_counts_and_logs(
db,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
name: str,
payload: bytes,
) -> None:
"""The fail-soft stays LOCKED A3-shaped for the vector case: a file
the extractor refuses skips the doc ENTIRELY — no row, no copy (not
even the image dir), ``images_failed`` + a warning naming the vector
extraction, the sync continues. This is the ONLY skip class the phase
keeps for an SVG (:func:`app.rag.svg_text.extract_svg_text`)."""
root = tmp_path / "badsvg"
root.mkdir()
(root / name).write_bytes(payload)
llm = _image_llm(tmp_path)
handed = _record_description(monkeypatch)
try:
with caplog.at_level(logging.INFO, logger="app.importer"):
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.files, summary.added, summary.images_failed) == (1, 0, 1)
assert summary.svg_indexed == 0
assert handed == []
assert db.scalar(select(Document).where(Document.source == root.name)) is None
assert not Path(llm.settings.image_dir).expanduser().exists(), (
"no copy — and not even the image dir — may be left behind"
)
warnings = [
r
for r in caplog.records
if "svg text extraction failed" in r.getMessage()
]
assert len(warnings) == 1 and warnings[0].levelno == logging.WARNING
assert "image description failed" not in caplog.text
line = next(
r.getMessage()
for r in caplog.records
if r.getMessage().startswith("import: summary files=")
)
assert "images_failed=1" in line and "svg_indexed=0" in line
finally:
_cleanup_source(db, root.name)
def test_markdown_reference_context_prepends_for_an_svg(
db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 125's enrichment is kind-agnostic: a ``![alt](topo.svg)``
reference in the same source still builds ``image_context`` (from the
file on disk, no vision call) and the doc's content becomes
``<context>\\n\\n<extracted text>`` — context first for retrieval, the
vector file's own words second."""
root = tmp_path / "svganchor"
root.mkdir()
(root / "guide.md").write_text(
"Why this diagram exists.\n\n![The rack plan](topo.svg)\n\nWhat follows it.\n",
encoding="utf-8",
)
(root / "topo.svg").write_bytes(SVG_TOPO)
llm = _image_llm(tmp_path)
handed = _record_description(monkeypatch)
try:
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.files, summary.added, summary.images_failed) == (2, 2, 0)
assert summary.svg_indexed == 1
assert handed == []
doc = _doc(db, root.name, "topo.svg")
assert doc.is_image is True
assert doc.image_context is not None
assert doc.image_context.startswith(f"Referenced in {root.name}/guide.md:")
assert 'Alt text: "The rack plan"' in doc.image_context
assert doc.content == f"{doc.image_context}\n\n{SVG_TOPO_TEXT}"
assert doc.summary == doc.content
finally:
_cleanup_source(db, root.name)
# --- the fast-path kind guard (the text → image migration) ------------------
def test_text_indexed_svg_row_is_converted_on_the_next_sync(
db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Design D4: the row an earlier run indexed as TEXT (``svg`` not yet
in the image set) is converted by the NEXT sync — same bytes, same
``file_hash`` — and the sync AFTER that is fast again.
The conversion needs BOTH halves of the guard: the fast path in
:func:`_index_file` must fall through, and the image branch's own
content-digest skip must require ``is_image`` — an ASCII file's text
digest equals its byte digest, so without it the row would report
``unchanged`` forever and never gain a copy (asserted below, because
it is the whole reason the guard exists)."""
root = tmp_path / "migrate"
root.mkdir()
(root / "topo.svg").write_bytes(SVG_TOPO)
handed = _record_description(monkeypatch)
# Stage A — the pre-phase state: images OFF, ``svg`` imported as text.
text_llm = _image_llm(
tmp_path,
images=False,
import_extensions=f"{_settings().import_extensions},svg",
)
try:
summary_a = asyncio.run(import_sources([root], text_llm, session=db))
assert (summary_a.files, summary_a.added, summary_a.svg_indexed) == (1, 1, 0)
before = _doc(db, root.name, "topo.svg")
assert before.is_image is False
assert before.image_path is None
assert before.content == SVG_TOPO.decode("utf-8") # the raw XML the owner saw
byte_digest = hashlib.sha256(SVG_TOPO).hexdigest()
assert before.file_hash == byte_digest
# The trap D4 closes: the text digest of a byte-clean file IS the
# byte digest, so no digest comparison can spot the mismatch.
assert before.content_hash == byte_digest
doc_id = before.id
# Stage B — the toggle on: the guard fires, the row converts.
image_llm = _image_llm(tmp_path)
summary_b = asyncio.run(import_sources([root], image_llm, session=db))
assert (summary_b.added, summary_b.updated, summary_b.unchanged) == (0, 1, 0)
assert (summary_b.images_failed, summary_b.svg_indexed) == (0, 1)
assert handed == [], "the conversion must not call vision either"
after = _doc(db, root.name, "topo.svg")
assert after.id == doc_id # converted in place — the id (and copy name) is stable
assert after.is_image is True
assert after.content == SVG_TOPO_TEXT
assert after.summary == after.content
assert after.file_hash == byte_digest # the bytes never changed
# The image branch stamps the BYTES digest as ``content_hash`` —
# the SAME value the text path had already stored, which is why
# that skip needed its own ``is_image`` guard.
assert after.content_hash == byte_digest
copy = Path(str(after.image_path))
assert copy == Path(image_llm.settings.image_dir).expanduser() / f"{doc_id}.svg"
assert copy.read_bytes() == SVG_TOPO
# The text chunks are replaced, not appended: one content chunk
# (the extracted text) + the is_summary mirror.
chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc_id)).all()
assert [c.content for c in chunks if not c.is_summary] == [SVG_TOPO_TEXT]
assert [c.content for c in chunks if c.is_summary] == [SVG_TOPO_TEXT]
# Stage C — the guard fired once: the fast path resumes.
summary_c = asyncio.run(import_sources([root], image_llm, session=db))
assert (summary_c.added, summary_c.updated, summary_c.unchanged) == (0, 0, 1)
assert (summary_c.svg_indexed, summary_c.images_failed) == (0, 0)
assert handed == []
assert _doc(db, root.name, "topo.svg").is_image is True
finally:
_cleanup_source(db, root.name)
def test_correctly_classified_raster_row_still_takes_the_fast_path(
db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The phase-147 fast path survives the guard for every row whose
KIND already agrees: a raster image with a matching ``file_hash`` is
UNCHANGED on the next sync — the seam is not called a second time and
no vector counter moves."""
root = tmp_path / "rasterfast"
root.mkdir()
(root / "pic.png").write_bytes(PNG_1X1)
llm = _image_llm(tmp_path)
handed = _record_description(monkeypatch)
try:
first = asyncio.run(import_sources([root], llm, session=db))
assert (first.added, first.unchanged, first.svg_indexed) == (1, 0, 0)
assert handed == ["pic.png"] # the raster branch DID use the seam
doc = _doc(db, root.name, "pic.png")
assert doc.is_image is True
second = asyncio.run(import_sources([root], llm, session=db))
assert (second.added, second.updated, second.unchanged) == (0, 0, 1)
assert second.svg_indexed == 0
assert handed == ["pic.png"], "the fast path must not re-describe the image"
assert _doc(db, root.name, "pic.png").content == VISION_SENTINEL
finally:
_cleanup_source(db, root.name)
def test_correctly_classified_vector_row_still_takes_the_fast_path(
db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Same pin for the vector row: once converted (or freshly indexed)
it is UNCHANGED — no second extraction, no second ``svg_indexed``,
no second copy write."""
root = tmp_path / "vectorfast"
root.mkdir()
(root / "topo.svg").write_bytes(SVG_TOPO)
llm = _image_llm(tmp_path)
handed = _record_description(monkeypatch)
try:
first = asyncio.run(import_sources([root], llm, session=db))
assert (first.added, first.svg_indexed) == (1, 1)
copy = Path(str(_doc(db, root.name, "topo.svg").image_path))
copy.unlink() # the fast path must not re-create it either
second = asyncio.run(import_sources([root], llm, session=db))
assert (second.added, second.updated, second.unchanged) == (0, 0, 1)
assert (second.svg_indexed, second.images_failed) == (0, 0)
assert handed == []
assert not copy.exists()
finally:
_cleanup_source(db, root.name)
def test_row_kind_mismatch_table() -> None:
"""The guard's whole truth table (pure — no DB, no filesystem):
it fires ONLY for a TEXT row whose file the CURRENT settings would
index as an image. The reverse direction (an ``is_image`` row while
the toggle is off, or a plain text file) stays on the fast path —
the phase-122 prune guard owns the off state (LOCKED A7)."""
svg = Path("/src/diagrams/topo.svg")
md = Path("/src/notes.md")
text_row = Document(source="s", path="topo.svg", is_image=False)
image_row = Document(source="s", path="topo.svg", is_image=True)
def _settings_(*, images: bool) -> Settings:
return _settings(images=images)
# The migration: the toggle is on, the file is an image, the row is not.
assert importer._row_kind_mismatch(svg, text_row, _settings_(images=True)) is True
# Already an image doc → nothing to migrate.
assert importer._row_kind_mismatch(svg, image_row, _settings_(images=True)) is False
# Toggle off → the walk never admits the file, the prune guard protects
# the row: never force the image path (LOCKED A7).
assert importer._row_kind_mismatch(svg, text_row, _settings_(images=False)) is False
assert importer._row_kind_mismatch(svg, image_row, _settings_(images=False)) is False
# A file outside the image set is a text file either way.
assert importer._row_kind_mismatch(md, text_row, _settings_(images=True)) is False
# An owner-narrowed image set (no ``svg``) is not a mismatch either.
narrow = _settings(images=True, image_extensions="png,jpg")
assert importer._row_kind_mismatch(svg, text_row, narrow) is False
+426
View File
@@ -0,0 +1,426 @@
"""Unit tests: SVG text extraction (phase 181, task 01).
:func:`app.rag.svg_text.extract_svg_text` is a PURE function — the text
that makes an SVG document retrievable WITHOUT a vision call (LOCKED
A1). No DB, no filesystem, no LLM: every case is a literal SVG string,
so the whole contract is pinned here before the importer branch (task
02) relies on it.
The matrix:
* the guards that run BEFORE any parse — empty / whitespace-only input,
the :data:`~app.rag.svg_text.MAX_SVG_BYTES` cap, the
``<!DOCTYPE``/``<!ENTITY`` refusal (case-insensitive, both a doctype
wrapper and a bare entity declaration), malformed XML → ``None``
(never a raise);
* extraction — ``<title>``/``<desc>``/``<text>`` in DOCUMENT order,
``<tspan>`` children joined with a space, namespace-prefixed and
namespaced tags treated alike, ``xlink:`` attributes ignored,
whitespace normalised, repeats dropped case-insensitively keeping the
first, ``&amp;``/``&#8212;`` decoded by the parser;
* the :data:`~app.rag.svg_text.MAX_SVG_TEXT_CHARS` cap — cut at the
last newline/period before the cap, hard-cut only when the window
holds no boundary at all;
* the LOCKED A2 structural fallback — stem, shape census (counts,
plural, top-3 selection with a document-order tie-break) and the
``viewBox`` clause, each omitted when the file does not state it.
Importer-level tests (the vector branch, the no-vision-call seam) live
in the phase's ingest module (tasks 02/05).
"""
from __future__ import annotations
from app.rag.svg_text import (
MAX_SVG_BYTES,
MAX_SVG_TEXT_CHARS,
SvgText,
extract_svg_text,
local_name,
)
SVG_NS = "http://www.w3.org/2000/svg"
XLINK_NS = "http://www.w3.org/1999/xlink"
# --- constants -------------------------------------------------------------
def test_module_constants_are_pinned() -> None:
"""The caps are part of the contract the importer's tests assume."""
assert MAX_SVG_BYTES == 2 * 1024 * 1024
assert MAX_SVG_TEXT_CHARS == 4000
# --- the local_name helper -------------------------------------------------
def test_local_name_strips_the_clark_notation_namespace() -> None:
assert local_name(f"{{{SVG_NS}}}title") == "title"
assert local_name("title") == "title"
def test_local_name_of_a_non_string_tag_is_empty() -> None:
"""Comment / PI markers are callables — they carry no name."""
assert local_name(object()) == ""
# --- guards (no parse attempted) -------------------------------------------
def test_empty_input_is_a_refusal_not_a_crash() -> None:
assert extract_svg_text(b"") is None
assert extract_svg_text(b"", stem="diagram") is None
def test_whitespace_only_input_is_a_refusal() -> None:
assert extract_svg_text(b" \n\t \r\n") is None
def test_bytes_over_the_size_cap_are_refused() -> None:
"""The memory guard: an over-cap file never reaches the parser."""
padded = b"<svg><title>" + b"x" * (MAX_SVG_BYTES + 1) + b"</title></svg>"
assert len(padded) > MAX_SVG_BYTES
assert extract_svg_text(padded, stem="huge") is None
def test_bytes_exactly_at_the_size_cap_are_parsed() -> None:
"""The cap is ``>``-based: exactly ``MAX_SVG_BYTES`` is in range."""
opener, closer = b"<svg><title>", b"</title></svg>"
filler = b"x" * (MAX_SVG_BYTES - len(opener) - len(closer))
result = extract_svg_text(opener + filler + closer)
assert result is not None
assert result.structural is False
def test_doctype_with_an_internal_entity_is_refused() -> None:
"""Stdlib ElementTree happily EXPANDS an internal entity — proving
why the doctype-bearing input is refused outright (billion-laughs
class): the reference must never be resolved."""
svg = b'<!DOCTYPE svg [<!ENTITY x "y">]><svg><title>&x;</title></svg>'
assert extract_svg_text(svg, stem="bomb") is None
def test_bare_entity_declaration_is_refused() -> None:
assert extract_svg_text(b'<!ENTITY x "y"><svg><title>x</title></svg>') is None
def test_entity_declaration_hidden_inside_content_is_refused() -> None:
"""The marker scan is over the raw bytes — a declaration anywhere
(even in a position the parser would reject) refuses the file."""
assert extract_svg_text(b"<svg>\n<!entity x 'y'>\n</svg>") is None
def test_guards_are_case_insensitive() -> None:
assert extract_svg_text(b'<!doctype svg SYSTEM "http://x/y.dtd"><svg/>') is None
assert extract_svg_text(b'<!entity x "y"><svg/>') is None
def test_malformed_xml_is_a_refusal_not_an_exception() -> None:
"""The caller's fail-soft is a skip (``images_failed``), so a parse
error must never escape."""
assert extract_svg_text(b"<svg><title>unclosed") is None
assert extract_svg_text(b"<svg><rect></svg>") is None
assert extract_svg_text(b"\x00\x01\x02") is None
assert extract_svg_text(b"not xml at all") is None
# --- extraction: title / desc / text ---------------------------------------
def test_title_desc_and_text_are_collected_in_document_order() -> None:
svg = (
b"<svg>"
b"<title>Top Title</title>"
b"<desc>What the diagram shows</desc>"
b"<text>first label</text>"
b"<text>second label</text>"
b"</svg>"
)
assert extract_svg_text(svg, stem="topo") == SvgText(
text="Top Title\nWhat the diagram shows\nfirst label\nsecond label",
structural=False,
)
def test_a_title_after_its_shape_still_follows_document_order() -> None:
svg = b"<svg><text>later</text><g><title>group title</title></g><desc>d</desc></svg>"
assert extract_svg_text(svg) == SvgText(
text="later\ngroup title\nd", structural=False
)
def test_title_and_desc_concatenate_nested_markup() -> None:
svg = b"<title>Deploy <tspan>flow</tspan> map</title>"
full = b"<svg>" + svg + b"</svg>"
assert extract_svg_text(full) == SvgText(text="Deploy flow map", structural=False)
def test_text_children_are_joined_with_a_space() -> None:
"""``<text>CPU<tspan>usage</tspan></text>`` reads "CPU usage" —
without the space the labels weld into one unsearchable token."""
svg = b'<svg><text>CPU<tspan>usage</tspan>7d</text></svg>'
assert extract_svg_text(svg) == SvgText(text="CPU usage 7d", structural=False)
def test_whitespace_is_normalised_in_every_item() -> None:
svg = b"<svg><title> rack\n\t layout map </title><text> a\n b </text></svg>"
assert extract_svg_text(svg) == SvgText(
text="rack layout map\na b", structural=False
)
def test_blank_title_is_dropped_and_never_stops_the_walk() -> None:
svg = b"<svg><title> </title><desc> </desc><text>real label</text></svg>"
assert extract_svg_text(svg) == SvgText(text="real label", structural=False)
def test_only_blank_text_falls_back_to_the_structural_line() -> None:
"""A blank ``<title>``/``<text>`` contributes no words, so the file
is still described by what it is (the empty ``<text>`` counts as a
drawn element)."""
svg = b'<svg viewBox="0 0 4 4"><title></title><text> </text></svg>'
assert extract_svg_text(svg, stem="blank") == SvgText(
text='Vector diagram "blank" — 1 shape (text), viewBox 4×4', structural=True
)
def test_repeats_are_dropped_case_insensitively_keeping_the_first() -> None:
svg = (
b"<svg>"
b"<desc>Power Draw</desc>"
b"<title>power draw</title>"
b"<text>Other</text>"
b"</svg>"
)
assert extract_svg_text(svg) == SvgText(text="Power Draw\nOther", structural=False)
def test_parser_decodes_entity_references_in_the_text() -> None:
"""``&amp;`` comes out decoded — the renderer re-escapes on display
(phase 180), so storing the decoded value is the correct direction."""
svg = b"<svg><title>Red &amp; Blue &#8212; map</title></svg>"
assert extract_svg_text(svg) == SvgText(text="Red & Blue — map", structural=False)
def test_attributes_are_never_text() -> None:
svg = (
b'<svg xmlns:xlink="'
+ XLINK_NS.encode()
+ b'"><title lang="en">Visible</title>'
b'<image xlink:href="secret-internal-name.png"/></svg>'
)
assert extract_svg_text(svg, stem="diagram") == SvgText(
text="Visible", structural=False
)
def test_an_image_only_svg_yields_no_words_from_its_href() -> None:
"""ASSUMPTION (task 01): an embedded ``<image>`` is not followed —
its ``href`` is not text, so the structural line describes the file."""
svg = (
b'<svg xmlns:xlink="'
+ XLINK_NS.encode()
+ b'" viewBox="0 0 10 10">'
b'<image xlink:href="inner.png"/>'
b"</svg>"
)
result = extract_svg_text(svg, stem="poster")
assert result is not None
assert result.structural is True
assert result.text == 'Vector diagram "poster" — 1 shape (image), viewBox 10×10'
# --- extraction: namespaces ------------------------------------------------
def test_the_default_svg_namespace_is_ignored() -> None:
svg = (
b'<svg xmlns="'
+ SVG_NS.encode()
+ b'"><title>Namespace title</title><desc>ns desc</desc>'
b'<text>ns label</text></svg>'
)
assert extract_svg_text(svg) == SvgText(
text="Namespace title\nns desc\nns label", structural=False
)
def test_a_declared_prefix_form_counts_like_the_plain_tag() -> None:
"""``ElementTree`` expands ``<svg:title>`` to Clark notation, so the
local-name match covers it for free."""
svg = (
b'<svg xmlns:svg="'
+ SVG_NS.encode()
+ b'"><svg:title>Prefixed</svg:title>'
b"<svg:desc>Prefixed desc</svg:desc></svg>"
)
assert extract_svg_text(svg) == SvgText(
text="Prefixed\nPrefixed desc", structural=False
)
def test_a_foreign_namespace_element_is_not_worded_text() -> None:
"""A ``<title>`` in a NON-SVG vocabulary is still matched by local
name (the walk is namespace-agnostic) while the foreign element
counts as a shape when it carries no words."""
svg = (
b'<svg xmlns:h="http://www.w3.org/1999/xhtml">'
b'<foreignObject><h:p>paragraph</h:p></foreignObject>'
b"</svg>"
)
result = extract_svg_text(svg, stem="mixed")
assert result is not None
assert result.structural is True
assert result.text == 'Vector diagram "mixed" — 2 shapes (foreignObject, p)'
def test_the_xml_declaration_encoding_is_honoured() -> None:
svg = b"<?xml version='1.0' encoding='UTF-8'?><svg><title>Caf\xc3\xa9</title></svg>"
assert extract_svg_text(svg) == SvgText(text="Café", structural=False)
def test_comments_are_not_text_and_not_shapes() -> None:
svg = b'<svg viewBox="0 0 2 2"><!-- a comment --><path d="M0 0"/></svg>'
result = extract_svg_text(svg, stem="clean")
assert result is not None
assert result.text == 'Vector diagram "clean" — 1 shape (path), viewBox 2×2'
# --- the MAX_SVG_TEXT_CHARS cap --------------------------------------------
def test_extraction_under_the_cap_is_untouched() -> None:
svg = b"<svg><title>" + b"x" * (MAX_SVG_TEXT_CHARS - 20) + b"</title></svg>"
result = extract_svg_text(svg)
assert result is not None
assert len(result.text) == MAX_SVG_TEXT_CHARS - 20
def test_over_cap_text_ends_on_a_sentence_boundary() -> None:
svg = b"<svg><title>" + b"x. " * 2000 + b"</title></svg>"
result = extract_svg_text(svg)
assert result is not None
assert result.structural is False
assert len(result.text) <= MAX_SVG_TEXT_CHARS
assert len(result.text) > MAX_SVG_TEXT_CHARS - 10
assert result.text.endswith(".")
assert result.text.count("x.") < 2000 # the cut is mid-item
def test_over_cap_text_prefers_the_last_newline_boundary() -> None:
"""Two items joined with ``\\n``: the newline is the LAST boundary
inside the window, so the second item is dropped whole (never a
partial word) and the trailing newline is stripped."""
first = b"a" * (MAX_SVG_TEXT_CHARS - 10)
svg = b"<svg><title>" + first + b"</title><text>" + b"b" * 200 + b"</text></svg>"
result = extract_svg_text(svg)
assert result is not None
assert result.text == first.decode()
def test_over_cap_text_without_any_boundary_is_hard_cut() -> None:
svg = b"<svg><title>" + b"x" * (MAX_SVG_TEXT_CHARS + 1000) + b"</title></svg>"
result = extract_svg_text(svg)
assert result is not None
assert result.text == "x" * MAX_SVG_TEXT_CHARS
# --- the structural fallback (LOCKED A2) -----------------------------------
def test_single_shape_line_matches_the_pinned_wording() -> None:
svg = b'<svg><path d="M0 0 L1 1"/></svg>'
assert extract_svg_text(svg, stem="topo") == SvgText(
text='Vector diagram "topo" — 1 shape (path)', structural=True
)
def test_shape_noun_pluralises_and_counts_are_listed() -> None:
svg = b"<svg><path/><path/><path/></svg>"
assert extract_svg_text(svg, stem="topo") == SvgText(
text='Vector diagram "topo" — 3 shapes (path×3)', structural=True
)
def test_top_three_kinds_with_document_order_tie_break() -> None:
svg = b"<svg><g/><g/><g/><path/><path/><circle/><rect/></svg>"
result = extract_svg_text(svg, stem="census")
assert result is not None
assert result.text == 'Vector diagram "census" — 7 shapes (g×3, path×2, circle)'
assert result.structural is True
def test_structure_elements_are_never_counted_as_shapes() -> None:
svg = b"<svg><title> </title><desc/><g><rect/></g></svg>"
result = extract_svg_text(svg, stem="nested")
assert result is not None
assert result.text == 'Vector diagram "nested" — 2 shapes (g, rect)'
def test_a_nested_svg_element_is_structure_too() -> None:
svg = b"<svg><g><svg><rect/></svg></g></svg>"
result = extract_svg_text(svg, stem="inset")
assert result is not None
assert result.text == 'Vector diagram "inset" — 2 shapes (g, rect)'
def test_viewbox_reports_width_and_height() -> None:
svg = b'<svg viewBox="0 0 128 64"><path/></svg>'
result = extract_svg_text(svg, stem="wide")
assert result is not None
assert result.text == 'Vector diagram "wide" — 1 shape (path), viewBox 128×64'
def test_viewbox_accepts_commas_and_integral_floats() -> None:
svg = b'<svg viewBox=" 0 , 0 , 8.0 , 6.0 "><path/></svg>'
result = extract_svg_text(svg, stem="comma")
assert result is not None
assert result.text == 'Vector diagram "comma" — 1 shape (path), viewBox 8×6'
def test_viewbox_keeps_fractional_sizes_without_padding() -> None:
svg = b'<svg viewBox="0 0 8.5 6.25"><g><circle/></g></svg>'
result = extract_svg_text(svg)
assert result is not None
assert result.text == "Vector diagram — 2 shapes (g, circle), viewBox 8.5×6.25"
def test_unparseable_viewbox_omits_the_clause() -> None:
for raw in (b"", b"0 0 8", b"0 0 8 6 4", b"0 0 wide tall", b"0 0 NaN NaN"):
svg = b'<svg viewBox="' + raw + b'"><path/></svg>'
result = extract_svg_text(svg, stem="odd")
assert result is not None, raw
assert result.text == 'Vector diagram "odd" — 1 shape (path)', raw
def test_missing_viewbox_omits_the_clause() -> None:
result = extract_svg_text(b"<svg><path/></svg>", stem="nobox")
assert result is not None
assert "viewBox" not in result.text
def test_viewbox_without_shapes_is_the_only_fact() -> None:
svg = b'<svg viewBox="0 0 16 9"></svg>'
assert extract_svg_text(svg, stem="canvas") == SvgText(
text='Vector diagram "canvas" — viewBox 16×9', structural=True
)
def test_empty_stem_never_renders_empty_quotes() -> None:
assert extract_svg_text(b"<svg></svg>") == SvgText(
text="Vector diagram", structural=True
)
assert extract_svg_text(b'<svg viewBox="0 0 1 1"><path/></svg>') == SvgText(
text="Vector diagram — 1 shape (path), viewBox 1×1", structural=True
)
def test_a_root_that_is_not_svg_still_yields_honest_text() -> None:
"""The walk is tag-based — a mis-named root is not rejected, and the
root is never counted among the shapes."""
result = extract_svg_text(b"<html><title>Legacy</title></html>", stem="legacy")
assert result == SvgText(text="Legacy", structural=False)
structural = extract_svg_text(b"<html><body/></html>", stem="legacy")
assert structural == SvgText(
text='Vector diagram "legacy" — 1 shape (body)', structural=True
)