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
@@ -0,0 +1,13 @@
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.
@@ -0,0 +1,109 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 88%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 262 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 374 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 106 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 48 0 100%
app/schemas.py 400 0 100%
--------------------------------------------------
TOTAL 4669 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,19 @@
That's an empty harness session log — not mine, leaving it untouched. Task 01 is complete and all gates are green.
## Report
**Implemented (task 01 — `BOR_ANSWER_IMAGES` feature toggle):**
- `app/config.py`: new `Settings.answer_images: bool = False` in a new "Answer images (phase 124)" block after `chat_image_max_mb` — docstring per task spec (gates the ANSWER side only; INDEPENDENT of `images`/`BOR_IMAGES` per LOCKED A2; off = no `show_image` tool, no prompt clause, no `answer_image` frame key)
- `.env.example`: commented `# BOR_ANSWER_IMAGES=0` + "off by default; enable alongside BOR_IMAGES" comment beside the existing image block
- `tests/unit/test_image_chips_and_answers.py` (new, created per phase overview for tasks 01–05): default-off (unset/`0`/`false`), truthy parsing (`1`/`true`, mirroring the `stream_thinking` bool-test precedent), and bidirectional independence from `images`
- Per task instruction, no `answer_images` key in `GET /api/config` (UI is purely reactive)
**Test / lint / coverage results:**
- `uv run pytest` → **2799 passed** (51 s, full unit + integration suite)
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% gate)
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings
- New-file run: `uv run pytest tests/unit/test_image_chips_and_answers.py tests/unit/test_config.py -v --no-cov` → 63 passed
**Decisions / deviations:** none — followed the task file exactly; verified via grep no other code reads the field yet (off-state byte-identical). Left changes uncommitted per harness protocol.
**Next pending task:** `02_show_image_tool.md` (`agent_tools` gating + `show_image` handler).
@@ -0,0 +1,108 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 15%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 33%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 95%]
........................................................................ [ 97%]
............................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 248 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 349 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 102 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 48 0 100%
app/schemas.py 390 0 100%
--------------------------------------------------
TOTAL 4616 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,9 @@
All gates green. Final report:
**Task 02 — `show_image` agent tool (toggle-gated): complete**
- `app/rag/agent.py`: new `SHOW_IMAGE_TOOL` constant (gate copy per LOCKED A5 in the description) + `agent_tools(settings)` returning a fresh `[...AGENT_TOOLS]` (+ `show_image` only when `answer_images` on); new refusal constants `MISSING_SHOW_ARGS` / `NOT_AN_IMAGE_DOC` (arg-echoed) / `ALREADY_SHOWING`; `show_image` branch in `_execute_tool` (after `read`, before the unknown-tool fallback) — missing-arg → refusal, unresolvable → phase-72 `_no_document_refusal`, text doc → refused (never converted to read), second call → `ALREADY_SHOWING` (A3, server-enforced), success → `holder.shown_images.append` + `tool_calls += 1` + confirming line and the exact read-style image doc block (`Document …` / `date:` / `IMAGE_DOC_MARKER` / description, no truncation machinery); `AgentHolder.shown_images` field + docstring; `run_agent` wiring now `agent_tools(settings) if max_rounds > 0 else None` (kill switch unchanged). `AGENT_TOOLS` itself is byte-identical (verified by diff + all existing agent pins pass).
- `tests/unit/test_image_chips_and_answers.py`: 9 new tests — offer gating (off = 3 base, element-for-element + fresh-list mutation safety; on = +`show_image` with gate copy), tool-definition shape, `run_agent` offer wiring on/off, refusal-text pins, and the full handler matrix (a–e) against real Postgres rows.
- Results: `uv run pytest --cov=app` → 2809 passed, TOTAL **99%** (>90%); `uv run ruff check .` → clean; `uv run pyright` → 0 errors; regression `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → 4 passed.
- Deviations: none; task 02 is unit-scoped (integration pins are task 04, E2E is task 07).
- Next pending task: `03_prompt_clause.md` (the toggle-gated `<tools>` clause in the HIGH prompt).
@@ -0,0 +1,109 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 38%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 97%]
........................................................................ [ 99%]
. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 248 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 374 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 102 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 48 0 100%
app/schemas.py 390 0 100%
--------------------------------------------------
TOTAL 4641 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,16 @@
Task 03 complete. All criteria verified.
**Implemented**
- `app/rag/prompts.py`: new `SHOW_IMAGE_CLAUSE` constant (all four A5 gate elements: GENERATED-description nature / "actually wants to see" bar / one image per answer / answer fully in text); `build_high_prompt` gained `show_image: bool = False` — the clause rides inside `<tools>` (after the three-tool copy, before `</tools>`); `TOOLS_SECTION` and the toggle-off output untouched.
- `app/api/chat.py`: the single HIGH-prompt build site (`plan_turn`) passes `show_image=settings.answer_images`; deflection prompt untouched.
- Tests: 6 new in `tests/unit/test_prompts.py` (byte-identity off, structural equation on, four phrases, clause HIGH-only + `build_deflect_prompt` has no flag, empty-docs build, all-sections shape) + 2 `plan_turn` wiring pins in `tests/unit/test_image_chips_and_answers.py` (grounded plan carries the clause exactly when the toggle is on; deflected never).
**Results**
- `uv run pytest --cov=app --cov-report=term-missing` → 2817 passed, 0 failed; TOTAL 99% (prompts.py 100%, chat.py 99%)
- `uv run ruff check . && uv run pyright` → clean (fixed one import-sort + one pyright `**kwargs` inference)
- Pre-existing `test_prompts.py`/`test_prompt_lock.py` pins pass unmodified (62 passed)
**Decisions**
- Clause constant carries no trailing newline; the built section inserts one before `</tools>` (house style: closing tag on its own line) — literal task construction otherwise.
**Next pending task:** `04_frame_and_persistence.md` (ChatDoneEvent/ChatMessage `answer_image` + done-frame wiring).
@@ -0,0 +1,109 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 66%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 89%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 97%]
........................................................................ [ 99%]
......... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 248 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 374 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 106 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 48 0 100%
app/schemas.py 390 0 100%
--------------------------------------------------
TOTAL 4645 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,12 @@
Everything is green. The previous attempt's code was intact; the rejection was a child-run reporting glitch, not a test/lint failure. All gates re-verified.
**Report**
- Implemented: `SHOW_IMAGE_CLAUSE` constant in `app/rag/prompts.py` (all four A5 gate elements); `build_high_prompt` gained `show_image: bool = False` — clause lands inside `<tools>` after the three-tool copy, before `</tools>`; `TOOLS_SECTION` and the toggle-off output byte-identical.
- `app/api/chat.py`: the single HIGH-prompt build site (`plan_turn`) passes `show_image=settings.answer_images`; deflection prompt untouched.
- Tests: 6 new in `tests/unit/test_prompts.py` (byte-identity off, structural equation on, four stable phrases, clause HIGH-only + `build_deflect_prompt` takes no flag, empty-docs build, full-sections shape) + 2 `plan_turn` wiring pins in `tests/unit/test_image_chips_and_answers.py` (grounded plan carries the clause exactly when the toggle is on; deflected never).
- `uv run pytest --cov=app --cov-report=term-missing` → 2817 passed, 0 failed; TOTAL 99% (prompts.py 100%, chat.py 99%).
- `uv run ruff check . && uv run pyright` → all checks passed, 0 errors.
- Pre-existing `test_prompts.py`/`test_prompt_lock.py` pins pass unmodified (62 passed).
- Decisions: clause constant carries no trailing newline; the built section inserts one before `</tools>` (house style: closing tag on its own line) — literal task construction otherwise.
- Next pending task: `04_frame_and_persistence.md` (ChatDoneEvent/ChatMessage `answer_image` + done-frame wiring).
@@ -0,0 +1,109 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 66%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 89%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 97%]
........................................................................ [ 99%]
......... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 248 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 374 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 106 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 48 0 100%
app/schemas.py 390 0 100%
--------------------------------------------------
TOTAL 4645 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,16 @@
All green. Final report:
**Implemented (task 04 — frame + persistence):**
- `app/schemas.py`: `ChatDoneEvent.answer_image: SourceRef | None` + wrap serializer dropping the key when `None` (byte-identical-off); `ChatMessage.answer_image` (brain records; docstring pins it distinct from the phase-123 user `image` path) + extended the wrap serializer to drop it when `None`
- `app/api/chat.py`: done frame passes `source_ref_with_image(holder.shown_images[0]) if holder.shown_images else None`
- `tests/conftest.py`: `BOR_ANSWER_IMAGES` leak guard (phase-106/122 house pattern)
- 14 unit tests (`test_image_chips_and_answers.py`): serializer omission/presence, pre-phase parse, full-ref, phase-83 bounds; 4 integration chat tests (show_image turn → `answer_image` + cited source + 4-tool offer; toggle-off → 3 tools + no key; zero-tool + deflected-toggle-on → no key on raw wire); 3 chats tests (201 round-trip incl. raw JSONB — phase-113 422 pin, no-key unchanged, shared-serve fidelity)
**Verification:**
- `uv run pytest --cov=app --cov-report=term-missing` → 2834 passed, TOTAL **99%** (>90%; `schemas.py` 100%, `chat.py` 99% — sole miss is pre-existing line 521)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
- Regression check: `tests/e2e/test_share_chat.py` (4 passed) + `tests/e2e/test_image_documents.py` (4 passed) in isolation
**Decisions:** extended the single existing `ChatMessage` wrap serializer (Pydantic allows one per model) so pre-phase records round-trip byte-identically — existing `FULL_BRAIN`/`_expect` pins pass unmodified. Test (a) scripts read+show_image (cited tier = read docs until task 05's auto-cite); (b) omits the show_image call per the phase overview ("no such call occurs"). No live infra touched; no commits made.
**Next pending task:** `.agents/phases/todo/124_image_chips_and_answers/05_auto_cite_image_docs.md`
@@ -0,0 +1,109 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 63%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 91%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 99%]
.......................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 249 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 374 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 106 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 48 0 100%
app/schemas.py 400 0 100%
--------------------------------------------------
TOTAL 4656 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,17 @@
Everything is green. The prior rejection was a harness report-format issue, not a code failure — all work was already in the working tree and remains intact. Final verification complete.
**Implemented (task 04 — the `answer_image` done frame + saved-chat persistence):**
- `app/schemas.py`: `ChatDoneEvent.answer_image: SourceRef | None` + a `model_serializer(mode="wrap")` that drops the key when `None` (byte-identical-off — absent, never `null`). `ChatMessage.answer_image` (brain records; docstring marks it distinct from the phase-123 user `image` path) + extended the wrap serializer to drop it when `None`.
- `app/api/chat.py`: done frame passes `source_ref_with_image(holder.shown_images[0]) if holder.shown_images else None`.
- `tests/conftest.py`: `BOR_ANSWER_IMAGES` leak guard (phase-106/122 house pattern).
- 14 unit tests + 4 chat-integration tests + 3 chats-integration tests (phase-113 422 round-trip pin, no-key unchanged, shared-serve fidelity).
**Results (all re-run this session):**
- `uv run pytest --cov=app --cov-report=term-missing` → **2834 passed**, TOTAL **99%** (>90%; `schemas.py` 100%)
- `uv run ruff check .` → All checks passed
- `uv run pyright` → 0 errors, 0 warnings
- task-04 files in isolation → 147 passed
**Notable decisions:** extended the single existing `ChatMessage` wrap serializer (Pydantic allows one per model) so pre-phase records round-trip byte-identically; existing `FULL_BRAIN`/`_expect` pins pass unmodified. Test (a) scripts read+show_image (cited tier = read docs until task 05); (b) omits the show_image call per the phase overview ("no such call occurs"). No live infra touched; no commits made (left in working tree for the harness).
**Next pending task:** `.agents/phases/todo/124_image_chips_and_answers/05_auto_cite_image_docs.md`
@@ -0,0 +1,109 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 63%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 91%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 99%]
.......................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 249 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 374 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 106 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 48 0 100%
app/schemas.py 400 0 100%
--------------------------------------------------
TOTAL 4656 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,11 @@
Working tree intact, everything green. Task complete.
**Report — Task 05: auto-cite suggested-tier image docs**
- `app/api/chat.py`: new module-level `merge_cited_docs(read_docs, suggested_docs, deflected)` — phase-119 read-docs tier + the single documented exception: suggested-tier `is_image` docs auto-cited in suggestion order after reads, deduped by `(source, path)`; deflected → `[]`. Pipeline calls it; `cited_seen` derived from the result (related-tier exclusion and `record_docs` untouched). Comment block updated per spec.
- Unit: 6 new tests in `tests/unit/test_image_chips_and_answers.py` covering cases (a)–(f) (zero-read image cited, read-image once at read position, text never auto-cited, deflected `[]`, two images in suggestion order, read order preserved/deduped).
- Integration: `tests/integration/test_chat_api.py` — zero-read grounded turn with a seeded image doc (content==summary==description + question-overlapping chunk, phase-122 invariant) → `done.sources` carries the image ref with `image_url`, `related` excludes it, `query_log.sources` unchanged; text-only KB turn → `sources == []` byte-identical.
- Tests: `uv run pytest` → **2842 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (chat.py 99%); `uv run ruff check . && uv run pyright` → **clean (0 errors)**.
- Predecessor E2E regression checks (in isolation): `test_image_documents.py` 4 passed, `test_chat_image_questions.py` 5 passed — scripted `read` still valid, no double chips.
- Notable: image-doc summaries carry no `Source:` pointer line (verbatim description, phase-122 task 03) — seed pin asserts the `<document source=… path=…>` block instead.
- Next pending task: `.agents/phases/todo/124_image_chips_and_answers/06_chat_ui_answer_image.md`.
@@ -0,0 +1,109 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 35%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 73%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 96%]
........................................................................ [ 98%]
.................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 262 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 374 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 106 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 48 0 100%
app/schemas.py 400 0 100%
--------------------------------------------------
TOTAL 4669 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,10 @@
**Report — Task 05: auto-cite suggested-tier image docs (re-verified after harness rejection)**
- The rejection was process-level ("child run ended without a clean final report") — the work was complete and green; no code defects were found. Working tree re-verified intact, all gates re-run from scratch.
- `app/api/chat.py`: module-level `merge_cited_docs(read_docs, suggested_docs, deflected)` — phase-119 read-docs tier + the single documented exception: suggested-tier `is_image` docs auto-cited in suggestion order after reads, deduped by `(source, path)`; deflected → `[]`. Pipeline calls it; `cited_seen` derived from the result; `record_docs`/related-tier exclusion untouched.
- Unit: 6 new helper-matrix tests in `tests/unit/test_image_chips_and_answers.py` (zero-read image cited, read-image once at read position, text never auto-cited, deflected `[]`, two images in suggestion order, read order preserved/deduped).
- Integration: `tests/integration/test_chat_api.py` — zero-read grounded turn with seeded image doc (content==summary==description + question-overlapping chunk) → `done.sources` carries the image ref with `image_url`, `related` excludes it, `query_log.sources` unchanged; text-only KB turn → `sources == []` byte-identical.
- Tests: `uv run pytest --no-cov` → **2842 passed**; task-specific subset → 8 passed.
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (gate >90%; chat.py 99%, sole miss is the pre-existing SEC-14-04 503 fast path).
- Lint/types: `uv run ruff check .` → all passed; `uv run pyright` → **0 errors, 0 warnings**.
- Next pending task: `.agents/phases/todo/124_image_chips_and_answers/06_chat_ui_answer_image.md`.
@@ -0,0 +1,109 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 35%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 73%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 96%]
........................................................................ [ 98%]
.................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 262 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 374 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 106 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 48 0 100%
app/schemas.py 400 0 100%
--------------------------------------------------
TOTAL 4669 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,22 @@
All green. Final verification summary:
- **Unit pins added** (`tests/unit/test_image_chips_and_answers.py`, task-06 section): `appendAnswerImage` contract (lazy img, bytes route, title→summary alt/caption swap via `fetchContentSummary`, collapse-on-error `fig.remove()`, chip navigation), the three wiring points (done handler renders before chip row, `answer_image: ev.answer_image || undefined` in the save meta, restore renders before chip row), the shared page's non-interactive `<figure>` copy (zero controls, no guest fetch of the user-gated content endpoint, wired before `addSources`), and the answer-scale themed CSS (320px cap, contain, `var(--surface)`/`var(--line)`/`var(--ink-soft)` tokens only, zero hex literals, hover scoped to `a.answer-image`).
**Implemented (task 06 — the answer figure UI):**
- `frontend/assets/app.js`: new `appendAnswerImage(wrap, ref)` helper (lazy `<img>` on the public bytes route, visible caption, alt/caption = title → summary swap, collapse-to-nothing on img error, `documentUrl` href + left-click same-page modal); wired into the `done` handler right before `appendSources` (A4: below text, above chips), the `rememberBrainTurn` meta (`answer_image: ev.answer_image || undefined`), and `renderStoredMessage`'s brain branch (before `appendSources`).
- `frontend/assets/shared.js`: `addAnswerImage` — a plain non-interactive `<figure>` (owner-locked zero controls; public bytes route makes the guest view faithful; caption keeps the title — the summary endpoint is user-gated for guests), wired in `renderSharedMessage` before `addSources`.
- `frontend/assets/styles.css`: `.answer-image` family — `.source-image` language at answer scale: full-column width, 320px max-height cap, `object-fit: contain`, theme bordered-image treatment, AA-safe muted caption (3-line clamp; full text in alt/title), hover underline scoped to the chat page's anchor only.
**Test / lint / coverage results:**
- `uv run pytest` → **2848 passed** (unit + integration, exit 0)
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% gate)
- `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors**
- JS syntax: `node --check` clean on `app.js` + `shared.js` (ES module)
**Notable decisions:**
- Chat-page figure is an `<a class="answer-image">` (the exact `appendSourceImageFigure` anchor pattern — the codebase calls that a "figure" too); shared page uses a plain `<figure>` (zero controls) — the `a.` CSS prefix keeps the hover out of the guest view without extra scoping.
- Shared page renders the title as the always-visible caption and does **not** fetch `/api/documents/content` (it is `require_user`-gated; a guest fetch would 401 — the WCAG alt contract holds with the title standing in).
- Caption line-clamped at 3 lines (summaries can be 12k chars; full text carried by `alt` + caption `title`).
- No live infrastructure touched; no `app/` code changes (server-side work was tasks 01–05, already in the working tree).
**Next pending task:** `07_answer_image_tests.md` — the mock-LLM `show_image` trigger + isolated E2E `test_answer_images.py` (it exercises this UI end-to-end: live figure, restore, shared page, collapse, toggle-off contract).
@@ -0,0 +1,109 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 88%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 262 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 374 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 106 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 48 0 100%
app/schemas.py 400 0 100%
--------------------------------------------------
TOTAL 4669 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,18 @@
All gates green. The prior rejection was a harness interruption (child run killed before reporting — empty `a2.err`, no `a2.md`); the work was already complete and validated (`a1.validate` = "validation OK").
**Implemented (task 06 — the answer figure UI):**
- `app.js`: `appendAnswerImage(wrap, ref)` (lazy `<img>` on public bytes route, visible caption, title→summary swap, collapse-to-nothing on img error, chip navigation); wired into the `done` handler (before `appendSources`, A4: below text, above chips), `rememberBrainTurn` meta (`answer_image: ev.answer_image || undefined`), and `renderStoredMessage` restore (before `appendSources`).
- `shared.js`: `addAnswerImage` — plain non-interactive `<figure>` (zero controls; public bytes route), wired in `renderSharedMessage` before `addSources`.
- `styles.css`: `.answer-image` family — answer scale (320px cap, contain, theme bordered-image, AA muted caption, hover scoped to `a.` only).
**Test / lint / coverage:**
- `uv run pytest --cov=app --cov-report=term-missing` → **2848 passed**, **TOTAL 99%** (>90% gate)
- `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors**
- JS syntax: `node --check` clean on `app.js` + `shared.js`
**Notable decisions:**
- Chat figure is `<a class="answer-image">` (chip-anchor pattern); shared page uses plain `<figure>` (zero controls) — `a.` CSS prefix keeps hover out of guest view.
- Shared page renders title caption, no `/api/documents/content` fetch (user-gated → would 401; alt contract holds with title).
- No `app/` code changes (server work was tasks 01–05); no live infra touched.
**Next pending task:** `07_answer_image_tests.md` — mock-LLM `show_image` trigger + isolated E2E `test_answer_images.py`.
@@ -0,0 +1,109 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 88%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 262 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 374 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 106 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 48 0 100%
app/schemas.py 400 0 100%
--------------------------------------------------
TOTAL 4669 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,13 @@
All gates are green. Final report:
**Task 07 complete — mock `show_image` trigger + isolated E2E suite**
- `tests/e2e/mock_llm.py`: two new documented triggers — `SHOW_IMAGE_TRIGGER` ("show me the picture of", clause-gated: scripted `show_image` only, zero `read`s, follow-up keyed on the confirming line, final answer = sentence + `<documents>` tail echo) and `DIAGRAM_TRIGGER` ("which diagram is in your notes", zero tool calls, quotes the seeded image doc's block tail; works on toggle-off apps too). Image-doc pick imports `IMAGE_MIMES` (no drift).
- `tests/e2e/test_answer_images.py` (new, 5 tests, ports 8162/8163): App A (feature ON) — feature flow (figure src/lazy/position A4, answer text, auto-cited chip + figure, no Nearby-docs repeat, wire pin: 4 tools offered, exactly one `show_image` executed, saved record carries `answer_image`), reload restore + shared-page render (fresh anonymous context), bug-fix-only zero-read chip flow; App B (default off) — 3 base tools only, no figure, no `answer_image` key in the record, bug-fix trigger still chips.
- **Defect fixed from task 06's work:** the document image bytes route was `require_user` (phase 122/79 posture) while task 06's shared-page renderer assumed it public — guest shared chats 401'd and the figure collapsed, contradicting locked A4. Made the route public (phase-123 chat-image precedent: unguessable uuid4 = credential; document TEXT stays gated) in `app/api/docs.py` and rewrote the one auth pin in `tests/integration/test_docs_api.py`.
**Gates:** `uv run pytest tests/e2e/test_answer_images.py -v --no-cov` → 5 passed (twice — re-runnable; suite cleans its `saved_chats` rows); spot-checks `test_image_documents.py` 4 passed, `test_agent_document_tools.py` 4 passed (unmodified); `uv run pytest` → 2848 passed; `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 99% (>90%); `uv run ruff check .` clean; `uv run pyright` → 0 errors.
**Decisions:** App B pins `BOR_ANSWER_IMAGES=false` explicitly (process env beats an operator's `.env` — phase-122 default-app convention; unset is the same `False`). No live-infrastructure changes. No commits made (harness owns the phase commit).
Next pending task: none in this phase — task 07 is the last of 124; the pipeline gate should move the phase dir to `complete/`.
@@ -0,0 +1,109 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 88%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 262 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 374 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 106 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 48 0 100%
app/schemas.py 400 0 100%
--------------------------------------------------
TOTAL 4669 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,13 @@
All gates green. Clean final report:
**Task 07 complete — mock-LLM `show_image` trigger + isolated E2E suite**
- `tests/e2e/mock_llm.py`: two new documented triggers — `SHOW_IMAGE_TRIGGER` ("show me the picture of", clause-gated on the `show_image` prompt clause: streams only a `show_image` tool call on the seeded image-extension `<documents>` block, zero `read`s; follow-up keyed on the "Image shown to the user" line streams the final answer + the `<documents>` tail echo) and `DIAGRAM_TRIGGER` ("which diagram is in your notes": zero tool calls, quotes the seeded image doc's block tail; fires on toggle-off apps too). Image-doc pick imports `IMAGE_MIMES` (drift-proof); both phrases verified collision-free.
- `tests/e2e/test_answer_images.py` (new, 5 tests, ports 8162/8163): App A (feature ON) — feature flow (figure src/`loading="lazy"`/A4 position, answer text, auto-cited chip + figure, no Nearby-docs repeat, wire pin: 4 tools offered, exactly one `show_image` executed, saved record carries `answer_image`), reload restore + shared-page render in a fresh anonymous context, bug-fix-only zero-read chip flow; App B (default off) — offer list is exactly 3 base tools, no figure, no `answer_image` key in the record, bug-fix trigger still chips.
- **Defect fixed (prior task 06 premise):** the image bytes route was `require_user` (phase 122/79) so guest shared chats 401'd and the figure collapsed, contradicting locked A4 — made the route public (phase-123 chat-image precedent: unguessable uuid4 = credential; document text stays gated) in `app/api/docs.py` and rewrote the single auth pin in `tests/integration/test_docs_api.py`.
**Gates:** `uv run pytest tests/e2e/test_answer_images.py -v --no-cov` → 5 passed (in isolation, re-run confirmed); `tests/e2e/test_image_documents.py` + `test_agent_document_tools.py` → 8 passed unmodified; `uv run pytest` → 2848 passed; `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%); `uv run ruff check .` clean; `uv run pyright` → 0 errors.
**Decisions:** App B pins `BOR_ANSWER_IMAGES=false` explicitly (process env beats an operator's `.env`; unset = the same `False`). Suite cleans its own `saved_chats` rows (shared-DB re-runnability). No live-infrastructure changes; no commits (harness owns the phase commit).
**Next pending task:** none in this phase — task 07 is the last of 124; the pipeline gate should move the phase dir to `complete/`.
@@ -0,0 +1,109 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 88%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 262 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 374 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 106 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 48 0 100%
app/schemas.py 400 0 100%
--------------------------------------------------
TOTAL 4669 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+6
View File
@@ -126,6 +126,12 @@ BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume,
# BOR_IMAGE_DIR=~/bor-sources/images # persistent home for the served image bytes
# (uploads are replaced, checkouts re-cloned)
# Phase 124 (answer images): off by default; enable alongside
# BOR_IMAGES to let the model show a supporting image in its answer
# (independent of it — BOR_IMAGES indexes image docs, this one shows
# them in answers).
# BOR_ANSWER_IMAGES=0
# --- Chat image questions (phase 123: attach an image to a question) ---
# Gated by the SAME BOR_IMAGES toggle above (enable only when the chat
# model supports vision). The question image is stored on the server —
+82 -8
View File
@@ -462,7 +462,14 @@ def plan_turn(
fts_hits,
False,
build_high_prompt(
suggested, notes=steering, kb_overview=kb_text, folder_lines=folder_lines
suggested,
notes=steering,
kb_overview=kb_text,
folder_lines=folder_lines,
# Phase 124 (task 03, LOCKED A5): the grounded HIGH prompt
# carries the show_image clause exactly when the
# BOR_ANSWER_IMAGES toggle is on (off = byte-identical).
show_image=settings.answer_images,
),
suggested,
related_docs,
@@ -486,6 +493,51 @@ def plan_turn(
)
def merge_cited_docs(
read_docs: list[Document],
suggested_docs: list[Document],
deflected: bool,
) -> list[Document]:
"""Compose the citation tier (the done frame's ``sources``, PLAN §9).
Phase 119 (LOCKED A1): on grounded turns the agent's READ DOCS —
deduped by ``(source, path)``, read order; on deflected turns
nothing (a weak-hit answer is not a citation). Phase 124 (TODO L3)
adds the rule's single documented exception: a suggested-tier
IMAGE doc's seed block IS its entire content (content == summary
== the vision description, phase 122 task 03) — a ``read`` would
add only the marker line, so a real model never performs one and
the doc's chip never appeared. "Suggested" is therefore equivalent
to "read" for it, and it is auto-cited — in SUGGESTION order AFTER
the read docs, deduped against them (an image doc the agent also
explicitly read appears once, at its read position). Non-image
suggested docs are never auto-cited: the phase-119 read-docs-only
rule stays intact for text docs (a zero-read text turn still chips
nothing).
"""
if deflected:
return []
cited: list[Document] = []
seen: set[tuple[str, str]] = set()
for doc in read_docs:
key = (doc.source, doc.path)
if key not in seen:
seen.add(key)
cited.append(doc)
# Phase 124 (TODO L3): the suggested-tier IMAGE docs join the
# citation tier — "suggested" is equivalent to "read" for them
# (their seed block is their entire content), so a zero-read
# grounded turn chips them too. Suggestion order, deduped.
for doc in suggested_docs:
if not doc.is_image:
continue
key = (doc.source, doc.path)
if key not in seen:
seen.add(key)
cited.append(doc)
return cited
@router.post("/chat")
async def chat(
request: ChatRequest,
@@ -1035,15 +1087,21 @@ async def chat(
# phase-119 cite discipline, A5) — and [] on
# deflected ones (a deflected answer cites nothing;
# the weak hits stay in the durable record).
# Phase 124 (TODO L3) gives that rule its single
# documented exception — the suggested-tier IMAGE
# docs (``merge_cited_docs``, below): a seed block
# IS an image doc's entire content, so "suggested"
# is equivalent to "read" for it and it is
# auto-cited in suggestion order AFTER the read
# docs, deduped (a read image doc stays at its read
# position, once). Text docs keep the read-docs-only
# rule — a zero-read text turn still chips nothing.
# A cancelled turn (the generator closed by the
# consumer) never reaches this step — no query_log row.
cited_docs: list[Document] = []
cited_seen: set[tuple[str, str]] = set()
for doc in holder.read_docs:
key = (doc.source, doc.path)
if key not in cited_seen:
cited_seen.add(key)
cited_docs.append(doc)
cited_docs = merge_cited_docs(
holder.read_docs, plan.suggested_docs, plan.deflected
)
cited_seen = {(d.source, d.path) for d in cited_docs}
record_docs: list[Document] = []
seen: set[tuple[str, str]] = set()
for doc in [*plan.suggested_docs, *plan.related_docs, *holder.read_docs]:
@@ -1124,12 +1182,28 @@ async def chat(
for d in plan.related_docs
if (d.source, d.path) not in cited_seen
]
# Phase 124 (task 04): the answer's first-class image —
# the shown document's ref (the LLM's ``show_image``
# pick, task 02's ``holder.shown_images``), built by the
# SAME shared ref builder (source/path/title +
# ``image_url``). ``None`` on every other turn — a
# deflected turn runs no agent (``shown_images`` is
# always empty) and an image-less grounded turn never
# called the tool — and ``None`` is DROPPED on
# serialization, so those frames stay byte-identical to
# pre-phase (no key, no null).
answer_ref = (
source_ref_with_image(holder.shown_images[0])
if holder.shown_images
else None
)
yield sse_event(
ChatDoneEvent(
deflected=plan.deflected,
sources=cited_refs,
related=related_refs,
suggestions=plan.suggestions,
answer_image=answer_ref,
).model_dump()
)
finally:
+22 -10
View File
@@ -28,12 +28,17 @@ BYTES route: the persistent copy behind an ``is_image`` document's
``image_path``, served with the extension's ``Content-Type`` (the
``app.rag.summarizer.IMAGE_MIMES`` map — one map, one truth) and a
``Cache-Control: private, max-age=3600`` header (the bytes are
content-hashed — long enough, bustable by re-upload). User-gated like
the content endpoint (phase 79 — the ONLY anonymous surface is the
shared chats): a missing doc, a non-image doc, a doc whose
content-hashed — long enough, bustable by re-upload). PUBLIC (phase
124 — the phase-123 chat-image precedent: the image is part of a
shared chat's rendered content, and the shared chat — the app's ONLY
anonymous surface — must render the answer figure faithfully for
guests; the unguessable ``uuid4`` document id IS the credential, no
enumeration value): a missing doc, a non-image doc, a doc whose
``image_path`` is NULL, or a row whose copy was lost all map to 404
``document not found`` (the router's unknown-document shape —
traversal/UUID-guessing has no filesystem surface to hit).
traversal/UUID-guessing has no filesystem surface to hit). The
document's TEXT (the content endpoint) stays user-gated — only the
bytes are public.
PATCH /api/folders/summary — the admin folder-description editor
(phase 97, task 03): update / create / clear a stored
@@ -212,16 +217,23 @@ def get_document_content(
def get_document_image(
doc_id: str,
db: Session = Depends(get_db), # noqa: B008
_user: None = Depends(require_user), # noqa: B008 # phase 79 posture (see docstring)
) -> FileResponse:
"""The phase-122 (task 04) image BYTES route — the persistent copy
behind an ``is_image`` document's ``image_path``.
Same auth posture as the document content endpoint (phase 79 —
``require_user``: admin OR live token holder; the ONLY anonymous
surface is the shared chats — the image is part of a document's
content, so it travels under the same gate): anonymous callers get
401 ``authentication required`` before any row is read.
PUBLIC (phase 124, LOCKED A4 — "visible … on the shared page"): a
guest opening a shared chat sees the brain answer's image figure,
so the bytes must serve ANONYMOUSLY. The trust model is the
phase-123 chat-image precedent (the question attachment travels in
the SAME shared content): the document id is an unguessable
server-minted ``uuid4`` — IT IS the credential, with no
enumeration value — exactly like the saved-chat id (phase 55) and
the chat-image filename (phase 123). (The phase-122/79
``require_user`` posture was set before the image became part of a
shared chat's rendered content; the shared page's figure — built
on this route per the phase-124 design — 401'd and collapsed for
every guest, contradicting the owner-locked A4. The document's
TEXT stays user-gated: only the bytes are public.)
404 ``document not found`` (the router's unknown-document shape) in
every non-servable case — a missing id (an unparseable string maps
+17
View File
@@ -412,6 +412,23 @@ class Settings(BaseSettings):
#: (the ``upload_max_mb`` pattern).
chat_image_max_mb: int = 10
# --- Answer images (phase 124: the LLM shows a supporting image in the answer) ---
#: Feature toggle for the answer-image capability (phase 124,
#: ``BOR_ANSWER_IMAGES``; ``0``/``false`` = off — the DEFAULT,
#: LOCKED A2). The single knob that turns the whole feature on or
#: off. Gates the ANSWER side ONLY: while on, the grounded agent is
#: offered the ``show_image`` tool, the HIGH prompt carries its
#: teaching clause, and a shown doc rides the done frame's
#: ``answer_image`` key (and the saved record's). While off,
#: behavior is byte-identical everywhere — no ``show_image`` tool
#: offered, no prompt clause, no ``answer_image`` key in any frame
#: or record. INDEPENDENT of ``images``/``BOR_IMAGES`` (LOCKED A2):
#: that toggle indexes image documents, this one decides whether
#: the model may show one in an answer — ``on`` with ``images`` off
#: is a coherent (if image-less) state: the tool is offered, finds
#: no image docs, and always refuses.
answer_images: bool = False
# --- Docs push (phase 59: save a chat answer as documentation) ---
#: The git repo a saved chat answer is committed to (phase 59, D3):
#: **any** remote — a URL (``https://``, ``ssh://``, ``git@``) or a
+143 -2
View File
@@ -390,6 +390,71 @@ AGENT_TOOLS: list[dict[str, Any]] = [
},
]
#: The 4th agent tool (phase 124, task 02; LOCKED A3/A5) — offered
#: ONLY when ``settings.answer_images`` (``BOR_ANSWER_IMAGES``) is true:
#: :func:`agent_tools` composes the offer list (the three base tools
#: stay :data:`AGENT_TOOLS`, byte-identical). The description IS the
#: relevance gate (A5 — "we don't want to just show every image that
#: seems relevant"): call it only when the image itself contains the
#: answer, at most ONE image per answer, the text answer is always
#: given in full (the image supplements, never replaces). Same OpenAI
#: function shape as the other three (one required string arg ``path``,
#: the combined ``source/path`` identity — the bare-path-will-not-
#: resolve teaching mirrored from ``read``).
SHOW_IMAGE_TOOL: dict[str, Any] = {
"type": "function",
"function": {
"name": "show_image",
"description": (
"Show a standalone image document to the user as part of "
"your answer. Call it only when the image itself contains "
"the answer — a diagram, screenshot, or photo of the thing "
"the user asked about — never for a merely related image. "
"Show at most one image per answer. The image supplements "
"your text answer — answer fully in text either way. Its "
"`path` is the combined `source/path` string, exactly as "
"shown in the `ls` output or the `<documents>` blocks — a "
"bare document path will not resolve. Call one tool at a "
"time — wait for this result before your next call."
),
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": (
"The image document to show, as the combined "
"`source/path` string exactly as shown in the "
"`ls` output (e.g. "
"'homelab/docs/architecture.png'). A bare "
"document path (without the source name) will "
"not resolve. Show at most one image per "
"answer — a second call is refused."
),
}
},
"required": ["path"],
},
},
}
def agent_tools(settings: Settings) -> list[dict[str, Any]]:
"""The grounded turn's tool offer list (phase 124, task 02).
The three base tools (:data:`AGENT_TOOLS`, byte-identical) plus
:data:`SHOW_IMAGE_TOOL` when ``settings.answer_images``
(``BOR_ANSWER_IMAGES``) is true — otherwise exactly the three base
tools (the pre-phase-124 offer, byte-identical: no 4th tool in the
wire ``tools`` array). A FRESH list on every call — callers never
mutate the result or the constants (``run_agent`` passes it straight
to the LLM request).
"""
tools: list[dict[str, Any]] = list(AGENT_TOOLS)
if settings.answer_images:
tools.append(SHOW_IMAGE_TOOL)
return tools
#: Tool refusal texts (phase 37): rejected calls count in nothing
#: (``holder.tool_calls`` tracks executed calls); the round cap bounds
#: their pathological repetition (phase 45). The in-context line is a
@@ -407,6 +472,24 @@ ALREADY_IN_CONTEXT = (
UNKNOWN_TOOL = "Unknown tool."
MISSING_READ_ARGS = "read requires a string argument 'path'."
MISSING_SEARCH_ARGS = "grep requires a string argument 'pattern'."
MISSING_SHOW_ARGS = "show_image requires a string argument 'path'."
#: The ``show_image`` refusals (phase 124, task 02): the same contract
#: as every other refusal — counts in nothing, consumes a round,
#: changes no context. ``NOT_AN_IMAGE_DOC``: one ``{arg}`` field, the
#: argument echoed as passed — a text doc is refused, never silently
#: converted to a read (LOCKED A5). ``ALREADY_SHOWING``: the LOCKED A3
#: one-image-per-answer gate, enforced SERVER-side (the model is taught
#: it in the tool description + the prompt clause; the handler is the
#: actual gate).
NOT_AN_IMAGE_DOC = (
"'{arg}' is not an image document — only standalone image "
"documents can be shown as answers."
)
ALREADY_SHOWING = (
"Already showing an image this answer — do not show another one. "
"Answer from what you have."
)
#: The read-truncation notice (phase 95, task 01) — appended, after the
#: shared :data:`TRUNCATION_MARKER`, to a ``read`` result whose document
@@ -1166,12 +1249,19 @@ class AgentHolder:
as today — this list only carries the truncation signal the agent
loop turns into :class:`app.rag.llm.ToolResultPiece` values (task 02
surfaces them to the UI).
``shown_images`` (phase 124, task 02): the documents ``show_image``
showed, in call order — at most one in practice (LOCKED A3: a
second call is refused with :data:`ALREADY_SHOWING`, which appends
nothing). Read by ``app.api.chat`` after the stream for the done
frame's ``answer_image`` field (task 04) — the ``read_docs``
precedent (an API-layer read-after-stream signal).
"""
read_docs: list[Document] = field(default_factory=list)
tool_calls: int = 0
scaffold_stripped: int = 0
read_truncations: list[tuple[str, int, int]] = field(default_factory=list)
shown_images: list[Document] = field(default_factory=list)
def _execute_tool(
@@ -1327,6 +1417,51 @@ def _execute_tool(
f"{marker}"
f"{doc.content}"
)
if call.name == "show_image":
# Phase 124 (task 02; LOCKED A3/A5): the 4th agent tool — the
# model shows a standalone image document as part of its
# answer. Offered only when ``settings.answer_images``
# (``agent_tools``); the handler enforces the contract
# server-side — image docs only, ONE image per answer. Every
# refusal below counts in nothing and consumes a round, like
# every other refusal.
raw_path = call.arguments.get("path")
arg = raw_path.strip() if isinstance(raw_path, str) else ""
if not arg:
return MISSING_SHOW_ARGS
doc, _source, _path = _resolve_path(db, arg)
if doc is None:
# The same phase-72 "did you mean …?" teaching as the read
# branch (a refusal — not counted, no context).
return _no_document_refusal(db, arg)
if not doc.is_image:
# LOCKED A5: a text doc is refused, never silently
# converted to a read — the tool shows IMAGE documents
# only.
return NOT_AN_IMAGE_DOC.format(arg=arg)
if holder.shown_images:
# LOCKED A3: one image per answer — server-enforced, not
# just taught.
return ALREADY_SHOWING
holder.shown_images.append(doc)
holder.tool_calls += 1
# The confirming result: a NEW first line (absent from ``read``
# — the model must know the show registered) followed by the
# exact document block ``read`` returns for an image doc at-or-
# under-cap (the ``Document …`` header + phase-106 date line +
# :data:`IMAGE_DOC_MARKER` — the header contract the E2E mock
# pins on ``read`` results is deliberately reused here). An
# image doc's content is the summary-capped vision description
# — short by construction (phase 122, task 03), so NO
# truncation machinery; the plain block stays correct even if
# it ever exceeded the cap.
return (
"Image shown to the user — it is now part of your answer.\n"
f"Document {doc.source}/{doc.path}:\n"
f"date: {doc.created_at:%Y-%m-%d}\n"
f"{IMAGE_DOC_MARKER}\n"
f"{doc.content}"
)
if call.name == "grep":
raw_pattern = call.arguments.get("pattern")
pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
@@ -1485,9 +1620,15 @@ async def run_agent(
# Phase 45: no per-tool budgets — the tools stay offered for the
# whole turn, bounded by the round cap. ``0`` is the no-tools kill
# switch: exactly one request with ``tools=None`` (the pre-phase-37
# path).
# path). Phase 124 (task 02): the offer list is :func:`agent_tools`
# — the three base tools (byte-identical) plus ``show_image`` only
# when ``settings.answer_images`` (``BOR_ANSWER_IMAGES``) is on;
# off (the default) the offer is element-for-element the
# pre-phase-124 list.
max_rounds = settings.agent_max_rounds
tools: list[dict[str, Any]] | None = AGENT_TOOLS if max_rounds > 0 else None
tools: list[dict[str, Any]] | None = (
agent_tools(settings) if max_rounds > 0 else None
)
rounds = 0
while True:
calls: list[ToolCallPiece] = []
+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(
+69 -10
View File
@@ -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
+93
View File
@@ -1726,6 +1726,81 @@ function fetchContentSummary(source, path) {
.then((doc) => (doc && typeof doc.summary === "string" ? doc.summary : null));
}
/* Phase 124 (task 06, TODO L3): the LLM's ANSWER IMAGE — the brain
* answer's first-class figure (the `done` frame's `answer_image` ref,
* task 04). A4: it sits in the .msg-body BELOW the answer text and
* ABOVE the source-chip row — the callers append it right BEFORE
* appendSources creates the chip row, so it lands in between. The
* text bubble is untouched: the figure is ADDITIVE ("summaries
* should still be provided").
*
* The visual sibling of appendSourceImageFigure (the compact chip
* figure) but a different surface: the answer figure is the answer's
* own image, not a chip accessory — .answer-image scales it up (the
* .source-image language at answer size, styles.css). Same WCAG alt
* contract as the chip figure: alt + the VISIBLE caption = the
* document's SUMMARY (the vision description); the frame carries no
* summary (the ref is source/path/title/image_url), so the figure
* fetches the content endpoint the chip's modal already uses and
* swaps the summary in; until it settles — and when it fails — the
* title stands in (a caption is ALWAYS visible). A FAILED IMAGE LOAD
* REMOVES the whole figure (the collapse rule — the answer figure has
* no chip of its own to fall back to; the chip row below still cites
* the doc, the phase-124 auto-cite): an answer whose image bytes are
* gone renders as a normal answer, never a broken-image icon.
* Navigation reuses the chip affordance: the same documentUrl href
* (the /document.html escape hatch) + left-click → same-page modal
* (phase 26, the appendSources/appendSourceImageFigure pattern). */
function appendAnswerImage(wrap, ref) {
if (
!ref ||
typeof ref.source !== "string" ||
typeof ref.path !== "string" ||
typeof ref.image_url !== "string" ||
!ref.image_url
)
return; // no ref (or a malformed one) → no figure DOM at all
const body = wrap.querySelector(".msg-body");
const label = `${ref.source}/${ref.path}`;
const fig = document.createElement("a");
fig.className = "answer-image";
fig.href = documentUrl(ref.source, ref.path, "/"); // back → the chat page
fig.title = label; // full path as the native tooltip (chip pattern)
fig.setAttribute("aria-label", label); // the accessible name is the full path
fig.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(ref.source, ref.path, fig);
});
const img = document.createElement("img");
img.className = "answer-image-img";
img.src = ref.image_url; // the public bytes route (task 04's ref)
img.loading = "lazy"; // A4: lazy-loaded
img.alt = ref.title || label; // the summary arrives via the fetch below
img.addEventListener("error", () => {
// Collapse to NOTHING (there is no chip inside the figure to fall
// back to) — the answer renders normally, the chip row below
// still cites the doc.
fig.remove();
});
const caption = document.createElement("span");
caption.className = "answer-image-caption";
caption.textContent = ref.title || label; // visible caption: the title first…
fig.append(img, caption);
body.appendChild(fig);
// …and the document's summary once the content fetch settles (the
// chip figure's SAME pattern — fetchContentSummary fails soft to
// null: the title stays).
fetchContentSummary(ref.source, ref.path).then((summary) => {
if (!fig.isConnected) return; // collapsed (img error) or bubble cleared
if (summary && summary.trim() !== "") {
img.alt = summary;
caption.textContent = summary;
caption.title = summary; // full text (the caption is line-clamped)
}
});
}
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase 04,
shared component + one-tap submit, phase 05). The group is accessible
(role=list + aria-label) and wraps cleanly at every width. */
@@ -1906,6 +1981,11 @@ function renderStoredMessage(m) {
wrap.classList.add("is-deflected");
appendMaybeTry(wrap, m.suggestions);
}
// Phase 124 (task 06, TODO L3): the answer image restores with the
// bubble — the SAME helper + the SAME A4 position as the live done
// path (below the text, above the chip row). A record without the
// key renders byte-identically (pre-phase chats restore unchanged).
if (m.answer_image) appendAnswerImage(wrap, m.answer_image);
appendSources(wrap, m.sources);
// Phase 120: a failed turn (m.failed) is a note, not an answer — no
// Tune (the "note, not an answer" scope the m.stopped exclusion
@@ -2985,6 +3065,13 @@ async function runTurn(text, { reask = false, image = null } = {}) {
wrap.classList.add("is-deflected");
appendMaybeTry(wrap, ev.suggestions);
}
// Phase 124 (task 06, TODO L3): the LLM's answer image — the
// answer's first-class figure (A4): appended to .msg-body
// right BEFORE the chip row, so it lands below the answer
// text and above the sources. The text is untouched (the
// figure is additive — "summaries should still be provided");
// a frame without it renders byte-identically (no figure DOM).
if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);
appendSources(wrap, ev.sources);
// Phase 113 (task 02): the cited tier stays the citation
// surface (the chips above); the related tier (scored docs
@@ -3019,6 +3106,12 @@ async function runTurn(text, { reask = false, image = null } = {}) {
tools: toolAcc.length ? toolAcc : undefined,
deflected: !!ev.deflected,
sources: ev.sources,
// Phase 124 (task 06, TODO L3): the answer image persists
// with the turn (undefined drops the key from the JSON — the
// house optional-meta pattern, the `related` line below is
// the precedent): an image-less / pre-phase turn stores a
// byte-identical record.
answer_image: ev.answer_image || undefined,
// Phase 113 (task 02): the related tier persists with the
// turn (undefined drops the key from the JSON — the house
// optional-meta pattern), so the restore path re-renders the
+54 -3
View File
@@ -349,6 +349,50 @@ function addBubbleImage(wrap, src, alt) {
bubble.prepend(img);
}
/* The brain answer's image (phase 124, task 06, TODO L3) — the local
* copy of the chat page's appendAnswerImage (the per-page duplication
* house style: this file keeps its own small copies of the chat
* page's message-fragment builders). NON-INTERACTIVE like every other
* shared-page fragment (owner-locked zero controls: no link, no
* modal, no click handler — a plain <figure>, unlike the chat page's
* anchor). The bytes route is PUBLIC (the token is the shared chat's
* credential, like the phase-123 user-image contract), so the img
* loads for guests exactly as it does for the owner. The visible
* caption keeps the document's TITLE: the content endpoint the chat
* page fetches for the summary is user-gated (a guest fetch would
* just 401), and a caption is ALWAYS visible either way (the WCAG
* alt contract holds with the title standing in). A failed load
* REMOVES the figure (the collapse rule — never a broken-image
* icon). */
function addAnswerImage(wrap, ref) {
if (
!ref ||
typeof ref.source !== "string" ||
typeof ref.path !== "string" ||
typeof ref.image_url !== "string" ||
!ref.image_url
) {
return; // no ref (or a malformed one) → no figure DOM at all
}
const body = wrap?.querySelector?.(".msg-body");
if (!body) return;
const title =
typeof ref.title === "string" && ref.title ? ref.title : "image";
const fig = document.createElement("figure");
fig.className = "answer-image";
const img = document.createElement("img");
img.className = "answer-image-img";
img.src = ref.image_url; // the public bytes route
img.loading = "lazy";
img.alt = title;
img.addEventListener("error", () => fig.remove()); // collapse: never a broken icon
const caption = document.createElement("span");
caption.className = "answer-image-caption";
caption.textContent = title; // the visible caption (always)
fig.append(img, caption);
body.appendChild(fig);
}
/* One stored record through the SAME .msg structure the chat page
* uses (pixel-parity with the chat page's restore path): user → the
* .msg.user bubble (with the question's attached image when the
@@ -356,9 +400,11 @@ function addBubbleImage(wrap, src, alt) {
* .msg.brain bubble with the optional
* thinking block (restored COLLAPSED — phase 17), the tool lines,
* the deflection treatment + the plain-text "Maybe try" chips, the
* plain-text source chips, and the stopped note. NO interactive
* markup is ever created here — no buttons, no forms, no links, no
* click handlers (owner-locked: zero controls). Markdown goes
* brain answer's image figure (phase 124, task 06 — a plain
* non-interactive <figure>), the plain-text source chips, and the
* stopped note. NO interactive markup is ever created here — no
* buttons, no forms, no links, no click handlers (owner-locked:
* zero controls). Markdown goes
* through the global escape-first renderMarkdown (markdown.js): the
* stored payloads are raw text, so the renderer's XSS safety applies
* unchanged. */
@@ -385,6 +431,11 @@ function renderSharedMessage(m) {
wrap.classList.add("is-deflected");
addMaybeTry(wrap, m.suggestions);
}
// Phase 124 (task 06, TODO L3): the answer image renders with the
// brain bubble — the SAME A4 position as the chat page's render
// (below the text, above the chip row). A record without the key
// renders byte-identically (no figure DOM).
addAnswerImage(wrap, m.answer_image);
addSources(wrap, m.sources);
if (m.stopped) addStoppedNote(wrap);
}
+47
View File
@@ -814,6 +814,53 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
}
.source-image:hover { text-decoration: underline; } /* flat — the caption underlines, the chip pattern */
/* Phase 124 (task 06, TODO L3): the LLM's ANSWER IMAGE — the brain
answer's first-class figure (A4: below the text, above the chips).
The .source-image language at ANSWER scale — the figure is the
answer's own image, not a chip accessory: max-width 100% of the
.msg-body column, a 320px height cap (the 46rem chat column — a
full-height image would blow the turn's height), contain on the
theme's surface with the bordered-image treatment
(.source-image-img's border/background/radius), the visible
caption in the AA-safe muted ink (the same --ink-soft the chip
caption uses — the row's page background, 8.6:1). Token-only (the
zero-literal invariant — the monochrome themes carry it). The
hover underline applies to the chat page's ANCHOR figure only (the
shared page's figure is a plain <figure> — zero controls, the
element-type scope keeps it out). */
.answer-image {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
max-width: 100%;
min-width: 0;
}
.answer-image-img {
display: block;
max-width: 100%;
max-height: 320px; /* the one free number (task 06): the answer scale */
width: auto;
height: auto;
object-fit: contain;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.answer-image-caption {
max-width: 100%;
color: var(--ink-soft); /* 8.6:1 on --bg (AA) — the row's page background */
font-size: 0.75rem;
line-height: 1.25;
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 3; /* the summary can be long; the alt + title carry the full text */
-webkit-box-orient: vertical;
overflow: hidden;
}
a.answer-image { text-decoration: none; }
a.answer-image:hover { text-decoration: underline; } /* flat — the caption underlines, the chip pattern */
/* Phase 113 (task 02): the related-docs row — the SECONDARY tier of
scored docs (phase 113 task 01's usefulness bar demotes the
sub-floor hits out of the citation surface; on a deflected turn the
+7
View File
@@ -78,6 +78,13 @@ os.environ["BOR_IMAGES"] = str(_Settings.model_fields["images"].default)
os.environ["BOR_IMAGE_EXTENSIONS"] = str(_Settings.model_fields["image_extensions"].default)
os.environ["BOR_IMAGE_DIR"] = str(_Settings.model_fields["image_dir"].default)
# Phase 124 (task 04): the same leak class for the answer-image toggle
# — an operator's local ``.env`` may legitimately carry
# ``BOR_ANSWER_IMAGES`` (the dev environment runs the feature on), and
# the byte-identical-off pins (the ``show_image`` offer absent, the
# ``answer_image`` done-frame key absent) must see the code default.
os.environ["BOR_ANSWER_IMAGES"] = str(_Settings.model_fields["answer_images"].default)
from app.db import SessionLocal, db_available # noqa: E402
from app.main import app as fastapi_app # noqa: E402
+223
View File
@@ -467,6 +467,56 @@ Implements just enough of the aipi surface:
the trigger phrase is disjoint from every other trigger's; verified
2026-09-16: no existing E2E question or fixture file contains the
phrase, so every other suite is unaffected.
- user message containing ``show me the picture of``
(``SHOW_IMAGE_TRIGGER``, phase 124 task 07 — the image-chips-and-
answers suite ``tests/e2e/test_answer_images.py``) **and** the
system prompt contains ``show_image`` (the tool clause's presence
— the ``BOR_ANSWER_IMAGES`` toggle's server-side marker: the
clause rides inside the HIGH prompt's ``<tools>`` section ONLY
when the toggle is on, so on a default-OFF app the trigger NEVER
fires and the mock behaves exactly as pre-phase for every other
suite) -> the deterministic SHOW_IMAGE flow, discriminated
statelessly from the tool results (streaming only): the mock
picks the IMAGE document seeded in the ``<documents>`` blocks —
the block whose ``path`` attribute ends in an image extension
(``app.rag.summarizer.IMAGE_MIMES``' family — the suite's
single-PNG fixture guarantees exactly one such block):
* request 1 (``tools`` offered, no confirmed show yet): stream
ONLY ``tool_calls`` deltas — ``show_image`` with
``{"path": <combined source/path>}`` (synthetic id
``call_show``, ``finish_reason: "tool_calls"``, no content) —
NO ``read`` (the ZERO-READ turn: the image doc's seed block
already carries its entire content, so a real model never
reads it — the zero-read shape is what makes the bug-fix's
chip assertion meaningful);
* the follow-up request (a ``tool``-role result carrying the
agent's confirming line ``Image shown to the user`` —
``_SHOW_CONFIRM_MARKER``) in the messages: the deterministic
FINAL answer — a short sentence ending with the ``<documents>``
block's tail echo (the phase-24 END_OF_NOTES echo convention —
it proves the seed was there), NO further tool calls.
Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger
phrase — the phase-71/72/94/118 ordering convention); verified
2026-09-26: no existing E2E question or fixture file contains the
phrase, so every other suite is unaffected.
- user message containing ``which diagram is in your notes``
(``DIAGRAM_TRIGGER``, phase 124 task 07 — the BUG-FIX-ONLY
trigger, same suite) -> the deterministic ZERO-READ answer: the
mock answers straight from the seed with NO tool calls at all
(no ``show_image``, no ``read`` — unlike the ``SHOW_IMAGE_
TRIGGER`` flow it needs NO clause, so it also fires on a default-
OFF app: the chips fix is not gated by ``BOR_ANSWER_IMAGES``,
it belongs to the ``BOR_IMAGES``-indexed docs) and QUOTES THE
TAIL of the seeded image doc's ``<document>`` block (the same
image-doc pick as above): proving the description WAS in the
prompt (for an image doc the seed block IS its entire content)
and that its chip appears with NO tool calls at all (the phase-
124 auto-cite — the fix for the owner's report). Checked BEFORE
the ``DEFLECT_MODE`` branch (the marker lives in the user message
— the ``TABLE_TRIGGER``/``HISTORY_TRIGGER`` convention), so a
marker question always gets the echo whatever the honesty gate
says. Verified 2026-09-26: no existing E2E question or fixture
file contains the phrase, so every other suite is unaffected.
Failure injection (phase 67, LLM retry, TODO.md L3) — deterministic
dead-endpoint behavior for the retry E2E suite (``tests/e2e/
@@ -580,6 +630,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
from app.rag.agent import ALREADY_IN_CONTEXT, CORRECTION_INSTRUCTION
from app.rag.folder_summaries import FOLDER_HEADER_PREFIX # phase 94: the mock's key
from app.rag.summarizer import IMAGE_MIMES # phase 124: the image-doc extension family
app = FastAPI()
@@ -1756,6 +1807,117 @@ _SUMMARY_SEED_READ_CALL_RE = re.compile(
)
# ---------------------------------------------------------------------------
# Phase 124 (task 07, image chips and answers, TODO L3): the scripted
# SHOW_IMAGE flow + the zero-read bug-fix trigger — see the module
# docstring
# ---------------------------------------------------------------------------
#: A user message containing this substring (case-insensitive) —
#: combined with the ``show_image`` prompt clause in the system
#: prompt (the ``BOR_ANSWER_IMAGES`` toggle's server-side marker —
#: the clause rides inside the HIGH prompt's ``<tools>`` section ONLY
#: when the toggle is on) — drives the deterministic SHOW_IMAGE flow
#: (the mock shows the seeded image document and answers straight
#: from the seed — ZERO ``read`` calls). On a default-OFF app the
#: clause is absent, so the trigger NEVER fires there and the mock
#: behaves exactly as pre-phase for every other suite. Disjoint from
#: every other trigger phrase — the phase-71/72/94/118 ordering
#: convention; verified 2026-09-26: no existing E2E question or
#: fixture file contains the phrase, so every other suite is
#: unaffected.
SHOW_IMAGE_TRIGGER = "show me the picture of"
#: The phase-124 BUG-FIX-ONLY trigger (case-insensitive): the mock
#: answers straight from the seed with ZERO tool calls and QUOTES THE
#: TAIL of the seeded image doc's ``<document>`` block — proving the
#: description was in the prompt (for an image doc the seed block IS
#: its entire content) and that its chip appears with NO tool calls
#: at all (the phase-124 auto-cite). Unlike ``SHOW_IMAGE_TRIGGER``
#: it needs NO clause — the chips fix is not gated by
#: ``BOR_ANSWER_IMAGES`` (it belongs to the ``BOR_IMAGES``-indexed
#: docs), so it also fires on a default-OFF app. Checked BEFORE the
#: ``DEFLECT_MODE`` branch in :func:`compose_answer` (the marker
#: lives in the user message — the TABLE/HISTORY convention), so a
#: marker question always gets the echo whatever the honesty gate
#: says.
DIAGRAM_TRIGGER = "which diagram is in your notes"
#: The agent's ``show_image`` SUCCESS result's confirming first line
#: (``app.rag.agent``'s ``show_image`` branch — the line ``read``'s
#: result never carries): the SHOW_IMAGE flow's follow-up request
#: keys on it in a ``tool``-role result (stateless discrimination —
#: the house marker-flow convention).
_SHOW_CONFIRM_MARKER = "Image shown to the user"
#: The image-doc extension family (``app.rag.summarizer.IMAGE_MIMES``
#: — phase 122's ext→mime map, imported so the mock can never drift
#: from the app's image family): a seeded ``<documents>`` block is
#: the IMAGE document when its ``path`` attribute ends in one of
#: these (the suite's single-PNG fixture guarantees exactly one such
#: block — the pick is deterministic).
_IMAGE_PATH_SUFFIXES = tuple(IMAGE_MIMES)
def _seeded_image_doc(system: str) -> tuple[str, str, str] | None:
"""The seeded image doc's ``(source, path, content)`` — or ``None``.
Phase 124 (task 07): the ``<documents>`` block (the
:data:`_DOCUMENT_BLOCK_RE` machinery) whose ``path`` attribute
ends in an image extension (:data:`_IMAGE_PATH_SUFFIXES`) is the
seeded image document — for an image doc the block's content IS
the document's entire text (the vision description — content ==
summary, phase 122 task 03). Both of this phase's triggers pick
the doc this way (the suite's single-PNG fixture guarantees
exactly one such block; ``None`` = a text-only KB, the premise
broke — the caller degrades loudly).
"""
for block in _DOCUMENT_BLOCK_RE.finditer(system):
if block.group("path").lower().endswith(_IMAGE_PATH_SUFFIXES):
return (block.group("source"), block.group("path"), block.group("content"))
return None
def _show_image_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a phase-124 SHOW_IMAGE request (see the module
docstring). Stateless over the messages, like the other marker
flows:
* ``("call", combined)`` — the system prompt carries the
``show_image`` clause (the toggle-ON gate) and no ``tool``-role
result has confirmed the show yet: stream ONLY the
``show_image`` tool-call delta on the seeded image doc's
combined ``source/path`` (synthetic id ``call_show``,
``finish_reason: "tool_calls"``, no content) — NO ``read`` (the
zero-read turn).
* ``("answer",)`` — a ``tool``-role result carrying the agent's
confirming line (:data:`_SHOW_CONFIRM_MARKER`) is in the
messages: the deterministic FINAL answer — a short sentence
ending with the ``<documents>`` block's tail echo (the phase-24
END_OF_NOTES echo convention — it proves the seed was there),
NO further tool calls.
* ``None`` — not the flow: the trigger phrase is absent, the
clause is absent (a default-OFF app — the mock behaves exactly
as pre-phase), the turn is deflected (no ``<documents>`` seed
— a deflection prompt never carries one), or no seeded image
doc exists (a text-only KB — the premise broke; the suite fails
loudly on the missing figure).
"""
if SHOW_IMAGE_TRIGGER not in _user(body).lower():
return None
system = _system(body)
if "show_image" not in system:
return None
doc = _seeded_image_doc(system)
if doc is None:
return None
if any(_SHOW_CONFIRM_MARKER in r for r in _tool_results(body)):
return ("answer",)
if not body.get("tools"):
return None
return ("call", f"{doc[0]}/{doc[1]}")
def _summary_seed_read_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a phase-118 scripted summary-read request (see the
module docstring). The question carries the scripted call (``read
@@ -2280,6 +2442,31 @@ def compose_answer(body: dict[str, Any]) -> str:
# message, a deflection prompt never carries it, so a marker
# question always gets the echo whatever the gate says.
answer = _history_echo(body)
elif DIAGRAM_TRIGGER in user.lower():
# Phase 124 (task 07): the BUG-FIX-ONLY trigger — the mock
# answers straight from the seed with ZERO tool calls (no
# ``show_image``, no ``read`` — the clause is NOT required,
# so the flow also fires on a default-OFF app: the chips fix
# belongs to the ``BOR_IMAGES``-indexed docs) and quotes the
# TAIL of the seeded image doc's ``<document>`` block (the
# same pick as the SHOW_IMAGE flow — the block's content is
# the image doc's entire text, the vision description):
# proving the description WAS in the prompt and that the chip
# appears with NO tool calls at all (the phase-124 auto-cite
# — the fix for the owner's report). Checked BEFORE the
# DEFLECT_MODE branch (the marker lives in the user message —
# the TABLE/HISTORY convention), so a marker question always
# gets the echo whatever the honesty gate says. A text-only KB
# (no seeded image doc) degrades to the plain line — the
# suite's premise broke; it fails loudly on the missing quote.
image_doc = _seeded_image_doc(system)
answer = (
f"Your notes carry that diagram — it reads: “{image_doc[2][-160:]}” "
"(Deterministic mock answer for E2E.)"
if image_doc
else "Your notes don't carry that diagram — there is nothing to show. "
"(Deterministic mock answer for E2E.)"
)
elif "DEFLECT_MODE" in system:
answer = (
"Ah — I haven't done anything like that, so I don't want to make stuff up! "
@@ -2911,6 +3098,42 @@ def chat_completions(body: dict[str, Any]) -> Any:
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 124 (task 07, image chips and answers, TODO L3): the
# scripted SHOW_IMAGE flow — the mock shows the seeded image
# document (the ``<documents>`` block with the image-
# extension path) and answers straight from the seed: ZERO
# ``read`` calls (the zero-read turn that makes the bug-fix's
# chip assertion meaningful). Clause-gated — the ``show_image``
# teaching line in the system prompt is the
# ``BOR_ANSWER_IMAGES`` toggle's presence marker, so on a
# default-OFF app the trigger never fires and the mock stays
# byte-identical to pre-phase for every other suite. Checked
# BEFORE the plain TOOLS_TRIGGER flow (disjoint trigger
# phrase — the phase-71/72/94/118 ordering convention; the
# trigger needs the clause, so deflected turns never hit it).
show_image = _show_image_flow(body)
if show_image is not None:
if show_image[0] == "call":
stream = _tool_call_stream(
"show_image", {"path": show_image[1]}, "call_show"
)
else: # "answer" — the final answer + the tail echo
block = _DOCUMENTS_BLOCK_RE.search(_system(body))
tail = (block.group(0) if block else _context(body))[-160:]
stream = _sse_stream(
_apply_max_tokens(
"Here is the picture you asked about — …and the very "
f"end of my notes reads: “{tail}” "
"(Deterministic mock answer for E2E.)",
body.get("max_tokens"),
),
0.0,
)
return StreamingResponse(
stream,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 109 (task 03, never-frozen-turn story suite): the
# deterministic REPORTED-REPRO turn (delta → tool →
# thinking-after-delta — TODO.md L3): the scripted first answer,
+971
View File
@@ -0,0 +1,971 @@
"""Phase 124 E2E (Playwright): image source chips + LLM-chosen answer images.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_answer_images.py -v --no-cov
The suite proves BOTH halves of TODO.md L3 on the real user path, plus
the toggle-off contract — two module apps on dedicated ports (the
phase-122 ``test_image_documents.py`` pattern), sharing one deterministic
mock LLM and the shared e2e Postgres (truncate/re-import per app):
* **App A (feature ON)** — ``BOR_IMAGES=true`` +
``BOR_ANSWER_IMAGES=true``: the standalone PNG arrives through the
REAL admin upload flow + the RAG page's "Sync sources" (the mock's
vision model writes the byte-stable description), then:
- **Feature flow** — the ``show me the picture of …`` trigger (the
mock's phase-124 scripted trigger, ``tests/e2e/mock_llm.py``): the
mock scripts ONLY a ``show_image`` call (NO ``read`` — the ZERO-READ
turn, the shape that makes the chip assertion meaningful). The
brain bubble shows the ANSWER FIGURE (``.answer-image`` — ``<img>``
from the bytes route, ``loading="lazy"``, below the text and above
the chip row per A4, alt/caption settling to the summary), the
answer TEXT is still streamed in full (the tail echo of the
``<documents>`` seed proves the description was in the prompt), the
source-chip row carries the image doc's chip + its phase-122
figure (the AUTO-CITE proof — the mock made zero ``read`` calls,
asserted on the mock's request capture), and the "Nearby docs" row
does not repeat the doc. The wire pin: the first model request
offered FOUR tools (the three base + ``show_image``) and the turn
executed exactly one ``show_image`` — the saved brain record
persists the ``answer_image`` ref.
- **Restore + share** — a second feature turn, then a page RELOAD:
the restored brain bubble shows the figure again (the
``answer_image`` persistence through the ``bor.chat.v1`` restore
path), and the shared link (the existing chat-page Share flow, a
fresh anonymous context) renders the figure on the shared page.
- **Bug-fix-only flow** — the ``which diagram is in your notes``
trigger (the mock answers straight from the seed with ZERO tool
calls — no ``show_image``, no ``read``): the source-chip row STILL
carries the image doc's chip + figure (the bug fix on the pure
zero-read path — the assertion the owner's report describes), the
answer text quotes the seeded description's tail, and NO answer
figure renders (the model never called the tool).
* **App B (default off)** — ``BOR_IMAGES=true``, ``BOR_ANSWER_IMAGES``
pinned to the code default ``false`` (EXPLICITLY — process env ranks
above an operator's local gitignored ``.env``, so the default-off
contract under test cannot leak in, the phase-122 default-app
convention; the production default is unset = the same ``False``):
the same feature trigger question → the clause is absent from the
prompt, so the mock's trigger NEVER fires — the mock never sees
``show_image`` among the offered tools (the request capture carries
exactly the THREE base tools), the brain bubble has NO answer
figure, and the saved brain record carries NO ``answer_image`` key
(the byte-identical-off contract). The bug-fix trigger STILL chips
the image doc (the chips fix is NOT gated by ``BOR_ANSWER_IMAGES`` —
it belongs to the ``BOR_IMAGES``-indexed docs, per the phase design).
DB isolation: the suites share one Postgres; ``saved_chats`` survives
the truncation (the house pattern), so every question text here is
UNIQUE (its auto-title is therefore unique) and the row lookups key on
it. App B's module seed truncates the KB tables App A's tests used —
App B's tests run AFTER App A's (file order) and never look back.
"""
from __future__ import annotations
import base64
import json
import os
import re
import subprocess
import sys
import time
import zipfile
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import httpx
import pytest
from playwright.sync_api import Browser, Page, expect
from sqlalchemy import select, text
from app.config import Settings
from app.db import SessionLocal
from app.models import Document
from e2e.auth_helpers import login
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER
REPO = Path(__file__).resolve().parents[2]
GIT_SOURCES_URL = "/git-sources.html"
SOURCE_NAME = "e2e-answer-img" # the archive stem (archive_source_name)
DOC_PATH = "pic.png"
#: The mock's deterministic answer tail (every composed answer ends
#: with it — the turn-settled wait signal, the house convention).
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: The TAIL of the fixture description (``IMAGE_DESCRIPTION_ANSWER`` —
#: the image doc's entire content): both phase-124 mock answers quote
#: it (the feature flow's ``<documents>`` tail echo, the bug-fix flow's
#: block-content tail) — asserting it proves the SEED was in the
#: prompt and reached the rendered answer.
DESCRIPTION_TAIL = "Title: Homelab Network Map."
#: App A (feature ON) — the ``show_image`` trigger question (cosine
#: 0.4552 against the mock's token-overlap embeddings, well over the
#: mock-calibrated 0.30 gate). Unique auto-title (the DB is shared).
FEATURE_QUESTION = "Show me the picture of the homelab network diagram?"
#: The restore/share flow's variant (the trigger substring is kept —
#: cosine 0.4358, still over the gate).
RESTORE_QUESTION = "Show me the picture of the homelab network diagram? (restore)"
#: App B (default off) — the feature trigger's variant (the clause is
#: absent, so the mock answers with the plain composed answer — cosine
#: 0.4187, the turn still grounds).
FEATURE_OFF_QUESTION = (
"Show me the picture of the homelab network diagram? (toggle off)"
)
#: The bug-fix-only trigger question (cosine 0.3410): the mock answers
#: straight from the seed with ZERO tool calls and quotes the image
#: doc's block tail — the chip must appear anyway (the auto-cite).
DIAGRAM_QUESTION = "Which diagram is in your notes about the homelab network?"
#: App B's bug-fix variant (the chips fix is not toggle-gated — cosine
#: 0.3113, still over the mock-calibrated gate).
DIAGRAM_OFF_QUESTION = (
"Which diagram is in your notes about the homelab network? (default off)"
)
#: A real 1×1 transparent PNG (the unit/integration suites' fixture —
#: the pipeline is content-agnostic, the well-formed bytes keep the
#: upload + serve + render path honest).
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
)
UPLOAD_TIMEOUT_MS = 30_000
SYNC_TIMEOUT_MS = 60_000
SYNCED_LABEL = re.compile(r"^Synced \d{1,2}:\d{2}$")
# ---------------------------------------------------------------------------
# Module apps: feature ON (App A) and default-OFF (App B). Each owns
# its port + its scratch upload/source/image dirs.
# ---------------------------------------------------------------------------
def _app_env(
mock_llm: int, app_port: int, *, answer_images: str, scratch: Path
) -> dict[str, str]:
"""The conftest app env (leak guards included) with the phase-124
knobs: BOTH apps index images (``BOR_IMAGES=true`` — the fixture's
PNG must become a document on either), and ``BOR_ANSWER_IMAGES``
is pinned EXPLICITLY per app (``true`` for App A, ``false`` for
App B — process env ranks above an operator's local gitignored
``.env``, so the contract under test cannot leak in either
direction, the phase-122 default-app convention; App B's explicit
``false`` IS the production default — unset is ``False``). The
image/upload homes are in the suite's scratch dir (the app under
test must not write image copies into the owner's real
``~/bor-sources``)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# Mock-calibrated gate (conftest pattern) — both trigger questions
# ground on these values (see the question constants above).
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
# Phase 67: instant retry waits + the code-default budget.
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# Leak guards (conftest pattern).
env["BOR_GIT_SOURCES"] = ""
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
Settings.model_fields["suggestions"].default
)
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
# Phase 124: both apps index images; only the ANSWER side differs.
env["BOR_IMAGES"] = "true"
env["BOR_ANSWER_IMAGES"] = answer_images
env["BOR_UPLOAD_DIR"] = str(scratch / "uploads")
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
env["BOR_IMAGE_DIR"] = str(scratch / "images")
return env
@pytest.fixture(scope="module")
def app_server(mock_llm: int, tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
"""App A (feature ON) — ``BOR_IMAGES=true`` +
``BOR_ANSWER_IMAGES=true`` (the conftest session app is never
started in this isolated run, so no port clash)."""
scratch = tmp_path_factory.mktemp("bor_answer_img_on")
port = int(os.environ.get("E2E_APP_PORT_ANSWER_IMG_ON", "8162"))
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
cwd=REPO,
env=_app_env(mock_llm, port, answer_images="true", scratch=scratch),
)
try:
_wait_http(f"http://127.0.0.1:{port}/api/health")
yield f"http://127.0.0.1:{port}"
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
@pytest.fixture(scope="module")
def default_app_server(
mock_llm: int, tmp_path_factory: pytest.TempPathFactory
) -> Iterator[str]:
"""App B (default off) — ``BOR_IMAGES=true`` +
``BOR_ANSWER_IMAGES=false`` pinned explicitly (the production
default — the toggle-off contract; only the negative tests start
it, and they run AFTER App A's — file order)."""
scratch = tmp_path_factory.mktemp("bor_answer_img_off")
port = int(os.environ.get("E2E_APP_PORT_ANSWER_IMG_OFF", "8163"))
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
cwd=REPO,
env=_app_env(mock_llm, port, answer_images="false", scratch=scratch),
)
try:
_wait_http(f"http://127.0.0.1:{port}/api/health")
yield f"http://127.0.0.1:{port}"
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def default_app_url(default_app_server: str) -> str:
return default_app_server
@pytest.fixture(scope="module")
def zip_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The fixture archive: ONE standalone PNG at the root — source
``e2e-answer-img``, document path ``pic.png`` (the mock's seeded-
image-doc pick is deterministic: the only image-extension block)."""
root = tmp_path_factory.mktemp("bor_answer_img_zip")
(root / DOC_PATH).write_bytes(PNG_1X1)
archive = root / f"{SOURCE_NAME}.zip"
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
zf.write(root / DOC_PATH, arcname=DOC_PATH)
return archive
# ---------------------------------------------------------------------------
# DB + UI + mock-capture helpers
# ---------------------------------------------------------------------------
def _truncate_all() -> None:
"""Fresh KB + registry (the E2E isolation pattern): the suites
share one Postgres, and a leftover row would corrupt the counts.
``sources_meta`` (the KB generation counter) resets with the KB —
the sync re-creates the row. ``saved_chats`` is never touched by
the truncation — the unique question texts keep the row lookups
unambiguous, and :func:`_cleanup_saved_rows` owns this suite's
rows (re-runnable on the shared DB)."""
with SessionLocal() as db:
db.execute(text(
"TRUNCATE chunks, documents, query_log, kb_overview, "
"git_sources, sources_meta, folder_summaries"
))
db.commit()
def _health_db(app_url: str) -> bool:
try:
return httpx.get(f"{app_url}/api/health", timeout=5).json()["db"] == "up"
except Exception: # noqa: BLE001 — unreachable is the skip case
return False
def _mock_capture(mock_llm: int) -> list[dict[str, Any]]:
"""The mock's recent chat-completions request bodies (oldest
first) — the wire lens on the app's tool OFFER and the turn's
tool CALLS (the mock is the only E2E lens on the LLM's context)."""
r = httpx.get(f"http://127.0.0.1:{mock_llm}/v1/e2e/captured", timeout=5)
assert r.status_code == 200
return r.json()
def _mock_capture_reset(mock_llm: int) -> None:
r = httpx.post(f"http://127.0.0.1:{mock_llm}/v1/e2e/captured/reset", timeout=5)
assert r.status_code == 200
def _executed_tool_calls(captured: list[dict[str, Any]]) -> list[str]:
"""Every tool call the MODEL made across *captured* requests (the
assistant ``tool_calls`` messages), in order — the executed-call
lens (a refusal still appears here; it is the model's call)."""
return [
tc["function"]["name"]
for body in captured
for m in body["messages"]
if m.get("role") == "assistant" and m.get("tool_calls")
for tc in m["tool_calls"]
]
def _offered_tool_names(body: dict[str, Any]) -> list[str]:
return [t["function"]["name"] for t in (body.get("tools") or [])]
def _upload_zip(page: Page, app_url: str, archive: Path) -> None:
"""The real admin upload flow: form login → the git-sources page →
pick the archive → submit → the terminal result line (phase 90:
"Uploaded <source> — press Sync sources to import it.")."""
login(page, app_url, next=GIT_SOURCES_URL)
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
page.set_input_files("#archive-upload-file", str(archive))
page.click("#archive-upload-btn")
result = page.locator("#archive-upload-result")
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
expect(result).to_have_text(
f"Uploaded {SOURCE_NAME} — press Sync sources to import it.",
timeout=UPLOAD_TIMEOUT_MS,
)
def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
"""The RAG page's "Sync sources" button (the scan — phase 90 A3):
click → "Syncing…" → the terminal "Synced HH:MM" label + the fresh
counts in #sync-result (the never-stale contract)."""
page.goto(app_url + "/sources.html")
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=30_000)
expect(page.locator("#sync-label")).to_have_text(
re.compile(r"^Sync sources$|^Synced \d{1,2}:\d{2}$")
)
btn.click()
expect(btn).to_be_disabled()
expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled()
expect(page.locator("#sync-result")).to_have_text(expected_result)
def _seed_image_doc(
app_url: str, browser: Browser, zip_path: Path
) -> dict[str, str]:
"""Upload the single-PNG zip through the admin UI, then scan it
with the RAG page's Sync button (the mock's vision model describes
the image in-process). Returns the seeded doc's identity (the
bytes route + the description + the title) for the render
assertions. The app's ``/api/config`` flag is asserted first — the
env override must have reached the app under test, or the whole
premise of the suite is void."""
cfg = httpx.get(f"{app_url}/api/config", timeout=5).json()
assert cfg["images"] is True, "the module app must run with BOR_IMAGES=true"
_truncate_all()
page = browser.new_page(viewport={"width": 1280, "height": 800})
try:
_upload_zip(page, app_url, zip_path)
_sync_via_ui(page, app_url, expected_result="1 added")
with SessionLocal() as db:
doc = db.scalar(
select(Document).where(
Document.source == SOURCE_NAME, Document.path == DOC_PATH
)
)
assert doc is not None, "the uploaded image must become a document"
assert doc.is_image is True and doc.image_path is not None
# content == summary == the mock's description (the ONLY
# embedded text of the doc — the pipeline contract, phase 122).
assert doc.content == IMAGE_DESCRIPTION_ANSWER
assert doc.summary == IMAGE_DESCRIPTION_ANSWER
return {
"image_url": f"/api/documents/{doc.id}/image",
"description": IMAGE_DESCRIPTION_ANSWER,
"title": doc.title,
}
finally:
page.close()
@pytest.fixture(scope="module")
def seeded(
app_url: str,
browser: Browser,
zip_path: Path,
) -> Iterator[dict[str, str]]:
"""App A's KB: the fixture image doc through the real UI path."""
if not _health_db(app_url):
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
_cleanup_saved_rows() # a previous run's rows would be stale (new doc id)
yield _seed_image_doc(app_url, browser, zip_path)
_cleanup_saved_rows()
_truncate_all()
@pytest.fixture(scope="module")
def seeded_off(
default_app_url: str,
browser: Browser,
zip_path: Path,
) -> Iterator[dict[str, str]]:
"""App B's KB: the SAME fixture image doc through the SAME real
UI path (images indexed, the answer feature OFF). Runs AFTER all
of App A's tests (file order) — its truncation wipes App A's KB
tables, and App B's tests never look back."""
if not _health_db(default_app_url):
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
_cleanup_saved_rows()
yield _seed_image_doc(default_app_url, browser, zip_path)
_cleanup_saved_rows()
_truncate_all()
# ---------------------------------------------------------------------------
# Saved-chat + share helpers (the test_share_chat.py conventions)
# ---------------------------------------------------------------------------
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login
— used to call the admin API with plain httpx (the test's API side
sees exactly what the signed-in browser sees)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _auto_title(question: str) -> str:
"""The phase-50 auto-title convention: the first question,
whitespace-collapsed, capped at 120 chars."""
return " ".join(question.split())[:120]
#: This suite's auto-titles (the shared e2e Postgres keeps
#: ``saved_chats`` across runs — the test_share_chat cleanup
#: concern): a re-run must not find the previous run's row (same
#: title, but the KB re-seed minted a NEW doc id — the row's
#: ``answer_image.image_url`` would no longer match the seed).
_SUITE_TITLES = tuple(
_auto_title(q)
for q in (
FEATURE_QUESTION,
RESTORE_QUESTION,
FEATURE_OFF_QUESTION,
DIAGRAM_QUESTION,
DIAGRAM_OFF_QUESTION,
)
)
def _cleanup_saved_rows() -> None:
"""Delete THIS suite's saved rows by title (the house pattern —
test_share_chat deletes its own rows; here the module-boundary
fixture calls it, so a re-run starts clean). Best-effort: a
failed delete (DB unreachable — the skip case) never masks the
skip; a failed delete with the DB up just re-creates the stale-
row premise, and the suite fails loudly on the row assertion."""
try:
with SessionLocal() as db:
db.execute(
text("DELETE FROM saved_chats WHERE title = ANY(:titles)"),
{"titles": list(_SUITE_TITLES)},
)
db.commit()
except Exception: # noqa: BLE001 — DB unreachable: the skip handles it
pass
def _wait_saved_row(
app_url: str,
cookies: dict[str, str],
title: str,
messages: int = 2,
) -> dict[str, Any]:
"""Wait for the auto-saved row (phase 55: auto-saves are SILENT —
A2 — so there is no status line to wait on). The upsert is
fire-and-forget from the UI's point of view, so poll the admin
list until the row with the conversation's auto-title appears with
the expected message count."""
deadline = time.monotonic() + 15
last: dict[str, Any] | None = None
while time.monotonic() < deadline:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert r.status_code == 200
rows = r.json()["chats"]
last = next((c for c in rows if c["title"] == title), None)
if last is not None and last["message_count"] >= messages:
return last
time.sleep(0.2)
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
def _chat_messages(
app_url: str, cookies: dict[str, str], chat_id: str
) -> list[dict[str, Any]]:
r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
assert r.status_code == 200
return r.json()["messages"]
def _brain_record(messages: list[dict[str, Any]]) -> dict[str, Any]:
brains = [m for m in messages if m.get("who") == "brain"]
assert brains, f"no brain record in the saved conversation: {messages!r}"
return brains[-1]
def _grant_clipboard(page: Page, app_url: str) -> None:
"""Grant the async-clipboard permissions on the admin context
(test_share_chat.py — headless Chromium still requires the grant
before ``writeText`` resolves on a secure origin)."""
page.context.grant_permissions(
["clipboard-read", "clipboard-write"], origin=app_url
)
def _click_share(app_url: str, page: Page) -> None:
"""Press the chat page's Share pill and pin the owner-locked
outcome on the live region (the test_share_chat.py branch —
clipboard success vs the inline-link fallback)."""
page.locator("#share-chat-btn").click()
if page.evaluate("() => !!navigator.clipboard"):
expect(page.locator("#send-status")).to_have_text(
"Share link copied.", timeout=15_000
)
else:
expect(page.locator("#send-status")).to_have_text(
"Share link ready — copy it from the field.", timeout=15_000
)
expect(page.locator(".share-link-fallback")).to_be_visible()
def _assert_answer_figure(
page: Page,
app_url: str,
seeded: dict[str, str],
*,
caption: str,
last_brain: str = ".msg.brain",
) -> None:
"""The A4 answer-figure contract in one place (the live, restore,
and shared surfaces share the shape — the shared page's figure is
a plain ``<figure>`, the chat page's an anchor, but the inner
``img``/caption classes are the same): exactly ONE ``.answer-image``
in the brain bubble, its ``<img>`` from the bytes route
(``loading="lazy"``, visible — the bytes really serve), and the
caption/alt settled to *caption* (the summary on the admin
surfaces, the title on the guest one)."""
fig = page.locator(f"{last_brain} .answer-image")
expect(fig).to_have_count(1)
img = fig.locator("img")
expect(img).to_have_count(1)
expect(img).to_be_visible(timeout=15_000)
expect(img).to_have_attribute("src", seeded["image_url"])
expect(img).to_have_attribute("loading", "lazy")
expect(fig.locator(".answer-image-caption")).to_have_text(
caption, timeout=15_000
)
# The served bytes are a real image (the public bytes route).
resp = httpx.get(app_url + seeded["image_url"], timeout=10)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/")
assert resp.content == PNG_1X1
def _assert_image_chip(
page: Page, seeded: dict[str, str], *, last_brain: str = ".msg.brain"
) -> None:
"""The citation chip the AUTO-CITE earns (phase 124 task 05): the
image doc's text chip + its phase-122 compact figure (``<img>``
from the bytes route, alt settling to the summary — the admin
surface; the shared page's chip is plain text and asserted
separately)."""
chip = page.locator(f"{last_brain} .source-chip")
expect(chip).to_have_count(1)
expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}")
fig = page.locator(f"{last_brain} .source-image")
expect(fig).to_have_count(1)
img = fig.locator(".source-image-img")
expect(img).to_be_visible(timeout=15_000)
expect(img).to_have_attribute("src", seeded["image_url"])
expect(img).to_have_attribute("alt", seeded["description"], timeout=15_000)
def _body_child_classes(page: Page) -> list[str]:
"""The LAST brain bubble's ``.msg-body`` direct children's class
names, in DOM order — the A4 position pin (bubble < answer-image
< msg-meta)."""
return page.evaluate(
"""() => {
const body = [...document.querySelectorAll(".msg.brain")].pop()
.querySelector(".msg-body");
return [...body.children].map((el) => el.className);
}"""
)
# ---------------------------------------------------------------------------
# 1. App A (feature ON) — the feature flow: the scripted show_image
# turn (ZERO reads) renders the answer figure + the auto-cited chip
# ---------------------------------------------------------------------------
def test_feature_flow_answer_figure_chips_and_zero_reads(
page: Page, app_url: str, seeded: dict[str, str], mock_llm: int
) -> None:
"""The ``show me the picture of …`` trigger: the mock scripts ONLY
a ``show_image`` call (NO ``read`` — the zero-read turn). Asserts:
(1) the brain bubble's ``.msg-body`` carries ``.answer-image`` with
an ``<img>`` from the bytes route (200, image Content-Type) and
``loading="lazy"``; (2) the figure sits BEFORE the ``.msg-meta``
source-chip row (A4); (3) the answer TEXT is non-empty and quotes
the seed's tail (the "summaries still provided" contract + the
echo proving the seed was in the prompt); (4) the source-chip row
carries the image doc's chip + its figure (the AUTO-CITE proof —
the mock made ZERO ``read`` calls: only ``show_image`` — asserted
on the wire); (5) the "Nearby docs" row does NOT repeat the doc.
Plus the persistence pin: the saved brain record carries the
``answer_image`` ref (the task-04 frame field, round-tripped)."""
login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden()
_mock_capture_reset(mock_llm)
page.fill("#message-input", FEATURE_QUESTION)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble").last
# The answer TEXT is non-empty (the "summaries still provided"
# contract) — the tail echo lands LAST (the terminal signal): it
# quotes the <documents> seed's tail, i.e. the description.
expect(bubble).to_contain_text(DESCRIPTION_TAIL, timeout=30_000)
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
# (1) The answer figure: the bytes route, lazy, alt/caption the
# summary (the fetchContentSummary pattern settles both).
_assert_answer_figure(page, app_url, seeded, caption=seeded["description"])
# (2) A4 position: below the answer text, above the chip row.
classes = _body_child_classes(page)
assert "bubble" in classes, f"no answer bubble in .msg-body: {classes}"
assert "answer-image" in classes, f"no .answer-image in .msg-body: {classes}"
assert "msg-meta" in classes, f"no chip row in .msg-body: {classes}"
assert (
classes.index("bubble")
< classes.index("answer-image")
< classes.index("msg-meta")
), f"A4 order violated (bubble < answer-image < msg-meta): {classes}"
# (4) The chip row carries the image doc's chip + its figure —
# the AUTO-CITE (the model never read it; the seed IS its
# entire content, so suggested == read for it).
_assert_image_chip(page, seeded)
# (5) The "Nearby docs" row does NOT repeat the image doc — the
# related tier excludes cited members (the single-doc KB has
# no related tier at all: the row is absent).
expect(page.locator(".msg.brain .related-docs")).to_have_count(0)
# The WIRE pin: exactly two model calls (the show_image round +
# the final answer), the FIRST request offered the FOUR tools
# (three base + show_image — the toggle-ON offer gate), and the
# turn executed EXACTLY one tool call: show_image — NO read (the
# zero-read turn that makes the chip assertion meaningful).
captured = _mock_capture(mock_llm)
assert len(captured) == 2, (
f"expected exactly 2 model calls (call + answer), got {len(captured)}"
)
assert _offered_tool_names(captured[0]) == ["ls", "read", "grep", "show_image"]
assert _executed_tool_calls(captured) == ["show_image"]
tool_results = [
m.get("content") or ""
for m in captured[1]["messages"]
if m.get("role") == "tool"
]
assert any("Image shown to the user" in r for r in tool_results), (
f"the confirming tool result never reached the model: {tool_results}"
)
# The persistence pin: the auto-saved brain record carries the
# answer_image ref (source/path/title/image_url — the task-04
# frame field; the restore + shared flows assert on its render).
row = _wait_saved_row(app_url, _admin_cookies(page), _auto_title(FEATURE_QUESTION))
record = _brain_record(_chat_messages(app_url, _admin_cookies(page), str(row["id"])))
assert record.get("answer_image") == {
"source": SOURCE_NAME,
"path": DOC_PATH,
"title": seeded["title"],
"image_url": seeded["image_url"],
}, f"the brain record's answer_image is wrong: {record.get('answer_image')!r}"
# ---------------------------------------------------------------------------
# 2. App A — reload restores the figure; the shared page shows it
# ---------------------------------------------------------------------------
def test_reload_restores_figure_and_shared_page_shows_it(
page: Page,
app_url: str,
seeded: dict[str, str],
mock_llm: int,
browser: Browser,
) -> None:
"""A second feature turn (a unique question — unique auto-title on
the shared DB), then a page RELOAD: the restored brain bubble
shows the figure again (the ``answer_image`` persistence through
the ``bor.chat.v1`` restore path — pixel-parity with the live
render), and the existing Share flow's link — opened in a FRESH
anonymous context — renders the figure on the shared page (the
bytes route is public; the guest caption keeps the title — the
content endpoint is user-gated, so the summary fetch is skipped
there)."""
login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden()
_mock_capture_reset(mock_llm)
page.fill("#message-input", RESTORE_QUESTION)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(DESCRIPTION_TAIL, timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
_assert_answer_figure(page, app_url, seeded, caption=seeded["description"])
_assert_image_chip(page, seeded)
# The auto-save settled (the row exists with both messages) before
# the reload — the Share click later shares the linked row, or
# re-creates it unlinked (either path is owner-locked; the row
# lookup keys on the unique auto-title).
cookies = _admin_cookies(page)
_wait_saved_row(app_url, cookies, _auto_title(RESTORE_QUESTION))
# RELOAD: the restored brain bubble shows the figure again — the
# localStorage restore (rememberBrainTurn's save point ran
# synchronously in the done handler, before the send button
# re-enabled, so the record is durable before we reload).
page.reload()
expect(page.locator("#kb-banner")).to_be_hidden()
restored = page.locator(".msg.brain .bubble").last
expect(restored).to_contain_text(DESCRIPTION_TAIL, timeout=30_000)
expect(restored).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
_assert_answer_figure(page, app_url, seeded, caption=seeded["description"])
_assert_image_chip(page, seeded)
# SHARE: the existing chat-page Share flow → a fresh anonymous
# context sees the figure (and the plain-text chip).
_grant_clipboard(page, app_url)
_click_share(app_url, page)
# The share landed on the pre-existing row (the linked branch —
# the idempotent share) or a fresh row (the unlinked save-and-
# share) — either carries the token; the unique auto-title keeps
# the lookup unambiguous on the shared DB.
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
rows = [
c for c in r.json()["chats"]
if c["title"] == _auto_title(RESTORE_QUESTION)
]
token_row = next((c for c in rows if c.get("share_url")), None)
assert token_row is not None and token_row.get("share_url"), (
f"no share_url on any shared row: {rows!r}"
)
share_link = str(token_row["share_url"])
assert share_link.startswith("/shared/")
anon = browser.new_page(viewport={"width": 1280, "height": 800})
try:
anon.goto(app_url + share_link)
expect(anon.locator("#shared-title")).to_have_text(
_auto_title(RESTORE_QUESTION), timeout=30_000
)
expect(anon.locator(".msg.brain .bubble").last).to_contain_text(
DESCRIPTION_TAIL, timeout=30_000
)
# The shared page renders the figure — the guest caption keeps
# the TITLE (no summary fetch — the content endpoint is
# user-gated), the img is from the public bytes route.
_assert_answer_figure(anon, app_url, seeded, caption=seeded["title"])
# The shared chip is PLAIN TEXT (guests cannot open documents):
chip = anon.locator(".msg.brain .source-chip")
expect(chip).to_have_count(1)
expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}")
# The shared page is zero-controls: the figure is a plain
# <figure>, never an anchor (the owner-locked contract).
assert anon.locator(".msg.brain a.answer-image").count() == 0
finally:
anon.close()
# ---------------------------------------------------------------------------
# 3. App A — the bug-fix-only flow: ZERO tool calls, the chip STILL
# appears (the pure zero-read path — the owner's report)
# ---------------------------------------------------------------------------
def test_bugfix_only_flow_zero_tool_calls_still_chips(
page: Page, app_url: str, seeded: dict[str, str], mock_llm: int
) -> None:
"""The ``which diagram is in your notes`` trigger: the mock
answers straight from the seed with ZERO tool calls (no
``show_image`` — the clause's flow is not triggered — and no
``read``): the source-chip row STILL carries the image doc's chip
+ figure (the bug fix on the pure zero-read path — the assertion
the owner's report describes), the answer text quotes the seeded
description's tail (the description WAS in the prompt), and NO
answer figure renders (the model never called the tool)."""
login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden()
_mock_capture_reset(mock_llm)
page.fill("#message-input", DIAGRAM_QUESTION)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble").last
# The answer quotes the seeded image doc's block tail (the
# description's end — the seed was in the prompt) and settles.
expect(bubble).to_contain_text(DESCRIPTION_TAIL, timeout=30_000)
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
# ZERO tool calls — on the wire (exactly ONE model call, no
# tool_calls message at all) and in the DOM (no tool record).
captured = _mock_capture(mock_llm)
assert len(captured) == 1, (
f"the bug-fix flow must make exactly 1 model call, got {len(captured)}"
)
assert _executed_tool_calls(captured) == [], (
f"the bug-fix flow must make ZERO tool calls: {_executed_tool_calls(captured)}"
)
expect(page.locator(".msg.brain .tool-calls-disclosure")).to_have_count(0)
# NO answer figure (the model never called show_image) — but the
# chip row STILL carries the image doc's chip + figure (the auto-
# cite: suggested == read for an image doc, no read performed).
expect(page.locator(".msg.brain .answer-image")).to_have_count(0)
_assert_image_chip(page, seeded)
# ---------------------------------------------------------------------------
# 4. App B (default off) — the toggle-off contract: no show_image
# offered, no answer figure, no answer_image key in the record
# ---------------------------------------------------------------------------
def test_toggle_off_flow_no_tool_no_answer_image_key(
page: Page,
default_app_url: str,
seeded_off: dict[str, str],
mock_llm: int,
) -> None:
"""The same feature trigger question on the default-OFF app: the
prompt clause is ABSENT, so the mock's trigger never fires (the
plain composed answer streams instead) — and the contract holds
at every surface: the mock NEVER sees ``show_image`` among the
offered tools (the request capture carries exactly the THREE base
tools), the brain bubble has NO ``.answer-image``, and the saved
brain record carries NO ``answer_image`` key (the byte-identical-
off contract — the key is absent, never null)."""
login(page, default_app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden()
_mock_capture_reset(mock_llm)
page.fill("#message-input", FEATURE_OFF_QUESTION)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
# No answer figure anywhere in the brain bubble.
expect(page.locator(".msg.brain .answer-image")).to_have_count(0)
# The WIRE pin: the offer list is EXACTLY the three base tools
# (no show_image — the toggle-OFF gate on the offer), and the
# turn made no tool calls at all (the mock's feature trigger is
# clause-gated — it never fired, so the plain answer streamed in
# one call).
captured = _mock_capture(mock_llm)
assert len(captured) == 1, (
f"expected exactly 1 model call (no tool round), got {len(captured)}"
)
assert _offered_tool_names(captured[0]) == ["ls", "read", "grep"], (
f"show_image leaked into the default-off offer: "
f"{_offered_tool_names(captured[0])}"
)
assert _executed_tool_calls(captured) == []
# The saved brain record carries NO answer_image key (ABSENT — the
# byte-identical-off contract; the phase-113 422-silence pin is
# the positive half of this, in the App A flow + the integration
# suite). The chip, though, IS still auto-cited (the chips fix is
# not toggle-gated — asserted in the next test).
row = _wait_saved_row(
default_app_url,
_admin_cookies(page),
_auto_title(FEATURE_OFF_QUESTION),
)
record = _brain_record(
_chat_messages(default_app_url, _admin_cookies(page), str(row["id"]))
)
assert "answer_image" not in record, (
f"the default-off brain record must NOT carry answer_image: "
f"{record.get('answer_image')!r}"
)
# ---------------------------------------------------------------------------
# 5. App B (default off) — the bug-fix trigger STILL chips the image
# doc (the chips fix belongs to BOR_IMAGES, not BOR_ANSWER_IMAGES)
# ---------------------------------------------------------------------------
def test_toggle_off_bugfix_trigger_still_chips(
page: Page, default_app_url: str, seeded_off: dict[str, str], mock_llm: int
) -> None:
"""The bug-fix trigger on the default-OFF app (the clause is
absent — the trigger does NOT require it): ZERO tool calls, the
answer quotes the seeded description's tail, and the source-chip
row STILL carries the image doc's chip + figure — the chips fix
is NOT gated by ``BOR_ANSWER_IMAGES`` (it belongs to the
``BOR_IMAGES``-indexed docs, per the phase design)."""
login(page, default_app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden()
_mock_capture_reset(mock_llm)
page.fill("#message-input", DIAGRAM_OFF_QUESTION)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(DESCRIPTION_TAIL, timeout=30_000)
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
captured = _mock_capture(mock_llm)
assert len(captured) == 1
assert _executed_tool_calls(captured) == []
expect(page.locator(".msg.brain .tool-calls-disclosure")).to_have_count(0)
expect(page.locator(".msg.brain .answer-image")).to_have_count(0)
_assert_image_chip(page, seeded_off)
+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)
+971
View File
@@ -0,0 +1,971 @@
"""Unit tests: phase 124 — image chips and answers.
Task 01 covers the ``BOR_ANSWER_IMAGES`` feature toggle
(``Settings.answer_images``). Later tasks in this phase extend this
file (the ``show_image`` tool, the prompt clause, the ``answer_image``
frame key, the auto-cite merge).
"""
from __future__ import annotations
from typing import Any
import pytest
from app.config import Settings
def _settings(**kwargs: Any) -> Settings:
"""Build Settings without reading a .env file (deterministic tests).
Mirrors ``tests/unit/test_config.py::_settings``.
"""
kwargs.setdefault("_env_file", None)
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
def test_answer_images_defaults_off(monkeypatch: pytest.MonkeyPatch) -> None:
"""LOCKED A2: ``BOR_ANSWER_IMAGES`` gates the whole answer-image
feature and is OFF by default — unset, ``=0``, and ``=false`` all
parse to ``False`` (off = byte-identical behavior everywhere)."""
monkeypatch.delenv("BOR_ANSWER_IMAGES", raising=False)
assert _settings().answer_images is False
monkeypatch.setenv("BOR_ANSWER_IMAGES", "0")
assert _settings().answer_images is False
monkeypatch.setenv("BOR_ANSWER_IMAGES", "false")
assert _settings().answer_images is False
def test_answer_images_truthy_env_parse(monkeypatch: pytest.MonkeyPatch) -> None:
"""The settings loader's accepted truthy forms (the
``stream_thinking``/``images`` bool-test precedent): ``1`` and
``true`` enable the toggle."""
monkeypatch.setenv("BOR_ANSWER_IMAGES", "1")
assert _settings().answer_images is True
monkeypatch.setenv("BOR_ANSWER_IMAGES", "true")
assert _settings().answer_images is True
def test_answer_images_independent_of_images(monkeypatch: pytest.MonkeyPatch) -> None:
"""LOCKED A2: the answer-side toggle is a SEPARATE knob from
``images``/``BOR_IMAGES`` — indexing image docs and showing them in
answers are independent owner decisions. Setting one never moves
the other, in either direction."""
monkeypatch.delenv("BOR_ANSWER_IMAGES", raising=False)
monkeypatch.delenv("BOR_IMAGES", raising=False)
# Default: both off.
s = _settings()
assert s.answer_images is False
assert s.images is False
# Enabling the answer toggle alone leaves indexing off — a
# coherent (if image-less) state: the tool is offered, finds no
# image docs, and always refuses.
monkeypatch.setenv("BOR_ANSWER_IMAGES", "1")
s = _settings()
assert s.answer_images is True
assert s.images is False
# Enabling indexing alone leaves the answer side off (byte-
# identical pre-phase-124 behavior).
monkeypatch.delenv("BOR_ANSWER_IMAGES")
monkeypatch.setenv("BOR_IMAGES", "1")
s = _settings()
assert s.answer_images is False
assert s.images is True
# Both on — the full feature state.
monkeypatch.setenv("BOR_ANSWER_IMAGES", "true")
s = _settings()
assert s.answer_images is True
assert s.images is True
# ---------------------------------------------------------------------------
# Task 02 — the ``show_image`` agent tool (toggle-gated)
import asyncio # noqa: E402
import uuid # noqa: E402
from collections.abc import AsyncIterator # noqa: E402
from copy import deepcopy # noqa: E402
from datetime import UTC, datetime # noqa: E402
from typing import cast # noqa: E402
from unittest.mock import MagicMock # noqa: E402
from sqlalchemy.orm import Session # noqa: E402
from app.models import Document # noqa: E402
from app.rag.agent import ( # noqa: E402
AGENT_TOOLS,
ALREADY_SHOWING,
IMAGE_DOC_MARKER,
MISSING_SHOW_ARGS,
NOT_AN_IMAGE_DOC,
SHOW_IMAGE_TOOL,
AgentHolder,
_execute_tool,
_no_document_refusal,
agent_tools,
run_agent,
)
from app.rag.llm import LLMClient, StreamPiece, ToolCallPiece # noqa: E402
#: The fixture documents' fixed creation date (phase 106, D5): the
#: ``show_image`` result formats ``doc.created_at`` on its date line —
#: a pinned value keeps the result-string assertions exact.
_SHOW_FIXTURE_CREATED_AT = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
# --- the offer list: toggle-gated, the base three byte-identical ---
def test_agent_tools_off_is_the_three_base_tools() -> None:
"""LOCKED A2: toggle off (the default) → exactly the three base
tools, element-for-element equal to ``AGENT_TOOLS`` — and a FRESH
list (not the constant), so a caller mutating the offer can never
mutate the constant."""
off = agent_tools(_settings())
assert [t["function"]["name"] for t in off] == ["ls", "read", "grep"]
assert off == AGENT_TOOLS # element-for-element
assert off is not AGENT_TOOLS # a fresh list, never the constant itself
off.append({"junk": True}) # mutating the offer must not touch the constant
assert len(AGENT_TOOLS) == 3
def test_agent_tools_on_appends_show_image() -> None:
"""Toggle on → the three base tools (byte-identical) plus the 4th
tool, ``show_image`` — the offer list the grounded turn passes to
the model request (LOCKED A2: ``BOR_ANSWER_IMAGES`` gates ONLY the
offer; the base three are unchanged)."""
on = agent_tools(_settings(answer_images=True))
assert [t["function"]["name"] for t in on] == ["ls", "read", "grep", "show_image"]
assert on[:3] == AGENT_TOOLS # the base three byte-identical
assert on[3] == SHOW_IMAGE_TOOL
def test_show_image_tool_definition_carries_the_relevance_gate() -> None:
"""The tool definition is the same OpenAI function shape as the
other three (one required string arg ``path``, the combined
``source/path`` identity — the bare-path-will-not-resolve teaching
mirrored from ``read``), and its description IS the relevance gate
(LOCKED A5: never a merely related image, at most ONE image, the
full text answer always stands)."""
fn = SHOW_IMAGE_TOOL["function"]
assert SHOW_IMAGE_TOOL["type"] == "function"
assert fn["name"] == "show_image"
assert fn["parameters"]["required"] == ["path"]
assert fn["parameters"]["properties"]["path"]["type"] == "string"
desc = fn["description"]
assert "at most one image" in desc
assert "never for a merely related" in desc
assert "answer fully in text either way" in desc
assert "bare document path will not resolve" in desc
def test_run_agent_gates_the_show_image_offer() -> None:
"""The wiring pin (completion criteria): a grounded turn's offer
(the ``tools`` on the model request, ``max_rounds`` > 0) is the
three base tools when the toggle is off — element-for-element the
pre-phase-124 list — and gains ``show_image`` only when it is on."""
class _ScriptedLLM:
"""One canned content-only round; records the ``tools`` every
request carried (the DB is never touched — no tool calls)."""
def __init__(self) -> None:
self.requests: list[
tuple[list[dict[str, Any]], list[dict[str, Any]] | None]
] = []
async def chat_stream(
self,
messages: list[dict[str, str]],
tools: list[dict[str, Any]] | None = None,
scaffolding: object | None = None,
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
self.requests.append((deepcopy(messages), tools))
yield StreamPiece("content", "Done!")
def _mock_session() -> Session:
mock = cast("Session", MagicMock())
mock.__enter__ = MagicMock(return_value=mock)
mock.__exit__ = MagicMock(return_value=False)
mock.scalar = MagicMock(return_value=None)
mock.execute = MagicMock(return_value=MagicMock(scalars=MagicMock(return_value=[])))
return mock
async def _consume(llm: _ScriptedLLM, settings: Settings) -> None:
async for _piece in run_agent(
cast("LLMClient", llm),
_mock_session,
system_prompt="SYSTEM_PROMPT",
user_message="QUESTION",
seed_docs=[],
settings=settings,
holder=AgentHolder(),
):
pass
llm_off = _ScriptedLLM()
asyncio.run(_consume(llm_off, _settings()))
assert len(llm_off.requests) == 1
assert llm_off.requests[0][1] == AGENT_TOOLS # off: element-for-element pre-124
llm_on = _ScriptedLLM()
asyncio.run(_consume(llm_on, _settings(answer_images=True)))
sent = llm_on.requests[0][1]
assert sent is not None # the grounded turn (max_rounds > 0) offers tools
assert [t["function"]["name"] for t in sent] == [
"ls",
"read",
"grep",
"show_image",
]
# --- the handler: the refusal matrix + the confirming success ---
def test_show_image_refusal_texts_are_pinned() -> None:
"""The refusal texts (the ``MISSING_READ_ARGS`` /
``ALREADY_IN_CONTEXT`` shapes) are pinned — the model's
self-correction relies on them being stable."""
assert MISSING_SHOW_ARGS == "show_image requires a string argument 'path'."
assert ALREADY_SHOWING == (
"Already showing an image this answer — do not show another one. "
"Answer from what you have."
)
assert NOT_AN_IMAGE_DOC.format(arg="s/p.png") == (
"'s/p.png' is not an image document — only standalone image "
"documents can be shown as answers."
)
def _show_call(arguments: dict[str, Any]) -> ToolCallPiece:
return ToolCallPiece(id="call_show", name="show_image", arguments=arguments)
def _seed_show_doc(
db: Session, *, is_image: bool, content: str, path: str
) -> Document:
"""One real documents row ``show_image`` can resolve (source
``showsrc`` — no fixture collision); cleaned up by the caller."""
doc = Document(
id=uuid.uuid4(),
source="showsrc",
path=path,
full_path=f"/showsrc/{path}",
title="diagram" if is_image else "Notes",
content=content,
content_hash="0" * 64,
created_at=_SHOW_FIXTURE_CREATED_AT,
is_image=is_image,
)
if is_image:
doc.image_path = f"/tmp/{doc.id}.png"
db.add(doc)
db.commit()
return doc
def _show(db: Session, settings: Settings, holder: AgentHolder, arguments: dict[str, Any]) -> str:
return _execute_tool(db, _show_call(arguments), [], holder, settings)
def test_show_image_missing_or_empty_path_is_refused(db: Session) -> None:
"""(a) missing/empty/whitespace/non-string ``path`` →
``MISSING_SHOW_ARGS`` — counts in nothing, nothing shown."""
holder = AgentHolder()
for arguments in ({}, {"path": " "}, {"path": None}):
assert _show(db, _settings(), holder, arguments) == MISSING_SHOW_ARGS
assert holder.tool_calls == 0
assert holder.shown_images == []
def test_show_image_unresolvable_arg_gets_the_no_document_teaching(
db: Session,
) -> None:
"""(b) an unresolvable argument → the phase-72
``_no_document_refusal`` text (the bare-argument line, and the
did-you-mean combined-identity for a path-like argument) — counts
in nothing, nothing shown."""
holder = AgentHolder()
# A bare argument (no ``/``) can never be a document — the
# no-DB-lookup refusal, byte-identical to the read branch's line.
assert _show(db, _settings(), holder, {"path": "nosuchdoc"}) == (
"No document at 'nosuchdoc' — check the ls output."
)
assert holder.tool_calls == 0
assert holder.shown_images == []
# A path-like argument matching exactly ONE indexed document's path
# gets the combined-identity teaching.
doc = _seed_show_doc(db, is_image=True, content="A red square.", path="phase124/diagram.png")
try:
result = _show(db, _settings(), holder, {"path": "phase124/diagram.png"})
assert result == _no_document_refusal(db, "phase124/diagram.png")
assert "showsrc/phase124/diagram.png" in result # the combined identity named
assert holder.tool_calls == 0
assert holder.shown_images == []
finally:
db.delete(doc)
db.commit()
def test_show_image_of_a_text_doc_is_refused_not_converted(db: Session) -> None:
"""(c) a resolved TEXT document → ``NOT_AN_IMAGE_DOC`` (the argument
echoed) — a text doc is refused, NEVER silently converted to a
read; counts in nothing, nothing shown or read."""
doc = _seed_show_doc(
db, is_image=False, content="just text.", path="phase124/notes.md"
)
try:
holder = AgentHolder()
result = _show(db, _settings(), holder, {"path": "showsrc/phase124/notes.md"})
assert result == NOT_AN_IMAGE_DOC.format(arg="showsrc/phase124/notes.md")
assert holder.tool_calls == 0
assert holder.shown_images == []
assert holder.read_docs == [] # never converted to a read
finally:
db.delete(doc)
db.commit()
def test_show_image_of_an_image_doc_confirms_and_records(db: Session) -> None:
"""(d) a resolved IMAGE document → the confirming result — the NEW
first line (absent from ``read``) followed by the exact
read-style document block (the ``Document …`` header + date line +
:data:`IMAGE_DOC_MARKER` + the description), ``shown_images ==
[doc]``, and ``tool_calls`` bumped (an executed call — it feeds the
per-turn ``tool_calls=N``)."""
doc = _seed_show_doc(
db,
is_image=True,
content="A red square on a white background.",
path="phase124/diagram.png",
)
try:
holder = AgentHolder()
result = _show(db, _settings(), holder, {"path": "showsrc/phase124/diagram.png"})
assert result == (
"Image shown to the user — it is now part of your answer.\n"
"Document showsrc/phase124/diagram.png:\n"
"date: 2024-06-15\n"
f"{IMAGE_DOC_MARKER}\n"
"A red square on a white background."
)
assert holder.shown_images == [doc]
assert holder.tool_calls == 1
finally:
db.delete(doc)
db.commit()
def test_show_image_second_call_is_refused(db: Session) -> None:
"""(e) a second ``show_image`` in the same turn →
``ALREADY_SHOWING`` (LOCKED A3, server-enforced) — appends nothing,
counts in nothing; the first document stays the one shown."""
doc = _seed_show_doc(
db, is_image=True, content="A red square.", path="phase124/diagram2.png"
)
try:
holder = AgentHolder()
first = _show(db, _settings(), holder, {"path": "showsrc/phase124/diagram2.png"})
assert first.startswith("Image shown to the user — it is now part of your answer.")
second = _show(db, _settings(), holder, {"path": "showsrc/phase124/diagram2.png"})
assert second == ALREADY_SHOWING
assert holder.shown_images == [doc] # still exactly one shown
assert holder.tool_calls == 1 # the refusal counts in nothing
finally:
db.delete(doc)
db.commit()
# ---------------------------------------------------------------------------
# Task 03 — the ``show_image`` prompt clause (toggle-gated)
#
# The clause body itself is pinned in ``tests/unit/test_prompts.py``
# (``SHOW_IMAGE_CLAUSE`` + the ``build_high_prompt`` builds). This
# section pins the chat-side wiring: the grounded plan carries the
# clause EXACTLY when ``settings.answer_images`` is true, and a
# deflected plan never does.
from app.api.chat import plan_turn # noqa: E402
from app.rag.prompts import SHOW_IMAGE_CLAUSE, build_high_prompt # noqa: E402
from app.rag.retriever import RetrievedChunk # noqa: E402
#: A detached document row + its chunk — ``plan_turn`` is pure (no DB),
#: so the wiring is pinned without a database (the cosine 0.9 clears
#: the 0.62 default ``relevance_threshold`` → HIGH/grounded; the
#: 0.2 / no-fts chunk stays LOW/deflected — the gate is strict).
_P3_DOC = Document(
id=uuid.uuid4(),
source="Homelab",
path="kubernetes.md",
full_path="/tmp/kubernetes.md",
title="Kubernetes Homelab Cluster",
content="Talos Linux on three nodes.",
content_hash="0" * 64,
created_at=_SHOW_FIXTURE_CREATED_AT,
summary="A Talos Linux cluster on three nodes.",
)
def _plan3(cosine: float, settings: Settings):
chunk = RetrievedChunk(
chunk_id=uuid.uuid4(),
position=0,
content="kubernetes",
score=1.0,
document=_P3_DOC,
cosine=cosine,
)
return plan_turn([chunk], settings)
def test_plan_turn_grounded_carries_clause_exactly_when_toggle_on() -> None:
"""Completion pin: the grounded plan's system prompt is BYTE-
IDENTICAL to the pre-task build when ``answer_images`` is off (the
default — the pre-phase pins pass untouched) and gains the clause
(inside ``<tools>``) exactly when it is on."""
off = _plan3(0.9, _settings())
assert off.deflected is False
assert off.system_prompt == build_high_prompt([_P3_DOC])
assert SHOW_IMAGE_CLAUSE not in off.system_prompt
on = _plan3(0.9, _settings(answer_images=True))
assert on.deflected is False
assert on.system_prompt == build_high_prompt([_P3_DOC], show_image=True)
assert SHOW_IMAGE_CLAUSE in on.system_prompt
assert on.system_prompt.index("<tools>") < on.system_prompt.index(SHOW_IMAGE_CLAUSE)
assert on.system_prompt.index(SHOW_IMAGE_CLAUSE) < on.system_prompt.index("</tools>")
def test_plan_turn_deflected_never_carries_the_clause() -> None:
"""A deflected turn has no tools and never shows an image (the
phase's locked design): the deflection prompt takes no such flag,
so its build is identical — and clause-free — whether the toggle
is off or on."""
off = _plan3(0.2, _settings())
assert off.deflected is True
assert SHOW_IMAGE_CLAUSE not in off.system_prompt
assert "<tools>" not in off.system_prompt
on = _plan3(0.2, _settings(answer_images=True))
assert on.deflected is True
assert on.system_prompt == off.system_prompt # toggle has no effect at all
assert SHOW_IMAGE_CLAUSE not in on.system_prompt
# ---------------------------------------------------------------------------
# Task 04 — the ``answer_image`` done frame + saved-chat persistence
#
# The wire contract: ``ChatDoneEvent.answer_image`` (the shown doc's
# :class:`SourceRef`, OMITTED from the frame when absent — every
# toggle-off / image-less turn stays byte-identical) and
# ``ChatMessage.answer_image`` (the brain-record key the done-time
# auto-save persists).
#
from json import dumps as _json_dumps # noqa: E402
from pydantic import ValidationError # noqa: E402
from app.schemas import ChatDoneEvent, ChatMessage, SourceRef # noqa: E402
#: The shown image doc's full ref (all four fields — a shown doc is an
#: image doc, so its ref always carries the ``image_url`` bytes route).
_SHOWN_REF = {
"source": "docs",
"path": "pic.png",
"title": "pic",
"image_url": "/api/documents/0e8a6e5d-7b6c-4f2a-9c1d-3e5f7a9b1c2d/image",
}
def test_done_event_without_answer_image_drops_the_key() -> None:
"""``answer_image=None`` (every deflected / image-less / pre-phase
turn) → ``model_dump()`` has NO ``answer_image`` key — ABSENT, never
``null`` — and the dump is byte-identical to the pre-phase frame
shape (the byte-identical-off contract, task 01). Checked on the
dict AND on the raw JSON (no ``"answer_image"`` token, no
``null`` field)."""
event = ChatDoneEvent(
deflected=False,
sources=[SourceRef(source="docs", path="a.md", title="A")],
related=[SourceRef(source="docs", path="b.md", title="B")],
suggestions=["Maybe try X?"],
)
dump = event.model_dump()
assert "answer_image" not in dump
# The pre-phase-124 key set, exactly.
assert set(dump) == {"type", "deflected", "sources", "related", "suggestions"}
assert _json_dumps(dump, sort_keys=True) == _json_dumps(
{
"type": "done",
"deflected": False,
"sources": [{"source": "docs", "path": "a.md", "title": "A"}],
"related": [{"source": "docs", "path": "b.md", "title": "B"}],
"suggestions": ["Maybe try X?"],
},
sort_keys=True,
)
raw = _json_dumps(dump)
assert "answer_image" not in raw
def test_done_event_without_answer_image_parses_a_pre_phase_frame() -> None:
"""Back-compat: a pre-phase-124 done frame (no ``answer_image``
key) parses with the default ``None`` and re-dumps byte-identically
(PLAN §4 — the field is additive in both directions)."""
old_payload = {
"type": "done",
"deflected": True,
"sources": [],
"related": [],
"suggestions": [],
}
event = ChatDoneEvent(**old_payload)
assert event.answer_image is None
assert event.model_dump() == old_payload
def test_done_event_with_answer_image_carries_the_full_ref() -> None:
"""``answer_image`` set (the shown doc) → the key is present with
ALL FOUR ref fields (source/path/title + the ``image_url`` bytes
route), and the existing ``sources``/``related``/``suggestions``
serialization is untouched."""
event = ChatDoneEvent(
deflected=False,
sources=[SourceRef(source="docs", path="pic.png", title="pic")],
related=[],
suggestions=[],
answer_image=SourceRef(**_SHOWN_REF),
)
dump = event.model_dump()
assert dump["answer_image"] == _SHOWN_REF
assert dump["sources"] == [{"source": "docs", "path": "pic.png", "title": "pic"}]
assert dump["related"] == []
assert dump["suggestions"] == []
assert dump["type"] == "done"
assert dump["deflected"] is False
def test_done_event_answer_image_ref_drops_its_own_null_image_url() -> None:
"""The ref's OWN serializer still applies inside the frame: a
text-shaped ref (``image_url=None`` — a corner that cannot occur
server-side, since only image docs are shown, but a client-built
frame could carry it) keeps the key and drops the null field
(the :class:`SourceRef.image_url` omission rule)."""
event = ChatDoneEvent(
deflected=False,
sources=[],
answer_image=SourceRef(source="s", path="p.png", title="t"),
)
dump = event.model_dump()
assert dump["answer_image"] == {"source": "s", "path": "p.png", "title": "t"}
assert "image_url" not in dump["answer_image"]
# --- the ``ChatMessage`` boundary (the saved-chat record key) ---
def test_chat_message_brain_record_with_answer_image_parses() -> None:
"""A brain record carrying ``answer_image`` (the shown doc's full
ref shape) parses — the phase-113 regression: without the field,
the ``extra="forbid"`` boundary would 422 every done-time auto-save
that carries the key (the A2 quiet-failure path)."""
msg = ChatMessage.model_validate(
{"who": "brain", "text": "Here's the diagram…", "answer_image": _SHOWN_REF}
)
assert msg.answer_image is not None
assert msg.answer_image.source == "docs"
assert msg.answer_image.path == "pic.png"
assert msg.answer_image.title == "pic"
assert msg.answer_image.image_url == _SHOWN_REF["image_url"]
def test_chat_message_without_or_null_answer_image_parses() -> None:
"""``answer_image`` ABSENT (every pre-phase-124 record) and explicit
``None`` both parse (the default) — and neither serializes the key
(ABSENT, never ``null`` — pre-phase and image-less records
round-trip byte-identically, the phase-50 contract)."""
absent = ChatMessage.model_validate({"who": "brain", "text": "hi"})
assert absent.answer_image is None
assert "answer_image" not in absent.model_dump()
explicit_null = ChatMessage.model_validate(
{"who": "brain", "text": "hi", "answer_image": None}
)
assert explicit_null.answer_image is None
assert "answer_image" not in explicit_null.model_dump()
def test_chat_message_with_answer_image_serializes_the_ref() -> None:
"""A record WITH a shown ref keeps it in the dump — the restore
path re-renders the answer figure from it (the round-trip)."""
msg = ChatMessage.model_validate(
{"who": "brain", "text": "Here's the diagram…", "answer_image": _SHOWN_REF}
)
dump = msg.model_dump()
assert dump["answer_image"] == _SHOWN_REF
def test_chat_message_user_image_field_still_parses_independently() -> None:
"""The user record's phase-123 ``image`` (the stored PATH string of
the question's attachment) is untouched and semantically distinct:
it still parses on a user record, still serializes the path, and is
independent of ``answer_image`` (a brain record carries the shown
ref, never the attachment path)."""
user = ChatMessage.model_validate(
{
"who": "user",
"text": "What is in this image?",
"image": "/api/chat-images/" + "a" * 32 + ".png",
}
)
assert user.image == "/api/chat-images/" + "a" * 32 + ".png"
assert user.answer_image is None
dump = user.model_dump()
assert dump["image"] == user.image
assert "answer_image" not in dump
def test_chat_message_answer_image_without_image_url_parses() -> None:
"""``answer_image`` missing ``image_url`` parses — the field is
optional on :class:`SourceRef` (its own serializer then drops the
null on the way out)."""
msg = ChatMessage.model_validate(
{
"who": "brain",
"text": "hi",
"answer_image": {"source": "s", "path": "p.png", "title": "t"},
}
)
assert msg.answer_image is not None
assert msg.answer_image.image_url is None
assert msg.model_dump()["answer_image"] == {"source": "s", "path": "p.png", "title": "t"}
def test_chat_message_phase83_bounds_still_bind_the_answer_image_ref() -> None:
"""The phase-83 value bounds bind the ref's fields EXACTLY like
every other :class:`SourceRef` (``source`` ≤ 120, ``path`` ≤ 1000,
``title`` ≤ 500 — the ``documents`` column lengths): one over any
cap is a 422 at the boundary."""
for bad in (
{"source": "s" * 121, "path": "p", "title": "t"},
{"source": "s", "path": "p" * 1001, "title": "t"},
{"source": "s", "path": "p", "title": "t" * 501},
):
with pytest.raises(ValidationError):
ChatMessage.model_validate(
{"who": "brain", "text": "hi", "answer_image": bad}
)
# Exactly AT the caps: parses (a server-built ref from a full-length
# row fits by construction — the same pin as the done-event ref).
at_caps = ChatMessage.model_validate(
{
"who": "brain",
"text": "hi",
"answer_image": {"source": "s" * 120, "path": "p" * 1000, "title": "t" * 500},
}
)
assert at_caps.answer_image is not None
# ---------------------------------------------------------------------------
# Task 05 — bug fix: suggested-tier image docs earn their source chips
#
# ``merge_cited_docs`` (``app/api/chat.py``) composes the citation tier
# (the done frame's ``sources``): the phase-119 read-docs-only rule
# plus phase 124's single documented exception — a suggested-tier image
# doc's seed block IS its entire content (content == summary == the
# vision description, phase 122 task 03), so "suggested" ≡ "read" for
# it and it is auto-cited even on a zero-read grounded turn (a ``read``
# would add only the marker line — the bug: the chip never appeared).
from app.api.chat import merge_cited_docs # noqa: E402
def _uc_doc(source: str, path: str, *, is_image: bool = False) -> Document:
"""A detached documents row (no DB) — ``merge_cited_docs`` touches
only the ``(source, path)`` identity and ``is_image``."""
return Document(
id=uuid.uuid4(),
source=source,
path=path,
full_path=f"/{source}/{path}",
title=path.rsplit("/", 1)[-1],
content="A red square." if is_image else "just text.",
content_hash="0" * 64,
created_at=_SHOW_FIXTURE_CREATED_AT,
is_image=is_image,
)
def test_merge_cited_docs_zero_read_suggested_image_doc_is_cited() -> None:
"""(a) The bug fix: a suggested-tier IMAGE doc + zero reads → it
IS cited (the zero-read grounded turn — the phase-119 "chips
nothing" fast path — chips it; pre-phase-124 this returned
``[]``)."""
img = _uc_doc("docs", "pic.png", is_image=True)
assert merge_cited_docs([], [img], deflected=False) == [img]
def test_merge_cited_docs_read_image_doc_appears_once_at_read_position() -> None:
"""(b) Dedup: an image doc the agent ALSO explicitly read appears
exactly ONCE — at its READ position (the read loop sees it first;
the suggestion merge must not re-add or relocate it)."""
img = _uc_doc("docs", "pic.png", is_image=True)
text = _uc_doc("docs", "notes.md")
merged = merge_cited_docs([img, text], [img, text], deflected=False)
assert merged == [img, text] # once each, read order
assert merged.count(img) == 1
# Read position preserved even when the suggestion order differs:
# the image doc read SECOND stays second (the suggested tier does
# not move read docs).
merged = merge_cited_docs([text, img], [img], deflected=False)
assert merged == [text, img]
def test_merge_cited_docs_zero_read_suggested_text_doc_not_cited() -> None:
"""(c) The phase-119 contract intact: a suggested TEXT doc + zero
reads → NOT cited (auto-citation is image docs only — a zero-read
text turn still chips nothing)."""
text = _uc_doc("docs", "notes.md")
assert merge_cited_docs([], [text], deflected=False) == []
def test_merge_cited_docs_deflected_cites_nothing() -> None:
"""(d) A deflected turn cites NOTHING (phase 112) — even when the
suggested tier (the weak hits, A3) holds image docs and even when
``read_docs`` is non-empty by construction (deflected turns run no
agent — the guard makes the contract explicit)."""
img = _uc_doc("docs", "pic.png", is_image=True)
assert merge_cited_docs([], [img], deflected=True) == []
assert merge_cited_docs([img], [img], deflected=True) == []
def test_merge_cited_docs_two_suggested_image_docs_in_suggestion_order() -> None:
"""(e) Two suggested image docs (zero reads, text docs interleaved
in the tier) → BOTH are cited, in SUGGESTION order (the retrieval
rank is the chip order) — the text members are skipped."""
text1 = _uc_doc("docs", "a.md")
second = _uc_doc("docs", "b.png", is_image=True)
text2 = _uc_doc("docs", "c.md")
first = _uc_doc("docs", "a.png", is_image=True)
merged = merge_cited_docs([], [text1, second, text2, first], deflected=False)
assert merged == [second, first] # suggestion order, images only
# With a read doc present, the reads come first, then the image
# docs in suggestion order.
read = _uc_doc("docs", "read.md")
merged = merge_cited_docs([read], [second, first], deflected=False)
assert merged == [read, second, first]
def test_merge_cited_docs_read_order_preserved_for_mixed_read_docs() -> None:
"""(f) Read order is preserved for mixed read text docs and
duplicate reads collapse (dedupe by ``(source, path)``, first
occurrence wins) — with the suggested image doc appended AFTER
the reads."""
a = _uc_doc("docs", "a.md")
b = _uc_doc("docs", "b.md")
img = _uc_doc("docs", "pic.png", is_image=True)
merged = merge_cited_docs([b, a, b], [img, a], deflected=False)
assert merged == [b, a, img]
# A duplicate image read collapses too (once, at its read
# position) — the suggestion merge adds nothing for it.
merged = merge_cited_docs([img, img, a], [img], deflected=False)
assert merged == [img, a]
# ---------------------------------------------------------------------------
# Task 06 — the answer figure: live render, persistence, restore, shared
#
# House-style frontend source contracts (the phase-122/123 pattern —
# the browser-level scenario is task 07's isolated E2E): the chat
# page's ``appendAnswerImage`` helper (lazy img, the public bytes
# route, the visible caption, the collapse-on-error rule, the chip
# navigation affordance), its three wiring points (the ``done``
# handler BEFORE the chip row, the ``rememberBrainTurn`` meta key, the
# ``renderStoredMessage`` restore BEFORE the chip row), the shared
# page's NON-INTERACTIVE local copy (zero controls), and the
# answer-scale themed CSS (token-only, the 320px cap).
import re # noqa: E402
from pathlib import Path # noqa: E402
FRONTEND = Path(__file__).resolve().parents[2] / "frontend" # noqa: E402
APP_JS = FRONTEND / "assets" / "app.js" # noqa: E402
SHARED_JS = FRONTEND / "assets" / "shared.js" # noqa: E402
STYLES_CSS = FRONTEND / "assets" / "styles.css" # noqa: E402
def _function_body(js: str, header: str) -> str:
"""The full text of the function whose header is ``header`` — from
the header to its brace-matched closing ``}`` (the
``test_image_documents.py`` house helper; the pinned functions' code
carries balanced braces)."""
start = js.index(header)
body_open = js.index("{", start)
depth = 0
for j in range(body_open, len(js)):
if js[j] == "{":
depth += 1
elif js[j] == "}":
depth -= 1
if depth == 0:
return js[start : j + 1]
raise AssertionError(f"unbalanced braces for {header!r}")
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _shared_js() -> str:
return SHARED_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def test_answer_image_helper_builds_the_lazy_capturing_figure() -> None:
"""The chat page's ``appendAnswerImage`` (the visual sibling of
``appendSourceImageFigure`` — the house createElement style, no
HTML strings): the figure anchors on the chip's navigation
affordance (the ``documentUrl`` href + the phase-26 same-page
modal), the img is LAZY on the public bytes route, alt + the
visible caption start at the TITLE and swap to the document's
SUMMARY via the SAME ``fetchContentSummary`` pattern (fails soft —
the title stands), and a FAILED IMAGE LOAD removes the whole
figure (the collapse rule — never a broken icon)."""
body = _function_body(_js(), "function appendAnswerImage(wrap, ref) {")
assert 'fig.className = "answer-image";' in body
assert 'fig.href = documentUrl(ref.source, ref.path, "/");' in body
assert "openDocumentModal(ref.source, ref.path, fig);" in body
assert "e.preventDefault();" in body # no new tab (phase 26)
assert 'img.className = "answer-image-img";' in body
assert "img.src = ref.image_url;" in body # the public bytes route
assert 'img.loading = "lazy";' in body # A4: lazy-loaded
assert "img.alt = ref.title || label;" in body # title first (always visible)
assert 'caption.className = "answer-image-caption";' in body
assert "caption.textContent = ref.title || label;" in body # visible caption
assert "fetchContentSummary(ref.source, ref.path)" in body
assert "img.alt = summary;" in body # the swap once it settles
assert "caption.textContent = summary;" in body
assert "if (!fig.isConnected) return;" in body # collapsed/cleared → no swap
assert "fig.remove();" in body # the collapse-on-error rule
# The guard: no ref (or a malformed one) → no figure DOM at all.
assert "!ref" in body
assert 'typeof ref.image_url !== "string"' in body
def test_done_handler_renders_the_figure_before_the_chips() -> None:
"""A4 position, live path: the ``done`` handler appends the figure
to ``.msg-body`` right BEFORE ``appendSources`` creates the chip
row (below the text, above the chips) — and only when the frame
carries the key (a frame without it renders byte-identically,
no figure DOM)."""
js = _js()
call = "if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);"
assert call in js
done = js.index('} else if (ev.type === "done") {')
next_branch = js.index('} else if (ev.type === "tool_result") {')
handler = js[done:next_branch]
assert call in handler
assert handler.index(call) < handler.index("appendSources(wrap, ev.sources);")
def test_done_handler_persists_the_answer_image_meta() -> None:
"""The done-time save point carries the key (the house
undefined-drops-the-key pattern — the ``related`` line is the
precedent): image-less / pre-phase turns store byte-identical
records; a record WITH the key round-trips through the task-04
boundary (``ChatMessage.answer_image``)."""
js = _js()
done = js.index('} else if (ev.type === "done") {')
next_branch = js.index('} else if (ev.type === "tool_result") {')
handler = js[done:next_branch]
assert "answer_image: ev.answer_image || undefined," in handler
def test_stored_message_restore_renders_the_figure_before_the_chips() -> None:
"""The refresh landing: ``renderStoredMessage``'s brain branch
renders ``m.answer_image`` through the SAME helper at the SAME A4
position (before the chip row) — a record without the key renders
byte-identically (pre-phase chats restore unchanged)."""
body = _function_body(_js(), "function renderStoredMessage(m) {")
call = "if (m.answer_image) appendAnswerImage(wrap, m.answer_image);"
assert call in body
assert body.index(call) < body.index("appendSources(wrap, m.sources);")
def test_shared_page_figure_is_non_interactive() -> None:
"""The shared page's local copy (the per-page duplication house
style): a PLAIN non-interactive ``<figure>`` (owner-locked zero
controls — no anchor, no href, no click handler), the LAZY img on
the PUBLIC bytes route (the guest view is faithful — the
phase-123 user-image contract), the title as the always-visible
caption (the content endpoint is user-gated — a guest fetch would
401; no fetch, the title stands), and the same collapse-on-error
rule. Wired where the brain branch appends sources (figure
BEFORE the chips)."""
shared = _shared_js()
body = _function_body(shared, "function addAnswerImage(wrap, ref) {")
assert 'document.createElement("figure")' in body # plain figure, no anchor
assert 'fig.className = "answer-image";' in body
assert 'document.createElement("a")' not in body # zero controls
assert ".href" not in body
assert 'addEventListener("click"' not in body
assert 'img.className = "answer-image-img";' in body
assert "img.src = ref.image_url;" in body # the public bytes route
assert 'img.loading = "lazy";' in body
assert "img.alt = title;" in body
assert 'caption.className = "answer-image-caption";' in body
assert "caption.textContent = title;" in body
assert "fig.remove()" in body # the collapse-on-error rule
assert "fetchContentSummary" not in body # user-gated endpoint — no guest fetch
# The brain branch wiring: figure BEFORE the chips.
msg = _function_body(shared, "function renderSharedMessage(m) {")
assert "addAnswerImage(wrap, m.answer_image);" in msg
assert msg.index("addAnswerImage(wrap, m.answer_image);") < msg.index(
"addSources(wrap, m.sources);"
)
def test_answer_image_css_is_answer_scale_and_themed() -> None:
"""The ``.answer-image`` family (the ``.source-image`` language at
answer scale, the zero-literal invariant so the monochrome themes
carry it): the img is block, full-column-width safe, CAPPED at
320px, contain on the theme's surface with the bordered-image
treatment; the caption is the AA-safe muted ink (8.6:1 on the
row's page background); the hover underline is scoped to the chat
page's ANCHOR figure only (the shared page's plain ``<figure>`` is
out of scope by element type)."""
css = _css()
for selector in (".answer-image", ".answer-image-img", ".answer-image-caption"):
assert re.search(rf"^{re.escape(selector)} \{{", css, re.M), f"missing rule: {selector}"
img = re.search(r"^\.answer-image-img \{\n([\s\S]*?)\n\}", css, re.M)
assert img, "missing .answer-image-img rule"
img_body = img.group(1)
assert "max-height: 320px" in img_body # the answer-scale cap
assert "object-fit: contain" in img_body
assert "display: block" in img_body
assert "background: var(--surface)" in img_body # the theme's surface
assert "border: 1px solid var(--line)" in img_body
assert "max-width: 100%" in img_body
cap = re.search(r"^\.answer-image-caption \{\n([\s\S]*?)\n\}", css, re.M)
assert cap, "missing .answer-image-caption rule"
assert "color: var(--ink-soft)" in cap.group(1) # AA: 8.6:1 on --bg
base = re.search(r"^\.answer-image \{\n([\s\S]*?)\n\}", css, re.M)
assert base and "max-width: 100%" in base.group(1) # full bubble width
hover = re.search(r"^a\.answer-image:hover \{([^}]*)\}", css, re.M)
assert hover and "text-decoration: underline" in hover.group(1), (
"the chat-page anchor figure keeps the flat-underline hover"
)
# Zero literals: the whole phase-124 block is token-only (no hex
# colors — the monochrome theme grays it automatically).
start = css.index("/* Phase 124 (task 06")
end = css.index("/* Phase 113 (task 02): the related-docs row")
assert start != -1 and end > start
assert re.search(r"#[0-9a-fA-F]{3,6}\b", css[start:end]) is None
+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