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
+283
View File
@@ -2507,3 +2507,286 @@ def test_shared_chat_serves_the_user_image_path(
finally:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
# ---------------------------------------------------------------------------
# Phase 124, task 04 — the done frame's OPTIONAL answer_image (the
# shown document's ref: the LLM's ``show_image`` pick) + the byte-
# identical-off contract (every other turn: no key, no null).
# ---------------------------------------------------------------------------
@pytest.fixture()
def answer_images_on(monkeypatch: pytest.MonkeyPatch) -> None:
"""The chat endpoint's settings view with ``answer_images`` ON
(the ``BOR_ANSWER_IMAGES`` feature path; the monkeypatch fixture
reverts the patch, the conftest leak-guard pins the default OFF
for every other test in the module)."""
settings = Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
answer_images=True,
)
monkeypatch.setattr(chat_api, "get_settings", lambda: settings)
def test_show_image_turn_done_frame_carries_answer_image(
client, db, seeded_kb: FakeRagLLM, answer_images_on: None
) -> None:
"""A grounded turn whose mock client scripts a ``show_image`` tool
call on a seeded image doc (the scripted-tool mechanism — the
``use your tools`` precedent) → the final SSE ``done`` frame JSON
carries ``answer_image`` with ``image_url ==
/api/documents/<id>/image`` and the cited ``sources`` include the
same doc (its scripted read — the phase-119 citation tier)."""
doc = _seed_image_doc(db)
scripted = FakeRagLLM(
tool_script=[
[
ToolCallPiece(
id="call_1",
name="read",
arguments={"path": "docs/pic.png"},
)
],
[
ToolCallPiece(
id="call_2",
name="show_image",
arguments={"path": "docs/pic.png"},
)
],
]
)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
done = frames[-1]
assert done["deflected"] is False
assert done["answer_image"] == {
"source": "docs",
"path": "pic.png",
"title": "pic",
"image_url": f"/api/documents/{doc.id}/image",
}
# The cited tier includes the same doc (the scripted read).
assert ("docs", "pic.png") in [(s["source"], s["path"]) for s in done["sources"]]
# The toggle was honoured on the wire: the first (agent) request
# offered the 4th tool.
offered = scripted.seen_tools[0]
assert offered is not None
assert [t["function"]["name"] for t in offered] == [
"ls",
"read",
"grep",
"show_image",
]
def test_toggle_off_same_flow_offers_three_tools_and_no_answer_image_key(
client, db, seeded_kb: FakeRagLLM
) -> None:
"""The SAME scripted flow with ``answer_images=False`` (the
default) → ``show_image`` is NOT in the offered tools (the mock's
recorded request carries exactly the three base tools) and the
``done`` frame has NO ``answer_image`` key — the feature's
server-side gate is the OFFER (a model that was never offered the
tool never calls it, so no such call occurs in the flow)."""
_seed_image_doc(db) # resolvable, but the tool is never offered
scripted = FakeRagLLM(
tool_script=[
[
ToolCallPiece(
id="call_1",
name="read",
arguments={"path": "docs/pic.png"},
)
]
]
)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
done = frames[-1]
assert done["deflected"] is False
assert "answer_image" not in done # ABSENT, not null
# The offer: exactly the three base tools — no ``show_image``.
offered = scripted.seen_tools[0]
assert offered is not None
assert [t["function"]["name"] for t in offered] == ["ls", "read", "grep"]
def test_zero_tool_grounded_turn_done_frame_has_no_answer_image_key(
client, db, seeded_kb: FakeRagLLM, answer_images_on: None
) -> None:
"""A zero-tool grounded turn (no scripted calls — the toggle is ON,
so the offer carries ``show_image``, but the model never calls it)
→ the ``done`` frame has NO ``answer_image`` key — byte-identical
to pre-phase, checked on the raw wire text (not a re-serialized
dict)."""
scripted = FakeRagLLM() # no tool_script: a zero-tool turn
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
with client.stream("POST", "/api/chat", json={"message": QUESTION}) as r:
assert r.status_code == 200
buf = ""
raw_frames: list[str] = []
for part in r.iter_text():
buf += part
while "\n\n" in buf:
frame, buf = buf.split("\n\n", 1)
frame = frame.strip()
if frame.startswith("data:"):
raw_frames.append(frame)
finally:
fastapi_app.dependency_overrides.clear()
done_raw = [f for f in raw_frames if '"type": "done"' in f]
assert len(done_raw) == 1
# The BYTE check: the key is absent from the wire text itself.
assert "answer_image" not in done_raw[0]
done = json.loads(done_raw[0].removeprefix("data:").strip())
assert done["deflected"] is False
assert "answer_image" not in done
def test_deflected_turn_done_frame_has_no_answer_image_key_even_toggle_on(
client, db, seeded_kb: FakeRagLLM, answer_images_on: None
) -> None:
"""A deflected turn runs no agent and never shows an image (the
phase's locked design): even with the toggle ON, the ``done``
frame has NO ``answer_image`` key — byte-identical to pre-phase."""
scripted = FakeRagLLM()
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
_, _, frames = _stream_chat(client, OFF_TOPIC)
finally:
fastapi_app.dependency_overrides.clear()
done = frames[-1]
assert done["deflected"] is True
assert "answer_image" not in done
# ---------------------------------------------------------------------------
# Phase 124, task 05 — bug fix: a suggested-tier IMAGE doc earns its
# source chip WITHOUT a ``read`` (its seed block IS its entire content —
# content == summary == the vision description, phase 122 task 03 — so
# "suggested" ≡ "read" for it and the phase-119 cite discipline is
# honored, not weakened). Text docs keep the read-docs-only rule.
# ---------------------------------------------------------------------------
def _seed_image_doc_with_chunk(db) -> Document:
"""An ``is_image`` documents row WITH a retrievable chunk — the
phase-122 pipeline embeds the vision description as the image doc's
content (the only embedded text of the document), so the fixture
chunk mirrors that: a description tuned to overlap ``QUESTION``
(the mock embeddings are token-overlap vectors, as in the E2E
mock), which lands the image doc in the SUGGESTED tier (top-5
fused rank, no floor — A3) where the auto-cite merge operates.
The ``tsv`` column is generated (``to_tsvector`` of the content),
so the chunk also hits the FTS half. The fixture's teardown
truncates the tables."""
doc = _seed_image_doc(db)
# The phase-122 invariant for image docs: content == summary == the
# vision description (the ONE embedded text of the document — no
# pointer line on the summary). The description is tuned to overlap
# ``QUESTION`` (the mock embeddings are token-overlap vectors, as in
# the E2E mock), which lands the image doc in the SUGGESTED tier
# (top-5 fused rank, no floor — A3) where the auto-cite merge
# operates.
description = "Diagram of how my Kubernetes cluster is set up"
doc.content = description
doc.summary = description
db.add(
Chunk(
id=uuid.uuid4(),
document_id=doc.id,
position=0,
content=description,
embedding=_token_vec(description),
)
)
db.commit()
return doc
def test_zero_read_grounded_turn_auto_cites_suggested_image_doc(
client, db, seeded_kb: FakeRagLLM
) -> None:
"""The bug fix pinned end-to-end (TODO L3): a grounded turn with
NO scripted tool calls (the mock answers straight from the seed —
a real model never ``read``s an image doc: its seed block already
carries 100% of its content) whose suggested tier contains an
image doc → the done frame's ``sources`` include the image ref
WITH ``image_url`` set (the chip + figure data — pre-phase-124 a
zero-read turn chipped NOTHING, the phase-122 E2E had to script a
``read`` to earn the chip), and ``related`` does NOT contain the
same ``(source, path)`` (the auto-cited doc leaves the "Nearby
docs" row — no duplicate figure)."""
doc = _seed_image_doc_with_chunk(db)
scripted = FakeRagLLM() # no tool_script: a zero-read grounded turn
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
done = frames[-1]
assert done["deflected"] is False
# The image doc IS a citation chip now — and its ref (and ONLY its
# ref) carries the bytes route the figure renders from.
sources = done["sources"]
img_refs = [s for s in sources if (s["source"], s["path"]) == ("docs", "pic.png")]
assert len(img_refs) == 1 # once — not duplicated by the read merge
assert img_refs[0]["image_url"] == f"/api/documents/{doc.id}/image"
for ref in sources:
if ref is not img_refs[0]:
assert "image_url" not in ref # the text refs stay pre-122-shaped
# Consequence (a): the related tier already excludes cited-seen
# members — the auto-cited image doc is NOT also a "nearby doc".
related_keys = {(s["source"], s["path"]) for s in done["related"]}
assert ("docs", "pic.png") not in related_keys
# Consequence (b): the observability fields are UNCHANGED — the
# durable record (query_log.sources) carried the suggested image
# doc before the phase (it was suggested, not read); auto-citation
# touches the citation tier only, not the record.
row = db.scalars(select(QueryLog)).one()
assert "docs/pic.png" in row.sources
assert "docs/homelab/kubernetes.md" in row.sources
# The seed contract held: the HIGH prompt seeded the image doc's
# summary — its vision description, the ENTIRE content (image docs
# store no ``Source:`` pointer line — the summary mirrors the
# description verbatim, phase 122 task 03) — as a ``<document>``
# block. This is WHY "suggested" ≡ "read" for the doc: a ``read``
# would add only the marker line.
system = scripted.seen_messages[0][0]["content"]
assert '<document source="docs" path="pic.png"' in system
assert "Diagram of how my Kubernetes cluster is set up" in system
def test_zero_read_text_only_turn_still_chips_nothing(
client, db, seeded_kb: FakeRagLLM
) -> None:
"""Text docs are unaffected (consequence c): a zero-read grounded
turn on the plain text-only KB (no image docs anywhere in the
retrieval) → ``sources`` is ``[]`` — byte-identical to pre-phase
(no auto-cite without image docs) and no ``image_url`` key on any
ref (the omission rule at the frame level)."""
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
done = frames[-1]
assert done["deflected"] is False
assert done["sources"] == [] # phase-119 read-docs-only rule intact
for ref in done["sources"] + done["related"]:
assert "image_url" not in ref
+106
View File
@@ -899,6 +899,112 @@ def test_shared_serve_includes_the_user_image_path(
assert "image" not in messages[2]
# ---------------------------------------------------------------------------
# Phase 124 (task 04): the brain answer's first-class image — the
# shown document's ref (the LLM's ``show_image`` pick) rides the BRAIN
# record's ``answer_image`` key (distinct from the user record's
# phase-123 ``image`` attachment path — the attachment belongs to the
# question, the answer image to the brain's reply). The phase-113
# lesson applied up front: the done-time auto-save (``rememberBrainTurn``)
# carries the key, and the ``extra="forbid"`` boundary silently 422'd
# any key the model was missing (the A2 quiet-failure path swallowed
# it — grounded turns silently unsaved). These round-trips are that
# regression pin.
# ---------------------------------------------------------------------------
#: The shown image doc's full ref — the done frame's ``answer_image``
#: shape (source/path/title + the ``image_url`` bytes route).
ANSWER_IMAGE_REF: dict[str, Any] = {
"source": "Homelab",
"path": "kubernetes-cluster.png",
"title": "Kubernetes Cluster Diagram",
"image_url": "/api/documents/0e8a6e5d-7b6c-4f2a-9c1d-3e5f7a9b1c2d/image",
}
def test_create_round_trips_the_brain_answer_image_ref(
admin_client: TestClient, db: Session
) -> None:
"""The phase-113 regression pin (the A2 quiet-failure path): a
brain record carrying ``answer_image`` → ``POST /api/chats`` is
201 (NOT a silent 422) and the served chat round-trips the ref
BYTE-FOR-BYTE (the restore path re-renders the answer figure from
it). The user record carries NO ``answer_image`` key, and the
user's phase-123 ``image`` attachment key stays distinct."""
brain = {**FULL_BRAIN, "answer_image": ANSWER_IMAGE_REF}
r = admin_client.post(
"/api/chats",
json={"messages": [_user(FIRST_QUESTION), brain]},
)
assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
body = r.json()
assert body["messages"][1]["answer_image"] == ANSWER_IMAGE_REF
assert "answer_image" not in body["messages"][0]
assert "image" not in body["messages"][1] # the brain's image is a REF
# The RAW stored JSONB (the DB is the durable boundary): the full
# ref, byte-for-byte.
raw = db.execute(
text("SELECT messages FROM saved_chats WHERE id = :id"),
{"id": body["id"]},
).scalar_one()
assert raw[1]["answer_image"] == ANSWER_IMAGE_REF
assert "answer_image" not in raw[0]
# The admin GET round-trips the ref losslessly.
got = admin_client.get(f"/api/chats/{body['id']}")
assert got.status_code == 200
assert got.json()["messages"][1]["answer_image"] == ANSWER_IMAGE_REF
def test_create_without_answer_image_round_trips_unchanged(
admin_client: TestClient,
) -> None:
"""A record WITHOUT the key (every pre-phase-124 chat, every
image-less turn) round-trips UNCHANGED: the response carries no
``answer_image`` key at all — ABSENT, never ``null`` (the
byte-identical-off contract; the phase-50 round-trip contract).
``_expect`` (the stored shape) is unchanged by phase 124 — the
omission serializer inherits it on every surface."""
brain = {**FULL_BRAIN}
r = admin_client.post(
"/api/chats",
json={"messages": [_user(FIRST_QUESTION), brain]},
)
assert r.status_code == 201, r.text
body = r.json()
assert body["messages"] == _expect([_user(FIRST_QUESTION), brain])
assert "answer_image" not in body["messages"][0]
assert "answer_image" not in body["messages"][1]
got = admin_client.get(f"/api/chats/{body['id']}")
assert got.status_code == 200
assert "answer_image" not in got.json()["messages"][1]
def test_shared_serve_includes_the_brain_answer_image_ref(
admin_client: TestClient,
) -> None:
"""The shared view is faithful (task 06's render depends on it):
the PUBLIC snapshot's brain record carries the answer ref (the
bytes route is public — a shared chat is faithful, the phase-123
user-image precedent), and the user record stays WITHOUT the key."""
brain = {**FULL_BRAIN, "answer_image": ANSWER_IMAGE_REF}
r = admin_client.post(
"/api/chats",
json={"messages": [_user(FIRST_QUESTION), brain]},
)
assert r.status_code == 201, r.text
share_url = _share(admin_client, r.json()["id"])["share_url"]
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
got = anon.get(f"/api{share_url}")
assert got.status_code == 200
messages = got.json()["messages"]
assert messages[1]["answer_image"] == ANSWER_IMAGE_REF
assert "answer_image" not in messages[0]
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
admin_client: TestClient,
) -> None:
+17 -11
View File
@@ -1305,24 +1305,30 @@ def test_image_route_404_matrix(admin_client: TestClient, db, tmp_path: Path) ->
_cleanup_kb(db)
def test_image_route_requires_user_like_the_content_endpoint(
def test_image_route_is_public_like_the_shared_chat_content(
admin_client: TestClient, db, tmp_path: Path
) -> None:
"""Phase 79 posture (the task's "PUBLIC, like the document content
endpoint" — the content endpoint has been user-gated since phase
79; the ONLY anonymous surface is the shared chats, PLAN A10): an
anonymous caller gets 401 ``authentication required`` before any
row is read (a FRESH client — the module's fixture ``client``
stays unsigned here), a signed-in caller gets the bytes."""
"""Phase 124 (LOCKED A4 — the answer figure is "visible … on the
shared page"): the BYTES route is PUBLIC (the phase-123
chat-image precedent — the image is part of a shared chat's
rendered content, and the shared chat is the app's ONLY anonymous
surface, PLAN A10; the unguessable ``uuid4`` document id IS the
credential, no enumeration value): an ANONYMOUS caller (a FRESH
client — no session) gets the bytes with the image Content-Type,
and a signed-in caller gets the same. The document's TEXT (the
content endpoint) stays user-gated — only the bytes are public."""
_cleanup_kb(db)
try:
doc = _seed_image_doc(db, tmp_path)
anonymous = TestClient(fastapi_app)
r = anonymous.get(f"/api/documents/{doc.id}/image")
assert r.status_code == 401
assert r.json() == {"detail": "authentication required"}
# The signed-in client (admin) passes.
assert admin_client.get(f"/api/documents/{doc.id}/image").status_code == 200
assert r.status_code == 200
assert r.headers["content-type"].startswith("image/")
assert r.content == PNG_1X1
# The signed-in client (admin) gets the same bytes.
r_admin = admin_client.get(f"/api/documents/{doc.id}/image")
assert r_admin.status_code == 200
assert r_admin.content == PNG_1X1
finally:
_cleanup_kb(db)