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
+76
View File
@@ -1526,6 +1526,10 @@ function appendSources(wrap, sources) {
chip.textContent = label;
chip.title = label;
meta.appendChild(chip);
// Phase 122 (task 05): a ref for an IMAGE document carries
// `image_url` (the frame's only new field — omitted on text refs):
// its chip gains the compact inline figure right after it.
if (s.image_url) appendSourceImageFigure(meta, s);
}
body.appendChild(meta);
// Accessible full path whenever the pill visually truncates.
@@ -1575,10 +1579,82 @@ function appendRelated(wrap, related) {
link.title = docLabel; // full path as the native tooltip (chip pattern)
link.setAttribute("aria-label", docLabel); // the accessible name is the full path
row.appendChild(link);
// Phase 122 (task 05): the related tier rides the same ref shape —
// an image doc's ref carries image_url and gets the same figure.
if (s.image_url) appendSourceImageFigure(row, s);
}
body.appendChild(row);
}
/* Phase 122 (task 05): the chat's sources block shows a retrieved
* IMAGE document "nicely" (TODO L6): the ref's chip gains a COMPACT
* INLINE FIGURE right after it. The chip keeps its text and its
* affordance — the figure is ADDITIVE, never a replacement — and the
* figure reuses the chip's navigation (the same documentUrl href, the
* /document.html escape hatch; the same left-click → same-page modal,
* phase 26). alt + the VISIBLE caption = the document's SUMMARY (the
* vision description — the WCAG alt contract): the frame carries no
* summary (image_url is the only new frame field), so the figure
* fetches the content endpoint the chip's modal already uses (the
* (source, path) pair is the same lookup key) and swaps the summary
* in; until it settles — and when it fails — the title stands in (a
* caption is ALWAYS visible). A FAILED IMAGE LOAD collapses the figure
* to the plain chip (the figure is removed — never a broken-image
* icon: the row's bytes route 404s when the copy was lost). */
function appendSourceImageFigure(meta, s) {
const label = `${s.source}/${s.path}`;
const fig = document.createElement("a");
fig.className = "source-image";
fig.setAttribute("role", "listitem");
fig.href = documentUrl(s.source, s.path, "/"); // back → the chat page
fig.title = label; // full path as the native tooltip (chip pattern)
fig.setAttribute("aria-label", label); // the accessible name is the full path
fig.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(s.source, s.path, fig);
});
const img = document.createElement("img");
img.className = "source-image-img";
img.src = s.image_url;
img.alt = s.title || label; // the summary arrives via the fetch below
img.addEventListener("error", () => {
// The plain chip stays (it is the collapse target) — the figure,
// with its not-yet-resolved alt, goes.
fig.remove();
});
const caption = document.createElement("span");
caption.className = "source-image-caption";
caption.textContent = s.title || label; // visible caption: the title first…
fig.append(img, caption);
meta.appendChild(fig);
// …and the document's summary once the content fetch settles.
fetchContentSummary(s.source, s.path).then((summary) => {
if (!fig.isConnected) return; // collapsed (img error) or bubble cleared
if (summary && summary.trim() !== "") {
img.alt = summary;
caption.textContent = summary;
}
});
}
/* The document content endpoint the chip's modal already boots
* against (the (source, path) lookup key the ref carries) — phase 122
* (task 05) asks it ONLY for the image figure's summary (alt +
* caption). Any failure (404, network, bad body) resolves to null —
* the title fallback stands and the figure is unaffected. */
function fetchContentSummary(source, path) {
const url =
"/api/documents/content?source=" +
encodeURIComponent(source) +
"&path=" +
encodeURIComponent(path);
return fetch(url)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null)
.then((doc) => (doc && typeof doc.summary === "string" ? doc.summary : null));
}
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase 04,
shared component + one-tap submit, phase 05). The group is accessible
(role=list + aria-label) and wraps cleanly at every width. */