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
|
||||
# (uploads are replaced, checkouts re-cloned)
|
||||
|
||||
# Phase 124 (answer images): off by default; enable alongside
|
||||
# BOR_IMAGES to let the model show a supporting image in its answer
|
||||
# (independent of it — BOR_IMAGES indexes image docs, this one shows
|
||||
# them in answers).
|
||||
# BOR_ANSWER_IMAGES=0
|
||||
|
||||
# --- Chat image questions (phase 123: attach an image to a question) ---
|
||||
# Gated by the SAME BOR_IMAGES toggle above (enable only when the chat
|
||||
# model supports vision). The question image is stored on the server —
|
||||
|
||||
+82
-8
@@ -462,7 +462,14 @@ def plan_turn(
|
||||
fts_hits,
|
||||
False,
|
||||
build_high_prompt(
|
||||
suggested, notes=steering, kb_overview=kb_text, folder_lines=folder_lines
|
||||
suggested,
|
||||
notes=steering,
|
||||
kb_overview=kb_text,
|
||||
folder_lines=folder_lines,
|
||||
# Phase 124 (task 03, LOCKED A5): the grounded HIGH prompt
|
||||
# carries the show_image clause exactly when the
|
||||
# BOR_ANSWER_IMAGES toggle is on (off = byte-identical).
|
||||
show_image=settings.answer_images,
|
||||
),
|
||||
suggested,
|
||||
related_docs,
|
||||
@@ -486,6 +493,51 @@ def plan_turn(
|
||||
)
|
||||
|
||||
|
||||
def merge_cited_docs(
|
||||
read_docs: list[Document],
|
||||
suggested_docs: list[Document],
|
||||
deflected: bool,
|
||||
) -> list[Document]:
|
||||
"""Compose the citation tier (the done frame's ``sources``, PLAN §9).
|
||||
|
||||
Phase 119 (LOCKED A1): on grounded turns the agent's READ DOCS —
|
||||
deduped by ``(source, path)``, read order; on deflected turns
|
||||
nothing (a weak-hit answer is not a citation). Phase 124 (TODO L3)
|
||||
adds the rule's single documented exception: a suggested-tier
|
||||
IMAGE doc's seed block IS its entire content (content == summary
|
||||
== the vision description, phase 122 task 03) — a ``read`` would
|
||||
add only the marker line, so a real model never performs one and
|
||||
the doc's chip never appeared. "Suggested" is therefore equivalent
|
||||
to "read" for it, and it is auto-cited — in SUGGESTION order AFTER
|
||||
the read docs, deduped against them (an image doc the agent also
|
||||
explicitly read appears once, at its read position). Non-image
|
||||
suggested docs are never auto-cited: the phase-119 read-docs-only
|
||||
rule stays intact for text docs (a zero-read text turn still chips
|
||||
nothing).
|
||||
"""
|
||||
if deflected:
|
||||
return []
|
||||
cited: list[Document] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for doc in read_docs:
|
||||
key = (doc.source, doc.path)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
cited.append(doc)
|
||||
# Phase 124 (TODO L3): the suggested-tier IMAGE docs join the
|
||||
# citation tier — "suggested" is equivalent to "read" for them
|
||||
# (their seed block is their entire content), so a zero-read
|
||||
# grounded turn chips them too. Suggestion order, deduped.
|
||||
for doc in suggested_docs:
|
||||
if not doc.is_image:
|
||||
continue
|
||||
key = (doc.source, doc.path)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
cited.append(doc)
|
||||
return cited
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
async def chat(
|
||||
request: ChatRequest,
|
||||
@@ -1035,15 +1087,21 @@ async def chat(
|
||||
# phase-119 cite discipline, A5) — and [] on
|
||||
# deflected ones (a deflected answer cites nothing;
|
||||
# the weak hits stay in the durable record).
|
||||
# Phase 124 (TODO L3) gives that rule its single
|
||||
# documented exception — the suggested-tier IMAGE
|
||||
# docs (``merge_cited_docs``, below): a seed block
|
||||
# IS an image doc's entire content, so "suggested"
|
||||
# is equivalent to "read" for it and it is
|
||||
# auto-cited in suggestion order AFTER the read
|
||||
# docs, deduped (a read image doc stays at its read
|
||||
# position, once). Text docs keep the read-docs-only
|
||||
# rule — a zero-read text turn still chips nothing.
|
||||
# A cancelled turn (the generator closed by the
|
||||
# consumer) never reaches this step — no query_log row.
|
||||
cited_docs: list[Document] = []
|
||||
cited_seen: set[tuple[str, str]] = set()
|
||||
for doc in holder.read_docs:
|
||||
key = (doc.source, doc.path)
|
||||
if key not in cited_seen:
|
||||
cited_seen.add(key)
|
||||
cited_docs.append(doc)
|
||||
cited_docs = merge_cited_docs(
|
||||
holder.read_docs, plan.suggested_docs, plan.deflected
|
||||
)
|
||||
cited_seen = {(d.source, d.path) for d in cited_docs}
|
||||
record_docs: list[Document] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for doc in [*plan.suggested_docs, *plan.related_docs, *holder.read_docs]:
|
||||
@@ -1124,12 +1182,28 @@ async def chat(
|
||||
for d in plan.related_docs
|
||||
if (d.source, d.path) not in cited_seen
|
||||
]
|
||||
# Phase 124 (task 04): the answer's first-class image —
|
||||
# the shown document's ref (the LLM's ``show_image``
|
||||
# pick, task 02's ``holder.shown_images``), built by the
|
||||
# SAME shared ref builder (source/path/title +
|
||||
# ``image_url``). ``None`` on every other turn — a
|
||||
# deflected turn runs no agent (``shown_images`` is
|
||||
# always empty) and an image-less grounded turn never
|
||||
# called the tool — and ``None`` is DROPPED on
|
||||
# serialization, so those frames stay byte-identical to
|
||||
# pre-phase (no key, no null).
|
||||
answer_ref = (
|
||||
source_ref_with_image(holder.shown_images[0])
|
||||
if holder.shown_images
|
||||
else None
|
||||
)
|
||||
yield sse_event(
|
||||
ChatDoneEvent(
|
||||
deflected=plan.deflected,
|
||||
sources=cited_refs,
|
||||
related=related_refs,
|
||||
suggestions=plan.suggestions,
|
||||
answer_image=answer_ref,
|
||||
).model_dump()
|
||||
)
|
||||
finally:
|
||||
|
||||
+22
-10
@@ -28,12 +28,17 @@ BYTES route: the persistent copy behind an ``is_image`` document's
|
||||
``image_path``, served with the extension's ``Content-Type`` (the
|
||||
``app.rag.summarizer.IMAGE_MIMES`` map — one map, one truth) and a
|
||||
``Cache-Control: private, max-age=3600`` header (the bytes are
|
||||
content-hashed — long enough, bustable by re-upload). User-gated like
|
||||
the content endpoint (phase 79 — the ONLY anonymous surface is the
|
||||
shared chats): a missing doc, a non-image doc, a doc whose
|
||||
content-hashed — long enough, bustable by re-upload). PUBLIC (phase
|
||||
124 — the phase-123 chat-image precedent: the image is part of a
|
||||
shared chat's rendered content, and the shared chat — the app's ONLY
|
||||
anonymous surface — must render the answer figure faithfully for
|
||||
guests; the unguessable ``uuid4`` document id IS the credential, no
|
||||
enumeration value): a missing doc, a non-image doc, a doc whose
|
||||
``image_path`` is NULL, or a row whose copy was lost all map to 404
|
||||
``document not found`` (the router's unknown-document shape —
|
||||
traversal/UUID-guessing has no filesystem surface to hit).
|
||||
traversal/UUID-guessing has no filesystem surface to hit). The
|
||||
document's TEXT (the content endpoint) stays user-gated — only the
|
||||
bytes are public.
|
||||
|
||||
PATCH /api/folders/summary — the admin folder-description editor
|
||||
(phase 97, task 03): update / create / clear a stored
|
||||
@@ -212,16 +217,23 @@ def get_document_content(
|
||||
def get_document_image(
|
||||
doc_id: str,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
_user: None = Depends(require_user), # noqa: B008 # phase 79 posture (see docstring)
|
||||
) -> FileResponse:
|
||||
"""The phase-122 (task 04) image BYTES route — the persistent copy
|
||||
behind an ``is_image`` document's ``image_path``.
|
||||
|
||||
Same auth posture as the document content endpoint (phase 79 —
|
||||
``require_user``: admin OR live token holder; the ONLY anonymous
|
||||
surface is the shared chats — the image is part of a document's
|
||||
content, so it travels under the same gate): anonymous callers get
|
||||
401 ``authentication required`` before any row is read.
|
||||
PUBLIC (phase 124, LOCKED A4 — "visible … on the shared page"): a
|
||||
guest opening a shared chat sees the brain answer's image figure,
|
||||
so the bytes must serve ANONYMOUSLY. The trust model is the
|
||||
phase-123 chat-image precedent (the question attachment travels in
|
||||
the SAME shared content): the document id is an unguessable
|
||||
server-minted ``uuid4`` — IT IS the credential, with no
|
||||
enumeration value — exactly like the saved-chat id (phase 55) and
|
||||
the chat-image filename (phase 123). (The phase-122/79
|
||||
``require_user`` posture was set before the image became part of a
|
||||
shared chat's rendered content; the shared page's figure — built
|
||||
on this route per the phase-124 design — 401'd and collapsed for
|
||||
every guest, contradicting the owner-locked A4. The document's
|
||||
TEXT stays user-gated: only the bytes are public.)
|
||||
|
||||
404 ``document not found`` (the router's unknown-document shape) in
|
||||
every non-servable case — a missing id (an unparseable string maps
|
||||
|
||||
@@ -412,6 +412,23 @@ class Settings(BaseSettings):
|
||||
#: (the ``upload_max_mb`` pattern).
|
||||
chat_image_max_mb: int = 10
|
||||
|
||||
# --- Answer images (phase 124: the LLM shows a supporting image in the answer) ---
|
||||
#: Feature toggle for the answer-image capability (phase 124,
|
||||
#: ``BOR_ANSWER_IMAGES``; ``0``/``false`` = off — the DEFAULT,
|
||||
#: LOCKED A2). The single knob that turns the whole feature on or
|
||||
#: off. Gates the ANSWER side ONLY: while on, the grounded agent is
|
||||
#: offered the ``show_image`` tool, the HIGH prompt carries its
|
||||
#: teaching clause, and a shown doc rides the done frame's
|
||||
#: ``answer_image`` key (and the saved record's). While off,
|
||||
#: behavior is byte-identical everywhere — no ``show_image`` tool
|
||||
#: offered, no prompt clause, no ``answer_image`` key in any frame
|
||||
#: or record. INDEPENDENT of ``images``/``BOR_IMAGES`` (LOCKED A2):
|
||||
#: that toggle indexes image documents, this one decides whether
|
||||
#: the model may show one in an answer — ``on`` with ``images`` off
|
||||
#: is a coherent (if image-less) state: the tool is offered, finds
|
||||
#: no image docs, and always refuses.
|
||||
answer_images: bool = False
|
||||
|
||||
# --- Docs push (phase 59: save a chat answer as documentation) ---
|
||||
#: The git repo a saved chat answer is committed to (phase 59, D3):
|
||||
#: **any** remote — a URL (``https://``, ``ssh://``, ``git@``) or a
|
||||
|
||||
+143
-2
@@ -390,6 +390,71 @@ AGENT_TOOLS: list[dict[str, Any]] = [
|
||||
},
|
||||
]
|
||||
|
||||
#: The 4th agent tool (phase 124, task 02; LOCKED A3/A5) — offered
|
||||
#: ONLY when ``settings.answer_images`` (``BOR_ANSWER_IMAGES``) is true:
|
||||
#: :func:`agent_tools` composes the offer list (the three base tools
|
||||
#: stay :data:`AGENT_TOOLS`, byte-identical). The description IS the
|
||||
#: relevance gate (A5 — "we don't want to just show every image that
|
||||
#: seems relevant"): call it only when the image itself contains the
|
||||
#: answer, at most ONE image per answer, the text answer is always
|
||||
#: given in full (the image supplements, never replaces). Same OpenAI
|
||||
#: function shape as the other three (one required string arg ``path``,
|
||||
#: the combined ``source/path`` identity — the bare-path-will-not-
|
||||
#: resolve teaching mirrored from ``read``).
|
||||
SHOW_IMAGE_TOOL: dict[str, Any] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "show_image",
|
||||
"description": (
|
||||
"Show a standalone image document to the user as part of "
|
||||
"your answer. Call it only when the image itself contains "
|
||||
"the answer — a diagram, screenshot, or photo of the thing "
|
||||
"the user asked about — never for a merely related image. "
|
||||
"Show at most one image per answer. The image supplements "
|
||||
"your text answer — answer fully in text either way. Its "
|
||||
"`path` is the combined `source/path` string, exactly as "
|
||||
"shown in the `ls` output or the `<documents>` blocks — a "
|
||||
"bare document path will not resolve. Call one tool at a "
|
||||
"time — wait for this result before your next call."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The image document to show, as the combined "
|
||||
"`source/path` string exactly as shown in the "
|
||||
"`ls` output (e.g. "
|
||||
"'homelab/docs/architecture.png'). A bare "
|
||||
"document path (without the source name) will "
|
||||
"not resolve. Show at most one image per "
|
||||
"answer — a second call is refused."
|
||||
),
|
||||
}
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def agent_tools(settings: Settings) -> list[dict[str, Any]]:
|
||||
"""The grounded turn's tool offer list (phase 124, task 02).
|
||||
|
||||
The three base tools (:data:`AGENT_TOOLS`, byte-identical) plus
|
||||
:data:`SHOW_IMAGE_TOOL` when ``settings.answer_images``
|
||||
(``BOR_ANSWER_IMAGES``) is true — otherwise exactly the three base
|
||||
tools (the pre-phase-124 offer, byte-identical: no 4th tool in the
|
||||
wire ``tools`` array). A FRESH list on every call — callers never
|
||||
mutate the result or the constants (``run_agent`` passes it straight
|
||||
to the LLM request).
|
||||
"""
|
||||
tools: list[dict[str, Any]] = list(AGENT_TOOLS)
|
||||
if settings.answer_images:
|
||||
tools.append(SHOW_IMAGE_TOOL)
|
||||
return tools
|
||||
|
||||
#: Tool refusal texts (phase 37): rejected calls count in nothing
|
||||
#: (``holder.tool_calls`` tracks executed calls); the round cap bounds
|
||||
#: their pathological repetition (phase 45). The in-context line is a
|
||||
@@ -407,6 +472,24 @@ ALREADY_IN_CONTEXT = (
|
||||
UNKNOWN_TOOL = "Unknown tool."
|
||||
MISSING_READ_ARGS = "read requires a string argument 'path'."
|
||||
MISSING_SEARCH_ARGS = "grep requires a string argument 'pattern'."
|
||||
MISSING_SHOW_ARGS = "show_image requires a string argument 'path'."
|
||||
|
||||
#: The ``show_image`` refusals (phase 124, task 02): the same contract
|
||||
#: as every other refusal — counts in nothing, consumes a round,
|
||||
#: changes no context. ``NOT_AN_IMAGE_DOC``: one ``{arg}`` field, the
|
||||
#: argument echoed as passed — a text doc is refused, never silently
|
||||
#: converted to a read (LOCKED A5). ``ALREADY_SHOWING``: the LOCKED A3
|
||||
#: one-image-per-answer gate, enforced SERVER-side (the model is taught
|
||||
#: it in the tool description + the prompt clause; the handler is the
|
||||
#: actual gate).
|
||||
NOT_AN_IMAGE_DOC = (
|
||||
"'{arg}' is not an image document — only standalone image "
|
||||
"documents can be shown as answers."
|
||||
)
|
||||
ALREADY_SHOWING = (
|
||||
"Already showing an image this answer — do not show another one. "
|
||||
"Answer from what you have."
|
||||
)
|
||||
|
||||
#: The read-truncation notice (phase 95, task 01) — appended, after the
|
||||
#: shared :data:`TRUNCATION_MARKER`, to a ``read`` result whose document
|
||||
@@ -1166,12 +1249,19 @@ class AgentHolder:
|
||||
as today — this list only carries the truncation signal the agent
|
||||
loop turns into :class:`app.rag.llm.ToolResultPiece` values (task 02
|
||||
surfaces them to the UI).
|
||||
``shown_images`` (phase 124, task 02): the documents ``show_image``
|
||||
showed, in call order — at most one in practice (LOCKED A3: a
|
||||
second call is refused with :data:`ALREADY_SHOWING`, which appends
|
||||
nothing). Read by ``app.api.chat`` after the stream for the done
|
||||
frame's ``answer_image`` field (task 04) — the ``read_docs``
|
||||
precedent (an API-layer read-after-stream signal).
|
||||
"""
|
||||
|
||||
read_docs: list[Document] = field(default_factory=list)
|
||||
tool_calls: int = 0
|
||||
scaffold_stripped: int = 0
|
||||
read_truncations: list[tuple[str, int, int]] = field(default_factory=list)
|
||||
shown_images: list[Document] = field(default_factory=list)
|
||||
|
||||
|
||||
def _execute_tool(
|
||||
@@ -1327,6 +1417,51 @@ def _execute_tool(
|
||||
f"{marker}"
|
||||
f"{doc.content}"
|
||||
)
|
||||
if call.name == "show_image":
|
||||
# Phase 124 (task 02; LOCKED A3/A5): the 4th agent tool — the
|
||||
# model shows a standalone image document as part of its
|
||||
# answer. Offered only when ``settings.answer_images``
|
||||
# (``agent_tools``); the handler enforces the contract
|
||||
# server-side — image docs only, ONE image per answer. Every
|
||||
# refusal below counts in nothing and consumes a round, like
|
||||
# every other refusal.
|
||||
raw_path = call.arguments.get("path")
|
||||
arg = raw_path.strip() if isinstance(raw_path, str) else ""
|
||||
if not arg:
|
||||
return MISSING_SHOW_ARGS
|
||||
doc, _source, _path = _resolve_path(db, arg)
|
||||
if doc is None:
|
||||
# The same phase-72 "did you mean …?" teaching as the read
|
||||
# branch (a refusal — not counted, no context).
|
||||
return _no_document_refusal(db, arg)
|
||||
if not doc.is_image:
|
||||
# LOCKED A5: a text doc is refused, never silently
|
||||
# converted to a read — the tool shows IMAGE documents
|
||||
# only.
|
||||
return NOT_AN_IMAGE_DOC.format(arg=arg)
|
||||
if holder.shown_images:
|
||||
# LOCKED A3: one image per answer — server-enforced, not
|
||||
# just taught.
|
||||
return ALREADY_SHOWING
|
||||
holder.shown_images.append(doc)
|
||||
holder.tool_calls += 1
|
||||
# The confirming result: a NEW first line (absent from ``read``
|
||||
# — the model must know the show registered) followed by the
|
||||
# exact document block ``read`` returns for an image doc at-or-
|
||||
# under-cap (the ``Document …`` header + phase-106 date line +
|
||||
# :data:`IMAGE_DOC_MARKER` — the header contract the E2E mock
|
||||
# pins on ``read`` results is deliberately reused here). An
|
||||
# image doc's content is the summary-capped vision description
|
||||
# — short by construction (phase 122, task 03), so NO
|
||||
# truncation machinery; the plain block stays correct even if
|
||||
# it ever exceeded the cap.
|
||||
return (
|
||||
"Image shown to the user — it is now part of your answer.\n"
|
||||
f"Document {doc.source}/{doc.path}:\n"
|
||||
f"date: {doc.created_at:%Y-%m-%d}\n"
|
||||
f"{IMAGE_DOC_MARKER}\n"
|
||||
f"{doc.content}"
|
||||
)
|
||||
if call.name == "grep":
|
||||
raw_pattern = call.arguments.get("pattern")
|
||||
pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
|
||||
@@ -1485,9 +1620,15 @@ async def run_agent(
|
||||
# Phase 45: no per-tool budgets — the tools stay offered for the
|
||||
# whole turn, bounded by the round cap. ``0`` is the no-tools kill
|
||||
# switch: exactly one request with ``tools=None`` (the pre-phase-37
|
||||
# path).
|
||||
# path). Phase 124 (task 02): the offer list is :func:`agent_tools`
|
||||
# — the three base tools (byte-identical) plus ``show_image`` only
|
||||
# when ``settings.answer_images`` (``BOR_ANSWER_IMAGES``) is on;
|
||||
# off (the default) the offer is element-for-element the
|
||||
# pre-phase-124 list.
|
||||
max_rounds = settings.agent_max_rounds
|
||||
tools: list[dict[str, Any]] | None = AGENT_TOOLS if max_rounds > 0 else None
|
||||
tools: list[dict[str, Any]] | None = (
|
||||
agent_tools(settings) if max_rounds > 0 else None
|
||||
)
|
||||
rounds = 0
|
||||
while True:
|
||||
calls: list[ToolCallPiece] = []
|
||||
|
||||
+67
-2
@@ -258,6 +258,43 @@ TOOLS_SECTION: str = (
|
||||
"</tools>"
|
||||
)
|
||||
|
||||
#: The ``show_image`` contract clause (phase 124, task 03, LOCKED A5) —
|
||||
#: appended INSIDE the ``<tools>`` section of the HIGH prompt, after the
|
||||
#: existing three-tool copy and before the closing tag, ONLY when
|
||||
#: :func:`build_high_prompt` is called with ``show_image=True`` (the
|
||||
#: ``BOR_ANSWER_IMAGES`` toggle, via ``app.api.chat``). It teaches the
|
||||
#: same relevance gate as the ``show_image`` tool description in
|
||||
#: :mod:`app.rag.agent` (LOCKED A5: the gate is the LLM's decision — no
|
||||
#: server-side image scoring), plus the image-doc nature the tool
|
||||
#: description cannot fully carry: the text of an image document (in a
|
||||
#: ``<documents>`` block or a ``read`` result) is a description
|
||||
#: GENERATED from the image, not the image's own words. The four gate
|
||||
#: elements, all stated here: (1) the image-doc nature; (2) the
|
||||
#: "contains the answer" bar — call it only when THAT image is what the
|
||||
#: user actually wants to see, never a merely related image, never to
|
||||
#: pad an answer; (3) at most ONE image per answer (also enforced
|
||||
#: server-side, task 02's ``ALREADY_SHOWING`` refusal); (4) the text
|
||||
#: answer is still provided in full — the image supplements, never
|
||||
#: replaces. ``TOOLS_SECTION`` itself stays BYTE-IDENTICAL (the
|
||||
#: pre-phase pins in :mod:`tests.unit.test_prompt_lock` hold); a
|
||||
#: ``show_image=False`` build (the default — every pre-phase call site)
|
||||
#: is byte-identical to the pre-task output. The ``<tools>`` marker the
|
||||
#: E2E mock keys on is untouched either way — the clause rides on its
|
||||
#: presence, not the wording. The LOW/deflection prompt never carries
|
||||
#: the clause (a deflected turn has no tools and never shows an image —
|
||||
#: the phase's locked design).
|
||||
SHOW_IMAGE_CLAUSE: str = (
|
||||
"`show_image` displays a standalone image document as part of your "
|
||||
"answer. The text of an image document — in a <documents> block or "
|
||||
"a `read` result — is a description GENERATED from the image, not "
|
||||
"the image's own words. Call `show_image` only when that image is "
|
||||
"what the user actually wants to see: a diagram, screenshot, or "
|
||||
"photo of the very thing you are answering about — never for a "
|
||||
"merely related image, and never to pad an answer. Show at most one "
|
||||
"image per answer. The image supplements your text — answer fully "
|
||||
"in text either way."
|
||||
)
|
||||
|
||||
|
||||
def history_to_messages(
|
||||
history: Sequence[HistoryTurn],
|
||||
@@ -426,6 +463,7 @@ def build_high_prompt(
|
||||
notes: Sequence[str] | None = None,
|
||||
kb_overview: str | None = None,
|
||||
folder_lines: Sequence[str] = (),
|
||||
show_image: bool = False,
|
||||
) -> str:
|
||||
"""Grounded turn: locked persona (+ steering, + KB overview) + the
|
||||
top-ranked documents' SUMMARY blocks + the ``<tools>`` instructions
|
||||
@@ -434,7 +472,8 @@ def build_high_prompt(
|
||||
phase 118, LOCKED A6 re-revising A7: the ``<documents>`` section
|
||||
seeds SUMMARIES, never full texts — full text enters the context
|
||||
only through the capped ``read`` tool; phase 119, LOCKED A4: the
|
||||
suggested-folder context lines, below).
|
||||
suggested-folder context lines, below; phase 124, task 03, LOCKED
|
||||
A5: the optional :data:`SHOW_IMAGE_CLAUSE`, below).
|
||||
|
||||
Section order: ``<relevance>`` → ``<knowledge_base>`` → ``<tuning>``
|
||||
→ ``<documents>`` → ``<tools>``; empty steering/overview omit their
|
||||
@@ -442,6 +481,23 @@ def build_high_prompt(
|
||||
cap — not the prompt — decides whether the tools are actually
|
||||
offered to the model, see :mod:`app.rag.agent`).
|
||||
|
||||
*show_image* (phase 124, task 03, LOCKED A5): when true (the
|
||||
caller passes the ``BOR_ANSWER_IMAGES`` toggle —
|
||||
``app.api.chat``'s grounded plan is the only call site that does),
|
||||
the :data:`SHOW_IMAGE_CLAUSE` rides INSIDE the ``<tools>`` section
|
||||
— appended after the existing three-tool copy and before the
|
||||
closing ``</tools>`` tag, so the ``<tools>`` marker the E2E mock
|
||||
keys on is untouched and the clause reaches the model together with
|
||||
the tool teaching. The section order is otherwise UNCHANGED and
|
||||
:data:`TOOLS_SECTION` itself is never edited: the built section is
|
||||
``TOOLS_SECTION``'s text minus its final ``</tools>`` + the clause
|
||||
+ the closing tag. The default ``False`` keeps EVERY pre-existing
|
||||
call byte-identical (the pre-task pins in
|
||||
:mod:`tests.unit.test_prompts` / :mod:`tests.unit.test_prompt_lock`
|
||||
hold untouched). The deflection prompt (:func:`build_deflect_prompt`)
|
||||
never carries the clause — a deflected turn has no tools and never
|
||||
shows an image.
|
||||
|
||||
When at least one block is present, the section leads with the
|
||||
start-here :data:`SUGGEST_INTRO` line (before the first block — the
|
||||
phase-15 ``_STEERING_INTRO`` / phase-31 ``_KB_INTRO`` precedent): the
|
||||
@@ -506,7 +562,16 @@ def build_high_prompt(
|
||||
for part in (build_kb_section(kb_overview or ""), build_steering_section(notes or [])):
|
||||
if part:
|
||||
prompt += "\n" + part
|
||||
return prompt + "\n<documents>\n" + body + "\n</documents>\n" + TOOLS_SECTION
|
||||
if show_image:
|
||||
# Phase 124 (task 03, LOCKED A5): the clause rides inside
|
||||
# <tools> — TOOLS_SECTION's text minus its final </tools>, the
|
||||
# clause (on its own line, after the existing three-tool copy),
|
||||
# then the closing tag. TOOLS_SECTION itself is unedited and the
|
||||
# show_image=False path keeps the exact pre-task concatenation.
|
||||
tools_section = TOOLS_SECTION[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
|
||||
else:
|
||||
tools_section = TOOLS_SECTION
|
||||
return prompt + "\n<documents>\n" + body + "\n</documents>\n" + tools_section
|
||||
|
||||
|
||||
def build_deflect_prompt(
|
||||
|
||||
+69
-10
@@ -242,6 +242,21 @@ class ChatDoneEvent(BaseModel):
|
||||
citation chip — while ``sources`` stays the citation surface. The
|
||||
field is ADDITIVE: old clients ignore unknown fields (PLAN §4 house
|
||||
contract) and old frames without it parse with the default ``[]``.
|
||||
|
||||
Phase 124 (task 04): ``answer_image`` — the brain answer's
|
||||
FIRST-CLASS image: the standalone image document the LLM chose to
|
||||
show as part of its answer (its ``show_image`` pick, behind
|
||||
``BOR_ANSWER_IMAGES``; LOCKED A5 — the relevance gate is the model's
|
||||
decision). It is the shown document's :class:`SourceRef` built by
|
||||
the shared :func:`app.rag.retriever.source_ref_with_image` (source /
|
||||
path / title + the ``image_url`` bytes route), distinct from a
|
||||
citation in ``sources`` — the document may be cited as well, but
|
||||
this key IS the answer's image (the UI's answer figure, task 06).
|
||||
``None`` (every deflected turn — they run no agent — every
|
||||
image-less turn, and the whole pre-phase) is DROPPED on
|
||||
serialization — ABSENT, never ``null`` — so those frames stay
|
||||
byte-identical to pre-phase (the byte-identical-off contract, task
|
||||
01; the :class:`SourceRef.image_url` omission precedent).
|
||||
"""
|
||||
|
||||
type: str = "done"
|
||||
@@ -249,6 +264,25 @@ class ChatDoneEvent(BaseModel):
|
||||
sources: list[SourceRef]
|
||||
related: list[SourceRef] = []
|
||||
suggestions: list[str] = []
|
||||
#: Phase 124 (task 04) — see the class docstring. A single
|
||||
#: :class:`SourceRef` (no list — LOCKED A3: one image per answer);
|
||||
#: the ref's own phase-83 field caps bind.
|
||||
answer_image: SourceRef | None = None
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||||
"""The phase-124 answer-image omission rule: ``answer_image``
|
||||
``None`` (every deflected / image-less / pre-phase turn)
|
||||
serializes WITHOUT the key — ABSENT, never ``null`` — so those
|
||||
done frames stay byte-identical to pre-phase (the
|
||||
:class:`SourceRef.image_url` / phase-123 ``image`` omission
|
||||
precedents). A shown ref keeps all its fields (its own
|
||||
serializer drops a ``None`` ``image_url`` — a shown doc is an
|
||||
image doc, so in practice it always carries it)."""
|
||||
data = handler(self)
|
||||
if self.answer_image is None:
|
||||
data.pop("answer_image", None)
|
||||
return data
|
||||
|
||||
|
||||
class ChatErrorEvent(BaseModel):
|
||||
@@ -967,22 +1001,47 @@ class ChatMessage(BaseModel):
|
||||
# omission precedent). The path is ≤ 500 chars — no phase-83
|
||||
# cap pressure (it is never a data URL, LOCKED A5).
|
||||
image: str | None = Field(default=None, max_length=500)
|
||||
# Phase 124 (task 04): the brain answer's FIRST-CLASS image — the
|
||||
# shown document's :class:`SourceRef` (the LLM's ``show_image``
|
||||
# pick, ``BOR_ANSWER_IMAGES``), on the BRAIN record only. It is a
|
||||
# DIFFERENT thing from the user record's phase-123 ``image`` (the
|
||||
# stored PATH STRING of the question's attachment): that field is
|
||||
# untouched and the two are semantically distinct — the attachment
|
||||
# belongs to the question, the answer image to the brain's reply
|
||||
# (a user record never carries ``answer_image``, a brain record
|
||||
# never carries ``image``). A single ref — no length cap needed
|
||||
# (the ref's own phase-83 field caps bind). The done-time
|
||||
# auto-save (``rememberBrainTurn``, task 06) persists the done
|
||||
# frame's ``answer_image`` into this key, and the restore path
|
||||
# re-renders the answer figure from it — the field must exist so
|
||||
# the ``extra="forbid"`` boundary does NOT 422 those saves (the
|
||||
# phase-113 lesson, applied up front: a missing key silently
|
||||
# unsaved every grounded brain record). ``None`` (every pre-phase
|
||||
# record, every image-less turn) serializes WITHOUT the key —
|
||||
# ABSENT, never ``null`` (the :meth:`_drop_image_when_absent`
|
||||
# serializer below), so pre-phase and image-less chats round-trip
|
||||
# byte-identically (the phase-50 contract).
|
||||
answer_image: SourceRef | None = None
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _drop_image_when_absent(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||||
"""The phase-123 image omission rule: ``image: None`` (every
|
||||
text-only record, and every pre-phase-123 record) serializes
|
||||
WITHOUT the key — ABSENT, never ``null`` — so the stored
|
||||
``bor.chat.v1`` JSONB and the saved/shared wire shape stay
|
||||
byte-identical to pre-phase for text-only chats (the phase-50
|
||||
round-trip contract). A record WITH an image keeps the path —
|
||||
the user bubble, the refreshed page, and the shared chat all
|
||||
render it from the served route (the image is part of the
|
||||
chat's content, so it rides the same public/credential-
|
||||
is-the-id trust model)."""
|
||||
"""The image omission rules: ``image: None`` (every text-only
|
||||
record, and every pre-phase-123 record) and ``answer_image:``
|
||||
``None`` (every record without a shown answer image, and every
|
||||
pre-phase-124 record) serialize WITHOUT the keys — ABSENT,
|
||||
never ``null`` — so the stored ``bor.chat.v1`` JSONB and the
|
||||
saved/shared wire shape stay byte-identical to pre-phase for
|
||||
those records (the phase-50 round-trip contract; the
|
||||
phase-122 ``SourceRef.image_url`` omission precedent). A
|
||||
record WITH one keeps it — the user bubble, the refreshed page,
|
||||
and the shared chat all render the image from the served route
|
||||
(the image is part of the chat's content, so it rides the same
|
||||
public/credential-is-the-id trust model)."""
|
||||
data = handler(self)
|
||||
if self.image is None:
|
||||
data.pop("image", None)
|
||||
if self.answer_image is None:
|
||||
data.pop("answer_image", None)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -1726,6 +1726,81 @@ function fetchContentSummary(source, path) {
|
||||
.then((doc) => (doc && typeof doc.summary === "string" ? doc.summary : null));
|
||||
}
|
||||
|
||||
/* Phase 124 (task 06, TODO L3): the LLM's ANSWER IMAGE — the brain
|
||||
* answer's first-class figure (the `done` frame's `answer_image` ref,
|
||||
* task 04). A4: it sits in the .msg-body BELOW the answer text and
|
||||
* ABOVE the source-chip row — the callers append it right BEFORE
|
||||
* appendSources creates the chip row, so it lands in between. The
|
||||
* text bubble is untouched: the figure is ADDITIVE ("summaries
|
||||
* should still be provided").
|
||||
*
|
||||
* The visual sibling of appendSourceImageFigure (the compact chip
|
||||
* figure) but a different surface: the answer figure is the answer's
|
||||
* own image, not a chip accessory — .answer-image scales it up (the
|
||||
* .source-image language at answer size, styles.css). Same WCAG alt
|
||||
* contract as the chip figure: alt + the VISIBLE caption = the
|
||||
* document's SUMMARY (the vision description); the frame carries no
|
||||
* summary (the ref is source/path/title/image_url), so the figure
|
||||
* fetches the content endpoint the chip's modal already uses and
|
||||
* swaps the summary in; until it settles — and when it fails — the
|
||||
* title stands in (a caption is ALWAYS visible). A FAILED IMAGE LOAD
|
||||
* REMOVES the whole figure (the collapse rule — the answer figure has
|
||||
* no chip of its own to fall back to; the chip row below still cites
|
||||
* the doc, the phase-124 auto-cite): an answer whose image bytes are
|
||||
* gone renders as a normal answer, never a broken-image icon.
|
||||
* Navigation reuses the chip affordance: the same documentUrl href
|
||||
* (the /document.html escape hatch) + left-click → same-page modal
|
||||
* (phase 26, the appendSources/appendSourceImageFigure pattern). */
|
||||
function appendAnswerImage(wrap, ref) {
|
||||
if (
|
||||
!ref ||
|
||||
typeof ref.source !== "string" ||
|
||||
typeof ref.path !== "string" ||
|
||||
typeof ref.image_url !== "string" ||
|
||||
!ref.image_url
|
||||
)
|
||||
return; // no ref (or a malformed one) → no figure DOM at all
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
const label = `${ref.source}/${ref.path}`;
|
||||
const fig = document.createElement("a");
|
||||
fig.className = "answer-image";
|
||||
fig.href = documentUrl(ref.source, ref.path, "/"); // back → the chat page
|
||||
fig.title = label; // full path as the native tooltip (chip pattern)
|
||||
fig.setAttribute("aria-label", label); // the accessible name is the full path
|
||||
fig.addEventListener("click", (e) => {
|
||||
e.preventDefault(); // no new tab (phase 26) — the modal takes over
|
||||
e.stopPropagation();
|
||||
openDocumentModal(ref.source, ref.path, fig);
|
||||
});
|
||||
const img = document.createElement("img");
|
||||
img.className = "answer-image-img";
|
||||
img.src = ref.image_url; // the public bytes route (task 04's ref)
|
||||
img.loading = "lazy"; // A4: lazy-loaded
|
||||
img.alt = ref.title || label; // the summary arrives via the fetch below
|
||||
img.addEventListener("error", () => {
|
||||
// Collapse to NOTHING (there is no chip inside the figure to fall
|
||||
// back to) — the answer renders normally, the chip row below
|
||||
// still cites the doc.
|
||||
fig.remove();
|
||||
});
|
||||
const caption = document.createElement("span");
|
||||
caption.className = "answer-image-caption";
|
||||
caption.textContent = ref.title || label; // visible caption: the title first…
|
||||
fig.append(img, caption);
|
||||
body.appendChild(fig);
|
||||
// …and the document's summary once the content fetch settles (the
|
||||
// chip figure's SAME pattern — fetchContentSummary fails soft to
|
||||
// null: the title stays).
|
||||
fetchContentSummary(ref.source, ref.path).then((summary) => {
|
||||
if (!fig.isConnected) return; // collapsed (img error) or bubble cleared
|
||||
if (summary && summary.trim() !== "") {
|
||||
img.alt = summary;
|
||||
caption.textContent = summary;
|
||||
caption.title = summary; // full text (the caption is line-clamped)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase 04,
|
||||
shared component + one-tap submit, phase 05). The group is accessible
|
||||
(role=list + aria-label) and wraps cleanly at every width. */
|
||||
@@ -1906,6 +1981,11 @@ function renderStoredMessage(m) {
|
||||
wrap.classList.add("is-deflected");
|
||||
appendMaybeTry(wrap, m.suggestions);
|
||||
}
|
||||
// Phase 124 (task 06, TODO L3): the answer image restores with the
|
||||
// bubble — the SAME helper + the SAME A4 position as the live done
|
||||
// path (below the text, above the chip row). A record without the
|
||||
// key renders byte-identically (pre-phase chats restore unchanged).
|
||||
if (m.answer_image) appendAnswerImage(wrap, m.answer_image);
|
||||
appendSources(wrap, m.sources);
|
||||
// Phase 120: a failed turn (m.failed) is a note, not an answer — no
|
||||
// Tune (the "note, not an answer" scope the m.stopped exclusion
|
||||
@@ -2985,6 +3065,13 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
wrap.classList.add("is-deflected");
|
||||
appendMaybeTry(wrap, ev.suggestions);
|
||||
}
|
||||
// Phase 124 (task 06, TODO L3): the LLM's answer image — the
|
||||
// answer's first-class figure (A4): appended to .msg-body
|
||||
// right BEFORE the chip row, so it lands below the answer
|
||||
// text and above the sources. The text is untouched (the
|
||||
// figure is additive — "summaries should still be provided");
|
||||
// a frame without it renders byte-identically (no figure DOM).
|
||||
if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);
|
||||
appendSources(wrap, ev.sources);
|
||||
// Phase 113 (task 02): the cited tier stays the citation
|
||||
// surface (the chips above); the related tier (scored docs
|
||||
@@ -3019,6 +3106,12 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
tools: toolAcc.length ? toolAcc : undefined,
|
||||
deflected: !!ev.deflected,
|
||||
sources: ev.sources,
|
||||
// Phase 124 (task 06, TODO L3): the answer image persists
|
||||
// with the turn (undefined drops the key from the JSON — the
|
||||
// house optional-meta pattern, the `related` line below is
|
||||
// the precedent): an image-less / pre-phase turn stores a
|
||||
// byte-identical record.
|
||||
answer_image: ev.answer_image || undefined,
|
||||
// Phase 113 (task 02): the related tier persists with the
|
||||
// turn (undefined drops the key from the JSON — the house
|
||||
// optional-meta pattern), so the restore path re-renders the
|
||||
|
||||
@@ -349,6 +349,50 @@ function addBubbleImage(wrap, src, alt) {
|
||||
bubble.prepend(img);
|
||||
}
|
||||
|
||||
/* The brain answer's image (phase 124, task 06, TODO L3) — the local
|
||||
* copy of the chat page's appendAnswerImage (the per-page duplication
|
||||
* house style: this file keeps its own small copies of the chat
|
||||
* page's message-fragment builders). NON-INTERACTIVE like every other
|
||||
* shared-page fragment (owner-locked zero controls: no link, no
|
||||
* modal, no click handler — a plain <figure>, unlike the chat page's
|
||||
* anchor). The bytes route is PUBLIC (the token is the shared chat's
|
||||
* credential, like the phase-123 user-image contract), so the img
|
||||
* loads for guests exactly as it does for the owner. The visible
|
||||
* caption keeps the document's TITLE: the content endpoint the chat
|
||||
* page fetches for the summary is user-gated (a guest fetch would
|
||||
* just 401), and a caption is ALWAYS visible either way (the WCAG
|
||||
* alt contract holds with the title standing in). A failed load
|
||||
* REMOVES the figure (the collapse rule — never a broken-image
|
||||
* icon). */
|
||||
function addAnswerImage(wrap, ref) {
|
||||
if (
|
||||
!ref ||
|
||||
typeof ref.source !== "string" ||
|
||||
typeof ref.path !== "string" ||
|
||||
typeof ref.image_url !== "string" ||
|
||||
!ref.image_url
|
||||
) {
|
||||
return; // no ref (or a malformed one) → no figure DOM at all
|
||||
}
|
||||
const body = wrap?.querySelector?.(".msg-body");
|
||||
if (!body) return;
|
||||
const title =
|
||||
typeof ref.title === "string" && ref.title ? ref.title : "image";
|
||||
const fig = document.createElement("figure");
|
||||
fig.className = "answer-image";
|
||||
const img = document.createElement("img");
|
||||
img.className = "answer-image-img";
|
||||
img.src = ref.image_url; // the public bytes route
|
||||
img.loading = "lazy";
|
||||
img.alt = title;
|
||||
img.addEventListener("error", () => fig.remove()); // collapse: never a broken icon
|
||||
const caption = document.createElement("span");
|
||||
caption.className = "answer-image-caption";
|
||||
caption.textContent = title; // the visible caption (always)
|
||||
fig.append(img, caption);
|
||||
body.appendChild(fig);
|
||||
}
|
||||
|
||||
/* One stored record through the SAME .msg structure the chat page
|
||||
* uses (pixel-parity with the chat page's restore path): user → the
|
||||
* .msg.user bubble (with the question's attached image when the
|
||||
@@ -356,9 +400,11 @@ function addBubbleImage(wrap, src, alt) {
|
||||
* .msg.brain bubble with the optional
|
||||
* thinking block (restored COLLAPSED — phase 17), the tool lines,
|
||||
* the deflection treatment + the plain-text "Maybe try" chips, the
|
||||
* plain-text source chips, and the stopped note. NO interactive
|
||||
* markup is ever created here — no buttons, no forms, no links, no
|
||||
* click handlers (owner-locked: zero controls). Markdown goes
|
||||
* brain answer's image figure (phase 124, task 06 — a plain
|
||||
* non-interactive <figure>), the plain-text source chips, and the
|
||||
* stopped note. NO interactive markup is ever created here — no
|
||||
* buttons, no forms, no links, no click handlers (owner-locked:
|
||||
* zero controls). Markdown goes
|
||||
* through the global escape-first renderMarkdown (markdown.js): the
|
||||
* stored payloads are raw text, so the renderer's XSS safety applies
|
||||
* unchanged. */
|
||||
@@ -385,6 +431,11 @@ function renderSharedMessage(m) {
|
||||
wrap.classList.add("is-deflected");
|
||||
addMaybeTry(wrap, m.suggestions);
|
||||
}
|
||||
// Phase 124 (task 06, TODO L3): the answer image renders with the
|
||||
// brain bubble — the SAME A4 position as the chat page's render
|
||||
// (below the text, above the chip row). A record without the key
|
||||
// renders byte-identically (no figure DOM).
|
||||
addAnswerImage(wrap, m.answer_image);
|
||||
addSources(wrap, m.sources);
|
||||
if (m.stopped) addStoppedNote(wrap);
|
||||
}
|
||||
|
||||
@@ -814,6 +814,53 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
}
|
||||
.source-image:hover { text-decoration: underline; } /* flat — the caption underlines, the chip pattern */
|
||||
|
||||
/* Phase 124 (task 06, TODO L3): the LLM's ANSWER IMAGE — the brain
|
||||
answer's first-class figure (A4: below the text, above the chips).
|
||||
The .source-image language at ANSWER scale — the figure is the
|
||||
answer's own image, not a chip accessory: max-width 100% of the
|
||||
.msg-body column, a 320px height cap (the 46rem chat column — a
|
||||
full-height image would blow the turn's height), contain on the
|
||||
theme's surface with the bordered-image treatment
|
||||
(.source-image-img's border/background/radius), the visible
|
||||
caption in the AA-safe muted ink (the same --ink-soft the chip
|
||||
caption uses — the row's page background, 8.6:1). Token-only (the
|
||||
zero-literal invariant — the monochrome themes carry it). The
|
||||
hover underline applies to the chat page's ANCHOR figure only (the
|
||||
shared page's figure is a plain <figure> — zero controls, the
|
||||
element-type scope keeps it out). */
|
||||
.answer-image {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.answer-image-img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 320px; /* the one free number (task 06): the answer scale */
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.answer-image-caption {
|
||||
max-width: 100%;
|
||||
color: var(--ink-soft); /* 8.6:1 on --bg (AA) — the row's page background */
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3; /* the summary can be long; the alt + title carry the full text */
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
a.answer-image { text-decoration: none; }
|
||||
a.answer-image:hover { text-decoration: underline; } /* flat — the caption underlines, the chip pattern */
|
||||
|
||||
/* Phase 113 (task 02): the related-docs row — the SECONDARY tier of
|
||||
scored docs (phase 113 task 01's usefulness bar demotes the
|
||||
sub-floor hits out of the citation surface; on a deflected turn the
|
||||
|
||||
@@ -78,6 +78,13 @@ os.environ["BOR_IMAGES"] = str(_Settings.model_fields["images"].default)
|
||||
os.environ["BOR_IMAGE_EXTENSIONS"] = str(_Settings.model_fields["image_extensions"].default)
|
||||
os.environ["BOR_IMAGE_DIR"] = str(_Settings.model_fields["image_dir"].default)
|
||||
|
||||
# Phase 124 (task 04): the same leak class for the answer-image toggle
|
||||
# — an operator's local ``.env`` may legitimately carry
|
||||
# ``BOR_ANSWER_IMAGES`` (the dev environment runs the feature on), and
|
||||
# the byte-identical-off pins (the ``show_image`` offer absent, the
|
||||
# ``answer_image`` done-frame key absent) must see the code default.
|
||||
os.environ["BOR_ANSWER_IMAGES"] = str(_Settings.model_fields["answer_images"].default)
|
||||
|
||||
from app.db import SessionLocal, db_available # noqa: E402
|
||||
from app.main import app as fastapi_app # noqa: E402
|
||||
|
||||
|
||||
@@ -467,6 +467,56 @@ Implements just enough of the aipi surface:
|
||||
the trigger phrase is disjoint from every other trigger's; verified
|
||||
2026-09-16: no existing E2E question or fixture file contains the
|
||||
phrase, so every other suite is unaffected.
|
||||
- user message containing ``show me the picture of``
|
||||
(``SHOW_IMAGE_TRIGGER``, phase 124 task 07 — the image-chips-and-
|
||||
answers suite ``tests/e2e/test_answer_images.py``) **and** the
|
||||
system prompt contains ``show_image`` (the tool clause's presence
|
||||
— the ``BOR_ANSWER_IMAGES`` toggle's server-side marker: the
|
||||
clause rides inside the HIGH prompt's ``<tools>`` section ONLY
|
||||
when the toggle is on, so on a default-OFF app the trigger NEVER
|
||||
fires and the mock behaves exactly as pre-phase for every other
|
||||
suite) -> the deterministic SHOW_IMAGE flow, discriminated
|
||||
statelessly from the tool results (streaming only): the mock
|
||||
picks the IMAGE document seeded in the ``<documents>`` blocks —
|
||||
the block whose ``path`` attribute ends in an image extension
|
||||
(``app.rag.summarizer.IMAGE_MIMES``' family — the suite's
|
||||
single-PNG fixture guarantees exactly one such block):
|
||||
* request 1 (``tools`` offered, no confirmed show yet): stream
|
||||
ONLY ``tool_calls`` deltas — ``show_image`` with
|
||||
``{"path": <combined source/path>}`` (synthetic id
|
||||
``call_show``, ``finish_reason: "tool_calls"``, no content) —
|
||||
NO ``read`` (the ZERO-READ turn: the image doc's seed block
|
||||
already carries its entire content, so a real model never
|
||||
reads it — the zero-read shape is what makes the bug-fix's
|
||||
chip assertion meaningful);
|
||||
* the follow-up request (a ``tool``-role result carrying the
|
||||
agent's confirming line ``Image shown to the user`` —
|
||||
``_SHOW_CONFIRM_MARKER``) in the messages: the deterministic
|
||||
FINAL answer — a short sentence ending with the ``<documents>``
|
||||
block's tail echo (the phase-24 END_OF_NOTES echo convention —
|
||||
it proves the seed was there), NO further tool calls.
|
||||
Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger
|
||||
phrase — the phase-71/72/94/118 ordering convention); verified
|
||||
2026-09-26: no existing E2E question or fixture file contains the
|
||||
phrase, so every other suite is unaffected.
|
||||
- user message containing ``which diagram is in your notes``
|
||||
(``DIAGRAM_TRIGGER``, phase 124 task 07 — the BUG-FIX-ONLY
|
||||
trigger, same suite) -> the deterministic ZERO-READ answer: the
|
||||
mock answers straight from the seed with NO tool calls at all
|
||||
(no ``show_image``, no ``read`` — unlike the ``SHOW_IMAGE_
|
||||
TRIGGER`` flow it needs NO clause, so it also fires on a default-
|
||||
OFF app: the chips fix is not gated by ``BOR_ANSWER_IMAGES``,
|
||||
it belongs to the ``BOR_IMAGES``-indexed docs) and QUOTES THE
|
||||
TAIL of the seeded image doc's ``<document>`` block (the same
|
||||
image-doc pick as above): proving the description WAS in the
|
||||
prompt (for an image doc the seed block IS its entire content)
|
||||
and that its chip appears with NO tool calls at all (the phase-
|
||||
124 auto-cite — the fix for the owner's report). Checked BEFORE
|
||||
the ``DEFLECT_MODE`` branch (the marker lives in the user message
|
||||
— the ``TABLE_TRIGGER``/``HISTORY_TRIGGER`` convention), so a
|
||||
marker question always gets the echo whatever the honesty gate
|
||||
says. Verified 2026-09-26: no existing E2E question or fixture
|
||||
file contains the phrase, so every other suite is unaffected.
|
||||
|
||||
Failure injection (phase 67, LLM retry, TODO.md L3) — deterministic
|
||||
dead-endpoint behavior for the retry E2E suite (``tests/e2e/
|
||||
@@ -580,6 +630,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.rag.agent import ALREADY_IN_CONTEXT, CORRECTION_INSTRUCTION
|
||||
from app.rag.folder_summaries import FOLDER_HEADER_PREFIX # phase 94: the mock's key
|
||||
from app.rag.summarizer import IMAGE_MIMES # phase 124: the image-doc extension family
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@@ -1756,6 +1807,117 @@ _SUMMARY_SEED_READ_CALL_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 124 (task 07, image chips and answers, TODO L3): the scripted
|
||||
# SHOW_IMAGE flow + the zero-read bug-fix trigger — see the module
|
||||
# docstring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: A user message containing this substring (case-insensitive) —
|
||||
#: combined with the ``show_image`` prompt clause in the system
|
||||
#: prompt (the ``BOR_ANSWER_IMAGES`` toggle's server-side marker —
|
||||
#: the clause rides inside the HIGH prompt's ``<tools>`` section ONLY
|
||||
#: when the toggle is on) — drives the deterministic SHOW_IMAGE flow
|
||||
#: (the mock shows the seeded image document and answers straight
|
||||
#: from the seed — ZERO ``read`` calls). On a default-OFF app the
|
||||
#: clause is absent, so the trigger NEVER fires there and the mock
|
||||
#: behaves exactly as pre-phase for every other suite. Disjoint from
|
||||
#: every other trigger phrase — the phase-71/72/94/118 ordering
|
||||
#: convention; verified 2026-09-26: no existing E2E question or
|
||||
#: fixture file contains the phrase, so every other suite is
|
||||
#: unaffected.
|
||||
SHOW_IMAGE_TRIGGER = "show me the picture of"
|
||||
|
||||
#: The phase-124 BUG-FIX-ONLY trigger (case-insensitive): the mock
|
||||
#: answers straight from the seed with ZERO tool calls and QUOTES THE
|
||||
#: TAIL of the seeded image doc's ``<document>`` block — proving the
|
||||
#: description was in the prompt (for an image doc the seed block IS
|
||||
#: its entire content) and that its chip appears with NO tool calls
|
||||
#: at all (the phase-124 auto-cite). Unlike ``SHOW_IMAGE_TRIGGER``
|
||||
#: it needs NO clause — the chips fix is not gated by
|
||||
#: ``BOR_ANSWER_IMAGES`` (it belongs to the ``BOR_IMAGES``-indexed
|
||||
#: docs), so it also fires on a default-OFF app. Checked BEFORE the
|
||||
#: ``DEFLECT_MODE`` branch in :func:`compose_answer` (the marker
|
||||
#: lives in the user message — the TABLE/HISTORY convention), so a
|
||||
#: marker question always gets the echo whatever the honesty gate
|
||||
#: says.
|
||||
DIAGRAM_TRIGGER = "which diagram is in your notes"
|
||||
|
||||
#: The agent's ``show_image`` SUCCESS result's confirming first line
|
||||
#: (``app.rag.agent``'s ``show_image`` branch — the line ``read``'s
|
||||
#: result never carries): the SHOW_IMAGE flow's follow-up request
|
||||
#: keys on it in a ``tool``-role result (stateless discrimination —
|
||||
#: the house marker-flow convention).
|
||||
_SHOW_CONFIRM_MARKER = "Image shown to the user"
|
||||
|
||||
#: The image-doc extension family (``app.rag.summarizer.IMAGE_MIMES``
|
||||
#: — phase 122's ext→mime map, imported so the mock can never drift
|
||||
#: from the app's image family): a seeded ``<documents>`` block is
|
||||
#: the IMAGE document when its ``path`` attribute ends in one of
|
||||
#: these (the suite's single-PNG fixture guarantees exactly one such
|
||||
#: block — the pick is deterministic).
|
||||
_IMAGE_PATH_SUFFIXES = tuple(IMAGE_MIMES)
|
||||
|
||||
|
||||
def _seeded_image_doc(system: str) -> tuple[str, str, str] | None:
|
||||
"""The seeded image doc's ``(source, path, content)`` — or ``None``.
|
||||
|
||||
Phase 124 (task 07): the ``<documents>`` block (the
|
||||
:data:`_DOCUMENT_BLOCK_RE` machinery) whose ``path`` attribute
|
||||
ends in an image extension (:data:`_IMAGE_PATH_SUFFIXES`) is the
|
||||
seeded image document — for an image doc the block's content IS
|
||||
the document's entire text (the vision description — content ==
|
||||
summary, phase 122 task 03). Both of this phase's triggers pick
|
||||
the doc this way (the suite's single-PNG fixture guarantees
|
||||
exactly one such block; ``None`` = a text-only KB, the premise
|
||||
broke — the caller degrades loudly).
|
||||
"""
|
||||
for block in _DOCUMENT_BLOCK_RE.finditer(system):
|
||||
if block.group("path").lower().endswith(_IMAGE_PATH_SUFFIXES):
|
||||
return (block.group("source"), block.group("path"), block.group("content"))
|
||||
return None
|
||||
|
||||
|
||||
def _show_image_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||
"""Classify a phase-124 SHOW_IMAGE request (see the module
|
||||
docstring). Stateless over the messages, like the other marker
|
||||
flows:
|
||||
|
||||
* ``("call", combined)`` — the system prompt carries the
|
||||
``show_image`` clause (the toggle-ON gate) and no ``tool``-role
|
||||
result has confirmed the show yet: stream ONLY the
|
||||
``show_image`` tool-call delta on the seeded image doc's
|
||||
combined ``source/path`` (synthetic id ``call_show``,
|
||||
``finish_reason: "tool_calls"``, no content) — NO ``read`` (the
|
||||
zero-read turn).
|
||||
* ``("answer",)`` — a ``tool``-role result carrying the agent's
|
||||
confirming line (:data:`_SHOW_CONFIRM_MARKER`) is in the
|
||||
messages: the deterministic FINAL answer — a short sentence
|
||||
ending with the ``<documents>`` block's tail echo (the phase-24
|
||||
END_OF_NOTES echo convention — it proves the seed was there),
|
||||
NO further tool calls.
|
||||
* ``None`` — not the flow: the trigger phrase is absent, the
|
||||
clause is absent (a default-OFF app — the mock behaves exactly
|
||||
as pre-phase), the turn is deflected (no ``<documents>`` seed
|
||||
— a deflection prompt never carries one), or no seeded image
|
||||
doc exists (a text-only KB — the premise broke; the suite fails
|
||||
loudly on the missing figure).
|
||||
"""
|
||||
if SHOW_IMAGE_TRIGGER not in _user(body).lower():
|
||||
return None
|
||||
system = _system(body)
|
||||
if "show_image" not in system:
|
||||
return None
|
||||
doc = _seeded_image_doc(system)
|
||||
if doc is None:
|
||||
return None
|
||||
if any(_SHOW_CONFIRM_MARKER in r for r in _tool_results(body)):
|
||||
return ("answer",)
|
||||
if not body.get("tools"):
|
||||
return None
|
||||
return ("call", f"{doc[0]}/{doc[1]}")
|
||||
|
||||
|
||||
def _summary_seed_read_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||
"""Classify a phase-118 scripted summary-read request (see the
|
||||
module docstring). The question carries the scripted call (``read
|
||||
@@ -2280,6 +2442,31 @@ def compose_answer(body: dict[str, Any]) -> str:
|
||||
# message, a deflection prompt never carries it, so a marker
|
||||
# question always gets the echo whatever the gate says.
|
||||
answer = _history_echo(body)
|
||||
elif DIAGRAM_TRIGGER in user.lower():
|
||||
# Phase 124 (task 07): the BUG-FIX-ONLY trigger — the mock
|
||||
# answers straight from the seed with ZERO tool calls (no
|
||||
# ``show_image``, no ``read`` — the clause is NOT required,
|
||||
# so the flow also fires on a default-OFF app: the chips fix
|
||||
# belongs to the ``BOR_IMAGES``-indexed docs) and quotes the
|
||||
# TAIL of the seeded image doc's ``<document>`` block (the
|
||||
# same pick as the SHOW_IMAGE flow — the block's content is
|
||||
# the image doc's entire text, the vision description):
|
||||
# proving the description WAS in the prompt and that the chip
|
||||
# appears with NO tool calls at all (the phase-124 auto-cite
|
||||
# — the fix for the owner's report). Checked BEFORE the
|
||||
# DEFLECT_MODE branch (the marker lives in the user message —
|
||||
# the TABLE/HISTORY convention), so a marker question always
|
||||
# gets the echo whatever the honesty gate says. A text-only KB
|
||||
# (no seeded image doc) degrades to the plain line — the
|
||||
# suite's premise broke; it fails loudly on the missing quote.
|
||||
image_doc = _seeded_image_doc(system)
|
||||
answer = (
|
||||
f"Your notes carry that diagram — it reads: “{image_doc[2][-160:]}” "
|
||||
"(Deterministic mock answer for E2E.)"
|
||||
if image_doc
|
||||
else "Your notes don't carry that diagram — there is nothing to show. "
|
||||
"(Deterministic mock answer for E2E.)"
|
||||
)
|
||||
elif "DEFLECT_MODE" in system:
|
||||
answer = (
|
||||
"Ah — I haven't done anything like that, so I don't want to make stuff up! "
|
||||
@@ -2911,6 +3098,42 @@ def chat_completions(body: dict[str, Any]) -> Any:
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
# Phase 124 (task 07, image chips and answers, TODO L3): the
|
||||
# scripted SHOW_IMAGE flow — the mock shows the seeded image
|
||||
# document (the ``<documents>`` block with the image-
|
||||
# extension path) and answers straight from the seed: ZERO
|
||||
# ``read`` calls (the zero-read turn that makes the bug-fix's
|
||||
# chip assertion meaningful). Clause-gated — the ``show_image``
|
||||
# teaching line in the system prompt is the
|
||||
# ``BOR_ANSWER_IMAGES`` toggle's presence marker, so on a
|
||||
# default-OFF app the trigger never fires and the mock stays
|
||||
# byte-identical to pre-phase for every other suite. Checked
|
||||
# BEFORE the plain TOOLS_TRIGGER flow (disjoint trigger
|
||||
# phrase — the phase-71/72/94/118 ordering convention; the
|
||||
# trigger needs the clause, so deflected turns never hit it).
|
||||
show_image = _show_image_flow(body)
|
||||
if show_image is not None:
|
||||
if show_image[0] == "call":
|
||||
stream = _tool_call_stream(
|
||||
"show_image", {"path": show_image[1]}, "call_show"
|
||||
)
|
||||
else: # "answer" — the final answer + the tail echo
|
||||
block = _DOCUMENTS_BLOCK_RE.search(_system(body))
|
||||
tail = (block.group(0) if block else _context(body))[-160:]
|
||||
stream = _sse_stream(
|
||||
_apply_max_tokens(
|
||||
"Here is the picture you asked about — …and the very "
|
||||
f"end of my notes reads: “{tail}” "
|
||||
"(Deterministic mock answer for E2E.)",
|
||||
body.get("max_tokens"),
|
||||
),
|
||||
0.0,
|
||||
)
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
# Phase 109 (task 03, never-frozen-turn story suite): the
|
||||
# deterministic REPORTED-REPRO turn (delta → tool →
|
||||
# thinking-after-delta — TODO.md L3): the scripted first answer,
|
||||
|
||||
@@ -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:
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 124, task 04 — the done frame's OPTIONAL answer_image (the
|
||||
# shown document's ref: the LLM's ``show_image`` pick) + the byte-
|
||||
# identical-off contract (every other turn: no key, no null).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def answer_images_on(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The chat endpoint's settings view with ``answer_images`` ON
|
||||
(the ``BOR_ANSWER_IMAGES`` feature path; the monkeypatch fixture
|
||||
reverts the patch, the conftest leak-guard pins the default OFF
|
||||
for every other test in the module)."""
|
||||
settings = Settings(
|
||||
_env_file=None, # pyright: ignore[reportCallIssue]
|
||||
answer_images=True,
|
||||
)
|
||||
monkeypatch.setattr(chat_api, "get_settings", lambda: settings)
|
||||
|
||||
|
||||
def test_show_image_turn_done_frame_carries_answer_image(
|
||||
client, db, seeded_kb: FakeRagLLM, answer_images_on: None
|
||||
) -> None:
|
||||
"""A grounded turn whose mock client scripts a ``show_image`` tool
|
||||
call on a seeded image doc (the scripted-tool mechanism — the
|
||||
``use your tools`` precedent) → the final SSE ``done`` frame JSON
|
||||
carries ``answer_image`` with ``image_url ==
|
||||
/api/documents/<id>/image`` and the cited ``sources`` include the
|
||||
same doc (its scripted read — the phase-119 citation tier)."""
|
||||
doc = _seed_image_doc(db)
|
||||
scripted = FakeRagLLM(
|
||||
tool_script=[
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1",
|
||||
name="read",
|
||||
arguments={"path": "docs/pic.png"},
|
||||
)
|
||||
],
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_2",
|
||||
name="show_image",
|
||||
arguments={"path": "docs/pic.png"},
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||
try:
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
done = frames[-1]
|
||||
assert done["deflected"] is False
|
||||
assert done["answer_image"] == {
|
||||
"source": "docs",
|
||||
"path": "pic.png",
|
||||
"title": "pic",
|
||||
"image_url": f"/api/documents/{doc.id}/image",
|
||||
}
|
||||
# The cited tier includes the same doc (the scripted read).
|
||||
assert ("docs", "pic.png") in [(s["source"], s["path"]) for s in done["sources"]]
|
||||
# The toggle was honoured on the wire: the first (agent) request
|
||||
# offered the 4th tool.
|
||||
offered = scripted.seen_tools[0]
|
||||
assert offered is not None
|
||||
assert [t["function"]["name"] for t in offered] == [
|
||||
"ls",
|
||||
"read",
|
||||
"grep",
|
||||
"show_image",
|
||||
]
|
||||
|
||||
|
||||
def test_toggle_off_same_flow_offers_three_tools_and_no_answer_image_key(
|
||||
client, db, seeded_kb: FakeRagLLM
|
||||
) -> None:
|
||||
"""The SAME scripted flow with ``answer_images=False`` (the
|
||||
default) → ``show_image`` is NOT in the offered tools (the mock's
|
||||
recorded request carries exactly the three base tools) and the
|
||||
``done`` frame has NO ``answer_image`` key — the feature's
|
||||
server-side gate is the OFFER (a model that was never offered the
|
||||
tool never calls it, so no such call occurs in the flow)."""
|
||||
_seed_image_doc(db) # resolvable, but the tool is never offered
|
||||
scripted = FakeRagLLM(
|
||||
tool_script=[
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1",
|
||||
name="read",
|
||||
arguments={"path": "docs/pic.png"},
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||
try:
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
done = frames[-1]
|
||||
assert done["deflected"] is False
|
||||
assert "answer_image" not in done # ABSENT, not null
|
||||
# The offer: exactly the three base tools — no ``show_image``.
|
||||
offered = scripted.seen_tools[0]
|
||||
assert offered is not None
|
||||
assert [t["function"]["name"] for t in offered] == ["ls", "read", "grep"]
|
||||
|
||||
|
||||
def test_zero_tool_grounded_turn_done_frame_has_no_answer_image_key(
|
||||
client, db, seeded_kb: FakeRagLLM, answer_images_on: None
|
||||
) -> None:
|
||||
"""A zero-tool grounded turn (no scripted calls — the toggle is ON,
|
||||
so the offer carries ``show_image``, but the model never calls it)
|
||||
→ the ``done`` frame has NO ``answer_image`` key — byte-identical
|
||||
to pre-phase, checked on the raw wire text (not a re-serialized
|
||||
dict)."""
|
||||
scripted = FakeRagLLM() # no tool_script: a zero-tool turn
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||
try:
|
||||
with client.stream("POST", "/api/chat", json={"message": QUESTION}) as r:
|
||||
assert r.status_code == 200
|
||||
buf = ""
|
||||
raw_frames: list[str] = []
|
||||
for part in r.iter_text():
|
||||
buf += part
|
||||
while "\n\n" in buf:
|
||||
frame, buf = buf.split("\n\n", 1)
|
||||
frame = frame.strip()
|
||||
if frame.startswith("data:"):
|
||||
raw_frames.append(frame)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
done_raw = [f for f in raw_frames if '"type": "done"' in f]
|
||||
assert len(done_raw) == 1
|
||||
# The BYTE check: the key is absent from the wire text itself.
|
||||
assert "answer_image" not in done_raw[0]
|
||||
done = json.loads(done_raw[0].removeprefix("data:").strip())
|
||||
assert done["deflected"] is False
|
||||
assert "answer_image" not in done
|
||||
|
||||
|
||||
def test_deflected_turn_done_frame_has_no_answer_image_key_even_toggle_on(
|
||||
client, db, seeded_kb: FakeRagLLM, answer_images_on: None
|
||||
) -> None:
|
||||
"""A deflected turn runs no agent and never shows an image (the
|
||||
phase's locked design): even with the toggle ON, the ``done``
|
||||
frame has NO ``answer_image`` key — byte-identical to pre-phase."""
|
||||
scripted = FakeRagLLM()
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||
try:
|
||||
_, _, frames = _stream_chat(client, OFF_TOPIC)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
done = frames[-1]
|
||||
assert done["deflected"] is True
|
||||
assert "answer_image" not in done
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 124, task 05 — bug fix: a suggested-tier IMAGE doc earns its
|
||||
# source chip WITHOUT a ``read`` (its seed block IS its entire content —
|
||||
# content == summary == the vision description, phase 122 task 03 — so
|
||||
# "suggested" ≡ "read" for it and the phase-119 cite discipline is
|
||||
# honored, not weakened). Text docs keep the read-docs-only rule.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_image_doc_with_chunk(db) -> Document:
|
||||
"""An ``is_image`` documents row WITH a retrievable chunk — the
|
||||
phase-122 pipeline embeds the vision description as the image doc's
|
||||
content (the only embedded text of the document), so the fixture
|
||||
chunk mirrors that: a description tuned to overlap ``QUESTION``
|
||||
(the mock embeddings are token-overlap vectors, as in the E2E
|
||||
mock), which lands the image doc in the SUGGESTED tier (top-5
|
||||
fused rank, no floor — A3) where the auto-cite merge operates.
|
||||
The ``tsv`` column is generated (``to_tsvector`` of the content),
|
||||
so the chunk also hits the FTS half. The fixture's teardown
|
||||
truncates the tables."""
|
||||
doc = _seed_image_doc(db)
|
||||
# The phase-122 invariant for image docs: content == summary == the
|
||||
# vision description (the ONE embedded text of the document — no
|
||||
# pointer line on the summary). The description is tuned to overlap
|
||||
# ``QUESTION`` (the mock embeddings are token-overlap vectors, as in
|
||||
# the E2E mock), which lands the image doc in the SUGGESTED tier
|
||||
# (top-5 fused rank, no floor — A3) where the auto-cite merge
|
||||
# operates.
|
||||
description = "Diagram of how my Kubernetes cluster is set up"
|
||||
doc.content = description
|
||||
doc.summary = description
|
||||
db.add(
|
||||
Chunk(
|
||||
id=uuid.uuid4(),
|
||||
document_id=doc.id,
|
||||
position=0,
|
||||
content=description,
|
||||
embedding=_token_vec(description),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return doc
|
||||
|
||||
|
||||
def test_zero_read_grounded_turn_auto_cites_suggested_image_doc(
|
||||
client, db, seeded_kb: FakeRagLLM
|
||||
) -> None:
|
||||
"""The bug fix pinned end-to-end (TODO L3): a grounded turn with
|
||||
NO scripted tool calls (the mock answers straight from the seed —
|
||||
a real model never ``read``s an image doc: its seed block already
|
||||
carries 100% of its content) whose suggested tier contains an
|
||||
image doc → the done frame's ``sources`` include the image ref
|
||||
WITH ``image_url`` set (the chip + figure data — pre-phase-124 a
|
||||
zero-read turn chipped NOTHING, the phase-122 E2E had to script a
|
||||
``read`` to earn the chip), and ``related`` does NOT contain the
|
||||
same ``(source, path)`` (the auto-cited doc leaves the "Nearby
|
||||
docs" row — no duplicate figure)."""
|
||||
doc = _seed_image_doc_with_chunk(db)
|
||||
scripted = FakeRagLLM() # no tool_script: a zero-read grounded turn
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||
try:
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
done = frames[-1]
|
||||
assert done["deflected"] is False
|
||||
# The image doc IS a citation chip now — and its ref (and ONLY its
|
||||
# ref) carries the bytes route the figure renders from.
|
||||
sources = done["sources"]
|
||||
img_refs = [s for s in sources if (s["source"], s["path"]) == ("docs", "pic.png")]
|
||||
assert len(img_refs) == 1 # once — not duplicated by the read merge
|
||||
assert img_refs[0]["image_url"] == f"/api/documents/{doc.id}/image"
|
||||
for ref in sources:
|
||||
if ref is not img_refs[0]:
|
||||
assert "image_url" not in ref # the text refs stay pre-122-shaped
|
||||
# Consequence (a): the related tier already excludes cited-seen
|
||||
# members — the auto-cited image doc is NOT also a "nearby doc".
|
||||
related_keys = {(s["source"], s["path"]) for s in done["related"]}
|
||||
assert ("docs", "pic.png") not in related_keys
|
||||
# Consequence (b): the observability fields are UNCHANGED — the
|
||||
# durable record (query_log.sources) carried the suggested image
|
||||
# doc before the phase (it was suggested, not read); auto-citation
|
||||
# touches the citation tier only, not the record.
|
||||
row = db.scalars(select(QueryLog)).one()
|
||||
assert "docs/pic.png" in row.sources
|
||||
assert "docs/homelab/kubernetes.md" in row.sources
|
||||
# The seed contract held: the HIGH prompt seeded the image doc's
|
||||
# summary — its vision description, the ENTIRE content (image docs
|
||||
# store no ``Source:`` pointer line — the summary mirrors the
|
||||
# description verbatim, phase 122 task 03) — as a ``<document>``
|
||||
# block. This is WHY "suggested" ≡ "read" for the doc: a ``read``
|
||||
# would add only the marker line.
|
||||
system = scripted.seen_messages[0][0]["content"]
|
||||
assert '<document source="docs" path="pic.png"' in system
|
||||
assert "Diagram of how my Kubernetes cluster is set up" in system
|
||||
|
||||
|
||||
def test_zero_read_text_only_turn_still_chips_nothing(
|
||||
client, db, seeded_kb: FakeRagLLM
|
||||
) -> None:
|
||||
"""Text docs are unaffected (consequence c): a zero-read grounded
|
||||
turn on the plain text-only KB (no image docs anywhere in the
|
||||
retrieval) → ``sources`` is ``[]`` — byte-identical to pre-phase
|
||||
(no auto-cite without image docs) and no ``image_url`` key on any
|
||||
ref (the omission rule at the frame level)."""
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||
try:
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
done = frames[-1]
|
||||
assert done["deflected"] is False
|
||||
assert done["sources"] == [] # phase-119 read-docs-only rule intact
|
||||
for ref in done["sources"] + done["related"]:
|
||||
assert "image_url" not in ref
|
||||
|
||||
@@ -899,6 +899,112 @@ def test_shared_serve_includes_the_user_image_path(
|
||||
assert "image" not in messages[2]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 124 (task 04): the brain answer's first-class image — the
|
||||
# shown document's ref (the LLM's ``show_image`` pick) rides the BRAIN
|
||||
# record's ``answer_image`` key (distinct from the user record's
|
||||
# phase-123 ``image`` attachment path — the attachment belongs to the
|
||||
# question, the answer image to the brain's reply). The phase-113
|
||||
# lesson applied up front: the done-time auto-save (``rememberBrainTurn``)
|
||||
# carries the key, and the ``extra="forbid"`` boundary silently 422'd
|
||||
# any key the model was missing (the A2 quiet-failure path swallowed
|
||||
# it — grounded turns silently unsaved). These round-trips are that
|
||||
# regression pin.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The shown image doc's full ref — the done frame's ``answer_image``
|
||||
#: shape (source/path/title + the ``image_url`` bytes route).
|
||||
ANSWER_IMAGE_REF: dict[str, Any] = {
|
||||
"source": "Homelab",
|
||||
"path": "kubernetes-cluster.png",
|
||||
"title": "Kubernetes Cluster Diagram",
|
||||
"image_url": "/api/documents/0e8a6e5d-7b6c-4f2a-9c1d-3e5f7a9b1c2d/image",
|
||||
}
|
||||
|
||||
|
||||
def test_create_round_trips_the_brain_answer_image_ref(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""The phase-113 regression pin (the A2 quiet-failure path): a
|
||||
brain record carrying ``answer_image`` → ``POST /api/chats`` is
|
||||
201 (NOT a silent 422) and the served chat round-trips the ref
|
||||
BYTE-FOR-BYTE (the restore path re-renders the answer figure from
|
||||
it). The user record carries NO ``answer_image`` key, and the
|
||||
user's phase-123 ``image`` attachment key stays distinct."""
|
||||
brain = {**FULL_BRAIN, "answer_image": ANSWER_IMAGE_REF}
|
||||
r = admin_client.post(
|
||||
"/api/chats",
|
||||
json={"messages": [_user(FIRST_QUESTION), brain]},
|
||||
)
|
||||
assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
|
||||
body = r.json()
|
||||
assert body["messages"][1]["answer_image"] == ANSWER_IMAGE_REF
|
||||
assert "answer_image" not in body["messages"][0]
|
||||
assert "image" not in body["messages"][1] # the brain's image is a REF
|
||||
|
||||
# The RAW stored JSONB (the DB is the durable boundary): the full
|
||||
# ref, byte-for-byte.
|
||||
raw = db.execute(
|
||||
text("SELECT messages FROM saved_chats WHERE id = :id"),
|
||||
{"id": body["id"]},
|
||||
).scalar_one()
|
||||
assert raw[1]["answer_image"] == ANSWER_IMAGE_REF
|
||||
assert "answer_image" not in raw[0]
|
||||
|
||||
# The admin GET round-trips the ref losslessly.
|
||||
got = admin_client.get(f"/api/chats/{body['id']}")
|
||||
assert got.status_code == 200
|
||||
assert got.json()["messages"][1]["answer_image"] == ANSWER_IMAGE_REF
|
||||
|
||||
|
||||
def test_create_without_answer_image_round_trips_unchanged(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
"""A record WITHOUT the key (every pre-phase-124 chat, every
|
||||
image-less turn) round-trips UNCHANGED: the response carries no
|
||||
``answer_image`` key at all — ABSENT, never ``null`` (the
|
||||
byte-identical-off contract; the phase-50 round-trip contract).
|
||||
``_expect`` (the stored shape) is unchanged by phase 124 — the
|
||||
omission serializer inherits it on every surface."""
|
||||
brain = {**FULL_BRAIN}
|
||||
r = admin_client.post(
|
||||
"/api/chats",
|
||||
json={"messages": [_user(FIRST_QUESTION), brain]},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["messages"] == _expect([_user(FIRST_QUESTION), brain])
|
||||
assert "answer_image" not in body["messages"][0]
|
||||
assert "answer_image" not in body["messages"][1]
|
||||
|
||||
got = admin_client.get(f"/api/chats/{body['id']}")
|
||||
assert got.status_code == 200
|
||||
assert "answer_image" not in got.json()["messages"][1]
|
||||
|
||||
|
||||
def test_shared_serve_includes_the_brain_answer_image_ref(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
"""The shared view is faithful (task 06's render depends on it):
|
||||
the PUBLIC snapshot's brain record carries the answer ref (the
|
||||
bytes route is public — a shared chat is faithful, the phase-123
|
||||
user-image precedent), and the user record stays WITHOUT the key."""
|
||||
brain = {**FULL_BRAIN, "answer_image": ANSWER_IMAGE_REF}
|
||||
r = admin_client.post(
|
||||
"/api/chats",
|
||||
json={"messages": [_user(FIRST_QUESTION), brain]},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
share_url = _share(admin_client, r.json()["id"])["share_url"]
|
||||
|
||||
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
|
||||
got = anon.get(f"/api{share_url}")
|
||||
assert got.status_code == 200
|
||||
messages = got.json()["messages"]
|
||||
assert messages[1]["answer_image"] == ANSWER_IMAGE_REF
|
||||
assert "answer_image" not in messages[0]
|
||||
|
||||
|
||||
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
|
||||
@@ -1305,24 +1305,30 @@ def test_image_route_404_matrix(admin_client: TestClient, db, tmp_path: Path) ->
|
||||
_cleanup_kb(db)
|
||||
|
||||
|
||||
def test_image_route_requires_user_like_the_content_endpoint(
|
||||
def test_image_route_is_public_like_the_shared_chat_content(
|
||||
admin_client: TestClient, db, tmp_path: Path
|
||||
) -> None:
|
||||
"""Phase 79 posture (the task's "PUBLIC, like the document content
|
||||
endpoint" — the content endpoint has been user-gated since phase
|
||||
79; the ONLY anonymous surface is the shared chats, PLAN A10): an
|
||||
anonymous caller gets 401 ``authentication required`` before any
|
||||
row is read (a FRESH client — the module's fixture ``client``
|
||||
stays unsigned here), a signed-in caller gets the bytes."""
|
||||
"""Phase 124 (LOCKED A4 — the answer figure is "visible … on the
|
||||
shared page"): the BYTES route is PUBLIC (the phase-123
|
||||
chat-image precedent — the image is part of a shared chat's
|
||||
rendered content, and the shared chat is the app's ONLY anonymous
|
||||
surface, PLAN A10; the unguessable ``uuid4`` document id IS the
|
||||
credential, no enumeration value): an ANONYMOUS caller (a FRESH
|
||||
client — no session) gets the bytes with the image Content-Type,
|
||||
and a signed-in caller gets the same. The document's TEXT (the
|
||||
content endpoint) stays user-gated — only the bytes are public."""
|
||||
_cleanup_kb(db)
|
||||
try:
|
||||
doc = _seed_image_doc(db, tmp_path)
|
||||
anonymous = TestClient(fastapi_app)
|
||||
r = anonymous.get(f"/api/documents/{doc.id}/image")
|
||||
assert r.status_code == 401
|
||||
assert r.json() == {"detail": "authentication required"}
|
||||
# The signed-in client (admin) passes.
|
||||
assert admin_client.get(f"/api/documents/{doc.id}/image").status_code == 200
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("image/")
|
||||
assert r.content == PNG_1X1
|
||||
# The signed-in client (admin) gets the same bytes.
|
||||
r_admin = admin_client.get(f"/api/documents/{doc.id}/image")
|
||||
assert r_admin.status_code == 200
|
||||
assert r_admin.content == PNG_1X1
|
||||
finally:
|
||||
_cleanup_kb(db)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
import inspect
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
@@ -43,6 +44,7 @@ from app.config import Settings
|
||||
from app.models import Document
|
||||
from app.rag.prompts import (
|
||||
PERSONA,
|
||||
SHOW_IMAGE_CLAUSE,
|
||||
SUGGEST_INTRO,
|
||||
TOOLS_SECTION,
|
||||
_base,
|
||||
@@ -1015,3 +1017,127 @@ def test_plain_text_line_never_leaks_into_high_prompt() -> None:
|
||||
high = build_high_prompt([doc], notes=notes, kb_overview=kb)
|
||||
assert PLAIN_TEXT_ONLY_LINE not in high
|
||||
assert "you have no tools" not in high
|
||||
|
||||
|
||||
# ---------- phase 124 (task 03, LOCKED A5): the show_image prompt clause ----------
|
||||
|
||||
|
||||
def test_show_image_clause_states_the_four_gate_elements() -> None:
|
||||
"""LOCKED A5: the clause states ALL FOUR gate elements, pinned on
|
||||
stable phrases, and references the tool by its backticked name (the
|
||||
``TOOLS_SECTION`` voice). (1) the image-doc nature — its text is a
|
||||
description GENERATED from the image; (2) the "contains the answer"
|
||||
bar — never a merely related image, never padding; (3) at most one
|
||||
image per answer; (4) the text answer is still provided in full."""
|
||||
assert "`show_image`" in SHOW_IMAGE_CLAUSE # backticked, like the other tools
|
||||
assert "description GENERATED from the image" in SHOW_IMAGE_CLAUSE # (1)
|
||||
assert "what the user actually wants to see" in SHOW_IMAGE_CLAUSE # (2)
|
||||
assert "never for a merely related image" in SHOW_IMAGE_CLAUSE # (2)
|
||||
assert "never to pad an answer" in SHOW_IMAGE_CLAUSE # (2)
|
||||
assert "at most one image per answer" in SHOW_IMAGE_CLAUSE # (3)
|
||||
assert "answer fully in text" in SHOW_IMAGE_CLAUSE # (4)
|
||||
# It is a clause, not a section: no ``<tools>`` markup of its own
|
||||
# (the ``<documents>`` mention is plain text, as in the section copy).
|
||||
assert "<tools>" not in SHOW_IMAGE_CLAUSE
|
||||
assert "</tools>" not in SHOW_IMAGE_CLAUSE
|
||||
|
||||
|
||||
def test_high_prompt_default_stays_byte_identical_to_pre_task() -> None:
|
||||
"""Completion pin: ``build_high_prompt(docs)`` (no new arg) and the
|
||||
explicit ``show_image=False`` are byte-identical to each other and
|
||||
end with the untouched ``TOOLS_SECTION`` — the pre-task pins above
|
||||
(and the sha pins in :mod:`tests.unit.test_prompt_lock`) pass
|
||||
UNMODIFIED, with or without documents."""
|
||||
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||
plain = build_high_prompt([doc])
|
||||
assert build_high_prompt([doc], show_image=False) == plain
|
||||
assert plain.endswith(TOOLS_SECTION)
|
||||
assert SHOW_IMAGE_CLAUSE not in plain
|
||||
empty = build_high_prompt([])
|
||||
assert build_high_prompt([], show_image=False) == empty
|
||||
assert empty.endswith(TOOLS_SECTION)
|
||||
assert SHOW_IMAGE_CLAUSE not in empty
|
||||
|
||||
|
||||
def test_high_prompt_show_image_clause_lands_inside_tools_after_the_copy() -> None:
|
||||
"""Phase 124 (task 03): ``show_image=True`` builds EXACTLY the
|
||||
toggle-off prompt with its final ``</tools>`` replaced by the clause
|
||||
(on its own line) + the closing tag — the prefix up to the clause
|
||||
is byte-identical to the toggle-off output (the three-tool copy
|
||||
untouched), the clause lands AFTER the existing copy and BEFORE the
|
||||
closing tag, and appears exactly once."""
|
||||
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||
off = build_high_prompt([doc])
|
||||
on = build_high_prompt([doc], show_image=True)
|
||||
# The full structural equation (the clause is the ONLY difference).
|
||||
assert on == off[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
|
||||
clause_start = on.index(SHOW_IMAGE_CLAUSE)
|
||||
# The prefix up to the clause is byte-identical to the toggle-off
|
||||
# build minus its closing tag.
|
||||
assert on[:clause_start] == off[: -len("</tools>")]
|
||||
# From the <tools> opener to the clause is TOOLS_SECTION's own text
|
||||
# minus its final </tools> — the constant itself is unedited (sha-
|
||||
# pinned in test_prompt_lock) and the three-tool copy is intact.
|
||||
assert on[on.index("<tools>") : clause_start] == TOOLS_SECTION[: -len("</tools>")]
|
||||
assert "Answer as soon as you have what you need." in on[:clause_start]
|
||||
# The clause (then the closing tag) is the tail of the prompt, after
|
||||
# the existing copy and before the closing tag — exactly once.
|
||||
assert on.endswith(SHOW_IMAGE_CLAUSE + "\n</tools>")
|
||||
assert on.index("Answer as soon as you have what you need.") < clause_start
|
||||
assert clause_start < on.index("</tools>")
|
||||
assert on.count(SHOW_IMAGE_CLAUSE) == 1
|
||||
assert on.count("<tools>") == 1 and on.count("</tools>") == 1
|
||||
|
||||
|
||||
def test_show_image_clause_rides_only_the_high_prompt() -> None:
|
||||
"""The clause appears ONLY in the HIGH prompt: the deflection prompt
|
||||
takes NO ``show_image`` flag at all and its output never carries the
|
||||
clause (byte-identical builds with/without notes/overview — a
|
||||
deflected turn has no tools and never shows an image, the phase's
|
||||
locked design)."""
|
||||
assert "show_image" not in inspect.signature(build_deflect_prompt).parameters
|
||||
for prompt in (
|
||||
build_deflect_prompt(["T1", "T2"]),
|
||||
build_deflect_prompt(["T1"], notes=["be concise"], kb_overview=OVERVIEW),
|
||||
build_deflect_prompt([]),
|
||||
):
|
||||
assert SHOW_IMAGE_CLAUSE not in prompt
|
||||
assert "`show_image`" not in prompt
|
||||
assert "at most one image per answer" not in prompt
|
||||
|
||||
|
||||
def test_high_prompt_empty_documents_show_image_clause_still_in_tools() -> None:
|
||||
"""The "(no documents matched…)" body + ``show_image=True``: the
|
||||
clause still lands inside ``<tools>`` — the grounded turn may still
|
||||
``ls``/``grep`` its way to an image doc and show it."""
|
||||
off = build_high_prompt([])
|
||||
on = build_high_prompt([], show_image=True)
|
||||
assert on == off[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
|
||||
assert "(no documents matched — do not invent specifics)" in on
|
||||
assert on.index("<tools>") < on.index(SHOW_IMAGE_CLAUSE) < on.index("</tools>")
|
||||
assert on.endswith(SHOW_IMAGE_CLAUSE + "\n</tools>")
|
||||
|
||||
|
||||
def test_show_image_clause_is_last_for_every_high_shape() -> None:
|
||||
"""Steering + KB overview + folder lines + documents all present:
|
||||
the section order is UNCHANGED (``<relevance>`` →
|
||||
``<knowledge_base>`` → ``<tuning>`` → ``<documents>`` → ``<tools>``)
|
||||
and the clause is still the last thing the model reads, inside
|
||||
``<tools>`` after the three-tool copy."""
|
||||
doc = _doc("deploy/k8s.md", "CONTENT_SENTINEL", "K8s Deploy", summary="K8s deploy notes.")
|
||||
lines = ["Homelab/deploy/: k8s.md (1 doc)"]
|
||||
off = build_high_prompt([doc], notes=["be concise"], kb_overview=OVERVIEW, folder_lines=lines)
|
||||
on = build_high_prompt(
|
||||
[doc],
|
||||
notes=["be concise"],
|
||||
kb_overview=OVERVIEW,
|
||||
folder_lines=lines,
|
||||
show_image=True,
|
||||
)
|
||||
assert on == off[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
|
||||
clause_start = on.index(SHOW_IMAGE_CLAUSE)
|
||||
assert on[on.index("<tools>") : clause_start] == TOOLS_SECTION[: -len("</tools>")]
|
||||
for section in ("<relevance>", "<knowledge_base>", "<tuning>", "<documents>"):
|
||||
assert on.index(section) < on.index("<tools>")
|
||||
assert on.index("K8s deploy notes.") < on.index("<tools>") # the summary still seeds
|
||||
assert SHOW_IMAGE_CLAUSE not in off # the toggle-off twin carries none of it
|
||||
|
||||
Reference in New Issue
Block a user