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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user