phase: 182_verbatim_show_text
**Phase 182 — final verification pass (all 8 tasks already complete; this was verification + docs + one defect fix).**
- Verified: `app/rag/verbatim.py` (locator grammar, `splitlines()` slice, line-boundary cap, `resolve_answer_blocks`), `SHOW_TEXT_TOOL` + `shown_texts`, `SHOW_TEXT_CLAUSE`, `done.answer_text` (9 fields, key dropped when empty) + `ChatMessageRecord.answer_text` (≤50, 600 000-char cap counted in the 8 M budget), `verbatim.js` + 4 `{verbatim:true}` renderers, `Containerfile` minify step, `.env.example` 3 vars.
- `uv run python -c "…answer_text, show_text_max_chars, show_text_max_blocks"` → `True 12000 5`; each negative refuses startup naming its field; `0` accepted as inert ✅
- `bash .agents/validate.sh` → **validation OK**: 4320 unit+integration tests green, `TOTAL 6849 17 → 99%` (gate app/ >90% OK), `ruff check .` "All checks passed!", `pyright` 0 errors (re-run after my edits, 08:28).
- E2E, each in isolation `--no-cov`: `test_verbatim_show_text.py` 13 passed (panel bytes == `documents.content`, server caption, clipboard == server string, `/document.html?source=…&path=…`, honest unresolved state, reload+share, `show_image` regression, flag-off app) ✅; green: `test_answer_images` `test_harness_aligned_tools` `test_agent_document_tools` `test_markdown_tables` `test_markdown_inline_links_images` `test_svg_document_preview` `test_smoke` `test_document_viewer` `test_markdown_blockquotes` `test_chat_persistence` `test_thinking_timeline` `test_chat_rag` `test_settings_mobile_value_width`.
- **Defect found & fixed (pre-existing, not phase 182):** `tests/e2e/test_admin_settings_panel.py::test_admin_sees_groups_and_source_badges` failed — its whole-page `assert "reese" not in page.content()` tripped on the shell's onboarding chips, which follow the newest saved chats in the one shared E2E DB (a leftover `reeselink.json` question from a tool suite). Scoped the strict sweep to `.settings-shell` and kept a whole-page `reese:reese` credential needle → 12 passed with the leftovers still present.
- Docs: README fixed 2 inconsistencies — Quick Start omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the app fail-loud refuses boot) and the broken anchor `#chat--` → `#chat-`; full re-read of README (1816 lines) + AGENTS confirmed the phase-182 feature/settings/log/UI-contract text matches the code, and every `.env.example` var + every `Settings` field is documented.
- Notable: kept `resolve_answer_blocks` synchronous on the SSE terminal path (bounded indexed SELECTs, matching the existing `query_log` precedent and the phase's documented fence-lookup bound); no live-infra changes made.
- Next pending phase: none — `todo/` holds only 182 (its 8 task files are in `complete/`; the harness commits + moves `00_phase.md`).
This commit is contained in:
+35
-3
@@ -84,6 +84,25 @@ A15 — `thinking`, `tool`, unlimited tool rounds under
|
||||
never-truncated contract is unchanged; the `read`-tool cap is
|
||||
the only exception, owner-permitted 2026-09-10.
|
||||
|
||||
Phase 182 (verbatim text display, owner chat 2026-10-14) adds
|
||||
**`show_text(path, offset?, limit?)` as the fifth agent tool** — offered
|
||||
only when `BOR_ANSWER_TEXT=1` (the default), gated exactly like
|
||||
`show_image`, with its own `<tools>` clause and its own kill switch — and
|
||||
the **additive `done.answer_text`** field above. The new settings are
|
||||
`BOR_ANSWER_TEXT` (master switch, ON), `BOR_SHOW_TEXT_MAX_CHARS` (per-block
|
||||
budget, 12 000) and `BOR_SHOW_TEXT_MAX_BLOCKS` (per-answer budget, 5), all
|
||||
following the `agent_max_rounds` pattern (`0` inert, negative refuses
|
||||
startup). The principle in one sentence: **the model points, the server
|
||||
supplies the bytes** — a ` ```bor source/path 12-40 ` fence or a
|
||||
`show_text` call names a location (canonical locator `source/path` or
|
||||
`source/path#start-end`, 1-based and inclusive), and the displayed text is
|
||||
sliced from `documents.content` server-side, so what the user copies is the
|
||||
knowledge base, never a re-typed approximation. **A1–A6 and A15 are
|
||||
otherwise
|
||||
UNCHANGED:** no new SSE event type, no new table or column, no new model,
|
||||
endpoint or transport, no change to retrieval, the honesty gate, the
|
||||
persona text or the embedding dimension.
|
||||
|
||||
---
|
||||
|
||||
## 3. High-Level Architecture
|
||||
@@ -129,7 +148,9 @@ POST /api/chat {message, history?} (auth: require_user)
|
||||
→ cosine top-100 ∪ FTS top-30 → RRF fuse (k=60) [A7]
|
||||
├─ HIGH (cosine ≥ 0.62 OR fts_hits > 0):
|
||||
│ persona + <knowledge_base> + <tuning> + full top-2 <documents>
|
||||
│ + <tools> → agent loop (ls/read/grep; round-capped
|
||||
│ + <tools> → agent loop (ls/read/grep, plus show_image when
|
||||
│ BOR_ANSWER_IMAGES=1 and show_text when BOR_ANSWER_TEXT=1 — the two
|
||||
│ "show the user an artifact" tools, gated independently; round-capped
|
||||
│ BOR_AGENT_MAX_ROUNDS=10, 0 = tools off) → turbo streamed
|
||||
└─ LOW (A8): DEFLECT_MODE prompt (weak titles only, no tools,
|
||||
byte-identical direct-stream path) → turbo streamed
|
||||
@@ -139,7 +160,15 @@ POST /api/chat {message, history?} (auth: require_user)
|
||||
SSE frames (`data: <json>\n\n`): `thinking` (before first delta) →
|
||||
`tool` (grounded turns, one per model call) → `retry` (pre-first-frame
|
||||
restarts) → `delta` (answer tokens) → `done` `{deflected, sources[],
|
||||
suggestions[], budget_exhausted?}` (terminal). Failure: `error` (terminal — no `done`, no
|
||||
suggestions[], budget_exhausted?, answer_text[]?}` (terminal). **Phase 182
|
||||
extends the `done` frame ADDITIVELY** — `answer_text` is the list of
|
||||
server-resolved verbatim blocks (`locator`, `source`, `path`, `start`,
|
||||
`end`, `total_lines`, `text`, `format`, `truncated`) the answer displayed;
|
||||
there is **no eighth event type**, the key is OMITTED when no block was
|
||||
displayed, and with `BOR_ANSWER_TEXT=0` it is absent on every turn (the
|
||||
frame is then byte-identical to pre-phase). The same array persists on the
|
||||
saved chat record, so restore and the anonymous shared page re-render the
|
||||
panels from the server's bytes. Failure: `error` (terminal — no `done`, no
|
||||
`query_log` row; a pre-stream DB outage is a plain 503 JSON). A
|
||||
`tools=None` request never emits a `tool` frame; a round-capped turn
|
||||
whose final reply carries no visible content answers with the budget
|
||||
@@ -273,8 +302,11 @@ scripts.…`, and tests all honor it.
|
||||
question=… embed_ms=… top_score=… fts_hits=… summary_hits=… tuning=N
|
||||
kb_chars=N history_msgs=N threshold=… deflected=… sources=…
|
||||
thinking_chars=… tool_calls=N rounds=N cap_hit=0|1 total_ms=…
|
||||
retries=N scaffold_stripped=N
|
||||
retries=N scaffold_stripped=N verbatim_blocks=N
|
||||
```
|
||||
(`verbatim_blocks=` — phase 182 — is the count of verbatim display blocks
|
||||
actually SENT on the `done` frame, `0` on every turn without them,
|
||||
deflected included.)
|
||||
(`sources=` = retrieval + agent-read docs, deduped; `tool_calls=`
|
||||
executed only; a cancelled turn writes neither line nor `query_log`
|
||||
row.)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
(no final assistant message — see the .err log)
|
||||
@@ -0,0 +1 @@
|
||||
error: no final assistant text
|
||||
@@ -0,0 +1,143 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 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/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 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 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 304 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/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 855 4 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 252 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 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6849 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,10 @@
|
||||
**Phase 182 — final verification pass (all 8 tasks already complete; this was verification + docs + one defect fix).**
|
||||
|
||||
- Verified: `app/rag/verbatim.py` (locator grammar, `splitlines()` slice, line-boundary cap, `resolve_answer_blocks`), `SHOW_TEXT_TOOL` + `shown_texts`, `SHOW_TEXT_CLAUSE`, `done.answer_text` (9 fields, key dropped when empty) + `ChatMessageRecord.answer_text` (≤50, 600 000-char cap counted in the 8 M budget), `verbatim.js` + 4 `{verbatim:true}` renderers, `Containerfile` minify step, `.env.example` 3 vars.
|
||||
- `uv run python -c "…answer_text, show_text_max_chars, show_text_max_blocks"` → `True 12000 5`; each negative refuses startup naming its field; `0` accepted as inert ✅
|
||||
- `bash .agents/validate.sh` → **validation OK**: 4320 unit+integration tests green, `TOTAL 6849 17 → 99%` (gate app/ >90% OK), `ruff check .` "All checks passed!", `pyright` 0 errors (re-run after my edits, 08:28).
|
||||
- E2E, each in isolation `--no-cov`: `test_verbatim_show_text.py` 13 passed (panel bytes == `documents.content`, server caption, clipboard == server string, `/document.html?source=…&path=…`, honest unresolved state, reload+share, `show_image` regression, flag-off app) ✅; green: `test_answer_images` `test_harness_aligned_tools` `test_agent_document_tools` `test_markdown_tables` `test_markdown_inline_links_images` `test_svg_document_preview` `test_smoke` `test_document_viewer` `test_markdown_blockquotes` `test_chat_persistence` `test_thinking_timeline` `test_chat_rag` `test_settings_mobile_value_width`.
|
||||
- **Defect found & fixed (pre-existing, not phase 182):** `tests/e2e/test_admin_settings_panel.py::test_admin_sees_groups_and_source_badges` failed — its whole-page `assert "reese" not in page.content()` tripped on the shell's onboarding chips, which follow the newest saved chats in the one shared E2E DB (a leftover `reeselink.json` question from a tool suite). Scoped the strict sweep to `.settings-shell` and kept a whole-page `reese:reese` credential needle → 12 passed with the leftovers still present.
|
||||
- Docs: README fixed 2 inconsistencies — Quick Start omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the app fail-loud refuses boot) and the broken anchor `#chat--` → `#chat-`; full re-read of README (1816 lines) + AGENTS confirmed the phase-182 feature/settings/log/UI-contract text matches the code, and every `.env.example` var + every `Settings` field is documented.
|
||||
- Notable: kept `resolve_answer_blocks` synchronous on the SSE terminal path (bounded indexed SELECTs, matching the existing `query_log` precedent and the phase's documented fence-lookup bound); no live-infra changes made.
|
||||
- Next pending phase: none — `todo/` holds only 182 (its 8 task files are in `complete/`; the harness commits + moves `00_phase.md`).
|
||||
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -0,0 +1,143 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 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/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 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 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 304 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/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 855 4 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 252 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 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6849 17 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 @@
|
||||
**Implemented (task 01 — settings + kill switch, inert)**
|
||||
- `app/config.py`: `answer_text: bool = True` (master switch, default ON, docstring names the env var + the exact off-state), `show_text_max_chars: int = 12_000`, `show_text_max_blocks: int = 5`, plus two `@field_validator`s (`…_non_negative`) that fail startup naming the field; `0` accepted as inert.
|
||||
- `app/core/settings_catalog.py`: three `SettingMeta` rows in the `Agent tools` group (`Answer text` / `Show text max chars` / `Show text max blocks`), plain — not secret, not restart-required, overridable.
|
||||
- `.env.example`: `BOR_ANSWER_TEXT=true`, `BOR_SHOW_TEXT_MAX_CHARS=12000`, `BOR_SHOW_TEXT_MAX_BLOCKS=5` in the agent-tools block with the `0`/negative semantics (dotenv-parsed and verified).
|
||||
- Tests: 5 new unit cases (`test_config.py`: defaults, env overrides, `0` inert, each negative refuses naming the field), 2 new catalogue pins (row group/label/flags + annotation/defaults), 1 new integration row-contract (`GET` shape, raw PUT → live, `0` storable, negative → 422 storing nothing, DELETE → default, anon 403), 1 new live-apply test (`_save` + `invalidate_settings()` → resolves without restart, reset returns defaults). No consumer reads the fields — no app behavior touched.
|
||||
- Stale-doc fix in previously-completed work: hard-coded "78 items/rows/settings" prose (already wrong at 79) made generic in `app/api/admin_settings.py`, `tests/integration/test_admin_settings_api.py`, `tests/e2e/test_admin_settings_panel.py`, `tests/e2e/test_settings_mobile_value_width.py`.
|
||||
|
||||
**Gates (all green)**
|
||||
- `uv run pytest` → **4274 passed** (unit + integration).
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%**; `app/config.py` 100%, `app/core/settings_catalog.py` 100%, `app/api/admin_settings.py` 100%.
|
||||
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
|
||||
- `uv run pytest tests/e2e/test_admin_settings_panel.py -v --no-cov` → 12 passed in isolation (the generated panel renders the three new rows).
|
||||
- Criteria checks: `get_settings()` prints `True 12000 5`; `BOR_SHOW_TEXT_MAX_CHARS=-1` / `BOR_SHOW_TEXT_MAX_BLOCKS=-1` each raise naming their field.
|
||||
|
||||
**Docs**: README env table — three new rows in *Agent tools* (ON-by-default rationale, line-boundary truncation, `0` = inert, negative refuses startup). No AGENTS.md change needed (it carries no env table; rule 5's verbatim-panel UI contract belongs to the phase's later/final task).
|
||||
|
||||
**Notable decisions**: budgets use the `>= 0` family (`0` = inert) rather than `> 0`, per LOCKED A8; block-cap error text is `must be >= 0 (blocks)`. No new consumers, no schemas/agent/prompts/frontend touched.
|
||||
|
||||
**Next pending task**: `.agents/phases/todo/182_verbatim_show_text/02_verbatim_resolver_module.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 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/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 280 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 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 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 304 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/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 454 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 855 4 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 156 0 100%
|
||||
app/rag/retriever.py 252 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 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/schemas.py 533 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6615 17 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
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
All gates green. Final report:
|
||||
|
||||
**Implemented**
|
||||
- `app/rag/verbatim.py` (new, inert — nothing imports it yet): `LOCATOR_LANG`, frozen `VerbatimBlock` / `SliceResult`, `canonical_locator` (tolerant `source/path` + ` `/`:`/`#`/`#L` + `12-40`/`12–40`/`L12-L40`/bare `12` → one canonical `source/path#start-end`, refuses no-source, empty, non-positive/reversed/non-numeric bounds, trailing junk, leading `bor`), `fences_in` (order-preserving, first-occurrence dedupe, bare `` ```bor `` not swallowed, unclosed fence yields nothing, body discarded), `slice_lines` (`read`/`grep`-identical 1-based semantics, clamping, `past_end` + true total), `cap_slice` (line-boundary cut + shared `TRUNCATION_MARKER`, mid-line exception documented, `cap<=0` raises), `document_by_identity` (bare first-slash `(source, path)` lookup, no agent import), `block_for` (refuses missing/image/past-end; canonical locator incl. cap-derived range), `resolve_answer_blocks` (kill switches first, fences-then-unlocated-shown order, fence wins dedupe, block-budget truncation).
|
||||
- One-way import rule enforced: no `app.rag.agent` import (AST test + clean `grep -n import`).
|
||||
- `tests/unit/test_verbatim_resolver.py`: 79 tests (grammar, fences, slicing, capping, identity, block, resolver fusion, every kill-switch combination, AST import rule), house style — detached `Document` rows + stand-in session like `tests/unit/test_agent.py`.
|
||||
|
||||
**Verification**
|
||||
- `uv run pytest tests/unit/test_verbatim_resolver.py -v` → 78→79 passed; `--cov=app.rag.verbatim` → **100%**
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **4353 passed**, TOTAL **99%** (>90%)
|
||||
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
|
||||
- `python -c "…canonical_locator('homelab/a.md#L12-L40')"` → `homelab/a.md#12-40` ✓
|
||||
|
||||
**Docs**: no doc impact (task states nothing user-visible yet; the module is unwired — tasks 03/05 hook it in, phase's final pass re-checks README/AGENTS).
|
||||
|
||||
**Notes/deviations**
|
||||
- Mid-session the repo was replayed under this run (HEAD now `814a400` phase 181, task 01 complete, `app/services/answer_text.py` superseded): I implemented the current task file's `app/rag/verbatim.py` spec against the live tree; earlier stale-tree work was discarded and left untouched.
|
||||
- `canonical_locator` refuses a range on a whitespace-bearing identity (documented grammar limit) and keeps one defensive non-numeric-bound guard (tested via monkeypatched grammar) so the parser stays total.
|
||||
- No live-infrastructure actions taken.
|
||||
|
||||
**Next pending task**: `.agents/phases/todo/182_verbatim_show_text/03_show_text_tool.md`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 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/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 280 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 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 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 304 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/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 454 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 855 4 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 156 0 100%
|
||||
app/rag/retriever.py 252 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 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 155 0 100%
|
||||
app/schemas.py 533 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6770 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,9 @@
|
||||
**Task 03 complete — `show_text` agent tool.**
|
||||
|
||||
- `app/rag/agent.py`: `SHOW_TEXT_TOOL` (exact task copy, after `SHOW_IMAGE_TOOL`), `agent_tools()` appends it when `answer_text` (offer order `ls, read, grep, show_image, show_text`; base three + pre-phase offers byte-identical), `AgentHolder.shown_texts`, and the `_execute_tool` branch in the exact 8-step order (path → flag-off `UNKNOWN_TOOL` → `_resolve_path` → A6 image refusal → reused `_read_range_args`/`BAD_RANGE_ARGS`/`OFFSET_PAST_END` → block budget before the work → `block_for` + canonical-locator dedupe → success result with the `read`/`show_image` header contract, `_origin_lines`, server-clamped `lines:` extent, `SHOWN_TEXT_TRUNCATION_NOTICE` on cap); module docstring extended with the A1–A3/A6 contract.
|
||||
- `SHOW_TEXT_*` refusals: 4 constants, each used by exactly one branch (agent.py:2248/2265/2286/2310).
|
||||
- Tests: +18 in `tests/unit/test_agent.py` (`-k show_text`: 18 passed) — offer matrix, tool-definition contract, refusal-copy pins, whole/ranged/capped shows, origin lines, 10 refusal cases with counting assertions, scripted-turn wiring (2nd line `Document …`, `lines: 3-5 of 5` matching the slice).
|
||||
- Deliberate offer-array pin updates (each commented): `test_chat_gate.py`, `test_image_chips_and_answers.py`, `test_agent.py`, `test_chat_api.py`, `test_agent_tools.py`, `test_read_line_ranges_handbook.py`, e2e `test_answer_images.py`; leak-guard env pins added to `tests/conftest.py` + `tests/e2e/conftest.py` (`BOR_ANSWER_TEXT`/`_MAX_CHARS`/`_MAX_BLOCKS` code defaults).
|
||||
- `README.md`: `show_text` row in the agent-tools table, new “Verbatim text display (phase 182)” section, cross-ref in the image table (settings rows already documented by task 01). No `chat_api.py`/`chat.js`/template touched.
|
||||
- Gates: `uv run pytest` → **4371 passed**; coverage `app/` **99%** TOTAL (agent.py 99%, only pre-existing line 1312 missing; verbatim.py 100%); ruff + pyright clean; E2E in isolation: `test_answer_images.py` 5 passed, plus `agent_document_tools`/`harness_aligned_tools`/`tool_budget_telemetry`/`read_truncation_cap`/`search_tool_scope` all green.
|
||||
- No git index/history mutations; phase stays in `todo/` (tasks 04–06 remain).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 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/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 280 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 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 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 304 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/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 855 4 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 156 0 100%
|
||||
app/rag/retriever.py 252 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 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 155 0 100%
|
||||
app/schemas.py 533 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6810 17 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
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
**Task 04 complete — `SHOW_TEXT_CLAUSE` taught, gated, byte-identical when off.**
|
||||
|
||||
- `app/rag/prompts.py`: new `SHOW_TEXT_CLAUSE` (locked 7-part copy: display contract → SEE/COPY/RUN → do-NOT-retype → ` ```bor <source/path> [range] ` with EMPTY body → server owns the numbers → refuse out loud → `show_image` pointer) + module-docstring paragraph.
|
||||
- `build_high_prompt(show_text=…)`: clause rides INSIDE `<tools>` after `SHOW_IMAGE_CLAUSE` via one "section minus final `</tools>` + clauses + tag" mechanism; `TOOLS_SECTION` untouched; all 4 flag combos pinned.
|
||||
- `app/api/chat.py`: grounded `TurnPlan` passes `show_text=settings.answer_text` beside `show_image`.
|
||||
- Tests: +7 in `test_prompts.py` (locked sentence order, byte-identity when off, inside-`<tools>` placement, clause composition, deflect never carries `show_text`/`bor`), +2 sha/composition locks in `test_prompt_lock.py`, +1 grounded integration pin in `test_chat_api.py` (clause **and** tool offered together: 3 tools off / 4 on).
|
||||
- Fixed pre-existing fallout of task 03 in prompt-reconstruction pins: `test_kb_overview_api.py` (×2), `test_image_chips_and_answers.py`.
|
||||
- **Defect fixed (deviation, deliberate):** `tests/e2e/mock_llm.py`'s SHOW_IMAGE flow gated on the loose `"show_image" in system`, which the locked text clause now matches → phase-124 toggle-off E2E failed. Tightened to `_SHOW_IMAGE_CLAUSE_MARKER` (a sentence only the image clause carries); intent unchanged, ON app fires exactly as before. Mock triggers for phase 182 still deferred to task 08.
|
||||
- Env note: a **stale `mock_llm` uvicorn from Oct 14 held port 8901** and served old mock code; killed it (test-double artifact, not live infra).
|
||||
|
||||
Gates: `uv run pytest --cov=app --cov-report=term-missing` → **4381 passed**, `app/` **99 %** (`prompts.py` 100 %). `uv run ruff check .` → clean; `uv run pyright` → 0 errors. E2E in isolation `--no-cov`: `test_answer_images.py`, `test_harness_aligned_tools.py`, `test_agent_document_tools.py`, `test_markdown_tables.py` all green. Criterion one-liner: `show_text=True` → `True`, default → `False`.
|
||||
|
||||
Docs: README "Verbatim text display (phase 182)" gained a *What the prompt teaches* bullet (copy-from-display rule + `bor` fence syntax + omit-the-range rule) and the off-state bullet now names the absent clause; SEE/COPY/RUN wording aligned. No AGENTS.md change (its rule-5 UI list lands with tasks 06/08).
|
||||
|
||||
Next pending task: `.agents/phases/todo/182_verbatim_show_text/05_done_frame_and_persistence.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 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/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 280 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 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 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 304 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/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 855 4 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 252 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 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 155 0 100%
|
||||
app/schemas.py 533 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6816 17 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
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
**Task 05 complete — all gates green.**
|
||||
|
||||
**Implemented**
|
||||
- `app/schemas.py`: new `VerbatimBlockRef` (source/path/title/start_line/end_line/total_lines/text≤600 000/truncated/locator≤2100); `ChatDoneEvent.answer_text` + serializer POPs the key when `None`/empty (absent, never `null`/`[]`); `ChatMessage.answer_text` (cap 50) dropped by `_drop_image_when_absent`; docstrings in the `answer_image` style incl. the two deliberate differences (list + inline bytes, A4).
|
||||
- `app/api/chat.py`: `visible_parts` accumulator beside `content_chars` (same post-filter pieces, never `thinking`); done-time `resolve_answer_blocks(...)` in a short-lived session, **skipped entirely on deflected turns** (D9), degrading to no-blocks + a named log line if the KB drops mid-answer; `answer_text=blocks or None`; per-turn line gains `verbatim_blocks=%d`; module docstring paragraph.
|
||||
- Defect fixes in task 02's `app/rag/verbatim.py` (found by the new frame tests): `cap_slice` under-counted a slice ending on a blank line (`splitlines()`), so the caption lied by one line **and** the locator mismatched the same range resolved by `show_text`, making one block paint twice; dedupe now keys on the resolved locator as well as the written canonical; fence scan stops once `show_text_max_blocks` blocks are in (bounded lookups on the loop).
|
||||
- `README.md`: `done.answer_text` wire contract + deflected/off semantics, payload-limit rows; `AGENTS.md` rule 10: `verbatim_blocks=N`.
|
||||
|
||||
**Gates**
|
||||
- `uv run pytest` → exit 0 (4398 collected); `uv run pytest --cov=app --cov-report=term-missing` → `app/schemas.py` 100 %, `app/rag/verbatim.py` 100 %, `app/api/chat.py` 99 % (only pre-existing miss: the SEC-14-04 concurrency 503 pre-check), TOTAL 99 %.
|
||||
- `uv run ruff check .` clean; `uv run pyright` 0 errors.
|
||||
- E2E in isolation (`--no-cov`, exit 0): `test_answer_images.py`, `test_harness_aligned_tools.py`+`test_agent_document_tools.py`+`test_markdown_tables.py`, `test_chat_persistence.py`/`test_share_chat.py`/`test_chat_rag.py`.
|
||||
- New tests: 3 gate unit + field-order pin, 8 chat-API integration (tool block, fence-only, fence+tool dedupe, max_blocks=2 order, byte-identity, `answer_text=False`, deflected, resolution-failure degradation), 4 chats-API persistence, 6 schema, 2 resolver defect pins.
|
||||
- Byte budget (phase-167 audit): observed `api method=POST path=/api/chat status=200 bytes=1272 enc=identity stream=1` on a `show_text` turn; worst-case frame (5 × 12 000-char blocks + long paths) ≈ 65 KB ≈ 25 % of the 256 KB budget — no concern.
|
||||
|
||||
**Deviations to note**
|
||||
1. `_require_within_total_budget` now also sums block bytes (task work-item 5 said existing validators don't change): `POST/PUT /api/chats` is anonymous (phase 55) and `answer_text[].text` carries the 600 000 cap, so without this the 8 000 000 SEC-05 invariant gains an unbounded second byte channel. Tightening only — no legitimate record (5 × 12 000 = 60 KB) is affected; pinned by `test_saved_chat_total_budget_counts_verbatim_block_text`.
|
||||
2. The `verbatim.py` fixes above touch task 02's module (behaviour bug, not a spec change) — pinned in `tests/unit/test_verbatim_resolver.py`.
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/182_verbatim_show_text/06_markdown_locator_and_panel_css.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 98%]
|
||||
........................................................................ [ 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/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 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 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 304 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/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 855 4 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 252 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 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6849 17 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
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
**Task 06 complete — all gates green.**
|
||||
|
||||
**Implemented**
|
||||
- `frontend/assets/markdown.js`: `renderMarkdown(md, opts)` (2nd optional arg, `opts && opts.verbatim === true` the only truthy path); new step-0 verbatim pass BEFORE the fence pass using the pinned regex; body/info line/closer dropped, `\u0000VERBATIMn\u0000` placeholder restored as `<div class="verbatim verbatim-pending" data-bor-locator="…">` + `verbatim-caption` + `role="status"` “Retrieving the exact text…”, everything through `escapeHtml`; unparseable locator returned byte-for-byte to the fence pass; new local `borLocator()` (client mirror of `app.rag.verbatim.canonical_locator`); paragraph guard gained the placeholder clause (pre-phase guard text untouched); header comment rewritten.
|
||||
- `frontend/assets/styles.css`: one labelled phase-182 region — `.verbatim`, sticky `.verbatim-head`, clipped `.verbatim-caption`, text `.verbatim-badge` (`--ok`, `text-transform: none`), `.verbatim-body` (`max-height: min(40rem,70vh)`, `overflow:auto`, `white-space: pre`, `tab-size: 2`, `overflow-wrap: normal`), `user-select: none` `.verbatim-lines` gutter, `.verbatim-pending`/`.verbatim-state`, `.verbatim-unresolved` (badge hidden), `.code-block` + `.code-block-copy`/`.verbatim-copy` (pill, 3px focus ring, ellipsis label, `.copied`); palette vars only, nothing animates; `.code-block-copy`/`.verbatim-copy` lifted to 44px in the ≤640 block.
|
||||
- New tests: `tests/unit/test_frontend_verbatim_markdown.py` (22: source pins + node behavioral + JS↔Python parity table + 400-case deterministic fuzz), `tests/unit/test_frontend_verbatim_css.py` (20).
|
||||
|
||||
**Gates**
|
||||
- `uv run pytest` → **4449 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (`app/rag/verbatim.py` 100%, no new `app/` code); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
|
||||
- New files in isolation: `uv run pytest tests/unit/test_frontend_verbatim_markdown.py tests/unit/test_frontend_verbatim_css.py -v` green.
|
||||
- E2E in isolation (`--no-cov`): `test_markdown_tables.py`, `test_markdown_blockquotes.py`, `test_markdown_inline_links_images.py`, `test_document_viewer.py`, `test_answer_images.py`, `test_thinking_display.py`, `test_background_no_motion.py` all green.
|
||||
- Live browser check against the served app: `{verbatim:true}` → `data-bor-locator="homelab/a.md#12-40"`, no `GARBAGE` in the HTML and none in the painted DOM; sticky head, 70vh-capped scroll body, `user-select: none` gutter, badge `VERBATIM`, copy pill 32px desktop → 44px at 375px.
|
||||
|
||||
**Docs**: README — new chat-section paragraph (opt-in pass + pass order) and a “The client never paints the model’s copy” bullet in §Verbatim text display; AGENTS.md rule 5 — the verbatim-panel contract (“verbatim-and-badged OR plainly the model’s”).
|
||||
|
||||
**Decisions / deviations**
|
||||
1. Criterion #2’s “OFF returns the ordinary `<pre><code>`”: a ` ```bor <path> ` info string has *never* matched the fence rule (`/```(\w*)\n/`), so OFF is escaped prose — pinned byte-identical to pre-phase; a bare ` ```bor ` fence still yields `<pre><code>`. Widening the fence rule was rejected (it would change every existing call site, violating the byte-identity mandate).
|
||||
2. Implemented the pinned regex verbatim: `(?![\s\S])` covers a fence open exactly at end-of-input (info line typed, body not yet streamed); a half-typed *body* renders as ordinary text until its closer arrives — same as every incomplete fence today. Documented in header + code comment.
|
||||
3. Added the `VERBATIM`-placeholder clause to the paragraph guard so the restored `<div>` isn’t wrapped in `<p>`; the pre-phase guard string is untouched (phase-180 pin stays green).
|
||||
4. Went beyond the task’s testing note by pinning locator parity in a unit test (table + deterministic fuzz) — parity is hydration-critical and checkable without a browser.
|
||||
5. Dropped `scrollbar-color` from `.verbatim-body`: `test_prose_word_wrap.py` pins scrollbar theming as modal-only (phase 116).
|
||||
6. Fixed a stale pin in `tests/unit/test_document_viewer.py` (`function renderMarkdown(md)` → `function renderMarkdown(md`), broken by the new signature.
|
||||
|
||||
**Next pending task**: `182_verbatim_show_text/07_hydration_and_copy_buttons.md` (opts the three answer renderers in, adds `verbatim.js` hydration + Copy buttons).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 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/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 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 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 304 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/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 855 4 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 252 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 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6849 17 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
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
All gates are green. Final state of the work:
|
||||
|
||||
## What landed (task 07 — hydration, panels, Copy buttons)
|
||||
|
||||
**New client — `frontend/assets/verbatim.js`** (531 lines, classic script, globals like `markdown.js`)
|
||||
- `buildVerbatimPanel(block, opts)` → sticky `.verbatim-head` (`.verbatim-caption` `source/path · lines 12–40 of 210` / `… · all 210 lines`, `.verbatim-badge` `VERBATIM`, `.verbatim-open`, `.verbatim-copy` `Copy N lines from <source/path>`), `.verbatim-body` = `ol.verbatim-lines` gutter + `pre.verbatim-text` (`pre.textContent = block.text`), `.verbatim-more` on a capped block; root `role="group"` + `aria-label` + `data-bor-locator` = the **server's** locator.
|
||||
- `hydrateVerbatim` (exact canonical-locator `===`, else exactly one block on the same `source/path`, else the pinned unresolved sentence — never throws, idempotent because only `.verbatim-pending` is touched), `appendUnlocatedVerbatim` (the `answer_image` band), `settleVerbatimPlaceholders`, `enhanceAnswerCodeBlocks` (`.code-block` + Copy, **no badge**, idempotent), `copyTextWithFallback`.
|
||||
- **No second locator grammar** (no `borLocator`, no range regex, no `parseInt`) — pinned as the phase's drift guard. All `createElement`/`textContent`, all `addEventListener`, zero `innerHTML`.
|
||||
|
||||
**Wiring** — `index.html` + `shared.html` load it after `markdown.js`; `Containerfile` minifies it without `--bundle`. `app.js`: both live answer renderers opt in, the `done` handler runs image → hydrate → unlocated → Copy-settle → sources, `answer_text: ev.answer_text || undefined` joins the save point, the restore path re-renders + hydrates from `m.answer_text`, five terminal settle sites (`error` frame, 300 s guard, its abort surface, Stop, the failed-turn funnel), `VERBATIM_LINK_OPTS` (`documentUrl(…, "/")` + `openDocumentModal`), and the share copy now **delegates** to the one clipboard helper. `shared.js`: same three globals + a plain `/document.html` href, no modal. `styles.css`: extended only inside the labelled phase-182 region (`.verbatim-body` flex row, `.verbatim-text`, `.verbatim-open`, `.verbatim-more`, 44 px mobile link) — palette variables only.
|
||||
|
||||
## Verification
|
||||
- `uv run pytest tests/unit tests/integration` → **4483 passed**; `--cov=app` → **TOTAL 99 %** (>90 %).
|
||||
- `ruff check .` → all checks passed; `pyright` → **0 errors**.
|
||||
- New `tests/unit/test_frontend_verbatim_blocks.py`: **34 tests** (source pins + a node half that runs caption/gutter/match/block-guard logic against the real file).
|
||||
- Stale pins reconciled (byte-pin `PINNED_RUN_TURN` regenerated, 3 brain-restore shapes, the task-06 call-site test rewritten as *only the answer renderers opted in*, the share-copy internals re-pinned at their new home, `shared.html` script order, `renderMarkdown(finalText, { verbatim: true })`).
|
||||
- Playwright regression pass: smoke, share_chat, chat_answer_clusters, chat_persistence, prose_word_wrap, no_empty_box, save_share_ux, failed_turn_retry, answer_images, long_answers, reasoning_bubble, edit_and_resend, chat_error_honesty — all green.
|
||||
- Real-browser sanity check (throwaway script, not committed): caption/badge/gutter/Copy at 1280 px and 375 px, gutter stays put while the body scrolls sideways, **clipboard equals the server string exactly**, `Copy`→`Copied`, plain code block copies with no badge, unresolved shows the sentence with no badge/body/`<pre>`, no page overflow at 375 px, 44 px tap targets, 3 px `:focus-visible` ring.
|
||||
|
||||
**Docs**: README (panel anatomy, the hydration rule, unresolved/settle behaviour, Copy-on-every-settled-block, parity, the shared-page exception, renderer opt-in count) and AGENTS rule 5's UI contract line.
|
||||
|
||||
## Notes
|
||||
- Nothing committed and nothing staged — child passes never touch the index/history; the harness makes the one atomic phase commit.
|
||||
- Environment gotcha worth knowing: a stray `python -m http.server` on port 8123 makes every E2E suite fail with `404`/`Sign-in failed (HTTP 501)`; I killed the one my own throwaway check left behind.
|
||||
- For task 08's E2E, the behaviours already pinned at source level that want browser assertions: exactly one `.verbatim` per locator after a reload, clipboard equality, the unresolved copy, nothing at *Retrieving the exact text…* after `slow_llm`/always-fail, the appended unlocated block above the source clusters, and shared-page parity with no other controls.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 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/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 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 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 304 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/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 855 4 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 252 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 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6849 17 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
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
**Task 08 complete.** Everything is verified green; nothing staged or committed (the pipeline owns the commit and the task-file move).
|
||||
|
||||
## Delivered
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `tests/e2e/test_verbatim_show_text.py` | **new**, 1 428 lines — the phase's dedicated Playwright suite (13 specs) |
|
||||
| `tests/e2e/mock_llm.py` | +402 lines — the three scripted `show_text` branches, the corrupted-fence-body generator, the fixture doc-picker, module docstring |
|
||||
| `.agents/PLAN.md` | §4 fifth tool + additive `done.answer_text`, §9 `verbatim_blocks=N`, revisions paragraph incl. the explicit **A1–A6 / A15 otherwise UNCHANGED** statement |
|
||||
| `README.md` | two precision fixes to the `BOR_ANSWER_TEXT=0` wording (server surface is pre-phase; the client's never-paint-the-model's-copy rule is flag-independent) |
|
||||
|
||||
Suite coverage: tool flow with a deliberately corrupted model fence, server-bytes/caption/gutter/badge/viewer link, five-tool offer + one executed call, in-page SSE tap (`≤7` event types, `answer_text` inside `done`), append path above the source clusters, clipboard byte-equality for `.verbatim-copy` and `.code-block-copy`, standalone fence with zero tool calls, honest state for a hallucinated locator, wrong range corrected to the server's extent, `OFFSET_PAST_END` refusal, reload + **`/history` → `/?chat=<id>` (local storage cleared, so the pass cannot be vacuous)** + anonymous shared page, image regression + `show_text`-on-image refusal naming `show_image`, 390 px scroll/44 px controls, and App B (`BOR_ANSWER_TEXT=false`).
|
||||
|
||||
## Gates
|
||||
|
||||
| Gate | Result |
|
||||
|---|---|
|
||||
| `pytest tests/e2e/test_verbatim_show_text.py --no-cov` (isolated) | **13 passed** in 28 s |
|
||||
| Regression sweep (all 14 named suites + phase 180's, isolated) | **14/14 rc=0**, no edits to existing specs |
|
||||
| `pytest --cov=app` (unit + integration) | all pass, `app/` coverage **99%** (17 missed / 6 849) |
|
||||
| `ruff check .` | All checks passed |
|
||||
| `pyright` | 0 errors |
|
||||
| Phase-116 trigger discipline | 41 triggers: no containment involving a new phrase; no other suite contains one |
|
||||
| **Mutation check (suite has teeth)** | 5/5 caught: drop the live `{verbatim:true}` opt-in · Copy writes wrong string · honest sentence muted · append path no-op'd · panel body swapped for the title |
|
||||
|
||||
## Flagged (per operating rule 3 — not silently deviated)
|
||||
|
||||
1. **App B fence rendering.** Task 08's bullet expects a ` ```bor ` fence with the flag off to render as an ordinary `<pre><code>` block. That contradicts the contract locked by task 07 and recorded in `AGENTS.md` rule 5 ("an unresolvable locator painting the pinned sentence… with no badge, no body and **none of the model's bytes**"). I asserted the **locked** behavior (honest sentence + Copy on the ordinary block) and corrected the README wording; pre-phase `<pre><code>` for a `bor` body would re-open the drift incident.
|
||||
2. **One existing mock branch was NOT byte-identical.** Phase 182's `SHOW_TEXT_CLAUSE` ends "For an image use `show_image` instead.", so phase 124's gate `if "show_image" not in system` now also matches a text-clause-only prompt and the image flow fired on `BOR_ANSWER_IMAGES=0` apps. Tightened to `_SHOW_IMAGE_CLAUSE_MARKER = "displays a standalone image document"` (a sentence only the image clause carries, `prompts.py:394`); `test_answer_images.py` green.
|
||||
3. **Trigger constant names** are public (`SHOW_TEXT_TRIGGER`, `SHOW_TEXT_INLINE_TRIGGER`, `SHOW_TEXT_MISSING_TRIGGER`) rather than the task's `_FENCE_ONLY_TRIGGER`/`_BAD_LOCATOR_TRIGGER`, matching the mock's convention for constants suites import; semantics, phrases and flow order are exactly as specified. `_SHOW_TEXT_CONFIRM_MARKER` keeps its task name.
|
||||
|
||||
Incident note for transparency: an earlier shell-quoting mistake of mine silently stripped `{ verbatim: true }` from `app.js:4363`; it was restored and the file is byte-verified (md5 `33a584f3…`), with the full suite, the E2E suite and the mutation harness all re-run against the restored tree.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 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/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 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 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 304 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/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 855 4 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 252 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 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6849 17 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
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 42 KiB |
@@ -104,6 +104,9 @@ BOR_NAME_HIT_BONUS=0.005 # selection-time bonus a name-hit doc's best f
|
||||
# --- Agent document tools (grounded turns may extend context: ls / read / grep) ---
|
||||
# BOR_AGENT_MAX_ROUNDS=10 # hard cap on agent tool rounds per turn (0 = no tools); every tool result carries the model's remaining count (phase 160)
|
||||
# BOR_READ_MAX_CHARS=128000 # cap on a `read` result (chars); over-cap reads truncate + a notice that teaches the grep -> read(offset, limit) workflow (phase 95, rewritten phase 131)
|
||||
BOR_ANSWER_TEXT=true # verbatim file display (phase 182): the master switch, ON by default (an answer's code block must never silently differ from the file). false = the escape hatch — the `show_text` tool is not offered, its <tools> teaching clause is absent, ```bor locator fences are not resolved, and the done frame carries no `answer_text` key (every byte-identity contract is exactly pre-phase)
|
||||
BOR_SHOW_TEXT_MAX_CHARS=12000 # per-block char budget for ONE displayed verbatim slice (phase 182); the slice is cut at a LINE BOUNDARY + the shared […truncated…] marker and the caption's line range shrinks to what is actually shown; 0 = inert (no block displays), negative fails startup naming the field
|
||||
BOR_SHOW_TEXT_MAX_BLOCKS=5 # per-answer cap on displayed verbatim blocks (phase 182), counted in answer order; 0 = inert (no block displays), negative fails startup naming the field
|
||||
|
||||
# --- Import scope (A9 default; ANY well-formed extension is allowed) ---
|
||||
# Comma-separated file extensions (lowercase, no dot) the importer reads.
|
||||
|
||||
@@ -47,7 +47,30 @@
|
||||
in-flight row (`role="status"`) / alert + Retry row
|
||||
(`role="alert"`, a real `<button>`) sits inside that same bounded
|
||||
scroll panel — and the stat cards read the server's `totals`,
|
||||
never a walk of a depth-limited tree (phase 167)) and
|
||||
never a walk of a depth-limited tree (phase 167); an answer's
|
||||
code block is EITHER verbatim-and-badged OR plainly the model's —
|
||||
never a silently-substituted hybrid: the ` ```bor ` locator fence
|
||||
renders through the ONE shared renderer as
|
||||
`.verbatim[data-bor-locator]` with the model's body DROPPED
|
||||
(`renderMarkdown(md, { verbatim: true })`, off by default so every
|
||||
other surface is byte-identical), styled from palette variables only
|
||||
with a `min(40rem, 70vh)` scroll body, a sticky `source/path`
|
||||
caption and a `user-select: none` line-number gutter, while the
|
||||
plain `.code-block` wrapper carries a Copy button and no badge
|
||||
(phase 182); those placeholders hydrate against the frame's
|
||||
`answer_text` through the ONE panel client
|
||||
(`frontend/assets/verbatim.js` — a classic script loaded AFTER
|
||||
`markdown.js` on the chat AND shared pages, no second locator grammar,
|
||||
a plain string compare against the server's canonical `locator`, the
|
||||
caption's numbers always the SERVER's extent, an unresolvable locator
|
||||
painting the pinned sentence `I could not verify this against the
|
||||
knowledge base` with no badge, no body and none of the model's bytes,
|
||||
no panel left at "Retrieving the exact text…" after a terminal frame
|
||||
(the `error` frame, the 300 s guard, Stop and the failure funnel all
|
||||
settle it), the Copy button's clipboard content equal to the server
|
||||
string exactly, and the shared page's panel Copy button + plain
|
||||
`/document.html` link the ONE owner-directed exception to that page's
|
||||
zero-controls rule) — phase 182 task 07) and
|
||||
meets WCAG 2.1 AA basics (semantic
|
||||
landmarks, labels, contrast ≥4.5:1, focus-visible, aria-live for the
|
||||
stream).
|
||||
@@ -70,7 +93,10 @@
|
||||
Playwright E2E file passes **in isolation**
|
||||
(`uv run pytest tests/e2e/test_<story>.py -v --no-cov`).
|
||||
10. **Amply log, visibly feed back:** server-side, log the required
|
||||
per-turn line (PLAN §9) **and** the per-response byte-budget line
|
||||
per-turn line (PLAN §9 — phase 182 appends `verbatim_blocks=N` after
|
||||
`scaffold_stripped=N`: the verbatim display blocks actually SENT on
|
||||
the `done` frame, `0` on every turn without them, deflected included)
|
||||
**and** the per-response byte-budget line
|
||||
(`app/core/response_budget.py` — one
|
||||
`api method=… path=… status=… bytes=… enc=… duration_ms=…` line per
|
||||
`/api/*` response, INFO at/over `BOR_LOG_LARGE_RESPONSE_BYTES`, read it
|
||||
|
||||
@@ -23,6 +23,7 @@ RUN mkdir -p /out/assets \
|
||||
&& esbuild ./assets/doc-edit.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/doc-edit.js \
|
||||
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
|
||||
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
|
||||
&& esbuild ./assets/verbatim.js --minify --outfile=/out/assets/verbatim.js \
|
||||
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
|
||||
&& cp ./index.html ./document.html ./login.html ./shared.html ./doc-edit.html /out/
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ uv sync
|
||||
# 2. Configure (copy and edit)
|
||||
cp .env.example .env
|
||||
# Set BOR_LLM_API_KEY — your aipi key (falls back to $AIPI_KEY)
|
||||
# Set BOR_ADMIN_PASSWORD and BOR_SESSION_SECRET — both are REQUIRED:
|
||||
# the app refuses to start while either is empty (see Admin & Sign-in)
|
||||
|
||||
# 3. Start the database
|
||||
podman compose up -d db
|
||||
@@ -162,6 +164,22 @@ collapses into body text (the UA default is 0.83em/0.67em) nor out-shouts
|
||||
the answer around it, in the bubble, the thinking block and the viewer
|
||||
alike.
|
||||
|
||||
**The renderer has one more pass, and it is opt-in** (phase 182):
|
||||
`renderMarkdown(md)` is now `renderMarkdown(md, opts)`, and the ONLY truthy
|
||||
option is `{ verbatim: true }`, passed by exactly the FOUR answer renderers —
|
||||
the live per-delta write, the `done`-time re-render, the chat page's restore
|
||||
and the shared page's record renderer. Every other call site omits it, so the
|
||||
feature is inert elsewhere. With it
|
||||
on, a ` ```bor <source/path> <range> ` locator fence is
|
||||
replaced — fence line, body and closer included — by the pending verbatim
|
||||
panel, so the model's re-typed copy of a file is never painted (see
|
||||
[Verbatim text display](#verbatim-text-display-phase-182)). Pass order with
|
||||
the option on is **verbatim → fences → tables → escape+inline → blockquotes →
|
||||
paragraphs → restore**; with it off — the document viewer, the thinking runs,
|
||||
and every question bubble on both pages — the output is byte-identical
|
||||
to pre-phase, so a document that legitimately contains a ` ```bor ` fence
|
||||
still renders its own text.
|
||||
|
||||
The document's meta row (the chat modal **and** the standalone viewer at
|
||||
`/document.html`) carries a **Copy path** button: one click copies the
|
||||
document's `source/path` (the exact form the `read` tool, the chips, and
|
||||
@@ -296,7 +314,15 @@ The overlay can be switched off for the whole process with
|
||||
### Shared chat (`/shared/<token>`)
|
||||
|
||||
A read-only snapshot of any conversation, shareable via a link. Open to
|
||||
everyone — no login or token required.
|
||||
everyone — no login or token required. The page still ships no composer, no
|
||||
Save/Share/Tune/Retry and no chips-as-buttons; the ONE interactive exception is
|
||||
phase 182's answer code: a shared verbatim panel and the answer's ordinary
|
||||
code blocks carry a **Copy** button and the panel a plain
|
||||
`/document.html?source=…&path=…` link, because a page that shows a slice of a
|
||||
file has to let the reader copy it and check it. Both come from the same
|
||||
`verbatim.js` the chat page calls (so a shared turn renders identically to the
|
||||
owner's), and a guest who follows the viewer link lands on the sign-in page —
|
||||
the documents API is admin-gated.
|
||||
|
||||
### Conversation payload limits
|
||||
|
||||
@@ -311,7 +337,9 @@ saving a conversation can never be stricter than continuing it (phase
|
||||
| One message (`text`, `thinking`) | 600 000 chars | a **full-length answer at the largest answer cap** — `BOR_MAX_OUTPUT_TOKENS` at 131 072 tokens ≈ 524 300 chars — as history and as a saved record, with margin (the old 32 000 ceiling broke even at the 32 768-token default) |
|
||||
| Turns sent as `history` | 500 | the whole browser conversation (the client sends everything it holds, minus the question being asked) |
|
||||
| Messages per saved chat | 500 | the same conversation, saved |
|
||||
| **Total chars per saved chat** | **8 000 000** | ~15 full-length answers; the one bound on the anonymous save surface, which stays bounded on purpose |
|
||||
| One verbatim block (`answer_text[].text`) | 600 000 chars | the same answer-text cap (phase 182) — comfortably above `BOR_SHOW_TEXT_MAX_CHARS`, so raising that budget never hits the wire limit |
|
||||
| Verbatim blocks per saved message | 50 | the `tools` / `segments` number (phase 182) — far past the `BOR_SHOW_TEXT_MAX_BLOCKS` default of 5 |
|
||||
| **Total chars per saved chat** | **8 000 000** | ~15 full-length answers; the one bound on the anonymous save surface, which stays bounded on purpose — and it counts the verbatim blocks' bytes too (phase 182), so the display blocks cannot become a second, unbounded byte channel |
|
||||
| One question (`message`) | 4 000 chars | unchanged; the composer counts it live |
|
||||
|
||||
These are sanity ceilings, not the prompt's budget: the real gate is
|
||||
@@ -675,8 +703,10 @@ server-side tools — on **grounded** (high-relevance) turns only:
|
||||
| **`read(path, offset?, limit?)`** | Appends one indexed document's text to context by its combined `source/path`; optional `offset` (1-based first line) + `limit` (line count) read just that part of a large document; very large reads are truncated at `BOR_READ_MAX_CHARS` with a notice that teaches the workflow — grep reports line numbers, then read that part with `offset` + `limit` |
|
||||
| **`grep(pattern, path?)`** | Searches indexed documents for an exact string (case- and accent-insensitive fixed substring — accents fold: `besta` matches `BESTÅ`, phase 162); returns up to 20 `source/path:line: text` matches; an optional `path` limits to one document |
|
||||
| **`show_image(path)`** | *(only when `BOR_ANSWER_IMAGES=1`)* shows one standalone image document as part of the answer — image documents only, at most one per answer |
|
||||
| **`show_text(path, offset?, limit?)`** | *(only when `BOR_ANSWER_TEXT=1`, the default)* displays the named range of a text document to the user **verbatim** — the server shows the bytes and keeps them byte-identical; the model describes them, never retypes them; image documents are refused (use `show_image`); per-answer display budgets enforced server-side |
|
||||
|
||||
(See [Answer images](#answer-images-phase-124) for the `show_image` tool.)
|
||||
(See [Answer images](#answer-images-phase-124) for the `show_image` tool and
|
||||
[Verbatim text display](#verbatim-text-display-phase-182) for `show_text`.)
|
||||
|
||||
**The prompt teaches the search discipline (phase 162).** A summary or an
|
||||
outline is a hint, never an inventory: the system prompt tells the model to run
|
||||
@@ -734,7 +764,7 @@ instead of an error frame (phase 160, the recorded revision of the
|
||||
phase-71 cap-path terminal). The `done` frame carries an additive
|
||||
`budget_exhausted` key (ABSENT on every non-cap turn) that drives the
|
||||
**Continue researching** button on the answer's meta row — see the
|
||||
[Chat (`/`)](#chat--) section.
|
||||
[Chat (`/`)](#chat-) section.
|
||||
|
||||
---
|
||||
|
||||
@@ -971,6 +1001,142 @@ of its answer:
|
||||
|
||||
---
|
||||
|
||||
## Verbatim text display (phase 182)
|
||||
|
||||
The mirror of the answer-image trust model, for words: when the user asks to
|
||||
see part of a document — a stanza, a passage, an exact range of lines — the
|
||||
model **names the location and the server shows the bytes, byte-for-byte**.
|
||||
The model never retypes quoted text, so a displayed quote cannot drift.
|
||||
|
||||
- **The model decides.** The grounded agent is offered a display tool,
|
||||
`show_text(path, offset?, limit?)`, and calls it when the user wants to
|
||||
SEE, COPY or RUN a file — a quadlet, a config, a script, a unit file —
|
||||
or read its exact wording. Optional
|
||||
`offset` (1-based first line) and `limit` (line count); an overshooting
|
||||
`limit` is clamped, an `offset` past the last line is refused with the
|
||||
true line count.
|
||||
- **What the prompt teaches.** With the switch on, the grounded `<tools>`
|
||||
section gains one clause (after the `show_image` clause when that rides
|
||||
too) saying the two things the tool description cannot: the user copies
|
||||
from **what is displayed**, not from the model's prose — so after a
|
||||
`show_text` call the model describes the lines instead of retyping them
|
||||
— and the inline locator fence for placing a displayed block mid-answer:
|
||||
a line of three backticks + `bor` + the combined `source/path` + an
|
||||
optional `12-40` range, with an **empty body** the app replaces with the
|
||||
stored text. Line numbers come from `grep`/`read`, never from a guess —
|
||||
when unsure, the range is omitted and the whole document is displayed.
|
||||
`BOR_ANSWER_TEXT=0` removes the clause too, so the prompt never teaches
|
||||
a tool the model was not given.
|
||||
- **Server-side budgets.** At most `BOR_SHOW_TEXT_MAX_BLOCKS` blocks per
|
||||
answer (a second identical show is refused — it is already on screen);
|
||||
each block is capped at `BOR_SHOW_TEXT_MAX_CHARS`, cut at a line boundary
|
||||
with a truncation marker. Image documents are refused with a pointer to
|
||||
`show_image` — the two display tools never convert into each other.
|
||||
- **The bytes ride the `done` frame.** A turn that displayed text ends
|
||||
with one extra, additive SSE key: `done.answer_text`, the list of blocks
|
||||
in **display order** — answer order first (one per ` ```bor ` fence, the
|
||||
fence controls placement), then the `show_text` blocks the answer never
|
||||
located. Each entry is
|
||||
`{source, path, title, start_line, end_line, total_lines, text, truncated, locator}`
|
||||
where `text` is the **server's** slice of `documents.content` (never
|
||||
model output) and `start_line`/`end_line` are the server's resolved
|
||||
1-based inclusive extent — the numbers a caption shows can never be the
|
||||
model's claim. `locator` is the canonical matching key
|
||||
(`source/path`, or `source/path#12-40` for a range). There is no new
|
||||
bytes route and no eighth SSE event type: the bytes travel inline, and a
|
||||
saved or shared chat renders its panels from its own record. The same
|
||||
array persists on the saved brain record (`bor.chat.v1` `answer_text`),
|
||||
so a reload and an anonymous shared page show the same blocks.
|
||||
- **Deflected turns resolve nothing.** A ` ```bor ` fence written into a
|
||||
deflected answer leaks no KB text — the disclaimer turn resolves no
|
||||
block and its frame carries no `answer_text` key.
|
||||
- **Off = pre-phase.** `BOR_ANSWER_TEXT=0` removes the tool from every
|
||||
offer **and** its `<tools>` clause (the grounded system prompt is
|
||||
byte-identical to pre-phase); answers return to composed text (the model
|
||||
quoting from what it read). A turn with no blocks serializes **without**
|
||||
the key — absent, never `null`, never `[]` — so every block-free frame
|
||||
and every pre-phase saved record keeps its exact pre-phase bytes. The
|
||||
SERVER surface is byte-pre-phase; the CLIENT rule is flag-independent, so
|
||||
a stray ` ```bor ` fence in a flag-off answer still cannot paint the
|
||||
model's copy — with no `answer_text` to hydrate it, it settles to the same
|
||||
honest sentence (the phase E2E's App B spec).
|
||||
|
||||
- **The client never paints the model's copy.** The one shared markdown
|
||||
renderer (`frontend/assets/markdown.js`) takes a SECOND, optional
|
||||
argument — `renderMarkdown(md, { verbatim: true })` — and with it ON a
|
||||
` ```bor ` locator fence is replaced, info line, body and closing fence
|
||||
included, by a pending panel:
|
||||
`<div class="verbatim verbatim-pending" data-bor-locator="…">` holding the
|
||||
`source/path` caption and the live-region line *Retrieving the exact
|
||||
text…*. The locator is normalized client-side by the exact mirror of the
|
||||
server's `canonical_locator` (`source/path`, or `source/path#12-40` —
|
||||
digits only, so the two sides match on a string compare), the model's
|
||||
bytes appear NOWHERE in the output, and a fence the model has not finished
|
||||
typing shows that same pending state instead of raw backticks. An
|
||||
unparseable locator degrades to an ordinary code block with the model's own
|
||||
body — no placeholder, no badge. With the argument OFF — every other
|
||||
surface in the app: the document viewer, the thinking blocks, the question
|
||||
bubbles — the renderer's output is byte-identical to pre-phase, so a
|
||||
document that legitimately contains a ` ```bor ` fence still renders its
|
||||
own text. Pass order with the option on:
|
||||
**verbatim → fences → tables → escape+inline → blockquotes → paragraphs →
|
||||
restore**.
|
||||
|
||||
- **Hydration paints the panel, and the panel is the file.** The client half
|
||||
is one classic script, `frontend/assets/verbatim.js`, loaded after
|
||||
`markdown.js` on the chat page and the shared page. At `done` (and on every
|
||||
restore) it matches each placeholder's normalized `data-bor-locator`
|
||||
against the frame's `answer_text[].locator` with a plain string compare —
|
||||
`markdown.js::borLocator` stays the ONLY locator grammar in the client, so
|
||||
the two sides cannot drift — and paints:
|
||||
|
||||
- `.verbatim-caption` — `source/path · lines 12–40 of 210`, or
|
||||
`source/path · all 210 lines` when the block is the whole document: the
|
||||
numbers are the **server's** extent, so a locator whose range the model got
|
||||
wrong still shows what was actually read (a panel matched on `source/path`
|
||||
alone is A2 made visible).
|
||||
- `.verbatim-badge` — the word `VERBATIM`: these bytes came from the
|
||||
knowledge base, not from the model.
|
||||
- `.verbatim-lines` — the server's line numbers, `user-select: none`, so a
|
||||
selection or a Copy never drags digits into the clipboard. A capped block's
|
||||
marker line gets a BLANK cell, because the marker is not a line of the file.
|
||||
- `.verbatim-text` — `answer_text[].text` through `textContent`, byte for
|
||||
byte (never re-wrapped, never trimmed, never joined).
|
||||
- `.verbatim-copy` — `Copy` → `Copied` (the state is TEXT), accessible name
|
||||
`Copy N lines from <source/path>`; the clipboard receives the server string
|
||||
exactly.
|
||||
- `.verbatim-open` — the document viewer: on the chat page the same same-page
|
||||
modal as a source chip (back → the chat), on the shared page a plain
|
||||
`/document.html?source=…&path=…` link.
|
||||
- `.verbatim-more` — on a capped block, one muted line saying the rest is in
|
||||
the document, plus that link.
|
||||
|
||||
A locator the server did not resolve renders the honest sentence *I could
|
||||
not verify this against the knowledge base — open `<source/path>`* with NO
|
||||
badge, NO body and none of the model's bytes; and every terminal path (the
|
||||
`error` frame, the 300 s guard, Stop, the failed-turn funnel) settles any
|
||||
leftover placeholder, so nothing ever sits at *Retrieving the exact text…*
|
||||
once a turn is over. A `show_text` block the answer never located is
|
||||
appended in the `answer_image` band — below the answer text, above the
|
||||
source clusters.
|
||||
|
||||
- **Every settled answer code block can be copied.** `enhanceAnswerCodeBlocks`
|
||||
wraps each `<pre>` inside a settled answer bubble that is not part of a
|
||||
panel in `.code-block` and adds a `.code-block-copy` pill: **no badge and no
|
||||
caption**, because a `VERBATIM` claim belongs only to a server-resolved
|
||||
block — an answer's code is either verbatim-and-badged or plainly the
|
||||
model's, never a silently-substituted hybrid. Buttons appear when the turn
|
||||
settles, never mid-stream. The share link's copy path delegates to the same
|
||||
`copyTextWithFallback`, so the http-origin inline select-to-copy degradation
|
||||
exists once (the owner-locked share strings and behavior are unchanged).
|
||||
- **Parity, and what stays untouched.** The live turn, a reload and an
|
||||
anonymous shared page call the same three globals in the same order, so one
|
||||
answer renders one way. Only the two answer renderers (plus the shared
|
||||
page's) opt in: a question, a thinking run and the document viewer stay
|
||||
one-argument calls, and `document.html` does not even load the panel client.
|
||||
|
||||
---
|
||||
|
||||
## PDF documents (phase 126)
|
||||
|
||||
PDFs are **opt-in**: add the `pdf` token to `BOR_IMPORT_EXTENSIONS`
|
||||
@@ -1339,6 +1505,9 @@ startup (the DB rows shadow the env, `.env` is never rewritten).
|
||||
|-----|---------|---------|
|
||||
| `BOR_AGENT_MAX_ROUNDS` | `10` | Hard cap on agent tool rounds per grounded turn (0 = no tools); every tool result carries the model's remaining count (phase 160) |
|
||||
| `BOR_READ_MAX_CHARS` | `128000` | Cap on a `read` tool result (chars); over-cap reads truncate + a notice that teaches the grep → `read(offset, limit)` workflow (phase 95, rewritten phase 131) |
|
||||
| `BOR_ANSWER_TEXT` | `1` | Verbatim file display (phase 182): the **master switch, ON by default** — an answer's code block is typed by the server from the indexed bytes, never retyped by the model. `0` = the escape hatch: the `show_text` tool is not offered, its `<tools>` teaching clause is absent, ` ```bor ` locator fences are not resolved, and the `done` frame carries no `answer_text` key (the server surface is exactly pre-phase; a stray fence on the client still settles to the honest unresolved sentence rather than the model's copy) |
|
||||
| `BOR_SHOW_TEXT_MAX_CHARS` | `12000` | Per-block character budget for ONE displayed verbatim slice (phase 182): the slice is cut at a **line boundary**, the shared `[…truncated…]` marker is appended and the caption's line range shrinks to what is actually shown. `0` = inert (nothing displays); a negative value refuses startup naming the field |
|
||||
| `BOR_SHOW_TEXT_MAX_BLOCKS` | `5` | Per-answer cap on displayed verbatim blocks (phase 182), counted in answer order. `0` = inert (nothing displays); a negative value refuses startup naming the field |
|
||||
|
||||
### Import & sources
|
||||
|
||||
@@ -1364,7 +1533,7 @@ startup (the DB rows shadow the env, `.env` is never rewritten).
|
||||
| `BOR_IMAGE_DIR` | `~/bor-sources/images` | Persistent home for the served image bytes (uploads are replaced, checkouts re-cloned) |
|
||||
| `BOR_CHAT_IMAGE_DIR` | `~/bor-sources/chat-images` | Where question images are stored (saved/shared chats carry the path, never base64) |
|
||||
| `BOR_CHAT_IMAGE_MAX_MB` | `10` | Question-image upload cap (MiB; must be > 0) |
|
||||
| `BOR_ANSWER_IMAGES` | `0` | Let the chat model show a standalone image document as part of its answer (phase 124): offers the agent the `show_image` tool and renders the shown image as an answer figure — **independent of `BOR_IMAGES`** (that toggle indexes image docs; this one shows them) |
|
||||
| `BOR_ANSWER_IMAGES` | `0` | Let the chat model show a standalone image document as part of its answer (phase 124): offers the agent the `show_image` tool and renders the shown image as an answer figure — **independent of `BOR_IMAGES`** (that toggle indexes image docs; this one shows them). The text-display sibling (`BOR_ANSWER_TEXT`, phase 182) lives in [Agent tools](#agent-tools) |
|
||||
|
||||
### Docs push
|
||||
|
||||
|
||||
@@ -244,16 +244,16 @@ def _refuse_embedded_credentials(key: str, value: str) -> None:
|
||||
def get_catalogue(
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> AdminSettingsOut:
|
||||
"""The whole catalogue (78 items, ``GROUPS`` order — the stable
|
||||
order the E2E pins against).
|
||||
"""The whole catalogue (one item per ``Settings`` field, ``GROUPS``
|
||||
order — the stable order the E2E pins against).
|
||||
|
||||
Reads the table once, resolves the effective ``Settings`` through
|
||||
``get_settings()`` (the overlay — DB-over-env, task 02), and
|
||||
emits one item per field. GETs log nothing here: the phase-167
|
||||
``ResponseBudgetMiddleware`` already writes the
|
||||
``api method=… bytes=…`` line, and the whole catalogue must stay
|
||||
under ``BOR_LOG_LARGE_RESPONSE_BYTES``' INFO bar (78 items ≈ 20 KB
|
||||
— verified in task 06 with the audit script).
|
||||
under ``BOR_LOG_LARGE_RESPONSE_BYTES``' INFO bar (the whole catalogue
|
||||
is ≈ 20 KB — verified in task 06 with the audit script).
|
||||
"""
|
||||
rows = _rows(db)
|
||||
effective = get_settings()
|
||||
@@ -371,7 +371,7 @@ def reset_value(
|
||||
@router.delete("", response_model=AdminSettingsCleared)
|
||||
def reset_all(db: Session = Depends(get_db)) -> AdminSettingsCleared: # noqa: B008
|
||||
"""The panel's "Clear all overrides": delete every row (the env
|
||||
values win again for all 78 settings), invalidate (task 03) and
|
||||
values win again for every setting), invalidate (task 03) and
|
||||
report how many rows went."""
|
||||
result = db.execute(delete(AppSetting).returning(AppSetting.key))
|
||||
db.commit()
|
||||
|
||||
+97
-2
@@ -227,6 +227,30 @@ pre-phase. A question image is turn-local: it is NEVER indexed as a
|
||||
document, and prior turns' images are never replayed into the model's
|
||||
history (``history_to_messages`` is unchanged — the text of a prior
|
||||
turn that had an image stands alone, LOCKED A7).
|
||||
|
||||
Verbatim display (phase 182, tasks 03/05; LOCKED A1/A4/A7/A9): a turn
|
||||
that showed file contents through the ``show_text`` tool (task 03, the
|
||||
bytes the SERVER resolved) — or whose ANSWER carries a `` ```bor ``
|
||||
locator fence (task 02's resolver, placement sugar that resolves
|
||||
standalone) — gains one additive ``done`` key, ``answer_text``: the
|
||||
list of :class:`~app.schemas.VerbatimBlockRef` blocks in DISPLAY order,
|
||||
each carrying the exact ``documents.content`` slice plus the SERVER's
|
||||
resolved 1-based, inclusive line extent and its canonical ``locator``.
|
||||
The model never supplies those bytes: the endpoint accumulates the
|
||||
turn's CLEAN VISIBLE answer text (the same post-scaffold-filter pieces
|
||||
that feed ``content_chars`` — never ``thinking``, so a locator can only
|
||||
ever come from the answer) and resolves it through
|
||||
:func:`app.rag.verbatim.resolve_answer_blocks` when the ``done`` frame
|
||||
is built. A deflected turn resolves NOTHING (D9 — a fence hallucinated
|
||||
into a disclaimer must not leak KB text through the turn that just said
|
||||
it had nothing); ``answer_text=False``, ``show_text_max_blocks=0`` and
|
||||
``show_text_max_chars=0`` short-circuit inside the resolver, and a turn
|
||||
with no blocks serializes WITHOUT the key (A9 — every block-free frame
|
||||
keeps its exact pre-phase bytes). The per-turn log line records
|
||||
``verbatim_blocks=N`` after ``scaffold_stripped=N`` — the blocks
|
||||
ACTUALLY SENT (after dedupe and the ``show_text_max_blocks`` cut), 0 on
|
||||
every turn without them including deflected ones (the PLAN §9 additive
|
||||
revision).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -235,7 +259,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -283,6 +307,9 @@ from app.rag.summarizer import (
|
||||
IMAGE_MIMES,
|
||||
image_data_url,
|
||||
) # phase 123: the phase-122 mime map + the shared data-URL helper
|
||||
from app.rag.verbatim import (
|
||||
resolve_answer_blocks, # phase 182: fences + shown blocks -> the done frame
|
||||
)
|
||||
from app.schemas import (
|
||||
ChatDoneEvent,
|
||||
ChatErrorEvent,
|
||||
@@ -292,6 +319,7 @@ from app.schemas import (
|
||||
ChatToolEvent,
|
||||
ChatToolResultEvent,
|
||||
SourceRef,
|
||||
VerbatimBlockRef,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("app.chat")
|
||||
@@ -527,6 +555,12 @@ def plan_turn(
|
||||
# carries the show_image clause exactly when the
|
||||
# BOR_ANSWER_IMAGES toggle is on (off = byte-identical).
|
||||
show_image=settings.answer_images,
|
||||
# Phase 182 (task 04, LOCKED A1/A8): the same rule for the
|
||||
# show_text clause — it rides exactly when the BOR_ANSWER_TEXT
|
||||
# master switch is on (the default), and an off build is
|
||||
# byte-identical to pre-phase (no clause, and agent_tools
|
||||
# offers four tools, not five).
|
||||
show_text=settings.answer_text,
|
||||
),
|
||||
suggested,
|
||||
related_docs,
|
||||
@@ -933,6 +967,16 @@ async def chat(
|
||||
thinking_chars = 0
|
||||
content_chars = 0 # phase 71: the turn's visible (clean) content
|
||||
scaffold_stripped = 0 # phase 71: sum across the turn's requests
|
||||
# Phase 182 (task 05, D3): the turn's clean VISIBLE answer
|
||||
# text — the SAME pieces counted into ``content_chars``
|
||||
# (post-scaffold-filter, never ``thinking``, so a locator
|
||||
# can only ever come from the answer), kept so the ``done``
|
||||
# builder can resolve the `` ```bor `` fences the answer
|
||||
# wrote against the indexed rows. Bounded by the model's own
|
||||
# output-token budget (``max_output_tokens``) — the answer
|
||||
# already lives in memory for the duration of the turn, so
|
||||
# this adds no new cap.
|
||||
visible_parts: list[str] = []
|
||||
# Phase 160 (task 02): the transport half of the cap path — a
|
||||
# request made WITHOUT tools (the deflected turn, A8, and its
|
||||
# recovery) never produces a ``tool`` frame: a structured
|
||||
@@ -1082,6 +1126,9 @@ async def chat(
|
||||
yield sse_event(ChatThinkingEvent(text=piece.text).model_dump())
|
||||
else:
|
||||
content_chars += len(piece.text)
|
||||
# Phase 182 (task 05): the same clean piece kept
|
||||
# as TEXT for the done-time locator pass.
|
||||
visible_parts.append(piece.text)
|
||||
yield sse_event({"type": "delta", "text": piece.text})
|
||||
if dropped_calls:
|
||||
logger.debug(
|
||||
@@ -1269,12 +1316,46 @@ async def chat(
|
||||
except Exception: # noqa: BLE001 — the answer already went out
|
||||
logger.exception("chat: failed to write query_log question=%r", request.message)
|
||||
|
||||
# Phase 182 (task 05, D2/D9): resolve the turn's verbatim
|
||||
# display blocks — the ``show_text`` tool's blocks
|
||||
# (``holder.shown_texts``, read AFTER the stream exactly
|
||||
# like ``shown_images``) fused with the blocks the answer's
|
||||
# own `` ```bor `` locator fences name, over the CLEAN
|
||||
# visible text accumulated above (never ``thinking``).
|
||||
# A DEFLECTED turn resolves NOTHING (D9 — a fence
|
||||
# hallucinated into a disclaimer must not leak KB text
|
||||
# through the turn that just said it had nothing), which is
|
||||
# also why this is the only branch with a resolved holder.
|
||||
# A DB failure here must not break a stream whose answer
|
||||
# already went out (the query_log precedent above): the
|
||||
# turn degrades to NO blocks, the frame simply omits the
|
||||
# key, and the client renders each fence in its honest
|
||||
# unresolved state — never the model's own bytes.
|
||||
blocks: list[VerbatimBlockRef] = []
|
||||
if not plan.deflected:
|
||||
try:
|
||||
with SessionLocal() as verbatim_db:
|
||||
resolved = resolve_answer_blocks(
|
||||
verbatim_db,
|
||||
"".join(visible_parts),
|
||||
holder.shown_texts,
|
||||
settings,
|
||||
)
|
||||
blocks = [VerbatimBlockRef(**asdict(b)) for b in resolved]
|
||||
except Exception: # noqa: BLE001 — the answer went out
|
||||
logger.exception(
|
||||
"chat: verbatim block resolution failed question=%r",
|
||||
request.message,
|
||||
)
|
||||
blocks = []
|
||||
|
||||
logger.info(
|
||||
"question=%r embed_ms=%d top_score=%.3f fts_hits=%d summary_hits=%d "
|
||||
"suggested=%d tuning=%d "
|
||||
"kb_chars=%d history_msgs=%d threshold=%.2f deflected=%s sources=%r "
|
||||
"thinking=%s thinking_chars=%d tool_calls=%d rounds=%d "
|
||||
"cap_hit=%d total_ms=%d retries=%d scaffold_stripped=%d",
|
||||
"cap_hit=%d total_ms=%d retries=%d scaffold_stripped=%d "
|
||||
"verbatim_blocks=%d",
|
||||
request.message,
|
||||
embed_ms,
|
||||
plan.top_score,
|
||||
@@ -1299,6 +1380,12 @@ async def chat(
|
||||
total_ms,
|
||||
retries_used,
|
||||
scaffold_stripped,
|
||||
# phase 182 (task 05): the blocks ACTUALLY SENT (after
|
||||
# the canonical-locator dedupe and the
|
||||
# ``show_text_max_blocks`` cut) — 0 on every turn
|
||||
# without them, deflected ones included (PLAN §9
|
||||
# additive revision).
|
||||
len(blocks),
|
||||
)
|
||||
settled = True # terminal: the done frame settles the turn
|
||||
# Phase 112 (A8 revised, TODO L2): a deflected turn cites
|
||||
@@ -1360,6 +1447,14 @@ async def chat(
|
||||
# (``False`` → ABSENT on serialization) is
|
||||
# correct by construction.
|
||||
budget_exhausted=holder.cap_reached,
|
||||
# Phase 182 (task 05): the answer's verbatim display
|
||||
# blocks, resolved above in DISPLAY order (answer
|
||||
# order first, then the tool blocks the answer never
|
||||
# located). ``blocks or None`` — an empty list is
|
||||
# ``None`` here and the serializer drops the key
|
||||
# either way, so a block-free frame keeps its exact
|
||||
# pre-phase bytes (LOCKED A9).
|
||||
answer_text=blocks or None,
|
||||
).model_dump()
|
||||
)
|
||||
finally:
|
||||
|
||||
@@ -294,6 +294,41 @@ class Settings(BaseSettings):
|
||||
#: 2026-09-10, ``TODO.md`` L5): A7's never-truncated contract is for
|
||||
#: the retrieval ``<documents>`` path, which stays whole.
|
||||
read_max_chars: int = 128_000
|
||||
#: Feature toggle for the verbatim text-display capability (phase 182,
|
||||
#: ``BOR_ANSWER_TEXT``; ``0``/``false`` = off). 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_text`` display tool,
|
||||
#: the HIGH prompt carries its teaching clause, a `` ```bor `` locator
|
||||
#: fence in the answer is resolved server-side, and the resolved blocks
|
||||
#: ride the done frame's ``answer_text`` key (and the saved record's).
|
||||
#: While off, behavior is byte-identical everywhere — no ``show_text``
|
||||
#: tool offered, no prompt clause, no locator resolution, no
|
||||
#: ``answer_text`` key in any frame or record. THE DEFAULT IS **TRUE**
|
||||
#: (owner decision 2026-10-14, LOCKED A8): unlike phase 124's
|
||||
#: ``answer_images`` (default off, a cosmetic extra), an answer whose
|
||||
#: code block silently differs from the file is a correctness bug, so
|
||||
#: the fix ships ON and this switch is an escape hatch, not an opt-in.
|
||||
answer_text: bool = True
|
||||
#: Cap in characters on ONE displayed verbatim slice (phase 182,
|
||||
#: ``BOR_SHOW_TEXT_MAX_CHARS``): the slice the server resolves for a
|
||||
#: ``show_text`` call or a `` ```bor `` locator fence is cut at the cap
|
||||
#: AT A LINE BOUNDARY, the shared ``[…truncated…]`` marker
|
||||
#: (``app/rag/retriever.py``) is appended and the caption's ``end_line``
|
||||
#: shrinks to the last fully-shown line — the caption never lies (D4).
|
||||
#: Worst-case ``done``-frame growth is
|
||||
#: ``show_text_max_chars * show_text_max_blocks`` = 60 KB against the
|
||||
#: 256 KB response budget (phase 167's byte line makes it visible). A
|
||||
#: char cap like ``read_max_chars`` (no tokenizer in the repo); ``0``
|
||||
#: is inert (nothing displays), a negative value is a typo and fails
|
||||
#: startup (validator).
|
||||
show_text_max_chars: int = 12_000
|
||||
#: Cap on the verbatim blocks displayed per answer (phase 182,
|
||||
#: ``BOR_SHOW_TEXT_MAX_BLOCKS``): the ``show_text`` calls and
|
||||
#: `` ```bor `` locator fences of one turn, in ANSWER order, past this
|
||||
#: count are not displayed (D8). ``0`` is inert (no block displays —
|
||||
#: the feature renders as plain prose), a negative value is a typo and
|
||||
#: fails startup (the ``agent_max_rounds`` validator pattern).
|
||||
show_text_max_blocks: int = 5
|
||||
|
||||
# --- Chat concurrency (SEC-14-04, task 03) ---
|
||||
#: Maximum concurrent chat turns allowed (SEC-14-04).
|
||||
@@ -705,6 +740,28 @@ class Settings(BaseSettings):
|
||||
raise ValueError("read_max_chars must be >= 0 (chars)")
|
||||
return v
|
||||
|
||||
@field_validator("show_text_max_chars")
|
||||
@classmethod
|
||||
def _show_text_max_chars_non_negative(cls, v: int) -> int:
|
||||
"""A negative per-block cap is a typo — it would slice from the END
|
||||
of the content (negative indexing) instead of failing. Fail loud at
|
||||
startup, naming the field (the ``read_max_chars`` pattern, phase
|
||||
182). ``0`` is legal and inert (no block displays)."""
|
||||
if v < 0:
|
||||
raise ValueError("show_text_max_chars must be >= 0 (chars)")
|
||||
return v
|
||||
|
||||
@field_validator("show_text_max_blocks")
|
||||
@classmethod
|
||||
def _show_text_max_blocks_non_negative(cls, v: int) -> int:
|
||||
"""A negative per-answer block cap is a typo — it would count down
|
||||
from the END of the block list instead of failing. Fail loud at
|
||||
startup, naming the field (the ``agent_max_rounds`` pattern, phase
|
||||
182). ``0`` is legal and inert (no block displays)."""
|
||||
if v < 0:
|
||||
raise ValueError("show_text_max_blocks must be >= 0 (blocks)")
|
||||
return v
|
||||
|
||||
@field_validator("pdf_pictorial_min_chars")
|
||||
@classmethod
|
||||
def _pdf_pictorial_min_chars_non_negative(cls, v: int) -> int:
|
||||
|
||||
@@ -267,6 +267,13 @@ CATALOG: dict[str, SettingMeta] = {
|
||||
"read_max_chars": SettingMeta(
|
||||
"read_max_chars", "Agent tools", "Read max chars"
|
||||
),
|
||||
"answer_text": SettingMeta("answer_text", "Agent tools", "Answer text"),
|
||||
"show_text_max_chars": SettingMeta(
|
||||
"show_text_max_chars", "Agent tools", "Show text max chars"
|
||||
),
|
||||
"show_text_max_blocks": SettingMeta(
|
||||
"show_text_max_blocks", "Agent tools", "Show text max blocks"
|
||||
),
|
||||
# --- Import & sources ---
|
||||
"import_extensions": SettingMeta(
|
||||
"import_extensions", "Import & sources", "Import extensions (CSV)"
|
||||
|
||||
+292
-13
@@ -211,6 +211,29 @@ task 04):
|
||||
— its ``"No matches for …"`` line is a (counted) result. A rejected
|
||||
call still consumes a *round* in the loop, so a pathological stream
|
||||
that keeps emitting rejected calls is bounded by the cap (point 4).
|
||||
|
||||
Display tools (phase 124 ``show_image``, phase 182 ``show_text`` —
|
||||
both composed by :func:`agent_tools`, each behind its own flag): they
|
||||
display content FOR THE USER. ``show_image`` never adds context; the
|
||||
5th tool ``show_text`` (phase 182, task 03) returns the resolved
|
||||
slice back to the model ONLY as the truth of what was displayed
|
||||
(LOCKED A3 — with a do-NOT-retype instruction: the model describes
|
||||
the block, the user copies the server's bytes), and its block rides
|
||||
``holder.shown_texts``, never ``read_docs``. The LOCKED core contract
|
||||
A1–A3/A6: **the model names a location, the server shows the bytes**
|
||||
— the handler resolves the slice through
|
||||
:func:`app.rag.verbatim.block_for` (same ``(source, path)`` identity,
|
||||
same 1-based ``splitlines()`` indexing as ``read``/``grep``, same
|
||||
char-budget-cut-at-a-line-boundary), the DISPLAYED EXTENT is the
|
||||
server's (an overshooting ``limit`` clamps into the result's ``lines:``
|
||||
header, an ``offset`` past the end is the :data:`OFFSET_PAST_END`
|
||||
teaching naming the true line count, the char cap shrinks the extent
|
||||
— the same strings ``read`` uses, one copy each), images belong to
|
||||
``show_image`` (a show of an ``is_image`` row is the
|
||||
:data:`SHOW_TEXT_NOT_A_TEXT_DOC` refusal, the exact mirror of
|
||||
:data:`NOT_AN_IMAGE_DOC` — never a silent conversion), and the inline
|
||||
``` ```bor ``` locator-fence form of the same contract (phase 182 D2)
|
||||
is resolved at the API layer (task 05), NOT here.
|
||||
4. Every call the model emits is appended back to the message history as
|
||||
the assistant tool-call message + the tool result (refusals included),
|
||||
consumes one round, and the model is called again. Every tool result
|
||||
@@ -317,6 +340,7 @@ from app.rag.llm import (
|
||||
from app.rag.retriever import TRUNCATION_MARKER, _fold
|
||||
from app.rag.scaffolding import ScaffoldingFilter
|
||||
from app.rag.source_removal import resolve_source_name
|
||||
from app.rag.verbatim import VerbatimBlock, block_for
|
||||
|
||||
logger = logging.getLogger("app.agent")
|
||||
|
||||
@@ -535,20 +559,104 @@ SHOW_IMAGE_TOOL: dict[str, Any] = {
|
||||
}
|
||||
|
||||
|
||||
def agent_tools(settings: Settings) -> list[dict[str, Any]]:
|
||||
"""The grounded turn's tool offer list (phase 124, task 02).
|
||||
#: The 5th agent tool (phase 182, task 03; LOCKED A1–A3/A6) — offered
|
||||
#: ONLY when ``settings.answer_text`` (``BOR_ANSWER_TEXT``, default ON):
|
||||
#: :func:`agent_tools` composes the offer list (the three base tools stay
|
||||
#: :data:`AGENT_TOOLS`, byte-identical). The description IS the behavior
|
||||
#: contract (the phase-124 discipline): the model NAMES a location, the
|
||||
#: SERVER shows the bytes verbatim — the user copies what is displayed,
|
||||
#: so the model must NOT retype it. Same OpenAI function shape as the
|
||||
#: other tools: one required string ``path`` (the combined
|
||||
#: ``source/path`` identity — the bare-path-will-not-resolve teaching
|
||||
#: mirrored from ``read``) plus the OPTIONAL ``offset``/``limit`` line
|
||||
#: range (the ``read`` wording reused, so every line-number surface in
|
||||
#: the app teaches one grammar).
|
||||
SHOW_TEXT_TOOL: dict[str, Any] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "show_text",
|
||||
"description": (
|
||||
"Display part of a document to the user EXACTLY as stored "
|
||||
"— the user sees the server's bytes and can copy them. "
|
||||
"Call it when the user wants to see, copy or run a file (a "
|
||||
"quadlet, a config, a script): they copy from what is "
|
||||
"displayed, so do NOT retype the content yourself after "
|
||||
"calling it — describe it instead. `path` is the combined "
|
||||
"`source/path` string exactly as shown in `ls`. `offset` "
|
||||
"(1-based first line) and `limit` (line count) are "
|
||||
"optional — omit them to display the whole document; use "
|
||||
"them when `grep`/`read` told you which lines matter. The "
|
||||
"displayed line numbers come from the server, not from "
|
||||
"your arguments: an overshooting `limit` is clamped and an "
|
||||
"`offset` past the end is refused with the true line "
|
||||
"count. Only text documents — for an image use "
|
||||
"`show_image`. Call one tool at a time — wait for this "
|
||||
"result before your next call."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The document to display, as the combined "
|
||||
"`source/path` string exactly as shown in the "
|
||||
"`ls` output (e.g. "
|
||||
"'homelab/active/container_caddy/caddy.md'). "
|
||||
"A bare document path (without the source "
|
||||
"name) will not resolve. Only text documents "
|
||||
"— for an image use `show_image`."
|
||||
),
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"The first line to display, 1-based. Omit it "
|
||||
"to display from the beginning of the "
|
||||
"document. Use it to display a specific part "
|
||||
"of a large document — grep reports the line "
|
||||
"numbers of what you found."
|
||||
),
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"The maximum number of lines to display, "
|
||||
"starting at offset. Omit it to display as "
|
||||
"many lines as fit under the display cap. Use "
|
||||
"it to show the part the user asked about — "
|
||||
"you are showing a part, not the document."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
The three base tools (:data:`AGENT_TOOLS`, byte-identical) plus
|
||||
|
||||
def agent_tools(settings: Settings) -> list[dict[str, Any]]:
|
||||
"""The grounded turn's tool offer list (phase 124, task 02; the 5th
|
||||
tool, phase 182, task 03).
|
||||
|
||||
The three base tools (:data:`AGENT_TOOLS`, byte-identical) plus the
|
||||
TWO display tools, each gated by its own flag:
|
||||
: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).
|
||||
(``BOR_ANSWER_IMAGES``) and :data:`SHOW_TEXT_TOOL` when
|
||||
``settings.answer_text`` (``BOR_ANSWER_TEXT``) — the offer order is
|
||||
``ls``, ``read``, ``grep``, ``show_image``, ``show_text``. With both
|
||||
flags off: exactly the three base tools (the pre-phase-124 offer,
|
||||
byte-identical: no display tool in the wire ``tools`` array); with
|
||||
only ``answer_text`` off it is the pre-phase-182 offer
|
||||
byte-identical. 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)
|
||||
if settings.answer_text:
|
||||
tools.append(SHOW_TEXT_TOOL)
|
||||
return tools
|
||||
|
||||
#: Tool refusal texts (phase 37): rejected calls count in nothing
|
||||
@@ -587,6 +695,56 @@ ALREADY_SHOWING = (
|
||||
"Answer from what you have."
|
||||
)
|
||||
|
||||
#: The ``show_text`` refusals (phase 182, task 03): the same contract as
|
||||
#: every other refusal — counts in nothing, consumes a round, changes no
|
||||
#: context. ``MISSING_SHOW_TEXT_ARGS`` mirrors ``MISSING_SHOW_ARGS``.
|
||||
#: ``SHOW_TEXT_NOT_A_TEXT_DOC``: one ``{arg}`` field, the argument echoed
|
||||
#: as passed — the row is a standalone IMAGE; image display belongs to
|
||||
#: ``show_image`` (LOCKED A6 — the exact mirror of
|
||||
#: :data:`NOT_AN_IMAGE_DOC`, never a silent conversion). The range
|
||||
#: problems REUSE :data:`BAD_RANGE_ARGS` and :data:`OFFSET_PAST_END`
|
||||
#: verbatim — the same teaching, the same echoed values, one copy of
|
||||
#: each string in the codebase.
|
||||
MISSING_SHOW_TEXT_ARGS = "show_text requires a string argument 'path'."
|
||||
SHOW_TEXT_NOT_A_TEXT_DOC = (
|
||||
"'{arg}' is a standalone image document — use show_image to display "
|
||||
"images."
|
||||
)
|
||||
#: One ``{max}`` field (``settings.show_text_max_blocks``): the
|
||||
#: per-answer block budget, enforced SERVER-side BEFORE the resolve work
|
||||
#: (the model is taught the budget in the description; the handler is
|
||||
#: the actual gate). ``0`` therefore disables the tool's effect while
|
||||
#: keeping it offered (the house kill-switch pattern).
|
||||
SHOW_TEXT_BLOCK_CAP = (
|
||||
"{max} text blocks are already displayed this answer — describe them "
|
||||
"instead of showing more."
|
||||
)
|
||||
#: One ``{locator}`` field — the canonical ``source/path[#first-last]``
|
||||
#: the model just asked for: the dedupe mirror of
|
||||
#: :data:`ALREADY_SHOWING`, but per BLOCK under the block budget (the
|
||||
#: budget allows SEVERAL different blocks; the same block twice teaches).
|
||||
SHOW_TEXT_ALREADY_SHOWN = (
|
||||
"{locator} is already on screen this answer — say so instead of "
|
||||
"showing it again."
|
||||
)
|
||||
|
||||
#: The ``show_text`` truncation notice (phase 182, task 03) — appended as
|
||||
#: the result's LAST line when the per-block char budget
|
||||
#: (``settings.show_text_max_chars``) cut the displayed slice (the block
|
||||
#: text already ends with the shared :data:`TRUNCATION_MARKER` on its own
|
||||
#: line, and the ``lines:`` extent already shrank to the last line
|
||||
#: actually displayed). In the :data:`READ_TRUNCATION_NOTICE` spirit it
|
||||
#: names the truth the model must not paper over — the rest is NOT
|
||||
#: displayed — and the way the user gets it (open the document; NO
|
||||
#: ``offset``/``limit`` re-show loop is implied, only the displayed
|
||||
#: slice is what the user copies). Fields: ``{source}``/``{path}``, the
|
||||
#: RESOLVED identity, plain (no ``!r``).
|
||||
SHOWN_TEXT_TRUNCATION_NOTICE = (
|
||||
"TRUNCATED — the rest of {source}/{path} is NOT displayed. The user "
|
||||
"can open the document to read it in full; describe the displayed "
|
||||
"lines instead of showing it again."
|
||||
)
|
||||
|
||||
#: The read-truncation notice (phase 95, task 01; REWRITTEN phase 131,
|
||||
#: task 01) — appended, after the shared :data:`TRUNCATION_MARKER`, to
|
||||
#: a ``read`` result whose document is LONGER than
|
||||
@@ -1642,6 +1800,17 @@ class AgentHolder:
|
||||
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).
|
||||
``shown_texts`` (phase 182, task 03): the verbatim blocks
|
||||
``show_text`` displayed, in CALL order, capped by
|
||||
``settings.show_text_max_blocks`` (a call over the budget is refused
|
||||
with :data:`SHOW_TEXT_BLOCK_CAP` and a duplicate of a shown locator
|
||||
with :data:`SHOW_TEXT_ALREADY_SHOWN` — both append nothing). Each
|
||||
entry is the RESOLVED
|
||||
:class:`app.rag.verbatim.VerbatimBlock` — the server's bytes plus
|
||||
the server's extent (the canonical ``locator`` is the dedupe key and
|
||||
the client's matching key, D7). Read by ``app.api.chat`` after the
|
||||
stream for the done frame's ``answer_text`` array (task 05) — the
|
||||
``shown_images``/``read_docs`` read-after-stream precedent.
|
||||
``read_coverage`` (phase 141, task 01): one ``(source, path,
|
||||
first_line, last_line)`` record per covered span — INCLUSIVE line
|
||||
numbers over the document's ``content.splitlines()``, in read
|
||||
@@ -1676,6 +1845,7 @@ class AgentHolder:
|
||||
read_truncations: list[tuple[str, int, int]] = field(default_factory=list)
|
||||
shown_images: list[Document] = field(default_factory=list)
|
||||
read_coverage: list[tuple[str, str, int, int]] = field(default_factory=list)
|
||||
shown_texts: list[VerbatimBlock] = field(default_factory=list)
|
||||
|
||||
|
||||
def _execute_tool(
|
||||
@@ -2065,6 +2235,113 @@ def _execute_tool(
|
||||
f"{IMAGE_DOC_MARKER}\n"
|
||||
f"{doc.content}"
|
||||
)
|
||||
if call.name == "show_text":
|
||||
# Phase 182 (task 03; LOCKED A1–A3/A6): the 5th agent tool — the
|
||||
# model NAMES a location, the SERVER shows the bytes verbatim.
|
||||
# Offered only when ``settings.answer_text`` (``agent_tools``);
|
||||
# the handler enforces the contract server-side. Every refusal
|
||||
# below appends nothing, increments 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_TEXT_ARGS
|
||||
if not settings.answer_text:
|
||||
# Defensive — the tool is NOT OFFERED while the flag is off,
|
||||
# so from the model's side this is a tool that does not
|
||||
# exist: the same refusal an un-offered name gets (the
|
||||
# tools-off posture — "Unknown tool."), counts in nothing.
|
||||
return UNKNOWN_TOOL
|
||||
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, nothing
|
||||
# displayed).
|
||||
return _no_document_refusal(db, arg)
|
||||
if doc.is_image:
|
||||
# LOCKED A6: an image row is refused, NEVER silently
|
||||
# converted — image display is ``show_image``'s job (the
|
||||
# mirror of the show_image branch's NOT_AN_IMAGE_DOC).
|
||||
return SHOW_TEXT_NOT_A_TEXT_DOC.format(arg=arg)
|
||||
# The OPTIONAL line range — the SAME validator, the SAME echoed
|
||||
# BAD_RANGE_ARGS teaching as ``read`` (one copy of each string);
|
||||
# ``offset`` without ``limit`` displays to the end, ``limit``
|
||||
# without ``offset`` counts from line 1 — ``slice_lines`` applies
|
||||
# both one-sided semantics with ``read``'s exact indexing.
|
||||
ok, range_error, offset, limit = _read_range_args(
|
||||
call.arguments.get("offset"), call.arguments.get("limit")
|
||||
)
|
||||
if not ok:
|
||||
return range_error
|
||||
# The per-answer block budget — checked BEFORE the resolve work
|
||||
# (and, with a 0 char budget, before ``cap_slice`` could raise):
|
||||
# either zeroed kill switch refuses like a spent budget, so
|
||||
# ``0`` disables the tool's EFFECT while it stays offered (D8).
|
||||
block_budget = (
|
||||
settings.show_text_max_blocks
|
||||
if settings.show_text_max_chars > 0
|
||||
else 0
|
||||
)
|
||||
if len(holder.shown_texts) >= block_budget:
|
||||
return SHOW_TEXT_BLOCK_CAP.format(max=block_budget)
|
||||
block = block_for(
|
||||
db,
|
||||
arg,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
cap=settings.show_text_max_chars,
|
||||
doc=doc,
|
||||
)
|
||||
if block is None:
|
||||
# An empty document or an ``offset`` past the last line —
|
||||
# the SAME OFFSET_PAST_END teaching as ``read``, the true
|
||||
# line count named (the same ``len(content.splitlines())``
|
||||
# expression ``read`` uses). Counts in nothing; a corrected
|
||||
# retry still proceeds.
|
||||
return OFFSET_PAST_END.format(
|
||||
source=doc.source,
|
||||
path=doc.path,
|
||||
total_lines=len(doc.content.splitlines()),
|
||||
offset=offset if offset is not None else 1,
|
||||
)
|
||||
if any(shown.locator == block.locator for shown in holder.shown_texts):
|
||||
# The canonical-locator dedupe (D7): the identical block is
|
||||
# already on screen — say so instead of painting it twice.
|
||||
return SHOW_TEXT_ALREADY_SHOWN.format(locator=block.locator)
|
||||
# Success: the block rides the holder for the done frame
|
||||
# (task 05) and the call counts (executed-only, the house rule).
|
||||
holder.shown_texts.append(block)
|
||||
holder.tool_calls += 1
|
||||
# The result contract (D5 — the ``read``/``show_image`` header
|
||||
# contract the E2E mock's ``_READ_RESULT_PREFIX`` parses): a NEW
|
||||
# first line (absent from ``read`` — the model must know the
|
||||
# show registered AND must not retype), then the exact document
|
||||
# headers ``read`` established (date line, phase-145 origin
|
||||
# lines), then the SERVER's resolved extent — clamped ``limit``
|
||||
# and cap-shrunk end included, the caption never lies — and the
|
||||
# verbatim slice itself (A21: LLM calls are free, and returning
|
||||
# the text means the model's commentary can never disagree with
|
||||
# the panel).
|
||||
origin = origin_for_source(db, doc.source)
|
||||
result = (
|
||||
"Text shown to the user — these lines are displayed "
|
||||
"verbatim; describe them, do NOT retype them.\n"
|
||||
f"Document {doc.source}/{doc.path}:\n"
|
||||
f"date: {doc.created_at:%Y-%m-%d}\n"
|
||||
f"{_origin_lines(origin)}"
|
||||
f"lines: {block.start_line}-{block.end_line} of "
|
||||
f"{block.total_lines}\n"
|
||||
f"{block.text}"
|
||||
)
|
||||
if block.truncated:
|
||||
# The char cap cut the slice: the block already ends with
|
||||
# the shared marker on its own line — append the pinned
|
||||
# truth (the rest is NOT displayed; the user opens the
|
||||
# document; no re-show loop implied).
|
||||
result += "\n" + SHOWN_TEXT_TRUNCATION_NOTICE.format(
|
||||
source=doc.source, path=doc.path
|
||||
)
|
||||
return result
|
||||
if call.name == "grep":
|
||||
raw_pattern = call.arguments.get("pattern")
|
||||
pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
|
||||
@@ -2229,11 +2506,13 @@ 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). 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.
|
||||
# path). Phase 124 (task 02) / 182 (task 03): the offer list is
|
||||
# :func:`agent_tools` — the three base tools (byte-identical) plus
|
||||
# ``show_image`` only when ``settings.answer_images``
|
||||
# (``BOR_ANSWER_IMAGES``) and ``show_text`` only when
|
||||
# ``settings.answer_text`` (``BOR_ANSWER_TEXT``, default ON); both
|
||||
# off: the offer is element-for-element the pre-phase-124 list,
|
||||
# ``answer_text`` alone off: the pre-phase-182 list.
|
||||
max_rounds = settings.agent_max_rounds
|
||||
tools: list[dict[str, Any]] | None = (
|
||||
agent_tools(settings) if max_rounds > 0 else None
|
||||
|
||||
+105
-7
@@ -66,6 +66,20 @@ backstop). The ``DEFLECT_MODE`` marker and everything else in the
|
||||
prompt stay put — the E2E mock LLM keys on the marker's *presence*,
|
||||
not the wording, so that contract is unchanged.
|
||||
|
||||
Display-tool clauses (phase 124 ``show_image``; phase 182 ``show_text``):
|
||||
the **HIGH** prompt's ``<tools>`` section can carry two extra clauses
|
||||
before its closing tag, each appended only when its own setting is on —
|
||||
:data:`SHOW_IMAGE_CLAUSE` when ``BOR_ANSWER_IMAGES`` (default OFF) and
|
||||
:data:`SHOW_TEXT_CLAUSE` when ``BOR_ANSWER_TEXT`` (default ON). The text
|
||||
clause teaches the two things the phase-182 incident needed said: the
|
||||
user copies from what is DISPLAYED (so never retype a file), and the
|
||||
inline `` ```bor <source/path> [range] ``` `` locator fence whose EMPTY
|
||||
body the app replaces with the server-resolved bytes. Both clauses
|
||||
compose through one mechanism (section minus its final tag + the present
|
||||
clauses + the tag), so a build with neither clause is byte-identical to
|
||||
pre-phase and a ``show_image``-only build reproduces the phase-124 bytes
|
||||
exactly; the LOW prompt carries neither.
|
||||
|
||||
Disclosed-answer contract (phase 112, task 03; owner decision
|
||||
2026-09-14, roadmap confirmation — option (iii) of the three the
|
||||
2026-09-15 interactive deflection test raised): that test found the
|
||||
@@ -388,6 +402,61 @@ SHOW_IMAGE_CLAUSE: str = (
|
||||
"in text either way."
|
||||
)
|
||||
|
||||
#: The ``show_text`` contract clause (phase 182, task 04, LOCKED A1–A3/A6) —
|
||||
#: appended INSIDE the ``<tools>`` section of the HIGH prompt, after the
|
||||
#: existing three-tool copy (and after :data:`SHOW_IMAGE_CLAUSE` when that
|
||||
#: rides too), ONLY when :func:`build_high_prompt` is called with
|
||||
#: ``show_text=True`` (the ``BOR_ANSWER_TEXT`` master switch, via
|
||||
#: ``app.api.chat``'s grounded plan — the only call site that passes it).
|
||||
#: It exists because the incident this phase fixes is a PROMPT gap, not a
|
||||
#: tool gap: nothing ever told the model that the user copies from what is
|
||||
#: DISPLAYED, so it retyped files out of context and every retype differed
|
||||
#: from the stored bytes by a character. What each sentence pins, in order:
|
||||
#: (1) the display contract — the tool shows the document EXACTLY as stored
|
||||
#: and the copy source is the display, not the prose; (2) when to call it —
|
||||
#: the user wants to SEE, COPY or RUN a file (the owner's quadlet / config /
|
||||
#: script shapes) rather than read content out of the answer; (3) the do-
|
||||
#: NOT-retype rule with its reason (anything the model types may differ by
|
||||
#: one character and the user cannot tell — the phase's reason for
|
||||
#: existing); (4) the placement syntax — the inline `` ```bor <source/path>
|
||||
#: [range] ``` `` locator fence with an EMPTY body (D1/D7: a fence is a
|
||||
#: resolution request, the app supplies the text, and D3 keeps the model's
|
||||
#: body out of the DOM); (5) the honesty-about-numbers rule — line numbers
|
||||
#: come from ``grep``/``read``, and when unsure the range is OMITTED
|
||||
#: because the displayed extent is the server's (A2); (6) the failure rule
|
||||
#: — refuse out loud, never reconstruct from memory (the persona's radical
|
||||
#: honesty, D3's posture on the model side); (7) the image pointer
|
||||
#: (LOCKED A6: images belong to ``show_image``, never silently converted).
|
||||
#: ``TOOLS_SECTION`` itself stays BYTE-IDENTICAL (the sha pins in
|
||||
#: :mod:`tests.unit.test_prompt_lock` hold); a ``show_text=False`` build
|
||||
#: (the default — every pre-phase call site) is byte-identical to the
|
||||
#: pre-task output, and a ``show_image=True, show_text=False`` build
|
||||
#: reproduces the phase-124 bytes EXACTLY. The ``<tools>`` marker the E2E
|
||||
#: mock keys on is untouched either way — both clauses ride on its
|
||||
#: presence, not its wording. The LOW/deflection prompt never carries the
|
||||
#: clause: a deflected turn has no tools, and phase 182's D9 forbids
|
||||
#: resolving a locator on a turn that just disclaimed the knowledge base.
|
||||
SHOW_TEXT_CLAUSE: str = (
|
||||
"`show_text` displays part of a document to the user EXACTLY as stored "
|
||||
"— the user copies from what is displayed, not from your prose. Call "
|
||||
"it whenever the user wants to SEE, COPY or RUN a file — a quadlet, a "
|
||||
"config, a script, a unit file — instead of writing the content into "
|
||||
"your answer yourself. After calling it, do NOT repeat the displayed "
|
||||
"lines in your answer — describe them, explain them, point at them. "
|
||||
"Anything you type yourself may differ from the file by a character, "
|
||||
"and the user cannot tell. To put a displayed block exactly where you "
|
||||
"want it in your answer, write a locator line on its own: three "
|
||||
"backticks followed by `bor`, a space, the combined `source/path`, and "
|
||||
"optionally the line range (` 12-40`). Leave its body EMPTY — the app "
|
||||
"replaces it with the exact stored text. Never type the file's content "
|
||||
"inside a `bor` block. Never guess line numbers — use the numbers "
|
||||
"`grep` and `read` reported. When you are not sure which lines, OMIT "
|
||||
"the range and display the whole document: the numbers you display are "
|
||||
"the server's, never yours. If `show_text` refuses, say what you could "
|
||||
"not display — do not reconstruct it from memory. For an image use "
|
||||
"`show_image` instead."
|
||||
)
|
||||
|
||||
|
||||
def history_to_messages(
|
||||
history: Sequence[HistoryTurn],
|
||||
@@ -762,6 +831,7 @@ def build_high_prompt(
|
||||
kb_overview: str | None = None,
|
||||
folder_lines: Sequence[str] = (),
|
||||
show_image: bool = False,
|
||||
show_text: bool = False,
|
||||
) -> str:
|
||||
"""Grounded turn: locked persona (+ steering, + KB overview) + the
|
||||
top-ranked documents' SUMMARY blocks + the ``<tools>`` instructions
|
||||
@@ -771,7 +841,8 @@ def build_high_prompt(
|
||||
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; phase 124, task 03, LOCKED
|
||||
A5: the optional :data:`SHOW_IMAGE_CLAUSE`, below).
|
||||
A5: the optional :data:`SHOW_IMAGE_CLAUSE`, below; phase 182, task 04,
|
||||
LOCKED A1–A3: the optional :data:`SHOW_TEXT_CLAUSE`, below).
|
||||
|
||||
Section order: ``<relevance>`` → ``<knowledge_base>`` → ``<tuning>``
|
||||
→ ``<documents>`` → ``<tools>``; empty steering/overview omit their
|
||||
@@ -796,6 +867,22 @@ def build_high_prompt(
|
||||
never carries the clause — a deflected turn has no tools and never
|
||||
shows an image.
|
||||
|
||||
*show_text* (phase 182, task 04, LOCKED A1–A3): when true (the caller
|
||||
passes the ``BOR_ANSWER_TEXT`` master switch — the same single grounded
|
||||
call site), the :data:`SHOW_TEXT_CLAUSE` rides INSIDE the same
|
||||
``<tools>`` section, AFTER :data:`SHOW_IMAGE_CLAUSE` when that is
|
||||
present too and always before the closing tag. The two flags compose
|
||||
independently through one mechanism (the built section is
|
||||
``TOOLS_SECTION`` minus its final ``</tools>`` + the present clauses,
|
||||
blank-line separated, + the closing tag): ``show_image`` only
|
||||
reproduces the phase-124 bytes exactly, ``show_text`` only is the
|
||||
three-tool copy plus the text clause, both is the image clause then
|
||||
the text clause, neither is byte-identical to pre-phase. The default
|
||||
``False`` keeps every pre-existing call byte-identical. The deflection
|
||||
prompt NEVER carries it — a deflected turn has no tools, and phase
|
||||
182's D9 forbids resolving a ``bor`` locator on a turn whose answer
|
||||
just disclaimed the knowledge base.
|
||||
|
||||
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
|
||||
@@ -861,13 +948,24 @@ def build_high_prompt(
|
||||
for part in (build_kb_section(kb_overview or ""), build_steering_section(notes or [])):
|
||||
if part:
|
||||
prompt += "\n" + part
|
||||
# Phase 124 (task 03, LOCKED A5) / phase 182 (task 04, LOCKED A1): the
|
||||
# display-tool clauses ride inside <tools> — TOOLS_SECTION's text minus
|
||||
# its final </tools>, the clauses offered for this build (each on its
|
||||
# own line, blank-line separated, after the existing three-tool copy),
|
||||
# then the closing tag. TOOLS_SECTION itself is unedited, the
|
||||
# no-clause path keeps the exact pre-task concatenation, and a
|
||||
# show_image-only build reproduces the phase-124 bytes exactly.
|
||||
clauses: list[str] = []
|
||||
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>"
|
||||
clauses.append(SHOW_IMAGE_CLAUSE)
|
||||
if show_text:
|
||||
clauses.append(SHOW_TEXT_CLAUSE)
|
||||
if clauses:
|
||||
tools_section = (
|
||||
TOOLS_SECTION[: -len("</tools>")]
|
||||
+ "\n\n".join(clauses)
|
||||
+ "\n</tools>"
|
||||
)
|
||||
else:
|
||||
tools_section = TOOLS_SECTION
|
||||
return prompt + "\n<documents>\n" + body + "\n</documents>\n" + tools_section
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Phase 182 (task 02): the verbatim locator grammar, the slicer, the resolver.
|
||||
|
||||
The contract in one line: **the model names a location, this module supplies
|
||||
the bytes** (PLAN §6's tool-surface extension, phase 182 D1–D7). An answer
|
||||
never carries a model-retyped copy of a source file; it carries a ``bor``
|
||||
locator fence (````` ```bor homelab/runbooks/nginx.md#12-40 `````), and the
|
||||
bytes shown to the human are read out of the indexed ``documents.content``
|
||||
row by :func:`block_for` / :func:`resolve_answer_blocks`. Because the
|
||||
displayed text is never generated, it cannot be paraphrased, "corrected",
|
||||
or hallucinated — the phase's whole point — and the same resolver serves
|
||||
both entry points (the ``show_text`` tool's own block, task 03, and the
|
||||
``done``-frame pass over the finished answer, task 05).
|
||||
|
||||
The module is deliberately split in two halves:
|
||||
|
||||
* the **pure** half — :func:`canonical_locator`, :func:`fences_in`,
|
||||
:func:`slice_lines`, :func:`cap_slice` — no DB, no settings, no I/O;
|
||||
* the **resolver** half — :func:`document_by_identity`, :func:`block_for`,
|
||||
:func:`resolve_answer_blocks` — one bare ``SELECT`` per locator and the
|
||||
kill-switch gate.
|
||||
|
||||
**Import rule (one-way, checkable with
|
||||
``grep -n "import" app/rag/verbatim.py``):** this module must NEVER import
|
||||
the agent module (:mod:`app.rag.agent`) — ``agent.py`` already depends on
|
||||
the pure half of this module for the ``show_text`` tool branch, so a
|
||||
reverse dependency cycles. Allowed here: ``sqlalchemy``,
|
||||
:class:`app.models.Document`,
|
||||
:data:`app.rag.retriever.TRUNCATION_MARKER`, :class:`app.config.Settings`,
|
||||
stdlib.
|
||||
|
||||
Nothing calls this module yet (tasks 03 and 05 wire it in), so landing it
|
||||
changes no behavior anywhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import Document
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
|
||||
__all__ = [
|
||||
"LOCATOR_LANG",
|
||||
"VerbatimBlock",
|
||||
"SliceResult",
|
||||
"block_for",
|
||||
"cap_slice",
|
||||
"canonical_locator",
|
||||
"document_by_identity",
|
||||
"fences_in",
|
||||
"resolve_answer_blocks",
|
||||
"slice_lines",
|
||||
]
|
||||
|
||||
#: The fence info-string token that marks a locator fence (D2). The
|
||||
#: markdown renderer (task 06) and the copy-path builder (task 07) key on
|
||||
#: this SAME constant — one grammar, one spelling, no drifting copy.
|
||||
LOCATOR_LANG = "bor"
|
||||
|
||||
#: The range separators a locator may use between the identity and the
|
||||
#: line range: a space, a colon, a bare ``#``, or ``#L`` (D7 — the grammar
|
||||
#: is TOLERANT on input because the string is model-written).
|
||||
_SEP = r"[ \t:#]+"
|
||||
|
||||
#: A line range: ``12-40``, ``12–40`` (en dash), ``L12-L40``, or a bare
|
||||
#: ``12`` (one line). An optional ``L`` prefixes either bound; whitespace
|
||||
#: around the dash is tolerated. Anchored at the end of the string by the
|
||||
#: caller, so a range-shaped tail followed by more text is junk, not a
|
||||
#: range.
|
||||
_RANGE = r"(?P<range>L?[0-9]+(?:[ \t]*[-\u2013][ \t]*L?[0-9]+)?)"
|
||||
|
||||
#: The trailing range of a locator string (see :data:`_SEP` /
|
||||
#: :data:`_RANGE`). Case-insensitive only for the ``L`` prefix.
|
||||
_TRAILING_RANGE_RE = re.compile(f"{_SEP}{_RANGE}$", re.IGNORECASE)
|
||||
|
||||
#: The opening line of a ``bor`` locator fence: the language token,
|
||||
#: whitespace, then a NON-EMPTY remainder (the locator). A bare
|
||||
#: `` ```bor `` carries no locator, so it never matches and stays an
|
||||
#: ordinary code block.
|
||||
_FENCE_OPEN_RE = re.compile(
|
||||
rf"^```{re.escape(LOCATOR_LANG)}[ \t]+(?P<locator>.*[^ \t])[ \t]*$"
|
||||
)
|
||||
|
||||
#: The two characters a bound may use as its line-number prefix.
|
||||
_LINE_PREFIX = "Ll"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerbatimBlock:
|
||||
"""One displayed verbatim block — the bytes plus the truth about them.
|
||||
|
||||
``start_line`` / ``end_line`` are **1-based and INCLUSIVE** and describe
|
||||
EXACTLY the lines :attr:`text` contains *after* any character cap — a
|
||||
capped block's ``end_line`` is the last line actually shown, never the
|
||||
line the locator asked for (the caption must not claim lines nobody
|
||||
sees). ``total_lines`` is the whole document's line count, so the
|
||||
caption can say ``lines 12-40 of 512``. :attr:`text` never carries a
|
||||
trailing newline (lines are joined with ``"\\n"``), and
|
||||
:attr:`truncated` says whether :data:`~app.rag.retriever.TRUNCATION_MARKER`
|
||||
was appended on its own last line.
|
||||
|
||||
:attr:`locator` is the CANONICAL form (D7) built by the factory
|
||||
(:func:`block_for`), never passed in by a caller — it is the dedupe key
|
||||
of :func:`resolve_answer_blocks` and the matching key the copy-path
|
||||
builder uses (task 07).
|
||||
"""
|
||||
|
||||
source: str
|
||||
path: str
|
||||
title: str
|
||||
start_line: int
|
||||
end_line: int
|
||||
total_lines: int
|
||||
text: str
|
||||
truncated: bool = False
|
||||
locator: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SliceResult:
|
||||
"""The outcome of one line-range slice (see :func:`slice_lines`).
|
||||
|
||||
``first_line`` / ``last_line`` are 1-based and inclusive; ``past_end``
|
||||
is the caller's refusal signal and always travels with the true
|
||||
``total_lines`` (the teaching refusal names the real line count).
|
||||
"""
|
||||
|
||||
text: str
|
||||
first_line: int
|
||||
last_line: int
|
||||
total_lines: int
|
||||
past_end: bool
|
||||
|
||||
|
||||
def canonical_locator(raw: str) -> str | None:
|
||||
"""Normalize one model-written locator string, or refuse it (D7).
|
||||
|
||||
Accepted forms — ``source/path`` (the ``read``/``grep`` identity
|
||||
contract, split at the FIRST ``'/'``) followed by an optional separator
|
||||
and line range:
|
||||
|
||||
========================== ==========================
|
||||
input canonical output
|
||||
========================== ==========================
|
||||
``homelab/a.md`` ``homelab/a.md``
|
||||
``homelab/a.md 12-40`` ``homelab/a.md#12-40``
|
||||
``homelab/a.md:12-40`` ``homelab/a.md#12-40``
|
||||
``homelab/a.md#12-40`` ``homelab/a.md#12-40``
|
||||
``homelab/a.md#L12-L40`` ``homelab/a.md#12-40``
|
||||
``homelab/a.md 12\u201340`` ``homelab/a.md#12-40``
|
||||
``homelab/a.md 12`` ``homelab/a.md#12-12``
|
||||
========================== ==========================
|
||||
|
||||
The canonical form is always ``source/path`` + ``#start-end`` with
|
||||
plain digits — never the en dash, never the ``L``, so the fence, the
|
||||
tool block and the UI share ONE string (D7).
|
||||
|
||||
Returns ``None`` (NOT a locator) for: empty/whitespace input, a value
|
||||
with no ``'/'`` in the path part, a non-numeric or non-positive bound,
|
||||
``start > end``, trailing junk after the range, or a leading ``bor``
|
||||
token the caller left in. No DB access, no case folding (paths are
|
||||
case-sensitive row values). A locator carrying a range also refuses an
|
||||
identity containing whitespace: the fence's info string is ONE
|
||||
whitespace-free identity plus the range, so a path that contains a
|
||||
space is expressed as a whole-document locator (no range) or not at
|
||||
all — the documented limit of the tolerant grammar.
|
||||
"""
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
return None
|
||||
# A caller that pasted the fence's language token in has already lost
|
||||
# the identity — refuse rather than guess which token was the path.
|
||||
if text.split(" ", 1)[0] == LOCATOR_LANG:
|
||||
return None
|
||||
|
||||
match = _TRAILING_RANGE_RE.search(text)
|
||||
if match is None:
|
||||
identity, range_part = text, None
|
||||
else:
|
||||
# ``match.start()`` is where the separator begins — the identity
|
||||
# is everything before it (a multi-character separator, e.g. two
|
||||
# spaces, is consumed whole).
|
||||
identity, range_part = text[: match.start()], match.group("range")
|
||||
|
||||
# The identity is one whitespace-free ``source/path`` token, and it
|
||||
# never ends in a dangling separator (``a.md#`` with no range is a
|
||||
# typo, not a whole document).
|
||||
if not identity or any(c.isspace() for c in identity):
|
||||
return None
|
||||
if identity.endswith(("#", ":", "-")):
|
||||
return None
|
||||
if "/" not in identity:
|
||||
return None
|
||||
if range_part is None:
|
||||
return identity
|
||||
|
||||
bounds = [b.strip().lstrip(_LINE_PREFIX) for b in re.split(r"[-\u2013]", range_part)]
|
||||
# Defensive: :data:`_RANGE` already guarantees digit bounds, so this
|
||||
# only fires if the grammar above is ever loosened — it keeps
|
||||
# ``canonical_locator`` total (never an ``int()`` ValueError to a
|
||||
# caller that hands it model-written text).
|
||||
if any(not b.isdigit() for b in bounds):
|
||||
return None
|
||||
start = int(bounds[0])
|
||||
end = int(bounds[1]) if len(bounds) == 2 else start
|
||||
if start < 1 or end < 1 or start > end:
|
||||
return None
|
||||
return f"{identity}#{start}-{end}"
|
||||
|
||||
|
||||
def fences_in(answer: str) -> list[str]:
|
||||
"""Every ``bor`` locator fence's RAW locator string, in answer order.
|
||||
|
||||
Rules (the same shape ``markdown.js`` applies, task 06):
|
||||
|
||||
* the opening line is a fence whose info string is exactly
|
||||
`` ```bor `` + whitespace + a NON-empty remainder — a bare
|
||||
`` ```bor `` with no path is an ordinary code block, not a locator,
|
||||
and is not swallowed;
|
||||
* the fence closes at the next line that is nothing but backticks;
|
||||
* an **unclosed** fence contributes nothing (defensive: a half-streamed
|
||||
answer must not resolve a locator the model never finished) and ends
|
||||
the scan, since its body runs to the end of the answer;
|
||||
* the body is read and DISCARDED — this module never looks at what the
|
||||
model typed inside the fence (the bytes come from the row, D1);
|
||||
* nested `` ``` `` inside a body is NOT supported: the fence ends at
|
||||
the first closer (documented limitation, same rule as the renderer).
|
||||
|
||||
Duplicates collapse keeping the FIRST occurrence.
|
||||
"""
|
||||
lines = answer.splitlines()
|
||||
locators: list[str] = []
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
match = _FENCE_OPEN_RE.match(lines[index].strip())
|
||||
if match is None:
|
||||
index += 1
|
||||
continue
|
||||
closer = _find_fence_close(lines, index + 1)
|
||||
if closer is None:
|
||||
# Unclosed: the body runs to the end of the answer, so there
|
||||
# is nothing further to scan and this fence yields nothing.
|
||||
break
|
||||
locator = match.group("locator").strip()
|
||||
if locator and locator not in locators:
|
||||
locators.append(locator)
|
||||
index = closer + 1
|
||||
return locators
|
||||
|
||||
|
||||
def _find_fence_close(lines: Sequence[str], start: int) -> int | None:
|
||||
"""Index of the fence's closing line at or after ``start``, ``None`` if unclosed."""
|
||||
for offset in range(start, len(lines)):
|
||||
stripped = lines[offset].strip()
|
||||
if stripped.startswith("```") and not stripped.strip("`"):
|
||||
return offset
|
||||
return None
|
||||
|
||||
|
||||
def slice_lines(
|
||||
content: str, offset: int | None = None, limit: int | None = None
|
||||
) -> SliceResult:
|
||||
"""Cut ``content`` to a 1-based line range with ``read``'s exact semantics.
|
||||
|
||||
Same indexing (``content.splitlines()``) and the same range arithmetic
|
||||
as the ``read`` tool (``app/rag/agent.py``) and ``grep``, so a line
|
||||
number the model got from ``grep`` names the same line here — the one
|
||||
shared coordinate system the whole phase depends on:
|
||||
|
||||
* ``offset`` omitted → 1; ``limit`` omitted → read to the end;
|
||||
* ``end = min(offset + limit - 1, total_lines)`` — an overshooting
|
||||
``limit`` CLAMPS (``last_line`` is the document's last line);
|
||||
* ``offset`` past the end, an empty document, or a ``limit`` below 1 →
|
||||
``past_end=True`` with empty text and the TRUE ``total_lines`` (the
|
||||
caller builds the teaching refusal).
|
||||
|
||||
Never raises for a range problem: the refusal is the caller's, the
|
||||
total always travels with the result.
|
||||
"""
|
||||
lines = content.splitlines()
|
||||
total_lines = len(lines)
|
||||
first = 1 if offset is None else offset
|
||||
end = total_lines if limit is None else min(first + limit - 1, total_lines)
|
||||
if total_lines == 0 or first > total_lines or end < first:
|
||||
return SliceResult(
|
||||
text="",
|
||||
first_line=first,
|
||||
last_line=0,
|
||||
total_lines=total_lines,
|
||||
past_end=True,
|
||||
)
|
||||
return SliceResult(
|
||||
text="\n".join(lines[first - 1 : end]),
|
||||
first_line=first,
|
||||
last_line=end,
|
||||
total_lines=total_lines,
|
||||
past_end=False,
|
||||
)
|
||||
|
||||
|
||||
def cap_slice(text: str, first_line: int, cap: int) -> tuple[str, int, bool]:
|
||||
"""Apply the character budget to one slice AT A LINE boundary.
|
||||
|
||||
Returns ``(text_to_show, last_line_shown, truncated)``. The whole
|
||||
point of cutting at a line boundary is that the caption's line range
|
||||
stays true: ``last_line_shown`` is the last line the reader actually
|
||||
sees, so a capped block never claims lines it did not show.
|
||||
|
||||
* ``cap <= 0`` raises :class:`ValueError` — a mis-wired caller must be
|
||||
loud. The ``show_text_max_chars == 0`` kill switch is checked by
|
||||
:func:`resolve_answer_blocks`, never by reaching here;
|
||||
* ``len(text) <= cap`` → the text untouched, its own last line,
|
||||
``truncated=False``;
|
||||
* otherwise cut to the last WHOLE line that fits and append the shared
|
||||
:data:`~app.rag.retriever.TRUNCATION_MARKER` on its own line — the
|
||||
marker is not content and is not counted in ``last_line_shown`` (a
|
||||
line counts as fitting only when its terminator is inside the
|
||||
budget, or sits exactly at the budget's edge);
|
||||
* a first line LONGER than ``cap`` cuts that one line mid-way (the only
|
||||
sub-line cut, documented) and still ends with the marker.
|
||||
"""
|
||||
if cap <= 0:
|
||||
raise ValueError(f"cap_slice needs a positive cap, got {cap!r}")
|
||||
if len(text) <= cap:
|
||||
if not text:
|
||||
return text, first_line, False
|
||||
# ``split("\\n")``, NOT ``splitlines()``: a slice that ends on a
|
||||
# BLANK line legitimately ends with ``"\\n"`` (line 5 of
|
||||
# ``"a\nb\n\n"`` is empty and present), and ``splitlines()`` drops
|
||||
# that final empty element — which would report an extent one
|
||||
# line short of the text actually shown (a caption that lies, and
|
||||
# a canonical locator that no longer matches the same range
|
||||
# resolved by another entry point).
|
||||
return text, first_line + len(text.split("\n")) - 1, False
|
||||
cut = text[:cap]
|
||||
if text[cap] == "\n":
|
||||
# The budget lands exactly on a line end — every line of the cut
|
||||
# is whole, nothing to trim.
|
||||
return (
|
||||
f"{cut}\n{TRUNCATION_MARKER}",
|
||||
first_line + len(cut.splitlines()) - 1,
|
||||
True,
|
||||
)
|
||||
boundary = cut.rfind("\n")
|
||||
if boundary <= 0:
|
||||
# No whole line fits — the single documented mid-line cut.
|
||||
return f"{cut}\n{TRUNCATION_MARKER}", first_line, True
|
||||
whole = cut[:boundary]
|
||||
return (
|
||||
f"{whole}\n{TRUNCATION_MARKER}",
|
||||
first_line + len(whole.splitlines()) - 1,
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def document_by_identity(db: Session, combined: str) -> Document | None:
|
||||
"""The indexed row at the combined ``source/path`` identity, or ``None``.
|
||||
|
||||
Split at the FIRST ``'/'`` — source names are directory basenames and
|
||||
can never contain one, so the part before is the source and everything
|
||||
after is the path (the same identity contract as
|
||||
``app.rag.agent._resolve_path``).
|
||||
|
||||
This is that contract **reimplemented as a bare lookup**, deliberately
|
||||
NOT a call into ``agent.py``: the tool branch already has
|
||||
``_resolve_path`` (with its did-you-mean suggestions), the fence
|
||||
resolver only needs yes/no, and importing the agent from here would
|
||||
cycle (``agent.py`` imports this module). A traversal string
|
||||
(``../../etc/passwd``) is simply a row that does not exist — there is
|
||||
no file access here to guard, only a ``WHERE`` clause.
|
||||
"""
|
||||
if "/" not in combined:
|
||||
return None
|
||||
source, _, path = combined.partition("/")
|
||||
return db.scalar(
|
||||
select(Document).where(Document.source == source, Document.path == path)
|
||||
)
|
||||
|
||||
|
||||
def block_for(
|
||||
db: Session,
|
||||
combined: str,
|
||||
*,
|
||||
offset: int | None = None,
|
||||
limit: int | None = None,
|
||||
cap: int,
|
||||
doc: Document | None = None,
|
||||
) -> VerbatimBlock | None:
|
||||
"""Build the one block a locator names, or ``None`` (nothing displays).
|
||||
|
||||
Resolves ``combined`` (or takes the caller's already-resolved ``doc``)
|
||||
and refuses ``None`` for: no such document (including every
|
||||
traversal-shaped identity), an ``is_image`` document (image display
|
||||
belongs to ``show_image`` — LOCKED A6), and an empty or past-the-end
|
||||
slice. Otherwise ``slice_lines`` → ``cap_slice`` → the block, with the
|
||||
canonical :attr:`~VerbatimBlock.locator` built here (``source/path``,
|
||||
plus ``#first-last`` when the request named a range **or** the cap cut
|
||||
the slice — the form task 07 matches on).
|
||||
"""
|
||||
if doc is None:
|
||||
doc = document_by_identity(db, combined)
|
||||
if doc is None or doc.is_image:
|
||||
return None
|
||||
sliced = slice_lines(doc.content, offset, limit)
|
||||
if sliced.past_end or not sliced.text:
|
||||
return None
|
||||
text, last_line, truncated = cap_slice(sliced.text, sliced.first_line, cap)
|
||||
locator = f"{doc.source}/{doc.path}"
|
||||
if offset is not None or limit is not None or truncated:
|
||||
locator = f"{locator}#{sliced.first_line}-{last_line}"
|
||||
return VerbatimBlock(
|
||||
source=doc.source,
|
||||
path=doc.path,
|
||||
title=doc.title,
|
||||
start_line=sliced.first_line,
|
||||
end_line=last_line,
|
||||
total_lines=sliced.total_lines,
|
||||
text=text,
|
||||
truncated=truncated,
|
||||
locator=locator,
|
||||
)
|
||||
|
||||
|
||||
def resolve_answer_blocks(
|
||||
db: Session,
|
||||
answer: str,
|
||||
shown: Sequence[VerbatimBlock],
|
||||
settings: Settings,
|
||||
) -> list[VerbatimBlock]:
|
||||
"""The blocks an answer displays, in DISPLAY ORDER (the done-time pass).
|
||||
|
||||
Fuses the two ways a block enters an answer — the `` ```bor `` locator
|
||||
fences the model wrote (resolved here, from the row) and the blocks the
|
||||
``show_text`` tool already showed during the turn (``shown``) — into
|
||||
the ONE list task 05 puts on the ``done`` frame and persists with the
|
||||
record.
|
||||
|
||||
**Kill switches first** (checked HERE so no caller has to remember
|
||||
them): ``[]`` when ``settings.answer_text`` is off, or
|
||||
``settings.show_text_max_blocks == 0``, or
|
||||
``settings.show_text_max_chars == 0``.
|
||||
|
||||
**Ordering rule** (task 07's DOM order and the E2E assertions depend on
|
||||
it): answer order first — one block per fence, in the order the fences
|
||||
appear — then the ``shown`` blocks the answer never located, in their
|
||||
original call order. Dedupe is by canonical locator, and the **fence
|
||||
wins** over a same-locator entry from ``shown`` because the fence
|
||||
controls placement. A fence that does not resolve (unknown path, image
|
||||
document, past-the-end range, malformed locator) is simply absent: the
|
||||
caller sees "not among the blocks", which is what task 07 renders as
|
||||
the unresolved state — the answer's own fence text is left alone, so
|
||||
nothing is silently deleted. The list is finally truncated to
|
||||
``settings.show_text_max_blocks`` — and the fence scan STOPS once that
|
||||
many blocks are in, because the same truncation makes every later
|
||||
block unreachable (the bound keeps a fence-stuffed answer to at most
|
||||
``show_text_max_blocks`` successful lookups instead of one ``SELECT``
|
||||
per fence on the request's event loop).
|
||||
"""
|
||||
if not settings.answer_text:
|
||||
return []
|
||||
if settings.show_text_max_blocks == 0 or settings.show_text_max_chars == 0:
|
||||
return []
|
||||
|
||||
cap = settings.show_text_max_chars
|
||||
blocks: list[VerbatimBlock] = []
|
||||
#: The locator strings already CLAIMED (canonical written forms) — a
|
||||
#: repeated fence never resolves twice, and an unresolvable fence
|
||||
#: still holds its slot against a same-locator ``shown`` block.
|
||||
claimed: set[str] = set()
|
||||
#: The locators of the blocks actually appended — a resolved block
|
||||
#: can carry a NARROWER locator than the locator its fence wrote (the
|
||||
#: char cap cut the slice), and the same range shown by the tool
|
||||
#: carries exactly that narrower key, so both keys dedupe.
|
||||
emitted: set[str] = set()
|
||||
|
||||
for raw in fences_in(answer):
|
||||
canonical = canonical_locator(raw)
|
||||
if canonical is None or canonical in claimed:
|
||||
continue
|
||||
# Reserve the key BEFORE resolving: an unresolvable fence must not
|
||||
# let a same-locator ``shown`` block jump into the fence's slot.
|
||||
claimed.add(canonical)
|
||||
block = _block_for_canonical(db, canonical, cap)
|
||||
if block is None or block.locator in emitted:
|
||||
continue
|
||||
claimed.add(block.locator)
|
||||
emitted.add(block.locator)
|
||||
blocks.append(block)
|
||||
if len(blocks) >= settings.show_text_max_blocks:
|
||||
# The display budget is already spent — the truncation below
|
||||
# makes every later block unreachable, so stop LOOKING too:
|
||||
# a fence-stuffed answer costs at most
|
||||
# ``show_text_max_blocks`` successful lookups instead of one
|
||||
# ``SELECT`` per fence on the request's event loop.
|
||||
break
|
||||
|
||||
for block in shown:
|
||||
if block.locator in emitted or block.locator in claimed:
|
||||
continue
|
||||
emitted.add(block.locator)
|
||||
blocks.append(block)
|
||||
|
||||
return blocks[: settings.show_text_max_blocks]
|
||||
|
||||
|
||||
def _block_for_canonical(db: Session, canonical: str, cap: int) -> VerbatimBlock | None:
|
||||
"""Resolve one ALREADY-CANONICAL locator (the output of :func:`canonical_locator`)."""
|
||||
identity, _, range_part = canonical.partition("#")
|
||||
if not range_part:
|
||||
return block_for(db, identity, cap=cap)
|
||||
start_str, _, end_str = range_part.partition("-")
|
||||
start = int(start_str)
|
||||
end = int(end_str)
|
||||
return block_for(db, identity, offset=start, limit=end - start + 1, cap=cap)
|
||||
+124
-7
@@ -182,6 +182,47 @@ class SourceRef(BaseModel):
|
||||
return data
|
||||
|
||||
|
||||
class VerbatimBlockRef(BaseModel):
|
||||
"""One SERVER-resolved verbatim block of a chat answer (phase 182).
|
||||
|
||||
The item of :attr:`ChatDoneEvent.answer_text` and of the persisted
|
||||
:class:`ChatMessage.answer_text` — the display block the
|
||||
``show_text`` tool or a `` ```bor `` locator fence asked for, with
|
||||
the exact bytes read out of ``documents.content`` by
|
||||
:func:`app.rag.verbatim.block_for`.
|
||||
|
||||
**``text`` is SERVER-supplied bytes, never model output** (LOCKED
|
||||
A4/A7): the model only ever names the location, so what the user
|
||||
reads and copies cannot differ from the indexed file by a character
|
||||
— the phase's whole reason for existing. The field carries the same
|
||||
600 000 cap as the phase-83/164 answer text (deliberately generous
|
||||
above the 12 000 default of ``BOR_SHOW_TEXT_MAX_CHARS``, so an
|
||||
operator raising that budget never hits the wire limit instead).
|
||||
|
||||
``start_line`` / ``end_line`` are **1-based and INCLUSIVE** and
|
||||
describe exactly the lines ``text`` contains AFTER any character cap
|
||||
— they are the SERVER's resolved extent, never the model's claim
|
||||
(LOCKED A2), which is what the panel's caption prints. The caps on
|
||||
``source`` / ``path`` / ``title`` are :class:`SourceRef`'s (the same
|
||||
``documents`` column lengths, phase 83 SEC-05); ``locator`` is the
|
||||
canonical matching key (phase 182 D7 — ``source/path`` or
|
||||
``source/path#12-40``) the client matches its rendered placeholder
|
||||
against.
|
||||
"""
|
||||
|
||||
source: str = Field(max_length=120)
|
||||
path: str = Field(max_length=1000)
|
||||
title: str = Field(max_length=500)
|
||||
start_line: int = Field(ge=0)
|
||||
end_line: int = Field(ge=0)
|
||||
total_lines: int = Field(ge=0)
|
||||
#: The verbatim bytes themselves (see the class docstring) — the
|
||||
#: phase-83 answer-text cap, generous above the per-block budget.
|
||||
text: str = Field(max_length=600_000)
|
||||
truncated: bool = False
|
||||
locator: str = Field(max_length=2100)
|
||||
|
||||
|
||||
class ChatThinkingEvent(BaseModel):
|
||||
"""SSE thinking event: one chunk of the model's reasoning (phase 17).
|
||||
|
||||
@@ -342,6 +383,23 @@ class ChatDoneEvent(BaseModel):
|
||||
byte-identical to pre-phase (the byte-identical-off contract,
|
||||
PLAN §13; the :attr:`answer_image` / :class:`SourceRef.image_url`
|
||||
omission precedent).
|
||||
|
||||
Phase 182 (task 05, LOCKED A1/A4/A9): ``answer_text`` — the answer's
|
||||
VERBATIM display blocks, the text sibling of :attr:`answer_image`
|
||||
with two deliberate differences. (1) It is a LIST: one answer may
|
||||
display several blocks (the ``show_text`` calls and/or the
|
||||
`` ```bor `` locator fences the answer itself wrote — D1), in
|
||||
DISPLAY order (answer order first, then the tool blocks the answer
|
||||
never located — :func:`app.rag.verbatim.resolve_answer_blocks`).
|
||||
(2) Each item carries the TEXT itself rather than a route: the bytes
|
||||
ride inline (A4 — no new bytes route, no new anonymous surface, so a
|
||||
shared page renders from its own record exactly like the phase-123
|
||||
chat image), and every byte of it comes from ``documents.content``,
|
||||
never from the model (A7). ``None`` / ``[]`` — every turn without
|
||||
blocks, every deflected turn (D9 resolves nothing on a disclaimer),
|
||||
every ``answer_text=False`` turn, and the whole pre-phase — is
|
||||
DROPPED on serialization: ABSENT, never ``null``, never ``[]``, so
|
||||
those frames stay byte-identical to pre-phase (the same contract).
|
||||
"""
|
||||
|
||||
type: str = "done"
|
||||
@@ -357,25 +415,36 @@ class ChatDoneEvent(BaseModel):
|
||||
#: ONLY on the cap-reached turn; ``False`` (every other turn)
|
||||
#: serializes WITHOUT the key — ABSENT, never ``false``.
|
||||
budget_exhausted: bool = False
|
||||
#: Phase 182 (task 05) — see the class docstring. ``None``/``[]``
|
||||
#: (every turn without verbatim blocks) serializes WITHOUT the key —
|
||||
#: ABSENT, never ``null``, never ``[]``.
|
||||
answer_text: list[VerbatimBlockRef] | 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
|
||||
"""The additive-key omission rules: ``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). Phase 160
|
||||
(task 03): ``budget_exhausted`` ``False`` (every non-cap turn)
|
||||
serializes WITHOUT the key too — ABSENT, never ``false`` — the
|
||||
same byte-identical-off contract."""
|
||||
same byte-identical-off contract. Phase 182 (task 05):
|
||||
``answer_text`` ``None`` OR EMPTY (a turn whose answer resolved
|
||||
no block, a deflected turn, the whole ``answer_text=False``
|
||||
configuration, and the pre-phase) serializes WITHOUT the key —
|
||||
ABSENT, never ``null`` and never ``[]``, so every block-free
|
||||
frame keeps its exact pre-phase bytes."""
|
||||
data = handler(self)
|
||||
if self.answer_image is None:
|
||||
data.pop("answer_image", None)
|
||||
if not self.budget_exhausted:
|
||||
data.pop("budget_exhausted", None)
|
||||
if not self.answer_text:
|
||||
data.pop("answer_text", None)
|
||||
return data
|
||||
|
||||
|
||||
@@ -1478,6 +1547,28 @@ class ChatMessage(BaseModel):
|
||||
# exactly like ``stopped`` — the phase-50 byte-identical
|
||||
# round-trip contract covers the new key automatically.
|
||||
budget_exhausted: bool | None = None
|
||||
# Phase 182 (task 05, LOCKED A4/A9): the answer's VERBATIM display
|
||||
# blocks — the persisted twin of the done frame's ``answer_text``
|
||||
# (the ``show_text`` tool's blocks and/or the ones a `` ```bor ``
|
||||
# locator fence resolved), on the BRAIN record only. Like
|
||||
# ``answer_image`` the field EXISTS so the ``extra="forbid"``
|
||||
# boundary does NOT 422 the done-time auto-save carrying it (the
|
||||
# phase-113 lesson, applied up front), and the restore path plus the
|
||||
# anonymous shared page re-render the panels from it — the bytes are
|
||||
# part of the chat's own content, so they ride the same
|
||||
# credential-is-the-row trust model and need NO new route (A4).
|
||||
# No migration (``saved_chats.messages`` is raw JSONB, the phase-50
|
||||
# / 160 precedent). ``None``/``[]`` serializes WITHOUT the key (the
|
||||
# :meth:`_drop_image_when_absent` serializer below), so a turn with
|
||||
# no blocks — and every pre-phase record — round-trips
|
||||
# byte-identically (A9). The per-block ``text`` cap is
|
||||
# :class:`VerbatimBlockRef`'s own; the list cap matches the
|
||||
# ``tools``/``segments`` cap (50), far past the
|
||||
# ``show_text_max_blocks`` default of 5 — and the block bytes count
|
||||
# toward :data:`SAVED_CHAT_TOTAL_CHARS_BUDGET`, so the display blocks
|
||||
# never become a second, unbounded channel on the anonymous save
|
||||
# surface (the phase-83 SEC-05 invariant).
|
||||
answer_text: list[VerbatimBlockRef] | None = Field(default=None, max_length=50)
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _drop_image_when_absent(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||||
@@ -1492,12 +1583,21 @@ class ChatMessage(BaseModel):
|
||||
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)."""
|
||||
public/credential-is-the-id trust model).
|
||||
|
||||
Phase 182 (task 05) extends the same rule to ``answer_text``:
|
||||
``None`` OR an empty list (every record without verbatim blocks,
|
||||
and every pre-phase-182 record) serializes WITHOUT the key —
|
||||
ABSENT, never ``null``, never ``[]`` (A9), so those records
|
||||
round-trip byte-identically while a record WITH blocks keeps the
|
||||
server's bytes for the restore path and the shared page."""
|
||||
data = handler(self)
|
||||
if self.image is None:
|
||||
data.pop("image", None)
|
||||
if self.answer_image is None:
|
||||
data.pop("answer_image", None)
|
||||
if not self.answer_text:
|
||||
data.pop("answer_text", None)
|
||||
# Phase 158 (task 01, D4): the timeline is ABSENT (never
|
||||
# null) when there is no run to render — the pre-phase /
|
||||
# no-thinking-no-tools shape stays byte-identical (the
|
||||
@@ -1529,11 +1629,28 @@ def _require_within_total_budget(messages: list[ChatMessage]) -> list[ChatMessag
|
||||
(phase 164): ``len(text) + len(thinking or "")`` over every message
|
||||
must stay within :data:`SAVED_CHAT_TOTAL_CHARS_BUDGET`.
|
||||
|
||||
Phase 182 (task 05) counts the VERBATIM BLOCKS into the same sum: a
|
||||
block's ``text`` carries the phase-83 answer-text cap (600 000) so an
|
||||
operator raising ``BOR_SHOW_TEXT_MAX_CHARS`` never hits the wire limit,
|
||||
and the anonymous write surface (``POST/PUT /api/chats`` is public —
|
||||
phase 55 A1) must not gain a second, UNBOUNDED byte channel next to
|
||||
the bounded one (the SEC-05 invariant: the total is what keeps the
|
||||
audited worst case at the audited order). A legitimate turn carries at
|
||||
most ``BOR_SHOW_TEXT_MAX_BLOCKS`` × ``BOR_SHOW_TEXT_MAX_CHARS`` bytes
|
||||
(5 × 12 000 = 60 KB by default), orders of magnitude below the budget,
|
||||
so nothing the server produces is affected — the bound only rejects a
|
||||
client stuffing blocks.
|
||||
|
||||
Raises :class:`ValueError` naming the budget, which Pydantic turns
|
||||
into the ordinary 422 validation response (``loc`` = the model
|
||||
itself, ``msg`` carrying the budget wording) — the same boundary-
|
||||
only style as every other phase-83/164 cap."""
|
||||
total = sum(len(m.text) + len(m.thinking or "") for m in messages)
|
||||
total = sum(
|
||||
len(m.text)
|
||||
+ len(m.thinking or "")
|
||||
+ sum(len(b.text) for b in m.answer_text or [])
|
||||
for m in messages
|
||||
)
|
||||
if total > SAVED_CHAT_TOTAL_CHARS_BUDGET:
|
||||
raise ValueError(
|
||||
f"saved chat exceeds the {SAVED_CHAT_TOTAL_CHARS_BUDGET:,}-character budget"
|
||||
|
||||
+111
-34
@@ -550,6 +550,19 @@ export function documentUrl(source, path, back = "/") {
|
||||
return url;
|
||||
}
|
||||
|
||||
/* Phase 182 (task 07): the ONE options object handed to the verbatim panel
|
||||
* client (assets/verbatim.js, a classic script loaded before this module),
|
||||
* so every panel on this page navigates EXACTLY like a source chip: the
|
||||
* same `documentUrl(…, "/")` href (back → the chat, phase 153) and the same
|
||||
* left-click → same-page modal (phase 26). The shared page passes no such
|
||||
* hook — there the plain `/document.html?…` href verbatim.js builds by
|
||||
* default is the whole affordance (no modal, admin-gated API). */
|
||||
const VERBATIM_LINK_OPTS = {
|
||||
documentHref: (source, path) => documentUrl(source, path, "/"),
|
||||
onOpen: (source, path, opener) =>
|
||||
openDocumentModal(source, path, opener, "/"), // back → the chat page
|
||||
};
|
||||
|
||||
/* ---------- steering notes (phase 15; panel module-owned from phase 34) ----------
|
||||
*
|
||||
* The owner's tuning notes steer every future answer: they live in
|
||||
@@ -2916,7 +2929,15 @@ function renderStoredMessage(m) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const wrap = addMessage("brain", renderMarkdown(m.text), true);
|
||||
// Phase 182 (task 07): the brain restore renderer OPTS IN the verbatim
|
||||
// pass (the ONE answer renderer pair — this and the live per-delta write),
|
||||
// so a stored ` ```bor ` fence becomes the same placeholder the live turn
|
||||
// painted and hydrates from the record's `answer_text` below. A pre-phase
|
||||
// record without the key renders its fences as the honest unresolved
|
||||
// state — the record is what it is, the client never resurrects model
|
||||
// bytes (locked A7). USER bubbles and the thinking blocks stay
|
||||
// one-argument calls: their text is not a locator surface.
|
||||
const wrap = addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true);
|
||||
// Phase 158 (task 03, D4): a record WITH `segments` renders the
|
||||
// per-run timeline through restoreTimeline (collapsed) — the runs
|
||||
// carry the SAME data as `m.thinking` + `m.tools`, in order, so
|
||||
@@ -3009,6 +3030,18 @@ function renderStoredMessage(m) {
|
||||
// the chips row, then the images row). A record without the key
|
||||
// renders byte-identically (pre-phase chats restore unchanged).
|
||||
if (m.answer_image) appendAnswerImage(wrap, m.answer_image);
|
||||
// Phase 182 (task 07): the verbatim panels restore WITH the bubble, in the
|
||||
// SAME order as the live `done` path — hydrate the placeholders inside the
|
||||
// bubble, then append the blocks the answer never located, then the source
|
||||
// clusters (the phase-124/130 position: below the text, above the chips).
|
||||
// The SAME builders as the live turn, so a reload renders the identical
|
||||
// panel (parity is the phase's hard requirement); a record with no
|
||||
// `answer_text` settles its placeholders to the unresolved state.
|
||||
hydrateVerbatim(wrap.querySelector(".bubble"), m.answer_text || [], VERBATIM_LINK_OPTS);
|
||||
appendUnlocatedVerbatim(wrap, m.answer_text || [], VERBATIM_LINK_OPTS);
|
||||
// The settled answer's ordinary code blocks get their Copy buttons here
|
||||
// too (never mid-stream — the live path does the same at `done`).
|
||||
enhanceAnswerCodeBlocks(wrap);
|
||||
appendSources(wrap, m.sources);
|
||||
// Phase 113 (task 02) / phase 130 (task 02): the related tier
|
||||
// restores with the bubble — BELOW the citation clusters and ABOVE
|
||||
@@ -3235,44 +3268,27 @@ function absoluteShareUrl(shareUrl) {
|
||||
return new URL(shareUrl, window.location.origin).toString();
|
||||
}
|
||||
|
||||
/* Select every text node in an element — the link field's
|
||||
* select-on-focus (an <a> has no .select(); a range does the job).
|
||||
* Best-effort: selection failure only means the user copies by hand. */
|
||||
function selectAllInField(el) {
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} catch {
|
||||
/* selection is best-effort — the field still shows the full URL */
|
||||
}
|
||||
}
|
||||
|
||||
/* Clipboard copy with the owner-locked inline-link fallback: a
|
||||
* non-secure (http) homelab origin rejects navigator.clipboard, so the
|
||||
* failure path renders a TRANSIENT <a> link field near the status line
|
||||
* (appended to the composer, beside the send button that carries
|
||||
* #send-status) — input-like, it selects its full URL on focus (click
|
||||
* or Tab, then Ctrl/Cmd+C). One field at a time (a new offer replaces
|
||||
* the old). Returns true when the clipboard took it. */
|
||||
* the old). Returns true when the clipboard took it.
|
||||
*
|
||||
* Phase 182 (task 07): the mechanism DELEGATES to the one implementation in
|
||||
* assets/verbatim.js (`copyTextWithFallback` + its select-on-focus helper),
|
||||
* because the verbatim panels and the ordinary answer code blocks needed the
|
||||
* same degradation and a second copy would have been a second source of
|
||||
* truth. Nothing else changes: the same host (the composer, beside the
|
||||
* status line), the same owner-locked field title, the same return value —
|
||||
* the share suites are the guard (tests/unit/test_save_chat_ui.py). */
|
||||
async function copyShareLinkWithFallback(absoluteUrl) {
|
||||
document.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
|
||||
try {
|
||||
await navigator.clipboard.writeText(absoluteUrl);
|
||||
return true;
|
||||
} catch {
|
||||
const field = document.createElement("a");
|
||||
field.className = "share-link-fallback";
|
||||
field.href = absoluteUrl; // carries the full URL (copy link address works too)
|
||||
field.textContent = absoluteUrl; // the URL is data — textContent, never innerHTML
|
||||
field.title = "Share link — click, then copy (Ctrl/Cmd+C)";
|
||||
field.addEventListener("focus", () => selectAllInField(field));
|
||||
composer.appendChild(field); // near the status line (inside the send button)
|
||||
field.focus({ preventScroll: true }); // selects the URL — ready to copy
|
||||
return false;
|
||||
}
|
||||
return copyTextWithFallback(
|
||||
absoluteUrl,
|
||||
composer, // near the status line (inside the send button)
|
||||
"Share link — click, then copy (Ctrl/Cmd+C)"
|
||||
);
|
||||
}
|
||||
|
||||
/* Share-success toast (phase 55, task 04 — TODO.md L5): the VISIBLE
|
||||
@@ -3903,6 +3919,10 @@ function finalizeFailedTurn(
|
||||
// open run closed (phase 158, D5 — the stop-finalize pattern),
|
||||
// mark it failed.
|
||||
closeAllRuns(wrap);
|
||||
// Phase 182 (task 07): the failure funnel is a settle as well — a partial
|
||||
// stream that carried a locator fence keeps its text, but the placeholder
|
||||
// settles to the unresolved state (no `done`, no server bytes).
|
||||
settleVerbatimPlaceholders(wrap.querySelector(".bubble"), "unverified");
|
||||
// Phase 161 (D2): the failed-turn funnel is a settle — fold the
|
||||
// container alongside the runs; the summary reads the live run
|
||||
// count (D6 — segRuns, module scope).
|
||||
@@ -4028,6 +4048,10 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
turnAbort?.abort(); // phase 48: same outcome, one owner — aborted is
|
||||
// set first, so the catch never reads the guard's
|
||||
// abort as a user stop
|
||||
// Phase 182 (task 07): the 300 s guard is a terminal frame — a panel
|
||||
// still reading "Retrieving the exact text…" would be a lie by then
|
||||
// (the hydrate at `done` never runs), so settle it to the honest state.
|
||||
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
|
||||
setUiState(UI_STATE.error, "That's taking a long time — the answer may be stuck.");
|
||||
});
|
||||
|
||||
@@ -4329,7 +4353,14 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// NEW run in the container — the D15 re-open of the shared
|
||||
// block is gone). Idempotent — a no-op without an active run.
|
||||
closeCurrentRun();
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
|
||||
// Phase 182 (task 07): the OPT-IN verbatim pass on the ONE answer
|
||||
// renderer. A ` ```bor ` locator fence becomes a pending placeholder
|
||||
// here (the model's re-typed body DROPPED, locked A7) that the `done`
|
||||
// branch below hydrates against the frame's server-resolved blocks —
|
||||
// mid-stream the reader sees "Retrieving the exact text…", never a
|
||||
// copy that might be wrong. Every other renderer on the page (user
|
||||
// bubbles, thinking runs, the document viewer) stays one-argument.
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc, { verbatim: true });
|
||||
// No page scroll (phase 42): the answer never follows the viewport.
|
||||
} else if (ev.type === "done") {
|
||||
sawDone = true;
|
||||
@@ -4361,6 +4392,25 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// 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);
|
||||
// Phase 182 (task 07, locked A1/A2): the answer's verbatim blocks —
|
||||
// `ev.answer_text`, the bytes the SERVER read out of the index — land
|
||||
// in the SAME band as the answer image (below the text, above the
|
||||
// source clusters): hydrate every placeholder the renderer painted,
|
||||
// then append the blocks the answer never located (a `show_text` call
|
||||
// the prose never named). A locator the server could not resolve
|
||||
// renders the honest unresolved sentence with NONE of the model's
|
||||
// bytes, and no panel is left at "Retrieving the exact text…".
|
||||
hydrateVerbatim(
|
||||
wrap.querySelector(".bubble"),
|
||||
ev.answer_text || [],
|
||||
VERBATIM_LINK_OPTS
|
||||
);
|
||||
appendUnlocatedVerbatim(wrap, ev.answer_text || [], VERBATIM_LINK_OPTS);
|
||||
// The turn SETTLED — this is where the ordinary code blocks get their
|
||||
// Copy buttons (never mid-stream: per-delta the bubble is rebuilt from
|
||||
// scratch, and a button that vanishes on the next token is not a
|
||||
// control). NO badge, NO caption on those (locked A5).
|
||||
enhanceAnswerCodeBlocks(wrap);
|
||||
appendSources(wrap, ev.sources);
|
||||
// Phase 113 (task 02): the cited tier stays the citation
|
||||
// surface (the chips above); the related tier (scored docs
|
||||
@@ -4378,7 +4428,11 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// what gets persisted.
|
||||
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
|
||||
if (!acc && sawThinking) {
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
|
||||
// Phase 182 (task 07): the same opt-in as the per-delta write —
|
||||
// this line paints the answer the user keeps (the fallback text,
|
||||
// so no placeholder exists in practice, but the ONE answer
|
||||
// renderer must stay ONE code path in shape).
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText, { verbatim: true });
|
||||
}
|
||||
// Persistence save point 2: the answer lands only when the turn is
|
||||
// complete (raw text + the done metadata; phase 17: + optional
|
||||
@@ -4405,6 +4459,13 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// the precedent): an image-less / pre-phase turn stores a
|
||||
// byte-identical record.
|
||||
answer_image: ev.answer_image || undefined,
|
||||
// Phase 182 (task 07): the verbatim blocks persist with the turn
|
||||
// (task 05's additive `answer_text` key) so a reload — and a shared
|
||||
// page — re-renders the identical panel. `undefined` drops the key
|
||||
// from the JSON (the house optional-meta pattern, the answer_image
|
||||
// line above is the precedent): a pre-phase or answer_text=False
|
||||
// record stays byte-identical.
|
||||
answer_text: ev.answer_text || 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
|
||||
@@ -4477,6 +4538,14 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
}
|
||||
}
|
||||
} else if (ev.type === "error") {
|
||||
// Phase 182 (task 07): an `error` frame is terminal — the `done`
|
||||
// hydrate will never run, so a placeholder that streamed in stays at
|
||||
// "Retrieving the exact text…" forever unless it settles HERE (the
|
||||
// honest unresolved state, never a stale promise of the exact text).
|
||||
settleVerbatimPlaceholders(
|
||||
wrap?.querySelector(".bubble"),
|
||||
"unverified"
|
||||
);
|
||||
// Phase 114 (TODO L6): carry the frame's optional hint (the
|
||||
// "question too long" frame has one) through the throw — the
|
||||
// banner shows it in place of the default reachability hint.
|
||||
@@ -4531,6 +4600,10 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
}
|
||||
} catch (err) {
|
||||
if (aborted) {
|
||||
// Phase 182 (task 07): defensive settle for the guard's own abort
|
||||
// surface (the timeout callback already ran it — idempotent, and the
|
||||
// turn must not leave a pending panel on ANY terminal path).
|
||||
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
|
||||
// The 300s guard already took the turn to the error state — its
|
||||
// own abort surfaces here, and it is never read as a user stop.
|
||||
// Phase 159 (task 03): the aborted settle must not strand the
|
||||
@@ -4551,6 +4624,10 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// convention; no sources/suggestions — the turn never settled).
|
||||
// The persistedOnLeave guard (phase 20) keeps a navigate-away —
|
||||
// which aborts the same fetch — from saving the partial twice.
|
||||
// Phase 182 (task 07): the STOP is terminal for this answer too — a
|
||||
// placeholder in the kept partial settles to the honest state (the
|
||||
// blocks never arrive without a `done` frame).
|
||||
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
|
||||
if (wrap && acc && !persistedOnLeave) {
|
||||
// Phase 158 (D5): the turn was stopped — fold every open run
|
||||
// (replaces the phase-117 closeToolCalls fold + the D15 block
|
||||
|
||||
+138
-6
@@ -102,6 +102,43 @@
|
||||
* out of scope. Everything without a link/image/`####`+ construct renders
|
||||
* byte-identically to
|
||||
* the pre-phase renderer (pinned by the phase-180 unit suite).
|
||||
*
|
||||
* Phase 182 (task 06) adds the OPT-IN verbatim pass and the second,
|
||||
* optional argument: `renderMarkdown(md, opts)`, where the ONLY truthy path
|
||||
* is `opts && opts.verbatim === true`. When on, a ` ```bor ` LOCATOR fence
|
||||
* (``` ```bor homelab/runbooks/nginx.md#12-40 ````) is replaced — info
|
||||
* line, body and closing fence — with a `\u0000VERBATIMn\u0000` placeholder
|
||||
* that the final restore step emits as
|
||||
* `<div class="verbatim verbatim-pending" data-bor-locator="…">`.
|
||||
* WHY THE MODEL'S BODY IS DISCARDED (the phase's core contract, locked A7):
|
||||
* the bytes inside a `bor` fence are the MODEL's re-typed copy of a
|
||||
* knowledge-base file, and a re-typed copy is exactly the bug this phase
|
||||
* kills — it can silently differ from the indexed bytes. So the renderer
|
||||
* keeps only the LOCATION (normalized by the local `borLocator`, the client
|
||||
* mirror of Python `app.rag.verbatim.canonical_locator`) and paints nothing
|
||||
* but the pending caption; the server
|
||||
* (`app.rag.verbatim.resolve_answer_blocks`) supplies the real bytes and
|
||||
* `verbatim.js` (task 07) hydrates the placeholder by matching
|
||||
* `data-bor-locator`. An UNPARSEABLE locator is put back byte-for-byte and
|
||||
* falls through to the ordinary fence pass — no placeholder, no badge, the
|
||||
* model's own body — and a fence left OPEN at the very end of the input
|
||||
* (the info line typed, the body not yet streamed) shows that same pending
|
||||
* state instead of raw backticks. The server's `fences_in` is STRICTER
|
||||
* there (it ignores an unclosed fence: the server only ever sees a finished
|
||||
* answer, the client sees a partial one); a half-typed BODY therefore still
|
||||
* reads as ordinary text until its closing fence lands, exactly like every
|
||||
* other incomplete fence does today. PASS ORDER with the option on:
|
||||
* verbatim → fences → tables → escape+inline → blockquotes → paragraphs →
|
||||
* restore (a ` ``` ` inside a `bor` body ends the fence at the FIRST closer
|
||||
* — the documented limitation the server shares, so both sides agree). The
|
||||
* placeholder is the one block element the paragraph pass lets through
|
||||
* unwrapped. BYTE-IDENTICAL WHEN OFF: with no second argument — every
|
||||
* existing call site, the document viewer (`document.js`), the thinking
|
||||
* blocks and the user bubbles — `opts` is undefined, the pass never runs,
|
||||
* no `\u0000VERBATIM` placeholder exists anywhere in the text, the restore
|
||||
* replace is a no-op, and a document that legitimately contains a ` ```bor `
|
||||
* fence still renders its own text (the phase-44/138/180 fixtures stay
|
||||
* byte-identical, pinned by their own suites).
|
||||
*/
|
||||
|
||||
function escapeHtml(s) {
|
||||
@@ -156,10 +193,83 @@ function mdAnchorOpen(safe) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderMarkdown(md) {
|
||||
// Phase 182 (task 06): the CLIENT MIRROR of Python
|
||||
// `app.rag.verbatim.canonical_locator` — the tolerant locator grammar of
|
||||
// phase 182 D7, re-implemented here because the renderer runs in the
|
||||
// browser and cannot import Python. PARITY IS LOAD-BEARING: the canonical
|
||||
// string this returns becomes the `data-bor-locator` key that `verbatim.js`
|
||||
// (task 07) matches against the server's `done.answer_text[].locator`, so a
|
||||
// divergence means a resolved block never hydrates. The parity table in
|
||||
// tests/unit/test_frontend_verbatim_markdown.py pins both sides; `fences_in`
|
||||
// on the server pins the fence shape.
|
||||
//
|
||||
// Grammar: a combined `source/path` (the `read`/`grep` identity contract,
|
||||
// split at the FIRST `/`) plus an OPTIONAL separator (` `, `:`, `#`, `#L`)
|
||||
// and line range — `12-40`, `12\u201340` (en dash), `L12-L40`, or a bare `12`
|
||||
// (one line). Returns `source/path` for a whole document and
|
||||
// `source/path#12-40` for a range (canonical DIGITS only, never the en dash,
|
||||
// never the `L`), or `""` (NOT a locator) for an empty/whitespace input, a
|
||||
// value with no `/` in the identity, a non-numeric or non-positive bound,
|
||||
// `start > end`, trailing junk after the range, a dangling separator
|
||||
// (`a.md#`), or a leading `bor` token the caller left in.
|
||||
function borLocator(raw) {
|
||||
const text = String(raw).trim();
|
||||
if (!text) return "";
|
||||
// A caller that pasted the fence's language token in has already lost the
|
||||
// identity — refuse rather than guess which token was the path.
|
||||
if (text.split(" ")[0] === "bor") return "";
|
||||
// Leftmost match of: one-or-more separators + the range, ANCHORED to the
|
||||
// end of the string (so a range-shaped tail followed by more text is
|
||||
// junk, not a range). Everything before it is the identity.
|
||||
const match = /[ \t:#]+(L?\d+(?:[ \t]*[-\u2013][ \t]*L?\d+)?)$/i.exec(text);
|
||||
const identity = match ? text.slice(0, match.index) : text;
|
||||
const rangePart = match ? match[1] : null;
|
||||
// One whitespace-free `source/path` token, never ending in a dangling
|
||||
// separator (`a.md#` with no range is a typo, not a whole document).
|
||||
if (!identity || /\s/.test(identity)) return "";
|
||||
if (/[#:\-]$/.test(identity)) return "";
|
||||
if (!identity.includes("/")) return "";
|
||||
if (rangePart === null) return identity;
|
||||
const bounds = rangePart.split(/[\u2013-]/).map((b) => b.trim().replace(/^[Ll]+/, ""));
|
||||
if (bounds.some((b) => !/^\d+$/.test(b))) return "";
|
||||
const start = parseInt(bounds[0], 10);
|
||||
const end = bounds.length === 2 ? parseInt(bounds[1], 10) : start;
|
||||
if (start < 1 || end < 1 || start > end) return "";
|
||||
return `${identity}#${start}-${end}`;
|
||||
}
|
||||
|
||||
function renderMarkdown(md, opts) {
|
||||
// 0. Protect the phase-182 ` ```bor ` LOCATOR fences — BEFORE the fence
|
||||
// pass, so a locator fence never reaches the ordinary code renderer.
|
||||
// OPT-IN ONLY (the byte-identity contract in the header): `opts` is
|
||||
// undefined at every existing call site and `opts.verbatim === true` is
|
||||
// the single truthy path. The whole match is DROPPED — info line, body
|
||||
// and closing fence — because the model's copy of the file is exactly
|
||||
// what must never be painted (locked A7); only the normalized locator is
|
||||
// kept. The closing alternative (`(?![\s\S])`) matches a fence left OPEN
|
||||
// at the very end of the input, so as soon as the model has typed the
|
||||
// info line the answer shows the pending state instead of raw backticks
|
||||
// (the deliberate asymmetry with the server's stricter `fences_in`: the
|
||||
// server only ever sees a finished answer, the client sees a partial
|
||||
// one). A bare ` ```bor ` with no path does NOT match — it is an ordinary
|
||||
// code block and must survive to the fence pass untouched — and an
|
||||
// unparseable locator is returned byte-for-byte, degrading to the
|
||||
// `<pre><code>` that pass produces today with the model's own body.
|
||||
const verbatim = [];
|
||||
let text = md;
|
||||
if (opts && opts.verbatim === true) {
|
||||
text = text.replace(/^```bor[ \t]+\S[^\n]*\n(?:[\s\S]*?^```[ \t]*$|(?![\s\S]))/gm, (match) => {
|
||||
const infoLine = match.slice(0, match.indexOf("\n")).replace(/^```bor[ \t]+/, "").trim();
|
||||
const canonical = borLocator(infoLine);
|
||||
if (canonical === "") return match;
|
||||
verbatim.push(canonical);
|
||||
return `\u0000VERBATIM${verbatim.length - 1}\u0000`;
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Protect fenced code blocks.
|
||||
const codeBlocks = [];
|
||||
let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
||||
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
||||
codeBlocks.push(`<pre><code>${escapeHtml(code.replace(/\n$/, ""))}</code></pre>`);
|
||||
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
|
||||
});
|
||||
@@ -356,19 +466,41 @@ function renderMarkdown(md) {
|
||||
}
|
||||
text = quoted.join("\n");
|
||||
|
||||
// 3. Paragraphs (double newline separated).
|
||||
// 3. Paragraphs (double newline separated). The first guard test is the
|
||||
// untouched pre-existing one; the second is the phase-182 placeholder,
|
||||
// which restores to a `<div>` (a BLOCK element) and must not be wrapped
|
||||
// in a `<p>` the way a code placeholder is. It can only ever match when
|
||||
// `opts.verbatim` was on, so every other caller keeps byte-identical
|
||||
// paragraph behavior.
|
||||
text = text
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => {
|
||||
const b = block.trim();
|
||||
if (!b) return "";
|
||||
if (/^<(h\d|ul|ol|pre|li|blockquote)/.test(b)) return b;
|
||||
if (/^<(h\d|ul|ol|pre|li|blockquote)/.test(b) || /^\u0000VERBATIM\d+\u0000$/.test(b)) return b;
|
||||
return `<p>${b.replace(/\n/g, "<br>")}</p>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// 4. Restore protected spans: tables (already final HTML) and code.
|
||||
// 4. Restore protected spans: tables (already final HTML), code, and the
|
||||
// phase-182 verbatim placeholders. A placeholder restores as the PENDING
|
||||
// panel and NEVER as the model's bytes (locked A7): the caption carries
|
||||
// the `source/path` identity, the state line is a polite live region
|
||||
// (`role="status"` announces the pending text once and the task-07
|
||||
// hydration swap as a change), and every interpolated value goes through
|
||||
// escapeHtml — the locator is model-written text landing in an ATTRIBUTE,
|
||||
// so this is the escape-first boundary with no exception.
|
||||
return text
|
||||
.replace(/\u0000TABLE(\d+)\u0000/g, (_m, i) => tables[Number(i)])
|
||||
.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]);
|
||||
.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)])
|
||||
.replace(/\u0000VERBATIM(\d+)\u0000/g, (_m, i) => {
|
||||
const canonical = verbatim[Number(i)];
|
||||
const identity = canonical.split("#")[0];
|
||||
return (
|
||||
`<div class="verbatim verbatim-pending" data-bor-locator="${escapeHtml(canonical)}">` +
|
||||
`<span class="verbatim-caption">${escapeHtml(identity)}</span>` +
|
||||
`<span class="verbatim-state" role="status">Retrieving the exact text…</span>` +
|
||||
"</div>"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,6 +64,25 @@ const invalidEl = document.querySelector("#shared-invalid");
|
||||
* literal is only the no-config fallback. */
|
||||
const brand = () => window.BOR_BRAND || "Brain of Reese";
|
||||
|
||||
/* Phase 182 (task 07): the shared page's verbatim link target — the plain
|
||||
* `/document.html?source=…&path=…` href, with NO modal hook (this page has
|
||||
* no document modal, and the documents API is admin-gated: a guest who
|
||||
* follows the link lands on the sign-in page, which is the honest answer to
|
||||
* "show me that file"). verbatim.js builds this same href by default; naming
|
||||
* it here is the parity statement — the SAME panel client, the SAME three
|
||||
* globals the chat page calls, so a shared turn renders identically to the
|
||||
* owner's (the phase's hard requirement). */
|
||||
const SHARED_VERBATIM_OPTS = {
|
||||
documentHref: (
|
||||
source,
|
||||
path
|
||||
) =>
|
||||
"/document.html?source=" +
|
||||
encodeURIComponent(source) +
|
||||
"&path=" +
|
||||
encodeURIComponent(path),
|
||||
};
|
||||
|
||||
/* ---------- the token (from the URL) ----------
|
||||
* The last path segment of /shared/<token>. A malformed or missing
|
||||
* token (a path without a final segment, or a non-uuid segment) →
|
||||
@@ -608,9 +627,13 @@ function addAnswerImage(wrap, ref) {
|
||||
* the deflection treatment + the plain-text "Maybe try" chips, the
|
||||
* 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
|
||||
* stopped note. NO interactive markup is ever created HERE — no
|
||||
* buttons, no forms, no links, no click handlers (owner-locked:
|
||||
* zero controls). Markdown goes
|
||||
* zero controls). The ONE exception is phase 182's verbatim panel, and it
|
||||
* is not built HERE either: hydrateVerbatim / appendUnlocatedVerbatim /
|
||||
* enhanceAnswerCodeBlocks (assets/verbatim.js, the classic-script client the
|
||||
* chat page calls too) own that Copy button and that plain viewer link —
|
||||
* parity first, so a guest reads the owner's answer. Markdown goes
|
||||
* through the global escape-first renderMarkdown (markdown.js): the
|
||||
* stored payloads are raw text, so the renderer's XSS safety applies
|
||||
* unchanged. */
|
||||
@@ -628,7 +651,12 @@ function renderSharedMessage(m) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const wrap = addSharedMessage("brain", renderMarkdown(m.text));
|
||||
// Phase 182 (task 07): the shared page's answer renderer OPTS IN the
|
||||
// verbatim pass — the same opt-in as the chat page's restore, so a stored
|
||||
// ` ```bor ` fence paints the same placeholder and hydrates from the
|
||||
// record's `answer_text` below. The USER bubble above stays one-argument
|
||||
// (a question is never a locator).
|
||||
const wrap = addSharedMessage("brain", renderMarkdown(m.text, { verbatim: true }));
|
||||
// Phase 158 (task 03, D4): a record WITH `segments` renders the
|
||||
// per-run timeline through addSegments (collapsed — the parity
|
||||
// copy of the chat page's restoreTimeline); the runs carry the
|
||||
@@ -654,8 +682,24 @@ function renderSharedMessage(m) {
|
||||
// (below the text, above the chip row). A record without the key
|
||||
// renders byte-identically (no figure DOM).
|
||||
addAnswerImage(wrap, m.answer_image);
|
||||
// Phase 182 (task 07): the verbatim panels render with the bubble in the
|
||||
// SAME order as the chat page's restore — hydrate the placeholders inside
|
||||
// the bubble, append the blocks the answer never located, then the source
|
||||
// chips (below the text, above the clusters). The panel DOM, the Copy
|
||||
// button and the unresolved sentence all come from verbatim.js — the ONE
|
||||
// implementation shared with the chat page (parity, not a second copy);
|
||||
// this file still builds no control of its own.
|
||||
hydrateVerbatim(
|
||||
wrap.querySelector(".bubble"),
|
||||
m.answer_text || [],
|
||||
SHARED_VERBATIM_OPTS
|
||||
);
|
||||
appendUnlocatedVerbatim(wrap, m.answer_text || [], SHARED_VERBATIM_OPTS);
|
||||
addSources(wrap, m.sources);
|
||||
if (m.stopped) addStoppedNote(wrap);
|
||||
// The settled answer's ordinary code blocks get the same Copy button the
|
||||
// chat page gives them (no badge, no caption — locked A5).
|
||||
enhanceAnswerCodeBlocks(wrap);
|
||||
}
|
||||
|
||||
/* ---------- the public read ----------
|
||||
|
||||
@@ -701,6 +701,289 @@ body::before {
|
||||
.doc-md h5, .msg .bubble h5, .thinking-text h5 { margin: 0.8rem 0 0.3rem; font-size: 0.95rem; }
|
||||
.doc-md h6, .msg .bubble h6, .thinking-text h6 { margin: 0.7rem 0 0.3rem; font-size: 0.88rem; }
|
||||
|
||||
/* ---------- Verbatim panels (phase 182, task 06) — BEGIN ----------
|
||||
A ` ```bor <source/path> <range> ` locator fence (markdown.js step 0) and
|
||||
its hydrated panel (verbatim.js, task 07): the answer shows the EXACT
|
||||
bytes of an indexed file, typed by the SERVER, with a caption whose
|
||||
numbers are the server's resolved extent, a VERBATIM badge, a line-number
|
||||
gutter and a Copy button whose clipboard content equals those bytes.
|
||||
The phase's UI contract (PLAN §7.1 + AGENTS rule 5): an answer's code
|
||||
block is EITHER verbatim-and-badged OR plainly the model's — never a
|
||||
silently-substituted hybrid, which is why `.verbatim-badge` belongs to a
|
||||
resolved panel alone and `.code-block` (the plain wrapper task 07 adds
|
||||
around an ordinary `<pre>`) carries the Copy button and no badge.
|
||||
|
||||
PHASE-08 TOKENS ONLY — no hex/rgb/hsl literal anywhere in this block (the
|
||||
phase-92 monochrome invariant, and the :root-drift test parses the
|
||||
built-ins out of this file, so a literal would be a second source of
|
||||
truth). A monochrome or inverted theme keeps every state readable because
|
||||
the states are carried by TEXT and STRUCTURE, never by colour alone
|
||||
(WCAG 1.4.1): `Copy` → `Copied` is a text change with the --ok family as
|
||||
support, the badge is the WORD "VERBATIM", the unresolved state is a
|
||||
sentence, and the line gutter is digits. Contrast of the pairs used here
|
||||
(built-in palette, all AA ≥4.5:1):
|
||||
--ink on --surface 15.3:1 (the code text)
|
||||
--ink-soft on --surface 8.2:1 (caption, gutter, pending state, button label)
|
||||
--ok-ink on --ok-bg 10.6:1 (the badge, the copied state's fill)
|
||||
--ok-ink on --surface 12.2:1 (the copied state's label on hover-off)
|
||||
--ink-soft on --err-bg 7.9:1 (the unresolved sentence)
|
||||
A code surface is also readable in a LIGHT theme for the same reason: no
|
||||
#fff/#000, no new hue — every token comes from the theme's own palette.
|
||||
|
||||
Static content: NOTHING in this block animates or transitions, so
|
||||
prefers-reduced-motion has nothing to still (the pending state is a
|
||||
`role="status"` live region — the announcement IS the feedback, a pulse
|
||||
would add nothing but motion). */
|
||||
.verbatim {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
margin: 0.5rem 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
/* The head row is the panel's sticky header — PLAN §7.1's bounded-panel
|
||||
rule (the .table-wrap table thead th idiom, phase 156): it stays put
|
||||
while the body scrolls, so the caption (source/path · lines 12–40 of 210)
|
||||
is never off-screen in a 40-line slice. OPAQUE --surface background, so
|
||||
scrolled bytes never show through it. */
|
||||
.verbatim-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem 0.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
/* The clipped label keeps the phase-156 `.cell-clip` ellipsis convention
|
||||
(block/flex item that shrinks below its text width, hard cut → "…"); the
|
||||
FULL text stays reachable through the control's aria-label / title, so
|
||||
the clip is visual only. */
|
||||
.verbatim-caption {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-soft); /* 8.2:1 on --surface */
|
||||
}
|
||||
/* The badge is a WORD, never an icon alone (locked A5: the VERBATIM claim
|
||||
must survive a screen reader and a monochrome theme), and
|
||||
`text-transform: none` keeps the lowercase letters the clipboard/AT
|
||||
would otherwise have to reconcile (no uppercase surprises spelled out
|
||||
letter-by-letter). --ok-ink on --ok-bg is the app's existing AA pair. */
|
||||
.verbatim-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--ok-bg);
|
||||
color: var(--ok-ink); /* 10.6:1 on --ok-bg */
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
text-transform: none;
|
||||
}
|
||||
/* The bounded scroll panel (PLAN §7.1: a bounded panel, never a page-long
|
||||
paste). CODE scrolls sideways and never wraps (phase 116 locked A1 —
|
||||
wrapping breaks code readability), so overflow-wrap is pinned back to
|
||||
normal against the .bubble's `overflow-wrap: anywhere` (the phase-165
|
||||
prose/code split: prose wraps, code scrolls). min-width: 0 keeps the
|
||||
long-line escape hatch that every other scroller in the app carries. */
|
||||
.verbatim-body {
|
||||
max-height: min(40rem, 70vh);
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--surface);
|
||||
color: var(--ink); /* 15.3:1 on --surface */
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
tab-size: 2;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
/* No scrollbar-color here ON PURPOSE: phase 116 scoped the themed
|
||||
scrollbar to `.doc-modal .doc-raw` alone (pinned by
|
||||
tests/unit/test_prose_word_wrap.py — "no global scrollbar restyle"), and
|
||||
the chat's other scrollers (.bubble pre, .md-table-wrap) keep the
|
||||
browser-native bar. The verbatim panel joins THOSE, not the modal. */
|
||||
/* The gutter: the SERVER's line numbers (the phase-131 coordinate system —
|
||||
the same 1-based lines `read` and `grep` report), never the model's
|
||||
claim. `user-select: none` is the load-bearing rule: a reader who
|
||||
selects the text — or hits Copy on a panel whose selection the browser
|
||||
picks up — never drags line numbers into the clipboard. The digits are
|
||||
rendered as list-item TEXT (list-style is off) so the rule behaves the
|
||||
same in every engine, tabular-nums keeps the column straight, and the
|
||||
gutter sticks to the left edge while the code scrolls under it. */
|
||||
.verbatim-lines {
|
||||
list-style: none;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
flex: 0 0 auto;
|
||||
min-width: 2.5rem;
|
||||
background: var(--surface); /* opaque: scrolled code never shows THROUGH
|
||||
the gutter (the .table-wrap thead rule) */
|
||||
margin: 0;
|
||||
padding: 0 0.6rem 0 0;
|
||||
text-align: right;
|
||||
color: var(--ink-soft); /* 8.2:1 on --surface — a gutter is still text */
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.verbatim-lines li {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
/* Neutralize the .bubble pre treatment INSIDE the panel: the panel is its
|
||||
own surface (its own padding, hairline, scroll), so a <pre> child must
|
||||
not stack a second border/padding on top of it. */
|
||||
.verbatim pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
/* The streaming/pending state (markdown.js step 0 → the restore step) and
|
||||
the settled-but-unresolvable state. The pending look is MUTED on purpose
|
||||
— it is not an answer yet — and it never paints the model's bytes. */
|
||||
.verbatim-pending {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.15rem 0.6rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: var(--surface);
|
||||
}
|
||||
.verbatim-state {
|
||||
min-width: 0;
|
||||
color: var(--ink-soft); /* 8.2:1 on --surface */
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
/* The honest failure state (locked A7): a locator the server could not
|
||||
resolve says so in a sentence, carries NO badge and NO body, and shows
|
||||
none of the model's text. --err-line is the app's existing UI boundary
|
||||
token (decorative, 4.6:1 — the sentence, not the colour, carries the
|
||||
meaning); the badge rule below is CSS-side enforcement of the claim that
|
||||
an unresolved panel never badges, whatever DOM reaches it. */
|
||||
.verbatim-unresolved {
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: var(--err-bg);
|
||||
border-color: var(--err-line);
|
||||
color: var(--ink-soft); /* 7.9:1 on --err-bg */
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.verbatim-unresolved .verbatim-badge { display: none; }
|
||||
/* The two Copy controls — `.verbatim-copy` inside a resolved panel's head,
|
||||
`.code-block-copy` under an ordinary answer code block (locked A5: every
|
||||
fenced block gets a Copy button, only a resolved one gets the badge).
|
||||
The house pill family (mono text, --line hairline, --surface fill,
|
||||
ink-soft label 8.2:1); the label is the designated clip target
|
||||
(text-overflow: ellipsis, the phase-156 convention) while the control's
|
||||
aria-label names the full path and line count. `min-height: 2rem` is the
|
||||
desktop pill; the ≤640px block below lifts both to the 44px touch floor,
|
||||
and the global :focus-visible ring is restated with the SAME values
|
||||
(explicit here because the panel clips with overflow: hidden). */
|
||||
.code-block {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
.code-block pre { margin: 0; }
|
||||
.code-block-copy,
|
||||
.verbatim-copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 2rem;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--ink-soft); /* 8.2:1 on --surface */
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.code-block-copy > span,
|
||||
.verbatim-copy > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.code-block-copy:hover,
|
||||
.verbatim-copy:hover { color: var(--ink); border-color: var(--ink-soft); }
|
||||
.code-block-copy:focus-visible,
|
||||
.verbatim-copy:focus-visible {
|
||||
outline: 3px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* The `.copied` state: the TEXT changes ("Copy" → "Copied", task 07 owns
|
||||
the swap) and this rule SUPPORTS it with the --ok family — colour is
|
||||
never the only signal (WCAG 1.4.1), so a monochrome theme still reads
|
||||
"Copied". */
|
||||
.code-block-copy.copied,
|
||||
.verbatim-copy.copied {
|
||||
color: var(--ok-ink); /* 10.0:1 on --surface */
|
||||
border-color: var(--ok-ink);
|
||||
background: var(--ok-bg);
|
||||
}
|
||||
/* A resolved panel's Copy pill sits at the END of the head row (the caption
|
||||
already took the flexible space; the auto margin keeps it right even
|
||||
when the caption clips). */
|
||||
.verbatim-copy { margin-left: auto; flex: 0 0 auto; }
|
||||
.code-block-copy { margin: 0.25rem 0 0; }
|
||||
/* What task 07's REAL DOM needs on top of task 06's contract — palette
|
||||
variables only, no new literal, nothing animated. The body is a ROW of
|
||||
(gutter, text): the gutter's `flex: 0 0 auto` + `position: sticky;
|
||||
left: 0` above are written for exactly this, and the text takes its own
|
||||
width so a long line scrolls UNDER the sticky gutter instead of wrapping
|
||||
(phase 116 locked A1). */
|
||||
.verbatim-body { display: flex; align-items: flex-start; }
|
||||
.verbatim-text { flex: 0 0 auto; }
|
||||
/* The panel's link to the document viewer (the chat page opens the modal,
|
||||
the shared page navigates — one affordance, one look). It is a TEXT link
|
||||
(underlined, not colour-only — WCAG 1.4.1) in the mono family of the
|
||||
caption it sits beside, and it restates the app-wide focus ring with the
|
||||
SAME values because the panel clips with `overflow: hidden`. */
|
||||
.verbatim-open {
|
||||
flex: 0 0 auto;
|
||||
color: var(--ink-soft); /* 8.2:1 on --surface */
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.verbatim-open:hover { color: var(--ink); }
|
||||
.verbatim-open:focus-visible {
|
||||
outline: 3px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* The truncation line of a capped block: one muted sentence naming that the
|
||||
rest lives in the document, plus the same link. It sits OUTSIDE the scroll
|
||||
body, so it stays readable without scrolling the bytes. */
|
||||
.verbatim-more {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
color: var(--ink-soft); /* 8.2:1 on --surface */
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
/* ---------- Verbatim panels (phase 182, task 06) — END ---------- */
|
||||
|
||||
.msg.user { justify-content: flex-end; }
|
||||
.msg.user .msg-body { align-items: flex-end; }
|
||||
.msg.user .bubble {
|
||||
@@ -6672,6 +6955,17 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
flex-shrink: 0 would keep each at 100%); the "Page N of M"
|
||||
status sits between them (the 0.75rem gap goes vertical). */
|
||||
.history-pager { flex-direction: column; align-items: center; }
|
||||
/* Phase 182 (task 06): the two Copy controls of a verbatim panel and of
|
||||
an ordinary answer code block reach the 44px touch floor on a phone
|
||||
(the desktop pill is 32px tall — fine for a pointer, too small a tap
|
||||
target for a thumb). The pill itself grows; its label keeps the
|
||||
ellipsis escape, so a long "Copy 210 lines from …" never widens the
|
||||
panel. */
|
||||
.code-block-copy, .verbatim-copy { min-height: 44px; }
|
||||
/* Phase 182 (task 07): the panel's viewer link is a control too — it
|
||||
reaches the same touch floor (inline-flex so the line box grows without
|
||||
moving the baseline of the caption row it sits in). */
|
||||
.verbatim-open { min-height: 44px; display: inline-flex; align-items: center; }
|
||||
}
|
||||
|
||||
/* Phase 46: prefers-reduced-motion stills the mobile menu — no
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
/* Brain of Reese — the phase-182 VERBATIM panel client (task 07).
|
||||
*
|
||||
* A ` ```bor <source/path>#12-40 ` locator fence is a CLAIM about a file,
|
||||
* never its contents: `markdown.js` (task 06) drops the model's body and
|
||||
* paints a `.verbatim.verbatim-pending` placeholder keyed by the canonical
|
||||
* locator, and the SERVER (`app.rag.verbatim.resolve_answer_blocks`,
|
||||
* task 05) puts the real bytes on the `done` frame as `answer_text`. This
|
||||
* file is the other half of that handshake: it RESOLVES the placeholders
|
||||
* against the frame's blocks (`hydrateVerbatim`), places the blocks the
|
||||
* answer never located (`appendUnlocatedVerbatim`), settles leftovers when
|
||||
* a turn ends without a `done` (`settleVerbatimPlaceholders`), gives every
|
||||
* settled answer code block a Copy button (`enhanceAnswerCodeBlocks`) and
|
||||
* owns the ONE clipboard path (`copyTextWithFallback`).
|
||||
*
|
||||
* WHY IT IS A CLASSIC SCRIPT, NOT A MODULE (the `markdown.js` pattern):
|
||||
* `index.html` and `shared.html` load it as a plain `<script>` right after
|
||||
* `markdown.js` and before their page modules, so the top-level function
|
||||
* declarations below are globals both module pages can call — the ONE
|
||||
* implementation shared by the live turn, the reload restore and the
|
||||
* anonymous shared page (parity is the phase's hard requirement). The
|
||||
* Containerfile minifies it WITHOUT `--bundle` for the same reason.
|
||||
*
|
||||
* NO LOCATOR GRAMMAR LIVES HERE (the phase's main drift risk): the canonical
|
||||
* string is produced in exactly ONE client place,
|
||||
* `markdown.js::borLocator` (the mirror of Python
|
||||
* `app.rag.verbatim.canonical_locator`). Hydration reads the already
|
||||
* normalized `data-bor-locator` attribute and compares it to the server's
|
||||
* `locator` with a plain string `===`. Nothing here re-parses a range,
|
||||
* canonicalizes an en dash, or normalizes a path.
|
||||
*
|
||||
* THE CONTRACT WITH THE SERVER (LOCKED A2/A4/A7):
|
||||
* • `block.text` is painted with `textContent` and copied with
|
||||
* `navigator.clipboard` VERBATIM — no trim, no re-wrap, no join, no
|
||||
* model byte ever substituted in, and the line numbers never enter the
|
||||
* clipboard (they live in the `user-select: none` gutter, a separate
|
||||
* node the copy path never reads);
|
||||
* • the caption's numbers are the SERVER's resolved extent, so a locator
|
||||
* whose range the model got wrong shows what the server actually read
|
||||
* (A2 made visible);
|
||||
* • a locator the server could not resolve says so — the unresolved node
|
||||
* carries NO badge, NO body and NONE of the model's bytes.
|
||||
*
|
||||
* The house frontend rules hold throughout: every node is built with
|
||||
* `createElement` + `textContent` (no HTML string anywhere — the data here
|
||||
* is model-written text and indexed file bytes), every handler is attached
|
||||
* with `addEventListener` (the CSP is `default-src 'self'`: no inline
|
||||
* handlers, no `onclick=`/`oncopy=` attribute), and the states are carried
|
||||
* by TEXT and STRUCTURE, never by colour alone (WCAG 1.4.1).
|
||||
*/
|
||||
|
||||
/* The one honest sentence for a locator nothing resolved (the pinned copy —
|
||||
* the whole unresolved state is this sentence plus the document link, so a
|
||||
* screen reader, a monochrome theme and a screenshot all agree on it). */
|
||||
function verbatimUnresolvedCopy() {
|
||||
return "I could not verify this against the knowledge base";
|
||||
}
|
||||
|
||||
/* The badge's claim, spelled out for the pointer tooltip — the WORD is the
|
||||
* signal (locked A5), the tooltip only explains it. */
|
||||
function verbatimBadgeTitle() {
|
||||
return "These bytes are copied from the knowledge base, not written by the model.";
|
||||
}
|
||||
|
||||
/* ---------- the small guards (a malformed frame never throws) ----------
|
||||
* `answer_text` crosses the wire, so every field is treated as untrusted
|
||||
* shape: a block that is not a plain object with the three fields the panel
|
||||
* cannot live without is DROPPED, exactly like the restore path drops a
|
||||
* malformed timeline entry. */
|
||||
|
||||
function isVerbatimBlock(block) {
|
||||
return !!(
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
typeof block.source === "string" &&
|
||||
block.source &&
|
||||
typeof block.path === "string" &&
|
||||
block.path &&
|
||||
typeof block.text === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function verbatimBlocks(blocks) {
|
||||
return Array.isArray(blocks) ? blocks.filter(isVerbatimBlock) : [];
|
||||
}
|
||||
|
||||
/* A positive line count, or null (a missing/garbage count never prints). */
|
||||
function verbatimCount(value) {
|
||||
return Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
/* The one `source/path` identity the whole app uses (the read/grep
|
||||
* coordinate). NO normalization here — see the header: markdown.js owns the
|
||||
* grammar, this only joins the two halves the server already resolved. */
|
||||
function verbatimIdentity(source, path) {
|
||||
return `${source}/${path}`;
|
||||
}
|
||||
|
||||
/* Split a canonical locator into its identity half and its range suffix.
|
||||
* The split is on the `#` the server PUT THERE (canonical_locator's own
|
||||
* separator) — never a re-parse of the range. */
|
||||
function verbatimSplitLocator(locator) {
|
||||
const text = typeof locator === "string" ? locator : "";
|
||||
const hash = text.indexOf("#");
|
||||
return hash === -1
|
||||
? { identity: text, hasRange: false }
|
||||
: { identity: text.slice(0, hash), hasRange: true };
|
||||
}
|
||||
|
||||
/* The plain viewer href (the shared page's whole affordance — that page has
|
||||
* no modal, and the documents API is admin-gated so a guest lands on the
|
||||
* sign-in page by design). app.js OVERRIDES this through
|
||||
* `opts.documentHref` with `documentUrl(…, "/")`, the chip's own
|
||||
* back-to-the-chat URL (phase 153). A rooted path built from
|
||||
* encodeURIComponent — never a `javascript:`/`data:` target. */
|
||||
function verbatimDocumentHref(source, path) {
|
||||
return (
|
||||
"/document.html?source=" +
|
||||
encodeURIComponent(source) +
|
||||
"&path=" +
|
||||
encodeURIComponent(path)
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- the panel's text pieces ---------- */
|
||||
|
||||
/* `homelab/a.md · lines 12–40 of 210`, or `homelab/a.md · all 210 lines`
|
||||
* when the block IS the whole document (the caption is the SERVER's extent:
|
||||
* `start_line`/`end_line` describe exactly the lines `text` contains after
|
||||
* any character cap, so a capped block reads as a range, never as "all"). */
|
||||
function verbatimCaptionText(block) {
|
||||
const identity = verbatimIdentity(block.source, block.path);
|
||||
const start = verbatimCount(block.start_line);
|
||||
const end = verbatimCount(block.end_line);
|
||||
const total = verbatimCount(block.total_lines);
|
||||
if (start === null || end === null || end < start) return identity;
|
||||
if (!block.truncated && start === 1 && total !== null && end === total) {
|
||||
return `${identity} · all ${total} lines`;
|
||||
}
|
||||
return total === null
|
||||
? `${identity} · lines ${start}–${end}`
|
||||
: `${identity} · lines ${start}–${end} of ${total}`;
|
||||
}
|
||||
|
||||
/* `Verbatim text from homelab/a.md, lines 12–40` (the range clause only when
|
||||
* the panel shows a range — a whole-document panel is named by its file). */
|
||||
function verbatimPanelLabel(block) {
|
||||
const identity = verbatimIdentity(block.source, block.path);
|
||||
const start = verbatimCount(block.start_line);
|
||||
const end = verbatimCount(block.end_line);
|
||||
if (start === null || end === null || end < start) {
|
||||
return `Verbatim text from ${identity}`;
|
||||
}
|
||||
if (start === end) return `Verbatim text from ${identity}, line ${start}`;
|
||||
return `Verbatim text from ${identity}, lines ${start}–${end}`;
|
||||
}
|
||||
|
||||
/* The lines the gutter numbers: one entry per LINE OF THE SERVER TEXT, each
|
||||
* `{ shown, number }`. A truncated block's last line is the server's
|
||||
* `[…truncated…]` marker — a real line of the panel, but NOT a line of the
|
||||
* file, so it gets no number (an empty cell keeps the rows aligned; the
|
||||
* phase's rule is that the gutter never claims a coordinate the file does
|
||||
* not have). */
|
||||
function verbatimGutter(block) {
|
||||
const lines = String(block.text).split("\n");
|
||||
const start = verbatimCount(block.start_line);
|
||||
const end = verbatimCount(block.end_line);
|
||||
const numbered = start !== null && end !== null && end >= start ? end - start + 1 : 0;
|
||||
return lines.map((_, i) => ({
|
||||
number: i < numbered ? String(start + i) : "\u00a0",
|
||||
}));
|
||||
}
|
||||
|
||||
/* ---------- the controls ---------- */
|
||||
|
||||
/* The select-on-focus of the clipboard fallback field (an `<a>` has no
|
||||
* `.select()`, a range does the job). Best-effort: a selection failure only
|
||||
* means the reader copies by hand — the field still shows the whole text.
|
||||
* (app.js's `copyShareLinkWithFallback` used to own this; task 07 moved the
|
||||
* pair into verbatim.js so the code blocks and the share link share ONE
|
||||
* degradation.) */
|
||||
function selectAllInFallbackField(el) {
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} catch {
|
||||
/* selection is best-effort — the field still carries the full text */
|
||||
}
|
||||
}
|
||||
|
||||
/* The phase-55 degradation, generalized (LOCKED: the owner-locked share
|
||||
* strings and behaviour are unchanged — app.js delegates here).
|
||||
* `navigator.clipboard` is unavailable on a non-secure (http) homelab origin
|
||||
* and rejects outside a user gesture, so the failure path renders ONE
|
||||
* transient link field inside `hostEl` that selects its text on focus
|
||||
* (click or Tab, then Ctrl/Cmd+C). Returns true when the clipboard took it.
|
||||
* NEVER reads the DOM — the caller hands over the exact string, which is
|
||||
* what makes "the clipboard content equals the server text" true. */
|
||||
async function copyTextWithFallback(text, hostEl, title) {
|
||||
const host = hostEl || document.body;
|
||||
document.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
const field = document.createElement("a");
|
||||
field.className = "share-link-fallback";
|
||||
// An <a> needs an href to focus; a code block has none, so it takes the
|
||||
// tabindex instead and stays a select-to-copy field either way.
|
||||
if (/^(?:https?:|mailto:|\/)/i.test(text)) field.href = text;
|
||||
else field.tabIndex = 0;
|
||||
field.textContent = text; // the text is data — textContent, never innerHTML
|
||||
field.title = title || "Click, then copy (Ctrl/Cmd+C)";
|
||||
field.addEventListener("focus", () => selectAllInFallbackField(field));
|
||||
host.appendChild(field); // beside the control that failed, never elsewhere
|
||||
field.focus({ preventScroll: true });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* One pill: `Copy` → `Copied` (the TEXT carries the state; `.copied` only
|
||||
* supports it — WCAG 1.4.1). The label rides a `<span>` because the clipped
|
||||
* label is the CSS ellipsis target (the phase-156 rule) while the control's
|
||||
* `aria-label` names the full path. Sticky by design: no timer, so a reader
|
||||
* who tabs away and back finds the state the click left. */
|
||||
function buildCopyButton(className, text, ariaLabel, hostEl) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = className;
|
||||
btn.setAttribute("aria-label", ariaLabel);
|
||||
const label = document.createElement("span");
|
||||
label.textContent = "Copy";
|
||||
btn.appendChild(label);
|
||||
btn.addEventListener("click", () => {
|
||||
copyTextWithFallback(text, hostEl || btn.parentElement, ariaLabel).then((ok) => {
|
||||
if (!ok) return; // the field is the feedback (it is focused and selected)
|
||||
btn.classList.add("copied");
|
||||
label.textContent = "Copied";
|
||||
});
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
/* The panel's link to the document. `opts.onOpen(source, path, linkEl)` is
|
||||
* the chat page's modal hook (phase 26/153 — left-click opens the viewer IN
|
||||
* the page, the href stays the `/document.html` escape hatch for
|
||||
* middle-click and copy-link); with no hook the href simply navigates,
|
||||
* which is the shared page's whole affordance. */
|
||||
function buildVerbatimOpenLink(source, path, opts, text) {
|
||||
const link = document.createElement("a");
|
||||
link.className = "verbatim-open";
|
||||
const identity = verbatimIdentity(source, path);
|
||||
const href =
|
||||
opts && typeof opts.documentHref === "function"
|
||||
? opts.documentHref(source, path)
|
||||
: verbatimDocumentHref(source, path);
|
||||
link.href = href;
|
||||
link.textContent = text || identity;
|
||||
link.title = identity; // the full path as the native tooltip (chip pattern)
|
||||
if (opts && typeof opts.onOpen === "function") {
|
||||
link.addEventListener("click", (event) => {
|
||||
event.preventDefault(); // no new tab — the modal takes over
|
||||
event.stopPropagation();
|
||||
opts.onOpen(source, path, link);
|
||||
});
|
||||
}
|
||||
return link;
|
||||
}
|
||||
|
||||
/* ---------- the panel ---------- */
|
||||
|
||||
/* buildVerbatimPanel(block, opts) → the `.verbatim` node.
|
||||
*
|
||||
* .verbatim[role=group][aria-label][data-bor-locator]
|
||||
* .verbatim-head sticky (PLAN §7.1)
|
||||
* .verbatim-caption source/path · lines 12–40 of 210 (clipped)
|
||||
* .verbatim-badge VERBATIM (a WORD)
|
||||
* .verbatim-open → the document viewer (optional)
|
||||
* .verbatim-copy Copy → Copied (aria-label
|
||||
* names path)
|
||||
* .verbatim-body the bounded scroll panel
|
||||
* ol.verbatim-lines the SERVER's line numbers (user-select: none)
|
||||
* pre.verbatim-text block.text, byte for byte
|
||||
* .verbatim-more the truncation line + the link (optional)
|
||||
*
|
||||
* `opts = { documentHref, onOpen }` — both optional (see the two builders
|
||||
* above). Every value reaches the DOM through `textContent`/`title`/a
|
||||
* built `href`: the caption carries indexed text and the aria-labels carry
|
||||
* model-written locators, so there is no HTML string to escape. */
|
||||
function buildVerbatimPanel(block, opts) {
|
||||
const identity = verbatimIdentity(block.source, block.path);
|
||||
const root = document.createElement("div");
|
||||
root.className = "verbatim";
|
||||
root.setAttribute("role", "group");
|
||||
root.setAttribute("aria-label", verbatimPanelLabel(block));
|
||||
// The SERVER's canonical locator is the identity of the placed block (the
|
||||
// pending placeholder's own attribute is replaced with it — that is how
|
||||
// `appendUnlocatedVerbatim` knows this block already has a home).
|
||||
root.dataset.borLocator =
|
||||
typeof block.locator === "string" && block.locator
|
||||
? block.locator
|
||||
: identity;
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "verbatim-head";
|
||||
const caption = document.createElement("span");
|
||||
caption.className = "verbatim-caption";
|
||||
caption.textContent = verbatimCaptionText(block);
|
||||
caption.title = verbatimCaptionText(block); // the clipped text stays reachable
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "verbatim-badge";
|
||||
badge.textContent = "VERBATIM";
|
||||
badge.title = verbatimBadgeTitle();
|
||||
head.append(caption, badge);
|
||||
head.appendChild(buildVerbatimOpenLink(block.source, block.path, opts));
|
||||
|
||||
const gutter = verbatimGutter(block);
|
||||
// The clipboard's line count (the aria-label's N): the SERVER's extent,
|
||||
// not the marker line the cap appended.
|
||||
const from = verbatimCount(block.start_line);
|
||||
const to = verbatimCount(block.end_line);
|
||||
const shown = from !== null && to !== null && to >= from ? to - from + 1 : gutter.length;
|
||||
head.appendChild(
|
||||
buildCopyButton(
|
||||
"verbatim-copy",
|
||||
block.text,
|
||||
`Copy ${shown} lines from ${identity}`,
|
||||
root
|
||||
)
|
||||
);
|
||||
root.appendChild(head);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "verbatim-body";
|
||||
const numbers = document.createElement("ol");
|
||||
numbers.className = "verbatim-lines";
|
||||
numbers.setAttribute("aria-hidden", "true"); // the range rides the panel's label
|
||||
for (const row of gutter) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = row.number;
|
||||
numbers.appendChild(li);
|
||||
}
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "verbatim-text";
|
||||
pre.textContent = block.text; // THE POINT OF THE PHASE: the server's bytes
|
||||
body.append(numbers, pre);
|
||||
root.appendChild(body);
|
||||
|
||||
if (block.truncated) {
|
||||
// One muted line naming where the rest lives, plus the same viewer link
|
||||
// — the panel never pretends the slice is the file.
|
||||
const more = document.createElement("div");
|
||||
more.className = "verbatim-more";
|
||||
const note = document.createElement("span");
|
||||
note.textContent = "Cut short by the size limit — the rest is in the document. ";
|
||||
more.appendChild(note);
|
||||
more.appendChild(
|
||||
buildVerbatimOpenLink(block.source, block.path, opts, `open ${identity}`)
|
||||
);
|
||||
root.appendChild(more);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/* The honest state for a locator the server did not resolve (and for a
|
||||
* placeholder no terminal frame ever settled): the sentence, plus the
|
||||
* document link ONLY when the identity actually parsed into a
|
||||
* `source/path`. NO badge, NO body, NONE of the model's bytes — the fence's
|
||||
* own text is already gone (task 06 dropped it), so there is nothing here
|
||||
* to leak even by accident. */
|
||||
function buildVerbatimUnresolved(locator, opts) {
|
||||
const root = document.createElement("div");
|
||||
root.className = "verbatim verbatim-unresolved";
|
||||
root.setAttribute("role", "note");
|
||||
const key = typeof locator === "string" ? locator : "";
|
||||
if (key) root.dataset.borLocator = key;
|
||||
root.dataset.verbatimState = (opts && opts.kind) || "unverified";
|
||||
const state = document.createElement("span");
|
||||
state.className = "verbatim-state";
|
||||
state.textContent = verbatimUnresolvedCopy();
|
||||
root.appendChild(state);
|
||||
const { identity } = verbatimSplitLocator(key);
|
||||
const slash = identity.indexOf("/");
|
||||
if (slash > 0 && slash < identity.length - 1) {
|
||||
root.appendChild(document.createTextNode(" — open "));
|
||||
root.appendChild(
|
||||
buildVerbatimOpenLink(identity.slice(0, slash), identity.slice(slash + 1), opts)
|
||||
);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/* ---------- hydration ---------- */
|
||||
|
||||
/* The ONE match rule (LOCKED A2 made visible):
|
||||
* 1. the canonical locator, compared as PLAIN STRINGS against the
|
||||
* server's `locator` — the attribute markdown.js already normalized;
|
||||
* 2. failing that, exactly ONE block sharing the same `source/path`
|
||||
* (the model's RANGE was wrong; the panel shows the server's extent);
|
||||
* 3. failing that, null → the unresolved state. An ambiguous identity
|
||||
* (two blocks, same file, different ranges) never guesses. */
|
||||
function matchVerbatimBlock(blocks, key) {
|
||||
for (const block of blocks) {
|
||||
if (block.locator === key) return block;
|
||||
}
|
||||
const identity = verbatimSplitLocator(key).identity;
|
||||
const samePath = blocks.filter(
|
||||
(block) => verbatimIdentity(block.source, block.path) === identity
|
||||
);
|
||||
return samePath.length === 1 ? samePath[0] : null;
|
||||
}
|
||||
|
||||
/* hydrateVerbatim(root, blocks, opts) — replace every PENDING placeholder
|
||||
* under `root` with its panel, or with the unresolved state.
|
||||
*
|
||||
* Only `.verbatim-pending` is touched, so the pass is idempotent: a panel
|
||||
* this function built (or one restored from storage) is skipped on a re-run,
|
||||
* which keeps a `Copied` state and a scrolled body alive. Never throws —
|
||||
* a missing/malformed `blocks` settles every placeholder to the honest
|
||||
* state instead. Returns the number of placeholders settled. */
|
||||
function hydrateVerbatim(root, blocks, opts) {
|
||||
if (!root || typeof root.querySelectorAll !== "function") return 0;
|
||||
const list = verbatimBlocks(blocks);
|
||||
let settled = 0;
|
||||
root.querySelectorAll(".verbatim-pending[data-bor-locator]").forEach((node) => {
|
||||
const key = node.getAttribute("data-bor-locator") || "";
|
||||
const block = matchVerbatimBlock(list, key);
|
||||
node.replaceWith(
|
||||
block ? buildVerbatimPanel(block, opts) : buildVerbatimUnresolved(key, opts)
|
||||
);
|
||||
settled += 1;
|
||||
});
|
||||
return settled;
|
||||
}
|
||||
|
||||
/* appendUnlocatedVerbatim(wrap, blocks, opts) — the blocks NO placeholder
|
||||
* matched (a `show_text` call whose answer never located the file), appended
|
||||
* in the frame's order at the `answer_image` position: below the answer
|
||||
* text, above the source clusters (the phase-124/130 DOM order — callers run
|
||||
* this BEFORE `appendSources`).
|
||||
*
|
||||
* State-free idempotence: a block is already placed when the wrap holds a
|
||||
* resolved panel carrying its canonical `locator`, so calling this twice
|
||||
* (or after a hydration that matched) appends nothing. */
|
||||
function appendUnlocatedVerbatim(wrap, blocks, opts) {
|
||||
if (!wrap || typeof wrap.querySelectorAll !== "function") return 0;
|
||||
const list = verbatimBlocks(blocks);
|
||||
if (!list.length) return 0;
|
||||
const host = wrap.querySelector(".msg-body") || wrap;
|
||||
const placed = new Set();
|
||||
wrap.querySelectorAll(".verbatim[data-bor-locator]").forEach((el) => {
|
||||
// An unresolved placeholder is NOT a placement (it names a locator the
|
||||
// server never returned, and the block belongs on screen anyway).
|
||||
if (el.classList.contains("verbatim-unresolved")) return;
|
||||
placed.add(el.getAttribute("data-bor-locator"));
|
||||
});
|
||||
let appended = 0;
|
||||
for (const block of list) {
|
||||
const key =
|
||||
typeof block.locator === "string" && block.locator
|
||||
? block.locator
|
||||
: verbatimIdentity(block.source, block.path);
|
||||
if (placed.has(key)) continue;
|
||||
host.appendChild(buildVerbatimPanel(block, opts));
|
||||
placed.add(key);
|
||||
appended += 1;
|
||||
}
|
||||
return appended;
|
||||
}
|
||||
|
||||
/* settleVerbatimPlaceholders(root, kind) — the terminal-frame sweep the
|
||||
* failure paths run (the `error` frame, the 300 s guard, the Stop action,
|
||||
* the failed-turn funnel): a panel NEVER sits at "Retrieving the exact
|
||||
* text…" once a turn is over. `kind` is recorded on the node
|
||||
* (`data-verbatim-state`) for the next reader; every kind renders the same
|
||||
* honest sentence, because "I could not verify this" is the truth for a
|
||||
* turn that ended before `done` too. */
|
||||
function settleVerbatimPlaceholders(root, kind) {
|
||||
if (!root || typeof root.querySelectorAll !== "function") return 0;
|
||||
let settled = 0;
|
||||
root.querySelectorAll(".verbatim-pending").forEach((node) => {
|
||||
node.replaceWith(
|
||||
buildVerbatimUnresolved(node.getAttribute("data-bor-locator") || "", { kind })
|
||||
);
|
||||
settled += 1;
|
||||
});
|
||||
return settled;
|
||||
}
|
||||
|
||||
/* ---------- the ordinary answer code block ---------- */
|
||||
|
||||
/* enhanceAnswerCodeBlocks(root) — every `<pre>` inside an answer bubble that
|
||||
* is NOT inside a `.verbatim` gets the `.code-block` wrapper and a Copy
|
||||
* button, ONCE the turn settled (the callers run it on the settled answer,
|
||||
* never mid-stream). NO badge, NO caption: a `VERBATIM` claim belongs only
|
||||
* to a server-resolved block (LOCKED A5) — plainly the model's, or verbatim
|
||||
* and badged, never a hybrid.
|
||||
*
|
||||
* Idempotent on re-render: a `<pre>` already inside a `.code-block` is left
|
||||
* alone (the live path re-runs the renderer per delta; the restore path runs
|
||||
* this once). Returns the number of blocks wrapped. */
|
||||
function enhanceAnswerCodeBlocks(root) {
|
||||
if (!root || typeof root.querySelectorAll !== "function") return 0;
|
||||
const bubbles = [];
|
||||
if (root.classList && root.classList.contains("bubble")) bubbles.push(root);
|
||||
root.querySelectorAll(".bubble").forEach((el) => bubbles.push(el));
|
||||
let wrapped = 0;
|
||||
for (const bubble of bubbles) {
|
||||
bubble.querySelectorAll("pre").forEach((pre) => {
|
||||
if (pre.closest(".verbatim")) return; // the panel owns its own Copy
|
||||
const parent = pre.parentElement;
|
||||
if (!parent || parent.classList.contains("code-block")) return;
|
||||
const box = document.createElement("div");
|
||||
box.className = "code-block";
|
||||
parent.insertBefore(box, pre);
|
||||
box.appendChild(pre);
|
||||
box.appendChild(
|
||||
buildCopyButton(
|
||||
"code-block-copy",
|
||||
pre.textContent || "",
|
||||
"Copy this code block",
|
||||
box
|
||||
)
|
||||
);
|
||||
wrapped += 1;
|
||||
});
|
||||
}
|
||||
return wrapped;
|
||||
}
|
||||
@@ -1695,6 +1695,12 @@
|
||||
identical no-op for the default name). -->
|
||||
<script src="assets/brand.js"></script>
|
||||
<script src="assets/markdown.js"></script>
|
||||
<!-- Phase 182 (task 07): the verbatim panel client — a CLASSIC script
|
||||
immediately AFTER markdown.js (it hydrates the placeholders that
|
||||
renderer emits, so it must be a global before app.js evaluates) and
|
||||
before the module scripts. No bundle, no import — same shape as
|
||||
markdown.js. -->
|
||||
<script src="assets/verbatim.js"></script>
|
||||
<!-- Phase 19: the shared header module loads through the page script's
|
||||
own `import "./header.js"` — a hoisted import that is evaluated
|
||||
before the page script body calls initSharedHeader() at boot. -->
|
||||
|
||||
+13
-1
@@ -187,7 +187,15 @@
|
||||
evaluated before the page script body calls initSharedHeader()
|
||||
at boot (no direct header.js <script> tag — single-evaluation
|
||||
design). NO modal overlay, NO composer, NO Save/Share/
|
||||
Retry/Tune markup anywhere (owner-locked: zero controls). -->
|
||||
Retry/Tune markup anywhere (owner-locked: zero controls).
|
||||
Phase 182 (task 07) is the ONE owner-directed exception, and it is
|
||||
narrow: a shared answer's verbatim panel and its ordinary code blocks
|
||||
carry a Copy button and a plain `/document.html?source=&path=` link,
|
||||
because a shared page that shows a slice of a file has to let the
|
||||
reader copy and check it. Both come from the SHARED panel client
|
||||
(verbatim.js — the same globals the chat page calls, so a shared turn
|
||||
renders pixel-identically); this page still builds no control of its
|
||||
own, wires no modal, and keeps every chip a plain span. -->
|
||||
<!-- ABSOLUTE asset paths on purpose: the page is served from the
|
||||
NESTED route /shared/<token> (not a root-level .html), so a
|
||||
relative "assets/…" ref would resolve to /shared/assets/… and
|
||||
@@ -196,6 +204,10 @@
|
||||
works with absolute refs too. -->
|
||||
<script src="/assets/brand.js"></script>
|
||||
<script src="/assets/markdown.js"></script>
|
||||
<!-- Phase 182 (task 07): the verbatim panel client, AFTER markdown.js
|
||||
(absolute path like its neighbours — the nested /shared/<token> route
|
||||
breaks a relative ref). -->
|
||||
<script src="/assets/verbatim.js"></script>
|
||||
<script type="module" src="/assets/shared.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -100,6 +100,19 @@ os.environ["BOR_IMAGE_DIR"] = str(_Settings.model_fields["image_dir"].default)
|
||||
# ``answer_image`` done-frame key absent) must see the code default.
|
||||
os.environ["BOR_ANSWER_IMAGES"] = str(_Settings.model_fields["answer_images"].default)
|
||||
|
||||
# Phase 182: the same leak class for the verbatim-display settings (the
|
||||
# dev environment runs the feature ON or tunes the budgets), and the
|
||||
# grounded tool-OFFER now depends on ``answer_text`` — the offer-matrix
|
||||
# pins (unit + integration) and the phase-124/182 byte-identity pins
|
||||
# must see the code defaults regardless of the operator's shell.
|
||||
os.environ["BOR_ANSWER_TEXT"] = str(_Settings.model_fields["answer_text"].default)
|
||||
os.environ["BOR_SHOW_TEXT_MAX_CHARS"] = str(
|
||||
_Settings.model_fields["show_text_max_chars"].default
|
||||
)
|
||||
os.environ["BOR_SHOW_TEXT_MAX_BLOCKS"] = str(
|
||||
_Settings.model_fields["show_text_max_blocks"].default
|
||||
)
|
||||
|
||||
# Phase 140: the same leak class for the pictorial-PDF toggle +
|
||||
# threshold — an operator's shell may carry BOR_PDF_PICTORIAL /
|
||||
# BOR_PDF_PICTORIAL_MIN_CHARS (the owner runs the feature on in
|
||||
|
||||
@@ -144,6 +144,18 @@ def app_server(mock_llm: int) -> Iterator[str]:
|
||||
# into the app under test (the phase-61 leak-guard pattern).
|
||||
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||
env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default)
|
||||
# Phase 182: the same leak guard for the verbatim-display settings —
|
||||
# the grounded tool OFFER now depends on ``answer_text``, so every
|
||||
# module's offer pins must see the CODE default unless the module's
|
||||
# own app opts in/out explicitly (a module overrides these in its
|
||||
# own ``env[...]`` assignment, which runs after this builder).
|
||||
env["BOR_ANSWER_TEXT"] = str(_Settings.model_fields["answer_text"].default)
|
||||
env["BOR_SHOW_TEXT_MAX_CHARS"] = str(
|
||||
_Settings.model_fields["show_text_max_chars"].default
|
||||
)
|
||||
env["BOR_SHOW_TEXT_MAX_BLOCKS"] = str(
|
||||
_Settings.model_fields["show_text_max_blocks"].default
|
||||
)
|
||||
env.setdefault("BOR_DATABASE_URL", "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese")
|
||||
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
|
||||
+402
-2
@@ -662,7 +662,10 @@ Implements just enough of the aipi surface:
|
||||
- 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
|
||||
system prompt carries the show_image clause's marker
|
||||
(:data:`_SHOW_IMAGE_CLAUSE_MARKER` — a sentence only the image
|
||||
clause carries, so the phase-182 ``show_text`` clause's pointer to
|
||||
``show_image`` never trips it; 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
|
||||
@@ -691,6 +694,62 @@ Implements just enough of the aipi surface:
|
||||
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 ``show me the exact text of``
|
||||
(``SHOW_TEXT_TRIGGER``, phase 182 task 08 — the verbatim-display suite
|
||||
``tests/e2e/test_verbatim_show_text.py``) **and** the system prompt
|
||||
carries the show_text clause's marker
|
||||
(:data:`_SHOW_TEXT_CLAUSE_MARKER` — a sentence only the TEXT clause
|
||||
carries, the exact mirror of ``_SHOW_IMAGE_CLAUSE_MARKER``, so a
|
||||
``BOR_ANSWER_TEXT=0`` app never fires it) -> the deterministic
|
||||
SHOW_TEXT flow, discriminated statelessly from the tool results
|
||||
(streaming only). The question carries the scripted target — a
|
||||
combined ``source/path`` with an optional ``12-40`` line range and an
|
||||
optional ``describe it without a block`` marker
|
||||
(``SHOW_TEXT_NO_FENCE_MARKER``):
|
||||
* request 1 (``tools`` offered, no tool result yet): stream ONLY
|
||||
``tool_calls`` deltas — ``show_text`` with ``{"path": <target>}``
|
||||
plus ``offset``/``limit`` when the question named a range
|
||||
(synthetic id ``call_show_text``); the target comes from the
|
||||
QUESTION (not the seed), which is what lets one trigger drive a
|
||||
text doc, an image doc (the LOCKED-A6 refusal) and an
|
||||
out-of-range request;
|
||||
* the follow-up request (any ``tool``-role result — a refusal means
|
||||
"stop calling" too): the FINAL answer — prose, one
|
||||
`` ```bor <locator> `` fence whose BODY is a DELIBERATELY
|
||||
CORRUPTED copy of the slice the tool returned (one flipped
|
||||
character + a trailing space via
|
||||
:func:`corrupt_verbatim_line`, plus a line carrying
|
||||
``SHOW_TEXT_MODEL_BODY_MARKER``), then a SECOND standalone
|
||||
`` ```bor `` fence for the OTHER seeded text document when the show
|
||||
SUCCEEDED (the answer-order half of the contract; a refused turn
|
||||
stays a single-fence answer so the honest-state cases are clean), then
|
||||
one ordinary
|
||||
`` ```bash `` block (the model's own, A5's Copy control) — unless
|
||||
the question carried ``SHOW_TEXT_NO_FENCE_MARKER``, which drops
|
||||
every fence so the shown block lands UNLOCATED (the client's
|
||||
append path, above ``.sources``).
|
||||
Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger
|
||||
phrase — the phase-71/72/94/118 ordering convention); no existing E2E
|
||||
question or fixture file contains the phrase.
|
||||
- user message containing ``put the block inline``
|
||||
(``SHOW_TEXT_INLINE_TRIGGER``, phase 182 task 08, same suite) -> the
|
||||
deterministic answer with ONE `` ```bor `` locator fence and ZERO tool
|
||||
calls — the half of the contract the tool does not cover (a fence that
|
||||
appears in the answer text on its own resolves through the same
|
||||
server-side pass). Its body is the model's own claim (it never saw the
|
||||
bytes), so it carries ``SHOW_TEXT_MODEL_BODY_MARKER`` and the suite
|
||||
asserts none of it painted. NOT clause-gated on purpose: on the
|
||||
``BOR_ANSWER_TEXT=0`` app the same text lands, which is how that suite
|
||||
proves the client never paints the model's bytes even with the tool and
|
||||
the clause gone. Checked BEFORE the ``DEFLECT_MODE`` branch (the marker
|
||||
lives in the user message — the ``TABLE_TRIGGER`` convention).
|
||||
- user message containing ``show me a block i cannot find``
|
||||
(``SHOW_TEXT_MISSING_TRIGGER``, phase 182 task 08, same suite) -> the
|
||||
deterministic answer whose one `` ```bor `` fence names
|
||||
``SHOW_TEXT_MISSING_TARGET`` — a locator the server has NO document
|
||||
for, so the client must land on its honest unresolved state (no badge,
|
||||
no body, none of the model's bytes). Checked BEFORE the
|
||||
``DEFLECT_MODE`` branch, same convention.
|
||||
- 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
|
||||
@@ -2716,6 +2775,20 @@ CONTEXT_LINE_TRIGGER = "quote the image reference"
|
||||
#: the house marker-flow convention).
|
||||
_SHOW_CONFIRM_MARKER = "Image shown to the user"
|
||||
|
||||
#: The SHOW_IMAGE flow's clause gate (phase 124, task 07) — tightened
|
||||
#: 2026-10-15 (phase 182, task 04). The gate's INTENT is "the
|
||||
#: ``show_image`` clause is in the system prompt" (the
|
||||
#: ``BOR_ANSWER_IMAGES`` toggle's server-side marker) and it was written
|
||||
#: as the loose substring check ``"show_image" in system``. Phase 182's
|
||||
#: locked ``SHOW_TEXT_CLAUSE`` ends with "For an image use `show_image`
|
||||
#: instead.", so that approximation now also matches a text-clause-only
|
||||
#: prompt and the image flow fired on the default-OFF app (the phase-124
|
||||
#: toggle-off pin failed). One sentence only the IMAGE clause carries is
|
||||
#: the exact marker: present iff ``show_image=True``, so the ON app fires
|
||||
#: exactly as before and a ``BOR_ANSWER_IMAGES=0`` app stays silent even
|
||||
#: when ``BOR_ANSWER_TEXT`` (default ON) is what mentions the tool.
|
||||
_SHOW_IMAGE_CLAUSE_MARKER = "displays a standalone image document"
|
||||
|
||||
#: 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
|
||||
@@ -2800,7 +2873,7 @@ def _show_image_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||
if SHOW_IMAGE_TRIGGER not in _user(body).lower():
|
||||
return None
|
||||
system = _system(body)
|
||||
if "show_image" not in system:
|
||||
if _SHOW_IMAGE_CLAUSE_MARKER not in system:
|
||||
return None
|
||||
doc = _seeded_image_doc(system)
|
||||
if doc is None:
|
||||
@@ -2812,6 +2885,295 @@ def _show_image_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||
return ("call", f"{doc[0]}/{doc[1]}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 182 (task 08): the SHOW_TEXT (verbatim display) flows
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: A user message containing this substring (case-insensitive) — combined
|
||||
#: with the ``show_text`` clause's marker (:data:`_SHOW_TEXT_CLAUSE_MARKER`,
|
||||
#: the ``BOR_ANSWER_TEXT`` toggle's server-side marker, exactly as
|
||||
#: ``_SHOW_IMAGE_CLAUSE_MARKER`` is phase 124's) — drives the deterministic
|
||||
#: SHOW_TEXT flow described in the module docstring: the scripted tool call
|
||||
#: on the first request, the fence-carrying final answer once a tool result
|
||||
#: exists. The TARGET lives in the question (a combined ``source/path`` +
|
||||
#: an optional ``12-40`` range + an optional :data:`
|
||||
#: SHOW_TEXT_NO_FENCE_MARKER`), which is what lets ONE trigger drive a text
|
||||
#: doc, an image doc (LOCKED A6's refusal) and a wrong-range request.
|
||||
#: Disjoint from every other trigger phrase — the phase-71/72/94/118
|
||||
#: ordering convention; no existing E2E question or fixture file contains
|
||||
#: the phrase, so every other suite is unaffected.
|
||||
SHOW_TEXT_TRIGGER = "show me the exact text of"
|
||||
|
||||
#: In the show-me question: the final answer carries NO locator fence, so
|
||||
#: the block the tool showed is UNLOCATED by the prose — the client's
|
||||
#: append path (below the answer text, above ``.sources``, phase 124's DOM
|
||||
#: order) is what renders it.
|
||||
SHOW_TEXT_NO_FENCE_MARKER = "describe it without a block"
|
||||
|
||||
#: A user message containing this substring (case-insensitive) -> the ONE
|
||||
#: locator fence with NO tool call at all (see the module docstring). Not
|
||||
#: clause-gated: the ``answer_text=False`` app receives the same text, so
|
||||
#: the suite can pin the client's own no-model-bytes rule on the kill-switch
|
||||
#: build too.
|
||||
SHOW_TEXT_INLINE_TRIGGER = "put the block inline"
|
||||
|
||||
#: A user message containing this substring (case-insensitive) -> the ONE
|
||||
#: fence naming :data:`SHOW_TEXT_MISSING_TARGET` (a locator with no row).
|
||||
SHOW_TEXT_MISSING_TRIGGER = "show me a block i cannot find"
|
||||
|
||||
#: The hallucinated locator (never seeded — the fixture's sources are
|
||||
#: ``baselab`` and the suite seeds no file of this name).
|
||||
SHOW_TEXT_MISSING_TARGET = "baselab/notes/the-file-that-is-not-there.md"
|
||||
|
||||
#: A token that exists ONLY in the model's re-typed fence BODY. The suite
|
||||
#: asserts it appears nowhere in the rendered DOM (the body is dropped at
|
||||
#: render, :mod:`app.rag.verbatim` supplies the bytes) while the answer's
|
||||
#: ordinary `` ```bash `` block — legitimately the model's — stays.
|
||||
SHOW_TEXT_MODEL_BODY_MARKER = "MODEL_RETYPED_THIS_LINE"
|
||||
|
||||
#: The ordinary (non-locator) fenced block every phase-182 answer carries —
|
||||
#: LOCKED A5's control: `.code-block` + Copy, NO badge, flag-independent.
|
||||
SHOW_TEXT_PLAIN_LANG = "bash"
|
||||
SHOW_TEXT_PLAIN_BODY = "systemctl --user start gufo.container"
|
||||
|
||||
#: The SHOW_TEXT flow's clause gate (phase 182, task 08) — one sentence
|
||||
#: :data:`app.rag.prompts.SHOW_TEXT_CLAUSE` carries and nothing else does,
|
||||
#: present iff ``answer_text=True`` (the exact mirror of
|
||||
#: :data:`_SHOW_IMAGE_CLAUSE_MARKER`, tightened for the same reason).
|
||||
_SHOW_TEXT_CLAUSE_MARKER = "displays part of a document to the user EXACTLY as stored"
|
||||
|
||||
#: The agent's ``show_text`` SUCCESS result's confirming first line
|
||||
#: (``app.rag.agent``'s ``show_text`` branch — the line ``read``'s result
|
||||
#: never carries). Kept for the wire assertion; the flow itself stops on
|
||||
#: ANY tool result, because a REFUSAL also means "do not call again" (the
|
||||
#: wrong-range and image-doc variants depend on that: the model still
|
||||
#: answers, and the fence it writes is what the client must judge honestly).
|
||||
_SHOW_TEXT_CONFIRM_MARKER = "Text shown to the user"
|
||||
|
||||
#: The target inside the question: a combined ``source/path`` plus an
|
||||
#: optional 1-based ``12-40`` / ``lines 12-40`` range — the model's CLAIM.
|
||||
#: (The server's extent is whatever the resolver returned; the suite pins
|
||||
#: the two apart.)
|
||||
_SHOW_TEXT_TARGET_RE = re.compile(
|
||||
r"(?P<path>[a-z0-9_.\-]+(?:/[a-z0-9_.\-]+)+)"
|
||||
r"(?:\s+(?:lines?\s+)?(?P<start>\d+)\s*[-\u2013]\s*(?P<end>\d+))?"
|
||||
)
|
||||
|
||||
|
||||
def corrupt_verbatim_line(line: str) -> str:
|
||||
"""The model's one-character drift on a real line (phase-182 E2E).
|
||||
|
||||
Flips the first ``a`` to ``4`` (or the last character when the line has
|
||||
no ``a``) and appends a trailing space — exactly the silent drift a
|
||||
retyping model produces, the incident the phase exists for. Exported so
|
||||
the suite can derive the SAME string from the fixture's stored text and
|
||||
assert none of it reached the DOM.
|
||||
"""
|
||||
flipped = line.replace("a", "4", 1)
|
||||
if flipped == line:
|
||||
flipped = line[:-1] + "4" if line else "4"
|
||||
return flipped + " "
|
||||
|
||||
|
||||
def _show_text_request(body: dict[str, Any]) -> tuple[str, int | None, int | None]:
|
||||
"""The scripted target ``(path, claimed_start, claimed_end)``.
|
||||
|
||||
Markdown emphasis/backticks are stripped first: the questions quote the
|
||||
path the way a person types it (``Show me the exact text of
|
||||
`baselab/a/b.md` 2-999?``), and the closing backtick sits between the
|
||||
path and its range.
|
||||
"""
|
||||
text = _user(body).lower().replace("`", "")
|
||||
match = _SHOW_TEXT_TARGET_RE.search(text)
|
||||
if match is None:
|
||||
return "", None, None
|
||||
start, end = match.group("start"), match.group("end")
|
||||
return (
|
||||
match.group("path"),
|
||||
int(start) if start else None,
|
||||
int(end) if end else None,
|
||||
)
|
||||
|
||||
|
||||
def _show_text_locator(path: str, start: int | None, end: int | None) -> str:
|
||||
"""The locator the model writes: ``source/path`` or ``source/path#12-40``."""
|
||||
return f"{path}#{start}-{end}" if start and end else path
|
||||
|
||||
|
||||
def _show_text_slice(result: str) -> str:
|
||||
"""The verbatim slice a ``show_text`` result carried (everything after its
|
||||
``lines: a-b of N`` extent line), or ``""`` when the call was refused."""
|
||||
lines = result.split("\n")
|
||||
for index, line in enumerate(lines):
|
||||
if line.startswith("lines: "):
|
||||
return "\n".join(lines[index + 1 :])
|
||||
return ""
|
||||
|
||||
|
||||
def _show_text_fence_body(slice_text: str) -> str:
|
||||
"""The deliberately corrupted copy of the slice + the sentinel line."""
|
||||
lines = [line for line in slice_text.split("\n") if line.strip()]
|
||||
if not lines:
|
||||
lines = ["the model has nothing to retype after a refusal"]
|
||||
return "\n".join(
|
||||
[corrupt_verbatim_line(lines[0]), *lines[1:], SHOW_TEXT_MODEL_BODY_MARKER]
|
||||
)
|
||||
|
||||
|
||||
def _show_text_plain_block() -> str:
|
||||
"""One ordinary fenced block — the model's own, badged nothing (A5)."""
|
||||
return f"```{SHOW_TEXT_PLAIN_LANG}\n{SHOW_TEXT_PLAIN_BODY}\n```"
|
||||
|
||||
|
||||
def _second_seeded_document(system: str, exclude: str) -> str:
|
||||
"""The OTHER text document in the ``<documents>`` seed (combined path),
|
||||
or ``""`` — the second fence's target (the answer-order half)."""
|
||||
for block in _DOCUMENT_BLOCK_RE.finditer(system):
|
||||
identity = f"{block.group('source')}/{block.group('path')}"
|
||||
if identity == exclude or identity.lower().endswith(_IMAGE_PATH_SUFFIXES):
|
||||
continue
|
||||
return identity
|
||||
return ""
|
||||
|
||||
|
||||
def _show_text_call_arguments(path: str, start: str, end: str) -> dict[str, Any]:
|
||||
"""The scripted ``show_text`` arguments: ``path`` always, ``offset`` +
|
||||
``limit`` only when the question named a range (``limit`` derived from
|
||||
the CLAIMED range — the server clamps it and the caption follows the
|
||||
server)."""
|
||||
arguments: dict[str, Any] = {"path": path}
|
||||
if start:
|
||||
first = int(start)
|
||||
last = int(end) if end else first
|
||||
arguments["offset"] = first
|
||||
arguments["limit"] = max(last - first + 1, 1)
|
||||
return arguments
|
||||
|
||||
|
||||
def _show_text_answer(body: dict[str, Any], result: str) -> str:
|
||||
"""The final answer of the SHOW_TEXT turn (prose + fences + a plain block).
|
||||
|
||||
With :data:`SHOW_TEXT_NO_FENCE_MARKER` in the question the answer carries
|
||||
NO locator fence at all — the block the tool showed then has no home in
|
||||
the prose, which is the client's APPEND path (below the answer text,
|
||||
above ``.sources``). The SECOND (other-file) fence rides only a turn whose
|
||||
show SUCCEEDED (:data:`_SHOW_TEXT_CONFIRM_MARKER`): a refused turn (the
|
||||
image document, a range past the end) is the suite's honest-state case and
|
||||
stays a single-fence answer.
|
||||
"""
|
||||
path, start, end = _show_text_request(body)
|
||||
parts = ["Here is the exact text, straight from the file:"]
|
||||
if SHOW_TEXT_NO_FENCE_MARKER not in _user(body).lower():
|
||||
parts += [
|
||||
"",
|
||||
"```bor " + _show_text_locator(path, start, end),
|
||||
_show_text_fence_body(_show_text_slice(result)),
|
||||
"```",
|
||||
]
|
||||
second = (
|
||||
_second_seeded_document(_system(body), path)
|
||||
if _SHOW_TEXT_CONFIRM_MARKER in result
|
||||
else ""
|
||||
)
|
||||
if second:
|
||||
parts += [
|
||||
"",
|
||||
"And the note file next to it, in full:",
|
||||
"",
|
||||
"```bor " + second,
|
||||
f"The model re-typed this file instead — {SHOW_TEXT_MODEL_BODY_MARKER}.",
|
||||
"```",
|
||||
]
|
||||
else:
|
||||
parts += [
|
||||
"",
|
||||
"It is on screen above — the panel holds it, so it is not repeated here.",
|
||||
]
|
||||
parts += [
|
||||
"",
|
||||
"To start it afterwards:",
|
||||
"",
|
||||
_show_text_plain_block(),
|
||||
"",
|
||||
"(Deterministic mock answer for E2E.)",
|
||||
]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _show_text_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||
"""Classify a phase-182 SHOW_TEXT request (see the module docstring).
|
||||
|
||||
Stateless over the messages, like the other marker flows:
|
||||
|
||||
* ``("call", path, start, end)`` — the trigger is in the question, the
|
||||
system prompt carries the ``show_text`` clause and no ``tool``-role
|
||||
result exists yet: stream ONLY the ``show_text`` tool-call delta built
|
||||
by :func:`_show_text_call_arguments` from the three strings (``""`` =
|
||||
no range).
|
||||
* ``("answer", text)`` — a ``tool``-role result is in the messages
|
||||
(success or refusal — both mean "stop calling"): the fence-carrying
|
||||
final answer.
|
||||
* ``None`` — the trigger is absent, the clause is absent (a
|
||||
``BOR_ANSWER_TEXT=0`` app: the mock behaves exactly as pre-phase), or
|
||||
the question names no ``source/path`` target.
|
||||
"""
|
||||
if SHOW_TEXT_TRIGGER not in _user(body).lower():
|
||||
return None
|
||||
if _SHOW_TEXT_CLAUSE_MARKER not in _system(body):
|
||||
return None
|
||||
path, start, end = _show_text_request(body)
|
||||
if not path:
|
||||
return None
|
||||
results = _tool_results(body)
|
||||
if results:
|
||||
return ("answer", _show_text_answer(body, results[-1]))
|
||||
return (
|
||||
"call",
|
||||
path,
|
||||
str(start) if start is not None else "",
|
||||
str(end) if end is not None else "",
|
||||
)
|
||||
|
||||
|
||||
def show_text_inline_answer(body: dict[str, Any]) -> str:
|
||||
"""The ``put the block inline`` answer: ONE locator fence, zero tool
|
||||
calls (see the module docstring)."""
|
||||
path, start, end = _show_text_request(body)
|
||||
return "\n".join(
|
||||
[
|
||||
"Sure — the block itself:",
|
||||
"",
|
||||
"```bor " + _show_text_locator(path, start, end),
|
||||
f"Run `podman generate quadlet gufo` and paste the inline block "
|
||||
f"for baselab — {SHOW_TEXT_MODEL_BODY_MARKER}.",
|
||||
"```",
|
||||
"",
|
||||
"To apply it:",
|
||||
"",
|
||||
_show_text_plain_block(),
|
||||
"",
|
||||
"(Deterministic mock answer for E2E.)",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def show_text_missing_answer() -> str:
|
||||
"""The ``show me a block i cannot find`` answer: a locator the server has
|
||||
no document for, so the client must land on its honest state."""
|
||||
return "\n".join(
|
||||
[
|
||||
"Here is the block you asked for:",
|
||||
"",
|
||||
"```bor " + SHOW_TEXT_MISSING_TARGET + "#3-4",
|
||||
f"A file that does not exist, re-typed by the model "
|
||||
f"{SHOW_TEXT_MODEL_BODY_MARKER}.",
|
||||
"```",
|
||||
"",
|
||||
"(Deterministic mock answer for E2E.)",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -4165,6 +4527,22 @@ def compose_answer(body: dict[str, Any]) -> str:
|
||||
# the user message, so a marker question always gets the inline
|
||||
# answer whatever the gate says.
|
||||
answer = INLINE_ANSWER
|
||||
elif SHOW_TEXT_INLINE_TRIGGER in user.lower():
|
||||
# Phase 182 (task 08, the verbatim-display suite): ONE locator fence
|
||||
# and ZERO tool calls — the standalone-fence half of the contract
|
||||
# (the server resolves the bytes from the row; the model's body is
|
||||
# dropped at render). Checked BEFORE the DEFLECT_MODE branch, like
|
||||
# TABLE_TRIGGER: the marker lives in the user message, so a marker
|
||||
# question always gets the fenced answer whatever the gate says.
|
||||
# NOT gated on the show_text clause (unlike the SHOW_TEXT_TRIGGER
|
||||
# flow): on a BOR_ANSWER_TEXT=0 app the same text lands with no
|
||||
# answer_text on the wire, which is the suite's witness that the
|
||||
# client never paints the model's bytes.
|
||||
answer = show_text_inline_answer(body)
|
||||
elif SHOW_TEXT_MISSING_TRIGGER in user.lower():
|
||||
# Phase 182 (task 08): the unresolvable locator — the client's honest
|
||||
# unresolved state is the subject. Same branch convention as above.
|
||||
answer = show_text_missing_answer()
|
||||
elif HISTORY_TRIGGER in user.lower():
|
||||
# Phase 74 (TODO L4, chat history): the deterministic history
|
||||
# echo — proves on the wire that the client's prior turns (and
|
||||
@@ -5310,6 +5688,28 @@ def chat_completions(body: dict[str, Any]) -> Any:
|
||||
# TOOLS_TRIGGER flow (disjoint trigger phrases — the
|
||||
# phase-71/72/94 ordering convention; the trigger needs the
|
||||
# ``<tools>`` section, so deflected turns never hit it).
|
||||
# Phase 182 (task 08): the deterministic SHOW_TEXT marker flow —
|
||||
# checked BEFORE the plain TOOLS_TRIGGER flow (disjoint trigger
|
||||
# phrase — the phase-71/72/94/118 ordering convention; the trigger
|
||||
# needs the show_text clause, so deflected turns and a
|
||||
# BOR_ANSWER_TEXT=0 app never hit it).
|
||||
show_text = _show_text_flow(body)
|
||||
if show_text is not None:
|
||||
if show_text[0] == "call":
|
||||
stream = _tool_call_stream(
|
||||
"show_text",
|
||||
_show_text_call_arguments(show_text[1], show_text[2], show_text[3]),
|
||||
"call_show_text",
|
||||
)
|
||||
else: # "answer" — the fence-carrying final answer
|
||||
stream = _sse_stream(
|
||||
_apply_max_tokens(show_text[1], body.get("max_tokens")), 0.0
|
||||
)
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
turn_progress = _turn_progress_flow(body)
|
||||
if turn_progress is not None:
|
||||
stream = _turn_progress_stream(turn_progress)
|
||||
|
||||
@@ -553,7 +553,7 @@ def test_admin_sees_groups_and_source_badges(page: Page, app_url: str, db_ready:
|
||||
return el.scrollHeight > el.clientHeight;
|
||||
}"""
|
||||
)
|
||||
assert scrolls, "78 rows must overflow the bounded card (the scroll must bite)"
|
||||
assert scrolls, "the catalogue rows must overflow the bounded card (the scroll must bite)"
|
||||
|
||||
# The groups render in the server's order (the pinned display
|
||||
# groups — at least these two).
|
||||
@@ -589,7 +589,19 @@ def test_admin_sees_groups_and_source_badges(page: Page, app_url: str, db_ready:
|
||||
du = page.locator('tr[data-key="database_url"]')
|
||||
assert du.locator(".settings-control").get_attribute("type") == "password"
|
||||
assert du.locator(".settings-control").input_value() == ""
|
||||
assert "reese" not in page.content(), "the DB URL's credentials leaked into the DOM"
|
||||
# The whole page carries NO trace of the URL's credential pair, and
|
||||
# the catalogue itself carries no `reese` at all (the user, the pass
|
||||
# and the DB name are all `reese`/`brain_of_reese`). The strict
|
||||
# no-`reese` sweep is scoped to the panel on purpose: the shell also
|
||||
# ships the CHAT view, whose onboarding chips follow the newest saved
|
||||
# chats in the one shared E2E database (phase 103) — a chat left by
|
||||
# an unrelated suite (its question names `reeselink.json`) is not a
|
||||
# credentials leak, and an unscoped sweep would make this assertion
|
||||
# depend on which suite saved a chat last.
|
||||
assert "reese:reese" not in page.content(), "the DB URL leaked into the DOM"
|
||||
assert "reese" not in page.locator(".settings-shell").inner_html(), (
|
||||
"the DB URL's credentials leaked into the settings catalogue"
|
||||
)
|
||||
expect(du.locator(".settings-reset")).to_be_disabled()
|
||||
assert "read-only" in (du.locator(".settings-reset").get_attribute("title") or "")
|
||||
|
||||
@@ -984,7 +996,7 @@ def test_clear_all_overrides(page: Page, app_url: str, db_ready: None) -> None:
|
||||
# The re-GET settles the badges (the status line lands BEFORE the
|
||||
# re-render — the §7.4 lifecycle); wait for the settled state
|
||||
# rather than snapshotting a mid-flight render (the combined-run
|
||||
# race — the 78-item catalog's re-GET can outlast the status
|
||||
# race — the full catalog's re-GET can outlast the status
|
||||
# poll's gap under a three-suite run, phase-173 regression pass).
|
||||
expect(page.locator(".settings-src.is-db")).to_have_count(0, timeout=30_000)
|
||||
expect(page.locator('tr[data-key="app_name"] .settings-src')).to_have_text("env")
|
||||
|
||||
@@ -723,7 +723,16 @@ def test_feature_flow_answer_figure_chips_and_zero_reads(
|
||||
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"]
|
||||
# Phase 182 (task 03) pin update, deliberate: ``answer_text``
|
||||
# defaults ON, so ``show_text`` rides LAST in the offer too — the
|
||||
# ``answer_images`` gate itself is unchanged.
|
||||
assert _offered_tool_names(captured[0]) == [
|
||||
"ls",
|
||||
"read",
|
||||
"grep",
|
||||
"show_image",
|
||||
"show_text",
|
||||
]
|
||||
assert _executed_tool_calls(captured) == ["show_image"]
|
||||
tool_results = [
|
||||
m.get("content") or ""
|
||||
@@ -941,10 +950,14 @@ def test_toggle_off_flow_no_tool_no_answer_image_key(
|
||||
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"], (
|
||||
# Phase 182 (task 03) pin update, deliberate: ``show_text`` is in
|
||||
# the default offer (``answer_text`` defaults ON); ``show_image``
|
||||
# is still the tool that must NOT leak in here.
|
||||
assert "show_image" not in _offered_tool_names(captured[0]), (
|
||||
f"show_image leaked into the default-off offer: "
|
||||
f"{_offered_tool_names(captured[0])}"
|
||||
)
|
||||
assert _offered_tool_names(captured[0]) == ["ls", "read", "grep", "show_text"]
|
||||
assert _executed_tool_calls(captured) == []
|
||||
|
||||
# The saved brain record carries NO answer_image key (ABSENT — the
|
||||
|
||||
@@ -17,7 +17,7 @@ Test → contract mapping (one story, one phase, one isolated file):
|
||||
small" pin: at 375px the admin arrives at the settings view (the
|
||||
phase-168 gate flow), and a representative spread of rows — one
|
||||
``type="text"`` input, one ``select``, one ``textarea`` (three
|
||||
distinct control types among the catalogue's 78 rows; the
|
||||
distinct control types among the catalogue's rows; the
|
||||
phase-168 catalogue guarantees text / number / select / textarea /
|
||||
password controls) — each renders its ``.settings-control``
|
||||
bounding box ≥ 192px wide (12 rem — owner-locked A3: the
|
||||
@@ -280,7 +280,7 @@ def test_mobile_value_inputs_are_usable_width(
|
||||
|
||||
# A representative spread: the FIRST row of each of three
|
||||
# distinct control types (one type="text" input, one select, one
|
||||
# textarea) among the catalogue's 78 rows (the phase-168
|
||||
# textarea) among the catalogue's rows (the phase-168
|
||||
# catalogue guarantees text / number / select / textarea /
|
||||
# password controls).
|
||||
keys = page.evaluate(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ The pins (00_phase, Testing & Quality):
|
||||
* the auth matrix — anonymous AND a signed-in token USER get 403 on ALL
|
||||
four routes (the router-wide ``require_admin`` closes the surface on
|
||||
every method), the admin 200s on all four;
|
||||
* the shape — ``items`` length == ``len(Settings.model_fields)`` (78),
|
||||
* the shape — ``items`` length == ``len(Settings.model_fields)`` (one per field),
|
||||
groups in ``GROUPS`` order (the stable order the E2E pins against),
|
||||
``totals.count`` / ``overridden`` correct, ``overlay_enabled`` true;
|
||||
on an empty table a field whose ``BOR_*`` var the test env sets
|
||||
@@ -152,7 +152,7 @@ def test_auth_matrix_all_four_routes(client: TestClient, db: Session) -> None:
|
||||
|
||||
|
||||
def test_catalogue_shape_and_sources_on_empty_table(admin_client) -> None:
|
||||
"""The whole catalogue, grouped and stable: 78 items (exactly
|
||||
"""The whole catalogue, grouped and stable: one item per field (exactly
|
||||
``Settings.model_fields``), groups in ``GROUPS`` order (each group a
|
||||
contiguous block, the block order = ``GROUPS``), ``totals`` correct,
|
||||
``overlay_enabled`` true. On an empty table: the env-var-set field
|
||||
@@ -418,7 +418,7 @@ def test_kill_switch_disables_the_mechanism_not_the_panel(
|
||||
def test_catalogue_stays_under_the_byte_budget(admin_client) -> None:
|
||||
"""The whole catalogue stays under the phase-167 INFO bar (64 KB —
|
||||
``BOR_LOG_LARGE_RESPONSE_BYTES``), so ``GET /api/admin/settings``
|
||||
never floods the log (78 items ≈ 20 KB; the audit script re-verifies
|
||||
never floods the log (the whole catalogue ≈ 20 KB; the audit script re-verifies
|
||||
the 256 KB budget in task 06)."""
|
||||
r = admin_client.get(API)
|
||||
assert r.status_code == 200
|
||||
@@ -708,6 +708,131 @@ def test_llm_summary_thinking_level_row_contract(
|
||||
admin_client.delete(f"{API}/llm_summary_thinking_level") # no row survives
|
||||
|
||||
|
||||
def test_verbatim_display_rows_contract(
|
||||
admin_client: TestClient, db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Phase 182 (task 01, LOCKED A8): the three verbatim-display rows are
|
||||
present in the ``Agent tools`` group with the right labels and types
|
||||
(``bool`` + two ``int``s, defaults ``true`` / ``12000`` / ``5``) and
|
||||
behave through the generic overlay machinery:
|
||||
(a) the GET rows carry group/label/type/env_var, are neither secret nor
|
||||
restart-required (the chat request path reads them per turn — live);
|
||||
(b) a PUT stores the RAW string in ``app_settings`` and the value
|
||||
resolves live WITHOUT a restart;
|
||||
(c) ``0`` is storable on both budgets — it is INERT, not an error
|
||||
(``app_settings`` holds ``"0"``, the effective value is ``0``);
|
||||
(d) a negative value is refused by the validate-before-store gate (A5)
|
||||
with a 422 naming the field and NOTHING stored — the same rule that
|
||||
refuses startup; a ``false`` for the master switch is storable
|
||||
(the kill switch);
|
||||
(e) a DELETE resets each to the env default;
|
||||
(f) the non-admin 403 gate applies.
|
||||
"""
|
||||
# A deterministic base (machine-independent, the kill-switch test's
|
||||
# idiom): the code defaults, no row in the table.
|
||||
for var in (
|
||||
"BOR_ANSWER_TEXT",
|
||||
"BOR_SHOW_TEXT_MAX_CHARS",
|
||||
"BOR_SHOW_TEXT_MAX_BLOCKS",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
invalidate_settings()
|
||||
|
||||
# (a) the rows.
|
||||
r = admin_client.get(API)
|
||||
assert r.status_code == 200, r.text
|
||||
expect = {
|
||||
"answer_text": ("Answer text", "bool", "true", "BOR_ANSWER_TEXT"),
|
||||
"show_text_max_chars": (
|
||||
"Show text max chars",
|
||||
"int",
|
||||
"12000",
|
||||
"BOR_SHOW_TEXT_MAX_CHARS",
|
||||
),
|
||||
"show_text_max_blocks": (
|
||||
"Show text max blocks",
|
||||
"int",
|
||||
"5",
|
||||
"BOR_SHOW_TEXT_MAX_BLOCKS",
|
||||
),
|
||||
}
|
||||
for key, (label, typ, default, envname) in expect.items():
|
||||
item = _item(r.json(), key)
|
||||
assert item["group"] == "Agent tools"
|
||||
assert item["label"] == label
|
||||
assert item["type"] == typ
|
||||
assert item["env_var"] == envname
|
||||
assert item["secret"] is False
|
||||
assert item["masked"] is False
|
||||
assert item["restart_required"] is False
|
||||
assert item["overridable"] is True
|
||||
assert item["value"] == default
|
||||
assert item["default"] == default
|
||||
assert item["source"] == "default" # env var unset, no row
|
||||
assert item["has_override"] is False
|
||||
assert item["db_value"] is None
|
||||
|
||||
# (b) PUT stores the raw string and resolves live, no restart.
|
||||
for key, sent, resolved in (
|
||||
("answer_text", "false", False),
|
||||
("show_text_max_chars", "2000", 2000),
|
||||
("show_text_max_blocks", "2", 2),
|
||||
):
|
||||
r = admin_client.put(f"{API}/{key}", json={"value": sent})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["source"] == "db"
|
||||
assert r.json()["has_override"] is True
|
||||
assert r.json()["db_value"] == sent
|
||||
assert db.execute(
|
||||
text("SELECT value FROM app_settings WHERE key = :k"), {"k": key}
|
||||
).scalar() == sent # stored raw, exactly as sent
|
||||
assert getattr(get_settings(), key) == resolved # live, no restart
|
||||
|
||||
# (c) ``0`` is storable on both budgets (inert, not an error).
|
||||
for key in ("show_text_max_chars", "show_text_max_blocks"):
|
||||
r = admin_client.put(f"{API}/{key}", json={"value": "0"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["value"] == "0"
|
||||
assert db.execute(
|
||||
text("SELECT value FROM app_settings WHERE key = :k"), {"k": key}
|
||||
).scalar() == "0"
|
||||
assert getattr(get_settings(), key) == 0
|
||||
|
||||
# (d) a negative value is refused before anything is stored (A5) —
|
||||
# the 422 names the field, the previous row is untouched.
|
||||
for key in ("show_text_max_chars", "show_text_max_blocks"):
|
||||
r = admin_client.put(f"{API}/{key}", json={"value": "-1"})
|
||||
assert r.status_code == 422, r.text
|
||||
assert key in r.json()["detail"]
|
||||
assert db.execute(
|
||||
text("SELECT value FROM app_settings WHERE key = :k"), {"k": key}
|
||||
).scalar() == "0" # unchanged
|
||||
assert getattr(get_settings(), key) == 0 # the live view is undisturbed
|
||||
|
||||
# (e) DELETE resets each to the env default (the vars are unset here,
|
||||
# so the code default wins again).
|
||||
for key, default in (
|
||||
("answer_text", True),
|
||||
("show_text_max_chars", 12_000),
|
||||
("show_text_max_blocks", 5),
|
||||
):
|
||||
r = admin_client.delete(f"{API}/{key}")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["has_override"] is False
|
||||
assert r.json()["source"] == "default"
|
||||
assert getattr(get_settings(), key) == default
|
||||
assert db.execute(
|
||||
text("SELECT count(*) FROM app_settings WHERE key = :k"), {"k": key}
|
||||
).scalar() == 0
|
||||
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0
|
||||
|
||||
# (f) the non-admin 403 gate (asserted once for this family — the full
|
||||
# four-route matrix is ``test_auth_matrix_all_four_routes``).
|
||||
anon = TestClient(fastapi_app)
|
||||
r = anon.put(f"{API}/answer_text", json={"value": "false"})
|
||||
assert r.status_code == 403 and r.json() == {"detail": "admin only"}
|
||||
|
||||
|
||||
def test_docs_token_is_write_only_masked_end_to_end(
|
||||
admin_client: TestClient, db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
||||
@@ -13,7 +13,10 @@ the ``scripts/``-consumer surface (``import_extension_set``), and the
|
||||
phase-172 thinking level (``llm_chat_thinking_level`` — the value the
|
||||
LLM layer reads per request via ``get_settings()``; the off switch is
|
||||
live-settable, A2) and its phase-178 summary twin (task 02:
|
||||
``llm_summary_thinking_level``, default ``""`` = off, A2).
|
||||
``llm_summary_thinking_level``, default ``""`` = off, A2), and the
|
||||
phase-182 verbatim-display family (task 01: ``answer_text`` — the master
|
||||
switch — plus the two ``show_text_max_*`` budgets, whose inert ``0``
|
||||
values and reset-to-default resolve live with no restart).
|
||||
|
||||
The rows are inserted directly through the ``db`` session (the admin
|
||||
API is task 04); the save itself is ``invalidate_settings()`` —
|
||||
@@ -249,6 +252,72 @@ def test_llm_summary_thinking_level_live_apply_no_restart(
|
||||
assert get_settings().llm_summary_thinking_level == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the verbatim-display switch + budgets: save/reset resolve the live values
|
||||
# (phase 182, task 01)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verbatim_display_settings_live_apply_no_restart(
|
||||
db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Phase 182 (task 01, LOCKED A8): the verbatim-display master switch
|
||||
and its two budgets follow the overlay live — a save (the PUT's write,
|
||||
minus the API: ``_save`` + ``invalidate_settings()`` is exactly what
|
||||
the PUT/DELETE call after the write) resolves
|
||||
``get_settings().answer_text`` / ``.show_text_max_chars`` /
|
||||
``.show_text_max_blocks`` WITHOUT a restart (the chat request path
|
||||
reads them per turn via ``get_settings()``), the ``0`` values resolve
|
||||
live as INERT (not an error, not coerced to the default), the bool
|
||||
accepts the env-native ``false``/``0`` spellings, and a reset (the
|
||||
DELETE) returns the env default.
|
||||
"""
|
||||
# A deterministic base (machine-independent, the kill-switch test's
|
||||
# idiom): the code defaults, no row in the table.
|
||||
for var in (
|
||||
"BOR_ANSWER_TEXT",
|
||||
"BOR_SHOW_TEXT_MAX_CHARS",
|
||||
"BOR_SHOW_TEXT_MAX_BLOCKS",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
invalidate_settings()
|
||||
assert get_settings().answer_text is True
|
||||
assert get_settings().show_text_max_chars == 12_000
|
||||
assert get_settings().show_text_max_blocks == 5
|
||||
|
||||
# Save: every value resolves through the overlay, no restart.
|
||||
_save(db, "answer_text", "false")
|
||||
_save(db, "show_text_max_chars", "2500")
|
||||
_save(db, "show_text_max_blocks", "1")
|
||||
invalidate_settings()
|
||||
assert get_settings().answer_text is False
|
||||
assert get_settings().show_text_max_chars == 2500
|
||||
assert get_settings().show_text_max_blocks == 1
|
||||
|
||||
# ``0`` is the inert state (the field is not treated as "unset"), and
|
||||
# the bool's other env-native spelling resolves to True.
|
||||
_save(db, "answer_text", "1")
|
||||
_save(db, "show_text_max_chars", "0")
|
||||
_save(db, "show_text_max_blocks", "0")
|
||||
invalidate_settings()
|
||||
assert get_settings().answer_text is True
|
||||
assert get_settings().show_text_max_chars == 0
|
||||
assert get_settings().show_text_max_blocks == 0
|
||||
|
||||
# The reset returns the env defaults (no process restart in between).
|
||||
db.execute(
|
||||
text(
|
||||
"DELETE FROM app_settings WHERE key IN "
|
||||
"('answer_text', 'show_text_max_chars', 'show_text_max_blocks')"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
invalidate_settings()
|
||||
assert get_settings().answer_text is True
|
||||
assert get_settings().show_text_max_chars == 12_000
|
||||
assert get_settings().show_text_max_blocks == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the chat concurrency semaphore follows the saved cap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -47,7 +47,7 @@ from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import Document, FolderSummary, GitSource
|
||||
from app.rag import agent
|
||||
from app.rag.agent import AGENT_TOOLS, AgentHolder, run_agent
|
||||
from app.rag.agent import AGENT_TOOLS, SHOW_TEXT_TOOL, AgentHolder, run_agent
|
||||
from app.rag.llm import (
|
||||
LLMClient,
|
||||
RetryPiece,
|
||||
@@ -319,7 +319,7 @@ def test_ls_top_level_lists_registered_sources_through_run_agent(
|
||||
|
||||
holder, llm = _run_call(db, "ls", {})
|
||||
|
||||
assert llm.requests[0][1] == AGENT_TOOLS
|
||||
assert llm.requests[0][1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
|
||||
assert llm.requests[1][0][3]["content"].startswith(
|
||||
"2 sources:\n"
|
||||
"\n"
|
||||
@@ -482,7 +482,7 @@ def test_ls_path_like_scope_unknown_source_gets_no_source_refusal(kb, src, db) -
|
||||
agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope="app")
|
||||
)
|
||||
assert holder.tool_calls == 0 and holder.read_docs == []
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL] # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_ls_unknown_folder_gets_not_a_folder_with_parents_subfolders(
|
||||
@@ -505,7 +505,7 @@ def test_ls_unknown_folder_gets_not_a_folder_with_parents_subfolders(
|
||||
"backups/ containers/ networking/"
|
||||
)
|
||||
assert holder.tool_calls == 0 and holder.read_docs == []
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL] # rejected → tools stay offered
|
||||
|
||||
|
||||
# ---------- read (the canonical combined source/path form) ----------
|
||||
@@ -595,7 +595,7 @@ def test_read_bare_path_single_source_suggestion_then_corrected_read(kb, db) ->
|
||||
"No document at 'deep/nested/doc.md' — "
|
||||
"did you mean 'Alpha/deep/nested/doc.md'?"
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
|
||||
# Round 2: the corrected combined identity succeeds — the full
|
||||
# content (plus the phase-106 D5 date line), the holder records the
|
||||
# row, and it counts.
|
||||
@@ -604,7 +604,7 @@ def test_read_bare_path_single_source_suggestion_then_corrected_read(kb, db) ->
|
||||
"date: 2024-06-15\n"
|
||||
"FULL-TEXT"
|
||||
)
|
||||
assert llm.requests[2][1] == AGENT_TOOLS
|
||||
assert llm.requests[2][1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
|
||||
# SEC-14-04: short-lived session loads fresh copies
|
||||
assert [d.id for d in holder.read_docs] == [created.id]
|
||||
assert holder.tool_calls == 1 # only the corrected read executed
|
||||
@@ -670,7 +670,7 @@ def test_grep_whole_kb_through_run_agent(kb, db) -> None:
|
||||
holder, llm = _run_call(db, "grep", {"pattern": "needle"})
|
||||
|
||||
# Offered: the first request carries AGENT_TOOLS (the 3-tool list).
|
||||
assert llm.requests[0][1] == AGENT_TOOLS
|
||||
assert llm.requests[0][1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
|
||||
# Executed against the real DB: catalog order, grep-style lines.
|
||||
assert llm.requests[1][0][3]["content"].startswith(
|
||||
"Alpha/a/one.md:2: needle in one\n"
|
||||
|
||||
@@ -36,10 +36,10 @@ from app.config import Settings, get_settings
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import Chunk, Document, GitSource, QueryLog
|
||||
from app.rag import agent
|
||||
from app.rag.agent import AGENT_TOOLS, READ_TRUNCATION_NOTICE
|
||||
from app.rag.agent import AGENT_TOOLS, READ_TRUNCATION_NOTICE, SHOW_TEXT_TOOL
|
||||
from app.rag.importer import import_sources
|
||||
from app.rag.llm import EmbeddingError, LLMError, StreamPiece, ToolCallPiece, ToolResultPiece
|
||||
from app.rag.prompts import build_high_prompt
|
||||
from app.rag.prompts import SHOW_TEXT_CLAUSE, build_high_prompt
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
from app.schemas import ChatDoneEvent, SourceRef
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
@@ -1291,9 +1291,12 @@ def test_grounded_turn_streams_tool_frames_and_cites_read_doc(
|
||||
# (not spent budgets) bounds the loop, and the model answered while
|
||||
# still being offered the tools (2 rounds < default cap 10).
|
||||
assert len(scripted.seen_messages) == 3
|
||||
assert scripted.seen_tools[0] == AGENT_TOOLS
|
||||
assert scripted.seen_tools[1] == AGENT_TOOLS
|
||||
assert scripted.seen_tools[2] == AGENT_TOOLS
|
||||
# Phase 182 (task 03) pin update, deliberate: ``answer_text``
|
||||
# defaults ON, so the grounded offer is the base three
|
||||
# (element-for-element ``AGENT_TOOLS``) plus ``show_text``.
|
||||
assert scripted.seen_tools[0] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
|
||||
assert scripted.seen_tools[1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
|
||||
assert scripted.seen_tools[2] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
|
||||
|
||||
# The query_log row carries the same combined source list.
|
||||
(row,) = db.scalars(select(QueryLog)).all()
|
||||
@@ -2887,7 +2890,10 @@ def test_show_image_turn_done_frame_carries_answer_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 the 4th tool. Phase 182 (task 03) pin update, deliberate:
|
||||
# with ``answer_text`` ON (the shipped default) ``show_text`` rides
|
||||
# LAST too — the ``answer_images`` gate itself is unchanged (it
|
||||
# still adds exactly ``show_image``, 3rd-position-offer order kept).
|
||||
offered = scripted.seen_tools[0]
|
||||
assert offered is not None
|
||||
assert [t["function"]["name"] for t in offered] == [
|
||||
@@ -2895,6 +2901,7 @@ def test_show_image_turn_done_frame_carries_answer_image(
|
||||
"read",
|
||||
"grep",
|
||||
"show_image",
|
||||
"show_text",
|
||||
]
|
||||
|
||||
|
||||
@@ -2903,8 +2910,9 @@ def test_toggle_off_same_flow_offers_three_tools_and_no_answer_image_key(
|
||||
) -> 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
|
||||
recorded request carries the three base tools — plus ``show_text``,
|
||||
phase 182's default-ON display tool, pin updated here deliberately)
|
||||
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
|
||||
@@ -2928,10 +2936,16 @@ def test_toggle_off_same_flow_offers_three_tools_and_no_answer_image_key(
|
||||
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``.
|
||||
# The offer: the three base tools — no ``show_image`` (phase 182:
|
||||
# ``show_text`` rides last, the default being ON).
|
||||
offered = scripted.seen_tools[0]
|
||||
assert offered is not None
|
||||
assert [t["function"]["name"] for t in offered] == ["ls", "read", "grep"]
|
||||
assert [t["function"]["name"] for t in offered] == [
|
||||
"ls",
|
||||
"read",
|
||||
"grep",
|
||||
"show_text",
|
||||
]
|
||||
|
||||
|
||||
def test_zero_tool_grounded_turn_done_frame_has_no_answer_image_key(
|
||||
@@ -3202,3 +3216,395 @@ def test_grounded_turn_suggested_plain_image_doc_block_is_byte_identical(
|
||||
"</document>"
|
||||
)
|
||||
assert "Referenced in" not in system
|
||||
|
||||
|
||||
# ---------- phase 182 (task 04): the show_text clause reaches the model ----------
|
||||
|
||||
|
||||
def _gate_settings(**overrides: Any) -> Settings:
|
||||
"""``Settings`` for the endpoint with the live honesty gate plus
|
||||
*overrides* (the thinking-kill-switch pattern above) — so only the
|
||||
flag under test changes from the calibrated default."""
|
||||
live = get_settings()
|
||||
return Settings(
|
||||
_env_file=None, # pyright: ignore[reportCallIssue]
|
||||
relevance_threshold=live.relevance_threshold,
|
||||
**overrides,
|
||||
)
|
||||
|
||||
|
||||
def test_grounded_turn_carries_show_text_clause_and_tool_per_the_flag(
|
||||
client, db, seeded_kb: FakeRagLLM, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Phase 182 (task 04, LOCKED A1/A8): on a GROUNDED turn the model
|
||||
request carries the ``SHOW_TEXT_CLAUSE`` inside ``<tools>`` exactly
|
||||
when ``BOR_ANSWER_TEXT`` is on — and the ``show_text`` tool itself is
|
||||
offered on the same condition, so the model is never taught a tool it
|
||||
was not given nor handed one it was not taught (``answer_images``
|
||||
stays off here, so the offer is the three base tools alone off, the
|
||||
three base tools + ``show_text`` on)."""
|
||||
for flag in (True, False):
|
||||
monkeypatch.setattr(
|
||||
chat_api, "get_settings", lambda f=flag: _gate_settings(answer_text=f)
|
||||
)
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||
first_request = len(seeded_kb.seen_tools)
|
||||
try:
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
assert frames[-1]["deflected"] is False, f"flag={flag}"
|
||||
system = seeded_kb.seen_messages[first_request][0]["content"]
|
||||
assert (SHOW_TEXT_CLAUSE in system) is flag, f"flag={flag}"
|
||||
if flag:
|
||||
assert system.index("<tools>") < system.index(SHOW_TEXT_CLAUSE)
|
||||
assert system.index(SHOW_TEXT_CLAUSE) < system.index("</tools>")
|
||||
offered = [t["function"]["name"] for t in (seeded_kb.seen_tools[first_request] or [])]
|
||||
assert ("show_text" in offered) is flag, f"flag={flag}"
|
||||
assert len(offered) == (4 if flag else 3), f"flag={flag}: {offered}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 182 (task 05): the done frame's ADDITIVE ``answer_text`` array —
|
||||
# the SERVER-resolved verbatim blocks (the ``show_text`` tool's and the
|
||||
# ones the answer's own ```bor locator fences name), the byte-identity of
|
||||
# every frame WITHOUT them, and the per-turn line's ``verbatim_blocks=N``
|
||||
# tail. The resolver itself is pinned in tests/unit/test_verbatim_resolver.py;
|
||||
# here the wire is the subject.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: A fixture doc with a stable, countable body (16 lines as imported).
|
||||
_K8S = "docs/homelab/kubernetes.md"
|
||||
|
||||
|
||||
def _verbatim_settings(**overrides: Any) -> Settings:
|
||||
"""The endpoint's settings view with the live gate plus *overrides*
|
||||
(the ``_gate_settings`` pattern above) — only the flag under test
|
||||
changes from the calibrated default."""
|
||||
return _gate_settings(**overrides)
|
||||
|
||||
|
||||
def _doc_lines(db, combined: str) -> list[str]:
|
||||
"""The indexed row's content split the way the resolver splits it
|
||||
(``splitlines()``, 1-based) — the authority the frame's text is
|
||||
compared against, never a copy typed into this file."""
|
||||
source, _, path = combined.partition("/")
|
||||
content = db.scalar(
|
||||
select(Document.content).where(
|
||||
Document.source == source, Document.path == path
|
||||
)
|
||||
)
|
||||
assert content is not None, combined
|
||||
return content.splitlines()
|
||||
|
||||
|
||||
def test_show_text_turn_done_frame_carries_the_server_block(
|
||||
client, db, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A scripted grounded turn whose model calls
|
||||
``show_text(docs/homelab/kubernetes.md, offset=3, limit=100)`` → the
|
||||
``done`` frame carries ONE ``answer_text`` entry whose ``text`` is
|
||||
byte-identical to ``documents.content`` lines 3–16 (the overshooting
|
||||
``limit`` CLAMPED, LOCKED A2), whose extent + ``total_lines`` are the
|
||||
SERVER's, and whose ``locator`` is the canonical
|
||||
``source/path#3-16`` (D7)."""
|
||||
scripted = FakeRagLLM(
|
||||
tool_script=[
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1",
|
||||
name="show_text",
|
||||
arguments={"path": _K8S, "offset": 3, "limit": 100},
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||
try:
|
||||
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
done = frames[-1]
|
||||
lines = _doc_lines(db, _K8S)
|
||||
# Phase 167's byte-budget line covers the done-bearing turn (the
|
||||
# stream total, ``stream=1``) — the audit surface that shows the
|
||||
# blocks' growth, pinned WELL inside the 256 KB budget here.
|
||||
budget = [
|
||||
r.getMessage()
|
||||
for r in caplog.records
|
||||
if " path=/api/chat " in r.getMessage()
|
||||
]
|
||||
assert len(budget) == 1, budget
|
||||
size = int(re.search(r"bytes=(\d+)", budget[0]).group(1)) # pyright: ignore[reportOptionalMemberAccess]
|
||||
assert 0 < size < 256 * 1024, budget[0]
|
||||
assert done["deflected"] is False
|
||||
assert done["answer_text"] == [
|
||||
{
|
||||
"source": "docs",
|
||||
"path": "homelab/kubernetes.md",
|
||||
"title": "Kubernetes Homelab Cluster",
|
||||
"start_line": 3,
|
||||
"end_line": len(lines),
|
||||
"total_lines": len(lines),
|
||||
"text": "\n".join(lines[2:]),
|
||||
"truncated": False,
|
||||
"locator": f"{_K8S}#3-{len(lines)}",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_answer_fence_resolves_the_same_block_without_a_tool_call(
|
||||
client, db, seeded_kb: FakeRagLLM
|
||||
) -> None:
|
||||
"""D1/D3: the ANSWER alone carries a ```bor locator fence (no tool
|
||||
call at all) and the block still resolves — resolution is a plain
|
||||
``(source, path)`` lookup, not a replay of a tool call. The frame's
|
||||
text is the DB's bytes: the model's (deliberately corrupt) fence body
|
||||
appears NOWHERE on the wire."""
|
||||
scripted = FakeRagLLM(
|
||||
answer=(
|
||||
"Here it is:\n"
|
||||
"```bor docs/homelab/kubernetes.md 3-5\n"
|
||||
"Exec=/MODEL_TYPED_LIES/never-render.bin\n"
|
||||
"```\n"
|
||||
"That is the whole range."
|
||||
)
|
||||
)
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||
try:
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
lines = _doc_lines(db, _K8S)
|
||||
done = frames[-1]
|
||||
assert done["answer_text"] == [
|
||||
{
|
||||
"source": "docs",
|
||||
"path": "homelab/kubernetes.md",
|
||||
"title": "Kubernetes Homelab Cluster",
|
||||
"start_line": 3,
|
||||
"end_line": 5,
|
||||
"total_lines": len(lines),
|
||||
"text": "\n".join(lines[2:5]),
|
||||
"truncated": False,
|
||||
"locator": f"{_K8S}#3-5",
|
||||
}
|
||||
]
|
||||
# The model's fence body never reaches the wire as a RESOLVED block
|
||||
# (the delta frames still carry the answer text — task 06/07 drop the
|
||||
# body at render time; here the pin is that the frame's bytes are the
|
||||
# DB's, not the model's).
|
||||
assert done["answer_text"][0]["text"] == "\n".join(lines[2:5])
|
||||
assert "MODEL_TYPED_LIES" not in json.dumps(done["answer_text"])
|
||||
# The prose frames still stream the model's own answer text (the
|
||||
# render-time body drop is task 06/07's job, pinned there) — the
|
||||
# frame is what carries the truth.
|
||||
answer = "".join(
|
||||
f["text"] for f in frames if f.get("type") == "delta"
|
||||
)
|
||||
assert "MODEL_TYPED_LIES" in answer
|
||||
|
||||
|
||||
def test_fence_plus_identical_tool_call_produce_exactly_one_block(
|
||||
client, db, seeded_kb: FakeRagLLM
|
||||
) -> None:
|
||||
"""D2 dedupe: the tool shows the range AND the answer locates the
|
||||
same canonical locator → exactly ONE entry (the fence wins, the block
|
||||
is not painted twice)."""
|
||||
scripted = FakeRagLLM(
|
||||
answer=(
|
||||
"The block:\n"
|
||||
"```bor docs/homelab/kubernetes.md#3-5\n"
|
||||
"```"
|
||||
),
|
||||
tool_script=[
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1",
|
||||
name="show_text",
|
||||
arguments={"path": _K8S, "offset": 3, "limit": 3},
|
||||
)
|
||||
]
|
||||
],
|
||||
)
|
||||
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 len(done["answer_text"]) == 1
|
||||
assert done["answer_text"][0]["locator"] == f"{_K8S}#3-5"
|
||||
|
||||
|
||||
def test_three_blocks_with_max_blocks_two_send_two_in_answer_order(
|
||||
client,
|
||||
db,
|
||||
seeded_kb: FakeRagLLM,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""D8: three fences in the answer, ``BOR_SHOW_TEXT_MAX_BLOCKS=2`` →
|
||||
exactly TWO entries, in ANSWER order (the cut is the resolver's, the
|
||||
frame keeps its order, and the log line counts what was SENT)."""
|
||||
monkeypatch.setattr(
|
||||
chat_api, "get_settings", lambda: _verbatim_settings(show_text_max_blocks=2)
|
||||
)
|
||||
scripted = FakeRagLLM(
|
||||
answer=(
|
||||
"A:\n```bor docs/homelab/kubernetes.md 1-2\n```\n"
|
||||
"B:\n```bor docs/homelab/backups.md 1-2\n```\n"
|
||||
"C:\n```bor docs/deployments/new-service.md 1-2\n```"
|
||||
)
|
||||
)
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||
try:
|
||||
with caplog.at_level("INFO", logger="app.chat"):
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
done = frames[-1]
|
||||
assert [b["locator"] for b in done["answer_text"]] == [
|
||||
"docs/homelab/kubernetes.md#1-2",
|
||||
"docs/homelab/backups.md#1-2",
|
||||
]
|
||||
lines = [
|
||||
r.getMessage() for r in caplog.records if "question=" in r.getMessage()
|
||||
]
|
||||
assert lines and lines[-1].endswith("verbatim_blocks=2")
|
||||
|
||||
|
||||
def test_turn_without_blocks_serializes_without_the_answer_text_key(
|
||||
client, db, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A plain grounded turn (no tool call, no fence): the ``done`` frame
|
||||
has NO ``answer_text`` key — checked on the RAW wire text — and its
|
||||
key set is exactly the pre-phase one (the A9 byte-identity pin), with
|
||||
``verbatim_blocks=0`` on the per-turn line."""
|
||||
scripted = FakeRagLLM()
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||
try:
|
||||
with caplog.at_level("INFO", logger="app.chat"):
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
done = frames[-1]
|
||||
assert "answer_text" not in done
|
||||
assert set(done) == {
|
||||
"type",
|
||||
"deflected",
|
||||
"sources",
|
||||
"related",
|
||||
"suggestions",
|
||||
}
|
||||
lines = [
|
||||
r.getMessage() for r in caplog.records if "question=" in r.getMessage()
|
||||
]
|
||||
assert lines and lines[-1].endswith("verbatim_blocks=0")
|
||||
|
||||
|
||||
def test_answer_text_off_keeps_the_pre_phase_frame_and_no_tool(
|
||||
client, db, seeded_kb: FakeRagLLM, monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The master switch OFF (LOCKED A8/A9) with an answer that DOES
|
||||
carry a fence: the frame is byte-for-byte the pre-phase shape (no
|
||||
key), the line reads ``verbatim_blocks=0``, and the model was never
|
||||
offered ``show_text`` — every byte-identity contract is exactly
|
||||
pre-phase."""
|
||||
monkeypatch.setattr(
|
||||
chat_api, "get_settings", lambda: _verbatim_settings(answer_text=False)
|
||||
)
|
||||
scripted = FakeRagLLM(
|
||||
answer=(
|
||||
"Here it is:\n"
|
||||
"```bor docs/homelab/kubernetes.md 3-5\n"
|
||||
"Exec=/MODEL_TYPED_LIES/never-render.bin\n"
|
||||
"```"
|
||||
)
|
||||
)
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||
try:
|
||||
with caplog.at_level("INFO", logger="app.chat"):
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
done = frames[-1]
|
||||
assert done["deflected"] is False
|
||||
assert set(done) == {"type", "deflected", "sources", "related", "suggestions"}
|
||||
offered = [t["function"]["name"] for t in (scripted.seen_tools[0] or [])]
|
||||
assert "show_text" not in offered
|
||||
lines = [
|
||||
r.getMessage() for r in caplog.records if "question=" in r.getMessage()
|
||||
]
|
||||
assert lines and lines[-1].endswith("verbatim_blocks=0")
|
||||
|
||||
|
||||
def test_deflected_frame_has_no_answer_text_even_with_a_fence(
|
||||
client, db, seeded_kb: FakeRagLLM
|
||||
) -> None:
|
||||
"""D9: a deflected answer whose mocked text contains a fence still
|
||||
resolves NOTHING — the frame has no ``answer_text`` key (the
|
||||
disclaimer never leaks KB bytes) and no tools were offered."""
|
||||
scripted = FakeRagLLM(
|
||||
answer=(
|
||||
"I haven't done anything like that.\n"
|
||||
"```bor docs/homelab/kubernetes.md 3-5\n"
|
||||
"```\n"
|
||||
"Maybe try a saved runbook?"
|
||||
)
|
||||
)
|
||||
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_text" not in done
|
||||
|
||||
|
||||
def test_resolution_failure_degrades_to_no_blocks(
|
||||
client,
|
||||
db,
|
||||
seeded_kb: FakeRagLLM,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The answer already went out — a DB failure inside the done-time
|
||||
resolution must not break the stream: the turn settles with the plain
|
||||
pre-phase ``done`` frame (no key), the line reads
|
||||
``verbatim_blocks=0``, and the failure is named on the log (the
|
||||
query_log-write degradation precedent, never a silent swallow)."""
|
||||
|
||||
def _boom(*_a: Any, **_kw: Any) -> list[Any]:
|
||||
raise RuntimeError("the KB went away mid-answer")
|
||||
|
||||
monkeypatch.setattr(chat_api, "resolve_answer_blocks", _boom)
|
||||
scripted = FakeRagLLM(answer="Here:\n```bor docs/homelab/kubernetes.md 1-2\n```")
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||
try:
|
||||
with caplog.at_level("INFO", logger="app.chat"):
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
assert frames[-1]["type"] == "done"
|
||||
assert "answer_text" not in frames[-1]
|
||||
lines = [
|
||||
r.getMessage()
|
||||
for r in caplog.records
|
||||
if "question=" in r.getMessage() or "verbatim" in r.getMessage()
|
||||
]
|
||||
assert any("verbatim block resolution failed" in m for m in lines)
|
||||
turn = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
||||
assert turn and turn[-1].endswith("verbatim_blocks=0")
|
||||
|
||||
@@ -2302,3 +2302,117 @@ def test_record_without_segments_round_trips_key_absent(
|
||||
assert r2.status_code == 201, r2.text
|
||||
got2 = admin_client.get(f"/api/chats/{r2.json()['id']}")
|
||||
assert got2.json()["messages"][0]["segments"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 182 (task 05): the answer's VERBATIM display blocks on the BRAIN
|
||||
# record — the persisted twin of the done frame's ``answer_text`` (the
|
||||
# show_text tool's blocks and the ones a ```bor locator fence resolved).
|
||||
# Same phase-113 lesson as ``related`` / ``answer_image``: the key must be
|
||||
# ACCEPTED (``extra="forbid"`` otherwise 422s the done-time auto-save and
|
||||
# the quiet failure leaves the turn unsaved), round-trip byte-for-byte so
|
||||
# the restore path and the anonymous shared page re-render the panels from
|
||||
# the record's own bytes (LOCKED A4 — no new route), and stay ABSENT for
|
||||
# every record without blocks (A9, the phase-50 round-trip contract).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: One resolved block — the done frame's ``answer_text`` item shape
|
||||
#: (the SERVER's text + its 1-based inclusive extent + the canonical
|
||||
#: locator the client matches its placeholder against).
|
||||
VERBATIM_BLOCK: dict[str, Any] = {
|
||||
"source": "Homelab",
|
||||
"path": "quadlet/gufo.container",
|
||||
"title": "gufo.container",
|
||||
"start_line": 3,
|
||||
"end_line": 9,
|
||||
"total_lines": 21,
|
||||
"text": "[Unit]\nDescription=gufo\n[Service]\nExecStart=/usr/bin/gufo",
|
||||
"truncated": False,
|
||||
"locator": "Homelab/quadlet/gufo.container#3-9",
|
||||
}
|
||||
|
||||
|
||||
def test_create_round_trips_the_brain_answer_text_blocks(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""A brain record carrying ``answer_text`` → 201 (NOT the silent 422)
|
||||
and the blocks survive byte-for-byte in the stored JSONB, the create
|
||||
response and the admin detail — the panels restore from the record."""
|
||||
brain = {**FULL_BRAIN, "answer_text": [VERBATIM_BLOCK]}
|
||||
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_text"] == [VERBATIM_BLOCK]
|
||||
assert "answer_text" not in body["messages"][0]
|
||||
|
||||
raw = db.execute(
|
||||
text("SELECT messages FROM saved_chats WHERE id = :id"),
|
||||
{"id": body["id"]},
|
||||
).scalar_one()
|
||||
assert raw[1]["answer_text"] == [VERBATIM_BLOCK]
|
||||
|
||||
got = admin_client.get(f"/api/chats/{body['id']}")
|
||||
assert got.status_code == 200
|
||||
assert got.json()["messages"][1]["answer_text"] == [VERBATIM_BLOCK]
|
||||
|
||||
|
||||
def test_create_without_answer_text_round_trips_unchanged(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
"""A record WITHOUT the key (every pre-phase-182 chat, every turn
|
||||
with no blocks) round-trips UNCHANGED — no ``answer_text`` key
|
||||
anywhere, ABSENT rather than ``null``/``[]`` on every surface (A9)."""
|
||||
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_text" not in body["messages"][0]
|
||||
assert "answer_text" not in body["messages"][1]
|
||||
|
||||
got = admin_client.get(f"/api/chats/{body['id']}")
|
||||
assert got.status_code == 200
|
||||
assert "answer_text" not in got.json()["messages"][1]
|
||||
|
||||
|
||||
def test_empty_answer_text_list_is_dropped_not_stored(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
"""An empty ``answer_text`` (the client saving a turn whose resolver
|
||||
returned nothing) serializes AWAY — never ``null``, never ``[]`` —
|
||||
so the stored record is byte-identical to the no-key shape (A9)."""
|
||||
brain = {**FULL_BRAIN, "answer_text": []}
|
||||
r = admin_client.post(
|
||||
"/api/chats",
|
||||
json={"messages": [_user(FIRST_QUESTION), brain]},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
assert "answer_text" not in r.json()["messages"][1]
|
||||
|
||||
|
||||
def test_shared_serve_includes_the_brain_answer_text_blocks(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
"""The anonymous shared page renders the SAME panels from the share's
|
||||
own record (A4 — no new bytes route): the public snapshot's brain
|
||||
record carries the blocks verbatim, the user record does not."""
|
||||
brain = {**FULL_BRAIN, "answer_text": [VERBATIM_BLOCK]}
|
||||
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_text"] == [VERBATIM_BLOCK]
|
||||
assert "answer_text" not in messages[0]
|
||||
|
||||
@@ -145,6 +145,10 @@ def test_no_row_high_prompt_byte_identical_to_pre_phase(
|
||||
notes=[],
|
||||
kb_overview=None,
|
||||
folder_lines=_suggested_folder_lines(db),
|
||||
# Phase 182 (task 04): the grounded build carries the show_text
|
||||
# clause exactly when BOR_ANSWER_TEXT is on (the default) — read
|
||||
# the live flag so this pin holds with the switch either way.
|
||||
show_text=get_settings().answer_text,
|
||||
)
|
||||
assert system["content"] == expected
|
||||
assert "<knowledge_base>" not in system["content"]
|
||||
@@ -208,6 +212,8 @@ def test_row_high_prompt_carries_kb_section_exactly(
|
||||
notes=[],
|
||||
kb_overview=OVERVIEW,
|
||||
folder_lines=_suggested_folder_lines(db),
|
||||
# Phase 182 (task 04): same as above — the flag is the live one.
|
||||
show_text=get_settings().answer_text,
|
||||
)
|
||||
assert system["content"] == expected
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ from app.rag.agent import (
|
||||
AGENT_TOOLS,
|
||||
READ_TRUNCATION_NOTICE,
|
||||
REREAD_PARTIAL,
|
||||
SHOW_TEXT_TOOL,
|
||||
AgentHolder,
|
||||
run_agent,
|
||||
)
|
||||
@@ -361,8 +362,11 @@ def test_grep_then_range_read_answers_the_find_traps_question(
|
||||
assert len(llm.requests) == 3
|
||||
assert [call.name for call in llm.calls] == ["grep", "read"]
|
||||
# The tools stay offered on every request (phase 45 — no budgets).
|
||||
assert llm.requests[0][1] == AGENT_TOOLS
|
||||
assert llm.requests[2][1] == AGENT_TOOLS
|
||||
# Phase 182 (task 03) pin update, deliberate: ``answer_text``
|
||||
# defaults ON, so the grounded offer is the base three
|
||||
# (element-for-element ``AGENT_TOOLS``) plus ``show_text``.
|
||||
assert llm.requests[0][1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
|
||||
assert llm.requests[2][1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
|
||||
|
||||
# Round 1: the lowercase pattern (grep is case-insensitive).
|
||||
assert llm.calls[0].arguments == {"pattern": PATTERN}
|
||||
|
||||
+568
-34
@@ -73,9 +73,17 @@ from app.models import Document, GitSource
|
||||
from app.rag import agent
|
||||
from app.rag.agent import (
|
||||
AGENT_TOOLS,
|
||||
MISSING_SHOW_TEXT_ARGS,
|
||||
READ_TRUNCATION_NOTICE,
|
||||
SHOW_TEXT_ALREADY_SHOWN,
|
||||
SHOW_TEXT_BLOCK_CAP,
|
||||
SHOW_TEXT_NOT_A_TEXT_DOC,
|
||||
SHOW_TEXT_TOOL,
|
||||
SHOWN_TEXT_TRUNCATION_NOTICE,
|
||||
UNKNOWN_TOOL,
|
||||
AgentHolder,
|
||||
MalformedReplyError,
|
||||
agent_tools,
|
||||
run_agent,
|
||||
)
|
||||
from app.rag.llm import (
|
||||
@@ -98,6 +106,20 @@ def _settings(**kwargs: Any) -> Settings:
|
||||
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
|
||||
|
||||
#: Phase 182 (task 03) pin update, deliberate: ``answer_text``
|
||||
#: (``BOR_ANSWER_TEXT``) defaults ON, so the default grounded offer is
|
||||
#: the three base tools (element-for-element ``AGENT_TOOLS``, the
|
||||
#: byte-identity pinned separately by ``test_agent_tools_names_and_
|
||||
#: parameters``) plus the ``show_text`` display tool — every
|
||||
#: "tools stay offered" pin in this module now expects this list. The
|
||||
#: pre-phase offer is still exactly ``AGENT_TOOLS`` with
|
||||
#: ``answer_text=False`` and ``answer_images=False`` (pinned in the
|
||||
#: phase-182 offer-matrix tests below). With ``answer_images=True`` the
|
||||
#: offer is ``[*AGENT_TOOLS, SHOW_IMAGE_TOOL, SHOW_TEXT_TOOL]`` (pinned
|
||||
#: in ``tests/unit/test_image_chips_and_answers.py``).
|
||||
_OFFER = [*AGENT_TOOLS, SHOW_TEXT_TOOL]
|
||||
|
||||
|
||||
#: The fixture documents' fixed creation date (phase 106, D5): the
|
||||
#: agent formats ``doc.created_at`` on the ``read`` result's second
|
||||
#: line — the detached fixture rows carry it exactly as the NOT NULL
|
||||
@@ -547,9 +569,9 @@ def test_ls_then_read_then_answer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Phase 45: no per-tool budgets — the tools stay offered on every
|
||||
# request (the round cap, not spent budgets, bounds the loop), so
|
||||
# the answer request still carries them (2 rounds < default cap 10).
|
||||
assert llm.requests[0][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[2][1] == AGENT_TOOLS
|
||||
assert llm.requests[0][1] == _OFFER
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
assert llm.requests[2][1] == _OFFER
|
||||
assert len(llm.requests) == 3
|
||||
|
||||
# read splits the combined form at the FIRST slash — one exact
|
||||
@@ -855,7 +877,7 @@ def test_ls_scoped_known_source_with_zero_docs_counts(
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert llm.requests[1][0][3]["content"].startswith("Homelab — 0 documents, 0 folders:")
|
||||
assert holder.tool_calls == 1 # an executed ls, not a refusal
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
|
||||
|
||||
def test_ls_scoped_unknown_source_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -873,7 +895,7 @@ def test_ls_scoped_unknown_source_refused(monkeypatch: pytest.MonkeyPatch) -> No
|
||||
assert llm.requests[1][0][3]["content"].startswith(
|
||||
agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope="Ghost")
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_ls_path_like_scope_unknown_source_gets_no_source_refusal(
|
||||
@@ -900,7 +922,7 @@ def test_ls_path_like_scope_unknown_source_gets_no_source_refusal(
|
||||
assert llm.requests[1][0][3]["content"].startswith(
|
||||
agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope="app")
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_ls_dot_scope_gets_not_a_directory_teaching_refusal(
|
||||
@@ -921,7 +943,7 @@ def test_ls_dot_scope_gets_not_a_directory_teaching_refusal(
|
||||
assert llm.requests[1][0][3]["content"].startswith(
|
||||
agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope=".")
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_ls_unknown_top_level_folder_gets_not_a_folder_teaching(
|
||||
@@ -958,7 +980,7 @@ def test_ls_unknown_top_level_folder_gets_not_a_folder_teaching(
|
||||
"'Homelab/netwoking' is not a folder — Homelab has: "
|
||||
"backups/ containers/ networking/"
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_ls_unknown_nested_folder_gets_not_a_folder_with_nested_parent(
|
||||
@@ -1527,7 +1549,7 @@ def test_read_bare_source_name_refused_without_db(monkeypatch: pytest.MonkeyPatc
|
||||
assert llm.requests[1][0][3]["content"].startswith(
|
||||
"No document at 'Homelab' — check the ls output."
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
|
||||
|
||||
def test_read_unknown_path_refused_echoing_argument(
|
||||
@@ -1549,7 +1571,7 @@ def test_read_unknown_path_refused_echoing_argument(
|
||||
assert llm.requests[1][0][3]["content"].startswith(
|
||||
"No document at 'S/ghost.md' — check the ls output."
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # tools stay offered (cap bounds)
|
||||
assert llm.requests[1][1] == _OFFER # tools stay offered (cap bounds)
|
||||
|
||||
|
||||
# ---------- read/grep: the "did you mean …?" suggestions (phase 72, task 02) ----------
|
||||
@@ -1649,7 +1671,7 @@ def test_read_bare_path_exact_match_gets_did_you_mean(
|
||||
"No document at 'active/container_caddy/caddy.md' — "
|
||||
"did you mean 'Homelab/active/container_caddy/caddy.md'?"
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_read_bare_path_suffix_match_gets_did_you_mean(
|
||||
@@ -1767,7 +1789,7 @@ def test_read_bare_filename_without_slash_keeps_no_db_refusal(
|
||||
assert llm.requests[1][0][3]["content"].startswith(
|
||||
"No document at 'caddy.md' — check the ls output."
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
|
||||
|
||||
def test_read_bare_path_of_suggested_doc_gets_suggestion_then_succeeds(
|
||||
@@ -1850,7 +1872,7 @@ def test_read_missing_arguments_refused(
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||||
assert llm.requests[1][0][3]["content"].startswith(agent.MISSING_READ_ARGS)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
|
||||
|
||||
def test_reading_a_suggested_seed_doc_adds_its_full_text(
|
||||
@@ -1887,7 +1909,7 @@ def test_reading_a_suggested_seed_doc_adds_its_full_text(
|
||||
assert llm.requests[1][0][3]["content"].startswith(
|
||||
"Document Homelab/kubernetes.md:\ndate: 2024-06-15\nK8S-CONTENT"
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
|
||||
|
||||
def test_re_reading_a_suggested_doc_is_deduped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -1938,7 +1960,7 @@ def test_re_reading_a_suggested_doc_is_deduped(monkeypatch: pytest.MonkeyPatch)
|
||||
assert llm.requests[2][0][5]["content"].startswith(agent.ALREADY_IN_CONTEXT)
|
||||
# Rejected → the tools are still offered on the next request (the
|
||||
# round cap is the only bound).
|
||||
assert llm.requests[2][1] == AGENT_TOOLS
|
||||
assert llm.requests[2][1] == _OFFER
|
||||
|
||||
|
||||
def test_reading_an_already_read_doc_is_deduped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -1960,7 +1982,7 @@ def test_reading_an_already_read_doc_is_deduped(monkeypatch: pytest.MonkeyPatch)
|
||||
)
|
||||
assert llm.requests[2][0][5]["content"].startswith(agent.ALREADY_IN_CONTEXT)
|
||||
# Rejected → the tools are still offered on the next request…
|
||||
assert llm.requests[2][1] == AGENT_TOOLS
|
||||
assert llm.requests[2][1] == _OFFER
|
||||
|
||||
|
||||
# ---------- phase 95: the read cap (bounded reads, honest truncation) ----------
|
||||
@@ -2359,7 +2381,7 @@ def test_bad_range_args_refused_before_any_lookup(
|
||||
assert llm.requests[1][0][3]["content"].startswith(agent.BAD_RANGE_ARGS.format(
|
||||
offset=2.5, limit=None
|
||||
))
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
|
||||
|
||||
def test_bad_range_args_beats_the_dedupe(
|
||||
@@ -3678,7 +3700,7 @@ def test_unknown_tool_name_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||||
assert llm.requests[1][0][3]["content"].startswith(agent.UNKNOWN_TOOL)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
# ---------- phase 145 (task 01): the origin/branch lines in the read result ----------
|
||||
@@ -4062,7 +4084,7 @@ def test_grep_whole_kb_grep_style_output(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
)
|
||||
assert holder.tool_calls == 1
|
||||
assert holder.read_docs == [] # locked A5: a grep adds no context
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # tools stay offered
|
||||
|
||||
|
||||
def test_grep_capped_at_20_matches_in_catalog_order(
|
||||
@@ -4370,7 +4392,7 @@ def test_grep_scoped_missing_document_refused(monkeypatch: pytest.MonkeyPatch) -
|
||||
"No document at 'S/ghost.md' — check the ls output."
|
||||
)
|
||||
assert holder.tool_calls == 0 and holder.read_docs == [] # a refusal
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_grep_scoped_missing_path_like_doc_gets_did_you_mean(
|
||||
@@ -4408,7 +4430,7 @@ def test_grep_scoped_missing_path_like_doc_gets_did_you_mean(
|
||||
)
|
||||
)
|
||||
assert holder.tool_calls == 0 and holder.read_docs == [] # a refusal
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_grep_scoped_bare_source_name_refused_without_db(
|
||||
@@ -4467,7 +4489,7 @@ def test_grep_missing_arguments_refused(
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert llm.requests[1][0][3]["content"].startswith(agent.MISSING_SEARCH_ARGS)
|
||||
assert holder.tool_calls == 0 and holder.read_docs == []
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_grep_no_matches_whole_kb(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -4577,7 +4599,7 @@ def test_relist_executes_and_counts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# after round 1, 8 left after round 2 (10-round budget).
|
||||
assert llm.requests[2][0][3]["content"] == listing + agent.TOOL_BUDGET_REMAINING.format(n=9)
|
||||
assert llm.requests[2][0][5]["content"] == listing + agent.TOOL_BUDGET_REMAINING.format(n=8)
|
||||
assert llm.requests[2][1] == AGENT_TOOLS # still offered (no budgets)
|
||||
assert llm.requests[2][1] == _OFFER # still offered (no budgets)
|
||||
|
||||
|
||||
def test_multi_read_executes_without_budgets(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -4604,7 +4626,7 @@ def test_multi_read_executes_without_budgets(monkeypatch: pytest.MonkeyPatch) ->
|
||||
assert llm.requests[2][0][5]["content"].startswith(
|
||||
"Document S/b.md:\ndate: 2024-06-15\nB-CONTENT"
|
||||
)
|
||||
assert llm.requests[2][1] == AGENT_TOOLS # the second read was still offered
|
||||
assert llm.requests[2][1] == _OFFER # the second read was still offered
|
||||
|
||||
|
||||
# ---------- round cap (phase 45: replaces the per-tool budgets) ----------
|
||||
@@ -4632,9 +4654,9 @@ def test_always_ls_bounded_by_round_cap(monkeypatch: pytest.MonkeyPatch) -> None
|
||||
]
|
||||
assert len(llm.requests) == 4 # 3 tool rounds + the forced answer
|
||||
# The three tool rounds were offered the tools…
|
||||
assert llm.requests[0][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[2][1] == AGENT_TOOLS
|
||||
assert llm.requests[0][1] == _OFFER
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
assert llm.requests[2][1] == _OFFER
|
||||
# …and the forced final request carries no tools, whatever is left.
|
||||
assert llm.requests[3][1] is None
|
||||
# Every re-list executed and counted.
|
||||
@@ -4686,9 +4708,9 @@ def test_rejected_read_spam_runs_to_round_cap(monkeypatch: pytest.MonkeyPatch) -
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings(agent_max_rounds=3)))
|
||||
assert len(llm.requests) == 4 # 3 rejected rounds + the forced answer
|
||||
assert llm.requests[0][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[2][1] == AGENT_TOOLS
|
||||
assert llm.requests[0][1] == _OFFER
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
assert llm.requests[2][1] == _OFFER
|
||||
assert llm.requests[3][1] is None # the forced final request: no tools
|
||||
assert holder.read_docs == [] and holder.tool_calls == 0 # nothing executed
|
||||
refusal = "No document at 'S/ghost.md' — check the ls output."
|
||||
@@ -5101,8 +5123,8 @@ def test_round_retried_before_first_piece(
|
||||
# The restart is byte-identical: same messages, same tools offered.
|
||||
assert len(llm.requests) == 3
|
||||
assert llm.requests[0] == llm.requests[1]
|
||||
assert llm.requests[0][1] == AGENT_TOOLS
|
||||
assert llm.requests[2][1] == AGENT_TOOLS # the answer round still offered
|
||||
assert llm.requests[0][1] == _OFFER
|
||||
assert llm.requests[2][1] == _OFFER # the answer round still offered
|
||||
# The flat delay was awaited exactly once, before the retry.
|
||||
assert sleeps == [2.5]
|
||||
tool_logs = [r for r in caplog.records if r.getMessage().startswith("agent tool=")]
|
||||
@@ -5397,7 +5419,7 @@ def test_scaffolding_only_round_gets_exactly_one_recovery(
|
||||
assert pieces == [StreamPiece("content", "The clean recovery answer.")]
|
||||
assert len(llm.requests) == 2 # the round + the one recovery
|
||||
# The round: the original system prompt, the tools offered.
|
||||
assert llm.requests[0][1] == AGENT_TOOLS
|
||||
assert llm.requests[0][1] == _OFFER
|
||||
assert llm.requests[0][0] == [
|
||||
{"role": "system", "content": "SYSTEM_PROMPT"},
|
||||
{"role": "user", "content": "QUESTION"},
|
||||
@@ -5534,7 +5556,7 @@ def test_scaffolding_round_with_tool_calls_needs_no_recovery(
|
||||
StreamPiece("content", "the answer after the tool"),
|
||||
]
|
||||
assert len(llm.requests) == 2
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # a normal round, not a recovery
|
||||
assert llm.requests[1][1] == _OFFER # a normal round, not a recovery
|
||||
for messages, _tools in llm.requests:
|
||||
assert all(
|
||||
m["content"] is None or agent.CORRECTION_INSTRUCTION not in m["content"]
|
||||
@@ -5615,3 +5637,515 @@ def test_forced_final_scaffolding_only_yields_the_budget_fallback(
|
||||
# calls — this is the fallback path, not the rejection path.
|
||||
assert holder.rejected_calls == 0
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 182 (task 03) — the 5th agent tool: ``show_text(path, offset?, limit?)``
|
||||
#
|
||||
# The display sibling of ``show_image`` (the phase-124 family in
|
||||
# ``tests/unit/test_image_chips_and_answers.py`` is the template): the model
|
||||
# NAMES a location, the SERVER shows the bytes (LOCKED A1–A3/A6). The suite
|
||||
# pins the offer matrix (``agent_tools``, four base + two display tools, each
|
||||
# behind its own flag), the successful result shape (the ``read``/
|
||||
# ``show_image`` header contract, the SERVER's extent in the ``lines:``
|
||||
# header), ``holder.shown_texts`` (the read-after-stream signal task 05 puts
|
||||
# on the ``done`` frame), and EVERY refusal — each counts in nothing,
|
||||
# appends nothing, and consumes a round like every other refusal.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_SHOW_SRC = "showsrc"
|
||||
_SHOW_PATH = "quadlets/gufo.container"
|
||||
_SHOW_IDENTITY = f"{_SHOW_SRC}/{_SHOW_PATH}"
|
||||
_SHOW_CONTENT = "L1-one\nL2-two\nL3-three\nL4-four\nL5-five"
|
||||
|
||||
|
||||
def _show_doc(content: str = _SHOW_CONTENT, *, is_image: bool = False) -> Document:
|
||||
"""The fixture row ``show_text`` resolves (detached, like every
|
||||
agent-suite fixture); ``is_image`` flips the A6 refusal case."""
|
||||
doc = _doc(_SHOW_SRC, _SHOW_PATH, "Gufo quadlet", content)
|
||||
doc.is_image = is_image
|
||||
return doc
|
||||
|
||||
|
||||
def _patch_show_doc(
|
||||
monkeypatch: pytest.MonkeyPatch, doc: Document | None
|
||||
) -> None:
|
||||
"""One resolvable document for ``_resolve_path`` (the same
|
||||
monkeypatch style as the read family: the DB accessors are the seam)."""
|
||||
|
||||
def _find(_db: Session, source: str, path: str) -> Document | None:
|
||||
if doc is not None and (source, path) == (doc.source, doc.path):
|
||||
return doc
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(agent, "find_document", _find)
|
||||
|
||||
|
||||
def _show_text(
|
||||
arguments: dict[str, Any],
|
||||
holder: AgentHolder,
|
||||
settings: Settings | None = None,
|
||||
) -> str:
|
||||
"""One ``show_text`` call through the pure ``_execute_tool`` seam (the
|
||||
budget trailer is appended by ``run_agent``, never here — the raw
|
||||
result strings are what this suite pins)."""
|
||||
return agent._execute_tool(
|
||||
_mock_session(),
|
||||
ToolCallPiece(id="c1", name="show_text", arguments=arguments),
|
||||
[],
|
||||
holder,
|
||||
settings or _settings(),
|
||||
)
|
||||
|
||||
|
||||
# --- the offer matrix (the task-03 completion pin: 4 base + 2 display) ---
|
||||
|
||||
|
||||
def test_show_text_offer_matrix_agent_tools_by_flags() -> None:
|
||||
"""``agent_tools`` offers FOUR tools with ``answer_text=False`` (the
|
||||
pre-phase-182 offer byte-identical — ``show_image`` last when
|
||||
offered) and FIVE with it ON (``show_text`` present, correct order
|
||||
``ls, read, grep, show_image, show_text``); both flags off is
|
||||
element-for-element ``AGENT_TOOLS`` — a fresh list, never the
|
||||
constant."""
|
||||
pre182 = agent_tools(_settings(answer_images=True, answer_text=False))
|
||||
assert [t["function"]["name"] for t in pre182] == [
|
||||
"ls",
|
||||
"read",
|
||||
"grep",
|
||||
"show_image",
|
||||
]
|
||||
assert pre182[:3] == AGENT_TOOLS # the base three byte-identical
|
||||
|
||||
both = agent_tools(_settings(answer_images=True, answer_text=True))
|
||||
assert [t["function"]["name"] for t in both] == [
|
||||
"ls",
|
||||
"read",
|
||||
"grep",
|
||||
"show_image",
|
||||
"show_text",
|
||||
]
|
||||
assert both[:3] == AGENT_TOOLS
|
||||
|
||||
pre124 = agent_tools(_settings(answer_images=False, answer_text=False))
|
||||
assert pre124 == AGENT_TOOLS # element-for-element
|
||||
assert pre124 is not AGENT_TOOLS # a fresh list, never the constant
|
||||
pre124.append({"junk": True}) # mutating the offer must not touch it
|
||||
assert len(AGENT_TOOLS) == 3
|
||||
|
||||
# The shipped default: ``answer_text`` is ON (LOCKED A8), so the
|
||||
# default grounded offer already carries the 5th tool.
|
||||
default = agent_tools(_settings())
|
||||
assert [t["function"]["name"] for t in default] == [
|
||||
"ls",
|
||||
"read",
|
||||
"grep",
|
||||
"show_text",
|
||||
]
|
||||
assert default == _OFFER # the module-wide offer pin stays honest
|
||||
|
||||
|
||||
def test_show_text_tool_definition_is_the_behavior_contract() -> None:
|
||||
"""The same OpenAI function shape as the other tools (one required
|
||||
string ``path`` + the OPTIONAL ``offset``/``limit`` integers), and
|
||||
the description IS the behavior contract (the phase-124 discipline):
|
||||
verbatim display, the do-NOT-retype instruction, the server-owned
|
||||
extent (clamp + past-end teaching), the ``show_image`` pointer
|
||||
(A6), the bare-path teaching mirrored from ``read``, and the
|
||||
one-call-at-a-time discipline."""
|
||||
assert SHOW_TEXT_TOOL["type"] == "function"
|
||||
fn = SHOW_TEXT_TOOL["function"]
|
||||
assert fn["name"] == "show_text"
|
||||
params = fn["parameters"]
|
||||
assert params["type"] == "object"
|
||||
assert params["required"] == ["path"]
|
||||
props = params["properties"]
|
||||
assert set(props) == {"path", "offset", "limit"}
|
||||
assert props["path"]["type"] == "string"
|
||||
assert props["offset"]["type"] == "integer"
|
||||
assert props["limit"]["type"] == "integer"
|
||||
# The ``read`` bare-path teaching reused verbatim (one grammar).
|
||||
assert (
|
||||
"A bare document path (without the source name) will not resolve."
|
||||
in props["path"]["description"]
|
||||
)
|
||||
desc = fn["description"]
|
||||
assert "EXACTLY as stored" in desc
|
||||
assert "do NOT retype the content yourself" in desc
|
||||
assert "`offset` (1-based first line) and `limit` (line count)" in desc
|
||||
assert "overshooting `limit` is clamped" in desc
|
||||
assert "refused with the true line" in desc
|
||||
assert "Only text documents — for an image use `show_image`" in desc
|
||||
assert "Call one tool at a time" in desc
|
||||
|
||||
|
||||
def test_show_text_refusal_texts_are_pinned() -> None:
|
||||
"""The four refusal texts are pinned (the model's self-correction
|
||||
relies on stable copy), and the range problems REUSE the read
|
||||
constants — one copy of each string in the codebase."""
|
||||
assert MISSING_SHOW_TEXT_ARGS == "show_text requires a string argument 'path'."
|
||||
assert SHOW_TEXT_NOT_A_TEXT_DOC.format(arg="s/p.png") == (
|
||||
"'s/p.png' is a standalone image document — use show_image to "
|
||||
"display images."
|
||||
)
|
||||
assert SHOW_TEXT_BLOCK_CAP.format(max=5) == (
|
||||
"5 text blocks are already displayed this answer — describe them "
|
||||
"instead of showing more."
|
||||
)
|
||||
assert SHOW_TEXT_ALREADY_SHOWN.format(locator="s/p.md#12-40") == (
|
||||
"s/p.md#12-40 is already on screen this answer — say so instead "
|
||||
"of showing it again."
|
||||
)
|
||||
# The reuse pins: the branch formats THESE constants, not copies —
|
||||
# the exact same teaching strings (and echoed values) ``read`` uses.
|
||||
assert agent.BAD_RANGE_ARGS.format(offset=0, limit=None) == (
|
||||
"`offset` and `limit` are whole line numbers — pass integers ≥ 1 "
|
||||
"(you passed offset=0, limit=None)."
|
||||
)
|
||||
assert agent.OFFSET_PAST_END.format(
|
||||
source=_SHOW_SRC, path=_SHOW_PATH, total_lines=5, offset=9
|
||||
).startswith(f"{_SHOW_IDENTITY} is 5 lines long — `offset` 9 is past the end.")
|
||||
|
||||
|
||||
# --- the successful show: whole document, ranged, capped ---
|
||||
|
||||
|
||||
def test_show_text_whole_document_success_shape(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Whole-document show (the taught default): result headers in the
|
||||
exact ``read``/``show_image`` order (a NEW first line, then
|
||||
``Document …``, ``date:``, the ``lines:`` extent, the VERBATIM
|
||||
slice), ``shown_texts`` carries the one block (canonical whole-doc
|
||||
locator), ``tool_calls`` counts the executed call, and the READ
|
||||
bookkeeping stays untouched (a show is not a context-adder)."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
result = _show_text({"path": _SHOW_IDENTITY}, holder)
|
||||
assert result == (
|
||||
"Text shown to the user — these lines are displayed verbatim; "
|
||||
"describe them, do NOT retype them.\n"
|
||||
f"Document {_SHOW_IDENTITY}:\n"
|
||||
"date: 2024-06-15\n"
|
||||
"lines: 1-5 of 5\n"
|
||||
f"{_SHOW_CONTENT}"
|
||||
)
|
||||
assert result.splitlines()[1].startswith("Document ") # the mock's contract
|
||||
assert len(holder.shown_texts) == 1
|
||||
block = holder.shown_texts[0]
|
||||
assert block.locator == _SHOW_IDENTITY # whole doc → no range part
|
||||
assert (block.start_line, block.end_line, block.total_lines) == (1, 5, 5)
|
||||
assert block.truncated is False
|
||||
assert holder.tool_calls == 1 # executed-only counting
|
||||
# A show NEVER touches the read paths (it is not a context-adder).
|
||||
assert holder.read_docs == []
|
||||
assert holder.read_coverage == []
|
||||
assert holder.read_truncations == []
|
||||
|
||||
|
||||
def test_show_text_ranged_show_clamps_limit_in_the_lines_header(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Ranged show with an OVERSHOOTING ``limit``: the header carries the
|
||||
SERVER's clamped extent (``lines: 4-5 of 5`` — the caption never
|
||||
lies), the slice is exactly the displayed lines, and the canonical
|
||||
locator carries the clamped range (the D7 matching key)."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
result = _show_text({"path": _SHOW_IDENTITY, "offset": 4, "limit": 99}, holder)
|
||||
assert result == (
|
||||
"Text shown to the user — these lines are displayed verbatim; "
|
||||
"describe them, do NOT retype them.\n"
|
||||
f"Document {_SHOW_IDENTITY}:\n"
|
||||
"date: 2024-06-15\n"
|
||||
"lines: 4-5 of 5\n"
|
||||
"L4-four\nL5-five"
|
||||
)
|
||||
block = holder.shown_texts[0]
|
||||
assert block.locator == f"{_SHOW_IDENTITY}#4-5"
|
||||
assert (block.start_line, block.end_line, block.total_lines) == (4, 5, 5)
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
|
||||
def test_show_text_capped_block_carries_marker_and_notice(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Capped show (``settings.show_text_max_chars``): the slice is cut
|
||||
at a LINE boundary, the shared ``TRUNCATION_MARKER`` rides the block,
|
||||
the ``lines:`` extent SHRINKS to the last displayed line (never the
|
||||
asked extent), and the pinned notice names that the rest is NOT
|
||||
displayed and the user opens the document (no re-show loop implied)."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
result = _show_text(
|
||||
{"path": _SHOW_IDENTITY},
|
||||
holder,
|
||||
_settings(show_text_max_chars=13),
|
||||
)
|
||||
assert TRUNCATION_MARKER in result
|
||||
assert result.endswith(
|
||||
SHOWN_TEXT_TRUNCATION_NOTICE.format(source=_SHOW_SRC, path=_SHOW_PATH)
|
||||
)
|
||||
block = holder.shown_texts[0]
|
||||
assert block.truncated is True
|
||||
# The budget (13 chars) lands exactly after line 2 — the caption
|
||||
# shrinks to the lines ACTUALLY shown (never the asked 1-5).
|
||||
assert block.end_line == 2
|
||||
assert block.locator == f"{_SHOW_IDENTITY}#1-2" # cap-derived range
|
||||
assert f"lines: 1-2 of 5\nL1-one\nL2-two\n{TRUNCATION_MARKER}" in result
|
||||
assert holder.tool_calls == 1 # a capped show is still a successful call
|
||||
|
||||
|
||||
def test_show_text_origin_lines_ridge_the_header_like_read(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The phase-145 origin contract: a git-sourced show carries the
|
||||
``origin:``/``branch:`` lines immediately after ``date:`` and BEFORE
|
||||
``lines:`` (the same ``_origin_lines`` block ``read`` renders; the
|
||||
second line stays ``Document …:``)."""
|
||||
doc = _show_doc()
|
||||
_patch_show_doc(monkeypatch, doc)
|
||||
|
||||
def _factory() -> Session:
|
||||
from app.models import GitSource
|
||||
|
||||
rows = [GitSource(url="https://github.com/owner/showsrc", kind="git", branch="main")]
|
||||
mock = cast("Session", MagicMock())
|
||||
mock.__enter__ = MagicMock(return_value=mock)
|
||||
mock.__exit__ = MagicMock(return_value=False)
|
||||
mock.scalars = MagicMock(return_value=MagicMock(all=MagicMock(return_value=rows)))
|
||||
return mock
|
||||
|
||||
holder = AgentHolder()
|
||||
result = agent._execute_tool(
|
||||
_factory(),
|
||||
ToolCallPiece(id="c1", name="show_text", arguments={"path": _SHOW_IDENTITY}),
|
||||
[],
|
||||
holder,
|
||||
_settings(),
|
||||
)
|
||||
lines = result.splitlines()
|
||||
assert lines[1] == f"Document {_SHOW_IDENTITY}:"
|
||||
assert lines[2] == "date: 2024-06-15"
|
||||
assert lines[3] == "origin: https://github.com/owner/showsrc"
|
||||
assert lines[4] == "branch: main"
|
||||
assert lines[5] == "lines: 1-5 of 5"
|
||||
|
||||
|
||||
# --- the refusal matrix: each counts in nothing, appends nothing ---
|
||||
|
||||
|
||||
def _assert_refusal(holder: AgentHolder) -> None:
|
||||
assert holder.tool_calls == 0
|
||||
assert holder.shown_texts == []
|
||||
assert holder.read_docs == []
|
||||
assert holder.read_coverage == []
|
||||
|
||||
|
||||
def test_show_text_missing_or_non_string_path_refused() -> None:
|
||||
"""Missing, blank and non-string ``path`` → ``MISSING_SHOW_TEXT_ARGS``
|
||||
(the ``MISSING_SHOW_ARGS`` shape); counts in nothing."""
|
||||
holder = AgentHolder()
|
||||
for arguments in ({}, {"path": " "}, {"path": None}, {"path": 42}):
|
||||
assert _show_text(arguments, holder) == MISSING_SHOW_TEXT_ARGS
|
||||
_assert_refusal(holder)
|
||||
|
||||
|
||||
def test_show_text_unknown_path_gets_the_did_you_mean_teaching(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The phase-72 teaching, REUSED unchanged: a bare argument keeps the
|
||||
no-DB line, a path-like argument matching one indexed ``path`` gets
|
||||
the combined-identity suggestion — nothing displayed either way."""
|
||||
_patch_show_doc(monkeypatch, None)
|
||||
doc = _show_doc()
|
||||
monkeypatch.setattr(
|
||||
agent,
|
||||
"find_path_candidates",
|
||||
lambda db, arg: [(_SHOW_SRC, _SHOW_PATH, doc.title)],
|
||||
)
|
||||
holder = AgentHolder()
|
||||
result = _show_text({"path": "quadlets/gufo.container"}, holder)
|
||||
assert result == agent.NO_DOCUMENT_DID_YOU_MEAN.format(
|
||||
arg="quadlets/gufo.container", source=_SHOW_SRC, path=_SHOW_PATH
|
||||
)
|
||||
assert _show_text({"path": "nope"}, holder) == (
|
||||
"No document at 'nope' — check the ls output."
|
||||
)
|
||||
_assert_refusal(holder)
|
||||
|
||||
|
||||
def test_show_text_on_an_image_doc_is_refused_never_converted(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""LOCKED A6: a standalone IMAGE row is refused with the
|
||||
``show_image`` pointer — never silently converted (the exact mirror
|
||||
of ``show_image``'s ``NOT_AN_IMAGE_DOC``); counts in nothing."""
|
||||
_patch_show_doc(monkeypatch, _show_doc(is_image=True))
|
||||
holder = AgentHolder()
|
||||
result = _show_text({"path": _SHOW_IDENTITY}, holder)
|
||||
assert result == SHOW_TEXT_NOT_A_TEXT_DOC.format(arg=_SHOW_IDENTITY)
|
||||
_assert_refusal(holder)
|
||||
|
||||
|
||||
def test_show_text_bad_range_args_reuse_the_read_teaching(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A non-whole/``< 1`` ``offset``/``limit`` → :data:`BAD_RANGE_ARGS`
|
||||
with the values echoed — the SAME constant the ``read`` branch
|
||||
formats (one copy of the string), fired before any display."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
assert _show_text({"path": _SHOW_IDENTITY, "offset": 0}, holder) == (
|
||||
agent.BAD_RANGE_ARGS.format(offset=0, limit=None)
|
||||
)
|
||||
assert _show_text({"path": _SHOW_IDENTITY, "offset": 1, "limit": "x"}, holder) == (
|
||||
agent.BAD_RANGE_ARGS.format(offset=1, limit="x")
|
||||
)
|
||||
_assert_refusal(holder)
|
||||
|
||||
|
||||
def test_show_text_offset_past_end_names_the_true_total(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An ``offset`` past the last line → :data:`OFFSET_PAST_END` (the
|
||||
SAME teaching as ``read``) naming the document's TRUE line count;
|
||||
counts in nothing, a corrected retry still proceeds."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
result = _show_text({"path": _SHOW_IDENTITY, "offset": 9}, holder)
|
||||
assert result == agent.OFFSET_PAST_END.format(
|
||||
source=_SHOW_SRC, path=_SHOW_PATH, total_lines=5, offset=9
|
||||
)
|
||||
assert "is 5 lines long" in result
|
||||
_assert_refusal(holder)
|
||||
|
||||
|
||||
def test_show_text_empty_document_is_refused_like_past_end(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An EMPTY document has nothing to display: the resolver's
|
||||
past-the-end refusal carries the true total (``0 lines long``) —
|
||||
never an empty block, never a crash."""
|
||||
_patch_show_doc(monkeypatch, _show_doc(content=""))
|
||||
holder = AgentHolder()
|
||||
result = _show_text({"path": _SHOW_IDENTITY}, holder)
|
||||
assert result == agent.OFFSET_PAST_END.format(
|
||||
source=_SHOW_SRC, path=_SHOW_PATH, total_lines=0, offset=1
|
||||
)
|
||||
_assert_refusal(holder)
|
||||
|
||||
|
||||
def test_show_text_block_cap_refuses_second_show(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The per-answer block budget (server-enforced): with
|
||||
``show_text_max_blocks=1`` a SECOND, DIFFERENT show is refused with
|
||||
the cap copy — the first block stays displayed, the refusal counts
|
||||
in nothing."""
|
||||
doc_a = _show_doc()
|
||||
doc_b = _doc(_SHOW_SRC, "quadlets/other.container", "Other", "B1\nB2")
|
||||
docs = {doc_a.path: doc_a, doc_b.path: doc_b}
|
||||
|
||||
def _find(_db: Session, source: str, path: str) -> Document | None:
|
||||
return docs.get(path) if source == _SHOW_SRC else None
|
||||
|
||||
monkeypatch.setattr(agent, "find_document", _find)
|
||||
holder = AgentHolder()
|
||||
settings = _settings(show_text_max_blocks=1)
|
||||
first = _show_text({"path": _SHOW_IDENTITY}, holder, settings)
|
||||
assert first.startswith("Text shown to the user —")
|
||||
second = _show_text({"path": f"{_SHOW_SRC}/quadlets/other.container"}, holder, settings)
|
||||
assert second == SHOW_TEXT_BLOCK_CAP.format(max=1)
|
||||
assert holder.tool_calls == 1 # the refusal counts in nothing
|
||||
assert len(holder.shown_texts) == 1 # still exactly the first block
|
||||
|
||||
|
||||
def test_show_text_zero_knobs_refuse_the_first_call(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The ``0`` kill switches (D8): ``show_text_max_blocks=0`` refuses
|
||||
the FIRST call with the cap copy, and ``show_text_max_chars=0`` (the
|
||||
inert per-block budget — nothing could display) refuses with the very
|
||||
same copy instead of reaching the resolver's raise. The tool stays
|
||||
OFFERED either way (the offer is ``answer_text``-gated only); counts
|
||||
in nothing."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
assert _show_text(
|
||||
{"path": _SHOW_IDENTITY}, holder, _settings(show_text_max_blocks=0)
|
||||
) == SHOW_TEXT_BLOCK_CAP.format(max=0)
|
||||
assert _show_text(
|
||||
{"path": _SHOW_IDENTITY}, holder, _settings(show_text_max_chars=0)
|
||||
) == SHOW_TEXT_BLOCK_CAP.format(max=0)
|
||||
_assert_refusal(holder)
|
||||
# The offer is untouched by the budgets (the gate is the flag).
|
||||
assert [t["function"]["name"] for t in agent_tools(
|
||||
_settings(show_text_max_blocks=0, show_text_max_chars=0)
|
||||
)] == ["ls", "read", "grep", "show_text"]
|
||||
|
||||
|
||||
def test_show_text_duplicate_identical_show_refused(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The canonical-locator dedupe: showing the EXACT same block twice
|
||||
is refused (``SHOW_TEXT_ALREADY_SHOWN`` names the locator) — the
|
||||
first block stays on screen, the refusal counts in nothing."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
first = _show_text({"path": _SHOW_IDENTITY, "offset": 2, "limit": 2}, holder)
|
||||
assert first.startswith("Text shown to the user —")
|
||||
second = _show_text({"path": _SHOW_IDENTITY, "offset": 2, "limit": 2}, holder)
|
||||
assert second == SHOW_TEXT_ALREADY_SHOWN.format(locator=f"{_SHOW_IDENTITY}#2-3")
|
||||
assert holder.tool_calls == 1
|
||||
assert len(holder.shown_texts) == 1
|
||||
|
||||
|
||||
def test_show_text_refused_when_answer_text_flag_off(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Defensive posture: with ``answer_text=False`` the tool was never
|
||||
OFFERED, so a call anyway is answered like an un-offered name (the
|
||||
tools-off posture — :data:`UNKNOWN_TOOL`) — counts in nothing,
|
||||
displays nothing (the escape hatch stays byte-identical everywhere)."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
result = _show_text({"path": _SHOW_IDENTITY}, holder, _settings(answer_text=False))
|
||||
assert result == UNKNOWN_TOOL
|
||||
_assert_refusal(holder)
|
||||
|
||||
|
||||
# --- the scripted turn (the completion-criteria wiring pin) ---
|
||||
|
||||
|
||||
def test_show_text_scripted_turn_returns_the_slice_to_the_model(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Completion pin: with ``answer_text=True`` a scripted agent turn
|
||||
returns a result whose 2nd line starts ``Document `` and whose
|
||||
``lines:`` header matches the slice actually returned; the block
|
||||
rides the holder for the done frame (task 05) and the tool-offer
|
||||
on the request carried the 5th tool."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="c1", name="show_text", arguments={"path": _SHOW_IDENTITY, "offset": 3})],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings())) # answer_text default ON
|
||||
assert llm.requests[0][1] == _OFFER # the grounded turn offers show_text
|
||||
content = llm.requests[1][0][3]["content"]
|
||||
assert content.endswith(agent._budget_note(9)) # the trailer rides too
|
||||
result = content.removesuffix(agent._budget_note(9))
|
||||
lines = result.splitlines()
|
||||
assert lines[0] == (
|
||||
"Text shown to the user — these lines are displayed verbatim; "
|
||||
"describe them, do NOT retype them."
|
||||
)
|
||||
assert lines[1].startswith("Document ")
|
||||
assert lines[3] == "lines: 3-5 of 5" # offset-only: to the END, clamped
|
||||
assert result.endswith("L3-three\nL4-four\nL5-five") # the slice verbatim
|
||||
assert len(holder.shown_texts) == 1
|
||||
assert holder.shown_texts[0].locator == f"{_SHOW_IDENTITY}#3-5"
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
@@ -23,7 +23,7 @@ from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import Document, KbOverview, QueryLog
|
||||
from app.rag import agent as rag_agent
|
||||
from app.rag.agent import AGENT_TOOLS
|
||||
from app.rag.agent import AGENT_TOOLS, SHOW_TEXT_TOOL
|
||||
from app.rag.llm import StreamPiece, ToolCallPiece
|
||||
from app.rag.prompts import SUGGEST_INTRO, build_deflect_prompt
|
||||
from app.rag.retriever import RetrievedChunk, weak_hit_titles
|
||||
@@ -1135,10 +1135,13 @@ def test_endpoint_grounded_turn_runs_agent_loop_with_tools(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 37: a grounded endpoint turn runs the agent loop — the
|
||||
single no-tool-call request carries ``AGENT_TOOLS`` (the default
|
||||
round cap keeps the tools offered), no ``tool`` frames stream, and
|
||||
the ``done`` event is the plain retrieval shape (the tool-free
|
||||
answer is byte-identical)."""
|
||||
single no-tool-call request carries the offered tool list (the
|
||||
default round cap keeps the tools offered), no ``tool`` frames
|
||||
stream, and the ``done`` event is the plain retrieval shape (the
|
||||
tool-free answer is byte-identical). Phase 182 (task 03) pin
|
||||
update, deliberate: the offer is the three base tools plus
|
||||
``show_text`` (``answer_text`` defaults ON), the base three staying
|
||||
element-for-element ``AGENT_TOOLS`."""
|
||||
_session, llm = gate_env
|
||||
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)]))
|
||||
@@ -1149,7 +1152,12 @@ def test_endpoint_grounded_turn_runs_agent_loop_with_tools(
|
||||
assert frames[-1]["deflected"] is False
|
||||
assert not any(f["type"] == "tool" for f in frames)
|
||||
assert len(llm.seen) == 1
|
||||
assert llm.seen_tools == [AGENT_TOOLS] # one request, tools offered
|
||||
# Phase 182 (task 03): ``answer_text`` defaults ON — the offer is
|
||||
# the base three (byte-identical) plus ``show_text``.
|
||||
assert llm.seen_tools == [[*AGENT_TOOLS, SHOW_TEXT_TOOL]] # one request
|
||||
offered = llm.seen_tools[0]
|
||||
assert offered is not None
|
||||
assert offered[:3] == AGENT_TOOLS # the base offer untouched
|
||||
# The system prompt is the HIGH prompt with the <tools> instructions
|
||||
# (phase 70: the harness-aligned ls/read/grep copy — new names in,
|
||||
# old phase-37/68 names out).
|
||||
@@ -1600,6 +1608,9 @@ def test_endpoint_log_line_records_suggested_after_summary_hits(
|
||||
"total_ms=",
|
||||
"retries=",
|
||||
"scaffold_stripped=",
|
||||
# phase 182 (task 05): the PLAN §9 additive tail — the blocks
|
||||
# actually SENT this turn, last on the line.
|
||||
"verbatim_blocks=",
|
||||
)
|
||||
idx = -1
|
||||
for field in order:
|
||||
@@ -1669,3 +1680,140 @@ def test_endpoint_log_line_records_suggested_after_summary_hits(
|
||||
line = _last_log_line()
|
||||
assert "thinking=- thinking_chars=" in line
|
||||
assert "thinking=high" not in line
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 182 (task 05): the per-turn log line's ``verbatim_blocks=N`` tail
|
||||
# and the deflected turn's resolve-noting posture (D9). The count is the
|
||||
# blocks ACTUALLY SENT (after the canonical-locator dedupe and the
|
||||
# ``show_text_max_blocks`` cut), so the resolver is faked here — its own
|
||||
# behaviour is pinned in ``tests/unit/test_verbatim_resolver.py`` and the
|
||||
# real end-to-end fusion in ``tests/integration/test_chat_api.py``.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _block(n: int) -> Any:
|
||||
"""One fake resolved block (the resolver's return type — the counting
|
||||
and the frame wiring are what these pins are about)."""
|
||||
from app.rag.verbatim import VerbatimBlock
|
||||
|
||||
return VerbatimBlock(
|
||||
source="Homelab",
|
||||
path=f"quadlet/gufo.container{n}",
|
||||
title=f"gufo{n}",
|
||||
start_line=1,
|
||||
end_line=4,
|
||||
total_lines=12,
|
||||
text=f"[Unit{n}\nExec=gufo",
|
||||
truncated=False,
|
||||
locator=f"Homelab/quadlet/gufo.container{n}#1-4",
|
||||
)
|
||||
|
||||
|
||||
def test_log_line_ends_with_verbatim_blocks_zero_on_a_plain_turn(
|
||||
client: TestClient,
|
||||
gate_env: tuple[_FakeSession, _CannedLLM],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A plain grounded turn (no tool blocks, no fence in the answer)
|
||||
ends ``… scaffold_stripped=0 verbatim_blocks=0`` — the PLAN §9 field
|
||||
is UNIFORM across turn shapes (the phase-67 ``retries=N`` pattern),
|
||||
and the ``done`` frame carries no ``answer_text`` key (A9)."""
|
||||
_session, _llm = gate_env
|
||||
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.30)]))
|
||||
|
||||
with caplog.at_level("INFO", logger="app.chat"):
|
||||
frames = _ask(client, "How is my Kubernetes cluster set up?")
|
||||
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
||||
assert lines
|
||||
assert lines[-1].endswith("scaffold_stripped=0 verbatim_blocks=0")
|
||||
done = [f for f in frames if f.get("type") == "done"]
|
||||
assert done and "answer_text" not in done[0]
|
||||
|
||||
|
||||
def test_log_line_counts_the_verbatim_blocks_sent(
|
||||
client: TestClient,
|
||||
gate_env: tuple[_FakeSession, _CannedLLM],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Two resolved blocks → ``verbatim_blocks=2`` AND both ride the
|
||||
``done`` frame's ``answer_text`` in the resolver's order (the frame
|
||||
and the line count the SAME list — never the raw tool-call count)."""
|
||||
_session, _llm = gate_env
|
||||
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.30)]))
|
||||
monkeypatch.setattr(
|
||||
chat_api, "resolve_answer_blocks", lambda *_a: [_block(1), _block(2)]
|
||||
)
|
||||
|
||||
with caplog.at_level("INFO", logger="app.chat"):
|
||||
frames = _ask(client, "How is my Kubernetes cluster set up?")
|
||||
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
||||
assert lines and lines[-1].endswith("verbatim_blocks=2")
|
||||
|
||||
done = [f for f in frames if f.get("type") == "done"]
|
||||
assert done
|
||||
assert done[0]["answer_text"] == [
|
||||
{
|
||||
"source": "Homelab",
|
||||
"path": "quadlet/gufo.container1",
|
||||
"title": "gufo1",
|
||||
"start_line": 1,
|
||||
"end_line": 4,
|
||||
"total_lines": 12,
|
||||
"text": "[Unit1\nExec=gufo",
|
||||
"truncated": False,
|
||||
"locator": "Homelab/quadlet/gufo.container1#1-4",
|
||||
},
|
||||
{
|
||||
"source": "Homelab",
|
||||
"path": "quadlet/gufo.container2",
|
||||
"title": "gufo2",
|
||||
"start_line": 1,
|
||||
"end_line": 4,
|
||||
"total_lines": 12,
|
||||
"text": "[Unit2\nExec=gufo",
|
||||
"truncated": False,
|
||||
"locator": "Homelab/quadlet/gufo.container2#1-4",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_deflected_turn_resolves_nothing_and_logs_zero(
|
||||
client: TestClient,
|
||||
gate_env: tuple[_FakeSession, _CannedLLM],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""D9: a DEFLECTED turn resolves NOTHING even when its own answer
|
||||
text carries a `` ```bor `` fence — a hallucinated locator must not
|
||||
leak KB text through the turn that just disclaimed it. The resolver
|
||||
is never called, the line reads ``verbatim_blocks=0`` and the frame
|
||||
carries no ``answer_text`` key."""
|
||||
_session, llm = gate_env
|
||||
llm.answer = (
|
||||
"I haven't done anything like that.\n"
|
||||
"```bor Homelab/quadlet/gufo.container 1-4\n"
|
||||
"Exec=/usr/bin/lie\n"
|
||||
"```"
|
||||
)
|
||||
doc = _doc("Sourdough Starter", "BREAD_DOC_SENT")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.10)]))
|
||||
calls: list[Any] = []
|
||||
monkeypatch.setattr(
|
||||
chat_api,
|
||||
"resolve_answer_blocks",
|
||||
lambda *_a: calls.append(1) or [], # pyright: ignore[reportFunctionMemberAccess]
|
||||
)
|
||||
|
||||
with caplog.at_level("INFO", logger="app.chat"):
|
||||
frames = _ask(client, "How do I bake sourdough bread?")
|
||||
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
||||
assert lines and lines[-1].endswith("verbatim_blocks=0")
|
||||
assert calls == [] # the resolver never ran on a deflected turn
|
||||
done = [f for f in frames if f.get("type") == "done"]
|
||||
assert done and done[0]["deflected"] is True
|
||||
assert "answer_text" not in done[0]
|
||||
|
||||
@@ -113,7 +113,9 @@ def test_raw_text_only_stored_and_re_rendered_on_restore() -> None:
|
||||
# per-call behavior override (default SCROLL instead of forced
|
||||
# "auto" — documented at the call site).
|
||||
assert 'addMessage("user", renderMarkdown(m.text), true)' in js
|
||||
assert 'addMessage("brain", renderMarkdown(m.text), true)' in js
|
||||
# Phase 182 (task 07): the brain restore renderer opts INTO the verbatim
|
||||
# pass (the user renderer does not — a question is never a locator).
|
||||
assert 'addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true)' in js
|
||||
assert "wrap.classList.add(\"is-deflected\")" in js
|
||||
assert "appendMaybeTry(wrap, m.suggestions)" in js
|
||||
assert "appendSources(wrap, m.sources)" in js
|
||||
@@ -289,7 +291,9 @@ def test_brain_turn_persists_optional_thinking_field() -> None:
|
||||
'const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "")'
|
||||
in branch
|
||||
)
|
||||
assert "renderMarkdown(finalText)" in branch, (
|
||||
# Phase 182 (task 07): the fallback renders through the SAME opt-in as
|
||||
# the per-delta write (ONE answer renderer, one shape).
|
||||
assert "renderMarkdown(finalText, { verbatim: true })" in branch, (
|
||||
"the substituted fallback must render into the bubble"
|
||||
)
|
||||
|
||||
|
||||
@@ -426,6 +426,76 @@ def test_read_max_chars_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
_settings()
|
||||
|
||||
|
||||
def test_answer_text_default_true_and_env_override(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 182 (LOCKED A8): the verbatim-display master switch defaults
|
||||
to ON — an answer whose code block silently differs from the file is a
|
||||
correctness bug, so the fix ships on and the switch is an escape hatch
|
||||
(unlike phase 124's ``answer_images``, which defaults OFF). Env-tunable
|
||||
in both directions; ``0``/``false`` is the byte-identical pre-phase
|
||||
state (no tool, no clause, no resolution, no ``answer_text`` key)."""
|
||||
monkeypatch.delenv("BOR_ANSWER_TEXT", raising=False)
|
||||
assert _settings().answer_text is True
|
||||
for off in ("0", "false"):
|
||||
monkeypatch.setenv("BOR_ANSWER_TEXT", off)
|
||||
assert _settings().answer_text is False
|
||||
for on in ("1", "true"):
|
||||
monkeypatch.setenv("BOR_ANSWER_TEXT", on)
|
||||
assert _settings().answer_text is True
|
||||
|
||||
|
||||
def test_show_text_max_chars_default_and_env_override(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 182 (LOCKED A8): one displayed verbatim slice is capped at
|
||||
``BOR_SHOW_TEXT_MAX_CHARS`` (default 12 000 chars — 5 blocks is a 60 KB
|
||||
worst case against the 256 KB response budget). ``0`` is accepted and
|
||||
means INERT (no block displays) — not a startup error."""
|
||||
monkeypatch.delenv("BOR_SHOW_TEXT_MAX_CHARS", raising=False)
|
||||
assert _settings().show_text_max_chars == 12_000
|
||||
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_CHARS", "500")
|
||||
assert _settings().show_text_max_chars == 500
|
||||
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_CHARS", "0")
|
||||
assert _settings().show_text_max_chars == 0
|
||||
|
||||
|
||||
def test_show_text_max_chars_rejects_negative(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A negative per-block cap is a typo — it would slice from the END of
|
||||
the content instead of failing, so startup refuses naming the field
|
||||
(the ``read_max_chars`` pattern, phase 182)."""
|
||||
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_CHARS", "-1")
|
||||
with pytest.raises(ValidationError, match="show_text_max_chars"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_show_text_max_blocks_default_and_env_override(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 182 (LOCKED A8): at most ``BOR_SHOW_TEXT_MAX_BLOCKS`` verbatim
|
||||
blocks display per answer (default 5, counted in answer order). ``0``
|
||||
is accepted and means INERT (no block displays)."""
|
||||
monkeypatch.delenv("BOR_SHOW_TEXT_MAX_BLOCKS", raising=False)
|
||||
assert _settings().show_text_max_blocks == 5
|
||||
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_BLOCKS", "1")
|
||||
assert _settings().show_text_max_blocks == 1
|
||||
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_BLOCKS", "0")
|
||||
assert _settings().show_text_max_blocks == 0
|
||||
|
||||
|
||||
def test_show_text_max_blocks_rejects_negative(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A negative per-answer block cap is a typo — it would count down
|
||||
from the END of the block list instead of failing, so startup refuses
|
||||
naming the field (the ``agent_max_rounds`` pattern, phase 182)."""
|
||||
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_BLOCKS", "-1")
|
||||
with pytest.raises(ValidationError, match="show_text_max_blocks"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_recency_boost_default_and_env_override(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -887,7 +887,10 @@ def test_renderer_extracted_to_shared_markdown_js() -> None:
|
||||
and BOTH pages load it via a relative <script src> before their module
|
||||
scripts — so the globals exist when app.js/document.js run."""
|
||||
md = _read(MARKDOWN_JS)
|
||||
assert "function renderMarkdown(md)" in md
|
||||
# Phase 182 (task 06) added the SECOND, optional `opts` argument — the
|
||||
# renderer still lives here exactly once, which is what this pin is
|
||||
# about (the argument is pinned by tests/unit/test_frontend_verbatim_markdown.py).
|
||||
assert "function renderMarkdown(md" in md
|
||||
assert "function escapeHtml(s)" in md
|
||||
|
||||
app_js = _read(APP_JS)
|
||||
|
||||
@@ -982,6 +982,10 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
turnAbort?.abort(); // phase 48: same outcome, one owner — aborted is
|
||||
// set first, so the catch never reads the guard's
|
||||
// abort as a user stop
|
||||
// Phase 182 (task 07): the 300 s guard is a terminal frame — a panel
|
||||
// still reading "Retrieving the exact text…" would be a lie by then
|
||||
// (the hydrate at `done` never runs), so settle it to the honest state.
|
||||
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
|
||||
setUiState(UI_STATE.error, "That's taking a long time — the answer may be stuck.");
|
||||
});
|
||||
|
||||
@@ -1283,7 +1287,14 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
// NEW run in the container — the D15 re-open of the shared
|
||||
// block is gone). Idempotent — a no-op without an active run.
|
||||
closeCurrentRun();
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
|
||||
// Phase 182 (task 07): the OPT-IN verbatim pass on the ONE answer
|
||||
// renderer. A ` ```bor ` locator fence becomes a pending placeholder
|
||||
// here (the model's re-typed body DROPPED, locked A7) that the `done`
|
||||
// branch below hydrates against the frame's server-resolved blocks —
|
||||
// mid-stream the reader sees "Retrieving the exact text…", never a
|
||||
// copy that might be wrong. Every other renderer on the page (user
|
||||
// bubbles, thinking runs, the document viewer) stays one-argument.
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc, { verbatim: true });
|
||||
// No page scroll (phase 42): the answer never follows the viewport.
|
||||
} else if (ev.type === "done") {
|
||||
sawDone = true;
|
||||
@@ -1315,6 +1326,25 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
// 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);
|
||||
// Phase 182 (task 07, locked A1/A2): the answer's verbatim blocks —
|
||||
// `ev.answer_text`, the bytes the SERVER read out of the index — land
|
||||
// in the SAME band as the answer image (below the text, above the
|
||||
// source clusters): hydrate every placeholder the renderer painted,
|
||||
// then append the blocks the answer never located (a `show_text` call
|
||||
// the prose never named). A locator the server could not resolve
|
||||
// renders the honest unresolved sentence with NONE of the model's
|
||||
// bytes, and no panel is left at "Retrieving the exact text…".
|
||||
hydrateVerbatim(
|
||||
wrap.querySelector(".bubble"),
|
||||
ev.answer_text || [],
|
||||
VERBATIM_LINK_OPTS
|
||||
);
|
||||
appendUnlocatedVerbatim(wrap, ev.answer_text || [], VERBATIM_LINK_OPTS);
|
||||
// The turn SETTLED — this is where the ordinary code blocks get their
|
||||
// Copy buttons (never mid-stream: per-delta the bubble is rebuilt from
|
||||
// scratch, and a button that vanishes on the next token is not a
|
||||
// control). NO badge, NO caption on those (locked A5).
|
||||
enhanceAnswerCodeBlocks(wrap);
|
||||
appendSources(wrap, ev.sources);
|
||||
// Phase 113 (task 02): the cited tier stays the citation
|
||||
// surface (the chips above); the related tier (scored docs
|
||||
@@ -1332,7 +1362,11 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
// what gets persisted.
|
||||
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
|
||||
if (!acc && sawThinking) {
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
|
||||
// Phase 182 (task 07): the same opt-in as the per-delta write —
|
||||
// this line paints the answer the user keeps (the fallback text,
|
||||
// so no placeholder exists in practice, but the ONE answer
|
||||
// renderer must stay ONE code path in shape).
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText, { verbatim: true });
|
||||
}
|
||||
// Persistence save point 2: the answer lands only when the turn is
|
||||
// complete (raw text + the done metadata; phase 17: + optional
|
||||
@@ -1359,6 +1393,13 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
// the precedent): an image-less / pre-phase turn stores a
|
||||
// byte-identical record.
|
||||
answer_image: ev.answer_image || undefined,
|
||||
// Phase 182 (task 07): the verbatim blocks persist with the turn
|
||||
// (task 05's additive `answer_text` key) so a reload — and a shared
|
||||
// page — re-renders the identical panel. `undefined` drops the key
|
||||
// from the JSON (the house optional-meta pattern, the answer_image
|
||||
// line above is the precedent): a pre-phase or answer_text=False
|
||||
// record stays byte-identical.
|
||||
answer_text: ev.answer_text || 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
|
||||
@@ -1431,6 +1472,14 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
}
|
||||
}
|
||||
} else if (ev.type === "error") {
|
||||
// Phase 182 (task 07): an `error` frame is terminal — the `done`
|
||||
// hydrate will never run, so a placeholder that streamed in stays at
|
||||
// "Retrieving the exact text…" forever unless it settles HERE (the
|
||||
// honest unresolved state, never a stale promise of the exact text).
|
||||
settleVerbatimPlaceholders(
|
||||
wrap?.querySelector(".bubble"),
|
||||
"unverified"
|
||||
);
|
||||
// Phase 114 (TODO L6): carry the frame's optional hint (the
|
||||
// "question too long" frame has one) through the throw — the
|
||||
// banner shows it in place of the default reachability hint.
|
||||
@@ -1485,6 +1534,10 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
}
|
||||
} catch (err) {
|
||||
if (aborted) {
|
||||
// Phase 182 (task 07): defensive settle for the guard's own abort
|
||||
// surface (the timeout callback already ran it — idempotent, and the
|
||||
// turn must not leave a pending panel on ANY terminal path).
|
||||
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
|
||||
// The 300s guard already took the turn to the error state — its
|
||||
// own abort surfaces here, and it is never read as a user stop.
|
||||
// Phase 159 (task 03): the aborted settle must not strand the
|
||||
@@ -1505,6 +1558,10 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
// convention; no sources/suggestions — the turn never settled).
|
||||
// The persistedOnLeave guard (phase 20) keeps a navigate-away —
|
||||
// which aborts the same fetch — from saving the partial twice.
|
||||
// Phase 182 (task 07): the STOP is terminal for this answer too — a
|
||||
// placeholder in the kept partial settles to the honest state (the
|
||||
// blocks never arrive without a `done` frame).
|
||||
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
|
||||
if (wrap && acc && !persistedOnLeave) {
|
||||
// Phase 158 (D5): the turn was stopped — fold every open run
|
||||
// (replaces the phase-117 closeToolCalls fold + the D15 block
|
||||
@@ -1635,14 +1692,23 @@ def test_run_turn_is_pinned_at_phase_159_state() -> None:
|
||||
list-of-objects no longer stringifies to ``[object Object]``; the
|
||||
task-03 pre-stream throw carries ``bubbleText`` (FAILED_REQUEST_TEXT)
|
||||
+ the phase-114 ``hint``, and the catch forwards ``bubbleText:
|
||||
err?.bubbleText`` into the failed-turn funnel — locked A3): the
|
||||
full source must match the pin — the ``reask`` path, the turn-local
|
||||
err?.bubbleText`` into the failed-turn funnel — locked A3)
|
||||
and phase 182 (task 07)'s sanctioned additions (the two answer
|
||||
renderers pass the verbatim opt-in — the per-delta write and the
|
||||
``done``-time re-render; the done settle hydrates the `` ```bor ``
|
||||
placeholders against ``ev.answer_text``, appends the blocks the answer
|
||||
never located, and gives the settled answer's ordinary code blocks their
|
||||
Copy buttons BEFORE ``appendSources``; the ``answer_text`` meta key rides
|
||||
``rememberBrainTurn`` with ``|| undefined``; and the terminal paths — the
|
||||
``error`` frame, the 300 s guard, its own abort surface, and the Stop
|
||||
branch — settle any leftover placeholder to the unresolved state):
|
||||
the full source must match the pin — the ``reask`` path, the turn-local
|
||||
resets, the
|
||||
SSE contract, the save points, and the sanctioned additions (the
|
||||
task-01 ``lastUserWrap`` assignment + the idle-settle mark
|
||||
pass, the task-03 aborted-settle mark pair, the phase-160
|
||||
done-settle pair, the phase-161 live-container additions, and
|
||||
the D5 refinement) included."""
|
||||
done-settle pair, the phase-161 live-container additions, the
|
||||
D5 refinement, and the phase-182 verbatim settle/hydrate pair) included."""
|
||||
assert _fn_source(_js(), "runTurn") == PINNED_RUN_TURN
|
||||
|
||||
|
||||
|
||||
@@ -173,7 +173,11 @@ def test_restore_landing_is_one_shot() -> None:
|
||||
js = _js()
|
||||
body = _fn_body(js, "renderStoredMessage")
|
||||
assert 'addMessage("user", renderMarkdown(m.text), true)' in body
|
||||
assert 'addMessage("brain", renderMarkdown(m.text), true)' in body
|
||||
# Phase 182 (task 07): the brain renderer gained the verbatim option —
|
||||
# the SCROLL intent (the trailing `true`) is untouched, and the option
|
||||
# object (`{ verbatim: true }`) never ends in `", true)"`, so the count
|
||||
# below still counts the two restore landings alone.
|
||||
assert 'addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true)' in body
|
||||
assert js.count('"auto", true') == 0, "the old forced 'auto' landing must be gone"
|
||||
# Submit reveal + the two restore landings — nothing else scrolls.
|
||||
# Phase 123 (task 02): the submit call is multi-line (the optional
|
||||
|
||||
@@ -0,0 +1,867 @@
|
||||
"""Unit: the verbatim panel client and the answer's Copy buttons (phase 182, task 07).
|
||||
|
||||
The browser half of the story lives in the phase's Playwright suite (task
|
||||
08); this module pins the SOURCE contract of ``frontend/assets/verbatim.js``
|
||||
and the three client surfaces that consume it, in the established
|
||||
``test_frontend_*`` convention — plus a node-driven half (skipped when node
|
||||
is unavailable, the phase-44/138/180/182 idiom) that runs the panel's PURE
|
||||
logic (caption, gutter, locator match, block guard) against the real file.
|
||||
|
||||
Why these particular pins (the phase's drift risks, in order):
|
||||
|
||||
* **ONE locator grammar.** ``markdown.js::borLocator`` is the client's single
|
||||
normalizer (task 06); hydration may only compare the server's canonical
|
||||
``locator`` against the already-normalized ``data-bor-locator`` with a
|
||||
plain string ``===``. A second grammar in a second file is the phase's main
|
||||
way to break silently — so this module pins that no normalizer exists in
|
||||
``verbatim.js`` (no ``borLocator``, no range regex, no ``parseInt``).
|
||||
* **The model's bytes never reach the screen or the clipboard.** The panel's
|
||||
text node is ``pre.textContent = block.text`` (server bytes, verbatim) and
|
||||
the Copy button copies the same string — never the DOM, never the gutter,
|
||||
never the model's re-typed fence body (which task 06 dropped upstream).
|
||||
* **The claim is carried by words, not colours** (LOCKED A5): the badge text
|
||||
is ``VERBATIM``, the honest state is the pinned sentence ``I could not
|
||||
verify this against the knowledge base``, and an ordinary code block gets a
|
||||
Copy button with NO badge.
|
||||
* **Parity.** The live turn, the reload restore and the anonymous shared page
|
||||
call the SAME globals, in the SAME order (hydrate → append the unlocated →
|
||||
settle the code blocks, all inside the ``answer_image`` band and BEFORE the
|
||||
source clusters), so one answer cannot render three different ways.
|
||||
* **No panel is ever left lying.** Every terminal path — the ``error`` frame,
|
||||
the 300 s guard, the Stop action, the failed-turn funnel — settles the
|
||||
leftover placeholders; nothing reads "Retrieving the exact text…" after the
|
||||
turn is over.
|
||||
* **CSP + the house XSS rule.** Everything is ``createElement`` +
|
||||
``textContent``, every handler is ``addEventListener``, and there is no
|
||||
``innerHTML``/``onclick=``/``oncopy=`` anywhere in the new client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
ASSETS = FRONTEND / "assets"
|
||||
VERBATIM_JS = ASSETS / "verbatim.js"
|
||||
APP_JS = ASSETS / "app.js"
|
||||
SHARED_JS = ASSETS / "shared.js"
|
||||
DOCUMENT_JS = ASSETS / "document.js"
|
||||
MARKDOWN_JS = ASSETS / "markdown.js"
|
||||
STYLES_CSS = ASSETS / "styles.css"
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
SHARED_HTML = FRONTEND / "shared.html"
|
||||
CONTAINERFILE = FRONTEND.parent / "Containerfile"
|
||||
|
||||
HAVE_NODE = shutil.which("node") is not None
|
||||
|
||||
#: The labelled phase-182 CSS region (task 06's markers — task 07 extends
|
||||
#: INSIDE it, so the palette-only and contiguity pins keep holding).
|
||||
BLOCK_BEGIN = "/* ---------- Verbatim panels (phase 182, task 06) — BEGIN ----------"
|
||||
BLOCK_END = "/* ---------- Verbatim panels (phase 182, task 06) — END ---------- */"
|
||||
|
||||
#: The honest state (pinned copy — task 07's Testing section).
|
||||
UNRESOLVED_COPY = "I could not verify this against the knowledge base"
|
||||
|
||||
#: The badge's WORD (LOCKED A5) and the tooltip that explains it.
|
||||
BADGE_TEXT = "VERBATIM"
|
||||
BADGE_TITLE = "These bytes are copied from the knowledge base, not written by the model."
|
||||
|
||||
#: The Copy control's accessible name template — the FULL path, never a
|
||||
#: clipped stand-in (the phase-156 clipped-label rule).
|
||||
COPY_ARIA = "Copy ${shown} lines from ${identity}"
|
||||
|
||||
#: The globals task 07 exposes for the two page modules.
|
||||
PUBLIC_GLOBALS = (
|
||||
"buildVerbatimPanel",
|
||||
"hydrateVerbatim",
|
||||
"appendUnlocatedVerbatim",
|
||||
"settleVerbatimPlaceholders",
|
||||
"enhanceAnswerCodeBlocks",
|
||||
"copyTextWithFallback",
|
||||
)
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
assert path.is_file(), f"missing file: {path}"
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _strip_comments(text: str) -> str:
|
||||
"""Comments out — the CSP and literal pins below are about CODE. The
|
||||
file's own header documents the very patterns it forbids (``onclick=``,
|
||||
``javascript:``), and a prose-blind grep would flag the documentation of
|
||||
the rule as the rule."""
|
||||
text = re.sub(r"/\*[\s\S]*?\*/", "", text)
|
||||
return re.sub(r"(?m)^\s*//.*$", "", text)
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of a top-level (optionally ``async``) ``function <name>``."""
|
||||
start = js.find(f"function {name}(")
|
||||
assert start != -1, f"{name}() must exist"
|
||||
return js[start : js.find("\n}\n", start) + 4]
|
||||
|
||||
|
||||
def _body(js: str, name: str) -> str:
|
||||
return _fn(js, name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the client ships, and ships in the right shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verbatim_js_is_a_classic_script_after_markdown_on_both_pages() -> None:
|
||||
"""Both pages load it as a PLAIN ``<script>`` immediately AFTER
|
||||
``markdown.js`` (whose placeholders it hydrates) and BEFORE their page
|
||||
module (which calls it as a global). Absolute paths on ``shared.html``
|
||||
(the nested ``/shared/<token>`` route breaks a relative ref), relative on
|
||||
``index.html`` exactly like its neighbours. No ``type="module"`` — a
|
||||
module would defer past the pages' boot and expose no globals."""
|
||||
index = _text(INDEX_HTML)
|
||||
shared = _text(SHARED_HTML)
|
||||
assert '<script src="assets/verbatim.js"></script>' in index
|
||||
assert '<script src="/assets/verbatim.js"></script>' in shared
|
||||
assert index.index('src="assets/markdown.js"') < index.index('src="assets/verbatim.js"'), (
|
||||
"verbatim.js hydrates markdown.js's placeholders — it loads after it"
|
||||
)
|
||||
assert index.index('src="assets/verbatim.js"') < index.index(
|
||||
'type="module" src="/assets/app.js"'
|
||||
), "the globals must exist before app.js evaluates"
|
||||
assert shared.index('src="/assets/markdown.js"') < shared.index('src="/assets/verbatim.js"')
|
||||
assert shared.index('src="/assets/verbatim.js"') < shared.index(
|
||||
'type="module" src="/assets/shared.js"'
|
||||
)
|
||||
tag = next(line for line in index.splitlines() if "verbatim.js" in line and "<script" in line)
|
||||
assert 'type="module"' not in tag, "a classic script — the globals are the API"
|
||||
# The document viewer never loads it (a file's own ```bor fence is text).
|
||||
document_html = _text(FRONTEND / "document.html")
|
||||
assert "verbatim.js" not in document_html
|
||||
|
||||
|
||||
def test_containerfile_produces_it_as_a_minified_classic_script() -> None:
|
||||
"""Stage 1 builds it the way it builds ``markdown.js``: ``--minify``
|
||||
WITHOUT ``--bundle`` (bundling a classic script would break the global
|
||||
exposure), and the source itself stays free of top-level
|
||||
``import``/``export``."""
|
||||
container = _text(CONTAINERFILE)
|
||||
line = next(
|
||||
row for row in container.splitlines() if "assets/verbatim.js" in row
|
||||
)
|
||||
assert "--minify" in line
|
||||
assert "--outfile=/out/assets/verbatim.js" in line
|
||||
assert "--bundle" not in line, "a classic script is never bundled"
|
||||
assert "--format=esm" not in line
|
||||
js = _text(VERBATIM_JS)
|
||||
assert "\nimport " not in js and "\nexport " not in js
|
||||
|
||||
|
||||
def test_every_public_global_exists() -> None:
|
||||
"""The API the two page modules call, as top-level function declarations
|
||||
(the ``markdown.js`` exposure pattern — a classic script's top-level
|
||||
functions ARE the globals)."""
|
||||
js = _text(VERBATIM_JS)
|
||||
for name in PUBLIC_GLOBALS:
|
||||
assert f"function {name}(" in js, f"verbatim.js must expose {name}"
|
||||
|
||||
|
||||
def test_no_locator_normalizer_is_re_implemented() -> None:
|
||||
"""THE phase's drift pin: hydration compares the server's canonical
|
||||
``locator`` against the ``data-bor-locator`` markdown.js already
|
||||
normalized, with a PLAIN string compare. No grammar here — no
|
||||
``borLocator``, no range regex, no en-dash handling, no ``parseInt``, no
|
||||
``L``-prefix stripping (a second grammar in a second file is how a
|
||||
resolved block stops hydrating)."""
|
||||
js = _text(VERBATIM_JS)
|
||||
assert "function borLocator" not in js
|
||||
assert "parseInt" not in js
|
||||
assert "\u2013" not in _fn(js, "matchVerbatimBlock"), "no en-dash parsing here"
|
||||
for pattern in ("[Ll]", "L?\\d", "#L", "trimStart", "normalize("):
|
||||
assert pattern not in js, f"{pattern} looks like a re-implemented grammar"
|
||||
match = _fn(js, "matchVerbatimBlock")
|
||||
assert "block.locator === key" in match, "the exact match is a plain string compare"
|
||||
hydrate = _fn(js, "hydrateVerbatim")
|
||||
assert 'node.getAttribute("data-bor-locator")' in hydrate, (
|
||||
"hydration reads the attribute markdown.js wrote — it never re-derives it"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the panel's contract (LOCKED A2/A5/A7)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_panel_is_a_labelled_group_keyed_by_the_servers_locator() -> None:
|
||||
"""``role="group"`` + ``aria-label="Verbatim text from <path>, lines
|
||||
N\u2013M"`` + ``data-bor-locator`` = the SERVER's canonical locator (the
|
||||
key ``appendUnlocatedVerbatim`` later recognises, which is why a
|
||||
re-render never duplicates a panel)."""
|
||||
panel = _fn(_text(VERBATIM_JS), "buildVerbatimPanel")
|
||||
assert 'root.setAttribute("role", "group")' in panel
|
||||
assert 'root.setAttribute("aria-label", verbatimPanelLabel(block))' in panel
|
||||
assert "root.dataset.borLocator" in panel
|
||||
assert "block.locator" in panel, "the server's locator is the panel's identity"
|
||||
label = _fn(_text(VERBATIM_JS), "verbatimPanelLabel")
|
||||
assert '"Verbatim text from' in json.dumps(label) or "Verbatim text from" in label
|
||||
|
||||
|
||||
def test_badge_is_a_word_with_the_pinned_explanation() -> None:
|
||||
"""LOCKED A5: the claim is the WORD ``VERBATIM`` (never an icon or a
|
||||
colour), with the tooltip that says what it means."""
|
||||
js = _text(VERBATIM_JS)
|
||||
assert f'badge.textContent = "{BADGE_TEXT}"' in js
|
||||
assert BADGE_TITLE in js
|
||||
assert 'badge.className = "verbatim-badge"' in js
|
||||
|
||||
|
||||
def test_unresolved_state_is_the_pinned_sentence_and_nothing_else() -> None:
|
||||
"""The honest state for a locator the server did not resolve: the pinned
|
||||
sentence, plus the document link ONLY when the identity parsed into a
|
||||
``source/path``. NO badge, NO body, NONE of the model's bytes — and the
|
||||
builder creates neither a badge nor a ``<pre>`` node at all, which is
|
||||
stronger than hiding one."""
|
||||
js = _text(VERBATIM_JS)
|
||||
assert f'"{UNRESOLVED_COPY}"' in js
|
||||
node = _fn(js, "buildVerbatimUnresolved")
|
||||
assert 'root.className = "verbatim verbatim-unresolved"' in node
|
||||
assert 'state.className = "verbatim-state"' in node
|
||||
assert "verbatim-badge" not in node, "an unresolved panel never badges"
|
||||
assert 'createElement("pre")' not in node, "and never has a body"
|
||||
assert 'createElement("ol")' not in node, "nor a gutter"
|
||||
assert "indexOf(" in node and "slash > 0" in node, (
|
||||
"the link appears only when the identity split into a source and a path"
|
||||
)
|
||||
|
||||
|
||||
def test_copy_button_names_the_full_path_and_copies_the_servers_bytes() -> None:
|
||||
"""The completion criterion at source level: the panel's Copy control is
|
||||
built from ``block.text`` (never from the DOM, never with the gutter's
|
||||
digits), and its accessible name is ``Copy N lines from <source/path>`` —
|
||||
the full path, because the visible label is the CSS clip target
|
||||
(phase 156)."""
|
||||
js = _text(VERBATIM_JS)
|
||||
panel = _fn(js, "buildVerbatimPanel")
|
||||
assert COPY_ARIA in panel
|
||||
build = panel[panel.index("buildCopyButton(") :]
|
||||
assert '"verbatim-copy"' in build
|
||||
assert "block.text," in build.split("\n)")[0], (
|
||||
"the copied string is `block.text` — the exact bytes the panel shows"
|
||||
)
|
||||
control = _fn(js, "buildCopyButton")
|
||||
assert 'btn.setAttribute("aria-label", ariaLabel)' in control
|
||||
assert 'label.textContent = "Copy"' in control
|
||||
assert 'label.textContent = "Copied"' in control, "the state is TEXT (WCAG 1.4.1)"
|
||||
assert 'btn.classList.add("copied")' in control
|
||||
clipboard = _fn(js, "copyTextWithFallback")
|
||||
assert "await navigator.clipboard.writeText(text)" in clipboard, (
|
||||
"the caller's string goes to the clipboard untouched — no DOM read"
|
||||
)
|
||||
|
||||
|
||||
def test_body_is_the_servers_text_with_the_servers_line_numbers() -> None:
|
||||
"""The bounded body: the ``<ol>`` gutter (``aria-hidden`` — the range
|
||||
rides the panel's label, and the digits are ``user-select: none`` in CSS
|
||||
so a selection never drags them into the clipboard) and the ``<pre>``
|
||||
whose ``textContent`` is ``block.text``, byte for byte."""
|
||||
js = _text(VERBATIM_JS)
|
||||
panel = _fn(js, "buildVerbatimPanel")
|
||||
assert 'body.className = "verbatim-body"' in panel
|
||||
assert 'numbers.className = "verbatim-lines"' in panel
|
||||
assert 'numbers.setAttribute("aria-hidden", "true")' in panel
|
||||
assert 'pre.className = "verbatim-text"' in panel
|
||||
assert "pre.textContent = block.text" in panel
|
||||
assert "li.textContent = row.number" in panel
|
||||
gutter = _fn(js, "verbatimGutter")
|
||||
assert 'split("\\n")' in gutter, "one gutter row per line of the SERVER text"
|
||||
|
||||
|
||||
def test_truncated_block_says_the_rest_is_in_the_document() -> None:
|
||||
"""A capped block appends ONE muted line plus the same viewer link — the
|
||||
panel never pretends the slice is the whole file."""
|
||||
panel = _fn(_text(VERBATIM_JS), "buildVerbatimPanel")
|
||||
assert "if (block.truncated)" in panel
|
||||
assert 'more.className = "verbatim-more"' in panel
|
||||
assert "the rest is in the document" in panel
|
||||
assert "buildVerbatimOpenLink(" in panel.split("if (block.truncated)")[1]
|
||||
|
||||
|
||||
def test_open_link_is_a_real_href_with_the_modal_hook() -> None:
|
||||
"""The panel links to the document viewer: an always-same-origin
|
||||
``/document.html?source=…&path=…`` built with ``encodeURIComponent`` (so a
|
||||
path can never turn into ``javascript:``/``data:``), plus the chat page's
|
||||
click-to-modal hook through ``opts.onOpen`` — attached with
|
||||
``addEventListener``, never an inline attribute."""
|
||||
js = _text(VERBATIM_JS)
|
||||
link = _fn(js, "buildVerbatimOpenLink")
|
||||
assert 'link.className = "verbatim-open"' in link
|
||||
assert "link.addEventListener(" in link
|
||||
assert "event.preventDefault()" in link
|
||||
assert "opts.onOpen(source, path, link)" in link
|
||||
href = _fn(js, "verbatimDocumentHref")
|
||||
assert "/document.html?source=" in href
|
||||
assert "encodeURIComponent(source)" in href and "encodeURIComponent(path)" in href
|
||||
code = _strip_comments(js)
|
||||
assert "javascript:" not in code and "data:" not in code, (
|
||||
"no scheme is ever assembled into a link target"
|
||||
)
|
||||
|
||||
|
||||
def test_plain_answer_code_blocks_get_copy_and_never_a_badge() -> None:
|
||||
"""``enhanceAnswerCodeBlocks``: every ``<pre>`` in an answer bubble that
|
||||
is NOT inside a ``.verbatim`` gets ``.code-block`` + ``.code-block-copy``.
|
||||
NO badge and NO caption — verbatim-and-badged OR plainly the model's,
|
||||
never a hybrid (LOCKED A5). Idempotent: a ``<pre>`` already inside a
|
||||
``.code-block`` is left alone."""
|
||||
fn = _fn(_text(VERBATIM_JS), "enhanceAnswerCodeBlocks")
|
||||
assert 'box.className = "code-block"' in fn
|
||||
assert '"code-block-copy"' in fn
|
||||
assert 'pre.closest(".verbatim")' in fn, "a panel owns its own Copy button"
|
||||
assert 'parent.classList.contains("code-block")' in fn, "idempotent on re-render"
|
||||
assert "verbatim-badge" not in fn
|
||||
assert "verbatim-caption" not in fn
|
||||
|
||||
|
||||
def test_hydration_is_idempotent_and_never_throws_on_garbage() -> None:
|
||||
"""Only ``.verbatim-pending`` is replaced, so a hydrated panel (or one
|
||||
restored from storage) survives a re-run with its ``Copied`` state and
|
||||
scroll position; a missing/malformed ``blocks`` settles to the honest
|
||||
state instead of throwing."""
|
||||
js = _text(VERBATIM_JS)
|
||||
hydrate = _fn(js, "hydrateVerbatim")
|
||||
assert ".verbatim-pending[data-bor-locator]" in hydrate
|
||||
assert (
|
||||
"verbatim-unresolved"
|
||||
not in hydrate.split("matchVerbatimBlock")[1].split("replaceWith")[1].split("\n")[0]
|
||||
), "the unresolved node comes from its own builder"
|
||||
assert "replaceWith(" in hydrate
|
||||
assert "try {" not in hydrate, "the guards make it total — no exception path"
|
||||
assert "verbatimBlocks(blocks)" in hydrate
|
||||
guard = _fn(js, "verbatimBlocks")
|
||||
assert "Array.isArray(blocks)" in guard, "a missing frame key is not an error"
|
||||
|
||||
|
||||
def test_unlocated_blocks_land_in_the_answer_image_band() -> None:
|
||||
"""The blocks no placeholder matched (a ``show_text`` call the prose never
|
||||
named) append to ``.msg-body`` — the same band as the answer image, below
|
||||
the text and above the source clusters, in frame order — and a block
|
||||
already placed (a panel carrying its canonical locator) is never
|
||||
appended twice."""
|
||||
fn = _fn(_text(VERBATIM_JS), "appendUnlocatedVerbatim")
|
||||
assert 'wrap.querySelector(".msg-body")' in fn
|
||||
assert "host.appendChild(buildVerbatimPanel(block, opts))" in fn
|
||||
assert "placed.add(" in fn and "if (placed.has(key)) continue" in fn
|
||||
assert "verbatim-unresolved" in fn, (
|
||||
"an unresolved placeholder is NOT a placement (the block still belongs on screen)"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_settle_renders_the_honest_state() -> None:
|
||||
"""``settleVerbatimPlaceholders`` turns every leftover ``.verbatim-pending``
|
||||
into the unresolved node and records the ``kind`` for the next reader."""
|
||||
js = _text(VERBATIM_JS)
|
||||
fn = _fn(js, "settleVerbatimPlaceholders")
|
||||
assert 'root.querySelectorAll(".verbatim-pending")' in fn
|
||||
assert "buildVerbatimUnresolved(" in fn
|
||||
assert "replaceWith(" in fn, "the pending node is REPLACED, never patched"
|
||||
assert 'node.getAttribute("data-bor-locator")' in fn, (
|
||||
"the locator the model named rides into the honest state"
|
||||
)
|
||||
unresolved = _fn(js, "buildVerbatimUnresolved")
|
||||
assert 'root.dataset.verbatimState = (opts && opts.kind) || "unverified"' in unresolved, (
|
||||
"the settle kind is recorded for the next reader"
|
||||
)
|
||||
assert "state.textContent = verbatimUnresolvedCopy()" in unresolved
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSP + the house XSS rule (the new client, and the new code in the pages)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_inline_handlers_and_no_html_anywhere_in_the_client() -> None:
|
||||
"""CSP is ``default-src 'self'``: no ``onclick=``/``oncopy=`` attribute
|
||||
strings, no inline ``<script>``, no HTML string, no ``innerHTML`` (not
|
||||
even ``+=``) in ``verbatim.js`` — every node is ``createElement``, every
|
||||
value ``textContent``/``title``/a built ``href``, every handler
|
||||
``addEventListener``."""
|
||||
code = _strip_comments(_text(VERBATIM_JS))
|
||||
for marker in (
|
||||
"onclick=",
|
||||
"oncopy=",
|
||||
"onerror=",
|
||||
"onload=",
|
||||
".innerHTML",
|
||||
".outerHTML",
|
||||
"insertAdjacentHTML(",
|
||||
"document.write(",
|
||||
"eval(",
|
||||
"<script",
|
||||
):
|
||||
assert marker not in code, f"verbatim.js must stay HTML/handler-free: {marker}"
|
||||
assert "document.createElement(" in code
|
||||
assert code.count("document.createElement(") > 15, (
|
||||
"the panel is built node by node (the house rule)"
|
||||
)
|
||||
assert "addEventListener(" in code
|
||||
|
||||
|
||||
def test_the_pages_new_code_adds_no_inline_handler_or_inner_html() -> None:
|
||||
"""The task-07 additions to ``app.js`` / ``shared.js`` obey the same rule:
|
||||
no ``innerHTML +=`` anywhere, and the only ``onclick=``-shaped text in the
|
||||
two files stays inside their existing comments (none)."""
|
||||
for path in (APP_JS, SHARED_JS):
|
||||
js = _text(path)
|
||||
assert "innerHTML += " not in js, f"{path.name}: no innerHTML concatenation"
|
||||
assert "onclick=" not in js
|
||||
assert "oncopy=" not in js
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# app.js: the live turn, the save point, the restore, the terminal paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _done_branch(js: str) -> str:
|
||||
start = js.index('ev.type === "done"')
|
||||
end = js.index('ev.type === "tool_result"', start)
|
||||
return js[start:end]
|
||||
|
||||
|
||||
def test_the_done_handler_hydrates_before_the_source_clusters() -> None:
|
||||
"""The order IS the layout: the answer image, then the hydrated panels,
|
||||
then the blocks the answer never located, then the Copy buttons on the
|
||||
settled code blocks, then the source clusters (phase-124/130 DOM order —
|
||||
the panels sit below the answer text and above the chips)."""
|
||||
branch = _done_branch(_text(APP_JS))
|
||||
for call in (
|
||||
"appendAnswerImage(wrap, ev.answer_image)",
|
||||
"hydrateVerbatim(",
|
||||
"appendUnlocatedVerbatim(wrap, ev.answer_text || [], VERBATIM_LINK_OPTS)",
|
||||
"enhanceAnswerCodeBlocks(wrap)",
|
||||
"appendSources(wrap, ev.sources)",
|
||||
):
|
||||
assert call in branch, f"the done handler must call {call}"
|
||||
order = [
|
||||
branch.index("appendAnswerImage("),
|
||||
branch.index("hydrateVerbatim("),
|
||||
branch.index("appendUnlocatedVerbatim("),
|
||||
branch.index("enhanceAnswerCodeBlocks("),
|
||||
branch.index("appendSources("),
|
||||
]
|
||||
assert order == sorted(order), "image → panels → unlocated → copy → sources"
|
||||
assert "VERBATIM_LINK_OPTS" in branch[branch.index("hydrateVerbatim(") :][:200]
|
||||
|
||||
|
||||
def test_the_answer_renderers_opt_in_and_nothing_else_does() -> None:
|
||||
"""The per-delta write and the ``done``-time re-render both pass
|
||||
``{ verbatim: true }`` (the third site is the restore renderer, pinned in
|
||||
``test_the_restore_path_rehydrates_from_the_record``); the user bubble of
|
||||
the same turn does NOT — a question is not a locator."""
|
||||
js = _text(APP_JS)
|
||||
branch = _done_branch(js)
|
||||
assert "renderMarkdown(acc, { verbatim: true })" in js
|
||||
assert "renderMarkdown(finalText, { verbatim: true })" in branch
|
||||
assert 'addMessage(\n "user",\n renderMarkdown(text),' in js, (
|
||||
"the sent question stays one-argument"
|
||||
)
|
||||
|
||||
|
||||
def test_the_save_point_persists_the_blocks_additively() -> None:
|
||||
"""``answer_text: ev.answer_text || undefined`` on the ``done`` save point
|
||||
— ``undefined`` drops the key from the JSON (the ``answer_image``
|
||||
precedent), so a pre-phase record stays byte-identical and a
|
||||
``answer_text=False`` turn stores nothing new."""
|
||||
branch = _done_branch(_text(APP_JS))
|
||||
assert "answer_text: ev.answer_text || undefined," in branch
|
||||
turn = _fn(_text(APP_JS), "rememberBrainTurn")
|
||||
assert "...meta" in turn, (
|
||||
"the meta spread carries the key — rememberBrainTurn itself is untouched"
|
||||
)
|
||||
|
||||
|
||||
def test_the_restore_path_rehydrates_from_the_record() -> None:
|
||||
"""Reload parity: the restore renderer opts in, then hydrates from
|
||||
``m.answer_text`` and appends the unlocated blocks in the same band, then
|
||||
settles the code blocks — the SAME builders as the live turn. A record
|
||||
with no ``answer_text`` renders its fences as the unresolved state (the
|
||||
record is what it is: never resurrect model bytes)."""
|
||||
js = _text(APP_JS)
|
||||
body = _fn(js, "renderStoredMessage")
|
||||
assert 'addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true)' in body
|
||||
assert (
|
||||
'hydrateVerbatim(wrap.querySelector(".bubble"), m.answer_text || [], VERBATIM_LINK_OPTS)'
|
||||
in body
|
||||
)
|
||||
assert "appendUnlocatedVerbatim(wrap, m.answer_text || [], VERBATIM_LINK_OPTS)" in body
|
||||
assert "enhanceAnswerCodeBlocks(wrap)" in body
|
||||
order = [
|
||||
body.index("appendAnswerImage(wrap, m.answer_image)"),
|
||||
body.index("hydrateVerbatim("),
|
||||
body.index("appendUnlocatedVerbatim("),
|
||||
body.index("enhanceAnswerCodeBlocks("),
|
||||
body.index("appendSources(wrap, m.sources)"),
|
||||
]
|
||||
assert order == sorted(order)
|
||||
|
||||
|
||||
def test_every_terminal_path_settles_the_placeholders() -> None:
|
||||
"""Nothing sits at \u201cRetrieving the exact text\u2026\u201d after a terminal
|
||||
frame: the ``error`` frame handler, the 300 s guard, that guard's own
|
||||
abort surface, the user-stop branch and the failed-turn funnel each call
|
||||
``settleVerbatimPlaceholders(…, "unverified")`` — five sites, no more."""
|
||||
js = _text(APP_JS)
|
||||
assert js.count("settleVerbatimPlaceholders(") == 5, (
|
||||
"the five terminal sites: error frame, timeout, aborted catch, stop "
|
||||
"catch, failed-turn funnel"
|
||||
)
|
||||
assert js.count('"unverified"') >= 5, "every settle names the honest kind"
|
||||
funnel = _fn(js, "finalizeFailedTurn")
|
||||
assert 'settleVerbatimPlaceholders(wrap.querySelector(".bubble"), "unverified");' in funnel
|
||||
assert funnel.index("closeAllRuns(wrap)") < funnel.index("settleVerbatimPlaceholders(")
|
||||
stop = js[js.index("stoppedByUser || err?.name ===") :]
|
||||
assert "settleVerbatimPlaceholders(" in stop[:800], "the Stop action settles"
|
||||
frame = js[js.index('ev.type === "error"') :]
|
||||
assert "settleVerbatimPlaceholders(" in frame[:700], "the error frame settles"
|
||||
|
||||
|
||||
def test_the_chat_panel_links_back_to_the_chat_page() -> None:
|
||||
"""The chat page's panels navigate EXACTLY like a source chip: the same
|
||||
``documentUrl(…, "/")`` href (back → the chat, phase 153) and the same
|
||||
left-click → same-page modal (phase 26), handed to the client as ONE
|
||||
options object."""
|
||||
js = _text(APP_JS)
|
||||
opts = (
|
||||
_fn(js, "VERBATIM_LINK_OPTS")
|
||||
if "function VERBATIM_LINK_OPTS" in js
|
||||
else js[
|
||||
js.index("const VERBATIM_LINK_OPTS") : js.index(
|
||||
"};", js.index("const VERBATIM_LINK_OPTS")
|
||||
)
|
||||
]
|
||||
)
|
||||
assert "documentHref:" in opts and 'documentUrl(source, path, "/")' in opts
|
||||
assert "onOpen:" in opts and 'openDocumentModal(source, path, opener, "/")' in opts
|
||||
# every app.js call site passes it
|
||||
assert _text(APP_JS).count("VERBATIM_LINK_OPTS") >= 5
|
||||
|
||||
|
||||
def test_the_share_path_delegates_to_the_one_clipboard_helper() -> None:
|
||||
"""Task 07 consolidates the clipboard path: ``app.js``'s owner-locked
|
||||
share copy delegates to ``copyTextWithFallback`` with the same host and
|
||||
the same title, and the page no longer carries its own selection
|
||||
helper."""
|
||||
js = _text(APP_JS)
|
||||
share = _fn(js, "copyShareLinkWithFallback")
|
||||
assert "copyTextWithFallback(" in share
|
||||
assert "composer," in share
|
||||
assert '"Share link — click, then copy (Ctrl/Cmd+C)"' in share
|
||||
assert "function selectAllInField" not in js, (
|
||||
"the selection helper moved with the clipboard path — one implementation"
|
||||
)
|
||||
verbatim = _text(VERBATIM_JS)
|
||||
assert "function selectAllInFallbackField" in verbatim
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# shared.js parity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_shared_page_reaches_the_same_globals_with_a_plain_link() -> None:
|
||||
"""Parity is a hard requirement: the shared renderer calls the SAME three
|
||||
globals in the SAME band and order as the chat page's restore, and passes
|
||||
a plain ``/document.html?source=&path=`` href with NO modal (that page has
|
||||
none, and the documents API is admin-gated)."""
|
||||
js = _text(SHARED_JS)
|
||||
body = _fn(js, "renderSharedMessage")
|
||||
assert "hydrateVerbatim(" in body
|
||||
assert "appendUnlocatedVerbatim(wrap, m.answer_text || [], SHARED_VERBATIM_OPTS)" in body
|
||||
assert "enhanceAnswerCodeBlocks(wrap)" in body
|
||||
order = [
|
||||
body.index("addAnswerImage(wrap, m.answer_image)"),
|
||||
body.index("hydrateVerbatim("),
|
||||
body.index("appendUnlocatedVerbatim("),
|
||||
body.index("addSources(wrap, m.sources)"),
|
||||
]
|
||||
assert order == sorted(order)
|
||||
opts = js[
|
||||
js.index("const SHARED_VERBATIM_OPTS") : js.index(
|
||||
"};", js.index("const SHARED_VERBATIM_OPTS")
|
||||
)
|
||||
]
|
||||
assert "/document.html?source=" in opts and "encodeURIComponent(" in opts
|
||||
assert "onOpen" not in opts, "no modal on the shared page"
|
||||
assert "openDocumentModal" not in js
|
||||
assert "document-modal" not in js
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# styles.css: the states the JS adds stay inside the phase-182 block
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _css_block() -> str:
|
||||
css = _text(STYLES_CSS)
|
||||
start = css.index(BLOCK_BEGIN)
|
||||
end = css.index(BLOCK_END, start)
|
||||
return css[start : end + len(BLOCK_END)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"selector", [".verbatim-body", ".verbatim-text", ".verbatim-open", ".verbatim-more"]
|
||||
)
|
||||
def test_the_task_07_dom_selectors_are_declared_in_the_phase_182_block(
|
||||
selector: str,
|
||||
) -> None:
|
||||
"""The DOM the panel builder really produces is styled INSIDE the labelled
|
||||
region (so task 06's palette-only and contiguity pins keep holding): the
|
||||
body is the flex row the gutter's ``flex: 0 0 auto`` was written for, and
|
||||
the viewer link + the truncation line are themed, not unstyled."""
|
||||
block = _css_block()
|
||||
rules = re.findall(r"(?m)^" + re.escape(selector) + r" \{([^}]*)\}", block)
|
||||
assert rules, f"{selector} must be declared inside the phase-182 block"
|
||||
# task 06's `_body` idiom: task 07 extends a rule with its own declaration
|
||||
# line (a second block for the same selector), so every body counts.
|
||||
body = " ".join(rules)
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", body), "no hex colour"
|
||||
for literal in ("rgb(", "rgba(", "hsl(", "hsla("):
|
||||
assert literal not in body, f"{selector} must stay palette-only ({literal})"
|
||||
assert not re.search(r":\s*(?:white|black|#fff|#000)\s*[;}]", body), (
|
||||
f"{selector} must not name a colour"
|
||||
)
|
||||
if selector == ".verbatim-body":
|
||||
assert "display: flex" in body, "the gutter + text row the sticky gutter needs"
|
||||
if selector == ".verbatim-body":
|
||||
assert "display: flex" in body, "the gutter + text row the sticky gutter needs"
|
||||
if selector == ".verbatim-open":
|
||||
assert "text-decoration: underline" in body, "a link is not colour-only"
|
||||
assert "var(--ink-soft)" in body, "the link is themed, not unstyled"
|
||||
if selector == ".verbatim-more":
|
||||
assert "var(--ink-soft)" in body and "var(--line)" in body
|
||||
|
||||
|
||||
def test_the_viewer_link_reaches_the_mobile_touch_floor() -> None:
|
||||
"""PLAN §7's 44px tap target applies to the panel's link too — it lives in
|
||||
the ``max-width: 640px`` block beside the two Copy controls."""
|
||||
css = _text(STYLES_CSS)
|
||||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
|
||||
assert mobile, "the 640px block must exist"
|
||||
hit = re.search(r"\.verbatim-open \{([^}]*)\}", mobile.group(1))
|
||||
assert hit and "min-height: 44px" in hit.group(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# node: the panel's pure logic, run against the real file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_HARNESS = """
|
||||
const fs = require("fs");
|
||||
const src = fs.readFileSync(process.argv[2], "utf8");
|
||||
const api = new Function(
|
||||
src +
|
||||
"\\nreturn { verbatimCaptionText, verbatimGutter, verbatimPanelLabel," +
|
||||
" verbatimIdentity, verbatimSplitLocator, verbatimDocumentHref," +
|
||||
" isVerbatimBlock, verbatimBlocks, matchVerbatimBlock, verbatimUnresolvedCopy," +
|
||||
" verbatimBadgeTitle };"
|
||||
)();
|
||||
const cases = JSON.parse(fs.readFileSync(process.argv[3], "utf8"));
|
||||
const out = {};
|
||||
for (const [name, body] of Object.entries(cases)) {
|
||||
try {
|
||||
// `with` so the case bodies read the panel API as plain names (the file
|
||||
// itself is sloppy-mode classic-script code, and the harness is its
|
||||
// own scope — no strict-mode conflict).
|
||||
const fn = new Function("api", "with (api) { return (" + body + "); }");
|
||||
out[name] = { ok: true, value: fn(api) };
|
||||
} catch (err) {
|
||||
out[name] = { ok: false, error: String(err && err.message ? err.message : err) };
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tmp_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
return tmp_path_factory.mktemp("verbatim")
|
||||
|
||||
|
||||
def _node(cases: dict[str, str], tmp_dir: Path) -> dict[str, object]:
|
||||
"""Run *cases* (JS bodies receiving the panel API) under node against the
|
||||
real ``verbatim.js`` — the pure half needs no DOM."""
|
||||
script = tmp_dir / "harness.cjs"
|
||||
script.write_text(_HARNESS, encoding="utf-8")
|
||||
case_file = tmp_dir / "cases.json"
|
||||
case_file.write_text(
|
||||
# the case bodies are plain JS EXPRESSIONS evaluated with the panel
|
||||
# API in scope (the harness wraps them, see _HARNESS)
|
||||
json.dumps(dict(cases)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["node", str(script), str(VERBATIM_JS), str(case_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_caption_prints_the_servers_extent(tmp_dir: Path) -> None:
|
||||
"""The caption is the SERVER's resolved extent (LOCKED A2 made visible):
|
||||
a slice reads ``path · lines 12\u201340 of 210``, a whole document reads
|
||||
``path · all 210 lines``, a CAPPED block is never labelled "all" even when
|
||||
it starts at line 1, and a garbage count degrades to the bare path rather
|
||||
than printing a lie."""
|
||||
out = _node(
|
||||
{
|
||||
"slice": """verbatimCaptionText({ source: "homelab", path: "a.md",
|
||||
start_line: 12, end_line: 40, total_lines: 210 })""",
|
||||
"whole": """verbatimCaptionText({ source: "homelab", path: "a.md",
|
||||
start_line: 1, end_line: 210, total_lines: 210 })""",
|
||||
"capped": """verbatimCaptionText({ source: "homelab", path: "a.md",
|
||||
start_line: 1, end_line: 30, total_lines: 210, truncated: true })""",
|
||||
"no_total": """verbatimCaptionText({ source: "homelab", path: "a.md",
|
||||
start_line: 3, end_line: 4 })""",
|
||||
"garbage": """verbatimCaptionText({ source: "homelab", path: "a.md",
|
||||
start_line: "x", end_line: null })""",
|
||||
"label": """verbatimPanelLabel({ source: "homelab", path: "a.md",
|
||||
start_line: 12, end_line: 40 })""",
|
||||
"label_whole": """verbatimPanelLabel({ source: "homelab", path: "a.md",
|
||||
start_line: 1, end_line: 1 })""",
|
||||
"href": """verbatimDocumentHref("src a", "dir/x y.md")""",
|
||||
},
|
||||
tmp_dir,
|
||||
)
|
||||
assert out["slice"] == {"ok": True, "value": "homelab/a.md \u00b7 lines 12\u201340 of 210"}
|
||||
assert out["whole"] == {"ok": True, "value": "homelab/a.md \u00b7 all 210 lines"}
|
||||
assert out["capped"] == {"ok": True, "value": "homelab/a.md \u00b7 lines 1\u201330 of 210"}
|
||||
assert out["no_total"] == {"ok": True, "value": "homelab/a.md \u00b7 lines 3\u20134"}
|
||||
assert out["garbage"] == {"ok": True, "value": "homelab/a.md"}
|
||||
assert out["label"] == {
|
||||
"ok": True,
|
||||
"value": "Verbatim text from homelab/a.md, lines 12\u201340",
|
||||
}
|
||||
assert out["label_whole"] == {
|
||||
"ok": True,
|
||||
"value": "Verbatim text from homelab/a.md, line 1",
|
||||
}, "one line reads `line N`, never `lines 1–1`"
|
||||
# SEC: the href is built from ENCODED halves (the `documentUrl` parity —
|
||||
# the whole path is one query value, slashes included, and FastAPI decodes
|
||||
# it back), and it is always a same-origin relative URL.
|
||||
assert out["href"] == {
|
||||
"ok": True,
|
||||
"value": "/document.html?source=src%20a&path=dir%2Fx%20y.md",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_gutter_numbers_the_servers_lines_and_not_the_marker(tmp_dir: Path) -> None:
|
||||
"""One gutter row per line of the server text (so the rows align with the
|
||||
``<pre>``), numbered from the server's ``start_line``. The truncation
|
||||
marker the cap appended is a real line of the panel but NOT a line of the
|
||||
file, so it gets a blank cell (an empty cell keeps the rows aligned \u2014 the
|
||||
gutter never claims a coordinate the file does not have)."""
|
||||
out = _node(
|
||||
{
|
||||
"range": """verbatimGutter({ text: "one\\ntwo\\nthree", start_line: 12,
|
||||
end_line: 14 }).map(r => r.number).join("|")""",
|
||||
"truncated": """verbatimGutter({ text: "one\\ntwo\\n\\u2026truncated\\u2026",
|
||||
start_line: 1, end_line: 2, truncated: true })
|
||||
.map(r => JSON.stringify(r.number)).join("|")""",
|
||||
"unnumbered": """verbatimGutter({ text: "a\\nb" }).map(r => r.number).join("|")""",
|
||||
},
|
||||
tmp_dir,
|
||||
)
|
||||
assert out["range"] == {"ok": True, "value": "12|13|14"}
|
||||
assert out["truncated"] == {"ok": True, "value": '"1"|"2"|" "'}
|
||||
assert out["unnumbered"] == {"ok": True, "value": " | "}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_match_rule_exact_then_unique_path_then_none(tmp_dir: Path) -> None:
|
||||
"""The ONE hydration rule: the canonical locator first; else exactly ONE
|
||||
block for the same ``source/path`` (the model's range was wrong \u2014 the panel
|
||||
shows the server's extent); else null, INCLUDING the ambiguous case (two
|
||||
blocks, same file, different ranges \u2014 never guess)."""
|
||||
out = _node(
|
||||
{
|
||||
"exact": """(() => { const b = [
|
||||
{ source: "h", path: "a.md", locator: "h/a.md#12-40" },
|
||||
{ source: "h", path: "a.md", locator: "h/a.md#1-5" }];
|
||||
return matchVerbatimBlock(b, "h/a.md#1-5").locator; })()""",
|
||||
"path_only": """(() => { const b = [{ source: "h", path: "a.md",
|
||||
locator: "h/a.md#1-9" }];
|
||||
return matchVerbatimBlock(b, "h/a.md#99-120").locator; })()""",
|
||||
"whole_doc_path_only": """(() => { const b = [{ source: "h", path: "a.md",
|
||||
locator: "h/a.md" }];
|
||||
return matchVerbatimBlock(b, "h/a.md").locator; })()""",
|
||||
"ambiguous": """(() => { const b = [
|
||||
{ source: "h", path: "a.md", locator: "h/a.md#1-9" },
|
||||
{ source: "h", path: "a.md", locator: "h/a.md#20-29" }];
|
||||
return matchVerbatimBlock(b, "h/a.md#7-8"); })()""",
|
||||
"none": """matchVerbatimBlock([], "h/a.md#1-2")""",
|
||||
"other_path": """matchVerbatimBlock([{ source: "h", path: "b.md",
|
||||
locator: "h/b.md" }], "h/a.md")""",
|
||||
"split": """JSON.stringify(verbatimSplitLocator("h/a.md#12-40"))""",
|
||||
"split_no_range": """JSON.stringify(verbatimSplitLocator("h/a.md"))""",
|
||||
},
|
||||
tmp_dir,
|
||||
)
|
||||
assert out["exact"] == {"ok": True, "value": "h/a.md#1-5"}
|
||||
assert out["path_only"] == {"ok": True, "value": "h/a.md#1-9"}
|
||||
assert out["whole_doc_path_only"] == {"ok": True, "value": "h/a.md"}
|
||||
assert out["ambiguous"] == {"ok": True, "value": None}
|
||||
assert out["none"] == {"ok": True, "value": None}
|
||||
assert out["other_path"] == {"ok": True, "value": None}
|
||||
assert out["split"] == {
|
||||
"ok": True,
|
||||
"value": '{"identity":"h/a.md","hasRange":true}',
|
||||
}
|
||||
assert out["split_no_range"] == {
|
||||
"ok": True,
|
||||
"value": '{"identity":"h/a.md","hasRange":false}',
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_malformed_blocks_are_dropped_and_never_throw(tmp_dir: Path) -> None:
|
||||
"""A malformed / missing ``answer_text`` is a NON-event: every odd shape
|
||||
(null, a bare object, a block without text, a block without a path,
|
||||
numbers-as-strings) is filtered out instead of throwing \u2014 hydration stays
|
||||
honest for the placeholders instead of painting garbage."""
|
||||
out = _node(
|
||||
{
|
||||
"undefined_blocks": """verbatimBlocks(undefined).length""",
|
||||
"not_an_array": """verbatimBlocks({ length: 1, 0: { source: "h", path: "a.md",
|
||||
text: "x" } }).length""",
|
||||
"mixed": """verbatimBlocks([null, 7, "s", {}, { source: "", path: "a.md", text: "x" },
|
||||
{ source: "h", path: "", text: "x" },
|
||||
{ source: "h", path: "a.md" },
|
||||
{ source: "h", path: "a.md", text: "" }]).length""",
|
||||
"identity_with_empty_text": """isVerbatimBlock({ source: "h",
|
||||
path: "a.md", text: "" })""",
|
||||
"identity": """verbatimIdentity("homelab", "runbooks/a.md")""",
|
||||
"unresolved_copy": """verbatimUnresolvedCopy()""",
|
||||
"badge_title": """verbatimBadgeTitle()""",
|
||||
},
|
||||
tmp_dir,
|
||||
)
|
||||
assert out["undefined_blocks"] == {"ok": True, "value": 0}
|
||||
assert out["not_an_array"] == {"ok": True, "value": 0}
|
||||
# only the last entry survives: the others miss source/path/text.
|
||||
assert out["mixed"] == {"ok": True, "value": 1}
|
||||
assert out["identity_with_empty_text"] == {"ok": True, "value": True}
|
||||
assert out["identity"] == {"ok": True, "value": "homelab/runbooks/a.md"}
|
||||
assert out["unresolved_copy"] == {"ok": True, "value": UNRESOLVED_COPY}
|
||||
assert out["badge_title"] == {"ok": True, "value": BADGE_TITLE}
|
||||
@@ -0,0 +1,465 @@
|
||||
"""Unit: the phase-182 verbatim-panel stylesheet contract (task 06).
|
||||
|
||||
The panel DOM is built by ``verbatim.js`` (task 07) and its streaming
|
||||
placeholder by ``markdown.js`` (task 06); this module pins the CSS contract
|
||||
that makes the phase readable, in the ``test_frontend_cluster_css.py`` /
|
||||
``test_markdown_inline_links_images.py`` house pattern (no browser — the
|
||||
browser-level evidence is the phase's Playwright suite, task 08):
|
||||
|
||||
* the phase-182 block EXISTS as one clearly-labelled region of
|
||||
``frontend/assets/styles.css`` (BEGIN/END markers) and every selector the
|
||||
contract names is styled there — ``.verbatim``, ``.verbatim-head``,
|
||||
``.verbatim-caption``, ``.verbatim-badge``, ``.verbatim-body``,
|
||||
``.verbatim-lines``, ``.verbatim-pending``, ``.verbatim-state``,
|
||||
``.verbatim-unresolved``, ``.code-block``, ``.code-block-copy``,
|
||||
``.verbatim-copy``;
|
||||
* the panel is a BOUNDED scroll panel (PLAN §7.1): ``max-height`` in the
|
||||
``min(…, 70vh)`` form + ``overflow: auto``, with the head row STICKY
|
||||
(``position: sticky; top: 0``) on an opaque background so the caption
|
||||
never scrolls away and rows never show through it;
|
||||
* the code surface never wraps (phase 116 locked A1, the phase-165 prose/code
|
||||
split): ``white-space: pre`` + ``overflow-wrap: normal`` + ``tab-size: 2``,
|
||||
and the line-number gutter is ``user-select: none`` so a Copy/select never
|
||||
drags numbers into the clipboard;
|
||||
* text + color, never color alone (WCAG 1.4.1): the badge is a TEXT badge
|
||||
with ``text-transform: none``, the unresolved state hides the badge, and
|
||||
the ``.copied`` state exists beside the text swap task 07 performs;
|
||||
* the whole block is PALETTE-VARIABLES ONLY (the phase-92 zero-literal /
|
||||
monochrome invariant): no hex/rgb/hsl literal anywhere, and every
|
||||
``var()`` is a declared ``:root`` token — so a light theme keeps this code
|
||||
surface readable and no new hue enters the system;
|
||||
* the ≤640px block lifts the Copy controls to the 44px touch floor, and
|
||||
nothing in the block animates (so ``prefers-reduced-motion`` has nothing
|
||||
to still — the rule is written as a guard so a future animation cannot be
|
||||
added without its still).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
STYLES_CSS = Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css"
|
||||
|
||||
#: The phase-182 region markers (the block is one contiguous, labelled
|
||||
#: region, so "no literals in the phase-182 block" is a parseable claim).
|
||||
BEGIN = "/* ---------- Verbatim panels (phase 182, task 06) — BEGIN ----------"
|
||||
END = "/* ---------- Verbatim panels (phase 182, task 06) — END ---------- */"
|
||||
|
||||
#: Every selector the task's contract names.
|
||||
PANEL_SELECTORS = (
|
||||
".verbatim",
|
||||
".verbatim-head",
|
||||
".verbatim-caption",
|
||||
".verbatim-badge",
|
||||
".verbatim-body",
|
||||
".verbatim-lines",
|
||||
".verbatim-pending",
|
||||
".verbatim-state",
|
||||
".verbatim-unresolved",
|
||||
".code-block",
|
||||
".code-block-copy",
|
||||
".verbatim-copy",
|
||||
)
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _phase182_block(css: str) -> str:
|
||||
"""The phase-182 region, markers included (comments intact — the block
|
||||
carries the contract in prose too)."""
|
||||
start = css.find(BEGIN)
|
||||
assert start != -1, "styles.css must carry the labelled phase-182 block (BEGIN marker)"
|
||||
end = css.find(END, start)
|
||||
assert end != -1, "the phase-182 block must be closed by its END marker"
|
||||
return css[start : end + len(END)]
|
||||
|
||||
|
||||
def _strip_comments(css: str) -> str:
|
||||
return re.sub(r"/\*[\s\S]*?\*/", "", css)
|
||||
|
||||
|
||||
def _rule_bodies(block: str, selector: str) -> list[str]:
|
||||
"""The bodies of every rule whose selector list contains ``selector``
|
||||
EXACTLY (the phase-180 parse pattern, comment-stripped)."""
|
||||
bodies: list[str] = []
|
||||
for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", _strip_comments(block)):
|
||||
selectors = [s.strip() for s in " ".join(match.group(1).split()).split(",")]
|
||||
if selector in selectors:
|
||||
bodies.append(match.group(2))
|
||||
return bodies
|
||||
|
||||
|
||||
def _body(block: str, selector: str) -> str:
|
||||
bodies = _rule_bodies(block, selector)
|
||||
assert bodies, f"styles.css must style {selector}"
|
||||
return " ".join(bodies)
|
||||
|
||||
|
||||
def _root_palette(css: str) -> set[str]:
|
||||
"""Every variable name the ``:root`` block declares."""
|
||||
root = re.search(r":root\s*\{", css)
|
||||
assert root, "styles.css must define :root"
|
||||
body = css[root.end() : css.find("}", root.end())]
|
||||
palette = set(re.findall(r"(--[a-z0-9-]+)\s*:", body))
|
||||
assert palette, "the :root palette must declare variables"
|
||||
return palette
|
||||
|
||||
|
||||
def _media_block(css: str, query: str) -> str:
|
||||
"""The body of the ``@media (<query>)`` block — the house non-greedy
|
||||
pattern (nested closing braces are indented, so the first column-0 ``}``
|
||||
is the block's own end)."""
|
||||
m = re.search("@media \\(" + re.escape(query) + r"\) \{([\s\S]*?)\n\}", css)
|
||||
assert m, f"{query} media query missing"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
_CSS = _css()
|
||||
_BLOCK = _phase182_block(_CSS)
|
||||
_ROOT = _root_palette(_CSS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the block and its selectors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_phase_182_block_is_labelled_and_contains_every_selector() -> None:
|
||||
"""The block is ONE clearly-labelled region carrying every selector of
|
||||
the contract (task 06 item 5) — a rule that drifted outside the region
|
||||
would escape the palette-only and reduced-motion pins below."""
|
||||
for selector in PANEL_SELECTORS:
|
||||
assert _rule_bodies(_BLOCK, selector), f"the phase-182 block must style {selector}"
|
||||
# …and the block says what it is.
|
||||
assert "phase 182" in _BLOCK
|
||||
assert "VERBATIM" in _BLOCK, "the block documents the badge it styles"
|
||||
|
||||
|
||||
def test_panel_is_a_themed_surface_card() -> None:
|
||||
"""``.verbatim`` is a block on the theme's own canvas: the bubble's
|
||||
rhythm (a vertical margin), a --line hairline, --radius-sm corners, the
|
||||
--surface fill, ``min-width: 0`` (the long-line escape hatch every
|
||||
scroller here carries) and ``overflow: hidden`` so the rounded corners
|
||||
clip the scrolled body."""
|
||||
body = _body(_BLOCK, ".verbatim")
|
||||
for prop in (
|
||||
"display: block",
|
||||
"min-width: 0",
|
||||
"background: var(--surface)",
|
||||
"border: 1px solid var(--line)",
|
||||
"border-radius: var(--radius-sm)",
|
||||
"overflow: hidden",
|
||||
):
|
||||
assert prop in body, f".verbatim must carry {prop}"
|
||||
assert re.search(r"margin:\s0\.[0-9]+rem 0;", body), (
|
||||
".verbatim sits in the bubble's rhythm (a rem margin, not a px one)"
|
||||
)
|
||||
|
||||
|
||||
def test_head_row_is_sticky_with_an_opaque_background() -> None:
|
||||
"""PLAN §7.1's bounded-panel rule: the head row STICKS to the top of the
|
||||
scroll panel, so the caption (``source/path · lines 12–40 of 210``) is
|
||||
still on screen in a 40-line slice. OPAQUE ``--surface`` background — a
|
||||
translucent head would let the bytes read through it (the phase-156
|
||||
``.table-wrap table thead th`` idiom)."""
|
||||
body = _body(_BLOCK, ".verbatim-head")
|
||||
assert "position: sticky" in body, "the head row must be sticky"
|
||||
assert "top: 0" in body, "the sticky offset is the panel top"
|
||||
assert "background: var(--surface)" in body, "the sticky head must be opaque"
|
||||
assert "z-index: 1" in body, "the sticky head paints above the scrolled body"
|
||||
assert "display: flex" in body, "the head is a row (caption, badge, copy)"
|
||||
|
||||
|
||||
def test_caption_is_mono_soft_and_clipped_the_house_way() -> None:
|
||||
"""``.verbatim-caption`` uses the phase-156 ``.cell-clip`` ellipsis
|
||||
convention (``min-width: 0`` + ``overflow: hidden`` +
|
||||
``text-overflow: ellipsis`` + ``white-space: nowrap``) so a long
|
||||
``source/path`` clips to ``…`` instead of breaking the head row — the
|
||||
FULL text stays reachable through the control's ``aria-label``/``title``
|
||||
(the clip is visual only). ``--ink-soft`` on ``--surface`` is 8.2:1
|
||||
(AA), and the mono family matches every other path in the app."""
|
||||
body = _body(_BLOCK, ".verbatim-caption")
|
||||
for prop in (
|
||||
"min-width: 0",
|
||||
"overflow: hidden",
|
||||
"text-overflow: ellipsis",
|
||||
"white-space: nowrap",
|
||||
"font-family: var(--mono)",
|
||||
"color: var(--ink-soft)",
|
||||
):
|
||||
assert prop in body, f".verbatim-caption must carry {prop}"
|
||||
# the ellipsis convention must not rely on a fixed max-width here: the
|
||||
# caption is the flexible item of a wrapping flex row.
|
||||
assert "flex: 1" in body, "the caption takes the row's flexible space"
|
||||
|
||||
|
||||
def test_badge_is_text_with_no_uppercase_surprise() -> None:
|
||||
"""The badge is the WORD ``VERBATIM`` in the --ok family (10.6:1 on its
|
||||
own fill) with ``text-transform: none`` — an uppercase-forced word is
|
||||
spelled out letter-by-letter by some screen readers, and an icon-only
|
||||
badge would fail WCAG 1.4.1 (the claim must be TEXT)."""
|
||||
body = _body(_BLOCK, ".verbatim-badge")
|
||||
assert "text-transform: none" in body
|
||||
assert "color: var(--ok-ink)" in body, "the badge is the --ok family"
|
||||
assert "background: var(--ok-bg)" in body
|
||||
assert "content:" not in body, "the badge text is DOM text, never a CSS ::before"
|
||||
|
||||
|
||||
def test_body_is_a_bounded_scroll_panel_of_code() -> None:
|
||||
"""``.verbatim-body`` is the bounded scroll panel: ``max-height`` in the
|
||||
``min(…, 70vh)`` form (PLAN §7.1 — the panel never eats the page),
|
||||
``overflow: auto`` both axes, the mono family, ``white-space: pre`` and
|
||||
``tab-size: 2`` (the file's own indentation survives), and
|
||||
``overflow-wrap: normal`` — CODE scrolls horizontally and prose never
|
||||
does (phase 116 locked A1, the phase-165 split), which also cancels the
|
||||
``.bubble``'s inherited ``overflow-wrap: anywhere``. ``min-width: 0``
|
||||
keeps the long-line escape hatch."""
|
||||
body = _body(_BLOCK, ".verbatim-body")
|
||||
assert re.search(r"max-height:\s*min\(\s*[0-9.]+rem,\s*70vh\s*\)", body), (
|
||||
".verbatim-body must cap with max-height: min(<rem>, 70vh)"
|
||||
)
|
||||
for prop in (
|
||||
"overflow: auto",
|
||||
"min-width: 0",
|
||||
"font-family: var(--mono)",
|
||||
"white-space: pre",
|
||||
"tab-size: 2",
|
||||
"overflow-wrap: normal",
|
||||
):
|
||||
assert prop in body, f".verbatim-body must carry {prop}"
|
||||
assert "white-space: pre-wrap" not in body, "code never wraps (locked A1)"
|
||||
|
||||
|
||||
def test_line_numbers_are_not_selectable() -> None:
|
||||
"""The gutter is ``user-select: none`` (with the ``-webkit-`` prefix) on
|
||||
BOTH the container and its items, right-aligned in ``--ink-soft``: a
|
||||
reader who selects the panel — or a Copy path that reads the selection —
|
||||
never drags line numbers into the clipboard. ``list-style: none`` means
|
||||
the digits are TEXT, so the rule behaves identically in every engine."""
|
||||
container = _body(_BLOCK, ".verbatim-lines")
|
||||
for prop in (
|
||||
"user-select: none",
|
||||
"-webkit-user-select: none",
|
||||
"text-align: right",
|
||||
"color: var(--ink-soft)",
|
||||
"list-style: none",
|
||||
):
|
||||
assert prop in container, f".verbatim-lines must carry {prop}"
|
||||
items = _body(_BLOCK, ".verbatim-lines li")
|
||||
assert "user-select: none" in items, "the <li> line numbers must be unselectable too"
|
||||
|
||||
|
||||
def test_pending_state_is_muted_and_static() -> None:
|
||||
"""The streaming state (``.verbatim-pending`` + ``.verbatim-state``, the
|
||||
markdown.js placeholder) is MUTED — ``--ink-soft`` on ``--surface``,
|
||||
8.2:1 — and it never announces itself with motion: the pending line is a
|
||||
``role="status"`` live region, so the announcement IS the feedback."""
|
||||
pending = _body(_BLOCK, ".verbatim-pending")
|
||||
state = _body(_BLOCK, ".verbatim-state")
|
||||
assert "color: var(--ink-soft)" in state, "the pending line is muted (not an answer)"
|
||||
assert "background: var(--surface)" in pending
|
||||
assert "animation" not in pending and "transition" not in pending
|
||||
assert "animation" not in state and "transition" not in state
|
||||
|
||||
|
||||
def test_unresolved_state_carries_no_badge() -> None:
|
||||
"""The honest failure state: the --err boundary + --err-bg canvas with
|
||||
--ink-soft text (7.9:1, AA), and CSS-side enforcement that a ``VERBATIM``
|
||||
badge NEVER appears on an unresolved panel whatever DOM reaches it — the
|
||||
badge belongs only to a server-resolved block (locked A5)."""
|
||||
body = _body(_BLOCK, ".verbatim-unresolved")
|
||||
assert "var(--err" in body, "the unresolved state is the --err family"
|
||||
assert "color: var(--ink-soft)" in body
|
||||
hide = _body(_BLOCK, ".verbatim-unresolved .verbatim-badge")
|
||||
assert "display: none" in hide, "an unresolved panel never badges"
|
||||
|
||||
|
||||
def test_copy_controls_are_pills_with_a_ring_and_an_ellipsis_label() -> None:
|
||||
"""``.code-block-copy`` / ``.verbatim-copy``: the house pill (mono text,
|
||||
--line hairline, --surface fill, ink-soft label), the label is the clip
|
||||
target (``text-overflow: ellipsis`` on its span, the control's
|
||||
``aria-label`` naming the full path), the ``:focus-visible`` ring is the
|
||||
app's 3px --brand ring, and the ``.copied`` rule exists to SUPPORT the
|
||||
text swap (``Copy`` → ``Copied``) with colour — never colour alone."""
|
||||
both = _body(_BLOCK, ".code-block-copy") + " " + _body(_BLOCK, ".verbatim-copy")
|
||||
for prop in (
|
||||
"border: 1px solid var(--line)",
|
||||
"background: var(--surface)",
|
||||
"color: var(--ink-soft)",
|
||||
"font-family: var(--mono)",
|
||||
"cursor: pointer",
|
||||
"min-height:",
|
||||
):
|
||||
assert prop in both, f"the copy controls must carry {prop}"
|
||||
label = _body(_BLOCK, ".code-block-copy > span")
|
||||
assert "text-overflow: ellipsis" in label, "a clipped copy label shows the hint"
|
||||
for selector in (".code-block-copy:focus-visible", ".verbatim-copy:focus-visible"):
|
||||
ring = _body(_BLOCK, selector)
|
||||
assert "outline: 3px solid var(--brand)" in ring, f"{selector} needs the house ring"
|
||||
assert "outline-offset: 2px" in ring
|
||||
for selector in (".code-block-copy.copied", ".verbatim-copy.copied"):
|
||||
copied = _body(_BLOCK, selector)
|
||||
assert "var(--ok" in copied, f"{selector} must use the --ok family as SUPPORT"
|
||||
|
||||
|
||||
def test_plain_code_block_wrapper_is_not_a_verbatim_panel() -> None:
|
||||
"""``.code-block`` is the plain wrapper task 07 puts around an ordinary
|
||||
answer ``<pre>``: a Copy button belongs to it, the ``VERBATIM`` badge
|
||||
does NOT — so the wrapper rule carries no --ok fill and no panel chrome
|
||||
(locked A5: verbatim-and-badged OR plainly the model's, never a hybrid)."""
|
||||
body = _body(_BLOCK, ".code-block")
|
||||
assert "position: relative" in body
|
||||
assert "min-width: 0" in body
|
||||
assert "--ok" not in body, "an ordinary code block never wears the verbatim badge"
|
||||
assert not _rule_bodies(_BLOCK, ".code-block .verbatim-badge"), (
|
||||
"no badge rule may exist inside the plain wrapper"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the palette-only invariant (phase 92 / the :root-drift guard)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_phase_182_block_uses_palette_variables_only() -> None:
|
||||
"""NO colour literal anywhere in the phase-182 block: every colour comes
|
||||
from a declared ``:root`` token (no hex, no ``rgb()``/``rgba()``, no
|
||||
``hsl()``, no named colour). A literal would be a second source of truth
|
||||
for the built-in palette (the ``test_theming.py`` drift guard parses the
|
||||
built-ins out of this file) and a new hue would break the monochrome
|
||||
invariant — this is also what keeps a code surface readable when the
|
||||
owner switches the app to a light or a monochrome theme."""
|
||||
code = _strip_comments(_BLOCK)
|
||||
refs = re.findall(r"var\((--[a-z0-9-]+)\)", code)
|
||||
assert refs, "the phase-182 block must reference palette vars"
|
||||
for ref in set(refs):
|
||||
assert ref in _ROOT, f"{ref} is not a :root palette var"
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", code), "no hex color literal in the block"
|
||||
for fn in ("rgb(", "rgba(", "hsl(", "hsla("):
|
||||
assert fn not in code, f"no {fn} color literal in the block"
|
||||
# named colours are the other way a second source of truth sneaks in
|
||||
for name in (": white", ": black", "white;", "black;", "white }", "black }"):
|
||||
assert name not in code, f"no named colour in the block ({name.strip()})"
|
||||
|
||||
|
||||
def test_phase_182_block_restates_no_global_focus_rule_wrongly() -> None:
|
||||
"""The restatement of the global ``:focus-visible`` ring uses the SAME
|
||||
values as the app-wide rule (3px --brand, 2px offset) — a different
|
||||
ring here would be a second, drifting source of focus truth."""
|
||||
global_rule = _css()[: _css().find("\n::selection")]
|
||||
assert re.search(r":focus-visible \{\s*outline: 3px solid var\(--brand\);\s*"
|
||||
r"outline-offset: 2px;", global_rule), (
|
||||
"the app-wide :focus-visible rule is the baseline this block copies"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mobile + reduced motion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mobile_block_keeps_a_44px_control() -> None:
|
||||
"""The ≤640px block lifts BOTH Copy controls to the 44px touch floor
|
||||
(PLAN §7: the phone tap target) — the desktop pill is smaller, so the
|
||||
rule must live in the media block, not only in the base rule."""
|
||||
mobile = _media_block(_CSS, "max-width: 640px")
|
||||
assert "Phase 182" in mobile, "the mobile block must name the phase it serves"
|
||||
hit = re.search(r"\.code-block-copy, \.verbatim-copy \{([^}]*)\}", mobile)
|
||||
assert hit, "the mobile block must raise both Copy controls together"
|
||||
assert "min-height: 44px" in hit.group(1)
|
||||
|
||||
|
||||
def test_nothing_in_the_block_animates_without_a_reduced_motion_still() -> None:
|
||||
"""``prefers-reduced-motion`` is honored by anything that animates. The
|
||||
phase-182 block animates NOTHING (the pending state is a live region, so
|
||||
the text swap is the feedback), which this pins as a GUARD: if a future
|
||||
edit adds an ``animation``/``transition``, the same selectors must appear
|
||||
in a ``prefers-reduced-motion: reduce`` block that stills them."""
|
||||
code = _strip_comments(_BLOCK)
|
||||
animated: list[str] = []
|
||||
for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", code):
|
||||
body = match.group(2)
|
||||
if re.search(r"(^|[;\s])(animation|transition)\s*:", body):
|
||||
animated.append(" ".join(match.group(1).split()))
|
||||
if not animated:
|
||||
assert "prefers-reduced-motion" in _BLOCK, (
|
||||
"a static block must say so, so the next reader does not add "
|
||||
"a pulse 'to match the app'"
|
||||
)
|
||||
return
|
||||
reduced = "\n".join(
|
||||
m.group(0)
|
||||
for m in re.finditer(r"@media \(prefers-reduced-motion: reduce\)[\s\S]*?\n\}", _CSS)
|
||||
)
|
||||
for selectors in animated:
|
||||
for selector in selectors.split(","):
|
||||
assert selector.strip() in reduced, (
|
||||
f"{selector} animates but is never stilled under "
|
||||
"prefers-reduced-motion"
|
||||
)
|
||||
|
||||
|
||||
def test_the_panel_never_repaints_the_existing_code_and_bubble_rules() -> None:
|
||||
"""The neighbours are untouched: ``.bubble pre`` (the ordinary answer
|
||||
code block) and ``.doc-raw`` (the viewer's scroller) keep their
|
||||
declarations, so the phase adds a SURFACE and does not restyle every
|
||||
code block in the app."""
|
||||
css = _CSS
|
||||
pre = re.search(r"\.bubble pre \{([^}]*)\}", css)
|
||||
assert pre, ".bubble pre must still exist"
|
||||
for prop in ("background: var(--surface)", "border: 1px solid var(--line)", "overflow-x: auto"):
|
||||
assert prop in pre.group(1), f".bubble pre must keep {prop}"
|
||||
raw = re.search(r"\.doc-raw \{([^}]*)\}", css)
|
||||
assert raw, ".doc-raw must still exist"
|
||||
assert "white-space: pre" in raw.group(1)
|
||||
|
||||
|
||||
def test_block_is_one_contiguous_region_of_the_sheet() -> None:
|
||||
"""The BEGIN/END markers wrap ONE region (a phase that appends a stray
|
||||
``.verbatim-*`` rule elsewhere escapes every pin in this module)."""
|
||||
css = _CSS
|
||||
assert css.count(BEGIN) == 1 and css.count(END) == 1
|
||||
assert css.index(BEGIN) < css.index(END)
|
||||
between = css[css.index(BEGIN) : css.index(END)]
|
||||
# every verbatim selector lives inside it
|
||||
for selector in PANEL_SELECTORS:
|
||||
assert re.search(r"(?m)^" + re.escape(selector) + r"[^\n]*\{", between), (
|
||||
f"{selector} must be declared inside the labelled phase-182 region"
|
||||
)
|
||||
outside = _strip_comments(css[: css.index(BEGIN)] + css[css.index(END) :])
|
||||
for selector in PANEL_SELECTORS:
|
||||
assert not re.search(
|
||||
r"(?m)^" + re.escape(selector) + r"[^\n]*\{", outside
|
||||
), f"{selector} must not be restyled outside the phase-182 region"
|
||||
|
||||
|
||||
def test_selector_names_match_the_dom_the_renderer_and_task_07_build() -> None:
|
||||
"""The CSS class names are the contract between the renderer's pending
|
||||
template (``markdown.js``) and the panel builder (``verbatim.js``, task
|
||||
07): the pending template's classes must be exactly the ones styled here,
|
||||
so the streaming placeholder is never an unstyled div."""
|
||||
js = (STYLES_CSS.parent / "markdown.js").read_text(encoding="utf-8")
|
||||
for cls in ("verbatim", "verbatim-pending", "verbatim-caption", "verbatim-state"):
|
||||
assert cls in js, f"markdown.js must emit .{cls}"
|
||||
assert _rule_bodies(_BLOCK, "." + cls), f"styles.css must style .{cls}"
|
||||
assert "verbatim-unresolved" in _BLOCK, (
|
||||
"the unresolved class is part of THIS contract (task 07 renders it)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pair", [("--ink", "--surface"), ("--ink-soft", "--surface")])
|
||||
def test_block_pairs_the_documented_text_tokens(pair: tuple[str, str]) -> None:
|
||||
"""The block pairs its text with the canvas the :root comment documents
|
||||
(``--ink`` and ``--ink-soft`` on ``--surface``) rather than inventing a
|
||||
third ink — the AA duty is inherited from the palette, not restated."""
|
||||
code = _strip_comments(_BLOCK)
|
||||
assert re.search(r"color: var\(" + re.escape(pair[0]) + r"\)", code), (
|
||||
f"the block must use {pair[0]}"
|
||||
)
|
||||
assert re.search(r"background: var\(" + re.escape(pair[1]) + r"\)", code), (
|
||||
f"the block must sit on {pair[1]}"
|
||||
)
|
||||
@@ -0,0 +1,636 @@
|
||||
"""Unit: the shared renderer's opt-in ``bor`` locator pass (phase 182, task 06).
|
||||
|
||||
Browser behavior lands in the phase's Playwright suite (task 08); here the
|
||||
established ``test_frontend_*`` convention pins the SOURCE contract of
|
||||
``frontend/assets/markdown.js`` without a browser — the second, optional
|
||||
argument and the single truthy guard, the pass ORDER (verbatim BEFORE
|
||||
fences), the ``\\u0000VERBATIMn\\u0000`` placeholder + restore step, the exact
|
||||
pending template (``verbatim-pending`` / ``data-bor-locator`` /
|
||||
``verbatim-caption`` / ``role="status"`` / the pinned copy
|
||||
``Retrieving the exact text…``), the local ``borLocator`` normalizer and the
|
||||
separator/range grammar it accepts, and the fact that the ordinary-fence
|
||||
``<pre><code>`` output string is UNCHANGED (task 06 item 4: task 07 wraps
|
||||
``<pre>`` nodes at the DOM level instead, so the document viewer, the
|
||||
thinking blocks and every other caller keep byte-identical HTML).
|
||||
|
||||
The node-driven behavioral half (skipped when node is unavailable, the
|
||||
phase-44/138/180 idiom) proves the story shapes: the completion criterion —
|
||||
``renderMarkdown("```bor homelab/a.md 12-40\\nGARBAGE\\n```", {verbatim:
|
||||
true})`` yields the placeholder DOM with
|
||||
``data-bor-locator="homelab/a.md#12-40"`` and NO ``GARBAGE`` anywhere in the
|
||||
returned HTML (locked A7, the phase's core contract) — while the SAME call
|
||||
without the second argument returns the pre-phase output byte-for-byte; a
|
||||
bare `` ```bor `` (no path) survives to the fence pass untouched; an
|
||||
unparseable locator is put back byte-for-byte (no placeholder, no badge, the
|
||||
model's own body — neither improved nor worsened); a fence left OPEN at the
|
||||
very end of the input shows the pending state; two fences restore in answer
|
||||
order; a locator carrying markup stays inert escaped text; and a standalone
|
||||
placeholder is not wrapped in a ``<p>``.
|
||||
|
||||
Parity half: ``borLocator`` is the documented CLIENT MIRROR of Python
|
||||
``app.rag.verbatim.canonical_locator``. The phase's E2E (task 08) pins that
|
||||
parity end to end (both sides must agree or hydration misses); this module
|
||||
pins the GRAMMAR too, by running both implementations over the same table
|
||||
and over a deterministic fuzz — the only way to catch a grammar drift
|
||||
before there is a browser in the loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
MARKDOWN_JS = FRONTEND / "assets" / "markdown.js"
|
||||
|
||||
HAVE_NODE = shutil.which("node") is not None
|
||||
|
||||
#: The single truthy path of the phase-182 second argument.
|
||||
VERBATIM_GUARD = "opts && opts.verbatim === true"
|
||||
|
||||
#: The verbatim fence pass — the pinned regex of task 06 item 2.
|
||||
VERBATIM_FENCE_RE = r"/^```bor[ \t]+\S[^\n]*\n(?:[\s\S]*?^```[ \t]*$|(?![\s\S]))/gm"
|
||||
|
||||
#: The pre-existing fence pass, untouched (task 06 item 4).
|
||||
ORDINARY_FENCE_RULE = (
|
||||
'codeBlocks.push(`<pre><code>${escapeHtml(code.replace(/\\n$/, ""))}</code></pre>`);'
|
||||
)
|
||||
|
||||
#: The untouched pre-phase paragraph guard (the phase-138/180 pin).
|
||||
PARAGRAPH_GUARD = r"/^<(h\d|ul|ol|pre|li|blockquote)/"
|
||||
|
||||
#: The placeholder's block-element clause added by task 06.
|
||||
PLACEHOLDER_GUARD = r'/^\u0000VERBATIM\d+\u0000$/'
|
||||
|
||||
#: The pinned pending copy (``…`` is ONE character, not three dots).
|
||||
PENDING_COPY = "Retrieving the exact text\u2026"
|
||||
|
||||
#: The completion criterion's input.
|
||||
CRITERION_INPUT = "```bor homelab/a.md 12-40\nGARBAGE\n```"
|
||||
|
||||
#: The pre-phase (verbatim-OFF) output of :data:`CRITERION_INPUT` — the
|
||||
#: byte-pin of the "inert until opted in" contract. A `` ```bor path `` info
|
||||
#: string never matched the ordinary fence rule (``/```(\w*)\n/`` needs the
|
||||
#: language token ALONE on that line), so OFF it is escaped prose.
|
||||
CRITERION_OFF_OUTPUT = "<p>```bor homelab/a.md 12-40<br>GARBAGE<br>```</p>"
|
||||
|
||||
#: The option task 07 will pass (never passed by any call site yet).
|
||||
ON: dict[str, bool] = {"verbatim": True}
|
||||
|
||||
#: The inputs the node cases render, keyed by case name.
|
||||
CASE_INPUT: dict[str, str] = {
|
||||
"criterion": CRITERION_INPUT,
|
||||
"bare_bor": "```bor\nfoo\n```",
|
||||
"open_bare": "```bor\n",
|
||||
"malformed": "```bor not-a-locator 12-40\nSECRET_BODY\n```",
|
||||
"open_fence": "```bor homelab/a.md 12-40\n",
|
||||
"two_fences": "```bor a/b 1-2\nBODY_ONE\n```\n\n```bor c/d\nBODY_TWO\n```",
|
||||
"en_dash": "```bor a/b 12\u201340\nBODY\n```",
|
||||
"l_prefix": "```bor a/b#L12-L40\nBODY\n```",
|
||||
"xss": "```bor a\"/><script>alert(1)</script>/b.md 1-2\nBODY\n```",
|
||||
"standalone": "prose\n\n```bor homelab/a.md\nBODY\n```\n\nafter",
|
||||
"attached": "prose\n```bor homelab/a.md 1-2\nBODY\n```",
|
||||
"nested": "```\ntext\n```bor a/b 1-2\nBODY\n```\n",
|
||||
"no_bor": "# Title\n\nsome **text** and `code`\n\n| a |\n|---|\n| 1 |\n\n> quote\n",
|
||||
}
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _run_node(script: str) -> str:
|
||||
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
|
||||
assert proc.returncode == 0, f"node failed: {proc.stderr}"
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _render(cases: dict[str, dict[str, bool] | None]) -> dict[str, str]:
|
||||
"""Run every case through the REAL renderer under node.
|
||||
|
||||
``None`` means the ONE-argument call (every pre-phase call site); a dict
|
||||
is passed as the second argument as-is.
|
||||
"""
|
||||
parts = []
|
||||
for name, opts in cases.items():
|
||||
arg = "" if opts is None else f", {json.dumps(opts)}"
|
||||
parts.append(f"out.{name} = renderMarkdown({json.dumps(CASE_INPUT[name])}{arg}); ")
|
||||
driver = _text(MARKDOWN_JS) + "\nconst out = {}; " + "".join(parts)
|
||||
driver += "console.log(JSON.stringify(out));"
|
||||
results: dict[str, str] = json.loads(_run_node(driver))
|
||||
return results
|
||||
|
||||
|
||||
def _bor_locator_source() -> str:
|
||||
js = _text(MARKDOWN_JS)
|
||||
start = js.index("function borLocator(raw) {")
|
||||
end = js.index("\nfunction renderMarkdown", start)
|
||||
return js[start:end]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the signature + the guard (task 06 item 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_render_markdown_takes_the_optional_opts_argument() -> None:
|
||||
"""``renderMarkdown(md)`` became ``renderMarkdown(md, opts)`` — a SECOND,
|
||||
OPTIONAL argument, so every existing call site (the document viewer, the
|
||||
thinking blocks, the user bubbles) keeps working untouched — and the
|
||||
ONLY truthy path is ``opts.verbatim === true`` (a strict ``===``, so
|
||||
``{verbatim: 1}`` / ``{verbatim: "yes"}`` stay OFF)."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
assert "function renderMarkdown(md, opts) {" in js, (
|
||||
"renderMarkdown must gain the second, optional `opts` argument"
|
||||
)
|
||||
assert "function renderMarkdown(md) {" not in js, "the one-argument signature is gone"
|
||||
body = js[js.index("function renderMarkdown(md, opts) {") :]
|
||||
assert body.count(VERBATIM_GUARD) == 1, (
|
||||
f"the pass must be guarded by exactly one `{VERBATIM_GUARD}` test"
|
||||
)
|
||||
assert "if (opts && " in js, "a missing opts object must be handled (no TypeError)"
|
||||
|
||||
|
||||
def test_header_comment_records_the_phase_182_pass() -> None:
|
||||
"""The header comment carries the three things the next reader needs: the
|
||||
pass exists and is opt-in, WHY the model's body is discarded (locked A7),
|
||||
and the byte-identical-when-off contract."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
header = js[: js.find("function escapeHtml")]
|
||||
flat = " ".join(
|
||||
" ".join(line.strip().lstrip("*").strip() for line in header.splitlines()).split()
|
||||
)
|
||||
for needle, label in [
|
||||
("Phase 182", "the phase reference"),
|
||||
("opts && opts.verbatim === true", "the single truthy path"),
|
||||
("BODY IS DISCARDED", "why the model's bytes are dropped"),
|
||||
("locked A7", "the locked decision behind it"),
|
||||
("canonical_locator", "the Python function this mirrors"),
|
||||
("BYTE-IDENTICAL WHEN OFF", "the inert-when-off contract"),
|
||||
("verbatim \u2192 fences \u2192 tables", "the new pass order"),
|
||||
("STRICTER", "the deliberate asymmetry with the server's fences_in"),
|
||||
("model's own body", "the malformed-locator degradation"),
|
||||
]:
|
||||
assert needle in flat, f"the header comment must record {label}: {needle!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the pass + the placeholder (task 06 item 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verbatim_pass_runs_before_the_fence_pass() -> None:
|
||||
"""Order is verbatim → fences → tables → escape+inline → blockquotes →
|
||||
paragraphs → restore. The verbatim regex MUST sit ahead of the ordinary
|
||||
fence replace, otherwise a locator fence is eaten as a code block first
|
||||
and the placeholder never exists."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
assert VERBATIM_FENCE_RE in js, f"the pinned fence regex is missing: {VERBATIM_FENCE_RE}"
|
||||
fence_pass = js.find(ORDINARY_FENCE_RULE)
|
||||
assert fence_pass != -1, "the ordinary fence pass must still be there"
|
||||
assert js.find(VERBATIM_FENCE_RE) < fence_pass, (
|
||||
"the verbatim pass must run BEFORE the fence pass"
|
||||
)
|
||||
# …and the rest of the pipeline keeps its order behind it.
|
||||
for marker in ("const tables = []", "text = inline(escapeHtml(text));", r"split(/\n{2,}/)"):
|
||||
assert fence_pass < js.find(marker), f"the pass order broke at {marker!r}"
|
||||
|
||||
|
||||
def test_placeholder_is_created_and_restored() -> None:
|
||||
"""A parsed locator becomes ``\\u0000VERBATIMn\\u0000`` and the FINAL restore
|
||||
step (alongside TABLE/CODE) turns it back into the panel — the
|
||||
placeholder survives escaping (NUL and digits are not escaped) and never
|
||||
reaches the DOM itself."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
assert "`\\u0000VERBATIM${verbatim.length - 1}\\u0000`" in js, (
|
||||
"the placeholder must be the indexed VERBATIM marker"
|
||||
)
|
||||
restore = r".replace(/\u0000VERBATIM(\d+)\u0000/g"
|
||||
assert restore in js, "the restore step must replace the VERBATIM placeholder"
|
||||
table = js.find(r".replace(/\u0000TABLE(\d+)\u0000/g")
|
||||
code = js.find(r".replace(/\u0000CODE(\d+)\u0000/g")
|
||||
assert table < code < js.find(restore), "the restore order is TABLE → CODE → VERBATIM"
|
||||
|
||||
|
||||
def test_pending_template_is_exact() -> None:
|
||||
"""The restore emits EXACTLY the pinned pending template: the
|
||||
``verbatim verbatim-pending`` panel, the canonical locator in
|
||||
``data-bor-locator`` (task 07's hydration key), the clipped
|
||||
``verbatim-caption``, and the ``role="status"`` state line carrying the
|
||||
pinned copy — every interpolated value through ``escapeHtml``."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
for marker in (
|
||||
'<div class="verbatim verbatim-pending" data-bor-locator="${escapeHtml(canonical)}">',
|
||||
'<span class="verbatim-caption">${escapeHtml(identity)}</span>',
|
||||
f'<span class="verbatim-state" role="status">{PENDING_COPY}</span>',
|
||||
):
|
||||
assert marker in js, f"the pending template must contain {marker!r}"
|
||||
assert 'data-bor-locator="${canonical}"' not in js, (
|
||||
"the locator is model-written text landing in an ATTRIBUTE — it must "
|
||||
"go through escapeHtml (the escape-first boundary)"
|
||||
)
|
||||
|
||||
|
||||
def test_malformed_locator_is_put_back_byte_for_byte() -> None:
|
||||
"""``borLocator`` returning ``""`` returns the ORIGINAL match, so a
|
||||
malformed locator degrades to whatever the fence pass does with this text
|
||||
today (no placeholder, no badge, the model's own body) — this phase
|
||||
neither improves nor worsens it."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
assert 'if (canonical === "") return match;' in js, (
|
||||
"an unparseable locator must be returned to the text unchanged"
|
||||
)
|
||||
|
||||
|
||||
def test_paragraph_guard_blocks_placeholder_and_untouched_guard_stays() -> None:
|
||||
"""The step-3 guard keeps the pre-phase test verbatim (the phase-138/180
|
||||
pin) and adds the placeholder clause, so a standalone panel restores as a
|
||||
``<div>`` instead of landing inside a ``<p>``."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
assert PARAGRAPH_GUARD in js, "the pre-phase paragraph guard must stay"
|
||||
assert js.count(PARAGRAPH_GUARD) == 1, "there is exactly one paragraph guard"
|
||||
assert PLACEHOLDER_GUARD in js, "the placeholder must be a block element too"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# borLocator — the client mirror (task 06 item 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bor_locator_exists_and_handles_every_grammar_token() -> None:
|
||||
"""``borLocator`` exists, is documented as the CLIENT MIRROR of
|
||||
``app.rag.verbatim.canonical_locator``, and its body carries every
|
||||
separator (`` ` ``, ``:``, ``#``, the ``L`` prefix) and range shape
|
||||
(``12-40``, the en dash, ``L12-L40``, a bare ``12``) of the D7 grammar —
|
||||
plus the refusals (non-numeric, ``< 1``, ``start > end``, trailing
|
||||
junk)."""
|
||||
body = _bor_locator_source()
|
||||
for token, label in [
|
||||
(r"[ \t:#]+", "the ` `/`:`/`#` separator class"),
|
||||
(r"L?\d+", "the optional `L` line prefix"),
|
||||
(r"[-\u2013]", "the hyphen AND the en dash range separator"),
|
||||
("(?:", "the OPTIONAL range group (a bare `12` and no range at all)"),
|
||||
("$/", "the end anchor (trailing junk is not a range)"),
|
||||
('includes("/")', "the first-`/` identity requirement"),
|
||||
("start < 1", "the `< 1` refusal"),
|
||||
("start > end", "the `start > end` refusal"),
|
||||
('replace(/^[Ll]+/, "")', "the `L` prefix strip on each bound"),
|
||||
]:
|
||||
assert token in body, f"borLocator must handle {label}: {token!r}"
|
||||
|
||||
|
||||
def test_bor_locator_is_documented_as_the_python_mirror() -> None:
|
||||
"""The docstring above ``borLocator`` names the Python function it
|
||||
mirrors and states that parity is load-bearing (a divergence means
|
||||
hydration misses), so nobody 'fixes' one side alone."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
comment = js[: js.index("function borLocator(raw) {")]
|
||||
flat = " ".join(
|
||||
" ".join(line.strip().lstrip("/").strip() for line in comment.splitlines()).split()
|
||||
)
|
||||
for needle, label in [
|
||||
("canonical_locator", "the Python function"),
|
||||
("CLIENT MIRROR", "that this is the mirror, not the source"),
|
||||
("PARITY IS LOAD-BEARING", "why both sides must agree"),
|
||||
("#12-40", "the canonical range form"),
|
||||
]:
|
||||
assert needle in flat, f"the docstring must record {label}: {needle!r}"
|
||||
|
||||
|
||||
def test_bor_locator_returns_canonical_digits_or_empty() -> None:
|
||||
"""Source shape: the canonical output is ``identity#start-end`` (digits
|
||||
only) and every refusal path returns ``""`` — never ``null``/``undefined``
|
||||
(the renderer compares against ``""``)."""
|
||||
body = _bor_locator_source()
|
||||
assert "return `${identity}#${start}-${end}`;" in body
|
||||
assert body.count('return "";') >= 6, "every refusal path must return the empty string"
|
||||
assert "return null" not in body and "return undefined" not in body, (
|
||||
'borLocator\'s not-a-locator value is "", so the renderer\'s '
|
||||
'`if (canonical === "")` test cannot miss'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ordinary fences are NOT wrapped in markdown (task 06 item 4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ordinary_fence_output_string_is_unchanged() -> None:
|
||||
"""Task 06 item 4: do NOT wrap ordinary fences in markdown — task 07
|
||||
wraps ``<pre>`` nodes at the DOM level, so the document viewer, the
|
||||
thinking blocks and every other caller keep byte-identical HTML. The
|
||||
fence rule and its ``<pre><code>`` output string are pinned verbatim."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
assert r"/```(\w*)\n([\s\S]*?)```/g" in js, "the ordinary fence regex is unchanged"
|
||||
assert ORDINARY_FENCE_RULE in js, "the ordinary fence output string is unchanged"
|
||||
assert 'class="code-block"' not in js, (
|
||||
"markdown.js must NOT emit the task-07 wrapper — that is a DOM-level wrap"
|
||||
)
|
||||
|
||||
|
||||
#: The answer renderers task 07 opted IN (the whole client switch — the
|
||||
#: per-delta write, the ``done``-time re-render, the brain restore).
|
||||
OPTED_IN_CALLS: tuple[str, ...] = (
|
||||
"renderMarkdown(acc, { verbatim: true })",
|
||||
"renderMarkdown(finalText, { verbatim: true })",
|
||||
"renderMarkdown(m.text, { verbatim: true })",
|
||||
)
|
||||
|
||||
|
||||
def test_only_the_answer_renderers_opted_in() -> None:
|
||||
"""Task 07 opted in exactly the THREE answer renderers (the per-delta
|
||||
write, the ``done``-time re-render, the brain restore) — and the shared
|
||||
page's one brain renderer. NOTHING else: the user-bubble renders, the
|
||||
live thinking runs, the legacy thinking restore and the whole document
|
||||
viewer stay one-argument calls, because a question and a scratchpad are
|
||||
not locators (task 06's byte-identity contract for every other surface).
|
||||
The count is the pin: a fourth ``verbatim: true`` in either file is a
|
||||
surface the phase never claimed."""
|
||||
app = _text(FRONTEND / "assets" / "app.js")
|
||||
shared = _text(FRONTEND / "assets" / "shared.js")
|
||||
for call in OPTED_IN_CALLS:
|
||||
assert call in app, f"app.js must render the answer through {call}"
|
||||
assert app.count("verbatim: true") == 3, (
|
||||
"exactly the three answer renderers may opt in in app.js"
|
||||
)
|
||||
assert (
|
||||
'addSharedMessage("brain", renderMarkdown(m.text, { verbatim: true }))' in shared
|
||||
)
|
||||
assert shared.count("verbatim: true") == 1, "the shared page opts in its one renderer"
|
||||
# The neighbours stay one-argument, in both files.
|
||||
assert 'addMessage("user", renderMarkdown(m.text), true)' in app
|
||||
assert 'addSharedMessage("user", renderMarkdown(m.text))' in shared
|
||||
for name in ("app.js", "shared.js"):
|
||||
js = _text(FRONTEND / "assets" / name)
|
||||
for site in re.findall(r"renderMarkdown\([^\n]*\)", js):
|
||||
if "thinking" in site or "entry.text" in site:
|
||||
assert "verbatim" not in site, f"{name}: {site} must stay one-argument"
|
||||
# A document's own ```bor fence is the document's text — never a locator.
|
||||
assert "verbatim: true" not in _text(FRONTEND / "assets" / "document.js")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# behavior (node): the story shapes + the completion criterion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_locator_fence_yields_the_placeholder_and_drops_the_model_bytes() -> None:
|
||||
"""THE completion criterion: the criterion input WITH ``{verbatim: true}``
|
||||
yields the placeholder DOM with
|
||||
``data-bor-locator="homelab/a.md#12-40"`` and NO ``GARBAGE`` anywhere in
|
||||
the returned HTML (locked A7). Two fences restore in answer order, the en
|
||||
dash and the ``L`` prefix canonicalize, and a standalone panel is not
|
||||
wrapped in a ``<p>``."""
|
||||
out = _render(
|
||||
{
|
||||
"criterion": ON,
|
||||
"two_fences": ON,
|
||||
"en_dash": ON,
|
||||
"l_prefix": ON,
|
||||
"standalone": ON,
|
||||
}
|
||||
)
|
||||
html = out["criterion"]
|
||||
assert 'class="verbatim verbatim-pending"' in html
|
||||
assert 'data-bor-locator="homelab/a.md#12-40"' in html
|
||||
assert ">homelab/a.md</span>" in html, "the caption carries the source/path"
|
||||
assert f">{PENDING_COPY}</span>" in html
|
||||
assert 'role="status"' in html
|
||||
assert "GARBAGE" not in html, "the model's body must appear NOWHERE (locked A7)"
|
||||
assert "\u0000" not in html, "no placeholder byte survives"
|
||||
assert "<p>" not in html, "a standalone panel is a block, not a <p>"
|
||||
|
||||
two = out["two_fences"]
|
||||
assert two.index("a/b#1-2") < two.index('data-bor-locator="c/d"'), (
|
||||
"two fences keep answer order"
|
||||
)
|
||||
assert two.count("verbatim-pending") == 2
|
||||
assert "BODY_ONE" not in two and "BODY_TWO" not in two
|
||||
assert out["en_dash"].count('data-bor-locator="a/b#12-40"') == 1
|
||||
assert "BODY" not in out["en_dash"]
|
||||
assert out["l_prefix"].count('data-bor-locator="a/b#12-40"') == 1
|
||||
assert "BODY" not in out["l_prefix"]
|
||||
assert out["standalone"] == (
|
||||
'<p>prose</p><div class="verbatim verbatim-pending" '
|
||||
'data-bor-locator="homelab/a.md"><span class="verbatim-caption">homelab/a.md</span>'
|
||||
f'<span class="verbatim-state" role="status">{PENDING_COPY}</span></div><p>after</p>'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_off_paths_are_byte_identical_to_the_pre_phase_renderer() -> None:
|
||||
"""Every call WITHOUT ``{verbatim: true}`` — no second argument, the
|
||||
document viewer's one-argument call, and an explicit
|
||||
``{verbatim: false}`` — returns the PRE-PHASE output byte-for-byte: the
|
||||
criterion input is escaped prose (a `` ```bor path `` info string never
|
||||
matched the ``/```(\\w*)\\n/`` fence rule), and a locator-free document
|
||||
renders identically with and without the option."""
|
||||
out = _render(
|
||||
{
|
||||
"criterion": None,
|
||||
"malformed": None,
|
||||
"two_fences": None,
|
||||
"standalone": None,
|
||||
"nested": None,
|
||||
"no_bor": None,
|
||||
}
|
||||
)
|
||||
assert out["criterion"] == CRITERION_OFF_OUTPUT, "the OFF path must not change a byte"
|
||||
for name, html in out.items():
|
||||
assert "verbatim" not in html, f"{name}: no placeholder markup with the option OFF"
|
||||
# the option object exists but is false → the same bytes as no argument
|
||||
on_false = _render({"criterion": {"verbatim": False}})
|
||||
assert on_false["criterion"] == CRITERION_OFF_OUTPUT
|
||||
truthy_but_strict = _render({"criterion": {}})
|
||||
assert truthy_but_strict["criterion"] == CRITERION_OFF_OUTPUT, (
|
||||
"the guard is `=== true`: an options object without the flag stays OFF"
|
||||
)
|
||||
# a locator-free document is untouched by the whole feature
|
||||
assert _render({"no_bor": ON})["no_bor"] == out["no_bor"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_bare_and_malformed_fences_fall_through_to_the_ordinary_pass() -> None:
|
||||
"""A bare `` ```bor `` (no path) is an ordinary code block and survives to
|
||||
the fence pass UNTOUCHED even with the option on; a bare `` ```bor ``
|
||||
with no body at all still paints its raw marker as prose; and an
|
||||
unparseable locator degrades to exactly what the fence pass does with
|
||||
this text today — the model's own body, no placeholder, no badge."""
|
||||
out = _render({"bare_bor": ON, "open_bare": ON, "malformed": ON})
|
||||
assert out["bare_bor"] == "<p><pre><code>foo</code></pre></p>", (
|
||||
"a bare ```bor fence must render EXACTLY as it does pre-phase"
|
||||
)
|
||||
assert out["open_bare"] == "<p>```bor</p>"
|
||||
assert out["malformed"] == "<p>```bor not-a-locator 12-40<br>SECRET_BODY<br>```</p>", (
|
||||
"a malformed locator is put back byte-for-byte: no placeholder, "
|
||||
"no badge, the model's own body"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_open_fence_at_the_end_shows_the_pending_state() -> None:
|
||||
"""The second alternative: a fence left OPEN at the very end of the input
|
||||
(the info line typed, the body not yet streamed) shows the pending
|
||||
placeholder instead of raw backticks — the deliberate asymmetry with the
|
||||
server's stricter ``fences_in`` (the server only ever sees a finished
|
||||
answer, the client sees a partial one)."""
|
||||
out = _render({"open_fence": ON})
|
||||
html = out["open_fence"]
|
||||
assert 'data-bor-locator="homelab/a.md#12-40"' in html
|
||||
assert "```" not in html, "no raw backticks while a locator fence is open"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_locator_markup_stays_inert_escaped_text() -> None:
|
||||
"""Escape-first holds on the new surface: a locator carrying ``"><script>``
|
||||
is HTML-escaped in BOTH the attribute and the caption, so it can never
|
||||
become a live element (the renderer's whole reason for existing)."""
|
||||
html = _render({"xss": ON})["xss"]
|
||||
assert "<script>" not in html
|
||||
assert "<script>" in html
|
||||
assert '"/><script>' in html
|
||||
assert "#1-2" in html, "the markup still canonicalized to a locator"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_a_bor_line_inside_an_ordinary_fence_agrees_with_the_server() -> None:
|
||||
"""Documented limitation shared with ``fences_in`` (both sides scan for
|
||||
the opening line, neither supports a nested `` ``` ``): a ``bor`` locator
|
||||
line inside an ordinary fence is a LOCATOR on both sides, so hydration
|
||||
still matches — it is never silently half-code, half-panel on one side
|
||||
only."""
|
||||
from app.rag.verbatim import fences_in
|
||||
|
||||
html = _render({"nested": ON})["nested"]
|
||||
assert 'data-bor-locator="a/b#1-2"' in html
|
||||
assert fences_in(CASE_INPUT["nested"]) == ["a/b 1-2"], (
|
||||
"the server must see the SAME locator in the same text"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_fence_attached_to_prose_still_loses_its_body() -> None:
|
||||
"""A locator fence with NO blank line above it still loses the model's
|
||||
body (the browser closes the surrounding ``<p>`` when it parses the
|
||||
``<div>``, the same fix-up an ordinary fenced ``<pre>`` already relies
|
||||
on) — the body never paints either way."""
|
||||
html = _render({"attached": ON})["attached"]
|
||||
assert 'data-bor-locator="homelab/a.md#1-2"' in html
|
||||
assert "BODY" not in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parity with the server's canonical_locator (task 06 item 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The D7 grammar table: input → the canonical string BOTH sides must
|
||||
#: produce, or ``""`` for "not a locator at all".
|
||||
PARITY_TABLE: dict[str, str] = {
|
||||
"homelab/a.md": "homelab/a.md",
|
||||
"homelab/a.md 12-40": "homelab/a.md#12-40",
|
||||
"homelab/a.md:12-40": "homelab/a.md#12-40",
|
||||
"homelab/a.md#12-40": "homelab/a.md#12-40",
|
||||
"homelab/a.md#L12-L40": "homelab/a.md#12-40",
|
||||
"homelab/a.md 12\u201340": "homelab/a.md#12-40",
|
||||
"homelab/a.md 12": "homelab/a.md#12-12",
|
||||
"homelab/a.md 12-40": "homelab/a.md#12-40",
|
||||
"homelab/a.md 12 - 40": "homelab/a.md#12-40",
|
||||
" homelab/a.md ": "homelab/a.md",
|
||||
"homelab/deep/dir/a.md#L3": "homelab/deep/dir/a.md#3-3",
|
||||
"homelab/a.md#l3-l7": "homelab/a.md#3-7",
|
||||
"homelab/a.md L7": "homelab/a.md#7-7",
|
||||
# the en dash is a RANGE separator, never the identity/range separator,
|
||||
# so this row is ONE whole-document identity whose path contains one:
|
||||
"homelab/a.md\u201312-40": "homelab/a.md\u201312-40",
|
||||
# refusals — not locators at all:
|
||||
"a.md": "",
|
||||
"": "",
|
||||
" ": "",
|
||||
"homelab/a.md 0-5": "",
|
||||
"homelab/a.md 40-12": "",
|
||||
"homelab/a.md x-y": "",
|
||||
"homelab/a.md 12-40 extra": "",
|
||||
"bor homelab/a.md": "",
|
||||
"homelab/a.md#": "",
|
||||
"homelab/a.md:": "",
|
||||
"homelab/a.md-": "",
|
||||
"homelab/my note.md 12": "",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_bor_locator_matches_the_python_canonical_locator() -> None:
|
||||
"""The mirror is exact: for every row of the grammar table the JS
|
||||
``borLocator`` and the Python ``canonical_locator`` agree — and the table
|
||||
itself is checked against the D7 spec, so a SIMULTANEOUS drift on both
|
||||
sides is caught too. A divergence here means hydration misses: the panel
|
||||
would sit unresolved forever."""
|
||||
from app.rag.verbatim import canonical_locator
|
||||
|
||||
script = (
|
||||
_bor_locator_source()
|
||||
+ "\nconst t = "
|
||||
+ json.dumps(PARITY_TABLE)
|
||||
+ ";\nconst out = {};\nfor (const k of Object.keys(t)) out[k] = borLocator(k);\n"
|
||||
"console.log(JSON.stringify(out));\n"
|
||||
)
|
||||
got: dict[str, str] = json.loads(_run_node(script))
|
||||
for raw, expected in PARITY_TABLE.items():
|
||||
assert got[raw] == expected, f"borLocator({raw!r}) != {expected!r} (got {got[raw]!r})"
|
||||
python = canonical_locator(raw) or ""
|
||||
assert python == expected, (
|
||||
f"canonical_locator({raw!r}) = {python!r} disagrees with the D7 "
|
||||
f"table value {expected!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_fuzz_locator_parity() -> None:
|
||||
"""A deterministic fuzz over the grammar's alphabet: the two
|
||||
implementations must agree on every string, including the shapes nobody
|
||||
hand-wrote (dangling separators, multi-bound ranges, lone ``L``s)."""
|
||||
from app.rag.verbatim import canonical_locator
|
||||
|
||||
rng = random.Random(182)
|
||||
alphabet = [
|
||||
"a", "b", "/", ".", ":", "#", " ", "L", "l", "-", "\u2013", "1", "2", "9", "x", "\t",
|
||||
]
|
||||
samples = [
|
||||
"".join(rng.choice(alphabet) for _ in range(rng.randint(0, 9))) for _ in range(400)
|
||||
]
|
||||
script = (
|
||||
_bor_locator_source()
|
||||
+ "\nconst t = "
|
||||
+ json.dumps(samples)
|
||||
+ ";\nconst out = [];\nfor (const k of t) out.push(borLocator(k));\n"
|
||||
"console.log(JSON.stringify(out));\n"
|
||||
)
|
||||
got: list[str] = json.loads(_run_node(script))
|
||||
for raw, js_value in zip(samples, got, strict=True):
|
||||
python = canonical_locator(raw) or ""
|
||||
assert js_value == python, f"parity break on {raw!r}: js={js_value!r} py={python!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# no regression on the neighboring passes (the phase's byte-identity mandate)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_adjacent_renderer_fixtures_stay_pinned_where_they_live() -> None:
|
||||
"""The phase-44 table, phase-138 blockquote and phase-180 image fixtures
|
||||
are the byte-identity evidence for the passes this task sits next to —
|
||||
they live in their own suites, so this pins that those suites still pin
|
||||
them (a deleted fixture here would silently drop the guarantee)."""
|
||||
for name, needle in [
|
||||
("test_markdown_tables.py", "md-table-wrap"),
|
||||
("test_markdown_blockquotes.py", '<blockquote class="md-quote">'),
|
||||
("test_markdown_inline_links_images.py", 'class="md-img"'),
|
||||
]:
|
||||
text = _text(Path(__file__).resolve().parent / name)
|
||||
assert needle in text, f"{name} must still pin {needle!r}"
|
||||
@@ -123,8 +123,12 @@ 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())
|
||||
mutate the constant. Phase 182 (task 03) pin update, deliberate:
|
||||
the flag pin passes ``answer_text=False`` explicitly — with
|
||||
``answer_text`` ON (the shipped default) the offer additionally
|
||||
carries ``show_text`` (pinned in ``tests/unit/test_agent.py``); the
|
||||
``answer_images`` gate itself is unchanged."""
|
||||
off = agent_tools(_settings(answer_text=False))
|
||||
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
|
||||
@@ -136,8 +140,12 @@ 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))
|
||||
offer; the base three are unchanged). Phase 182 (task 03) pin
|
||||
update, deliberate: ``answer_text=False`` isolates the
|
||||
``answer_images`` gate — with ``answer_text`` ON (the shipped
|
||||
default) ``show_text`` rides LAST (pinned in
|
||||
``tests/unit/test_agent.py``)."""
|
||||
on = agent_tools(_settings(answer_images=True, answer_text=False))
|
||||
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
|
||||
@@ -166,7 +174,11 @@ 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."""
|
||||
pre-phase-124 list — and gains ``show_image`` only when it is on.
|
||||
Phase 182 (task 03) pin update, deliberate: both apps pass
|
||||
``answer_text=False`` to isolate the ``answer_images`` gate (with
|
||||
the shipped default ON, ``show_text`` additionally rides last —
|
||||
pinned in ``tests/unit/test_agent.py``)."""
|
||||
|
||||
class _ScriptedLLM:
|
||||
"""One canned content-only round; records the ``tools`` every
|
||||
@@ -207,12 +219,12 @@ def test_run_agent_gates_the_show_image_offer() -> None:
|
||||
pass
|
||||
|
||||
llm_off = _ScriptedLLM()
|
||||
asyncio.run(_consume(llm_off, _settings()))
|
||||
asyncio.run(_consume(llm_off, _settings(answer_text=False)))
|
||||
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)))
|
||||
asyncio.run(_consume(llm_on, _settings(answer_images=True, answer_text=False)))
|
||||
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] == [
|
||||
@@ -427,14 +439,24 @@ def test_plan_turn_grounded_carries_clause_exactly_when_toggle_on() -> None:
|
||||
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())
|
||||
off_settings = _settings()
|
||||
off = _plan3(0.9, off_settings)
|
||||
assert off.deflected is False
|
||||
assert off.system_prompt == build_high_prompt([_P3_DOC])
|
||||
# Phase 182 (task 04): the reconstruction mirrors the OTHER display
|
||||
# clause's flag too (BOR_ANSWER_TEXT, default ON) so this pin stays
|
||||
# about answer_images alone — the show_text clause is pinned in
|
||||
# tests/unit/test_prompts.py.
|
||||
assert off.system_prompt == build_high_prompt(
|
||||
[_P3_DOC], show_text=off_settings.answer_text
|
||||
)
|
||||
assert SHOW_IMAGE_CLAUSE not in off.system_prompt
|
||||
|
||||
on = _plan3(0.9, _settings(answer_images=True))
|
||||
on_settings = _settings(answer_images=True)
|
||||
on = _plan3(0.9, on_settings)
|
||||
assert on.deflected is False
|
||||
assert on.system_prompt == build_high_prompt([_P3_DOC], show_image=True)
|
||||
assert on.system_prompt == build_high_prompt(
|
||||
[_P3_DOC], show_image=True, show_text=on_settings.answer_text
|
||||
)
|
||||
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>")
|
||||
|
||||
@@ -599,13 +599,18 @@ def test_meta_rows_append_to_msg_body_never_into_the_bubble() -> None:
|
||||
|
||||
def test_restore_renders_brain_text_through_the_pinned_call_shape() -> None:
|
||||
"""CORNER: the restore. The phase-14 restore renders every brain
|
||||
record through `addMessage("brain", renderMarkdown(m.text), true)`
|
||||
— with `m.text === ""` the node-pinned `renderMarkdown("") === ""
|
||||
record through `addMessage("brain", renderMarkdown(m.text, { verbatim:
|
||||
true }), true)` (the option is phase 182's verbatim opt-in — it changes
|
||||
which FENCES render, never whether the text does) — with `m.text === ""`
|
||||
the node-pinned `renderMarkdown("") === ""`
|
||||
` (pinned above) makes the bubble CHILD-LESS ⇒ `:empty` ⇒ hidden:
|
||||
an empty persisted answer paints no box, through the SAME rule
|
||||
(locked A3) — no restore-path branch was added."""
|
||||
js = _js()
|
||||
assert js.count('addMessage("brain", renderMarkdown(m.text), true)') == 1
|
||||
assert (
|
||||
js.count('addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true)')
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_shared_page_builds_the_identical_msg_structure_no_change_needed() -> None:
|
||||
|
||||
@@ -18,10 +18,13 @@ from app.rag.prompts import (
|
||||
ORIGIN_RULE,
|
||||
PERSONA,
|
||||
SEARCH_DISCIPLINE_RULE,
|
||||
SHOW_IMAGE_CLAUSE,
|
||||
SHOW_TEXT_CLAUSE,
|
||||
SUGGEST_INTRO,
|
||||
TOOLS_SECTION,
|
||||
_base,
|
||||
build_deflect_prompt,
|
||||
build_high_prompt,
|
||||
)
|
||||
|
||||
|
||||
@@ -323,3 +326,50 @@ def test_suggest_intro_byte_locked() -> None:
|
||||
) in SUGGEST_INTRO
|
||||
# The retired phase-118 final sentence is gone from the constant.
|
||||
assert "Cite the document(s) you used, by path." not in SUGGEST_INTRO
|
||||
|
||||
|
||||
# ---------- SHOW_TEXT_CLAUSE (phase 182, task 04: verbatim display) ----------
|
||||
|
||||
#: Anchors for ``SHOW_TEXT_CLAUSE`` — the ``show_text`` / ```bor``` locator
|
||||
#: teaching appended INSIDE ``<tools>`` when ``BOR_ANSWER_TEXT`` is on
|
||||
#: (phase 182, LOCKED A1–A3/A6). Pinned the way ``TOOLS_SECTION`` is
|
||||
#: (sha256 + exact prefix + total length) because this clause IS the fix
|
||||
#: for the retype bug the phase exists for: a silent wording drift here
|
||||
#: re-opens it (the tool alone never teaches that the user copies from
|
||||
#: what is DISPLAYED, nor the fence syntax).
|
||||
SHOW_TEXT_CLAUSE_SHA256 = "e03a8153ca6cde61c9437f3b66b4fa07cd2a3f9f2e24e3bb8c37ca1d7c7e9e3b"
|
||||
SHOW_TEXT_CLAUSE_LEN = 1179
|
||||
SHOW_TEXT_CLAUSE_PREFIX = (
|
||||
"`show_text` displays part of a document to the user EXACTLY as stored"
|
||||
)
|
||||
|
||||
|
||||
def test_show_text_clause_byte_locked() -> None:
|
||||
"""The clause is LOCKED copy: sha256 + exact prefix + total length,
|
||||
plus the two sentences the phase's contract depends on verbatim (the
|
||||
copy-from-the-display rule and the server-owns-the-numbers rule)."""
|
||||
assert len(SHOW_TEXT_CLAUSE) == SHOW_TEXT_CLAUSE_LEN
|
||||
assert _sha256(SHOW_TEXT_CLAUSE) == SHOW_TEXT_CLAUSE_SHA256
|
||||
assert SHOW_TEXT_CLAUSE.startswith(SHOW_TEXT_CLAUSE_PREFIX)
|
||||
assert SHOW_TEXT_CLAUSE.endswith("For an image use `show_image` instead.")
|
||||
assert "the user copies from what is displayed, not from your prose" in SHOW_TEXT_CLAUSE
|
||||
assert "the numbers you display are the server's, never yours" in SHOW_TEXT_CLAUSE
|
||||
|
||||
|
||||
def test_high_prompt_display_clause_composition_is_byte_locked() -> None:
|
||||
"""One mechanism, four combinations (phase 182, task 04): neither
|
||||
flag reproduces the untouched :data:`TOOLS_SECTION` tail,
|
||||
``show_image`` alone reproduces the phase-124 bytes exactly,
|
||||
``show_text`` alone appends only the text clause, and both append
|
||||
the image clause, a blank line, the text clause, then the closing
|
||||
tag — ``TOOLS_SECTION`` itself is never edited (its own sha pin
|
||||
above holds)."""
|
||||
off = build_high_prompt([])
|
||||
assert off.endswith(TOOLS_SECTION)
|
||||
head = off[: -len("</tools>")]
|
||||
assert build_high_prompt([], show_image=True) == head + SHOW_IMAGE_CLAUSE + "\n</tools>"
|
||||
assert build_high_prompt([], show_text=True) == head + SHOW_TEXT_CLAUSE + "\n</tools>"
|
||||
assert (
|
||||
build_high_prompt([], show_image=True, show_text=True)
|
||||
== head + SHOW_IMAGE_CLAUSE + "\n\n" + SHOW_TEXT_CLAUSE + "\n</tools>"
|
||||
)
|
||||
|
||||
@@ -30,6 +30,15 @@ discipline rules stay byte-identical): the teaching refusals in
|
||||
:mod:`app.rag.agent` re-state the same contract; the ``<tools>``
|
||||
marker keying (HIGH only) is unchanged.
|
||||
|
||||
And the phase-182 ``show_text`` clause (task 04, LOCKED A1–A3/A6): the
|
||||
verbatim-display teaching appended INSIDE ``<tools>`` when
|
||||
``BOR_ANSWER_TEXT`` is on — the user copies from what is DISPLAYED, never
|
||||
from the model's retype, and the inline ```bor <source/path> [range]```
|
||||
fence places a server-resolved block. The clause composes with the
|
||||
phase-124 ``show_image`` clause through one mechanism, and every build
|
||||
the flags do not select stays byte-identical (the constant's own sha pin
|
||||
lives in :mod:`tests.unit.test_prompt_lock`).
|
||||
|
||||
And the phase-119 cite discipline (task 04, LOCKED A5): the intro's
|
||||
final sentence ("cite the document(s) you used, by path", phase 118)
|
||||
is REPLACED — the HIGH prompt carries the discipline sentence exactly
|
||||
@@ -40,6 +49,7 @@ the LOW prompt and ``PERSONA`` / ``TOOLS_SECTION`` stay byte-identical
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
@@ -52,6 +62,7 @@ from app.rag.prompts import (
|
||||
PERSONA,
|
||||
SEARCH_DISCIPLINE_RULE,
|
||||
SHOW_IMAGE_CLAUSE,
|
||||
SHOW_TEXT_CLAUSE,
|
||||
SUGGEST_INTRO,
|
||||
TOOLS_SECTION,
|
||||
_base,
|
||||
@@ -1281,6 +1292,170 @@ def test_show_image_clause_is_last_for_every_high_shape() -> None:
|
||||
assert SHOW_IMAGE_CLAUSE not in off # the toggle-off twin carries none of it
|
||||
|
||||
|
||||
# ---------- phase 182 (task 04, LOCKED A1–A3/A6): the show_text clause ----------
|
||||
#
|
||||
# The clause is the fix for the retype bug: nothing in the pre-phase prompt
|
||||
# ever said the user copies from what is DISPLAYED, so the model retyped
|
||||
# files from context and every retype differed from the stored bytes by a
|
||||
# character. Pinned three ways: the locked sentence order (the behaviour
|
||||
# the E2E and the persona depend on), the byte-identity of every build the
|
||||
# flag does NOT select, and the composition of the two display clauses
|
||||
# through the one "section minus its final </tools>" mechanism.
|
||||
|
||||
#: The clause's sentence anchors, in the locked order (task 04's work
|
||||
#: list): display contract → when to call → do-NOT-retype → the ```bor```
|
||||
#: placement syntax → the numbers belong to the server → the failure rule
|
||||
#: → the image pointer.
|
||||
SHOW_TEXT_ANCHORS: tuple[str, ...] = (
|
||||
"displays part of a document to the user EXACTLY as stored",
|
||||
"the user copies from what is displayed, not from your prose",
|
||||
"Call it whenever the user wants to SEE, COPY or RUN a file",
|
||||
"a quadlet, a config, a script, a unit file",
|
||||
"do NOT repeat the displayed lines in your answer",
|
||||
"may differ from the file by a character, and the user cannot tell",
|
||||
"write a locator line on its own: three backticks followed by `bor`",
|
||||
"the combined `source/path`, and optionally the line range (` 12-40`)",
|
||||
"Leave its body EMPTY — the app replaces it with the exact stored text",
|
||||
"Never type the file's content inside a `bor` block",
|
||||
"Never guess line numbers — use the numbers `grep` and `read` reported",
|
||||
"OMIT the range and display the whole document",
|
||||
"the numbers you display are the server's, never yours",
|
||||
"If `show_text` refuses, say what you could not display",
|
||||
"do not reconstruct it from memory",
|
||||
"For an image use `show_image` instead",
|
||||
)
|
||||
|
||||
|
||||
def test_show_text_clause_carries_every_locked_rule_in_order() -> None:
|
||||
"""Every sentence of the contract is present, in the locked order, and
|
||||
the clause is a clause — one paragraph, no ``<tools>`` markup of its
|
||||
own (the ``SHOW_IMAGE_CLAUSE`` precedent)."""
|
||||
positions = [SHOW_TEXT_CLAUSE.index(anchor) for anchor in SHOW_TEXT_ANCHORS]
|
||||
assert positions == sorted(positions), SHOW_TEXT_ANCHORS
|
||||
assert "\n" not in SHOW_TEXT_CLAUSE
|
||||
assert SHOW_TEXT_CLAUSE.startswith("`show_text`")
|
||||
assert SHOW_TEXT_CLAUSE.endswith("instead.")
|
||||
assert "<tools>" not in SHOW_TEXT_CLAUSE and "</tools>" not in SHOW_TEXT_CLAUSE
|
||||
# The fence language is named as a token, never as a stray substring.
|
||||
assert re.search(r"(?<![A-Za-z0-9_])bor(?![A-Za-z0-9_])", SHOW_TEXT_CLAUSE)
|
||||
|
||||
|
||||
def test_high_prompt_default_stays_byte_identical_without_show_text() -> None:
|
||||
"""Completion pin: ``show_text`` defaults to ``False`` — the default
|
||||
build and the explicit ``show_text=False`` are byte-identical to each
|
||||
other and end with the untouched ``TOOLS_SECTION``, with no trace of
|
||||
the tool or the fence language (the sha pins in
|
||||
:mod:`tests.unit.test_prompt_lock` pass UNMODIFIED)."""
|
||||
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||
plain = build_high_prompt([doc])
|
||||
assert build_high_prompt([doc], show_text=False) == plain
|
||||
assert plain.endswith(TOOLS_SECTION)
|
||||
assert SHOW_TEXT_CLAUSE not in plain
|
||||
assert "show_text" not in plain
|
||||
assert not re.search(r"(?<![A-Za-z0-9_])bor(?![A-Za-z0-9_])", plain)
|
||||
empty = build_high_prompt([])
|
||||
assert build_high_prompt([], show_text=False) == empty
|
||||
assert empty.endswith(TOOLS_SECTION)
|
||||
assert SHOW_TEXT_CLAUSE not in empty
|
||||
|
||||
|
||||
def test_high_prompt_show_text_clause_lands_inside_tools_after_the_copy() -> None:
|
||||
"""``show_text=True`` builds EXACTLY the toggle-off prompt with its
|
||||
final ``</tools>`` replaced by the clause (on its own line, after the
|
||||
existing three-tool copy) + the closing tag — the three-tool copy is
|
||||
byte-identical, ``TOOLS_SECTION`` is unedited, and the clause is the
|
||||
last thing the model reads."""
|
||||
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||
off = build_high_prompt([doc])
|
||||
on = build_high_prompt([doc], show_text=True)
|
||||
assert on == off[: -len("</tools>")] + SHOW_TEXT_CLAUSE + "\n</tools>"
|
||||
clause_start = on.index(SHOW_TEXT_CLAUSE)
|
||||
assert on[:clause_start] == off[: -len("</tools>")]
|
||||
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]
|
||||
assert on.endswith(SHOW_TEXT_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_TEXT_CLAUSE) == 1
|
||||
assert on.count("<tools>") == 1 and on.count("</tools>") == 1
|
||||
|
||||
|
||||
def test_high_prompt_display_clauses_compose_independently() -> None:
|
||||
"""The two display flags are independent (LOCKED A1): ``show_image``
|
||||
only reproduces the phase-124 bytes exactly, ``show_text`` only is the
|
||||
three-tool copy plus the text clause, both puts the image clause
|
||||
FIRST and the text clause last, and neither is byte-identical to
|
||||
pre-phase — no combination leaks the other's clause."""
|
||||
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||
neither = build_high_prompt([doc])
|
||||
head = neither[: -len("</tools>")]
|
||||
image = build_high_prompt([doc], show_image=True)
|
||||
text = build_high_prompt([doc], show_text=True)
|
||||
both = build_high_prompt([doc], show_image=True, show_text=True)
|
||||
assert neither.endswith(TOOLS_SECTION)
|
||||
assert image == head + SHOW_IMAGE_CLAUSE + "\n</tools>"
|
||||
assert text == head + SHOW_TEXT_CLAUSE + "\n</tools>"
|
||||
assert both == head + SHOW_IMAGE_CLAUSE + "\n\n" + SHOW_TEXT_CLAUSE + "\n</tools>"
|
||||
assert SHOW_TEXT_CLAUSE not in image and SHOW_IMAGE_CLAUSE not in text
|
||||
assert both.index(SHOW_IMAGE_CLAUSE) < both.index(SHOW_TEXT_CLAUSE)
|
||||
for on in (image, text, both):
|
||||
assert on[: on.index("<tools>")] == neither[: on.index("<tools>")]
|
||||
assert on.count("<tools>") == 1 and on.count("</tools>") == 1
|
||||
|
||||
|
||||
def test_high_prompt_empty_documents_show_text_clause_still_in_tools() -> None:
|
||||
"""The "(no documents matched…)" body + ``show_text=True``: the clause
|
||||
still lands inside ``<tools>`` — the grounded turn may still ``ls`` /
|
||||
``grep`` its way to a file and display it."""
|
||||
off = build_high_prompt([])
|
||||
on = build_high_prompt([], show_text=True)
|
||||
assert on == off[: -len("</tools>")] + SHOW_TEXT_CLAUSE + "\n</tools>"
|
||||
assert "(no documents matched — do not invent specifics)" in on
|
||||
assert on.index("<tools>") < on.index(SHOW_TEXT_CLAUSE) < on.index("</tools>")
|
||||
assert on.endswith(SHOW_TEXT_CLAUSE + "\n</tools>")
|
||||
|
||||
|
||||
def test_show_text_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 text clause is
|
||||
still the last thing inside ``<tools>`` before the closing tag."""
|
||||
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_text=True,
|
||||
)
|
||||
assert on == off[: -len("</tools>")] + SHOW_TEXT_CLAUSE + "\n</tools>"
|
||||
clause_start = on.index(SHOW_TEXT_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>")
|
||||
assert on.endswith(SHOW_TEXT_CLAUSE + "\n</tools>")
|
||||
assert SHOW_TEXT_CLAUSE not in off
|
||||
|
||||
|
||||
def test_show_text_clause_rides_only_the_high_prompt() -> None:
|
||||
"""Neither the tool name nor the fence language ever reaches the
|
||||
deflection prompt (phase 182's D9: a deflected turn has no tools, and
|
||||
resolving a locator there would leak KB text through a turn that just
|
||||
disclaimed it) — and ``build_deflect_prompt`` takes no such flag."""
|
||||
assert "show_text" 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_TEXT_CLAUSE not in prompt
|
||||
assert "show_text" not in prompt
|
||||
assert not re.search(r"(?<![A-Za-z0-9_])bor(?![A-Za-z0-9_])", prompt)
|
||||
|
||||
|
||||
# ---------- phase 125 (task 05, LOCKED A4): the image context line ----------
|
||||
# An enriched image doc (tasks 03/04: non-NULL ``image_context`` in the
|
||||
# "Referenced in …" block shape) leads its ``<document>`` suggestion
|
||||
|
||||
@@ -63,6 +63,11 @@ from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
APP_JS = FRONTEND / "assets" / "app.js"
|
||||
# Phase 182 (task 07): the ONE clipboard implementation moved to the shared
|
||||
# verbatim panel client; the share path delegates to it, so the fallback's
|
||||
# internals are pinned there (the owner-locked behavior is re-pinned, not
|
||||
# dropped).
|
||||
VERBATIM_JS = FRONTEND / "assets" / "verbatim.js"
|
||||
# Phase 76 (task 02): SOURCES_HTML + GIT_SOURCES_HTML dropped — both
|
||||
# views are folded into the shell (index.html); both files deleted.
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
@@ -533,18 +538,36 @@ def test_share_current_chat_save_then_share_branch() -> None:
|
||||
assert finally_idx != -1 and "shareBtn.disabled = false" in body[finally_idx:], (
|
||||
"the button is re-enabled in the finally — never stale"
|
||||
)
|
||||
# The clipboard + fallback helpers live in app.js (the chat page's
|
||||
# copy of the per-page helper).
|
||||
# Phase 182 (task 07): the clipboard + fallback pair MOVED to
|
||||
# assets/verbatim.js so the answer's verbatim panels and ordinary code
|
||||
# blocks share ONE degradation with the share link. The owner-locked
|
||||
# behavior is unchanged and re-pinned on its new home: the same host (the
|
||||
# composer, beside the status line — it rides the delegation), the same
|
||||
# transient `.share-link-fallback` field, the same select-on-focus, the
|
||||
# same XSS contract (textContent only), the same title string.
|
||||
copy = _fn(js, "copyShareLinkWithFallback")
|
||||
assert "navigator.clipboard.writeText(absoluteUrl)" in copy
|
||||
assert 'field.className = "share-link-fallback"' in copy
|
||||
assert "field.href = absoluteUrl" in copy
|
||||
assert "field.textContent = absoluteUrl" in copy, "XSS contract: textContent only"
|
||||
assert 'field.addEventListener("focus", () => selectAllInField(field))' in copy, (
|
||||
"select-on-focus — the field-like behavior"
|
||||
assert "copyTextWithFallback(" in copy, (
|
||||
"the share path delegates — ONE clipboard implementation"
|
||||
)
|
||||
assert "composer.appendChild(field)" in copy, "near the status line (the composer)"
|
||||
sel = _fn(js, "selectAllInField")
|
||||
assert "absoluteUrl," in copy, "the shared URL is what gets copied"
|
||||
assert "composer," in copy, "near the status line (the composer)"
|
||||
assert '"Share link — click, then copy (Ctrl/Cmd+C)"' in copy, (
|
||||
"the owner-locked field title rides the delegation byte-identically"
|
||||
)
|
||||
ver = VERBATIM_JS.read_text(encoding="utf-8")
|
||||
impl = _fn(ver, "copyTextWithFallback")
|
||||
assert "await navigator.clipboard.writeText(text)" in impl
|
||||
assert 'field.className = "share-link-fallback"' in impl
|
||||
assert "field.href = text" in impl, "a URL field carries the href"
|
||||
assert "field.textContent = text" in impl, "XSS contract: textContent only"
|
||||
assert (
|
||||
'field.addEventListener("focus", () => selectAllInFallbackField(field))' in impl
|
||||
), "select-on-focus — the field-like behavior"
|
||||
assert "host.appendChild(field)" in impl, "appended to the caller's host"
|
||||
assert (
|
||||
'document.querySelectorAll(".share-link-fallback").forEach((el) => el.remove())' in impl
|
||||
), "one field at a time (a new offer replaces the old)"
|
||||
sel = _fn(ver, "selectAllInFallbackField")
|
||||
assert "document.createRange()" in sel and "selectNodeContents(el)" in sel
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user