Files
brain-of-reese/tests/unit/test_image_documents.py
ducoterra a19d78d284
Build and Push Containers / build-and-push-app (push) Successful in 1m57s
Build and Push Containers / build-and-push-db (push) Failing after 13s
phase: 122_image_documents
**Phase 122 (image documents) — final verification pass: all green. No code changes were needed; defects found: none.**

**Verified (implementation already complete in working tree, reviewed end-to-end):**
- Toggle (`BOR_IMAGES`/`BOR_IMAGE_EXTENSIONS`/`BOR_IMAGE_DIR`, off by default) + `GET /api/config` `images` flag
- Ingest: bytes digest, `image_dir` persistent copy, `content = summary = vision description` (chat-model call; only text embedded), fail-soft skip + `images_failed` counter
- Serve/display: `/api/documents/{id}/image` route (404 matrix), viewer `<img>` + description, Sources 48px lazy thumbnails, chat inline source figure (alt = summary), agent `read` marker
- Prune guard: images-off syncs never prune `is_image` docs

**Test / lint / coverage (exact commands & outcomes):**
- `uv run pytest` → exit 0 (green; note: pytest 9.1.1 `-q` omits the final count line in output — exit code authoritative)
- `uv run pytest --cov=app --cov-report=term-missing` → **2715 passed, exit 0, TOTAL 99%** (>90% gate)
- `uv run ruff check . && uv run pyright` → "All checks passed!" / "0 errors, 0 warnings, 0 informations"
- `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → **4 passed, exit 0** (isolation)

**Completion criteria:** (1) images=true → described/embedded/displayed docs: ✅ (E2E + integration) · (2) images=false byte-identical + image docs survive sync: ✅ (E2E negative app + unit/integration) · (3) viewer + chat rendering with alt text; failed description skips + logs, sync completes: ✅ · (4) test/lint/coverage gates: ✅ · (5) commit + phase move: deferred to harness per this pass's rules (working tree left uncommitted).

**Notable deviation (pre-existing, documented in code):** image route uses `require_user` (phase-79 posture, same gate as the document content endpoint) rather than the phase text's "public" parenthetical — matches the endpoint it mirrors.

**Next pending phase:** `123_chat_image_questions`.
2026-09-25 01:54:23 -04:00

1397 lines
60 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 ``<img>`` 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 "<empty>")
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 "<empty>")
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
``<doc-id>.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 ``<doc-id>.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:<mime>;base64,<bytes>``
— 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 ``<img>`` 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 <img> 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/<id>/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
``<img>`` 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 <img> 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 "")