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`.
This commit is contained in:
+8
-5
@@ -224,6 +224,7 @@ from app.rag.retriever import (
|
||||
retrieve,
|
||||
select_related,
|
||||
select_suggested,
|
||||
source_ref_with_image,
|
||||
weak_hit_titles,
|
||||
)
|
||||
from app.rag.scaffolding import ScaffoldingFilter # phase 71: the streaming filter
|
||||
@@ -984,14 +985,16 @@ async def chat(
|
||||
# since phase 119). The UI renders the row as the
|
||||
# de-emphasized related-docs row, never a citation chip;
|
||||
# old clients ignore the field.
|
||||
# Phase 122 (task 05): both tiers build their refs
|
||||
# through the shared helper — a ref for an image doc
|
||||
# carries the optional ``image_url`` (the bytes route),
|
||||
# a text doc's stays byte-identical to pre-phase (the
|
||||
# key is omitted, never null).
|
||||
cited_refs: list[SourceRef] = []
|
||||
if not plan.deflected:
|
||||
cited_refs = [
|
||||
SourceRef(source=d.source, path=d.path, title=d.title)
|
||||
for d in cited_docs
|
||||
]
|
||||
cited_refs = [source_ref_with_image(d) for d in cited_docs]
|
||||
related_refs = [
|
||||
SourceRef(source=d.source, path=d.path, title=d.title)
|
||||
source_ref_with_image(d)
|
||||
for d in plan.related_docs
|
||||
if (d.source, d.path) not in cited_seen
|
||||
]
|
||||
|
||||
+14
-8
@@ -1,6 +1,7 @@
|
||||
"""Public app metadata (display name + version) for the frontend brand
|
||||
layer, the phase-59 docs-push flag (the "Save as doc" gating), and the
|
||||
phase-62 UI customization strings (composer placeholder, footer line).
|
||||
layer, the phase-59 docs-push flag (the "Save as doc" gating), the
|
||||
phase-122 image flag (UI affordance gating), and the phase-62 UI
|
||||
customization strings (composer placeholder, footer line).
|
||||
|
||||
Phase 91 (task 01): the three UI strings are now the EFFECTIVE values —
|
||||
the ``ui_settings`` row (admin Theme tab) over the env values (B1: DB
|
||||
@@ -29,17 +30,21 @@ router = APIRouter(tags=["config"])
|
||||
@router.get("/config")
|
||||
def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]: # noqa: B008
|
||||
"""Public app metadata for the frontend brand layer (phase 39) +
|
||||
the phase-59 ``docs_repo_configured`` flag + the phase-62 UI
|
||||
customization keys (``input_placeholder``, ``footer_text``) — all
|
||||
display strings, the SAME boot fetch (no new network surface) and
|
||||
the same public posture as ``app_name`` (no secrets). Phase 91:
|
||||
the phase-59 ``docs_repo_configured`` flag + the phase-122
|
||||
``images`` flag + the phase-62 UI customization keys
|
||||
(``input_placeholder``, ``footer_text``) — all display strings,
|
||||
the SAME boot fetch (no new network surface) and the same public
|
||||
posture as ``app_name`` (no secrets). Phase 91:
|
||||
``app_name`` / ``input_placeholder`` / ``footer_text`` are the
|
||||
EFFECTIVE values (the admin Theme tab's ``ui_settings`` row over
|
||||
the env values — DB-over-env, B1); the frontend brand layer treats
|
||||
an empty string as "keep the template default" (the unset =>
|
||||
byte-identical contract). Phase 91 (task 03): the retired
|
||||
CSS-file theming's ``theme`` key is gone — the five keys are the
|
||||
entire response."""
|
||||
CSS-file theming's ``theme`` key is gone. Phase 122 (task 01):
|
||||
``images`` mirrors ``settings.images`` (the ``BOR_IMAGES`` master
|
||||
switch) — consumed by the chat composer (phase 123) to show/hide
|
||||
the image-attach control, optionally by the Sources page (an
|
||||
"images off" hint). The six keys are the entire response."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
effective = theming.effective_settings(db, settings)
|
||||
@@ -49,6 +54,7 @@ def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bo
|
||||
"app_name": effective["app_name"],
|
||||
"version": settings.app_version,
|
||||
"docs_repo_configured": settings.docs_configured,
|
||||
"images": settings.images,
|
||||
"input_placeholder": effective["input_placeholder"],
|
||||
"footer_text": effective["footer_text"],
|
||||
}
|
||||
|
||||
+158
-17
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user