phase: 130_chat_answer_clusters
Build and Push Containers / build-and-push-app (push) Successful in 2m27s
Build and Push Containers / build-and-push-db (push) Successful in 12s

**Phase 130 — final verification pass: 100% green, complete.**

- **Verified:** chips-only row (no chip for image docs) → images-only row (`.msg-images`) → related row → `.msg-actions` last; `getActionsRow` used by all 3 buttons + stopped/failed notes; `markLastRetryable` save-re-append hack gone; in-place chip fallback on image-load failure; CSS right-align with per-button auto margins removed — implementation matches the design in every detail.
- **Completion criteria:** (1) mixed citation renders chips→images→actions-last, DOM-asserted ✅ (2) all 3 buttons in one right-aligned `.msg-actions`; non-admin sees Retry-only; restore path identical ✅ (3) 404-bytes figure → plain chip, unit-pinned ✅ (4) E2E isolated ✅ (5) no behavior change (modal affordances, related tier, ARIA names) ✅
- **Tests/lint/coverage:** `uv run pytest tests/e2e/test_chat_answer_clusters.py -v --no-cov` → 4 passed; `uv run pytest --cov=app --cov-report=term-missing` → 3061 passed, **99%** app coverage; the 4 E2E files this phase modified (test_answer_images, test_image_documents, test_pdf_support, test_responsive_polish) → 22 passed in isolation; `uv run ruff check .` clean, `uv run pyright` → 0 errors.
- **Docs:** README.md — verified consistent in full (page table vs routes, env-var coverage vs `.env.example`, phase-130 cluster wording already in tree); no changes needed. AGENTS.md — fixed one pre-existing inconsistency: rule 5 still cited the retired "centered 46rem chat column"; now states the 72rem container contract (phase 100).
- **Decisions:** PLAN.md §7.1 still says 46rem, but it's owner-managed (phase 100 records the supersession pending owner sign-off) and out of my editable scope — AGENTS.md now cross-references the supersession.
- **Next pending phase:** `131_read_line_ranges` (todo/).
This commit is contained in:
2026-09-28 07:49:37 -04:00
parent 2d0f608246
commit c88c65641a
46 changed files with 3266 additions and 351 deletions
+94 -21
View File
@@ -51,12 +51,19 @@ image doc prefacing the description with the pinned
``IMAGE_DOC_MARKER`` line (header + date line byte-identical, the
cap-truncation case marker-before-cut, a text doc's result
byte-identical); and the house-style frontend source contracts — the
chat sources block's inline image figure (both the citation chips and
the related row, gated on ``s.image_url``) reads ``image_url``, sets
chat sources block's inline image figure reads ``image_url``, sets
``alt`` + the visible caption to the document's summary (fetched from
the content endpoint the chip's modal already uses — the frame
carries no summary), and collapses to the plain chip on an image
load error (never a broken-image icon). The SSE-level byte pin sits in
carries no summary), and becomes the IN-PLACE CHIP FALLBACK on an
image load error (phase 130 task 01: the figure is replaced, in
place, with a plain chip for the same document — never a broken-image
icon; the pre-phase-130 rule collapsed by removing the figure, the
chip staying). Phase 130 (task 01) also re-partitions the citation
block: the chips row holds the NON-image refs only (no chip for an
image doc — its figure IS the citation) and a second
``.msg-meta.msg-images`` row holds the figures in ref order (the
related row keeps its own interleave, unchanged — ASSUMPTION A4).
The SSE-level byte pin sits in
``tests/integration/test_chat_api.py``; the E2E rendering scenario is
task 06's isolated story suite.
"""
@@ -1342,16 +1349,23 @@ def test_read_of_image_doc_over_the_cap_truncates_after_the_marker(db) -> None:
# --- the chat sources block: the inline image (house-style frontend)
def test_chat_sources_figure_reads_image_url_sets_alt_and_collapses_on_error() -> None:
def test_chat_sources_figure_reads_image_url_sets_alt_and_falls_back_in_place() -> None:
"""House-style source contract (app.js appendSourceImageFigure —
task 05's "shown in the chat nicely" renderer): the figure's
``<img>`` comes from the ref's ``image_url``; ``alt`` is set
(title first, then the SUMMARY once the content fetch settles —
the frame carries no summary, image_url is its only new field)
and so is the VISIBLE caption; a FAILED image load removes the
whole figure — it collapses to the plain chip, never a
broken-image icon. Properties only (src/alt/textContent) — the
figure builds no innerHTML from document-derived data."""
and so is the VISIBLE caption. Phase 130 (task 01, ASSUMPTION A3)
REPLACES the old collapse-to-chip rule (``fig.remove()`` — the
chip stayed) with the IN-PLACE CHIP FALLBACK: a FAILED image load
replaces the figure, in place (``fig.replaceWith(chip)``), with a
plain ``.source-chip`` anchor for the same document (the same
``documentUrl`` href, the same left-click → modal, the label as
text) — there is no chip for image docs anymore, so the figure
itself becomes the citation; never a broken-image icon. The fetch
summary swap is untouched (its ``!fig.isConnected`` guard makes it
a no-op after the swap). Properties only (src/alt/textContent) —
the figure builds no innerHTML from document-derived data."""
js = APP_JS.read_text(encoding="utf-8")
body = _function_body(js, "function appendSourceImageFigure(meta, s) {")
assert "img.src = s.image_url" in body, "the <img> must come from s.image_url"
@@ -1360,10 +1374,34 @@ def test_chat_sources_figure_reads_image_url_sets_alt_and_collapses_on_error() -
assert "caption.textContent = s.title || label" in body # visible caption: title
assert "caption.textContent = summary" in body # …then the summary
assert 'img.addEventListener("error"' in body, "the error handler must exist"
assert "fig.remove();" in body, "a failed load collapses to the plain chip"
assert "fig.remove()" not in body, (
"the phase-122 collapse rule is RETIRED — the figure is never "
"simply removed (there is no chip left behind to stay)"
)
# Phase 130 (task 01): the in-place chip fallback — the figure is
# replaced with a plain .source-chip anchor for the same document.
assert "fig.replaceWith(chip);" in body, (
"a failed load replaces the figure IN PLACE with its chip"
)
assert 'chip.className = "source-chip";' in body, (
"the fallback is a PLAIN citation chip"
)
assert 'chip.setAttribute("role", "listitem");' in body # ARIA stays valid
assert 'chip.href = documentUrl(s.source, s.path, "/");' in body, (
"the fallback chip keeps the chip's /document.html href"
)
assert "openDocumentModal(s.source, s.path, chip);" in body, (
"the fallback chip keeps the left-click → same-page modal"
)
assert "e.preventDefault();" in body # no new tab (phase 26)
assert "chip.textContent = label;" in body, ("the label is the chip's text")
assert "chip.title = label;" in body # full path as the native tooltip
assert "fetchContentSummary(s.source, s.path)" in body, (
"the frame carries no summary — the figure fetches it"
)
assert "if (!fig.isConnected) return;" in body, (
"the summary swap is a no-op once the figure was replaced"
)
code = re.sub(r"//.*", "", re.sub(r"/\*.*?\*/", "", body, flags=re.S))
assert "innerHTML" not in code, "no innerHTML from document-derived data"
# The figure reuses the chip's navigation (phase 26 contract):
@@ -1371,20 +1409,55 @@ def test_chat_sources_figure_reads_image_url_sets_alt_and_collapses_on_error() -
assert "openDocumentModal(s.source, s.path, fig);" in body
def test_chat_sources_and_related_rows_gate_the_figure_on_image_url() -> None:
"""Both chat source surfaces (the citation chips AND the related
row — the frame's two ref tiers, built by the same server helper)
render the figure ONLY when the ref carries ``image_url``: text
refs (and every pre-phase saved ref) add zero new DOM — the
renderer stays byte-identical for them. The chip's text +
affordance stay (the figure is additive, not a replacement), and
the summary fetch targets the SAME content endpoint the chip's
modal already uses (failing soft to null — the title stands)."""
def test_chat_sources_splits_the_chips_row_from_the_images_row() -> None:
"""Phase 130 (task 01, TODO L6): ``appendSources`` stops
interleaving figures after chips — it PARTITIONS the refs on the
existing image-doc signal (the ``image_url`` presence, phase 122)
and builds two clusters: the chips row from the NON-image refs
ONLY (no chip for an image doc — the figure IS the citation) and
a second ``.msg-meta.msg-images`` row (``role=list`` +
``aria-label="Source images"``) holding one figure per image ref
in ref order. Each row is built only when its refs are non-empty
(image-only citations get no chips row; text-only turns render
the pre-phase-130 single row). The clipped-chip ``aria-label``
pass stays, over the chips row only. The related row is a
separate tier and is NOT reorganized (ASSUMPTION A4 — its own
chip/figure interleave is untouched), and the summary fetch
targets the SAME content endpoint the chip's modal already uses
(failing soft to null — the title stands)."""
js = APP_JS.read_text(encoding="utf-8")
src = _function_body(js, "function appendSources(wrap, sources) {")
assert 'if (s.image_url) appendSourceImageFigure(meta, s);' in src
assert 'chip.className = "source-chip";' in src, "the chip stays (additive)"
# The partition (the `image_url` presence is the image-doc signal):
assert "const imageRefs = sources.filter((s) => s.image_url);" in src
assert "const textRefs = sources.filter((s) => !s.image_url);" in src
# Cluster 1 — the chips row, text refs only, skipped when empty:
assert "if (textRefs.length) {" in src, (
"the chips row is skipped when there are no text refs "
"(an image-only citation still gets its images row)"
)
assert "for (const s of textRefs) {" in src, (
"one chip per NON-image ref — an image ref contributes NO chip"
)
assert 'meta.className = "msg-meta";' in src
assert 'meta.setAttribute("aria-label", "Sources");' in src # ARIA name kept
assert 'chip.className = "source-chip";' in src
assert "chip.textContent = label" in src
# The clipped-chip aria-label pass stays, over the chips row only:
assert "chip.scrollWidth > chip.clientWidth" in src
# Cluster 2 — the images row, image refs only, absent when empty:
assert "if (imageRefs.length) {" in src, (
"the images row is built only when image refs exist"
)
assert 'images.className = "msg-meta msg-images";' in src
assert 'images.setAttribute("role", "list");' in src
assert 'images.setAttribute("aria-label", "Source images");' in src
assert "for (const s of imageRefs) appendSourceImageFigure(images, s);" in src, (
"one figure per image ref, in ref order, no chips in the row"
)
# The phase-122 interleave is gone from appendSources (the figure
# no longer rides right after a chip in the chips row):
assert "if (s.image_url) appendSourceImageFigure(meta, s);" not in src
# The related tier is NOT reorganized (ASSUMPTION A4):
rel = _function_body(js, "function appendRelated(wrap, related) {")
assert 'if (s.image_url) appendSourceImageFigure(row, s);' in rel
fetch = _function_body(js, "function fetchContentSummary(source, path) {")