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
+69 -10
View File
@@ -242,6 +242,21 @@ class ChatDoneEvent(BaseModel):
citation chip — while ``sources`` stays the citation surface. The
field is ADDITIVE: old clients ignore unknown fields (PLAN §4 house
contract) and old frames without it parse with the default ``[]``.
Phase 124 (task 04): ``answer_image`` — the brain answer's
FIRST-CLASS image: the standalone image document the LLM chose to
show as part of its answer (its ``show_image`` pick, behind
``BOR_ANSWER_IMAGES``; LOCKED A5 — the relevance gate is the model's
decision). It is the shown document's :class:`SourceRef` built by
the shared :func:`app.rag.retriever.source_ref_with_image` (source /
path / title + the ``image_url`` bytes route), distinct from a
citation in ``sources`` — the document may be cited as well, but
this key IS the answer's image (the UI's answer figure, task 06).
``None`` (every deflected turn — they run no agent — every
image-less turn, and the whole pre-phase) is DROPPED on
serialization — ABSENT, never ``null`` — so those frames stay
byte-identical to pre-phase (the byte-identical-off contract, task
01; the :class:`SourceRef.image_url` omission precedent).
"""
type: str = "done"
@@ -249,6 +264,25 @@ class ChatDoneEvent(BaseModel):
sources: list[SourceRef]
related: list[SourceRef] = []
suggestions: list[str] = []
#: Phase 124 (task 04) — see the class docstring. A single
#: :class:`SourceRef` (no list — LOCKED A3: one image per answer);
#: the ref's own phase-83 field caps bind.
answer_image: SourceRef | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
"""The phase-124 answer-image omission rule: ``answer_image``
``None`` (every deflected / image-less / pre-phase turn)
serializes WITHOUT the key — ABSENT, never ``null`` — so those
done frames stay byte-identical to pre-phase (the
:class:`SourceRef.image_url` / phase-123 ``image`` omission
precedents). A shown ref keeps all its fields (its own
serializer drops a ``None`` ``image_url`` — a shown doc is an
image doc, so in practice it always carries it)."""
data = handler(self)
if self.answer_image is None:
data.pop("answer_image", None)
return data
class ChatErrorEvent(BaseModel):
@@ -967,22 +1001,47 @@ class ChatMessage(BaseModel):
# omission precedent). The path is ≤ 500 chars — no phase-83
# cap pressure (it is never a data URL, LOCKED A5).
image: str | None = Field(default=None, max_length=500)
# Phase 124 (task 04): the brain answer's FIRST-CLASS image — the
# shown document's :class:`SourceRef` (the LLM's ``show_image``
# pick, ``BOR_ANSWER_IMAGES``), on the BRAIN record only. It is a
# DIFFERENT thing from the user record's phase-123 ``image`` (the
# stored PATH STRING of the question's attachment): that field is
# untouched and the two are semantically distinct — the attachment
# belongs to the question, the answer image to the brain's reply
# (a user record never carries ``answer_image``, a brain record
# never carries ``image``). A single ref — no length cap needed
# (the ref's own phase-83 field caps bind). The done-time
# auto-save (``rememberBrainTurn``, task 06) persists the done
# frame's ``answer_image`` into this key, and the restore path
# re-renders the answer figure from it — the field must exist so
# the ``extra="forbid"`` boundary does NOT 422 those saves (the
# phase-113 lesson, applied up front: a missing key silently
# unsaved every grounded brain record). ``None`` (every pre-phase
# record, every image-less turn) serializes WITHOUT the key —
# ABSENT, never ``null`` (the :meth:`_drop_image_when_absent`
# serializer below), so pre-phase and image-less chats round-trip
# byte-identically (the phase-50 contract).
answer_image: SourceRef | None = None
@model_serializer(mode="wrap")
def _drop_image_when_absent(self, handler: SerializerFunctionWrapHandler) -> Any:
"""The phase-123 image omission rule: ``image: None`` (every
text-only record, and every pre-phase-123 record) serializes
WITHOUT the key — ABSENT, never ``null`` — so the stored
``bor.chat.v1`` JSONB and the saved/shared wire shape stay
byte-identical to pre-phase for text-only chats (the phase-50
round-trip contract). A record WITH an image keeps the path —
the user bubble, the refreshed page, and the shared chat all
render it from the served route (the image is part of the
chat's content, so it rides the same public/credential-
is-the-id trust model)."""
"""The image omission rules: ``image: None`` (every text-only
record, and every pre-phase-123 record) and ``answer_image:``
``None`` (every record without a shown answer image, and every
pre-phase-124 record) serialize WITHOUT the keys — ABSENT,
never ``null`` — so the stored ``bor.chat.v1`` JSONB and the
saved/shared wire shape stay byte-identical to pre-phase for
those records (the phase-50 round-trip contract; the
phase-122 ``SourceRef.image_url`` omission precedent). A
record WITH one keeps it — the user bubble, the refreshed page,
and the shared chat all render the image from the served route
(the image is part of the chat's content, so it rides the same
public/credential-is-the-id trust model)."""
data = handler(self)
if self.image is None:
data.pop("image", None)
if self.answer_image is None:
data.pop("answer_image", None)
return data