**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`.
1397 lines
60 KiB
Python
1397 lines
60 KiB
Python
"""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 "")
|