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
+126
View File
@@ -34,6 +34,7 @@ the LOW prompt and ``PERSONA`` / ``TOOLS_SECTION`` stay byte-identical
"""
from __future__ import annotations
import inspect
import uuid
from datetime import UTC, datetime
@@ -43,6 +44,7 @@ from app.config import Settings
from app.models import Document
from app.rag.prompts import (
PERSONA,
SHOW_IMAGE_CLAUSE,
SUGGEST_INTRO,
TOOLS_SECTION,
_base,
@@ -1015,3 +1017,127 @@ def test_plain_text_line_never_leaks_into_high_prompt() -> None:
high = build_high_prompt([doc], notes=notes, kb_overview=kb)
assert PLAIN_TEXT_ONLY_LINE not in high
assert "you have no tools" not in high
# ---------- phase 124 (task 03, LOCKED A5): the show_image prompt clause ----------
def test_show_image_clause_states_the_four_gate_elements() -> None:
"""LOCKED A5: the clause states ALL FOUR gate elements, pinned on
stable phrases, and references the tool by its backticked name (the
``TOOLS_SECTION`` voice). (1) the image-doc nature — its text is a
description GENERATED from the image; (2) the "contains the answer"
bar — never a merely related image, never padding; (3) at most one
image per answer; (4) the text answer is still provided in full."""
assert "`show_image`" in SHOW_IMAGE_CLAUSE # backticked, like the other tools
assert "description GENERATED from the image" in SHOW_IMAGE_CLAUSE # (1)
assert "what the user actually wants to see" in SHOW_IMAGE_CLAUSE # (2)
assert "never for a merely related image" in SHOW_IMAGE_CLAUSE # (2)
assert "never to pad an answer" in SHOW_IMAGE_CLAUSE # (2)
assert "at most one image per answer" in SHOW_IMAGE_CLAUSE # (3)
assert "answer fully in text" in SHOW_IMAGE_CLAUSE # (4)
# It is a clause, not a section: no ``<tools>`` markup of its own
# (the ``<documents>`` mention is plain text, as in the section copy).
assert "<tools>" not in SHOW_IMAGE_CLAUSE
assert "</tools>" not in SHOW_IMAGE_CLAUSE
def test_high_prompt_default_stays_byte_identical_to_pre_task() -> None:
"""Completion pin: ``build_high_prompt(docs)`` (no new arg) and the
explicit ``show_image=False`` are byte-identical to each other and
end with the untouched ``TOOLS_SECTION`` — the pre-task pins above
(and the sha pins in :mod:`tests.unit.test_prompt_lock`) pass
UNMODIFIED, with or without documents."""
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
plain = build_high_prompt([doc])
assert build_high_prompt([doc], show_image=False) == plain
assert plain.endswith(TOOLS_SECTION)
assert SHOW_IMAGE_CLAUSE not in plain
empty = build_high_prompt([])
assert build_high_prompt([], show_image=False) == empty
assert empty.endswith(TOOLS_SECTION)
assert SHOW_IMAGE_CLAUSE not in empty
def test_high_prompt_show_image_clause_lands_inside_tools_after_the_copy() -> None:
"""Phase 124 (task 03): ``show_image=True`` builds EXACTLY the
toggle-off prompt with its final ``</tools>`` replaced by the clause
(on its own line) + the closing tag — the prefix up to the clause
is byte-identical to the toggle-off output (the three-tool copy
untouched), the clause lands AFTER the existing copy and BEFORE the
closing tag, and appears exactly once."""
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
off = build_high_prompt([doc])
on = build_high_prompt([doc], show_image=True)
# The full structural equation (the clause is the ONLY difference).
assert on == off[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
clause_start = on.index(SHOW_IMAGE_CLAUSE)
# The prefix up to the clause is byte-identical to the toggle-off
# build minus its closing tag.
assert on[:clause_start] == off[: -len("</tools>")]
# From the <tools> opener to the clause is TOOLS_SECTION's own text
# minus its final </tools> — the constant itself is unedited (sha-
# pinned in test_prompt_lock) and the three-tool copy is intact.
assert on[on.index("<tools>") : clause_start] == TOOLS_SECTION[: -len("</tools>")]
assert "Answer as soon as you have what you need." in on[:clause_start]
# The clause (then the closing tag) is the tail of the prompt, after
# the existing copy and before the closing tag — exactly once.
assert on.endswith(SHOW_IMAGE_CLAUSE + "\n</tools>")
assert on.index("Answer as soon as you have what you need.") < clause_start
assert clause_start < on.index("</tools>")
assert on.count(SHOW_IMAGE_CLAUSE) == 1
assert on.count("<tools>") == 1 and on.count("</tools>") == 1
def test_show_image_clause_rides_only_the_high_prompt() -> None:
"""The clause appears ONLY in the HIGH prompt: the deflection prompt
takes NO ``show_image`` flag at all and its output never carries the
clause (byte-identical builds with/without notes/overview — a
deflected turn has no tools and never shows an image, the phase's
locked design)."""
assert "show_image" not in inspect.signature(build_deflect_prompt).parameters
for prompt in (
build_deflect_prompt(["T1", "T2"]),
build_deflect_prompt(["T1"], notes=["be concise"], kb_overview=OVERVIEW),
build_deflect_prompt([]),
):
assert SHOW_IMAGE_CLAUSE not in prompt
assert "`show_image`" not in prompt
assert "at most one image per answer" not in prompt
def test_high_prompt_empty_documents_show_image_clause_still_in_tools() -> None:
"""The "(no documents matched…)" body + ``show_image=True``: the
clause still lands inside ``<tools>`` — the grounded turn may still
``ls``/``grep`` its way to an image doc and show it."""
off = build_high_prompt([])
on = build_high_prompt([], show_image=True)
assert on == off[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
assert "(no documents matched — do not invent specifics)" in on
assert on.index("<tools>") < on.index(SHOW_IMAGE_CLAUSE) < on.index("</tools>")
assert on.endswith(SHOW_IMAGE_CLAUSE + "\n</tools>")
def test_show_image_clause_is_last_for_every_high_shape() -> None:
"""Steering + KB overview + folder lines + documents all present:
the section order is UNCHANGED (``<relevance>`` →
``<knowledge_base>`` → ``<tuning>`` → ``<documents>`` → ``<tools>``)
and the clause is still the last thing the model reads, inside
``<tools>`` after the three-tool copy."""
doc = _doc("deploy/k8s.md", "CONTENT_SENTINEL", "K8s Deploy", summary="K8s deploy notes.")
lines = ["Homelab/deploy/: k8s.md (1 doc)"]
off = build_high_prompt([doc], notes=["be concise"], kb_overview=OVERVIEW, folder_lines=lines)
on = build_high_prompt(
[doc],
notes=["be concise"],
kb_overview=OVERVIEW,
folder_lines=lines,
show_image=True,
)
assert on == off[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
clause_start = on.index(SHOW_IMAGE_CLAUSE)
assert on[on.index("<tools>") : clause_start] == TOOLS_SECTION[: -len("</tools>")]
for section in ("<relevance>", "<knowledge_base>", "<tuning>", "<documents>"):
assert on.index(section) < on.index("<tools>")
assert on.index("K8s deploy notes.") < on.index("<tools>") # the summary still seeds
assert SHOW_IMAGE_CLAUSE not in off # the toggle-off twin carries none of it