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
+93 -1
View File
@@ -285,6 +285,21 @@
* • the stat cards are UNTOUCHED — they keep their indexed_at
* "last indexed" semantics (the owner asked for the column, not
* the cards).
*
* Phase 122 (task 04) — the image-doc thumbnail: a file node of the
* tree carries the image affordance (is_image / image_url / summary —
* OMITTED on text nodes, the wire-additive rule) when it is an image
* document. makeRow's Path cell then renders a FIXED 48px thumbnail
* box (object-fit: cover, loading="lazy", alt = the summary — the
* vision description; a NULL summary falls back to the title) BEFORE
* the path link (the .kb-doc-path flex wrapper — the link keeps its
* ellipsis). Progressive enhancement: a failed fetch (or the lazy
* first paint) swaps in the document glyph INSIDE the same fixed box
* (no layout shift beyond the box, no broken-image placeholder). Text
* rows never get a box (the pre-phase bare-link cell, byte-identical).
* The glyph is static SVG (aria-hidden — the alt text is the
* accessible content); the box + img are properties only, never
* innerHTML with document-derived data (the house rule).
*/
import { fetchIsAdmin } from "./header.js";
@@ -1391,12 +1406,78 @@ export async function mount(root) {
chunks: f.chunks,
created_at: f.created_at, // phase 106 (task 08, D8): the tree's file date
indexed_at: f.indexed_at,
// Phase 122 (task 04): the image affordance — the tree's
// file node carries is_image / image_url / summary on an
// image doc (the omission rule: a TEXT node's wire shape
// carries none, so these stay undefined there and makeRow
// keeps the bare-link cell, byte-identical to pre-phase).
is_image: f.is_image,
image_url: f.image_url,
summary: f.summary,
})
);
}
if (tableWrap) tableWrap.hidden = files.length === 0;
}
/* Phase 122 (task 04): the document glyph — the fallback INSIDE the
* fixed thumbnail box (a failed fetch, or a node with no servable
* image_url). Static SVG, aria-hidden (the img's alt is the
* accessible content; this is decoration for the box). Built with
* createElementNS — the module keeps its ONE innerHTML (the static
* sync-modal skeleton, the test_kb_tree_ui pin). */
function docThumbGlyph() {
const span = document.createElement("span");
span.className = "kb-doc-thumb-glyph";
span.setAttribute("aria-hidden", "true");
const NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("viewBox", "0 0 48 48");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2.4");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
const sheet = document.createElementNS(NS, "path");
sheet.setAttribute(
"d",
"M12 4h16l8 8v28a4 4 0 0 1-4 4H12a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4Z"
);
const fold = document.createElementNS(NS, "path");
fold.setAttribute("d", "M28 4v8h8");
svg.append(sheet, fold);
span.appendChild(svg);
return span;
}
/* Phase 122 (task 04): the image-doc thumbnail — the FIXED 48px box
* (object-fit: cover via CSS, loading="lazy", alt = the summary —
* the vision description; a NULL/blank summary falls back to the
* title, then the path). A failed fetch swaps in the document
* glyph in the SAME box (progressive enhancement — no layout shift
* beyond the fixed box, no broken-image placeholder). Called only
* for image rows (makeRow gates on d.is_image). */
function docThumb(d) {
const box = document.createElement("span");
box.className = "kb-doc-thumb";
const alt =
typeof d.summary === "string" && d.summary.trim() !== ""
? d.summary
: d.title || d.path;
if (d.image_url) {
const img = document.createElement("img");
img.className = "kb-doc-thumb-img";
img.loading = "lazy";
img.src = d.image_url;
img.alt = alt;
img.addEventListener("error", () => box.replaceChildren(docThumbGlyph()));
box.appendChild(img);
} else {
box.appendChild(docThumbGlyph());
}
return box;
}
/* The no-data state (phase 97): zero sources (nothing registered,
* nothing indexed) OR a failed tree fetch (the former showEmpty
* failure behavior, unchanged in kind) — every catalog surface
@@ -1440,7 +1521,18 @@ export async function mount(root) {
});
link.title = d.path; // full path as the link's hover/accessible name
link.textContent = d.path;
pathTd.appendChild(link);
// Phase 122 (task 04): an image row gets the FIXED 48px thumbnail
// box before the path link (the .kb-doc-path flex wrapper — the
// link keeps its ellipsis). Text rows keep the bare-link cell,
// byte-identical to pre-phase (no box at all).
if (d.is_image) {
const pathWrap = document.createElement("div");
pathWrap.className = "kb-doc-path";
pathWrap.append(docThumb(d), link);
pathTd.appendChild(pathWrap);
} else {
pathTd.appendChild(link);
}
tr.appendChild(pathTd);
// Phase 106 (task 08, D8): the cell order is [title, chunks,