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**; ⑤`` 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:
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user