phase: 124_image_chips_and_answers
All green. Final report: **Phase 124 final verification pass — all criteria verified, no defects found (nothing to fix)** - Verified the implemented phase (all 7 tasks done, changes in working tree): `BOR_ANSWER_IMAGES` toggle, `show_image` tool + refusals + `holder.shown_images`, toggle-gated prompt clause, `answer_image` frame field (None-omitting serializer) + `ChatMessage` persistence, `merge_cited_docs` auto-cite bug fix, answer-figure UI (live/restore/shared/CSS), mock triggers + E2E suite. Also confirmed the bytes route → public change matches the phase-123 shared-chat precedent (task 06 design). - `uv run pytest --cov=app --cov-report=term-missing` → **2848 passed**, TOTAL **99%** (>90% gate) - `bash .agents/validate.sh` → **validation OK** (tests + coverage + ruff + pyright) - `uv run ruff check . && uv run pyright` → clean, 0 errors - `uv run pytest tests/e2e/test_answer_images.py -v --no-cov` (isolated) → **5/5 passed** - Mock-inert spot checks: `tests/e2e/test_image_documents.py` 4/4, `tests/e2e/test_agent_document_tools.py` 4/4 **Completion criteria:** ① zero-read suggested-tier image doc chips with figure — verified (unit matrix, integration `test_zero_read_grounded_turn_auto_cites_suggested_image_doc`, E2E bug-fix flows); ② show_image → answer figure below text/above chips, second call refused, persists + restore + shared — verified (unit handler matrix, integration round-trip, E2E feature flow); ③ default-off byte-identical (3 tools, no clause, no key) — verified (unit + integration + E2E toggle-off); ④ full suite green / >90% / lint clean — verified above; ⑤ commit + phase move — harness responsibility (changes left uncommitted in working tree, no live-infrastructure changes made). **Next pending phase:** none — `todo/` holds only this phase; the pipeline is clear once the harness moves it.
This commit is contained in:
+13
@@ -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.
|
||||||
+109
@@ -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
|
||||||
+19
@@ -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).
|
||||||
+108
@@ -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
|
||||||
+9
@@ -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).
|
||||||
+109
@@ -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
|
||||||
+16
@@ -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).
|
||||||
+109
@@ -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
|
||||||
+12
@@ -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).
|
||||||
+109
@@ -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
|
||||||
+16
@@ -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`
|
||||||
+109
@@ -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
|
||||||
+17
@@ -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`
|
||||||
+109
@@ -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
|
||||||
+11
@@ -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`.
|
||||||
+109
@@ -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
|
||||||
+10
@@ -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`.
|
||||||
+109
@@ -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
|
||||||
+22
@@ -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).
|
||||||
+109
@@ -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
|
||||||
+18
@@ -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`.
|
||||||
+109
@@ -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
|
||||||
+13
@@ -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/`.
|
||||||
+109
@@ -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
|
||||||
+13
@@ -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/`.
|
||||||
+109
@@ -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
|
||||||
@@ -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
|
# BOR_IMAGE_DIR=~/bor-sources/images # persistent home for the served image bytes
|
||||||
# (uploads are replaced, checkouts re-cloned)
|
# (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) ---
|
# --- Chat image questions (phase 123: attach an image to a question) ---
|
||||||
# Gated by the SAME BOR_IMAGES toggle above (enable only when the chat
|
# Gated by the SAME BOR_IMAGES toggle above (enable only when the chat
|
||||||
# model supports vision). The question image is stored on the server —
|
# model supports vision). The question image is stored on the server —
|
||||||
|
|||||||
+82
-8
@@ -462,7 +462,14 @@ def plan_turn(
|
|||||||
fts_hits,
|
fts_hits,
|
||||||
False,
|
False,
|
||||||
build_high_prompt(
|
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,
|
suggested,
|
||||||
related_docs,
|
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")
|
@router.post("/chat")
|
||||||
async def chat(
|
async def chat(
|
||||||
request: ChatRequest,
|
request: ChatRequest,
|
||||||
@@ -1035,15 +1087,21 @@ async def chat(
|
|||||||
# phase-119 cite discipline, A5) — and [] on
|
# phase-119 cite discipline, A5) — and [] on
|
||||||
# deflected ones (a deflected answer cites nothing;
|
# deflected ones (a deflected answer cites nothing;
|
||||||
# the weak hits stay in the durable record).
|
# 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
|
# A cancelled turn (the generator closed by the
|
||||||
# consumer) never reaches this step — no query_log row.
|
# consumer) never reaches this step — no query_log row.
|
||||||
cited_docs: list[Document] = []
|
cited_docs = merge_cited_docs(
|
||||||
cited_seen: set[tuple[str, str]] = set()
|
holder.read_docs, plan.suggested_docs, plan.deflected
|
||||||
for doc in holder.read_docs:
|
)
|
||||||
key = (doc.source, doc.path)
|
cited_seen = {(d.source, d.path) for d in cited_docs}
|
||||||
if key not in cited_seen:
|
|
||||||
cited_seen.add(key)
|
|
||||||
cited_docs.append(doc)
|
|
||||||
record_docs: list[Document] = []
|
record_docs: list[Document] = []
|
||||||
seen: set[tuple[str, str]] = set()
|
seen: set[tuple[str, str]] = set()
|
||||||
for doc in [*plan.suggested_docs, *plan.related_docs, *holder.read_docs]:
|
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
|
for d in plan.related_docs
|
||||||
if (d.source, d.path) not in cited_seen
|
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(
|
yield sse_event(
|
||||||
ChatDoneEvent(
|
ChatDoneEvent(
|
||||||
deflected=plan.deflected,
|
deflected=plan.deflected,
|
||||||
sources=cited_refs,
|
sources=cited_refs,
|
||||||
related=related_refs,
|
related=related_refs,
|
||||||
suggestions=plan.suggestions,
|
suggestions=plan.suggestions,
|
||||||
|
answer_image=answer_ref,
|
||||||
).model_dump()
|
).model_dump()
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+22
-10
@@ -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
|
``image_path``, served with the extension's ``Content-Type`` (the
|
||||||
``app.rag.summarizer.IMAGE_MIMES`` map — one map, one truth) and a
|
``app.rag.summarizer.IMAGE_MIMES`` map — one map, one truth) and a
|
||||||
``Cache-Control: private, max-age=3600`` header (the bytes are
|
``Cache-Control: private, max-age=3600`` header (the bytes are
|
||||||
content-hashed — long enough, bustable by re-upload). User-gated like
|
content-hashed — long enough, bustable by re-upload). PUBLIC (phase
|
||||||
the content endpoint (phase 79 — the ONLY anonymous surface is the
|
124 — the phase-123 chat-image precedent: the image is part of a
|
||||||
shared chats): a missing doc, a non-image doc, a doc whose
|
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
|
``image_path`` is NULL, or a row whose copy was lost all map to 404
|
||||||
``document not found`` (the router's unknown-document shape —
|
``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
|
PATCH /api/folders/summary — the admin folder-description editor
|
||||||
(phase 97, task 03): update / create / clear a stored
|
(phase 97, task 03): update / create / clear a stored
|
||||||
@@ -212,16 +217,23 @@ def get_document_content(
|
|||||||
def get_document_image(
|
def get_document_image(
|
||||||
doc_id: str,
|
doc_id: str,
|
||||||
db: Session = Depends(get_db), # noqa: B008
|
db: Session = Depends(get_db), # noqa: B008
|
||||||
_user: None = Depends(require_user), # noqa: B008 # phase 79 posture (see docstring)
|
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
"""The phase-122 (task 04) image BYTES route — the persistent copy
|
"""The phase-122 (task 04) image BYTES route — the persistent copy
|
||||||
behind an ``is_image`` document's ``image_path``.
|
behind an ``is_image`` document's ``image_path``.
|
||||||
|
|
||||||
Same auth posture as the document content endpoint (phase 79 —
|
PUBLIC (phase 124, LOCKED A4 — "visible … on the shared page"): a
|
||||||
``require_user``: admin OR live token holder; the ONLY anonymous
|
guest opening a shared chat sees the brain answer's image figure,
|
||||||
surface is the shared chats — the image is part of a document's
|
so the bytes must serve ANONYMOUSLY. The trust model is the
|
||||||
content, so it travels under the same gate): anonymous callers get
|
phase-123 chat-image precedent (the question attachment travels in
|
||||||
401 ``authentication required`` before any row is read.
|
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
|
404 ``document not found`` (the router's unknown-document shape) in
|
||||||
every non-servable case — a missing id (an unparseable string maps
|
every non-servable case — a missing id (an unparseable string maps
|
||||||
|
|||||||
@@ -412,6 +412,23 @@ class Settings(BaseSettings):
|
|||||||
#: (the ``upload_max_mb`` pattern).
|
#: (the ``upload_max_mb`` pattern).
|
||||||
chat_image_max_mb: int = 10
|
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) ---
|
# --- Docs push (phase 59: save a chat answer as documentation) ---
|
||||||
#: The git repo a saved chat answer is committed to (phase 59, D3):
|
#: The git repo a saved chat answer is committed to (phase 59, D3):
|
||||||
#: **any** remote — a URL (``https://``, ``ssh://``, ``git@``) or a
|
#: **any** remote — a URL (``https://``, ``ssh://``, ``git@``) or a
|
||||||
|
|||||||
+143
-2
@@ -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
|
#: Tool refusal texts (phase 37): rejected calls count in nothing
|
||||||
#: (``holder.tool_calls`` tracks executed calls); the round cap bounds
|
#: (``holder.tool_calls`` tracks executed calls); the round cap bounds
|
||||||
#: their pathological repetition (phase 45). The in-context line is a
|
#: their pathological repetition (phase 45). The in-context line is a
|
||||||
@@ -407,6 +472,24 @@ ALREADY_IN_CONTEXT = (
|
|||||||
UNKNOWN_TOOL = "Unknown tool."
|
UNKNOWN_TOOL = "Unknown tool."
|
||||||
MISSING_READ_ARGS = "read requires a string argument 'path'."
|
MISSING_READ_ARGS = "read requires a string argument 'path'."
|
||||||
MISSING_SEARCH_ARGS = "grep requires a string argument 'pattern'."
|
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
|
#: The read-truncation notice (phase 95, task 01) — appended, after the
|
||||||
#: shared :data:`TRUNCATION_MARKER`, to a ``read`` result whose document
|
#: 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
|
as today — this list only carries the truncation signal the agent
|
||||||
loop turns into :class:`app.rag.llm.ToolResultPiece` values (task 02
|
loop turns into :class:`app.rag.llm.ToolResultPiece` values (task 02
|
||||||
surfaces them to the UI).
|
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)
|
read_docs: list[Document] = field(default_factory=list)
|
||||||
tool_calls: int = 0
|
tool_calls: int = 0
|
||||||
scaffold_stripped: int = 0
|
scaffold_stripped: int = 0
|
||||||
read_truncations: list[tuple[str, int, int]] = field(default_factory=list)
|
read_truncations: list[tuple[str, int, int]] = field(default_factory=list)
|
||||||
|
shown_images: list[Document] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
def _execute_tool(
|
def _execute_tool(
|
||||||
@@ -1327,6 +1417,51 @@ def _execute_tool(
|
|||||||
f"{marker}"
|
f"{marker}"
|
||||||
f"{doc.content}"
|
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":
|
if call.name == "grep":
|
||||||
raw_pattern = call.arguments.get("pattern")
|
raw_pattern = call.arguments.get("pattern")
|
||||||
pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
|
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
|
# 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
|
# whole turn, bounded by the round cap. ``0`` is the no-tools kill
|
||||||
# switch: exactly one request with ``tools=None`` (the pre-phase-37
|
# 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
|
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
|
rounds = 0
|
||||||
while True:
|
while True:
|
||||||
calls: list[ToolCallPiece] = []
|
calls: list[ToolCallPiece] = []
|
||||||
|
|||||||
+67
-2
@@ -258,6 +258,43 @@ TOOLS_SECTION: str = (
|
|||||||
"</tools>"
|
"</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(
|
def history_to_messages(
|
||||||
history: Sequence[HistoryTurn],
|
history: Sequence[HistoryTurn],
|
||||||
@@ -426,6 +463,7 @@ def build_high_prompt(
|
|||||||
notes: Sequence[str] | None = None,
|
notes: Sequence[str] | None = None,
|
||||||
kb_overview: str | None = None,
|
kb_overview: str | None = None,
|
||||||
folder_lines: Sequence[str] = (),
|
folder_lines: Sequence[str] = (),
|
||||||
|
show_image: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Grounded turn: locked persona (+ steering, + KB overview) + the
|
"""Grounded turn: locked persona (+ steering, + KB overview) + the
|
||||||
top-ranked documents' SUMMARY blocks + the ``<tools>`` instructions
|
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
|
phase 118, LOCKED A6 re-revising A7: the ``<documents>`` section
|
||||||
seeds SUMMARIES, never full texts — full text enters the context
|
seeds SUMMARIES, never full texts — full text enters the context
|
||||||
only through the capped ``read`` tool; phase 119, LOCKED A4: the
|
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>``
|
Section order: ``<relevance>`` → ``<knowledge_base>`` → ``<tuning>``
|
||||||
→ ``<documents>`` → ``<tools>``; empty steering/overview omit their
|
→ ``<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
|
cap — not the prompt — decides whether the tools are actually
|
||||||
offered to the model, see :mod:`app.rag.agent`).
|
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
|
When at least one block is present, the section leads with the
|
||||||
start-here :data:`SUGGEST_INTRO` line (before the first block — the
|
start-here :data:`SUGGEST_INTRO` line (before the first block — the
|
||||||
phase-15 ``_STEERING_INTRO`` / phase-31 ``_KB_INTRO`` precedent): 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 [])):
|
for part in (build_kb_section(kb_overview or ""), build_steering_section(notes or [])):
|
||||||
if part:
|
if part:
|
||||||
prompt += "\n" + 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(
|
def build_deflect_prompt(
|
||||||
|
|||||||
+69
-10
@@ -242,6 +242,21 @@ class ChatDoneEvent(BaseModel):
|
|||||||
citation chip — while ``sources`` stays the citation surface. The
|
citation chip — while ``sources`` stays the citation surface. The
|
||||||
field is ADDITIVE: old clients ignore unknown fields (PLAN §4 house
|
field is ADDITIVE: old clients ignore unknown fields (PLAN §4 house
|
||||||
contract) and old frames without it parse with the default ``[]``.
|
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"
|
type: str = "done"
|
||||||
@@ -249,6 +264,25 @@ class ChatDoneEvent(BaseModel):
|
|||||||
sources: list[SourceRef]
|
sources: list[SourceRef]
|
||||||
related: list[SourceRef] = []
|
related: list[SourceRef] = []
|
||||||
suggestions: list[str] = []
|
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):
|
class ChatErrorEvent(BaseModel):
|
||||||
@@ -967,22 +1001,47 @@ class ChatMessage(BaseModel):
|
|||||||
# omission precedent). The path is ≤ 500 chars — no phase-83
|
# omission precedent). The path is ≤ 500 chars — no phase-83
|
||||||
# cap pressure (it is never a data URL, LOCKED A5).
|
# cap pressure (it is never a data URL, LOCKED A5).
|
||||||
image: str | None = Field(default=None, max_length=500)
|
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")
|
@model_serializer(mode="wrap")
|
||||||
def _drop_image_when_absent(self, handler: SerializerFunctionWrapHandler) -> Any:
|
def _drop_image_when_absent(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||||||
"""The phase-123 image omission rule: ``image: None`` (every
|
"""The image omission rules: ``image: None`` (every text-only
|
||||||
text-only record, and every pre-phase-123 record) serializes
|
record, and every pre-phase-123 record) and ``answer_image:``
|
||||||
WITHOUT the key — ABSENT, never ``null`` — so the stored
|
``None`` (every record without a shown answer image, and every
|
||||||
``bor.chat.v1`` JSONB and the saved/shared wire shape stay
|
pre-phase-124 record) serialize WITHOUT the keys — ABSENT,
|
||||||
byte-identical to pre-phase for text-only chats (the phase-50
|
never ``null`` — so the stored ``bor.chat.v1`` JSONB and the
|
||||||
round-trip contract). A record WITH an image keeps the path —
|
saved/shared wire shape stay byte-identical to pre-phase for
|
||||||
the user bubble, the refreshed page, and the shared chat all
|
those records (the phase-50 round-trip contract; the
|
||||||
render it from the served route (the image is part of the
|
phase-122 ``SourceRef.image_url`` omission precedent). A
|
||||||
chat's content, so it rides the same public/credential-
|
record WITH one keeps it — the user bubble, the refreshed page,
|
||||||
is-the-id trust model)."""
|
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)
|
data = handler(self)
|
||||||
if self.image is None:
|
if self.image is None:
|
||||||
data.pop("image", None)
|
data.pop("image", None)
|
||||||
|
if self.answer_image is None:
|
||||||
|
data.pop("answer_image", None)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1726,6 +1726,81 @@ function fetchContentSummary(source, path) {
|
|||||||
.then((doc) => (doc && typeof doc.summary === "string" ? doc.summary : null));
|
.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,
|
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase 04,
|
||||||
shared component + one-tap submit, phase 05). The group is accessible
|
shared component + one-tap submit, phase 05). The group is accessible
|
||||||
(role=list + aria-label) and wraps cleanly at every width. */
|
(role=list + aria-label) and wraps cleanly at every width. */
|
||||||
@@ -1906,6 +1981,11 @@ function renderStoredMessage(m) {
|
|||||||
wrap.classList.add("is-deflected");
|
wrap.classList.add("is-deflected");
|
||||||
appendMaybeTry(wrap, m.suggestions);
|
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);
|
appendSources(wrap, m.sources);
|
||||||
// Phase 120: a failed turn (m.failed) is a note, not an answer — no
|
// 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
|
// 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");
|
wrap.classList.add("is-deflected");
|
||||||
appendMaybeTry(wrap, ev.suggestions);
|
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);
|
appendSources(wrap, ev.sources);
|
||||||
// Phase 113 (task 02): the cited tier stays the citation
|
// Phase 113 (task 02): the cited tier stays the citation
|
||||||
// surface (the chips above); the related tier (scored docs
|
// 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,
|
tools: toolAcc.length ? toolAcc : undefined,
|
||||||
deflected: !!ev.deflected,
|
deflected: !!ev.deflected,
|
||||||
sources: ev.sources,
|
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
|
// Phase 113 (task 02): the related tier persists with the
|
||||||
// turn (undefined drops the key from the JSON — the house
|
// turn (undefined drops the key from the JSON — the house
|
||||||
// optional-meta pattern), so the restore path re-renders the
|
// optional-meta pattern), so the restore path re-renders the
|
||||||
|
|||||||
@@ -349,6 +349,50 @@ function addBubbleImage(wrap, src, alt) {
|
|||||||
bubble.prepend(img);
|
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
|
/* One stored record through the SAME .msg structure the chat page
|
||||||
* uses (pixel-parity with the chat page's restore path): user → the
|
* uses (pixel-parity with the chat page's restore path): user → the
|
||||||
* .msg.user bubble (with the question's attached image when 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
|
* .msg.brain bubble with the optional
|
||||||
* thinking block (restored COLLAPSED — phase 17), the tool lines,
|
* thinking block (restored COLLAPSED — phase 17), the tool lines,
|
||||||
* the deflection treatment + the plain-text "Maybe try" chips, the
|
* the deflection treatment + the plain-text "Maybe try" chips, the
|
||||||
* plain-text source chips, and the stopped note. NO interactive
|
* brain answer's image figure (phase 124, task 06 — a plain
|
||||||
* markup is ever created here — no buttons, no forms, no links, no
|
* non-interactive <figure>), the plain-text source chips, and the
|
||||||
* click handlers (owner-locked: zero controls). Markdown goes
|
* 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
|
* through the global escape-first renderMarkdown (markdown.js): the
|
||||||
* stored payloads are raw text, so the renderer's XSS safety applies
|
* stored payloads are raw text, so the renderer's XSS safety applies
|
||||||
* unchanged. */
|
* unchanged. */
|
||||||
@@ -385,6 +431,11 @@ function renderSharedMessage(m) {
|
|||||||
wrap.classList.add("is-deflected");
|
wrap.classList.add("is-deflected");
|
||||||
addMaybeTry(wrap, m.suggestions);
|
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);
|
addSources(wrap, m.sources);
|
||||||
if (m.stopped) addStoppedNote(wrap);
|
if (m.stopped) addStoppedNote(wrap);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */
|
.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
|
/* Phase 113 (task 02): the related-docs row — the SECONDARY tier of
|
||||||
scored docs (phase 113 task 01's usefulness bar demotes the
|
scored docs (phase 113 task 01's usefulness bar demotes the
|
||||||
sub-floor hits out of the citation surface; on a deflected turn the
|
sub-floor hits out of the citation surface; on a deflected turn the
|
||||||
|
|||||||
@@ -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_EXTENSIONS"] = str(_Settings.model_fields["image_extensions"].default)
|
||||||
os.environ["BOR_IMAGE_DIR"] = str(_Settings.model_fields["image_dir"].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.db import SessionLocal, db_available # noqa: E402
|
||||||
from app.main import app as fastapi_app # noqa: E402
|
from app.main import app as fastapi_app # noqa: E402
|
||||||
|
|
||||||
|
|||||||
@@ -467,6 +467,56 @@ Implements just enough of the aipi surface:
|
|||||||
the trigger phrase is disjoint from every other trigger's; verified
|
the trigger phrase is disjoint from every other trigger's; verified
|
||||||
2026-09-16: no existing E2E question or fixture file contains the
|
2026-09-16: no existing E2E question or fixture file contains the
|
||||||
phrase, so every other suite is unaffected.
|
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
|
Failure injection (phase 67, LLM retry, TODO.md L3) — deterministic
|
||||||
dead-endpoint behavior for the retry E2E suite (``tests/e2e/
|
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.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.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()
|
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:
|
def _summary_seed_read_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||||
"""Classify a phase-118 scripted summary-read request (see the
|
"""Classify a phase-118 scripted summary-read request (see the
|
||||||
module docstring). The question carries the scripted call (``read
|
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
|
# message, a deflection prompt never carries it, so a marker
|
||||||
# question always gets the echo whatever the gate says.
|
# question always gets the echo whatever the gate says.
|
||||||
answer = _history_echo(body)
|
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:
|
elif "DEFLECT_MODE" in system:
|
||||||
answer = (
|
answer = (
|
||||||
"Ah — I haven't done anything like that, so I don't want to make stuff up! "
|
"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",
|
media_type="text/event-stream",
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
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
|
# Phase 109 (task 03, never-frozen-turn story suite): the
|
||||||
# deterministic REPORTED-REPRO turn (delta → tool →
|
# deterministic REPORTED-REPRO turn (delta → tool →
|
||||||
# thinking-after-delta — TODO.md L3): the scripted first answer,
|
# thinking-after-delta — TODO.md L3): the scripted first answer,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -2507,3 +2507,286 @@ def test_shared_chat_serves_the_user_image_path(
|
|||||||
finally:
|
finally:
|
||||||
db.execute(text("TRUNCATE saved_chats"))
|
db.execute(text("TRUNCATE saved_chats"))
|
||||||
db.commit()
|
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
|
||||||
|
|||||||
@@ -899,6 +899,112 @@ def test_shared_serve_includes_the_user_image_path(
|
|||||||
assert "image" not in messages[2]
|
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(
|
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
|
||||||
admin_client: TestClient,
|
admin_client: TestClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -1305,24 +1305,30 @@ def test_image_route_404_matrix(admin_client: TestClient, db, tmp_path: Path) ->
|
|||||||
_cleanup_kb(db)
|
_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
|
admin_client: TestClient, db, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Phase 79 posture (the task's "PUBLIC, like the document content
|
"""Phase 124 (LOCKED A4 — the answer figure is "visible … on the
|
||||||
endpoint" — the content endpoint has been user-gated since phase
|
shared page"): the BYTES route is PUBLIC (the phase-123
|
||||||
79; the ONLY anonymous surface is the shared chats, PLAN A10): an
|
chat-image precedent — the image is part of a shared chat's
|
||||||
anonymous caller gets 401 ``authentication required`` before any
|
rendered content, and the shared chat is the app's ONLY anonymous
|
||||||
row is read (a FRESH client — the module's fixture ``client``
|
surface, PLAN A10; the unguessable ``uuid4`` document id IS the
|
||||||
stays unsigned here), a signed-in caller gets the bytes."""
|
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)
|
_cleanup_kb(db)
|
||||||
try:
|
try:
|
||||||
doc = _seed_image_doc(db, tmp_path)
|
doc = _seed_image_doc(db, tmp_path)
|
||||||
anonymous = TestClient(fastapi_app)
|
anonymous = TestClient(fastapi_app)
|
||||||
r = anonymous.get(f"/api/documents/{doc.id}/image")
|
r = anonymous.get(f"/api/documents/{doc.id}/image")
|
||||||
assert r.status_code == 401
|
assert r.status_code == 200
|
||||||
assert r.json() == {"detail": "authentication required"}
|
assert r.headers["content-type"].startswith("image/")
|
||||||
# The signed-in client (admin) passes.
|
assert r.content == PNG_1X1
|
||||||
assert admin_client.get(f"/api/documents/{doc.id}/image").status_code == 200
|
# 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:
|
finally:
|
||||||
_cleanup_kb(db)
|
_cleanup_kb(db)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -34,6 +34,7 @@ the LOW prompt and ``PERSONA`` / ``TOOLS_SECTION`` stay byte-identical
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ from app.config import Settings
|
|||||||
from app.models import Document
|
from app.models import Document
|
||||||
from app.rag.prompts import (
|
from app.rag.prompts import (
|
||||||
PERSONA,
|
PERSONA,
|
||||||
|
SHOW_IMAGE_CLAUSE,
|
||||||
SUGGEST_INTRO,
|
SUGGEST_INTRO,
|
||||||
TOOLS_SECTION,
|
TOOLS_SECTION,
|
||||||
_base,
|
_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)
|
high = build_high_prompt([doc], notes=notes, kb_overview=kb)
|
||||||
assert PLAIN_TEXT_ONLY_LINE not in high
|
assert PLAIN_TEXT_ONLY_LINE not in high
|
||||||
assert "you have no tools" not in high
|
assert "you have no tools" not in high
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- phase 124 (task 03, LOCKED A5): the show_image prompt clause ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_image_clause_states_the_four_gate_elements() -> None:
|
||||||
|
"""LOCKED A5: the clause states ALL FOUR gate elements, pinned on
|
||||||
|
stable phrases, and references the tool by its backticked name (the
|
||||||
|
``TOOLS_SECTION`` voice). (1) the image-doc nature — its text is a
|
||||||
|
description GENERATED from the image; (2) the "contains the answer"
|
||||||
|
bar — never a merely related image, never padding; (3) at most one
|
||||||
|
image per answer; (4) the text answer is still provided in full."""
|
||||||
|
assert "`show_image`" in SHOW_IMAGE_CLAUSE # backticked, like the other tools
|
||||||
|
assert "description GENERATED from the image" in SHOW_IMAGE_CLAUSE # (1)
|
||||||
|
assert "what the user actually wants to see" in SHOW_IMAGE_CLAUSE # (2)
|
||||||
|
assert "never for a merely related image" in SHOW_IMAGE_CLAUSE # (2)
|
||||||
|
assert "never to pad an answer" in SHOW_IMAGE_CLAUSE # (2)
|
||||||
|
assert "at most one image per answer" in SHOW_IMAGE_CLAUSE # (3)
|
||||||
|
assert "answer fully in text" in SHOW_IMAGE_CLAUSE # (4)
|
||||||
|
# It is a clause, not a section: no ``<tools>`` markup of its own
|
||||||
|
# (the ``<documents>`` mention is plain text, as in the section copy).
|
||||||
|
assert "<tools>" not in SHOW_IMAGE_CLAUSE
|
||||||
|
assert "</tools>" not in SHOW_IMAGE_CLAUSE
|
||||||
|
|
||||||
|
|
||||||
|
def test_high_prompt_default_stays_byte_identical_to_pre_task() -> None:
|
||||||
|
"""Completion pin: ``build_high_prompt(docs)`` (no new arg) and the
|
||||||
|
explicit ``show_image=False`` are byte-identical to each other and
|
||||||
|
end with the untouched ``TOOLS_SECTION`` — the pre-task pins above
|
||||||
|
(and the sha pins in :mod:`tests.unit.test_prompt_lock`) pass
|
||||||
|
UNMODIFIED, with or without documents."""
|
||||||
|
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||||
|
plain = build_high_prompt([doc])
|
||||||
|
assert build_high_prompt([doc], show_image=False) == plain
|
||||||
|
assert plain.endswith(TOOLS_SECTION)
|
||||||
|
assert SHOW_IMAGE_CLAUSE not in plain
|
||||||
|
empty = build_high_prompt([])
|
||||||
|
assert build_high_prompt([], show_image=False) == empty
|
||||||
|
assert empty.endswith(TOOLS_SECTION)
|
||||||
|
assert SHOW_IMAGE_CLAUSE not in empty
|
||||||
|
|
||||||
|
|
||||||
|
def test_high_prompt_show_image_clause_lands_inside_tools_after_the_copy() -> None:
|
||||||
|
"""Phase 124 (task 03): ``show_image=True`` builds EXACTLY the
|
||||||
|
toggle-off prompt with its final ``</tools>`` replaced by the clause
|
||||||
|
(on its own line) + the closing tag — the prefix up to the clause
|
||||||
|
is byte-identical to the toggle-off output (the three-tool copy
|
||||||
|
untouched), the clause lands AFTER the existing copy and BEFORE the
|
||||||
|
closing tag, and appears exactly once."""
|
||||||
|
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||||
|
off = build_high_prompt([doc])
|
||||||
|
on = build_high_prompt([doc], show_image=True)
|
||||||
|
# The full structural equation (the clause is the ONLY difference).
|
||||||
|
assert on == off[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
|
||||||
|
clause_start = on.index(SHOW_IMAGE_CLAUSE)
|
||||||
|
# The prefix up to the clause is byte-identical to the toggle-off
|
||||||
|
# build minus its closing tag.
|
||||||
|
assert on[:clause_start] == off[: -len("</tools>")]
|
||||||
|
# From the <tools> opener to the clause is TOOLS_SECTION's own text
|
||||||
|
# minus its final </tools> — the constant itself is unedited (sha-
|
||||||
|
# pinned in test_prompt_lock) and the three-tool copy is intact.
|
||||||
|
assert on[on.index("<tools>") : clause_start] == TOOLS_SECTION[: -len("</tools>")]
|
||||||
|
assert "Answer as soon as you have what you need." in on[:clause_start]
|
||||||
|
# The clause (then the closing tag) is the tail of the prompt, after
|
||||||
|
# the existing copy and before the closing tag — exactly once.
|
||||||
|
assert on.endswith(SHOW_IMAGE_CLAUSE + "\n</tools>")
|
||||||
|
assert on.index("Answer as soon as you have what you need.") < clause_start
|
||||||
|
assert clause_start < on.index("</tools>")
|
||||||
|
assert on.count(SHOW_IMAGE_CLAUSE) == 1
|
||||||
|
assert on.count("<tools>") == 1 and on.count("</tools>") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_image_clause_rides_only_the_high_prompt() -> None:
|
||||||
|
"""The clause appears ONLY in the HIGH prompt: the deflection prompt
|
||||||
|
takes NO ``show_image`` flag at all and its output never carries the
|
||||||
|
clause (byte-identical builds with/without notes/overview — a
|
||||||
|
deflected turn has no tools and never shows an image, the phase's
|
||||||
|
locked design)."""
|
||||||
|
assert "show_image" not in inspect.signature(build_deflect_prompt).parameters
|
||||||
|
for prompt in (
|
||||||
|
build_deflect_prompt(["T1", "T2"]),
|
||||||
|
build_deflect_prompt(["T1"], notes=["be concise"], kb_overview=OVERVIEW),
|
||||||
|
build_deflect_prompt([]),
|
||||||
|
):
|
||||||
|
assert SHOW_IMAGE_CLAUSE not in prompt
|
||||||
|
assert "`show_image`" not in prompt
|
||||||
|
assert "at most one image per answer" not in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_high_prompt_empty_documents_show_image_clause_still_in_tools() -> None:
|
||||||
|
"""The "(no documents matched…)" body + ``show_image=True``: the
|
||||||
|
clause still lands inside ``<tools>`` — the grounded turn may still
|
||||||
|
``ls``/``grep`` its way to an image doc and show it."""
|
||||||
|
off = build_high_prompt([])
|
||||||
|
on = build_high_prompt([], show_image=True)
|
||||||
|
assert on == off[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
|
||||||
|
assert "(no documents matched — do not invent specifics)" in on
|
||||||
|
assert on.index("<tools>") < on.index(SHOW_IMAGE_CLAUSE) < on.index("</tools>")
|
||||||
|
assert on.endswith(SHOW_IMAGE_CLAUSE + "\n</tools>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_image_clause_is_last_for_every_high_shape() -> None:
|
||||||
|
"""Steering + KB overview + folder lines + documents all present:
|
||||||
|
the section order is UNCHANGED (``<relevance>`` →
|
||||||
|
``<knowledge_base>`` → ``<tuning>`` → ``<documents>`` → ``<tools>``)
|
||||||
|
and the clause is still the last thing the model reads, inside
|
||||||
|
``<tools>`` after the three-tool copy."""
|
||||||
|
doc = _doc("deploy/k8s.md", "CONTENT_SENTINEL", "K8s Deploy", summary="K8s deploy notes.")
|
||||||
|
lines = ["Homelab/deploy/: k8s.md (1 doc)"]
|
||||||
|
off = build_high_prompt([doc], notes=["be concise"], kb_overview=OVERVIEW, folder_lines=lines)
|
||||||
|
on = build_high_prompt(
|
||||||
|
[doc],
|
||||||
|
notes=["be concise"],
|
||||||
|
kb_overview=OVERVIEW,
|
||||||
|
folder_lines=lines,
|
||||||
|
show_image=True,
|
||||||
|
)
|
||||||
|
assert on == off[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
|
||||||
|
clause_start = on.index(SHOW_IMAGE_CLAUSE)
|
||||||
|
assert on[on.index("<tools>") : clause_start] == TOOLS_SECTION[: -len("</tools>")]
|
||||||
|
for section in ("<relevance>", "<knowledge_base>", "<tuning>", "<documents>"):
|
||||||
|
assert on.index(section) < on.index("<tools>")
|
||||||
|
assert on.index("K8s deploy notes.") < on.index("<tools>") # the summary still seeds
|
||||||
|
assert SHOW_IMAGE_CLAUSE not in off # the toggle-off twin carries none of it
|
||||||
|
|||||||
Reference in New Issue
Block a user