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
+67 -2
View File
@@ -258,6 +258,43 @@ TOOLS_SECTION: str = (
"</tools>"
)
#: The ``show_image`` contract clause (phase 124, task 03, LOCKED A5) —
#: appended INSIDE the ``<tools>`` section of the HIGH prompt, after the
#: existing three-tool copy and before the closing tag, ONLY when
#: :func:`build_high_prompt` is called with ``show_image=True`` (the
#: ``BOR_ANSWER_IMAGES`` toggle, via ``app.api.chat``). It teaches the
#: same relevance gate as the ``show_image`` tool description in
#: :mod:`app.rag.agent` (LOCKED A5: the gate is the LLM's decision — no
#: server-side image scoring), plus the image-doc nature the tool
#: description cannot fully carry: the text of an image document (in a
#: ``<documents>`` block or a ``read`` result) is a description
#: GENERATED from the image, not the image's own words. The four gate
#: elements, all stated here: (1) the image-doc nature; (2) the
#: "contains the answer" bar — call it only when THAT image is what the
#: user actually wants to see, never a merely related image, never to
#: pad an answer; (3) at most ONE image per answer (also enforced
#: server-side, task 02's ``ALREADY_SHOWING`` refusal); (4) the text
#: answer is still provided in full — the image supplements, never
#: replaces. ``TOOLS_SECTION`` itself stays BYTE-IDENTICAL (the
#: pre-phase pins in :mod:`tests.unit.test_prompt_lock` hold); a
#: ``show_image=False`` build (the default — every pre-phase call site)
#: is byte-identical to the pre-task output. The ``<tools>`` marker the
#: E2E mock keys on is untouched either way — the clause rides on its
#: presence, not the wording. The LOW/deflection prompt never carries
#: the clause (a deflected turn has no tools and never shows an image —
#: the phase's locked design).
SHOW_IMAGE_CLAUSE: str = (
"`show_image` displays a standalone image document as part of your "
"answer. The text of an image document — in a <documents> block or "
"a `read` result — is a description GENERATED from the image, not "
"the image's own words. Call `show_image` only when that image is "
"what the user actually wants to see: a diagram, screenshot, or "
"photo of the very thing you are answering about — never for a "
"merely related image, and never to pad an answer. Show at most one "
"image per answer. The image supplements your text — answer fully "
"in text either way."
)
def history_to_messages(
history: Sequence[HistoryTurn],
@@ -426,6 +463,7 @@ def build_high_prompt(
notes: Sequence[str] | None = None,
kb_overview: str | None = None,
folder_lines: Sequence[str] = (),
show_image: bool = False,
) -> str:
"""Grounded turn: locked persona (+ steering, + KB overview) + the
top-ranked documents' SUMMARY blocks + the ``<tools>`` instructions
@@ -434,7 +472,8 @@ def build_high_prompt(
phase 118, LOCKED A6 re-revising A7: the ``<documents>`` section
seeds SUMMARIES, never full texts — full text enters the context
only through the capped ``read`` tool; phase 119, LOCKED A4: the
suggested-folder context lines, below).
suggested-folder context lines, below; phase 124, task 03, LOCKED
A5: the optional :data:`SHOW_IMAGE_CLAUSE`, below).
Section order: ``<relevance>`` → ``<knowledge_base>`` → ``<tuning>``
→ ``<documents>`` → ``<tools>``; empty steering/overview omit their
@@ -442,6 +481,23 @@ def build_high_prompt(
cap — not the prompt — decides whether the tools are actually
offered to the model, see :mod:`app.rag.agent`).
*show_image* (phase 124, task 03, LOCKED A5): when true (the
caller passes the ``BOR_ANSWER_IMAGES`` toggle —
``app.api.chat``'s grounded plan is the only call site that does),
the :data:`SHOW_IMAGE_CLAUSE` rides INSIDE the ``<tools>`` section
— appended after the existing three-tool copy and before the
closing ``</tools>`` tag, so the ``<tools>`` marker the E2E mock
keys on is untouched and the clause reaches the model together with
the tool teaching. The section order is otherwise UNCHANGED and
:data:`TOOLS_SECTION` itself is never edited: the built section is
``TOOLS_SECTION``'s text minus its final ``</tools>`` + the clause
+ the closing tag. The default ``False`` keeps EVERY pre-existing
call byte-identical (the pre-task pins in
:mod:`tests.unit.test_prompts` / :mod:`tests.unit.test_prompt_lock`
hold untouched). The deflection prompt (:func:`build_deflect_prompt`)
never carries the clause — a deflected turn has no tools and never
shows an image.
When at least one block is present, the section leads with the
start-here :data:`SUGGEST_INTRO` line (before the first block — the
phase-15 ``_STEERING_INTRO`` / phase-31 ``_KB_INTRO`` precedent): the
@@ -506,7 +562,16 @@ def build_high_prompt(
for part in (build_kb_section(kb_overview or ""), build_steering_section(notes or [])):
if part:
prompt += "\n" + part
return prompt + "\n<documents>\n" + body + "\n</documents>\n" + TOOLS_SECTION
if show_image:
# Phase 124 (task 03, LOCKED A5): the clause rides inside
# <tools> — TOOLS_SECTION's text minus its final </tools>, the
# clause (on its own line, after the existing three-tool copy),
# then the closing tag. TOOLS_SECTION itself is unedited and the
# show_image=False path keeps the exact pre-task concatenation.
tools_section = TOOLS_SECTION[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
else:
tools_section = TOOLS_SECTION
return prompt + "\n<documents>\n" + body + "\n</documents>\n" + tools_section
def build_deflect_prompt(