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
+158 -17
View File
@@ -23,6 +23,18 @@ GET /api/docs/tree — the admin's full recursive KB tree in one fetch
walks, with the file metadata the RAG view's rows and stat cards need
(the view drills client-side; ``GET /api/docs`` is untouched).
GET /api/documents/{doc_id}/image — the phase-122 (task 04) image
BYTES route: the persistent copy behind an ``is_image`` document's
``image_path``, served with the extension's ``Content-Type`` (the
``app.rag.summarizer.IMAGE_MIMES`` map — one map, one truth) and a
``Cache-Control: private, max-age=3600`` header (the bytes are
content-hashed — long enough, bustable by re-upload). User-gated like
the content endpoint (phase 79 — the ONLY anonymous surface is the
shared chats): a missing doc, a non-image doc, a doc whose
``image_path`` is NULL, or a row whose copy was lost all map to 404
``document not found`` (the router's unknown-document shape —
traversal/UUID-guessing has no filesystem surface to hit).
PATCH /api/folders/summary — the admin folder-description editor
(phase 97, task 03): update / create / clear a stored
``folder_summaries`` row, marking every non-empty save
@@ -34,11 +46,13 @@ contrast with the phase-57 ``is_summary`` re-embed above.
"""
from __future__ import annotations
import uuid
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -52,6 +66,7 @@ from app.rag.doc_dates import normalize_doc_date
from app.rag.folder_summaries import MIN_DOCS_PER_FOLDER, folder_of
from app.rag.importer import match_extension
from app.rag.llm import EmbeddingError, LLMClient
from app.rag.summarizer import IMAGE_FALLBACK_MIME, IMAGE_MIMES
from app.schemas import (
DateResult,
DateUpdate,
@@ -171,6 +186,11 @@ def get_document_content(
if row is None:
raise HTTPException(status_code=404, detail="document not found")
doc, chunks = row
# Phase 122 (task 04): the image affordance — ``is_image`` is ALWAYS
# present on the wire (text docs: false — the one new key); for an
# image doc, ``image_url`` is the bytes route's path (absent for
# text docs, and for an image row whose copy path is NULL — never
# null, the ``DocContent`` omission rule).
return DocContent(
source=doc.source,
path=doc.path,
@@ -181,6 +201,62 @@ def get_document_content(
content=doc.content,
indexed_at=doc.indexed_at.isoformat(),
chunks=chunks,
is_image=doc.is_image,
image_url=(
f"/api/documents/{doc.id}/image" if doc.is_image and doc.image_path else None
),
)
@router.get("/documents/{doc_id}/image", response_class=FileResponse)
def get_document_image(
doc_id: str,
db: Session = Depends(get_db), # noqa: B008
_user: None = Depends(require_user), # noqa: B008 # phase 79 posture (see docstring)
) -> FileResponse:
"""The phase-122 (task 04) image BYTES route — the persistent copy
behind an ``is_image`` document's ``image_path``.
Same auth posture as the document content endpoint (phase 79 —
``require_user``: admin OR live token holder; the ONLY anonymous
surface is the shared chats — the image is part of a document's
content, so it travels under the same gate): anonymous callers get
401 ``authentication required`` before any row is read.
404 ``document not found`` (the router's unknown-document shape) in
every non-servable case — a missing id (an unparseable string maps
here too, not to a 422 — a guessed id is an unknown document), a
text doc, an image doc whose ``image_path`` is NULL, or a row whose
copy was lost on disk (defensive — the row exists, the bytes
don't). There is no path parameter to a filesystem value: the path
comes from the ROW (the importer's ``image_dir`` copy), so there is
no traversal surface.
Servable rows stream the exact bytes with the extension's
``Content-Type`` (the ``IMAGE_MIMES`` map — one map, one truth with
the describe call's data-URL mime; an unexpected extension takes
``application/octet-stream``) and ``Cache-Control: private,
max-age=3600`` (the bytes are content-hashed — long enough to be
useful, bustable by re-upload).
"""
try:
uid = uuid.UUID(doc_id)
except ValueError:
raise HTTPException(status_code=404, detail="document not found") from None
doc = db.scalar(select(Document).where(Document.id == uid))
if doc is None or not doc.is_image or not doc.image_path:
raise HTTPException(status_code=404, detail="document not found")
image_file = Path(doc.image_path)
if not image_file.is_file():
# Defensive: the row exists but the copy was lost (the owner
# cleaned the image dir, the disk was wiped) — the viewer's
# onerror fallback renders the "image unavailable" note.
raise HTTPException(status_code=404, detail="document not found")
media_type = IMAGE_MIMES.get(image_file.suffix.lower(), IMAGE_FALLBACK_MIME)
return FileResponse(
image_file,
media_type=media_type,
headers={"Cache-Control": "private, max-age=3600"},
)
@@ -446,6 +522,13 @@ def _folder_counts(
return folders, counts
#: One image-docs map value (phase 122, task 04):
#: ``(doc_id, summary)`` — the id the bytes route's URL is built from
#: and the summary the RAG view's thumbnail uses as ``alt`` (the vision
#: description; ``None`` = the fail-soft backfill corner).
ImageDocInfo = tuple[str, str]
def _level_children(
source: str,
folder: str,
@@ -453,6 +536,7 @@ def _level_children(
counts: dict[str, int],
rows: Sequence[TreeFileRow],
summaries: Mapping[tuple[str, str], str],
images: Mapping[tuple[str, str], ImageDocInfo] | None = None,
) -> list[KbTreeFolder | KbTreeFile]:
"""One level's children (pure): subfolders in path order, then the
direct files in input (catalog) order.
@@ -477,10 +561,19 @@ def _level_children(
= the subtree's MAX document ``created_at``: the max over this
folder's direct files' dates and its subfolder children's (already
recursive) ``updated_at`` values, via :func:`_subtree_max`.
Since phase 122 (task 04), a file node whose ``(source, path)`` is
in *images* carries the thumbnail affordance (``is_image`` +
``image_url`` built from the mapped doc id + the mapped ``summary``
— see :class:`app.schemas.KbTreeFile`); every other file node is
the pre-phase shape (the omission rule keeps its wire shape
byte-identical).
"""
children: list[KbTreeFolder | KbTreeFile] = []
for sub in sorted(g for g in folders if folder_of(g) == folder):
sub_children = _level_children(source, sub, folders, counts, rows, summaries)
sub_children = _level_children(
source, sub, folders, counts, rows, summaries, images
)
children.append(
KbTreeFolder(
path=sub,
@@ -494,15 +587,34 @@ def _level_children(
)
for path, title, chunks, indexed_at, created_at in rows:
if folder_of(path) == folder:
children.append(
KbTreeFile(
path=path,
title=title,
chunks=chunks,
created_at=created_at,
indexed_at=indexed_at,
image_info = images.get((source, path)) if images else None
if image_info is not None:
# Phase 122 (task 04): the image-docs node — the RAG
# view's Path cell renders the 48px thumbnail from the
# bytes route's URL with ``alt = summary``.
doc_id, doc_summary = image_info
children.append(
KbTreeFile(
path=path,
title=title,
chunks=chunks,
created_at=created_at,
indexed_at=indexed_at,
is_image=True,
image_url=f"/api/documents/{doc_id}/image",
summary=doc_summary,
)
)
else:
children.append(
KbTreeFile(
path=path,
title=title,
chunks=chunks,
created_at=created_at,
indexed_at=indexed_at,
)
)
)
return children
@@ -530,6 +642,7 @@ def build_kb_tree(
names: Sequence[str],
doc_rows: Sequence[TreeDocRow],
summaries: Mapping[tuple[str, str], str],
images: Mapping[tuple[str, str], ImageDocInfo] | None = None,
) -> list[KbTreeSource]:
"""The pure tree builder behind ``GET /api/docs/tree`` (phase 97,
task 02) — module-level and DB-free so unit tests drive it
@@ -543,7 +656,13 @@ def build_kb_tree(
``{(source, folder_path): summary}`` over the stored
``folder_summaries`` rows (``folder_path = ""`` = the source root;
rows for sources the tree does not list are simply never
referenced).
referenced). *images* (phase 122, task 04) —
``{(source, path): (doc_id, summary)}`` over the stored image docs
(``is_image`` rows with a servable ``image_path`` — the endpoint
composes the bounded select); the default ``None``/empty map keeps
EVERY file node the pre-phase shape (byte-identical wire — a
pre-phase KB has no image rows, so the endpoint's own map is empty
for it).
Shape, per the phase-97 ``00_phase.md`` "The tree endpoint":
@@ -614,10 +733,10 @@ def build_kb_tree(
if name in listed: # defensive: list_source_names dedupes
continue
listed.add(name)
tree.append(_source_node(name, by_source.get(name, ()), summaries))
tree.append(_source_node(name, by_source.get(name, ()), summaries, images))
for source in sorted(by_source):
if source not in listed:
tree.append(_source_node(source, by_source[source], summaries))
tree.append(_source_node(source, by_source[source], summaries, images))
return tree
@@ -625,6 +744,7 @@ def _source_node(
source: str,
rows: Sequence[TreeFileRow],
summaries: Mapping[tuple[str, str], str],
images: Mapping[tuple[str, str], ImageDocInfo] | None = None,
) -> KbTreeSource:
"""One source node (pure): whole-source count + the source-root
summary + the root level's children (direct subfolders + direct
@@ -646,7 +766,7 @@ def _source_node(
0-document source (no children, no dates).
"""
folders, counts = _folder_counts(rows)
children = _level_children(source, "", folders, counts, rows, summaries)
children = _level_children(source, "", folders, counts, rows, summaries, images)
return KbTreeSource(
name=source,
documents=len(rows),
@@ -677,9 +797,12 @@ def list_kb_tree(
excluded — the tree has no document ids) + ALL stored
``folder_summaries`` rows (a bounded select — one row per
existing folder at the ≥ 1-doc rule; rows for sources the tree
does not list are never referenced by the builder) — through the
pure :func:`build_kb_tree`. ``GET /api/docs`` itself is
untouched.
does not list are never referenced by the builder) + the phase-122
(task 04) image-docs map (a second bounded select over the
``is_image`` rows with a servable ``image_path`` — empty for every
pre-phase KB, so the response stays byte-identical to pre-phase)
— through the pure :func:`build_kb_tree`. ``GET /api/docs`` itself
is untouched.
"""
names = list_source_names(db)
rows = db.execute(
@@ -712,4 +835,22 @@ def list_kb_tree(
select(FolderSummary.source, FolderSummary.folder_path, FolderSummary.summary)
).all()
}
return KbTree(sources=build_kb_tree(names, doc_rows, summaries))
# Phase 122 (task 04): the image-docs affordance map — a bounded
# select over the ``is_image`` rows only (a handful of rows at KB
# scale, never the whole catalog; the catalogue query above stays
# byte-identical). EMPTY for every pre-phase KB (no image rows), so
# the response stays byte-identical to pre-phase — the fields are
# row-driven, not toggle-driven (a surviving image doc keeps its
# thumbnail through a toggle-off sync, the prune guard's UX side).
images: dict[tuple[str, str], tuple[str, str]] = {
(source, path): (str(doc_id), summary)
for source, path, doc_id, summary in db.execute(
select(
Document.source,
Document.path,
Document.id,
Document.summary,
).where(Document.is_image.is_(True), Document.image_path.is_not(None))
).all()
}
return KbTree(sources=build_kb_tree(names, doc_rows, summaries, images))