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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user