phase: 124_image_chips_and_answers
Build and Push Containers / build-and-push-app (push) Successful in 2m12s
Build and Push Containers / build-and-push-db (push) Failing after 1m39s

All green. Final report:

**Phase 124 final verification pass — all criteria verified, no defects found (nothing to fix)**
- Verified the implemented phase (all 7 tasks done, changes in working tree): `BOR_ANSWER_IMAGES` toggle, `show_image` tool + refusals + `holder.shown_images`, toggle-gated prompt clause, `answer_image` frame field (None-omitting serializer) + `ChatMessage` persistence, `merge_cited_docs` auto-cite bug fix, answer-figure UI (live/restore/shared/CSS), mock triggers + E2E suite. Also confirmed the bytes route → public change matches the phase-123 shared-chat precedent (task 06 design).
- `uv run pytest --cov=app --cov-report=term-missing` → **2848 passed**, TOTAL **99%** (>90% gate)
- `bash .agents/validate.sh` → **validation OK** (tests + coverage + ruff + pyright)
- `uv run ruff check . && uv run pyright` → clean, 0 errors
- `uv run pytest tests/e2e/test_answer_images.py -v --no-cov` (isolated) → **5/5 passed**
- Mock-inert spot checks: `tests/e2e/test_image_documents.py` 4/4, `tests/e2e/test_agent_document_tools.py` 4/4

**Completion criteria:** ① zero-read suggested-tier image doc chips with figure — verified (unit matrix, integration `test_zero_read_grounded_turn_auto_cites_suggested_image_doc`, E2E bug-fix flows); ② show_image → answer figure below text/above chips, second call refused, persists + restore + shared — verified (unit handler matrix, integration round-trip, E2E feature flow); ③ default-off byte-identical (3 tools, no clause, no key) — verified (unit + integration + E2E toggle-off); ④ full suite green / >90% / lint clean — verified above; ⑤ commit + phase move — harness responsibility (changes left uncommitted in working tree, no live-infrastructure changes made).

**Next pending phase:** none — `todo/` holds only this phase; the pipeline is clear once the harness moves it.
This commit is contained in:
2026-09-26 17:06:09 -04:00
parent 7bd6335929
commit 62e4f6b572
65 changed files with 4909 additions and 46 deletions
+82 -8
View File
@@ -462,7 +462,14 @@ def plan_turn(
fts_hits,
False,
build_high_prompt(
suggested, notes=steering, kb_overview=kb_text, folder_lines=folder_lines
suggested,
notes=steering,
kb_overview=kb_text,
folder_lines=folder_lines,
# Phase 124 (task 03, LOCKED A5): the grounded HIGH prompt
# carries the show_image clause exactly when the
# BOR_ANSWER_IMAGES toggle is on (off = byte-identical).
show_image=settings.answer_images,
),
suggested,
related_docs,
@@ -486,6 +493,51 @@ def plan_turn(
)
def merge_cited_docs(
read_docs: list[Document],
suggested_docs: list[Document],
deflected: bool,
) -> list[Document]:
"""Compose the citation tier (the done frame's ``sources``, PLAN §9).
Phase 119 (LOCKED A1): on grounded turns the agent's READ DOCS —
deduped by ``(source, path)``, read order; on deflected turns
nothing (a weak-hit answer is not a citation). Phase 124 (TODO L3)
adds the rule's single documented exception: a suggested-tier
IMAGE doc's seed block IS its entire content (content == summary
== the vision description, phase 122 task 03) — a ``read`` would
add only the marker line, so a real model never performs one and
the doc's chip never appeared. "Suggested" is therefore equivalent
to "read" for it, and it is auto-cited — in SUGGESTION order AFTER
the read docs, deduped against them (an image doc the agent also
explicitly read appears once, at its read position). Non-image
suggested docs are never auto-cited: the phase-119 read-docs-only
rule stays intact for text docs (a zero-read text turn still chips
nothing).
"""
if deflected:
return []
cited: list[Document] = []
seen: set[tuple[str, str]] = set()
for doc in read_docs:
key = (doc.source, doc.path)
if key not in seen:
seen.add(key)
cited.append(doc)
# Phase 124 (TODO L3): the suggested-tier IMAGE docs join the
# citation tier — "suggested" is equivalent to "read" for them
# (their seed block is their entire content), so a zero-read
# grounded turn chips them too. Suggestion order, deduped.
for doc in suggested_docs:
if not doc.is_image:
continue
key = (doc.source, doc.path)
if key not in seen:
seen.add(key)
cited.append(doc)
return cited
@router.post("/chat")
async def chat(
request: ChatRequest,
@@ -1035,15 +1087,21 @@ async def chat(
# phase-119 cite discipline, A5) — and [] on
# deflected ones (a deflected answer cites nothing;
# the weak hits stay in the durable record).
# Phase 124 (TODO L3) gives that rule its single
# documented exception — the suggested-tier IMAGE
# docs (``merge_cited_docs``, below): a seed block
# IS an image doc's entire content, so "suggested"
# is equivalent to "read" for it and it is
# auto-cited in suggestion order AFTER the read
# docs, deduped (a read image doc stays at its read
# position, once). Text docs keep the read-docs-only
# rule — a zero-read text turn still chips nothing.
# A cancelled turn (the generator closed by the
# consumer) never reaches this step — no query_log row.
cited_docs: list[Document] = []
cited_seen: set[tuple[str, str]] = set()
for doc in holder.read_docs:
key = (doc.source, doc.path)
if key not in cited_seen:
cited_seen.add(key)
cited_docs.append(doc)
cited_docs = merge_cited_docs(
holder.read_docs, plan.suggested_docs, plan.deflected
)
cited_seen = {(d.source, d.path) for d in cited_docs}
record_docs: list[Document] = []
seen: set[tuple[str, str]] = set()
for doc in [*plan.suggested_docs, *plan.related_docs, *holder.read_docs]:
@@ -1124,12 +1182,28 @@ async def chat(
for d in plan.related_docs
if (d.source, d.path) not in cited_seen
]
# Phase 124 (task 04): the answer's first-class image —
# the shown document's ref (the LLM's ``show_image``
# pick, task 02's ``holder.shown_images``), built by the
# SAME shared ref builder (source/path/title +
# ``image_url``). ``None`` on every other turn — a
# deflected turn runs no agent (``shown_images`` is
# always empty) and an image-less grounded turn never
# called the tool — and ``None`` is DROPPED on
# serialization, so those frames stay byte-identical to
# pre-phase (no key, no null).
answer_ref = (
source_ref_with_image(holder.shown_images[0])
if holder.shown_images
else None
)
yield sse_event(
ChatDoneEvent(
deflected=plan.deflected,
sources=cited_refs,
related=related_refs,
suggestions=plan.suggestions,
answer_image=answer_ref,
).model_dump()
)
finally:
+22 -10
View File
@@ -28,12 +28,17 @@ 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
content-hashed — long enough, bustable by re-upload). PUBLIC (phase
124 — the phase-123 chat-image precedent: the image is part of a
shared chat's rendered content, and the shared chat — the app's ONLY
anonymous surface — must render the answer figure faithfully for
guests; the unguessable ``uuid4`` document id IS the credential, no
enumeration value): 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).
traversal/UUID-guessing has no filesystem surface to hit). The
document's TEXT (the content endpoint) stays user-gated — only the
bytes are public.
PATCH /api/folders/summary — the admin folder-description editor
(phase 97, task 03): update / create / clear a stored
@@ -212,16 +217,23 @@ def get_document_content(
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.
PUBLIC (phase 124, LOCKED A4 — "visible … on the shared page"): a
guest opening a shared chat sees the brain answer's image figure,
so the bytes must serve ANONYMOUSLY. The trust model is the
phase-123 chat-image precedent (the question attachment travels in
the SAME shared content): the document id is an unguessable
server-minted ``uuid4`` — IT IS the credential, with no
enumeration value — exactly like the saved-chat id (phase 55) and
the chat-image filename (phase 123). (The phase-122/79
``require_user`` posture was set before the image became part of a
shared chat's rendered content; the shared page's figure — built
on this route per the phase-124 design — 401'd and collapsed for
every guest, contradicting the owner-locked A4. The document's
TEXT stays user-gated: only the bytes are public.)
404 ``document not found`` (the router's unknown-document shape) in
every non-servable case — a missing id (an unparseable string maps