"""Unit: phase 122 — image documents as first-class, retrievable docs. Task 01 (the toggle): the single env knob for image support (``BOR_IMAGES``) plus its two companions (``BOR_IMAGE_EXTENSIONS`` / ``BOR_IMAGE_DIR``): defaults (LOCKED A3 — off by default), env overrides, and the dotted frozenset parse the walk filter will consume (the ``import_extension_set`` precedent). Task 02 (the ingest): the walk admits image extensions ONLY when the image set is passed (the ``images`` toggle on); the binary index path (bytes digest, persistent copy into ``image_dir``, ``is_image``/ ``image_path``, ``content`` = the description, ``read_text`` never called); the stale-copy delete on a content change; the LOCKED prune guard (images-off runs never prune ``is_image`` docs; images-on runs prune a deleted image file + its copy); and the fail-soft skip (failed description → no row, no orphan copy, ``images_failed`` + warning + the counter in the PLAN §9 line). Task 03 (the description): ``summarizer.describe_image`` — ONE CHAT-model (vision) call (LOCKED A3 — the ``lite`` summary model is NOT assumed vision-capable) with the OpenAI-compatible multimodal user message (the fixed prompt's text part + the image's data-URL part), the reply stripped + capped at ``summary_max_chars``, ``None`` on any client error / empty reply. The seam ``importer._describe_or_skip`` is filled (the tests here exercise BOTH the patched seam — the importer-side mechanics — and the REAL seam through a mock vision client): the description becomes ``content`` AND ``summary`` (the ``is_summary`` chunk mirrors it — the ONLY embedded text of the doc), a failed description skips the doc (no row, no copy — and a CHANGED image's old row stays as-is), and the phase-118 backfill for an unchanged image doc re-stores the stored description verbatim. Task 04 (serve + display): the content endpoint's wire affordance (``is_image`` always present, ``image_url`` absent for text docs — never null) and the tree file node's omission rule (a text node serializes byte-identically to pre-phase), plus the house-style frontend source contracts: the viewer renders the ```` from ``doc.image_url`` with ``alt = summary`` + the onerror note, and the Sources row's 48px lazy thumbnail falls back to the document glyph on a failed fetch (the glyph swap keeps the module's single-innerHTML pin). The ext→mime map pin (task 03's ``IMAGE_MIMES`` — one map, one truth with the serve route's ``Content-Type``). Task 05 (the RAG display): the shared frame builder (``retriever.source_ref_with_image`` — the chat API's cited AND related tiers both run through it) carries the OPTIONAL ``image_url`` (the bytes route) on an ``is_image`` doc's ref and OMITS the key for a text doc (never null — the text-doc frame stays byte-identical, the nested-``done``-frame pin included); the agent ``read`` result for an image doc prefacing the description with the pinned ``IMAGE_DOC_MARKER`` line (header + date line byte-identical, the cap-truncation case marker-before-cut, a text doc's result byte-identical); and the house-style frontend source contracts — the chat sources block's inline image figure (both the citation chips and the related row, gated on ``s.image_url``) reads ``image_url``, sets ``alt`` + the visible caption to the document's summary (fetched from the content endpoint the chip's modal already uses — the frame carries no summary), and collapses to the plain chip on an image load error (never a broken-image icon). The SSE-level byte pin sits in ``tests/integration/test_chat_api.py``; the E2E rendering scenario is task 06's isolated story suite. """ from __future__ import annotations import asyncio import base64 import logging import re from pathlib import Path from typing import Any import pytest from pydantic import ValidationError from sqlalchemy import select import app.rag.importer as importer from app.config import Settings from app.models import Chunk, Document from app.rag.importer import import_sources, iter_importable_files from app.rag.llm import EmbeddingError, LLMError from app.rag.summarizer import ( DESCRIBE_PROMPT, IMAGE_DESCRIPTION_MODE, IMAGE_FALLBACK_MIME, IMAGE_MIMES, describe_image, ) from tests.fakes import FakeEmbedder def _settings(**kwargs: Any) -> Settings: """Build Settings without reading a .env file (deterministic tests).""" kwargs.setdefault("_env_file", None) return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime) def test_image_defaults_match_locked_a3() -> None: """LOCKED A3: image support is OFF by default (enable only when the chat model supports vision), the built-in extension set is the six common formats, and the image bytes' persistent home is the ``~/bor-sources/images`` raw string (``expanduser`` is the importer's job — the ``sources_dir``/``upload_dir`` convention).""" s = _settings() assert s.images is False # Dotted, lowercased form — the shape the walk's matcher consumes # (the ``import_extension_set`` precedent). assert s.image_extension_set == {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"} assert s.image_dir == "~/bor-sources/images" # The raw string is stored untouched (no expanduser here). assert s.image_extensions == "png,jpg,jpeg,webp,gif,bmp" def test_image_set_is_separate_from_the_import_set() -> None: """Images are never user-added via ``BOR_IMPORT_EXTENSIONS`` — the ``images`` toggle is the single knob (LOCKED A3): an override of the import set leaves the image set untouched, and vice versa.""" s = _settings(import_extensions="md") assert s.import_extension_set == {".md"} assert s.image_extension_set == {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"} s2 = _settings(image_extensions="md") assert s2.image_extension_set == {".md"} assert ".md" in s2.import_extension_set # the default import set is intact def test_images_env_toggle(monkeypatch: pytest.MonkeyPatch) -> None: """``BOR_IMAGES`` follows the house bool convention: ``1``/``true`` on, ``0``/``false`` off (case-insensitive), unset → off.""" monkeypatch.delenv("BOR_IMAGES", raising=False) assert _settings().images is False for on in ("1", "true", "TRUE", "yes"): monkeypatch.setenv("BOR_IMAGES", on) assert _settings().images is True for off in ("0", "false", "FALSE", "no"): monkeypatch.setenv("BOR_IMAGES", off) assert _settings().images is False def test_image_extensions_env_override(monkeypatch: pytest.MonkeyPatch) -> None: """``BOR_IMAGE_EXTENSIONS`` overrides the CSV; the frozenset parse is case-insensitive, trims spaces, and normalizes a leading dot.""" monkeypatch.setenv("BOR_IMAGE_EXTENSIONS", "PNG, Jpeg , .webp") s = _settings() assert s.image_extensions == "PNG, Jpeg , .webp" # stored raw assert s.image_extension_set == {".png", ".jpeg", ".webp"} monkeypatch.setenv("BOR_IMAGE_EXTENSIONS", "tiff") assert _settings().image_extension_set == {".tiff"} def test_image_dir_env_override(monkeypatch: pytest.MonkeyPatch) -> None: """``BOR_IMAGE_DIR`` overrides the raw string (expanduser stays the importer's job — no Path resolution in Settings).""" monkeypatch.setenv("BOR_IMAGE_DIR", "/srv/bor-images") assert _settings().image_dir == "/srv/bor-images" def test_image_extensions_rejects_empty(monkeypatch: pytest.MonkeyPatch) -> None: """A blank list would index zero images silently — fail loud at startup, naming the value (the ``import_extensions`` precedent).""" monkeypatch.setenv("BOR_IMAGE_EXTENSIONS", " , ") with pytest.raises(ValidationError, match="image_extensions"): _settings() def test_image_extensions_rejects_malformed_tokens( monkeypatch: pytest.MonkeyPatch, ) -> None: """A malformed token (e.g. a ``jpeb`` typo with punctuation) fails startup loudly, naming the bad token(s) — the same shape guard as ``import_extensions``.""" monkeypatch.setenv("BOR_IMAGE_EXTENSIONS", "png,jpe b") with pytest.raises(ValidationError, match="jpe b"): _settings() # --------------------------------------------------------------------------- # Phase 122, task 02 — the image ingest: walk filter, binary branch, copy # lifecycle, prune guard, fail-soft skip. # --------------------------------------------------------------------------- #: A real 1×1 transparent PNG — the importer is content-agnostic (it #: never parses the image), but a well-formed fixture keeps the tests #: honest about what a real upload looks like. PNG_1X1 = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=" ) class _SummaryEmbedFailsLLM(FakeEmbedder): """``embed`` succeeds on the content batch (the first) and FAILS on the second — the phase-30 ``is_summary`` chunk: the image doc lands indexed (its content chunks are already committed), its ``documents.summary`` stays NULL (the fail-soft summary path), and the phase-118 backfill trigger arms. (Task 03: for an image the summary is the stored description — no ``lite`` chat call exists in that path anymore, so the summary chunk's embed is the one remaining failure class.)""" def __init__(self) -> None: super().__init__() self._embed_batches = 0 async def embed(self, texts: list[str]) -> list[list[float]]: self._embed_batches += 1 if self._embed_batches == 2: raise EmbeddingError( "simulated summary-chunk embed failure (test sentinel)" ) return await super().embed(texts) class _VisionMockLLM(FakeEmbedder): """The phase-122 mock VISION client (task 03): the CHAT model answers the multimodal describe call (the image bytes' data URL) with a fixed, retrieval-oriented description; text (``lite``) calls keep the ``FakeEmbedder`` behaviour. The REAL ``_describe_or_skip`` → ``describe_image`` chain runs against it (no seam patch); every chat call's model is recorded (``chat_models``).""" DESCRIPTION = ( "A network diagram of the homelab VLANs: the core switch, the " "router, and three labeled subnets." ) def __init__(self) -> None: super().__init__() self.chat_models: list[str | None] = [] async def chat( self, messages: list[dict[str, Any]], model: str | None = None ) -> str: self.chat_calls.append(list(messages)) self.chat_models.append(model) user = next((m["content"] for m in messages if m.get("role") == "user"), "") if isinstance(user, list): # The phase-122 describe call — the multimodal message. return self.DESCRIPTION first = user.split() return "Summary of " + (first[0] if first else "") class _VisionFailsLLM(FakeEmbedder): """A chat model that REJECTS the multimodal describe call (the non-vision model of the LOCKED A3 note — the SDK errors; every image is skipped + logged, the honest visible failure). Text calls keep the ``FakeEmbedder`` behaviour.""" async def chat( self, messages: list[dict[str, Any]], model: str | None = None ) -> str: self.chat_calls.append(list(messages)) user = next((m["content"] for m in messages if m.get("role") == "user"), "") if isinstance(user, list): raise LLMError("simulated non-vision chat model (test sentinel)") first = user.split() return "Summary of " + (first[0] if first else "") def _image_settings(tmp_path: Path, **kwargs: Any) -> Settings: """Phase-122 image settings (toggle ON by default; the image dir defaults under *tmp_path* unless overridden).""" kwargs.setdefault("_env_file", None) kwargs.setdefault("images", True) kwargs.setdefault("image_dir", str(tmp_path / "images")) return Settings(**kwargs) # pyright: ignore[reportCallIssue] def _image_llm[ ImageLLM: FakeEmbedder ](tmp_path: Path, llm_cls: type[ImageLLM] = FakeEmbedder, **kwargs: Any) -> ImageLLM: """A fake LLM whose settings carry the phase-122 image knobs. *llm_cls* (task 03) may be a mock vision client (``FakeEmbedder`` subclass carrying extra state such as ``chat_models``) — the PEP 695 type parameter keeps the helper's return type honest.""" llm = llm_cls() llm.settings = _image_settings(tmp_path, **kwargs) return llm def _patch_description( monkeypatch: pytest.MonkeyPatch, description: str | None ) -> list[bytes]: """Pin the task-02 seam (``importer._describe_or_skip``) to *description* and record the BYTES it is handed (the digest-rule pin). Task 03 replaces the seam's body — the importer-side mechanics under test here do not change with it.""" handed: list[bytes] = [] async def _fake( llm: importer.Embedder, *, data: bytes, source: str, rel: str, full_path: Path ) -> str | None: handed.append(data) return description monkeypatch.setattr(importer, "_describe_or_skip", _fake) return handed def _doc(db, source: str, rel: str) -> Document: doc = db.scalar( select(Document).where(Document.source == source, Document.path == rel) ) assert doc is not None, f"no documents row for ({source!r}, {rel!r})" return doc def _cleanup_source(db, source: str) -> None: for doc in db.scalars(select(Document).where(Document.source == source)).all(): db.delete(doc) db.commit() # --- the walk filter ------------------------------------------------------- def test_walk_accepts_images_only_when_the_image_set_is_passed( tmp_path: Path, ) -> None: """The walk admits image extensions ONLY when the caller passes the image set (``import_sources`` does so while the ``images`` toggle is on): with an empty set (the default — toggle off) a ``.png`` is invisible, byte-identical to the pre-phase walk.""" root = tmp_path / "imgroot" root.mkdir() (root / "a.md").write_text("# A\n\nbody\n", encoding="utf-8") (root / "pic.png").write_bytes(PNG_1X1) import_set = frozenset({".md", ".txt"}) assert [p.name for p in iter_importable_files(root, import_set)] == ["a.md"] got = [ p.name for p in iter_importable_files( root, import_set, image_extensions=frozenset({".png", ".jpg"}) ) ] assert got == ["a.md", "pic.png"] # rglob is sorted # An image-set file never enters the import set (no ``md``-style # bleed): the two sets stay separate (LOCKED A3). assert [ p.name for p in iter_importable_files( root, import_set, image_extensions=frozenset({".gif"}) ) ] == ["a.md"] def test_images_off_run_is_byte_identical_walk(db, tmp_path: Path) -> None: """With the toggle OFF, ``import_sources`` walks the pre-phase set — a ``.png`` is not even counted as a file (not ``unknown``, not an error, no image row).""" root = tmp_path / "offimg" root.mkdir() (root / "a.md").write_text("# A\n\nbody\n", encoding="utf-8") (root / "pic.png").write_bytes(PNG_1X1) llm = _image_llm(tmp_path, images=False) try: summary = asyncio.run(import_sources([root], llm, session=db)) assert (summary.files, summary.added, summary.images_failed) == (1, 1, 0) assert summary.formats == {"md": 1} # never ``unknown``, never ``png`` assert ( db.scalar( select(Document).where( Document.source == root.name, Document.path == "pic.png" ) ) is None ) finally: _cleanup_source(db, root.name) # --- the binary index path -------------------------------------------------- def test_image_binary_branch_digests_bytes_copies_and_sets_fields( db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The binary branch: the sha256 is over the raw BYTES (the digest rule is content identity), the copy lands in ``image_dir`` as ``.png``, ``is_image``/``image_path`` are set, ``content`` is the description (the ONLY embedded text — the embedding model never sees pixels), and ``read_text`` is NEVER called for an image.""" root = tmp_path / "imgsrc" root.mkdir() (root / "pic.png").write_bytes(PNG_1X1) llm = _image_llm(tmp_path) handed = _patch_description(monkeypatch, "A red square on a white background.") def _no_read_text(self, *args: Any, **kwargs: Any) -> None: raise AssertionError(f"read_text called for an image file: {self}") monkeypatch.setattr(Path, "read_text", _no_read_text) try: summary = asyncio.run(import_sources([root], llm, session=db)) assert (summary.files, summary.added, summary.images_failed) == (1, 1, 0) assert summary.formats == {"png": 1} # counted under its image token assert handed == [PNG_1X1] # the seam digests the BYTES, not text doc = _doc(db, root.name, "pic.png") assert doc.is_image is True assert doc.title == "pic" # the non-markdown stem rule assert doc.content == "A red square on a white background." # Task 03: the description IS the summary (stored verbatim — # no ``lite`` call, no pointer line). assert doc.summary == doc.content assert doc.image_path is not None copy = Path(doc.image_path) assert copy.parent == Path(llm.settings.image_dir).expanduser() assert copy.name == f"{doc.id}.png" # row and copy agree on the id assert copy.read_bytes() == PNG_1X1 # The ONLY embedded text is the description: every content chunk # carries it, and every chunk (content + the phase-30 summary # chunk) has a 768-dim vector. chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all() content_chunks = [c for c in chunks if not c.is_summary] assert [c.content for c in content_chunks] == [doc.content] for c in chunks: assert c.embedding is not None and len(c.embedding) == 768 finally: _cleanup_source(db, root.name) def test_failed_description_skips_the_doc_counts_and_logs( db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """LOCKED A3 fail-soft: a failed/empty description skips the doc entirely — no row, NO copy (the copy is only made after a successful description, so a failure never leaves an orphan — not even the dir), ``images_failed`` is bumped, a warning is logged, and the counter sits in the PLAN §9 summary line.""" root = tmp_path / "failimg" root.mkdir() (root / "pic.png").write_bytes(PNG_1X1) llm = _image_llm(tmp_path) _patch_description(monkeypatch, None) try: with caplog.at_level(logging.INFO, logger="app.importer"): summary = asyncio.run(import_sources([root], llm, session=db)) finally: _cleanup_source(db, root.name) assert (summary.files, summary.added, summary.images_failed) == (1, 0, 1) assert ( db.scalar(select(Document).where(Document.source == root.name)) is None ), "a failed description must leave NO row" assert not Path(llm.settings.image_dir).expanduser().exists(), ( "no copy — and not even the image dir — may be left behind" ) warnings = [ r for r in caplog.records if "image description failed" in r.getMessage() ] assert len(warnings) == 1 and warnings[0].levelno == logging.WARNING line = next( r.getMessage() for r in caplog.records if "import: summary files=" in r.getMessage() ) assert "images_failed=1" in line def test_unchanged_image_skips_and_backfills_null_summary( db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """An unchanged image (byte digest) is skipped — the seam is NOT called again, no content re-embed, no new copy — and an unchanged image doc whose summary is still NULL (an earlier fail-soft summary miss — for an image, the one remaining failure class after task 03: the ``is_summary`` chunk's embed) gets the phase-118 best-effort summary pass (image flavour: the summary is the stored description, stored verbatim — no ``lite`` call); the counters stay ``summary_backfilled``, never added/updated.""" root = tmp_path / "unchimg" root.mkdir() (root / "pic.png").write_bytes(PNG_1X1) handed = _patch_description(monkeypatch, "A red square.") try: # First import: the description succeeds but the summary # chunk's embed fails → the doc is indexed, its summary stays # NULL (the fail-soft summary path). llm1 = _image_llm(tmp_path, _SummaryEmbedFailsLLM) s1 = asyncio.run(import_sources([root], llm1, session=db)) assert (s1.added, s1.summaries, s1.summary_errors) == (1, 0, 1) assert len(handed) == 1 and handed[0] == PNG_1X1 doc = _doc(db, root.name, "pic.png") assert doc.content == "A red square." # the seam's description assert doc.summary is None assert doc.image_path is not None copy = Path(doc.image_path) assert copy.exists() # Unchanged re-run with a working LLM. llm2 = _image_llm(tmp_path) s2 = asyncio.run(import_sources([root], llm2, session=db)) assert (s2.unchanged, s2.added, s2.updated) == (1, 0, 0) assert s2.summary_backfilled == 1 and s2.images_failed == 0 assert len(handed) == 1, "the seam is NOT called on the unchanged run" assert len(llm2.calls) == 1, "no content re-embed — the summary chunk only" doc2 = _doc(db, root.name, "pic.png") assert doc2.summary is not None, "the NULL summary is backfilled" assert doc2.summary == doc2.content, "image summary = the stored description" schunks = [c for c in doc2.chunks if c.is_summary] assert len(schunks) == 1 assert schunks[0].position == -1 and schunks[0].embedding is not None assert schunks[0].content == doc2.summary finally: _cleanup_source(db, root.name) def test_changed_image_deletes_the_stale_copy_before_replacing( db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A CHANGED image (hash differs) deletes the stale ``image_path`` copy before writing the new one — here with a MOVED ``image_dir`` (the stale copy lives in the old dir and must be removed), the new copy takes the same ``.png`` name in the new dir, and the row's ``image_path``/``content`` follow.""" root = tmp_path / "chgimg" root.mkdir() old_dir, new_dir = tmp_path / "img-old", tmp_path / "img-new" v1, v2 = PNG_1X1, PNG_1X1 + b"replaced" # different bytes → different digest handed: list[bytes] = [] state = {"desc": "v1 description"} async def _fake( llm: importer.Embedder, *, data: bytes, source: str, rel: str, full_path: Path ) -> str | None: handed.append(data) return state["desc"] monkeypatch.setattr(importer, "_describe_or_skip", _fake) try: (root / "pic.png").write_bytes(v1) llm_old = _image_llm(tmp_path, image_dir=str(old_dir)) s1 = asyncio.run(import_sources([root], llm_old, session=db)) assert s1.added == 1 doc = _doc(db, root.name, "pic.png") assert doc.image_path is not None old_copy = Path(doc.image_path) assert old_copy.parent == old_dir and old_copy.exists() (root / "pic.png").write_bytes(v2) state["desc"] = "v2 description" llm_new = _image_llm(tmp_path, image_dir=str(new_dir)) s2 = asyncio.run(import_sources([root], llm_new, session=db)) assert (s2.updated, s2.added, s2.images_failed) == (1, 0, 0) assert handed == [v1, v2] doc2 = _doc(db, root.name, "pic.png") assert doc2.content == "v2 description" assert doc2.image_path is not None new_copy = Path(doc2.image_path) assert new_copy.parent == new_dir and new_copy.name == old_copy.name assert new_copy.read_bytes() == v2 assert not old_copy.exists(), "the stale copy must be deleted" finally: _cleanup_source(db, root.name) # --- the prune guard (LOCKED, derived from A3/A4) --------------------------- def test_prune_guard_images_off_keeps_image_docs_and_on_prunes_them( db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The phase-122 prune guard: with the toggle OFF the walk is blind to image files, and a ``prune=True`` run MUST NOT delete the pre-existing image doc (invisible to the walk ≠ deleted) — its copy survives too. With the toggle ON and the file gone, the normal prune runs: doc + copy deleted.""" root = tmp_path / "guardimg" root.mkdir() (root / "pic.png").write_bytes(PNG_1X1) llm_on = _image_llm(tmp_path) _patch_description(monkeypatch, "A red square.") try: s_on = asyncio.run(import_sources([root], llm_on, session=db)) assert s_on.added == 1 doc = _doc(db, root.name, "pic.png") assert doc.image_path is not None copy = Path(doc.image_path) assert copy.exists() # Toggle OFF, file still on disk (but invisible to the walk): # prune must NOT touch the image doc or its copy. llm_off = _image_llm(tmp_path, images=False) s_off = asyncio.run(import_sources([root], llm_off, session=db, prune=True)) assert (s_off.files, s_off.pruned) == (0, 0) _doc(db, root.name, "pic.png") # the doc survives assert copy.exists(), "the copy survives with the doc" # Toggle ON, file GONE: normal prune — doc + copy deleted. (root / "pic.png").unlink() s_on2 = asyncio.run(import_sources([root], llm_on, session=db, prune=True)) assert (s_on2.pruned, s_on2.added) == (1, 0) assert ( db.scalar(select(Document).where(Document.source == root.name)) is None ) assert not copy.exists(), "the copy is deleted with the doc" finally: _cleanup_source(db, root.name) # --------------------------------------------------------------------------- # Phase 122, task 03 — the description: the vision model writes the only # embedded text (``describe_image`` + the filled seam + the image-aware # summary storage). # --------------------------------------------------------------------------- class _VisionMock: """Duck-typed chat-model mock for :func:`describe_image` (task 03): records the message + model and returns a canned reply (or raises it). Satisfies the summarizer's ``SummaryLLM`` protocol. """ def __init__( self, reply: str | Exception = "A red square on a white background." ) -> None: self.settings = _settings() self.reply = reply self.messages: list[Any] = [] self.model: str | None = None async def chat( self, messages: list[dict[str, Any]], model: str | None = None ) -> str: self.messages = list(messages) self.model = model if isinstance(self.reply, Exception): raise self.reply return self.reply # --- describe_image: prompt shape, model, cap, fail-soft ------------------ def test_describe_prompt_carries_the_mode_marker() -> None: """The ``IMAGE_DESCRIPTION_MODE`` marker heads the describe prompt (the ``SUMMARY_MODE``/``DEFLECT_MODE`` convention, PLAN §6 — the deterministic E2E mock LLM keys on it; the story suite wires the mock's branch in task 06). Pinned here: a re-wrap of the prompt cannot silently re-route the mock, and the retrieval-oriented instruction stays in the constant.""" assert DESCRIBE_PROMPT.startswith(IMAGE_DESCRIPTION_MODE + ": ") for fragment in ( "faithfully", "visible text", "diagram, table", "salient details", "2-4 sentences", "ONLY text", ): assert fragment in DESCRIBE_PROMPT def test_describe_image_prompt_shape_multimodal_and_chat_model() -> None: """ONE chat-model call (LOCKED A3 — the vision ``llm_chat_model``, NOT the ``lite`` summary model) with the OpenAI-compatible multimodal user message: the fixed prompt's text part + the ``image_url`` part whose data URL is ``data:;base64,`` — no system prompt, no tools.""" mock = _VisionMock("A red square on a white background.") result = asyncio.run(describe_image(mock, data=PNG_1X1, mime="image/png")) assert result == "A red square on a white background." assert len(mock.messages) == 1 message = mock.messages[0] assert message["role"] == "user" content = message["content"] assert isinstance(content, list) and len(content) == 2 assert content[0] == {"type": "text", "text": DESCRIBE_PROMPT} b64 = base64.b64encode(PNG_1X1).decode("ascii") assert content[1] == { "type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}, } # LOCKED A3: the CHAT (vision) model — never the ``lite`` summary # model (it is not assumed vision-capable). assert mock.model == mock.settings.llm_chat_model assert mock.model != mock.settings.llm_summary_model def test_describe_image_caps_at_summary_max_chars() -> None: """The reply is cut exactly at ``summary_max_chars`` (the phase-30 cap — the description IS the summary, so it keeps the same uniform ceiling). The explicit ``settings`` parameter wins; ``settings=None`` falls back to the LLM's own settings.""" long_reply = "x" * 500 mock = _VisionMock(long_reply) capped = asyncio.run( describe_image( mock, data=PNG_1X1, mime="image/png", settings=_settings(summary_max_chars=100), ) ) assert capped == "x" * 100 mock2 = _VisionMock(long_reply) mock2.settings = _settings(summary_max_chars=42) assert ( asyncio.run(describe_image(mock2, data=PNG_1X1, mime="image/png")) == "x" * 42 ) def test_describe_image_strips_and_returns_none_on_failure_classes() -> None: """The reply is whitespace-stripped; ``None`` (the caller's fail-soft skip) on a client error, an empty reply, or a whitespace-only reply.""" mock = _VisionMock(" A red square.\n") assert ( asyncio.run(describe_image(mock, data=PNG_1X1, mime="image/png")) == "A red square." ) failing = _VisionMock(LLMError("simulated vision-model failure (test sentinel)")) assert asyncio.run(describe_image(failing, data=PNG_1X1, mime="image/png")) is None for empty in ("", " \n"): blank = _VisionMock(empty) assert ( asyncio.run(describe_image(blank, data=PNG_1X1, mime="image/png")) is None ) def test_image_mimes_cover_the_default_family() -> None: """The extension → MIME map (task 03; task 04 reuses it for the serve route's ``Content-Type`` — one map, one truth) covers the default image family — dotted lowercase keys, the ``image_extension_set`` shape — and the unlisted-token fallback is the generic mime (a custom ``BOR_IMAGE_EXTENSIONS`` format fails soft at the vision endpoint, never a guessed type).""" assert IMAGE_MIMES == { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".bmp": "image/bmp", } assert IMAGE_FALLBACK_MIME == "application/octet-stream" assert all(ext in IMAGE_MIMES for ext in _settings().image_extension_set) # --- the REAL seam (no patch): description → content = summary = the only # embedded text; failure → skip; changed + failure → old row stays ------- def test_image_description_real_seam_content_summary_and_only_embedded_text( db, tmp_path: Path ) -> None: """The REAL seam (no patch): the vision description becomes BOTH ``content`` and ``summary`` (the ``is_summary`` position −1 chunk mirrors it), the describe call went to the CHAT model (LOCKED A3), and the ONLY text ever handed to the embedder for the image is that description — the embedding model never sees pixels. The text doc in the same source is summarized by the ``lite`` stand-in, untouched by the image machinery.""" root = tmp_path / "realseam" root.mkdir() (root / "pic.png").write_bytes(PNG_1X1) (root / "notes.md").write_text("# Notes\n\nBody.\n", encoding="utf-8") llm = _image_llm(tmp_path, _VisionMockLLM) try: summary = asyncio.run(import_sources([root], llm, session=db)) assert (summary.files, summary.added, summary.images_failed) == (2, 2, 0) assert summary.summaries == 2 # the text doc + the image doc # The walk is sorted: notes.md first (its ``lite`` summary), # then pic.png (the multimodal describe — the CHAT model). assert llm.chat_models == [ llm.settings.llm_summary_model, llm.settings.llm_chat_model, ] doc = _doc(db, root.name, "pic.png") assert doc.is_image is True assert doc.content == _VisionMockLLM.DESCRIPTION assert doc.summary == _VisionMockLLM.DESCRIPTION # task 03: verbatim chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all() assert all(c.content == _VisionMockLLM.DESCRIPTION for c in chunks) summary_chunks = [c for c in chunks if c.is_summary] assert len(summary_chunks) == 1 and summary_chunks[0].position == -1 # The ONLY embedded text of the image doc is the description — # twice (the one content chunk + the is_summary chunk) — and no # base64/bytes anywhere in what the embedder saw. embedded = [t for batch in llm.calls for t in batch] assert embedded.count(_VisionMockLLM.DESCRIPTION) == 2 assert len(embedded) == 4 assert not any("iVBORw0KGgo" in t for t in embedded) finally: _cleanup_source(db, root.name) def test_image_description_real_seam_failure_skips_and_keeps_sync_green( db, tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: """The REAL seam with a NON-VISION chat model (LOCKED A3's honest failure — the SDK errors, ``describe_image`` returns ``None``): the doc is skipped (no row, no copy, not even the dir), ``images_failed`` is bumped, the warning is logged, and the sync completes with the other docs indexed.""" root = tmp_path / "realfail" root.mkdir() (root / "pic.png").write_bytes(PNG_1X1) (root / "notes.md").write_text("# Notes\n\nBody.\n", encoding="utf-8") llm = _image_llm(tmp_path, _VisionFailsLLM) try: with caplog.at_level(logging.INFO, logger="app.importer"): summary = asyncio.run(import_sources([root], llm, session=db)) finally: _cleanup_source(db, root.name) assert (summary.files, summary.added, summary.images_failed) == (2, 1, 1) assert ( db.scalar( select(Document).where( Document.source == root.name, Document.path == "pic.png" ) ) is None ), "a failed description must leave NO row" assert not Path(llm.settings.image_dir).expanduser().exists() # The importer's line names the document (the PLAN §9-style signal); # the seam's line names the REASON (model + the client error) — # together, the visible, greppable failure. doc_warnings = [ r for r in caplog.records if r.name == "app.importer" and "image description failed" in r.getMessage() ] assert len(doc_warnings) == 1 assert doc_warnings[0].levelno == logging.WARNING assert "source=realfail path=pic.png" in doc_warnings[0].getMessage() reason_warnings = [ r for r in caplog.records if r.name == "app.summarizer" and "image description failed" in r.getMessage() ] assert len(reason_warnings) == 1 assert "model=turbo" in reason_warnings[0].getMessage() def test_changed_image_with_failed_description_keeps_the_old_row( db, tmp_path: Path ) -> None: """A CHANGED image whose new bytes fail to describe: the doc is skipped (fail-soft) — the OLD row stays as-is (content, hash, and copy unmutated — the skip happens before any write), ``images_failed`` is bumped, and the sync completes.""" root = tmp_path / "keeprow" root.mkdir() v1, v2 = PNG_1X1, PNG_1X1 + b"changed" (root / "pic.png").write_bytes(v1) llm_ok = _image_llm(tmp_path, _VisionMockLLM) try: s1 = asyncio.run(import_sources([root], llm_ok, session=db)) assert s1.added == 1 doc1 = _doc(db, root.name, "pic.png") old_content, old_hash = doc1.content, doc1.content_hash assert doc1.image_path is not None old_copy = Path(doc1.image_path) assert old_copy.exists() (root / "pic.png").write_bytes(v2) llm_fail = _image_llm(tmp_path, _VisionFailsLLM) s2 = asyncio.run(import_sources([root], llm_fail, session=db)) assert (s2.files, s2.added, s2.updated, s2.images_failed) == (1, 0, 0, 1) doc2 = _doc(db, root.name, "pic.png") assert (doc2.content, doc2.content_hash) == (old_content, old_hash) assert doc2.image_path is not None assert Path(doc2.image_path) == old_copy and old_copy.exists() finally: _cleanup_source(db, root.name) # --------------------------------------------------------------------------- # Phase 122, task 04 — serve + display: the content endpoint's wire # affordance, the tree node's omission rule, and the frontend source # contracts (house-style source assertions). # --------------------------------------------------------------------------- from app.schemas import DocContent, KbTreeFile # noqa: E402 FRONTEND = Path(__file__).resolve().parents[2] / "frontend" DOCUMENT_JS = FRONTEND / "assets" / "document.js" SOURCES_JS = FRONTEND / "assets" / "sources.js" STYLES_CSS = FRONTEND / "assets" / "styles.css" def _text_doc_content() -> DocContent: return DocContent( source="S", path="a.md", title="A", format="md", created_at="2026-01-01T00:00:00+00:00", content="body", indexed_at="2026-01-01T00:00:00+00:00", chunks=1, ) _UNSET: Any = object() def _image_doc_content(doc_id: str = "d" * 32, image_url: Any = _UNSET) -> DocContent: """*image_url* defaults to the bytes route's path for *doc_id*; pass an explicit ``None`` for the row-without-a-copy case (the sentinel keeps ``None`` meaningful).""" return DocContent( source="S", path="pic.png", title="pic", format="png", summary="A red square.", created_at="2026-01-01T00:00:00+00:00", content="A red square.", indexed_at="2026-01-01T00:00:00+00:00", chunks=2, is_image=True, image_url=f"/api/documents/{doc_id}/image" if image_url is _UNSET else image_url, ) def test_doc_content_text_doc_wire_shape() -> None: """Task 04 wire contract: a TEXT doc response gains exactly ONE new key — ``is_image: false`` — and ``image_url`` is ABSENT (never null, the ``_drop_absent_share_url`` omission precedent). The new keys introduce no nulls anywhere in the text-doc shape (the pre-existing ``summary: null`` — the phase-36 markdown contract — is the only null; the integration suite pins the endpoint level, this pins the model level).""" dumped = _text_doc_content().model_dump() assert dumped["is_image"] is False # always present, never null assert "image_url" not in dumped # absent — never null def test_doc_content_image_doc_wire_shape() -> None: """An IMAGE doc response carries ``is_image: true`` + ``image_url`` (the bytes route's path). A ``None`` image_url (the row-without-a- copy corner) is omitted, not null — the viewer's onerror fallback covers it.""" dumped = _image_doc_content(doc_id="1" * 32).model_dump() assert dumped["is_image"] is True assert dumped["image_url"] == "/api/documents/" + "1" * 32 + "/image" missing_copy = _image_doc_content(image_url=None).model_dump() assert missing_copy["is_image"] is True assert "image_url" not in missing_copy # absent — never null def test_kb_tree_file_text_node_omits_the_image_affordance() -> None: """The tree's file-node omission rule (the wire contract behind the phase's byte-identical criterion): a text file node serializes byte-identically to pre-phase (all three image keys absent — not false/null), an image node keeps ``is_image`` + ``summary`` and drops a ``None`` ``image_url``.""" text = KbTreeFile(path="a.md", title="A", chunks=1, created_at="x", indexed_at="y") assert set(text.model_dump()) == { "kind", "path", "title", "chunks", "created_at", "indexed_at" } image = KbTreeFile( path="pic.png", title="pic", chunks=2, created_at="x", indexed_at="y", is_image=True, image_url="/api/documents/abc/image", summary="A red square.", ) dumped = image.model_dump() assert dumped["is_image"] is True assert dumped["image_url"] == "/api/documents/abc/image" assert dumped["summary"] == "A red square." no_copy = KbTreeFile( path="pic.png", title="pic", chunks=2, created_at="x", indexed_at="y", is_image=True ) dumped = no_copy.model_dump() assert dumped["is_image"] is True assert "image_url" not in dumped # absent — the glyph fallback renders assert dumped["summary"] is None # null stays (the alt fallback corner) def test_viewer_renders_the_image_block_from_image_url_with_alt_summary() -> None: """House-style source contract (document.js, the ONE shared renderDocument core — page + modal): an ``is_image`` doc renders the ```` block from ``doc.image_url`` (properties only — src / alt, never innerHTML with document-derived data), with ``alt`` = the summary (the WCAG alt contract — a NULL/blank summary falls back to the title), and the onerror fallback swaps in the "Image unavailable" note (the route's 404 — the row exists but the copy was lost; the description below still renders).""" js = DOCUMENT_JS.read_text(encoding="utf-8") assert "if (doc.is_image) {" in js, "the image block must gate on doc.is_image" assert 'wrap.className = "doc-image"' in js assert "img.src = doc.image_url" in js, "the must come from doc.image_url" # alt = the summary (the vision description) with the title fallback. assert "img.alt" in js assert "doc.summary.trim() !== \"\"" in js assert "? doc.summary" in js assert ": doc.title" in js # The onerror fallback — a small note (role=status), not a broken img. assert 'img.addEventListener("error"' in js assert 'note.className = "doc-image-unavailable"' in js assert 'note.setAttribute("role", "status")' in js assert "Image unavailable" in js # The description stays in the normal content slot below (the # existing plain-content path — doc-raw via textContent). assert "pre.textContent = doc.content" in js # ONE innerHTML in the module CODE (the XSS contract, unchanged — # the escape-first markdown render; comments may name it, the # count pin reads code only). code = re.sub(r"//.*", "", re.sub(r"/\*.*?\*/", "", js, flags=re.S)) assert code.count("innerHTML") == 1 assert "wrap.innerHTML = renderMarkdown(doc.content)" in code def test_viewer_suppresses_the_duplicate_summary_panel_for_images() -> None: """Phase 122 (task 04): for an image doc whose summary IS the verbatim description (summary === content — the importer invariant), the labeled Summary panel would duplicate the text right below the image, so the panel is suppressed; an admin-edited summary (different text) still renders with the phase-57 affordance (the gate stays a summary-presence check, only widened by the image-verbatim exclusion).""" js = DOCUMENT_JS.read_text(encoding="utf-8") assert "doc.is_image && doc.summary === doc.content" in js assert "if (doc.summary && doc.summary.trim() !== \"\" && !imageSummaryIsContent) {" in js def test_sources_row_thumbnail_falls_back_to_the_glyph_on_error() -> None: """House-style source contract (sources.js makeRow): an image row gets the FIXED 48px thumbnail box before the path link (the .kb-doc-path flex wrapper — the link keeps its ellipsis), with ``loading="lazy"`` and ``alt = summary`` (title/path fallback); a failed fetch swaps in the document glyph INSIDE the same box (progressive enhancement — no layout shift, no broken-image placeholder). Text rows keep the bare-link cell (the ``d.is_image`` gate). The glyph is static markup (aria-hidden; the module keeps its ONE innerHTML — the sync modal).""" js = SOURCES_JS.read_text(encoding="utf-8") assert 'pathWrap.className = "kb-doc-path"' in js assert "if (d.is_image) {" in js, "only image rows get the box" assert 'box.className = "kb-doc-thumb"' in js assert 'img.loading = "lazy"' in js assert "img.src = d.image_url" in js # alt = the summary, with the title/path fallback. assert "img.alt = alt" in js assert "d.summary.trim() !== \"\"" in js # The onerror fallback — the glyph IN the same fixed box. assert 'img.addEventListener("error"' in js assert "box.replaceChildren(docThumbGlyph())" in js assert 'span.className = "kb-doc-thumb-glyph"' in js assert 'span.setAttribute("aria-hidden", "true")' in js # The module's ONE innerHTML is still the sync-modal skeleton. code = re.sub(r"//.*", "", re.sub(r"/\*.*?\*/", "", js, flags=re.S)) assert code.count("innerHTML") == 1 # The fixed box is 48px in the CSS (object-fit: cover, the lazy # img fills it). css = STYLES_CSS.read_text(encoding="utf-8") assert "width: 48px" in css assert "object-fit: cover" in css def test_serve_css_covers_viewer_image_and_sources_thumb() -> None: """The phase-122 CSS ships: the viewer's image block (the theme's surface treatment, max-width 100% on the img, the reading-column cap — test_wide_column_css pins the token count) + the unavailable note, and the Sources thumbnail box (border/radius on theme tokens).""" css = STYLES_CSS.read_text(encoding="utf-8") for selector in (".doc-image", ".doc-image-img", ".doc-image-unavailable", ".kb-doc-thumb", ".kb-doc-thumb-img", ".kb-doc-thumb-glyph"): assert re.search(rf"^{re.escape(selector)} \{{", css, re.M), f"missing rule: {selector}" assert "max-width: 100%" in css # --------------------------------------------------------------------------- # Phase 122, task 05 — the RAG display: the SSE source frames' optional # ``image_url`` (the shared ``source_ref_with_image`` builder), the # agent ``read`` marker, and the chat sources block's inline image # (house-style frontend source contracts; the SSE-level byte pin sits in # tests/integration/test_chat_api.py, the E2E rendering in task 06's # isolated story suite). # --------------------------------------------------------------------------- import uuid # noqa: E402 from datetime import UTC, datetime # noqa: E402 from app.rag.agent import ( # noqa: E402 IMAGE_DOC_MARKER, READ_TRUNCATION_NOTICE, AgentHolder, _execute_tool, ) from app.rag.llm import ToolCallPiece # noqa: E402 from app.rag.retriever import TRUNCATION_MARKER, source_ref_with_image # noqa: E402 from app.schemas import ChatDoneEvent, SourceRef # noqa: E402 APP_JS = FRONTEND / "assets" / "app.js" def _function_body(js: str, header: str) -> str: """The full text of the function whose header is ``header`` — from the header to its brace-matched closing ``}`` (the test_source_chip_quality house pin; the pinned functions' template literals carry balanced ``${…}`` pairs and no string literal holds a stray brace).""" start = js.index(header) body_open = js.index("{", start) depth = 0 for j in range(body_open, len(js)): if js[j] == "{": depth += 1 elif js[j] == "}": depth -= 1 if depth == 0: return js[start : j + 1] raise AssertionError(f"unbalanced braces in {header!r}") def _frame_doc(is_image: bool) -> Document: """A detached documents row for the frame-helper pins (never persisted — ``source_ref_with_image`` only reads identity fields). ``is_image`` rows also carry the ``image_path`` the importer would have stored (the frame never uses it — the id alone builds the route).""" path = "pic.png" if is_image else "a.md" return Document( id=uuid.uuid4(), source="docs", path=path, full_path=f"/docs/{path}", title="pic" if is_image else "A", content="A red square." if is_image else "body", content_hash="0" * 64, created_at=datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC), is_image=is_image, image_path=None if not is_image else "/tmp/img.png", ) # --- the shared frame builder: image_url present/absent (omission rule) def test_source_ref_with_image_carries_the_bytes_route_for_image_docs() -> None: """The shared builder (``app.rag.retriever.source_ref_with_image`` — the chat API's cited AND related tiers both run through it): an ``is_image`` doc's ref carries the OPTIONAL ``image_url`` — the bytes route ``/api/documents//image`` (task 04's route, the same str-uuid form the content endpoint builds).""" doc = _frame_doc(is_image=True) ref = source_ref_with_image(doc) assert (ref.source, ref.path, ref.title) == ("docs", "pic.png", "pic") assert ref.image_url == f"/api/documents/{doc.id}/image" dumped = ref.model_dump() assert dumped["image_url"] == f"/api/documents/{doc.id}/image" # rides the wire def test_source_ref_with_image_text_doc_frame_is_byte_identical() -> None: """The omission rule: a TEXT doc's ref has ``image_url`` ``None`` and the model's serializer DROPS the key (never ``null``) — the wire key set stays exactly the pre-phase-122 shape, so a text-doc frame is byte-identical to before.""" doc = _frame_doc(is_image=False) ref = source_ref_with_image(doc) assert ref.image_url is None dumped = ref.model_dump() assert set(dumped) == {"source", "path", "title"} # no image_url key assert "image_url" not in dumped def test_done_frame_carries_image_url_only_on_the_image_ref() -> None: """Nested-serializer pin: inside the SSE ``done`` frame (the chat bubble's render input), the text refs keep the pre-122 key set and ONLY the image ref carries ``image_url`` (the frame's only new field — no other key, no doc-id leak beyond the path). A pre-phase client-saved ref (no ``image_url`` key) still parses — the ``None`` default keeps old saved chats intact.""" image, text = _frame_doc(is_image=True), _frame_doc(is_image=False) done = ChatDoneEvent( deflected=False, sources=[source_ref_with_image(text), source_ref_with_image(image)], related=[source_ref_with_image(text)], ) dumped = done.model_dump() assert set(dumped["sources"][0]) == {"source", "path", "title"} # text: pre-122 assert dumped["sources"][1]["image_url"] == f"/api/documents/{image.id}/image" assert "image_url" not in dumped["related"][0] # text: omitted, never null old_saved = SourceRef.model_validate( {"source": "docs", "path": "a.md", "title": "A"} ) assert old_saved.image_url is None # pre-phase saved refs parse # --- the agent read marker: image docs read as descriptions def test_image_doc_marker_is_the_pinned_line() -> None: """The marker is a module constant (``agent.IMAGE_DOC_MARKER``) — the model must reason about what it is reading: the text is a description GENERATED from the image, not the image's own words.""" assert IMAGE_DOC_MARKER == ( "Image document — the text below is a description generated from the image:" ) def _seed_read_doc(db, *, is_image: bool, content: str) -> Document: """One real documents row the ``read`` tool can resolve (source ``imgsrc`` — no fixture collision); cleaned up by the caller.""" path = "pic.png" if is_image else "a.md" doc = Document( id=uuid.uuid4(), source="imgsrc", path=path, full_path=f"/imgsrc/{path}", title="pic" if is_image else "A", content=content, content_hash="0" * 64, created_at=datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC), is_image=is_image, ) if is_image: doc.image_path = f"/tmp/{doc.id}.png" db.add(doc) db.commit() return doc def _read(db, settings, combined: str) -> tuple[str, AgentHolder]: holder = AgentHolder() result = _execute_tool( db, ToolCallPiece(id="call_1", name="read", arguments={"path": combined}), [], holder, settings, ) return result, holder def test_read_of_image_doc_prefaces_the_description_with_the_marker(db) -> None: """The ``read`` result for an image doc: the ``Document …`` header and the phase-106 date line stay BYTE-IDENTICAL (the E2E mock's ``_READ_RESULT_PREFIX`` header contract), and the marker line sits between the date line and the description — the model sees the description (the doc's ``content``), never raw bytes.""" doc = _seed_read_doc( db, is_image=True, content="A red square on a white background." ) try: result, holder = _read(db, _settings(), "imgsrc/pic.png") assert result == ( "Document imgsrc/pic.png:\n" "date: 2024-06-15\n" f"{IMAGE_DOC_MARKER}\n" "A red square on a white background." ) assert holder.read_docs == [doc] # an image read IS a citation read assert holder.tool_calls == 1 finally: db.delete(doc) db.commit() def test_read_of_text_doc_result_stays_byte_identical(db) -> None: """A non-image doc's ``read`` result carries NO marker — byte-identical to the pre-phase-122 result (header + date line + content, nothing else).""" doc = _seed_read_doc(db, is_image=False, content="CONTENT") try: result, _holder = _read(db, _settings(), "imgsrc/a.md") assert result == "Document imgsrc/a.md:\ndate: 2024-06-15\nCONTENT" assert IMAGE_DOC_MARKER not in result finally: db.delete(doc) db.commit() def test_read_of_image_doc_over_the_cap_truncates_after_the_marker(db) -> None: """The phase-95 cap still applies to image docs (the description is long prose like any content): the marker precedes the CUT, the shared truncation marker + notice follow the cut — and a text doc of the same length truncates WITHOUT the marker (byte-identical to pre-122).""" content = "x" * 50 image = _seed_read_doc(db, is_image=True, content=content) text = _seed_read_doc(db, is_image=False, content=content) settings = _settings(read_max_chars=10) try: img_result, _ = _read(db, settings, "imgsrc/pic.png") assert img_result == ( "Document imgsrc/pic.png:\n" "date: 2024-06-15\n" f"{IMAGE_DOC_MARKER}\n" f"{'x' * 10}\n" f"{TRUNCATION_MARKER}\n" f"{READ_TRUNCATION_NOTICE.format(shown=10, total=50)}" ) txt_result, _ = _read(db, settings, "imgsrc/a.md") assert txt_result == ( "Document imgsrc/a.md:\n" "date: 2024-06-15\n" f"{'x' * 10}\n" f"{TRUNCATION_MARKER}\n" f"{READ_TRUNCATION_NOTICE.format(shown=10, total=50)}" ) assert IMAGE_DOC_MARKER not in txt_result finally: db.delete(image) db.delete(text) db.commit() # --- the chat sources block: the inline image (house-style frontend) def test_chat_sources_figure_reads_image_url_sets_alt_and_collapses_on_error() -> None: """House-style source contract (app.js appendSourceImageFigure — task 05's "shown in the chat nicely" renderer): the figure's ```` comes from the ref's ``image_url``; ``alt`` is set (title first, then the SUMMARY once the content fetch settles — the frame carries no summary, image_url is its only new field) and so is the VISIBLE caption; a FAILED image load removes the whole figure — it collapses to the plain chip, never a broken-image icon. Properties only (src/alt/textContent) — the figure builds no innerHTML from document-derived data.""" js = APP_JS.read_text(encoding="utf-8") body = _function_body(js, "function appendSourceImageFigure(meta, s) {") assert "img.src = s.image_url" in body, "the must come from s.image_url" assert "img.alt = s.title || label" in body, "alt is set from the start (title)" assert "img.alt = summary" in body, "alt becomes the summary when it resolves" assert "caption.textContent = s.title || label" in body # visible caption: title assert "caption.textContent = summary" in body # …then the summary assert 'img.addEventListener("error"' in body, "the error handler must exist" assert "fig.remove();" in body, "a failed load collapses to the plain chip" assert "fetchContentSummary(s.source, s.path)" in body, ( "the frame carries no summary — the figure fetches it" ) code = re.sub(r"//.*", "", re.sub(r"/\*.*?\*/", "", body, flags=re.S)) assert "innerHTML" not in code, "no innerHTML from document-derived data" # The figure reuses the chip's navigation (phase 26 contract): assert 'fig.href = documentUrl(s.source, s.path, "/");' in body assert "openDocumentModal(s.source, s.path, fig);" in body def test_chat_sources_and_related_rows_gate_the_figure_on_image_url() -> None: """Both chat source surfaces (the citation chips AND the related row — the frame's two ref tiers, built by the same server helper) render the figure ONLY when the ref carries ``image_url``: text refs (and every pre-phase saved ref) add zero new DOM — the renderer stays byte-identical for them. The chip's text + affordance stay (the figure is additive, not a replacement), and the summary fetch targets the SAME content endpoint the chip's modal already uses (failing soft to null — the title stands).""" js = APP_JS.read_text(encoding="utf-8") src = _function_body(js, "function appendSources(wrap, sources) {") assert 'if (s.image_url) appendSourceImageFigure(meta, s);' in src assert 'chip.className = "source-chip";' in src, "the chip stays (additive)" assert "chip.textContent = label" in src rel = _function_body(js, "function appendRelated(wrap, related) {") assert 'if (s.image_url) appendSourceImageFigure(row, s);' in rel fetch = _function_body(js, "function fetchContentSummary(source, path) {") assert '"/api/documents/content?source="' in fetch assert ".catch(() => null)" in fetch, "a failed fetch fails soft (null)" def test_chat_sources_figure_css_is_compact_and_themed() -> None: """The figure's CSS (theme tokens only — the zero-literal invariant, so the monochrome themes carry it): a COMPACT img (max-height 96px, object-fit: contain) on the theme's surface, the caption in the AA-safe muted ink, and the hover is the flat underline (the related-doc pattern — no background swap, no elevation).""" css = STYLES_CSS.read_text(encoding="utf-8") for selector in (".source-image", ".source-image-img", ".source-image-caption"): assert re.search(rf"^{re.escape(selector)} \{{", css, re.M), f"missing rule: {selector}" img = re.search(r"^\.source-image-img \{\n([\s\S]*?)\n\}", css, re.M) assert img, "missing .source-image-img rule" img_body = img.group(1) assert "max-height: 96px" in img_body # the compact cap (the task's ~96px) assert "object-fit: contain" in img_body assert "background: var(--surface)" in img_body # the theme's surface cap = re.search(r"^\.source-image-caption \{\n([\s\S]*?)\n\}", css, re.M) assert cap, "missing .source-image-caption rule" assert "color: var(--ink-soft)" in cap.group(1) # AA: 5.1:1 on --surface hover = re.search(r"^\.source-image:hover \{([^}]*)\}", css, re.M) assert hover and "text-decoration: underline" in hover.group(1), ( "the hover is the flat underline (no background swap)" ) assert "background" not in (hover.group(1) if hover else "")