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:
+125
-11
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user