Files
brain-of-reese/.agents/phases/complete/181_svg_document_preview/00_phase.md
T
ducoterra 814a400e6d
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_svg_document_preview
**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`.
2026-10-14 22:54:43 -04:00

14 KiB
Raw Blame History

Phase 181 — SVG documents render as pictures, not as 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" Story: n/a (TODO-derived phase, Protocol B append — owner roadmap confirmation 2026-10-14) Context: The root cause is ingestion, not the viewer. app/config.py::image_extensions (L436) defaults to png,jpg,jpeg,webp,gif,bmp and .env mirrors it (BOR_IMAGE_EXTENSIONS), so an .svg can only enter the KB through BOR_IMPORT_EXTENSIONS — i.e. as a text document whose content is the raw XML, which frontend/assets/document.js::renderDocument then paints in <pre class="doc-raw">. That is exactly the "xml instead of a preview" the owner sees. The rest of the image machinery is already there and needs no change: app/rag/retriever.py::source_ref_with_image (L1010-1022) puts image_url = /api/documents/<id>/image on a cited image doc, app/schemas.py::SourceRef.image_url (L154-181) carries it (omitted when None), app/api/docs.py::get_document_content (L219-318) returns is_image + image_url + format, app/api/docs.py::get_document_image (L319-378) serves the stored bytes with IMAGE_MIMES.get(suffix, IMAGE_FALLBACK_MIME), frontend/assets/app.js::appendSources (L2386+) renders a .source-image figure per image_url ref, frontend/assets/document.js renders the .doc-image <img> block for doc.is_image (L453-470, L560+), frontend/assets/sources.js (L2328) renders the KB-tree thumbnail. app/rag/importer.py::_index_file already prefers the image branch over the text path (L1417-1425) and _index_image_file (L3532+) owns bytes-digest → _markdown_image_context (phase 125) → _describe_or_skip → copy to image_dir/<doc-id><ext> → is_image/image_path/content/summary. app/rag/summarizer.py::IMAGE_MIMES (L152-159, dotted keys) is the one map for both the vision data-URL mime and the route's Content-Type; describe_image is a CHAT-model vision call (phase 122 LOCKED A3). app/core/security_headers.py::CSP (L67-68) + its middleware (which PRESERVES a CSP an inner layer set — L109-110) is the injection boundary. app/rag/md_images.py::extract_image_refs (the phase-125 ![alt](path) scanner) already treats an .svg path like any other image. Test patterns: tests/unit/test_image_documents.py (toggle / ingest / describe-seam), tests/integration/test_docs_api.py (the image route), tests/e2e/test_image_documents.py + tests/e2e/test_answer_images.py (image E2E), tests/unit/test_markdown_blockquotes.py (source-pin + node harness).

Objective

An .svg in a source tree becomes an image document: its own text (<title>/<desc>/<text>) is what gets indexed and retrieved, its bytes are served as image/svg+xml under a sandboxed CSP, and every citation surface — the chat figure, the document modal, the /document.html viewer, the Sources thumbnail — paints the rendered vector graphic instead of XML. Plus the phase-180 renderer, an SVG referenced as ![alt](/api/documents/<id>/image) inside an answer or a document renders as a real image.

Design (shared by all tasks — the executor reads this, not the chat)

  • D1 — SVG is a vector image document; the vision call is bypassed (tasks 01–02, locked A1). OpenAI-compatible vision endpoints do not accept image/svg+xml, and adding a rasteriser dependency is out of scope. So an .svg takes the phase-122 image branch but its indexed text comes from the file itself: a new pure module app/rag/svg_text.py (the app/rag/md_images.py precedent — stdlib only, no I/O, fully unit-testable) returns the SVG's own words. content/summary follow the phase-122 image convention (summary = content) and the normal chunk pipeline embeds them; the phase-125 image_context (the ![alt](path) paragraphs around a reference to the file) still prepends, unchanged.
  • D2 — the text of a diagram is its <title>, <desc> and <text> (task 01). Matched by LOCAL name (namespace-agnostic: plain <title>, svg:title, and namespaced variants all count), in DOCUMENT order, whitespace-normalised, de-duplicated case-insensitively, joined with \n. Parsing is xml.etree.ElementTree — never a regex over untrusted markup — guarded against the billion-laughs/XE class by (i) refusing any input containing a <!DOCTYPE or <!ENTITY declaration (→ extraction failure, no parse attempt) and (ii) a MAX_SVG_BYTES = 2 MiB input cap (over-cap → extraction failure). XML entity refs inside text (&amp;) are decoded by the parser, which is the correct direction (the value is re-escaped on the way out by the renderer).
  • D3 — a textless SVG still gets honest, retrievable text (task 01, locked A2). Many real diagrams carry no words at all (pure <path>s). Skipping them (the phase-122 "undescribed image is unsearchable noise" posture) would make them invisible and never citable — the opposite of what the owner asked. So extract_svg_text falls back to a structural line built ONLY from facts in the file: Vector diagram "<file stem>" — <N> shapes (<the three most common element local names with counts>), viewBox <w>×<h> (each clause omitted when the file does not state it, the stem always present). No invented prose, no model call.
  • D4 — ingestion mechanics (task 02). image_extensions default gains svg (.env.example documents it; a live .env keeps its own value — the phase does not edit .env). app/rag/importer.py::_index_image_file branches on full_path.suffix.lower() == ".svg": vector files take extract_svg_text INSTEAD of _describe_or_skip; everything else (copy to image_dir, digest rules, is_image/image_path, title = file stem, counters, the on_chunk hook) is unchanged and shared. Fail-soft stays LOCKED A3-shaped: extraction returning None (over-cap, DOCTYPE-bearing, or unparseable XML) → summary.images_failed += 1, a warning, no row, no orphan copy. One extra guard is required for existing libraries: _index_file's fast path (L1403, doc.file_hash == file_digest) returns BEFORE the image branch, so a row that was indexed as TEXT when svg lived only in BOR_IMPORT_EXTENSIONS would stay a text doc forever even after the switch. Add a narrow mismatch guard — take the fast path only when the file's kind matches the row's (settings.images ∧ the extension is in image_extension_set ∧ doc.is_image is False ⇒ fall through to the image branch). Everything else keeps the phase-147 fast path byte-for-byte.
  • D5 — serving the bytes safely (task 03). IMAGE_MIMES[".svg"] = "image/svg+xml" in app/rag/summarizer.py (one map, one truth — the vector branch never reaches image_data_url, so the vision path is unaffected). get_document_image keeps its public, id-as-credential posture (phase 124 LOCKED A4 — a shared chat shows the figure to a guest) and its Cache-Control: private, max-age=3600, and adds ONE response header for image/svg+xml only: Content-Security-Policy: default-src 'none'; sandbox (the SecurityHeadersMiddleware preserves an inner CSP, so the route's value wins) — an <img> can never execute SVG script, but a user who opens the URL directly must not get a script-capable same-origin document either. X-Content-Type-Options: nosniff is already global.
  • D6 — the frontend (task 04). The modal, the viewer, the citation figure and the Sources thumbnail already key off is_image/image_url, so they get the SVG for free — the task is the sizing + honesty work: .doc-image-img--vector (applied in document.js where doc.format === "svg") with width: 100% so a viewBox-only SVG (no intrinsic size, the CSS default replaced-element box is 300×150) fills the .doc-image card instead of sitting small in it; the onerror .doc-image-unavailable fallback stays as-is; a legacy SVG row still stored as TEXT keeps rendering its XML in .doc-raw (honest — there are no bytes behind it) until the next sync converts it.
  • D7 — the chat figure is a 96px-capped thumbnail (.source-image-img, max-height: 96px; max-width: 100%; height: auto), so a size-less SVG inside it stays inside the cap; SourceRef carries no format field and NONE is added (the wire surface stays byte-identical for every text doc — phase-83/122 contract). Clicking the figure/chip opens the modal, which is the full-size preview (doc.format known there). Documented in the module comments.
  • NOT touched: the CSP constant in security_headers.py, app/api/chat_images.py::_CHAT_IMAGE_FILENAME_RE (question attachments are raster-only by design — a vector question image is out of scope), any rasteriser dependency, describe_image's prompt, the phase-124 answer-image path, the SourceRef schema, .env (user-owned), the PDF embedded-image pass (pdf.py — PDFs never embed SVG for this app's extractor).

Dependencies

  • 180_markdown_inline_links_images (todo) — its ![alt](src) transform is what makes ![alt](/api/documents/<id>/image) inside an answer or a document render; this phase supplies the route that answers that URL. No code-level dependency beyond that (the ingestion, the route and the viewer work without it).

Tasks

  1. 01_svg_text_extraction.md — the new pure app/rag/svg_text.py module: <title>/<desc>/<text> extraction with the XE/size guards and the structural fallback.
  2. 02_svg_index_as_image.md — svg in image_extensions, the vector branch in _index_image_file, and the fast-path kind-mismatch guard that converts existing text-SVG rows.
  3. 03_svg_bytes_route_csp.md — IMAGE_MIMES[".svg"] and the sandboxed image/svg+xml response from GET /api/documents/{id}/image.
  4. 04_svg_preview_render.md — the vector sizing class + CSS in the viewer, and the figure/modal/thumbnail verification (the "preview instead of XML" contract).
  5. 05_unit_integration_e2e.md — tests/unit/test_svg_text_extraction.py, tests/integration/test_svg_document_image.py, the phase's dedicated Playwright suite tests/e2e/test_svg_document_preview.py, the gates, the atomic commit.

Testing & Quality

  • Unit: tests/unit/test_svg_text_extraction.py (new, pure-function, no DB): title+desc+text in document order, namespace-prefixed variants, whitespace normalisation, de-duplication, &amp; decoded, the <!DOCTYPE/<!ENTITY refusal, the 2 MiB cap, malformed XML → None, the structural fallback for a textless SVG (stem + shape counts + viewBox, and each clause absent when unknown). Importer-level: the vector branch indexes an SVG with NO vision call (the _describe_or_skip seam patched to FAIL the test if called), content == extracted text, summary == content, the copy lands at image_dir/<id>.svg, is_image=True, the images_failed fail-soft path, and the fast-path conversion of a pre-existing text row (same bytes, file_hash unchanged).
  • Integration: tests/integration/test_svg_document_image.py (new): GET /api/documents/{id}/image for an SVG row → 200, Content-Type: image/svg+xml, the sandbox CSP header present, Cache-Control: private, max-age=3600; a raster row's response headers UNCHANGED (no CSP key added); the four 404 shapes (unknown id, unparseable id, text doc, missing file on disk) unchanged.
  • E2E (AGENTS rule 4): tests/e2e/test_svg_document_preview.py, run in isolation (uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov, DB up).
  • Coverage: >90% on app/ (uv run pytest --cov=app --cov-report=term-missing) — app/rag/svg_text.py is new pure code and must be covered near-completely; the importer/route deltas are covered by the tests above.
  • Lint/types: uv run ruff check . && uv run pyright clean.

Completion Criteria

  • an .svg in a source tree imports as is_image with image_path set, no vision call made, and its indexed text drawn from its own <title>/<desc>/<text>
  • GET /api/documents/<id>/image serves image/svg+xml with Content-Security-Policy: default-src 'none'; sandbox
  • clicking an SVG citation chip/figure in the chat opens the modal showing a rendered <img> — no XML text anywhere in the modal body
  • the /document.html viewer renders the SVG full-width in its .doc-image card (not the 300×150 default box) with the extracted text below it
  • a ![alt](/api/documents/<id>/image) reference inside a markdown document paints the graphic (the phase-180 renderer + this route — the E2E's two-stage seeding, test_markdown_image_ref_renders)
  • every raster/PDF image behaviour is unchanged (the phase-122/123/124/133/140 suites pass unedited)
  • uv run pytest green; coverage TOTAL >90%; the phase's E2E green in isolation; uv run ruff check . && uv run pyright clean
  • one --no-gpg-sign Conventional Commit; phase dir moved to .agents/phases/complete/ by the pipeline gate

Locked decisions

  • A1 — an SVG's indexed text is extracted from the file itself (<title>/<desc>/<text>), the vision describe call is bypassed for .svg; no rasteriser dependency is added (owner-confirmed 2026-10-14: "SVG as image document").
  • A2 — a textless SVG is indexed with a structural metadata line built only from facts in the file (stem, shape counts, viewBox) rather than skipped, so it stays retrievable and previewable (assistant decision made explicit here; it relaxes the phase-122 fail-soft skip for the vector case only — a genuinely unparseable or over-cap file still skips).
  • A3 — SVG bytes render ONLY through <img>, never inlined into the DOM, and the bytes route carries default-src 'none'; sandbox so a direct navigation cannot execute script (owner-confirmed 2026-10-14, roadmap confirmation).
  • A7 — svg joins the BOR_IMAGE_EXTENSIONS DEFAULT; the BOR_IMAGES toggle remains the single knob (phase 122 LOCKED A3). With BOR_IMAGES=0 an .svg stays a text document (raw XML in the viewer) and the walk never admits it as an image — no special-casing of the off state (owner-confirmed 2026-10-14, roadmap confirmation).

Commit

git add app/ frontend/ tests/ .env.example .agents/phases/ && git commit --no-gpg-sign -m "feat(kb): index SVGs as image documents and preview them instead of raw XML"