phase: 124_image_chips_and_answers
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:
+69
-10
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user