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

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

- Verified: `app/rag/svg_text.py` (100 % cov), vector branch in `_index_image_file`, `_row_kind_mismatch` text→image migration, `IMAGE_MIMES[".svg"]`, sandboxed CSP on `GET /api/documents/{id}/image`, `.doc-image-img--vector` in `document.js`/`styles.css`, figure/thumbnail deliberate non-changes.
- Tests/lint (exact commands): `uv run pytest --cov=app --cov-report=term-missing` → **4265 passed, TOTAL 99 %** (exit 0) · `uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov` → **5 passed in isolation** · `uv run ruff check .` → clean · `uv run pyright` → **0 errors**.
- Regression (each run in isolation, `--no-cov`, all rc=0): `test_image_documents`, `test_chat_image_questions`, `test_answer_images`, `test_chat_answer_clusters`, `test_image_anchor_context`, `test_image_upload_feedback`, `test_markdown_inline_links_images`, `test_prose_word_wrap`, `test_pdf_support`, `test_pictorial_pdf`, `test_garbage_pdf`, `test_pdf_image_extraction`, `test_pdf_image_context`, `test_pdf_pages_in_sources`, `test_pdf_strip_additive_selfheal`, `test_sync_image_progress`.
- Completion criteria: ①SVG→`is_image`+`image_path`, no vision call, own `<title>/<desc>/<text>` — **met**; ②`image/svg+xml` + `default-src 'none'; sandbox` + `private, max-age=3600`, raster headers byte-unchanged — **met**; ③chat figure → modal `<img>`, no XML — **met**; ④`/document.html` full-width vector in `.doc-image` + text below — **met**; ⑤`![alt](/api/documents/<id>/image)` paints — **met**; ⑥raster/PDF unchanged — **met**; ⑦all gates green — **met**; ⑧commit + phase-dir move — harness-owned.
- Documentation: found and fixed README inconsistencies — stale forward reference "left to phase 181 / is phase 181's job" → completed-tense sizing note; "Updating the documents" standalone-image list missing `svg`; new **Raster only** bullet for question uploads (`.svg` → 422) documenting the `chat_images` guard; missing `svg_indexed=N` / `import: svg indexed` log contract; env-table heading `122/123/124` → `122/123/124/181`; new troubleshooting entry for an `.svg` showing raw XML. Full audit: every `Settings` field and every `.env.example` var is documented; pages/commands/scripts all exist. AGENTS.md verified accurate (phase-181 §5 clause present) — no changes needed beyond what the phase wrote.
- Notable deviation (kept from task 02): `app/api/chat_images.py` subtracts `_VECTOR_IMAGE_EXTENSIONS` from the question-image accept set — a required consequence of LOCKED A7 (`svg` in the default image set) since `_CHAT_IMAGE_FILENAME_RE` refuses to *serve* `.svg`; the regex itself is untouched, as the phase specified.
- Note for the commit: changes include `README.md`, `AGENTS.md` and `.env.example` — the phase file's `git add app/ frontend/ tests/ .env.example .agents/phases/` line omits the two markdown files; stage them too.
- Next pending phase: `182_verbatim_show_text`.
This commit is contained in:
2026-10-14 22:54:43 -04:00
parent 6880bd0866
commit 814a400e6d
70 changed files with 4460 additions and 52 deletions
+125 -11
View File
@@ -111,6 +111,20 @@ summary (stored verbatim, no ``lite`` call, no pointer line), so the
``is_summary`` position −1 chunk mirrors ``Document.summary``, which
equals ``Document.content``.
The image branch is RASTER (vision-described, the paragraph above) OR
VECTOR (phase 181, LOCKED A1: an ``.svg``). A vector file takes the
SAME branch — bytes digest, ``image_dir`` copy, ``is_image``/``image_path``,
the ``summary = content`` convention, the phase-125 ``image_context``
enrichment — but NEVER the model call: its searchable text is extracted
from the file itself (:func:`app.rag.svg_text.extract_svg_text` — the
``<title>``/``<desc>``/``<text>`` words, or LOCKED A2's structural line
built from the stem, the shape census and the ``viewBox`` when the
drawing carries no words at all). A file the extractor refuses (over the
size cap, DOCTYPE/entity-bearing, malformed XML) fails soft exactly like
an undescribed raster: ``images_failed`` + a warning, no row, no copy.
Successful vector indexes count ``svg_indexed`` (log-only) and log one
``import: svg indexed`` line.
PDF files (phase 126, LOCKED A1/A2/A3/A4): a ``.pdf`` file (the
``pdf`` token of ``BOR_IMPORT_EXTENSIONS``) takes the PDF index path
(:func:`_index_pdf_file` — the branch sits in :func:`_index_file`
@@ -359,6 +373,7 @@ from app.rag.summarizer import (
describe_pdf_page,
generate_summary,
)
from app.rag.svg_text import SvgText, extract_svg_text
logger = logging.getLogger("app.importer")
@@ -433,6 +448,14 @@ class ImportSummary:
#: Fail-soft: the sync continues, this counter + the warning line
#: are the signal.
images_failed: int = 0
#: Vector image docs (phase 181, task 02 — LOCKED A1) indexed from
#: their OWN extracted text (:func:`app.rag.svg_text.extract_svg_text`)
#: with NO vision call. A subset of the image branch: it does NOT
#: move ``added``/``updated`` (those count as for any other image).
#: Log-only (the sync ``detail`` API shape is untouched — the stats
#: log-only convention, phase 133): the owner reads it in the sync
#: log beside the per-file ``import: svg indexed`` line.
svg_indexed: int = 0
#: PDFs (phase 133, task 03) whose text layer failed the garbage
#: gate (:func:`app.rag.pdf.is_usable_pdf_text` — empty layer or
#: XML/binary soup) and took the page-screenshot vision fallback
@@ -498,7 +521,7 @@ class ImportSummary:
logger.info(
"import: summary files=%d added=%d updated=%d unchanged=%d pruned=%d "
"errors=%d chunks=%d embed_batches=%d summaries=%d summary_errors=%d "
"summary_backfilled=%d dates_updated=%d images_failed=%d "
"summary_backfilled=%d dates_updated=%d images_failed=%d svg_indexed=%d "
"image_context_backfilled=%d pdfs_screenshotted=%d "
"pdfs_pictorial=%d pdf_pictorial_pages=%d pdf_images_deduped=%d "
"pdf_images_blank_skipped=%d formats=%s",
@@ -515,6 +538,7 @@ class ImportSummary:
self.summary_backfilled,
self.dates_updated,
self.images_failed,
self.svg_indexed,
self.image_context_backfilled,
self.pdfs_screenshotted,
self.pdfs_pictorial,
@@ -1325,6 +1349,38 @@ def _regate_pdf_strip(
)
def _row_kind_mismatch(path: Path, doc: Document, settings: Settings) -> bool:
"""Phase 181 (task 02, design D4): does this row's KIND disagree with
how *path* would be indexed NOW — the ONE case that must fall
THROUGH the phase-147 unchanged fast path?
``True`` only while the ``images`` toggle is ON and the file matches
the image extension set while the row is still a TEXT row
(``is_image`` False). That is exactly the migration this phase needs:
an ``.svg`` imported before ``svg`` joined
:py:attr:`Settings.image_extension_set` is a text document whose
``content`` is the raw XML — and because the text digest of an ASCII
file equals the digest of its bytes, its ``file_hash`` (and even its
``content_hash``) matches perfectly, so a hash-only fast path would
freeze it as XML forever: no bytes route, no preview, no
``image_url`` citation figure — the exact complaint this phase
answers. Falling through runs the image branch, which rewrites the
row (``is_image``/``image_path``/extracted ``content`` + the
``image_dir`` copy) through the normal index write: no alembic
migration, no backfill script, and the next sync is fast again.
The REVERSE direction is deliberately NOT forced: with the toggle
OFF the walk never admits an image file (so an ``is_image`` row never
reaches :func:`_index_file` at all) and the phase-122 prune guard
keeps the row; requiring ``settings.images`` here keeps that
contract byte-identical (``svg`` in ``BOR_IMPORT_EXTENSIONS`` with
the toggle off still means "text document", LOCKED A7).
"""
if not settings.images or doc.is_image:
return False
return match_extension(path, settings.image_extension_set) is not None
async def _index_file(
session: Session,
*,
@@ -1400,10 +1456,17 @@ async def _index_file(
# fast path (and the branches' unchanged path) refresh the
# stored date from the same source.
raw_date = file_mtime_datetime(full_path)
if doc is not None and doc.file_hash == file_digest:
if (
doc is not None
and doc.file_hash == file_digest
and not _row_kind_mismatch(full_path, doc, settings)
):
# The fast path: the raw bytes are what was indexed last time
# — the bookkeeping (backfill / D1 / D4) runs in the shared
# helper, nothing else does.
# helper, nothing else does. Phase 181 (task 02, D4): "the same
# bytes" is only "the same document" when the row's KIND also
# matches how the file would be indexed NOW — see
# :func:`_row_kind_mismatch` (the SVG text→image migration).
await _unchanged_doc(
session, doc, source=source, rel=rel, llm=llm, summary=summary,
raw_date=raw_date, file_digest=file_digest, full_path=full_path,
@@ -3542,7 +3605,17 @@ async def _index_image_file(
on_chunk: Callable[[int, int], None] | None = None,
) -> None:
"""The phase-122 image branch of :func:`_index_file` — a standalone
image is indexed from its BYTES, never its text:
image is indexed from its BYTES, never its text.
Phase 181 (task 02, LOCKED A1) makes the branch RASTER **or**
VECTOR: an ``.svg`` takes this same path (digest, copy, ``is_image``,
``image_path``, chunking, ``summary = content``, the phase-125
``image_context`` enrichment) but its ``content`` comes from
:func:`app.rag.svg_text.extract_svg_text` instead of the vision
seam, and a success counts the log-only ``svg_indexed``. The bullets
and the fail-soft below are shared by both kinds — the only
difference is where the text comes from.
* the sha256 digest is over the raw bytes (the digest rule is
content identity — the same bytes are the same document);
@@ -3600,13 +3673,20 @@ async def _index_image_file(
digest = hashlib.sha256(data).hexdigest()
doc = session.scalar(select(Document).where(Document.source == source, Document.path == rel))
if doc is not None and doc.content_hash == digest:
if doc is not None and doc.is_image and doc.content_hash == digest:
# Phase 147: the shared unchanged path — a pre-phase NULL-hash
# row, or a bytes-changed/content-same image (the byte digest
# still matches). The helper stamps the new file hash (the
# next sync takes the fast path); for an image the summary is
# the stored description (``doc.content``), so the image-aware
# ``_store_summary`` backfill re-stores it verbatim.
# Phase 181 (task 02, D4): ``doc.is_image`` joins the guard — a
# NON-image row routed here by :func:`_row_kind_mismatch` is the
# migration case, not an unchanged one. An ASCII SVG indexed as
# TEXT digests to the SAME value here (the text path hashes
# ``content.encode()``, which for a byte-clean file equals the
# raw-bytes digest), so without it the text→image conversion
# would report ``unchanged`` forever and never produce the copy.
await _unchanged_doc(
session, doc, source=source, rel=rel, llm=llm, summary=summary,
raw_date=raw_date, file_digest=file_digest, full_path=full_path,
@@ -3629,16 +3709,50 @@ async def _index_image_file(
# it is generated BEFORE anything is written, so a failure skips the
# doc with no row and no copy (the copy is only made after a
# successful description — a failure never leaves an orphan).
content = await _describe_or_skip(
llm, data=data, source=source, rel=rel, full_path=full_path,
context=image_context,
)
#
# Phase 181 (task 02, LOCKED A1): the VECTOR branch. An ``.svg`` is
# described by NOBODY — OpenAI-compatible vision endpoints reject
# ``image/svg+xml`` and no rasteriser dependency is in scope — so
# its text comes out of the file itself (its ``<title>``/``<desc>``/
# ``<text>`` words, or LOCKED A2's structural line). Everything else
# in this function is shared: the byte digest, the ``image_dir``
# copy, ``is_image``/``image_path``, the title, the counters, the
# phase-125 ``image_context`` prepend below, the chunk pipeline and
# ``summary = content``. The seam below is not reached, so no vision
# call happens for a vector file (pinned by the unit tests).
is_vector = full_path.suffix.lower() == ".svg"
svg: SvgText | None = None
if is_vector:
svg = extract_svg_text(data, stem=full_path.stem)
content = None if svg is None else svg.text
else:
content = await _describe_or_skip(
llm, data=data, source=source, rel=rel, full_path=full_path,
context=image_context,
)
if content is None:
# LOCKED A3 fail-soft: an undescribed image is unsearchable
# noise — skip the doc entirely (no row, no copy).
# noise — skip the doc entirely (no row, no copy). Phase 181
# (LOCKED A2): the vector case skips ONLY when the extractor
# refused the file (over the size cap, DOCTYPE/entity-bearing,
# malformed XML) — a textless SVG is NOT a failure, it gets the
# structural line.
summary.images_failed += 1
logger.warning("import: image description failed source=%s path=%s", source, rel)
logger.warning(
"import: %s source=%s path=%s",
"svg text extraction failed" if is_vector else "image description failed",
source, rel,
)
return
if svg is not None:
# Phase 181 (task 02): the log-only vector counter + the
# auditability line (AGENTS rule 10) — ``structural=True`` marks
# the docs whose text is the LOCKED A2 fallback line.
summary.svg_indexed += 1
logger.info(
"import: svg indexed source=%s path=%s structural=%s chars=%d",
source, rel, svg.structural, len(svg.text),
)
if image_context is not None:
# Phase 125 (task 03): the enriched content — the reference
# context FIRST (for retrieval), then the description. The