phase: 122_image_documents
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) — 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`.
This commit is contained in:
2026-09-25 01:54:23 -04:00
parent 0f77e9a876
commit a19d78d284
63 changed files with 5484 additions and 111 deletions
+101 -2
View File
@@ -106,11 +106,36 @@ class SourceRef(BaseModel):
rows server-side, so every server-built SSE ref fits by construction
(A3: the SSE path is provably unaffected); the cap binds only
client-saved refs — bounded at the boundary with a 422.
Phase 122 (task 05): ``image_url`` — the image BYTES route
(``/api/documents/<id>/image``) for a ref whose document is a
standalone image: the chat's sources block renders the compact
inline image from it (the "shown in the chat nicely" contract, TODO
L6). It is the ONLY new frame field (the doc id rides the path —
the same way the document content endpoint's ``(source, path)``
lookup does). For a TEXT document the field stays ``None`` and is
DROPPED on serialization (never ``null`` — the :class:`DocContent`
omission precedent), so a text-doc frame is byte-identical to
pre-phase. Server-built refs go through the shared
:func:`app.rag.retriever.source_ref_with_image` (one shape, both
frame tiers); a client-saved ref without the field parses with the
``None`` default (pre-phase saved chats restore unchanged).
"""
source: str = Field(max_length=120)
path: str = Field(max_length=1000)
title: str = Field(max_length=500)
#: Phase 122 (task 05) — see the class docstring. ``None`` (every
#: text doc, and every pre-phase client-saved ref) is omitted on
#: serialization — the key is ABSENT, never ``null``.
image_url: str | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
data = handler(self)
if self.image_url is None:
data.pop("image_url", None)
return data
class ChatThinkingEvent(BaseModel):
@@ -275,6 +300,20 @@ class KbTreeFile(BaseModel):
``GET /api/docs`` returns) / ``created_at`` (phase 106, D8 — the
document's creation date) / ``indexed_at`` (ISO-8601) are verbatim
from the catalogue row the endpoint reads.
Image affordance (phase 122, task 04): for an ``is_image`` file node
the three ``is_image`` / ``image_url`` / ``summary`` keys ride the
node (the RAG view's Path cell renders the 48px thumbnail from
``image_url`` with ``alt = summary``). For a TEXT file node all
three are OMITTED from the wire shape (the
:func:`_drop_image_fields` omission rule — a pre-phase KB, which has
no image rows, serializes byte-identically to pre-phase, and the
RAG view reads ``is_image === true`` — it never expects the keys on
a text node). ``image_url`` is ``None``-omitted even on an image
node (a row whose ``image_path`` was lost renders the glyph
fallback); ``summary`` stays ``null`` on an image node (the alt
falls back to the title client-side — the fail-soft backfill
corner).
"""
kind: Literal["file"] = "file"
@@ -286,6 +325,23 @@ class KbTreeFile(BaseModel):
#: the ``Created`` column (before ``Indexed``).
created_at: str
indexed_at: str
#: Phase 122 (task 04) — true iff the file is an image document
#: (LOCKED A3). Omitted from a text node's wire shape (see the class
#: docstring); the builder sets it only for a node whose
#: ``(source, path)`` is in the endpoint's image-docs map.
is_image: bool = False
#: Phase 122 (task 04) — the image bytes route
#: (``/api/documents/<id>/image``) for the RAG view's thumbnail;
#: ``None`` (→ absent) when the row has no servable copy.
image_url: str | None = None
#: Phase 122 (task 04) — the document's summary (for an image doc,
#: the vision description — the thumbnail's ``alt``); ``None`` for a
#: fail-soft row still awaiting the backfill.
summary: str | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
return _drop_image_fields(self, handler)
class KbTreeFolder(BaseModel):
@@ -374,8 +430,11 @@ class KbTree(BaseModel):
98, D3): true iff its recursive document count ≥
``MIN_DOCS_PER_FOLDER`` (1) AND it has no stored ``folder_summaries``
row — exactly ``missing_folder_summaries``'s candidate set (the
marker never drifts from the gap-fill); FILE nodes carry no flag
(the file table has no description column).
marker never drifts from the gap-fill). FILE nodes carry no pending
flag (the file table has no description column) — but, since phase
122 (task 04), an image FILE node carries the thumbnail affordance
keys (``is_image`` / ``image_url`` / ``summary`` — omitted on text
nodes, see :class:`KbTreeFile`).
"""
sources: list[KbTreeSource]
@@ -400,6 +459,27 @@ class DocContent(BaseModel):
content: str
indexed_at: str
chunks: int
#: Phase 122 (task 04) — true iff the document is a standalone
#: image (LOCKED A3: ``content`` is the vision description, the
#: bytes live behind :attr:`image_url`). ALWAYS present on the wire
#: (text docs: ``false`` — the wire-additive key, the phase-106
#: ``created_at`` pattern); the viewer renders the ``<img>`` block
#: only when true.
is_image: bool = False
#: Phase 122 (task 04) — the image bytes route
#: (``/api/documents/<id>/image``) for the viewer's ``<img>``.
#: ABSENT from the wire for text docs (``None`` → dropped by the
#: serializer — never ``null``, the :func:`_drop_absent_share_url`
#: omission precedent); also absent for an image row whose
#: ``image_path`` is NULL (the viewer's onerror fallback covers it).
image_url: str | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
data = handler(self)
if self.image_url is None:
data.pop("image_url", None)
return data
class SummaryUpdate(BaseModel):
@@ -891,6 +971,25 @@ class SavedChatUpdate(BaseModel):
messages: list[ChatMessage] = Field(min_length=1, max_length=200)
def _drop_image_fields(model: KbTreeFile, handler: SerializerFunctionWrapHandler) -> Any:
"""The phase-122 (task 04) image-affordance omission rule for
:class:`KbTreeFile` file nodes: a TEXT node (``is_image`` false) drops
ALL three image keys — a pre-phase KB (no image rows) serializes
byte-identically to pre-phase, and the RAG view's file row stays the
pre-phase bare-link cell. An IMAGE node keeps ``is_image`` +
``summary`` (a ``null`` summary is meaningful — the alt falls back
client-side) and drops ``image_url`` only when ``None`` (the
row-without-a-copy corner — never a ``null`` on the wire, the
:func:`_drop_absent_share_url` precedent)."""
data = handler(model)
if not model.is_image:
for key in ("is_image", "image_url", "summary"):
data.pop(key, None)
elif data.get("image_url") is None:
data.pop("image_url", None)
return data
def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHandler) -> Any:
"""The ``share_url`` omission rule (phase 51, task 02): ``None`` →
ABSENT from the JSON (not ``"share_url": null``) — an unshared chat