chore(agent): phase roadmap from TODO.md, 2 phases
Convert the 3 open TODO.md items into sequential phases appended to the existing pipeline (Protocol B, numbered after 179): - 180_markdown_inline_links_images: the shared escape-first renderer gains the [text](url) link transform, the  image transform, and the ####-###### heading levels, with CSS and the dedicated Playwright suite. - 181_svg_document_preview: SVGs index as vector image documents (text extracted from title/desc/text, no vision call), are served as sandboxed image/svg+xml, and preview as graphics instead of raw XML. TODO.md is cleared; its items now live in .agents/phases/. LLM-Generated: true
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# Phase 180 — Markdown inline links, inline images, and the full heading ladder
|
||||
|
||||
**Source:** `TODO.md` L4 ("Markdown [links](some-address) aren't being render correctly and show up as plain text"), L5 ("Quad-level headers (####) aren't being rendered and show up as plain text") and the in-answer half of L3 ("Need SVG render support on the front end when citing … source")
|
||||
**Story:** n/a (TODO-derived phase, Protocol B append — owner roadmap confirmation 2026-10-14)
|
||||
**Context:** `frontend/assets/markdown.js` — the SHARED escape-first renderer used by all three pages (`index.html` chat bubbles + thinking, `document.html` viewer, `shared.html`) and by `document-modal.js` through `document.js`'s `renderDocument`. Pipeline order: step 1 fence protection (`\u0000CODE n\u0000`) → step 1b GFM pipe-table protection (`\u0000TABLE n\u0000`, phase 44) → step 2 `text = inline(escapeHtml(text))` → step 2b blockquotes (phase 138) → step 3 the paragraph pass (split on `\n{2,}`, block guard `/^<(h\d|ul|ol|pre|li|blockquote)/`) → step 4 restore. The `inline(s)` chain (the single source of every inline transform, also applied per table cell) is today, in order: code span → `**strong**` → `*em*` → `^### `→`<h4>` → `^## `→`<h3>` → `^# `→`<h3>` → `^\s*[-*] `→`<li>` → `<ul>` grouping → `^\d+\. `→`<li>`. Three verified gaps: **no link transform exists at all** (`renderMarkdown("[a](https://x)")` returns the text verbatim inside a `<p>`), **no image transform exists at all** (`` prints literally), and the heading ladder stops at `###` — `####`/`#####`/`######` match no rule and print as plain text (each rule requires a space after its hashes, so no rule partially eats a deeper level). `frontend/assets/styles.css` — the `:root` palette (`--ink`, `--ink-soft`, `--line`, `--surface`, `--bg`, the `--brand-*` family, `--radius*`) plus the precedent rule families: `.md-table`/`.md-table-wrap` (L539-557), `.md-quote` + the `.msg.user .bubble .md-quote` inversion (L561-588), `.source-image-img` / `.doc-image-img` (L981-1027, L5611-5640) for image treatment. `app/core/security_headers.py::CSP` (L67-68) — `default-src 'self'; base-uri 'none'; frame-ancestors 'none'; img-src 'self' data:`: served-path and `data:` images are ALREADY permitted, no CSP change is needed or allowed here. Test patterns: `tests/unit/test_markdown_blockquotes.py` (source pins + a `HAVE_NODE`-skipped node behaviour harness + a byte-identity fixture string) and `tests/e2e/test_markdown_blockquotes.py` (dedicated fixture tree `tests/fixtures/quote_docs/`, mock LLM trigger, chat + viewer + localStorage-restore assertions).
|
||||
|
||||
## Objective
|
||||
`[text](url)` renders as a real, safe `<a>`, `` renders as a lazy `<img>`, and `####`–`######` render as `<h5>`/`<h6>` — in chat answers, the thinking block, saved/shared chats, the document viewer and the document modal, from the ONE shared renderer. Every input that contains none of the three constructs renders byte-identical to today, so the phase-44 table suite, the phase-138 blockquote suite and every renderer-dependent unit suite stay green unedited.
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **D1 — one chain, one place (tasks 01–03).** All three fixes are edits to the same `inline(s)` chain in `frontend/assets/markdown.js`. Nothing else in the pipeline moves: fences (step 1) and tables (step 1b) keep their precedence, and because a table cell goes through `inline(escapeHtml(cell))`, links/images/deep headings inside a cell work for free (pinned in task 05).
|
||||
- **D2 — escape-first is the security boundary, so NEVER unescape.** `inline()` always receives HTML-escaped text. The captured URL may therefore contain `&`/`'`/`"` — that is *valid* in an `href`/`src` and must be passed through verbatim (never decoded, never re-escaped). Any attempt to decode would re-open the injection surface the renderer exists to close.
|
||||
- **D3 — links (task 01).** `/\[([^\]]*)\]\(([^)\s]+)\)/g` (empty link text allowed, no whitespace inside the URL), applied AFTER the `*em*` rule and BEFORE the heading ladder. The URL is validated by an allowlist *function*, not a regex-in-replacement: allow `^https?://`, `^mailto:`, `^/`, `^#`, `^\.{1,2}/`; anything else (`javascript:`, `data:`, `vbscript:`, `file:`, bare `javascript:` variants) returns the ORIGINAL matched text unchanged — it stays visible as inert escaped text, never a live element. Absolute `http(s)://` links get `target="_blank" rel="noopener noreferrer"`; `mailto:`, root-relative and `#fragment` links get neither (a same-origin target in the same tab is the correct behaviour). Output marker: `<a class="md-link" …>`.
|
||||
- **D4 — images (task 02), matched BEFORE the link rule.** `/!\[([^\]]*)\]\(([^)\s]+)\)/g` → `<img class="md-img" src="…" alt="…" loading="lazy">`. The image rule runs FIRST so the `!` is never orphaned into `!<a>`. Same allowlist discipline: `/`-rooted served paths, `http(s)://` and `data:image/*` render; anything else stays inert text. `alt` is ALWAYS emitted (empty allowed) — an `<img>` without `alt` fails WCAG. Linked images (`[](url)`) get their OWN rule ahead of both (locked A6): `<a class="md-link" …><img class="md-img" …></a>` — otherwise the image rule leaves a stray `[` inside the link text.
|
||||
- **D5 — the heading ladder (task 03, locked A4).** Insert `/^#### (.*)$/gm → <h5>$1</h5>` and `/^#{5,6} (.*)$/gm → <h6>$1</h6>` immediately BEFORE the existing `/^### /` rule (deeper-first is the readable order; the rules cannot cross-match because every rule requires a space after its hashes). `#` and `##` keep mapping to `<h3>` — existing behaviour, deliberately unchanged. The step-3 guard already matches `h\d`, so no paragraph-guard change is needed (pinned, not assumed).
|
||||
- **D6 — CSS (task 04).** `.md-link` (underlined; `color: var(--brand-ink)` — 9.0:1 on `--surface`, the file's documented AA-safe brand text token — with `--brand-hover` on `:hover`, the shared 3px `--brand` `:focus-visible` ring already used at L163, and the `.msg.user .bubble .md-link { color: var(--bg) }` inversion following the `.md-quote` precedent at L588, 5.2:1 on the brand bubble), `.md-img` (`max-width:100%; height:auto; object-fit:contain` + the `.source-image-img` border/radius/surface language), and the `.doc-md` / `.bubble` / `.thinking-text` `h5`/`h6` type scale. Palette variables ONLY, no new hue (the monochrome invariant), every non-obvious color carrying its `/* n.n:1 on --x (AA) */` comment.
|
||||
- **D7 — byte-identity is the regression contract (task 05).** A checked-in fixture string with no links, no image refs and no `####`+ heading, plus its byte-exact pre-phase renderer output (the phase-138 `NO_QUOTE_INPUT`/`NO_QUOTE_PRE_PHASE_OUTPUT` shape), pinned in the node behaviour harness.
|
||||
- **Known, documented limits (recorded in the module docstring, not fixed):** inline code spans are NOT placeholder-protected (pre-existing: `**bold**` inside backticks already transforms), so markdown syntax inside an inline code span transforms too; a URL containing a literal `)` or whitespace is a miss; a size-less SVG (`viewBox` only, no `width`/`height`) in `.md-img` paints at the replaced-element default box — the vector sizing contract belongs to phase 181.
|
||||
- **NOT touched:** `app/**` (no server change in this phase), the CSP string, `document.js`'s `renderDocument` structure, `app.js`'s chip/figure builders, `styles.css` families outside `.md-*` + the `h5`/`h6` scale.
|
||||
|
||||
## Dependencies
|
||||
- `179_git_clone_server_freeze` (todo) — numbering/pipeline order only. The code dependency is the shared renderer at the head of `complete/` (phases 44 tables, 138 blockquotes).
|
||||
|
||||
## Tasks
|
||||
1. `01_inline_links.md` — the `[text](url)` → `<a class="md-link">` transform with the protocol allowlist in `markdown.js`.
|
||||
2. `02_inline_images.md` — the `` → `<img class="md-img">` transform (ahead of the link rule) + the linked-image rule.
|
||||
3. `03_heading_ladder.md` — `####` → `<h5>`, `#####`/`######` → `<h6>` in the ladder.
|
||||
4. `04_styles_markdown_inline.md` — `.md-link`, `.md-img` and the `h5`/`h6` type scale in `styles.css`.
|
||||
5. `05_unit_and_e2e.md` — `tests/unit/test_markdown_inline_links_images.py` + the phase's dedicated Playwright suite `tests/e2e/test_markdown_inline_links_images.py`, the full gates, the atomic commit.
|
||||
|
||||
## Testing & Quality
|
||||
- **Unit:** `tests/unit/test_markdown_inline_links_images.py` (new, the phase-138 pattern): source pins (the chain order code → strong → em → **image → linked-image → link** → headings → lists; the allowlist function; the exact `md-link`/`md-img` markers; no `unescape`/`decodeURIComponent` anywhere in the file; the `.md-link`/`.md-img` CSS rules reference palette vars only) + node behaviour (link renders with `target`/`rel`; `javascript:`/`data:` inert; relative + `#fragment` + `mailto:` link without `target`; `&` in a query string survives verbatim; image renders with `alt` (empty alt allowed), disallowed src inert; linked image wraps without a stray `[`; `####`→`<h5>`, `#####`/`######`→`<h6>`, `###`→`<h4>` unchanged; a link inside a fence stays code; a link/image inside a table cell renders; byte-identity fixture).
|
||||
- **E2E (AGENTS rule 4):** `tests/e2e/test_markdown_inline_links_images.py`, run **in isolation** (`uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov`, DB up).
|
||||
- **Coverage:** **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing`) — this phase adds no `app/` code, so the full suite staying green IS the gate.
|
||||
- **Lint/types:** `uv run ruff check . && uv run pyright` clean.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `[text](url)` renders as `<a class="md-link">` in a chat answer, the thinking block, a restored shared chat, the viewer and the document modal; `javascript:`/`data:` URLs stay visible as inert text
|
||||
- [ ] `` renders as `<img class="md-img" loading="lazy">` with an `alt` present; a non-allowlisted src stays inert text
|
||||
- [ ] `####` renders `<h5>`, `#####`/`######` render `<h6>`; `#`/`##`/`###` are unchanged
|
||||
- [ ] a link/image/`####` inside a fenced code block is not transformed; a cell-level link renders in a `.md-table`
|
||||
- [ ] an input with none of the three constructs renders byte-identical (phase-44 + phase-138 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
|
||||
- **A4 — `####` → `<h5>`; `#####` and `######` both → `<h6>`; `#`/`##` keep rendering as `<h3>` (owner-confirmed 2026-10-14, roadmap confirmation).**
|
||||
- **A5 — link/image URL allowlist = `http:`, `https:`, `mailto:` (links), `/`-rooted + `./`/`../` relative paths, `#fragment`, and `data:image/*` (images). Everything else renders as inert text, never a live element (owner-confirmed 2026-10-14, roadmap confirmation).**
|
||||
- **A6 — `[](url)` renders as a link wrapping the image (an explicit rule ahead of the two others), not as text with a stray bracket (assistant decision, surfaced here so the executor does not re-decide it).**
|
||||
|
||||
## Commit
|
||||
```bash
|
||||
git add frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(ui): render markdown links, inline images, and h5/h6 headings in the shared renderer"
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
# Task 01 — The link transform in the shared renderer
|
||||
|
||||
**Phase:** `180_markdown_inline_links_images` · **Source:** `TODO.md:4` — "Markdown [links](some-address) aren't being render correctly and show up as plain text"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
`renderMarkdown` turns `[text](url)` into `<a class="md-link" …>text</a>` in the shared `inline()` chain — through an explicit URL allowlist, escape-first, with every existing transform's output byte-identical.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/markdown.js` — in `const inline = (s) => …`, insert one link `.replace()` **after** the `*em*` rule and **before** the `^### ` heading rule: `/(\[)([^\]]*)(\]\()([^)\s]+)(\))/g` written plainly as `/\[([^\]]*)\]\(([^)\s]+)\)/g` (empty link text allowed, no whitespace in the URL). The replacement is a FUNCTION, not a string, so the URL passes the allowlist check:
|
||||
- `allowLinkUrl(url)` — returns the url for `^https?://`, `^mailto:`, `/`-rooted (`^/`), `^\.{1,2}/` relative, and `^#` fragment URLs; returns `null` for anything else (`javascript:`, `data:`, `vbscript:`, `file:`, `blob:`) — LOCKED A5;
|
||||
- allowed → `<a class="md-link" href="<url>"[ target="_blank" rel="noopener noreferrer"]>text</a>`; the `target`/`rel` pair is added ONLY for `^https?://` (a `mailto:`/relative/fragment link stays in the same tab);
|
||||
- not allowed → return the ORIGINAL matched substring untouched, so it renders as the inert escaped text it is today (never a live element, never dropped).
|
||||
2. `frontend/assets/markdown.js` — pass the captured URL through VERBATIM: the chain's input is already `escapeHtml`-ed, so `&`/`'`/`"` in a query string are already attribute-safe. No `decodeURIComponent`, no manual unescape, no re-escape (design D2 — this is the security boundary).
|
||||
3. `frontend/assets/markdown.js` — module docstring: add the phase-180 paragraph (the chain order is now code → strong → em → **linked-image → image → link** (tasks 02 adds the first two ahead of this one) → headings → lists), the allowlist (LOCKED A5), the `target`/`rel` rule, and the documented limits (a URL containing a literal `)` or a space is a miss; inline code spans are not placeholder-protected, so markdown syntax inside `` `…` `` transforms — the pre-existing `**strong**` behaviour, unchanged here).
|
||||
- ASSUMPTION: link text may itself contain inline markup (`**bold**`, `*em*`, `` `code` ``) — those rules run FIRST in the chain, so the text is already transformed when the link rule wraps it; no extra handling.
|
||||
- ASSUMPTION: autolinks (`<https://x>`) and reference-style links (`[text][ref]`) stay out of scope — the TODO names the inline `[text](url)` form only.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: pinned by task 05's file (source pins: the link rule sits between the `em` and heading rules, the marker is exactly `<a class="md-link"`, the file contains no `decodeURIComponent`/`unescape`; node behaviour: absolute link with `target="_blank" rel="noopener noreferrer"`, `mailto:`/relative/`#` link without them, `javascript:` + `data:` inert, `?a=1&b=2` keeping `&`, an empty link text, a link inside a table cell).
|
||||
- Coverage: n/a (frontend) — the suite + the pins are the gate.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] node repro: `renderMarkdown("see [the docs](https://example.com/a?x=1&y=2)")` → a `<p>` containing `<a class="md-link" href="https://example.com/a?x=1&y=2" target="_blank" rel="noopener noreferrer">the docs</a>`
|
||||
- [ ] node repro: `renderMarkdown("[click](javascript:alert(1))")` contains no `<a` and no `href`
|
||||
- [ ] every other existing construct (`strong`, `em`, `code`, `#`–`###`, lists, tables, fences, blockquotes) renders byte-identical to the pre-task renderer
|
||||
- [ ] `uv run pytest` green (the renderer-dependent unit suites unedited); `uv run ruff check .` clean
|
||||
@@ -0,0 +1,27 @@
|
||||
# Task 02 — The image transform (and the linked-image rule) in the shared renderer
|
||||
|
||||
**Phase:** `180_markdown_inline_links_images` · **Source:** `TODO.md:3` — "Need SVG render support on the front end when citing … Also would be nice to click an svg and see a preview in the RAG tab rather than the xml" (the in-answer rendering half; the document-preview half is phase `181_svg_document_preview`)
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
`renderMarkdown` turns `` into a lazy `<img class="md-img">` (and `[](url)` into that image wrapped in a link), through the same URL allowlist as task 01 — so a diagram referenced inside markdown paints instead of printing its reference text.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/markdown.js` — in the `inline()` chain, insert TWO rules **ahead of** task 01's link rule, in this order:
|
||||
1. **linked image first:** `/\[!\[([^\]]]*)\]\(([^)\s]+)\)\]\(([^)\s]+)\)/g` → `<a class="md-link" href="url"[ target="_blank" rel="noopener noreferrer"]><img class="md-img" src="src" alt="alt" loading="lazy"></a>` (LOCKED A6 — without this rule the image rule consumes the inner `` and the link rule then leaves a stray `[` in the link text). Both URLs are allowlist-checked (`allowLinkUrl` for the href, `allowImageUrl` for the src); either failing → the original text is returned untouched.
|
||||
2. **image second:** `/!\[([^\]]*)\]\(([^)\s]+)\)/g` → `<img class="md-img" src="src" alt="alt" loading="lazy">`.
|
||||
2. `frontend/assets/markdown.js` — add `allowImageUrl(url)` next to task 01's `allowLinkUrl`: allows `/`-rooted served paths (`/api/documents/<id>/image`, `/assets/…`), `^.{0,2}/` relative paths, `^https?://` and `^data:image/` — the `data:` case exists because the app's own CSP (`app/core/security_headers.py::CSP`, `img-src 'self' data:`) already permits `data:` images; everything else (`javascript:`, `file:`, non-image `data:`) returns `null` → the original escaped text stays visible and inert (LOCKED A5).
|
||||
3. `frontend/assets/markdown.js` — `alt` is ALWAYS emitted, escaped-passthrough like the URL, empty allowed (`alt=""` for a decorative image) — an `<img>` with no `alt` attribute fails WCAG 1.1.
|
||||
4. `frontend/assets/markdown.js` — docstring: record the final chain order (code → strong → em → **linked-image → image → link** → headings → lists), the image allowlist, and the documented limit that a size-less SVG (`viewBox` only) in `.md-img` paints at the replaced-element default box (vector sizing is phase 181's `04_svg_preview_render.md` contract).
|
||||
- ASSUMPTION: no `onerror` handler is added — the renderer emits an HTML STRING into `innerHTML`-style surfaces and the no-CDN/CSP posture forbids inline handlers; a broken src degrades to the browser's alt text (the house pattern for the shared renderer). The JS `onerror` fallbacks live in `document.js`/`app.js`, which do not route through this function.
|
||||
- ASSUMPTION: title syntax (``) is a miss (the URL capture forbids whitespace) — out of scope.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: pinned by task 05 (chain order pins the two image rules before the link rule; markers `<img class="md-img"` / `loading="lazy"`; `alt=` always present; node behaviour: served-path image, `data:image/*` image, disallowed-protocol image inert, linked image produces `<a class="md-link">` wrapping `<img class="md-img">` with no stray bracket, empty alt allowed, an image inside a table cell renders).
|
||||
- Coverage: n/a (frontend).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] node repro: `renderMarkdown('')` → `<p><img class="md-img" src="/api/documents/aaaa/image" alt="topology" loading="lazy"></p>`
|
||||
- [ ] node repro: `renderMarkdown('[](https://example.com)')` → one `<a class="md-link"` containing one `<img class="md-img"`, and the output contains no stray `[` or `]`
|
||||
- [ ] node repro: `renderMarkdown(')')` contains no `<img` and no `src=`
|
||||
- [ ] input with no image reference renders byte-identical; `uv run pytest` green
|
||||
@@ -0,0 +1,27 @@
|
||||
# Task 03 — Extend the heading ladder to `####`, `#####`, `######`
|
||||
|
||||
**Phase:** `180_markdown_inline_links_images` · **Source:** `TODO.md:5` — "Quad-level headers (####) aren't being rendered and show up as plain text"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
The ladder renders `#### x` as `<h5>x</h5>` and `##### x` / `###### x` as `<h6>x</h6>`; `#`, `##` and `###` keep their existing output exactly.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/markdown.js` — in the `inline()` chain, insert TWO rules immediately BEFORE the existing `/^### (.*)$/gm → "<h4>$1</h4>"` rule (deeper-first, so the ladder reads top-down by depth):
|
||||
- `/^#### (.*)$/gm` → `"<h5>$1</h5>"`
|
||||
- `/^#{5,6} (.*)$/gm` → `"<h6>$1</h6>"` (LOCKED A4: both `#####` and `######` map to `<h6>` — the app's type scale stops at two levels below `###`, no `<h7>` exists in HTML)
|
||||
The rules cannot cross-match each other or the existing ones because every heading rule requires a space after its hash run (`####` never matches `/^### /`).
|
||||
2. `frontend/assets/markdown.js` — leave `/^## /` and `/^# /` mapping to `<h3>` UNTOUCHED (LOCKED A4 — the existing flattening of `#`/`##` to `<h3>` is deliberate; the phase adds levels below `###`, it does not re-rank the existing ones).
|
||||
3. `frontend/assets/markdown.js` — the step-3 paragraph guard `/^<(h\d|ul|ol|pre|li|blockquote)/` already matches `h5`/`h6`, so NO change is needed there: pin it with a unit assertion rather than editing it.
|
||||
4. `frontend/assets/markdown.js` — docstring: record the ladder (`#`/`##` → `h3`, `###` → `h4`, `####` → `h5`, `#####`/`######` → `h6`) so the deliberate flattening is documented, not re-discovered.
|
||||
- ASSUMPTION: a heading line must start at column 0 of its line (the `m` flag, the existing rule shape) — an indented `#### x` stays plain text, matching `^### ` today.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: pinned by task 05 (source pins: the two new rules sit before `/^### /`; the guard regex unchanged; node behaviour: `#### A` → `<h5>A</h5>`, `##### A`/`###### A` → `<h6>A</h6>`, `### A` → `<h4>A</h4>` unchanged, `## A`/`# A` → `<h3>` unchanged, a `####` line inside a fence stays code, a `####` heading immediately followed by a paragraph is not wrapped in `<p>`).
|
||||
- Coverage: n/a (frontend).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] node repro: `renderMarkdown("#### Deep")` → `<h5>Deep</h5>` (no `<p>` wrapper, no literal `####`)
|
||||
- [ ] node repro: `renderMarkdown("##### Deeper\n###### Deepest")` → two `<h6>` elements
|
||||
- [ ] node repro: a `####` line inside a ``` fence renders inside `<pre><code>` unchanged
|
||||
- [ ] existing heading output byte-identical; `uv run pytest` green (phase-44/138 suites unedited)
|
||||
@@ -0,0 +1,26 @@
|
||||
# Task 04 — Styling for `.md-link`, `.md-img` and the `h5`/`h6` type scale
|
||||
|
||||
**Phase:** `180_markdown_inline_links_images` · **Source:** `TODO.md:4–5` (link + heading rendering), `TODO.md:3` (inline image rendering)
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
The three new elements are legible and theme-correct in every renderer context (chat bubble — brain and user, the thinking block, the shared page, the viewer, the document modal), built from the existing `:root` palette only.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/styles.css` — `.md-link` next to the `.md-quote` family (L561-588): `color: var(--brand-ink)` (the file's documented AA-safe brand text token, 9.0:1 on `--surface`), `text-decoration: underline` (a link must not rely on colour alone — WCAG 1.4.1), `text-underline-offset: 2px`, `:hover { color: var(--brand-hover) }`. The global `:focus-visible { outline: 3px solid var(--brand) }` (L162-166) already covers the link — do NOT add a competing focus rule, and do not reset the outline.
|
||||
2. `frontend/assets/styles.css` — `.msg.user .bubble .md-link { color: var(--bg); }` — the exact inversion the `.md-quote` precedent applies at L588 (the user bubble is a `--brand` fill: `--bg` on `--brand` is 5.2:1, AA). Add the same inversion for `:hover` if the default `--brand-hover` fails contrast on the brand fill — a colour-mix toward `--bg` is acceptable, a new hue is not.
|
||||
3. `frontend/assets/styles.css` — `.md-img`: `display: inline-block; max-width: 100%; height: auto; object-fit: contain;` plus the `.source-image-img` card language (`background: var(--surface)`, `border: 1px solid var(--line)`, `border-radius: var(--radius-sm)`) and `margin: 0.4rem 0`. No fixed width/height — an oversized screenshot must never widen the page.
|
||||
4. `frontend/assets/styles.css` — the deep-heading type scale, ONE rule set for all renderer contexts (no chat-specific duplication): `.doc-md h5, .msg .bubble h5, .thinking-text h5 { margin: 0.8rem 0 0.3rem; font-size: 0.95rem; }` and the `h6` sibling at `0.88rem`, following the existing `.doc-md h3` / `.doc-md h4` rhythm (L5282-5283) — the scale stays monotonic (`h3` 1.15 → `h4` 1.0 → `h5` 0.95 → `h6` 0.88) and every level keeps a visible size difference from body text.
|
||||
5. `frontend/assets/styles.css` — comment block above each rule in the house style: the phase number, the TODO source line, the contrast math for every non-obvious colour, and the "static content, no animation" note (the background-motion invariant).
|
||||
- ASSUMPTION: `h5`/`h6` in the chat bubble get explicit sizing (they inherit UA defaults today, which are larger than `0.95rem`) so a deep heading in an answer cannot out-shout the answer text.
|
||||
- ASSUMPTION: no `.md-img--vector` modifier here — vector (viewBox-only SVG) sizing belongs to `181_svg_document_preview/04_svg_preview_render.md`, which knows the document's format.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: pinned by task 05 — the `.md-link`, `.md-img`, `h5`/`h6` rules exist and reference ONLY `var(--…)` palette tokens (no hex literals, no `rgb(`) — the established `:root`-drift pin pattern used by the blockquote/table CSS tests; the `.msg.user .bubble .md-link` inversion is present.
|
||||
- Coverage: n/a (frontend).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] a link in a brain bubble, a user bubble, the thinking block, the shared page, the viewer and the document modal is underlined and passes ≥4.5:1 against its surface
|
||||
- [ ] Tab focus on a link shows the app's 3px `--brand` ring
|
||||
- [ ] a wide image in an answer does not produce a horizontal scrollbar on the chat page at 375px and at 1440px
|
||||
- [ ] `uv run pytest` green (the CSS-pinning unit suites unedited)
|
||||
@@ -0,0 +1,36 @@
|
||||
# Task 05 — Unit pins, the dedicated Playwright suite, the gates, the commit
|
||||
|
||||
**Phase:** `180_markdown_inline_links_images` · **Source:** `TODO.md:3–5` (the acceptance evidence for tasks 01–04)
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
Lock the three renderer fixes with a source-pin + node-behaviour unit file and the phase's own Playwright E2E suite (AGENTS rules 4/9), run every gate, and land the single atomic commit.
|
||||
|
||||
## Work
|
||||
1. `tests/unit/test_markdown_inline_links_images.py` (new — the `tests/unit/test_markdown_blockquotes.py` shape, copy its `HAVE_NODE = shutil.which("node") is not None` skip and its `subprocess` node harness):
|
||||
- **source pins on `frontend/assets/markdown.js`:** the chain order is code → strong → em → linked-image → image → link → `^#### ` → `^#{5,6} ` → `^### ` → `^## ` → `^# ` → lists (assert the index of each rule's literal in the file is strictly increasing); the exact markers `<a class="md-link"`, `<img class="md-img"`, `loading="lazy"`, `<h5>`, `<h6>`; `allowLinkUrl` / `allowImageUrl` named functions exist; the file contains NO `decodeURIComponent` / `unescape` / `innerHTML`; the step-3 guard regex is still exactly `/^<(h\d|ul|ol|pre|li|blockquote)/`.
|
||||
- **CSS pins on `frontend/assets/styles.css`:** `.md-link`, `.md-img`, the `h5` and `h6` rules exist and their colour values reference `var(--…)` only (no hex, no `rgb(`); `.msg.user .bubble .md-link` exists (the inversion).
|
||||
- **node behaviour (skipped without node):** every repro listed in tasks 01–04's Completion Criteria, PLUS the byte-identity contract — a checked-in `NO_INLINE_INPUT` / `NO_INLINE_PRE_PHASE_OUTPUT` fixture pair (a paragraph + a list + a pipe table + a fence + a blockquote, i.e. everything the renderer already supported) asserted byte-equal.
|
||||
2. `tests/fixtures/links_docs/` (new dedicated fixture tree — the phase-138 `quote_docs` precedent: a NEW tree, never the shared 13-doc fixture set, whose import count many suites pin). One document `homelab/inline-markdown.md` whose body carries: a `[absolute link](https://example.com/a?x=1&y=2)`, a `[relative link](/sources)`, a `mailto:` link, a `#anchor`, an inert `[oops](javascript:alert(1))` probe, `` (a tiny inline SVG — CSP `img-src 'self' data:` already allows it, no bytes fixture needed), an inert `)`, a `[](https://example.com)`, and an `#### Deep header` + `##### Deeper` line, plus a pipe table with a `[cell link](https://example.com)` inside a cell.
|
||||
3. `tests/e2e/test_markdown_inline_links_images.py` (new — the phase-138 E2E skeleton verbatim in structure: module-level fixture constants, `_import_fixtures` + `_run_in_thread` + `_reset_db` (truncate `chunks, documents, query_log, steering_notes, kb_overview` then re-import, `summary.added == 1`), `login(page, app_url, next="/")`, the mock LLM trigger answering with the byte-stable inline-markdown answer):
|
||||
- `test_chat_inline_markdown_renders` — the brain bubble contains `a.md-link` elements for the absolute (with `target="_blank"`, `rel` containing `noopener noreferrer`, `href` preserving `&`), relative and `mailto:` links; NO `a.md-link` for the `javascript:` probe (its text stays visible); `img.md-img` for the data-URL image with a non-empty `alt` and `loading="lazy"`; the linked image is an `a.md-link > img.md-img` with no stray bracket in its text; `h5`/`h6` elements exist; `table.md-table a.md-link` exists (cell-level transform); the turn is NOT deflected.
|
||||
- `test_viewer_inline_markdown_renders` — the fixture document opens from the Sources page in the document modal (admin, `openDocumentModal`) and renders the same `a.md-link` / `img.md-img` / `h5` / `h6` shapes (the shared renderer contract across surfaces).
|
||||
- `test_restore_keeps_inline_markdown` — reload the chat page: the localStorage restore path re-runs `renderMarkdown` and the restored brain bubble keeps its links, image and headings.
|
||||
- `test_no_horizontal_overflow_at_mobile` — at `viewport={"width": 375, "height": 720}`, `document.documentElement.scrollWidth <= clientWidth` with the wide image rendered (the `.md-img` `max-width: 100%` contract).
|
||||
4. Run the gates, in order, and fix what fails (do not baseline, do not skip):
|
||||
- `uv run pytest`
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL >90%
|
||||
- `uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov` (in isolation, DB up: `podman compose up -d db`)
|
||||
- `uv run ruff check . && uv run pyright`
|
||||
5. Move the phase directory to `.agents/phases/complete/` and commit — see the overview's Commit block.
|
||||
|
||||
## Testing & Quality
|
||||
- This task IS the phase's test gate. The unit file must fail if any of tasks 01–03's rules are reordered or removed, and must fail if task 04's CSS introduces a hard-coded colour.
|
||||
- Coverage: **>90%** on `app/` (no `app/` code changes in this phase — the number must not move).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest` green, including the new unit file (and green with node absent — the skip path)
|
||||
- [ ] `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%
|
||||
- [ ] `uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov` green in isolation
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean
|
||||
- [ ] one atomic commit, `--no-gpg-sign`: `feat(ui): render markdown links, inline images, and h5/h6 headings in the shared renderer`, containing `frontend/`, `tests/` and `.agents/`
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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 `` 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 `` 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 `` 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 (`&`) 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 `` transform is what makes `` 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, `&` 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 `` 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
|
||||
```bash
|
||||
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"
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
# Task 01 — `app/rag/svg_text.py`: the words inside a vector drawing
|
||||
|
||||
**Phase:** `181_svg_document_preview` · **Source:** `TODO.md:3` — "Need SVG render support on the front end when citing … source" (the indexed-text half: an SVG document must be retrievable without a vision call)
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
A new pure module turns SVG bytes into the text that makes the document searchable — its own `<title>`/`<desc>`/`<text>` words, or an honest structural line when it has none — with no third-party dependency, no I/O, and no network.
|
||||
|
||||
## Work
|
||||
1. `app/rag/svg_text.py` (new — the `app/rag/md_images.py` module shape: module docstring citing this phase + the LOCKED decisions, `from __future__ import annotations`, stdlib only, full type hints, pure functions):
|
||||
- `MAX_SVG_BYTES: int = 2 * 1024 * 1024` and `MAX_SVG_TEXT_CHARS: int = 4000` (module constants, documented — the cap binds on the EXTRACTED text, never silently mid-word: truncate at the last newline/period before the cap).
|
||||
- `@dataclass(frozen=True) class SvgText:` — `text: str` (the searchable text) and `structural: bool` (`True` when the fallback line was used, so the importer can log/count it).
|
||||
- `def extract_svg_text(data: bytes, *, stem: str = "") -> SvgText | None`.
|
||||
2. `app/rag/svg_text.py` — the guards, BEFORE any parse (LOCKED A3's sibling: untrusted XML):
|
||||
- empty/whitespace-only input → `None`;
|
||||
- `len(data) > MAX_SVG_BYTES` → `None` (the billion-laughs/memory guard, alongside the next one);
|
||||
- the lower-cased bytes containing `<!doctype` or `<!entity` → `None` (a document type declaration is refused outright, which is what makes stdlib `ElementTree` safe here — no external entities, no entity expansion);
|
||||
- `ElementTree.fromstring` raising `ParseError` → `None` (never re-raise: the caller's fail-soft is a skip, not a crash).
|
||||
3. `app/rag/svg_text.py` — extraction: a `local_name(tag)` helper that strips the `{namespace}` prefix, then walk `root.iter()` (document order) and collect, for local name `title` / `desc` → the element's `.text` block (`"".join(el.itertext())` so nested markup is carried), and for `text` → `" ".join(el.itertext())` (this is what covers `<tspan>` children). Normalise each item with `" ".join(item.split())`, drop empties, de-duplicate case-insensitively keeping the FIRST occurrence, cap at `MAX_SVG_TEXT_CHARS`, and return `SvgText("\n".join(items), structural=False)` when at least one item survived (parser-decoded entities like `&` come out decoded — the renderer re-escapes on display).
|
||||
4. `app/rag/svg_text.py` — the structural fallback (LOCKED A2), used only when the walk produced nothing: `Vector diagram "<stem>" — <N> shapes (<top-3 local names by count>), viewBox <w>×<h>` built ONLY from the file — `N` counts descendant elements excluding `svg`/`title`/`desc`; the `shapes` clause is omitted when `N == 0` and the noun pluralises normally (`1 shape`, `2 shapes`); the `viewBox` clause is omitted when the root has no parseable `viewBox` (four whitespace/comma-separated numbers; the last two are `w`/`h`) and when present reports `w×h` with integral values formatted without a trailing `.0`; the stem is `""` → the clause `Vector diagram` alone (no empty quotes). The em-dash form matches the app's own doc-comment typography and is the string the tests pin.
|
||||
5. `app/rag/svg_text.py` — module docstring: why the vision call is bypassed (LOCKED A1: vision endpoints reject `image/svg+xml`; no rasteriser dependency), the guard list, the document-order/dedupe rules, and the fallback contract (LOCKED A2: facts from the file, never invented prose).
|
||||
- ASSUMPTION: `<image>` elements embedding a raster inside the SVG are NOT followed (their `href` is not text; the byte-embedded raster belongs to the phase-151 PDF path, not here).
|
||||
- ASSUMPTION: `aria-label`/`role` attributes are not extracted — `<title>`/`<desc>` are the SVG-spec carriers and the fallback covers the rest.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_svg_text_extraction.py` starts here (completed in task 05) — every branch above is a pure-function test with a literal SVG string, no DB, no LLM, no filesystem: document order across `title`/`desc`/`text`, `tspan` joining, namespace-prefixed tags (`<svg:title>`, `xlink:` attributes ignored), whitespace normalisation, case-insensitive dedupe, `&` decoded, the `<!DOCTYPE`/`<!ENTITY` refusals (both a `<!DOCTYPE svg [<!ENTITY x "boom">]>` wrapper and a bare `<!ENTITY`), the over-cap refusal, malformed XML → `None`, and the fallback (shape counts + viewBox present/absent/malformed, empty stem).
|
||||
- Coverage: **>90%** on this module (target: near-100% — it is pure).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run python -c "from app.rag.svg_text import extract_svg_text; print(extract_svg_text(b'<svg viewBox=\"0 0 8 6\"><title>Top</title><text>a</text></svg>', stem=\"topo\"))"` reports the two extracted items in order with `structural=False`
|
||||
- [ ] a `<svg>` with only `<path>`s yields `Vector diagram "topo" — 1 shape (path)`-shaped text with `structural=True`
|
||||
- [ ] `extract_svg_text(b'<!DOCTYPE svg [<!ENTITY x "y">]><svg><title>&x;</title></svg>')` returns `None`
|
||||
- [ ] `uv run pytest tests/unit/test_svg_text_extraction.py -v` green; `uv run ruff check . && uv run pyright` clean
|
||||
@@ -0,0 +1,38 @@
|
||||
# Task 02 — SVG joins the image set and indexes as a vector document
|
||||
|
||||
**Phase:** `181_svg_document_preview` · **Source:** `TODO.md:3` — "Need SVG render support on the front end when citing … source. Also would be nice to click an svg and see a preview … rather than the xml"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
`svg` is an image extension, and `_index_image_file` indexes an `.svg` from its bytes + extracted text instead of a vision call — so the row arrives with `is_image=True`, `image_path` set and searchable content. A pre-existing text-indexed SVG row is converted on the next sync.
|
||||
|
||||
## Work
|
||||
1. `app/config.py` (L436) — `image_extensions: str = "png,jpg,jpeg,webp,gif,bmp"` → `"png,jpg,jpeg,webp,gif,bmp,svg"`; extend the field's doc-comment: SVG is the one VECTOR member (LOCKED A1 — no vision call, `app.rag.svg_text` supplies its text) and `BOR_IMAGES` remains the single knob (LOCKED A7). The existing `_image_extensions_known` validator (L659-673) needs no change (`svg` is a well-formed token); `image_extension_set` (L899-909) yields `.svg`.
|
||||
2. `.env.example` (the `# BOR_IMAGE_EXTENSIONS=…` line ~L167) and `README.md` (L794 prose, the L1239 settings table row) — add `svg` to the documented default, one sentence on the vector behaviour (indexed from its own `<title>`/`<desc>`/`<text>`; no vision call) and one on the `BOR_IMAGES=0` consequence (LOCKED A7: an `.svg` stays a text doc — raw XML in the viewer). Never touch the live `.env` (user-owned).
|
||||
3. `app/rag/importer.py::_index_image_file` (L3532+) — the vector branch, at the `_describe_or_skip` call site (L3632) ONLY:
|
||||
```
|
||||
if full_path.suffix.lower() == ".svg":
|
||||
extracted = extract_svg_text(full_path.read_bytes(), stem=full_path.stem)
|
||||
content = None if extracted is None else extracted.text
|
||||
else:
|
||||
content = await _describe_or_skip(...) # unchanged, byte-identical
|
||||
```
|
||||
Keep everything after it exactly as it is — the `images_failed += 1` + warning + `return` fail-soft when `content is None` (LOCKED A2: only a genuinely unusable file skips; the structural fallback means a textless SVG does NOT), the `image_context` prepend (`f"{image_context}\n\n{content}"`, phase 125 — unchanged), the digest over the raw bytes, the `image_dir/<doc-id>.svg` copy written only after success, `title = full_path.stem`, `is_image=True`, `image_path`, `summary = content`, the chunk pipeline and the `on_chunk` hook.
|
||||
4. `app/rag/importer.py` — import `extract_svg_text` next to the existing `app.rag.summarizer` imports (L356-359) and update the module docstring's image paragraph: the image branch is raster (vision-described) OR vector (`svg`, text-extracted, no model call).
|
||||
5. `app/rag/importer.py` — a log-only `svg_indexed: int = 0` counter on `ImportSummary` (docstring: log-only, the phase-133 stats convention — the sync `detail` API shape is untouched) incremented on a successful vector index, plus one `logger.info("import: svg indexed source=%s path=%s structural=%s chars=%d", …)` line so a run's SVG handling is auditable (AGENTS rule 10).
|
||||
6. `app/rag/importer.py::_index_file` — the fast-path kind guard (design D4). At the `if doc is not None and doc.file_hash == file_digest:` fast path (L1403), take it only when the row's KIND already matches how the file would be indexed now: compute the would-be image membership once (`settings.images` ∧ `match_extension(full_path, settings.image_extension_set) is not None`) and require `doc.is_image` to agree. That converts a row imported as TEXT (before `svg` joined the image set) — same bytes, same `file_hash` — into the image branch instead of returning early, while every correctly-classified file keeps the phase-147 fast path byte-for-byte. Add the explanatory comment (this is the migration; no alembic migration is needed — `is_image` flips through the normal index write).
|
||||
- The reverse direction (a raster row when `BOR_IMAGES` is off) must NOT be forced through the image path (the walk never admits it and the phase-122 prune guard protects it) — the guard only applies when `settings.images` is on.
|
||||
- ASSUMPTION: no dedicated backfill script (the phase-118/138 backfill pattern) — the next scheduled sync converts every SVG through the guard in step 6, which is idempotent and needs no new entrypoint.
|
||||
- ASSUMPTION: an SVG in BOTH `BOR_IMPORT_EXTENSIONS` and `BOR_IMAGE_EXTENSIONS` becomes an image document — `_index_file` already prefers the image branch (L1417-1425) over the text path, so no walk-precedence change is made.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit (`tests/unit/test_image_documents.py` extended, or a new `tests/unit/test_svg_image_ingest.py` if the file's scope reads better — the executor's call, stated in the commit body): the vector branch indexes an SVG while the `_describe_or_skip` seam is patched to raise (proving NO vision call), `content` equals the extracted text, `summary == content`, one `is_summary` chunk, `is_image=True`, `image_path` ends with `.svg` inside `image_dir`, `svg_indexed == 1`; extraction failure (over-cap bytes) → `images_failed == 1`, no row, no copy; the `image_context` enrichment prepends for an SVG exactly as for a raster; the fast-path guard — a row with `is_image=False` and a matching `file_hash` is RE-indexed as an image on the next run, while an already-correct row (raster `is_image=True`, same hash) still takes the fast path (assert `_describe_or_skip` is NOT called).
|
||||
- Regression: `tests/unit/test_image_documents.py`, `tests/integration/test_importer.py`, `test_import_docs_git.py`, `test_importer_dates.py` stay green — no raster/PDF behaviour changes.
|
||||
- Coverage: **>90%** on the changed lines.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `Settings().image_extension_set` contains `.svg` with no env override
|
||||
- [ ] importing a tree containing one `.svg` creates a row with `is_image=True`, an `image_path` under `image_dir`, and content from the SVG's own text — with the vision seam never invoked
|
||||
- [ ] a second sync over the unchanged file reports it `unchanged` (the guard fires once, the fast path resumes)
|
||||
- [ ] a pre-existing text-SVG row flips to `is_image=True` on the next sync (fixture test)
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean
|
||||
@@ -0,0 +1,31 @@
|
||||
# Task 03 — Serve the bytes as sandboxed `image/svg+xml`
|
||||
|
||||
**Phase:** `181_svg_document_preview` · **Source:** `TODO.md:3` — "…see a preview … rather than the xml" (the transport half: something must answer `/api/documents/<id>/image` with SVG the browser will paint, safely)
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
`GET /api/documents/{doc_id}/image` serves an SVG row as `image/svg+xml` behind a `default-src 'none'; sandbox` CSP, while every raster response keeps its current bytes, headers and 404 semantics unchanged.
|
||||
|
||||
## Work
|
||||
1. `app/rag/summarizer.py::IMAGE_MIMES` (L152-159) — add `".svg": "image/svg+xml"`. Extend the map's doc-comment: SVG is the one vector entry, and the vector import branch (task 02) never routes through `image_data_url`, so this entry exists for the BYTES route only (`app/api/docs.py::get_document_image` reads the same map — one map, one truth).
|
||||
2. `app/api/docs.py::get_document_image` (L319-378) — keep the lookup, the public id-as-credential posture (phase 124 LOCKED A4 — a shared chat shows the figure to a guest), the four 404 shapes (unknown id, unparseable id, text doc, missing file) and the `Cache-Control: private, max-age=3600`. Add ONE conditional header next to `media_type`:
|
||||
```
|
||||
headers = {"Cache-Control": "private, max-age=3600"}
|
||||
if media_type == "image/svg+xml":
|
||||
headers["Content-Security-Policy"] = "default-src 'none'; sandbox"
|
||||
```
|
||||
(LOCKED A3.) Raster responses gain NO new header — their header set is byte-identical.
|
||||
3. `app/api/docs.py` — docstring on the route: why SVG needs the extra header — an `<img>` can never execute SVG script, but the URL is directly navigable, and without the policy a stored SVG would be a script-capable same-origin document; `sandbox` blocks scripting AND makes the response an opaque origin (no same-origin fetch/form capability), `default-src 'none'` blocks every subresource. Note that `app/core/security_headers.py::SecurityHeadersMiddleware` PRESERVES an inner CSP (L109-110), so this route's value is the one the browser sees, and that the middleware's global `X-Content-Type-Options: nosniff` still applies.
|
||||
4. `README.md` — the security-headers section (where phase 82/123's CSP is described) gains one line: SVG document bytes are served sandboxed, with the one-sentence rationale.
|
||||
- ASSUMPTION: no `Content-Disposition: attachment` — the graphic must render inline; `sandbox` is the stronger and less intrusive control.
|
||||
- ASSUMPTION: the CSP is scoped to `image/svg+xml` (derived from the extension map, never from a client value) rather than applied to all image types — a raster needs no policy change, and adding one would risk the phase-123/124 image surfaces.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: `tests/integration/test_svg_document_image.py` (new, task 05 completes it): an SVG row → `200`, `content-type == "image/svg+xml"`, `content-security-policy == "default-src 'none'; sandbox"`, `x-content-type-options == nosniff`, `cache-control == "private, max-age=3600"`, body bytes byte-equal to the stored copy. A raster (`.png`) row → NO `content-security-policy` key in the response headers and the existing headers unchanged. The 404 shapes: unknown uuid, a non-uuid string (`404`, not 422), a text doc, an image row whose copy was deleted from disk.
|
||||
- Unit: `IMAGE_MIMES` contains `.svg` and the existing entries are untouched (the map is asserted by identity, not membership, so a silent removal fails).
|
||||
- Coverage: **>90%** on the changed lines.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `curl -sD- -o/dev/null http://127.0.0.1:8000/api/documents/<svg-doc-id>/image` shows `content-type: image/svg+xml` and `content-security-policy: default-src 'none'; sandbox`
|
||||
- [ ] the same request for a PNG row shows no `content-security-policy` header (and `content-type: image/png`)
|
||||
- [ ] `uv run pytest tests/integration/test_svg_document_image.py tests/integration/test_docs_api.py -v` green; `uv run ruff check . && uv run pyright` clean
|
||||
@@ -0,0 +1,28 @@
|
||||
# Task 04 — The preview instead of the XML (viewer, figure, modal, thumbnail)
|
||||
|
||||
**Phase:** `181_svg_document_preview` · **Source:** `TODO.md:3` — "Also would be nice to click an svg and see a preview in the RAG tab rather than the xml"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
An SVG document paints as a graphic at readable size in the document modal and the `/document.html` viewer, keeps its compact figure in the chat citation row, and keeps its Sources thumbnail — no XML text anywhere on those surfaces.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/document.js::docImageBlock` (L571-591) — one addition: when `doc.format === "svg"`, set the img's class to `doc-image-img doc-image-img--vector` (keep the existing base class so every current rule still applies). Rationale to put in the comment: an SVG carrying only a `viewBox` has NO intrinsic size, so the replaced-element default box (300×150) would sit small inside the full-width `.doc-image` card; `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 — design D7). Do NOT inline the SVG into the DOM (LOCKED A3): `img.src = doc.image_url` stays `createElement` + property assignment, which is already the file's XSS contract.
|
||||
2. `frontend/assets/styles.css` (next to `.doc-image-img`, L5623-5630) — `.doc-image-img--vector { width: 100%; height: auto; object-fit: contain; }` with the house comment (the no-intrinsic-size explanation, why `object-fit: contain` protects `max-height: 70vh` from squashing the drawing, static content/no animation). Palette-neutral: no new colour.
|
||||
3. `frontend/assets/app.js::appendSourceImageFigure` (~L2485+) — NO structural change: the figure already keys off `image_url`, already carries the summary caption and the chip's navigation (the click → `openDocumentModal` path is the "preview in the RAG tab" the owner asked for). Add the comment recording the deliberate non-change: a size-less SVG inside the 96px-capped `.source-image-img` scales inside the cap, and the full-size preview is the modal (no `format` field is added to `SourceRef` — the wire contract stays byte-identical for text docs).
|
||||
4. `frontend/assets/sources.js` (the `d.is_image` thumbnail branch, ~L2328) — verify the KB-tree thumbnail renders for an SVG row through the same `image_url` and add the same one-line comment; only touch the code if the E2E proves a real gap (report the finding in the commit body either way).
|
||||
5. Legacy honesty — do NOT special-case it: a row still indexed as TEXT (an `.svg` under `BOR_IMAGES=0`, or not yet re-synced) keeps rendering its XML in `.doc-raw`, which is the truth about that row (task 02's fast-path guard converts it on the next sync). Comment this in `docImageBlock` so nobody "fixes" it by sniffing the content string.
|
||||
- ASSUMPTION: no lightbox/zoom affordance is added — the modal at full width IS the preview the owner asked for; zoom is a separate feature request.
|
||||
- ASSUMPTION: no "download the SVG" control — the bytes URL is directly openable and the existing "Copy path" control already covers provenance.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_document_viewer.py` extended — `docImageBlock` for `format === "svg"` yields `class="doc-image-img doc-image-img--vector"` with `src` = `image_url` and `alt` = summary (title fallback); for `format === "png"` the class string is unchanged (the base class only); the CSS pins `.doc-image-img--vector` exists, references no colour literal, and keeps `object-fit: contain`.
|
||||
- E2E (task 05): the modal + viewer assertion that an SVG row renders an `<img>` with `naturalWidth > 0` and that the modal body contains no `<` XML text and no `<pre class="doc-raw">`.
|
||||
- Coverage: n/a (frontend) — the unit pins + the E2E are the gate.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] clicking an SVG source chip in a chat answer opens the modal showing the rendered graphic (no XML, no `.doc-raw` in the modal body)
|
||||
- [ ] `/document.html?source=…&path=…svg` shows the graphic spanning the `.doc-image` card (width ≥ 600px at a 1280px viewport for a `viewBox`-only SVG) with the extracted text below it
|
||||
- [ ] the chat citation figure and the Sources thumbnail show the graphic; the figure's caption is the extracted text
|
||||
- [ ] a raster image document's viewer DOM is unchanged (the phase-122/133 suites pass unedited)
|
||||
- [ ] `uv run pytest` green; `uv run ruff check .` clean
|
||||
@@ -0,0 +1,38 @@
|
||||
# Task 05 — Unit + integration + the dedicated Playwright suite, the gates, the commit
|
||||
|
||||
**Phase:** `181_svg_document_preview` · **Source:** `TODO.md:3` (the acceptance evidence for tasks 01–04)
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
Prove the whole SVG path — extraction, image-document ingestion, the sandboxed bytes route, and the preview at every surface — with the phase's own test files, run the AGENTS gates, and land the single atomic commit.
|
||||
|
||||
## Work
|
||||
1. `tests/unit/test_svg_text_extraction.py` — completes task 01's pure-function matrix (document order, `tspan` joining, namespaced tags, whitespace/dedupe rules, entity decoding, `<!DOCTYPE`/`<!ENTITY` refusal, the 2 MiB cap, malformed XML → `None`, the structural fallback with its plural + clause-omission rules, `MAX_*` constants pinned).
|
||||
2. `tests/unit/test_svg_image_ingest.py` (or the extension of `tests/unit/test_image_documents.py` chosen in task 02) — the vector branch with the `_describe_or_skip` seam patched to RAISE (the "no vision call" proof), `content`/`summary`/chunk shape, `is_image`/`image_path` under `image_dir`, `svg_indexed`, the extraction-failure skip (`images_failed`, no row, no copy), the phase-125 `image_context` prepend for an SVG, and the `_index_file` fast-path kind guard in BOTH directions (text row converts; correctly-classified row stays `unchanged`).
|
||||
3. `tests/integration/test_svg_document_image.py` — task 03's contract (SVG → `image/svg+xml` + the sandbox CSP + `nosniff` + `Cache-Control` + exact bytes; PNG → NO CSP key; the four 404 shapes) plus `GET /api/documents/content` for an SVG row: `is_image: true`, `image_url` present and of the form `/api/documents/<uuid>/image`, `format: "svg"`, `content` = the extracted text. Add the wire-contract pin that ties the phases together: that `image_url` shape is accepted by phase 180's `allowImageUrl` (assert the literal path passes the renderer's allowlist regex — the `` synergy).
|
||||
4. `tests/fixtures/svg_docs/` (new dedicated tree — never the shared fixture set) — `diagrams/topology.svg` (a hand-written multi-kilobyte diagram: a `viewBox` and NO `width`/`height`, a `<title>Homelab network topology</title>`, a `<desc>` sentence, two `<text>` labels, and a dozen `<path>`/`<rect>` shapes so retrieval has real words) + `notes/infra.md` (prose that names the diagram so the grounded question has a text co-citation) + `diagrams/blank.svg` (pure paths — the structural-fallback subject).
|
||||
5. `tests/e2e/test_svg_document_preview.py` (new; the `tests/e2e/test_image_documents.py` module-app pattern — its own app instance with `BOR_IMAGES=true` and `BOR_IMAGE_DIR` pinned to a suite tmp dir so a local `.env` cannot leak either way, the phase-138 `_reset_db` truncate-then-`import_sources` seeding, `login(...)`, mock LLM, `E2E_REAL_LLM` skipped):
|
||||
- `test_viewer_renders_svg_not_xml` — open the Sources row for `diagrams/topology.svg` (admin): the modal shows `img.doc-image-img--vector` with `naturalWidth > 0` and a non-empty `alt`; the modal body contains no `<pre class="doc-raw">` and no `<svg` XML text; the extracted `<title>` text appears as the description below the graphic; the "Full page" link's `/document.html` view shows the same graphic at ≥600px width at a 1280px viewport (the vector sizing contract).
|
||||
- `test_chat_figure_and_modal_preview` — the grounded question whose answer cites the SVG doc shows `.source-image` whose `<img>` loads (`naturalWidth > 0`) with the caption from the extracted text; clicking the figure opens the modal with the full graphic (the "preview in the RAG tab" contract) and Escape closes it.
|
||||
- `test_blank_svg_is_indexed_and_searchable` — `diagrams/blank.svg` exists as a document with the structural `Vector diagram "blank" — …` content (the LOCKED A2 fallback), listed on the Sources page with its thumbnail.
|
||||
- `test_markdown_image_ref_renders` (two-stage seeding — copy the fixture tree to the suite's tmp sources dir, import it, read the SVG doc's id from the DB, then write `notes/inline.md` containing `` 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.
|
||||
- `test_raster_and_pdf_surfaces_unchanged` — the smoke guard: a PNG doc in the same tree still renders through `.doc-image-img` (no `--vector` class) and the CSP header is absent on its bytes route.
|
||||
6. Run the gates in order, fixing what fails (no baselining, no skipping):
|
||||
- `uv run pytest`
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL >90%
|
||||
- `uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov` (in isolation, DB up)
|
||||
- `uv run ruff check . && uv run pyright`
|
||||
- `uv run python -m scripts.api_payload_audit` — the phase-167 byte-budget line for the new SVG route is INFO-sized (an SVG fixture stays well under 256 KB).
|
||||
7. Move the phase directory to `.agents/phases/complete/` and commit — see the overview's Commit block.
|
||||
|
||||
## Testing & Quality
|
||||
- This task IS the phase's gate. If a test cannot be made deterministic, fix the product, not the assertion (the AGENTS rule 9 posture) — and record anything genuinely untestable in the commit body.
|
||||
- Coverage: **>90%** on `app/`; `app/rag/svg_text.py` should sit at ≥95% (pure functions, no excuses).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest` green (new unit + integration files included)
|
||||
- [ ] `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%
|
||||
- [ ] `uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov` green in isolation
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean
|
||||
- [ ] `uv run python -m scripts.api_payload_audit` clean for the SVG route
|
||||
- [ ] one atomic commit, `--no-gpg-sign`: `feat(kb): index SVGs as image documents and preview them instead of raw XML`, containing `app/`, `frontend/`, `tests/`, `.env.example` and `.agents/`
|
||||
Reference in New Issue
Block a user