chore(agent): phase roadmap from TODO.md — 6 phases (111–116): banner retry, honesty gate, chip quality, embed length, draft discard, modal scrollbar
Build and Push Containers / build-and-push-app (push) Successful in 16s
Build and Push Containers / build-and-push-db (push) Successful in 13s

This commit is contained in:
2026-09-14 22:07:32 -04:00
parent 2b75f3cc85
commit f37c517590
25 changed files with 754 additions and 208 deletions
@@ -0,0 +1,42 @@
# Phase 111 — Chat error banner: a real Retry button (TODO L1)
**Source:** `TODO.md` L3–21 — "L1 — Chat error banner: 'Try again' is plain text, not a button (2026-09-15, brain-of-reese interactive test)"
**Story:** n/a (interactive-test follow-up fix; extends the phase-06 loading-feedback and phase-49/53 retry assets).
**Context:** `frontend/index.html:112` renders `#kb-banner` (chat page only — the Sources/document pages do not render it) with `#kb-banner-text`; `frontend/assets/app.js` — `showErrorBanner(detail)` (L2084) writes `${detail} ${ERROR_HINT}` as **plain text**; `ERROR_HINT` (L362) begins "Try again — …" so "Try again" reads as a clickable action but is not. `retryLastTurn(wrap)` (L2131) re-asks the last question in place (phase 49); `#stale-regenerate` (index.html:134, handler at app.js ~L1898) is the existing banner-button → `retryLastTurn` pattern.
## Objective
Give the chat-view error banner a real Retry control after a failed/dropped turn: the banner shows a visible Retry button that re-runs the last question without re-typing (reusing the phase-49 redo-in-place and the stale-banner button pattern). The banner text stops mimicking a button, and every existing `showErrorBanner` caller (share, save-doc, stale chat) keeps working text-only — the Retry button appears only on failed chat turns.
## Dependencies
- `110_fix_sse_db_pool_exhaustion` (complete) — pipeline predecessor (execution order) only; no code dependency (this phase touches `frontend/index.html`, `frontend/assets/app.js`, `frontend/assets/styles.css`, and frontend unit tests).
## Design (shared by all tasks — the executor reads this, not the chat)
- **Banner button (task 01):** add `<button type="button" class="banner-retry" id="banner-retry" hidden>` inside `#kb-banner` (after `#kb-banner-text`), mirroring the `#stale-regenerate` markup (same refresh SVG + visible "Retry" label). Hidden by default; `showErrorBanner(detail, opts)` gains an optional second arg — when the caller flags the error as a **failed chat turn** (the UI state-machine path at app.js:1281 `if (state === UI_STATE.error) showErrorBanner(errorDetail)`), the button is revealed and wired to `retryLastTurn(lastBrainWrap)` — the same last-brain-bubble targeting the `#stale-regenerate` handler uses. No retryable brain bubble → no button.
- **Copy:** `ERROR_HINT` becomes "If this persists, check the LLM is reachable." — the "Try again —" prefix moves to the button (the text must no longer read as a fake control).
- **Non-turn callers** (share failures L1857/L1870/L1892, save-doc L735/L742, stale L1620/L1950/L1962, …) pass no opts → text-only banner, no button — no behavior change for them.
- **CSS:** `.banner-retry` in `styles.css` reuses the `.stale-regenerate` pill look (same component family); the banner keeps `role="alert"`.
- **NOT touched:** the per-answer Retry pill (phase 49), the stale banner, `retryLastTurn` itself, the server, and the RAG/document views (no `#kb-banner` there — no split needed, ASSUMPTION in task 01).
## Tasks
1. `01_banner_retry_button.md` — banner Retry button markup + handler + hint-copy fix.
2. `02_banner_retry_tests.md` — frontend unit tests for the button's presence/handler + stale hint-copy assertions updated.
## Testing & Quality
- Unit: `tests/unit/test_frontend_banner_retry.py` (new, task 02) — house-style source assertions: `#banner-retry` exists in the `#kb-banner` markup (hidden by default, `type="button"`); `showErrorBanner` wires the click → `retryLastTurn`; the button is revealed only on the turn-error path; `clearErrorBanner` re-hides it; `ERROR_HINT` no longer starts with "Try again".
- E2E: no new file — the turn-error path is exercised by the existing `tests/e2e/test_llm_retry.py` and `tests/e2e/test_smoke.py` suites, which must stay green (no banner behavior change for non-turn callers).
- Coverage: **>90%** on `app/` (validate.sh gate; the frontend JS is pinned by the source-assertion unit tests — no app/ code changes in this phase).
## Completion Criteria
- [ ] After a dropped/failed chat turn, `#kb-banner` shows a visible Retry button; clicking it re-runs the last question without re-typing.
- [ ] Share/save-doc/stale-chat errors show a text-only banner (no button) — unchanged.
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%; `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/` by the pipeline gate.
## Locked decisions
- **A1 — Retry = re-ask the last question in place via the existing `retryLastTurn` (owner-confirmed 2026-09-14, roadmap confirmation).** No new retry mechanism; the phase-49 redo-in-place is reused.
- **A2 — the button is offered only on the UI state-machine's turn-error path (a dropped/failed chat turn); all other banner callers stay text-only (owner-confirmed 2026-09-14).** Matches L1's acceptance ("after a dropped/failed turn …").
## Commit
```bash
git add frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "fix(ui): give the chat error banner a real Retry button that re-asks the last question"
```
@@ -0,0 +1,34 @@
# Task 01 — Banner Retry button: markup, handler, hint-copy fix
**Phase:** `111_chat_banner_retry` · **Source:** `TODO.md:3–18` — "L1 — Chat error banner: 'Try again' is plain text, not a button … `showErrorBanner()` (`frontend/assets/app.js`, `ERROR_HINT` ~line 362) renders `${detail} ${ERROR_HINT}` as **plain text** into `#kb-banner-text` … Suggested fix: give the chat-view error banner a real Retry control that re-asks the last question (the stale-chat banner already has the pattern: `#stale-regenerate` → `retryLastTurn`; the phase-49 Retry pill asset exists). Keep `#kb-banner` dual-use working for the RAG view, or split the two banners if the RAG view's banner has different recovery semantics."
## Objective
The chat error banner gets a real Retry button (turn failures only) that re-asks the last question through the existing `retryLastTurn` redo-in-place; the hint copy stops mimicking the button.
## Work
1. `frontend/index.html` — inside `#kb-banner` (L112), add after `<span id="kb-banner-text">`:
```html
<button type="button" class="banner-retry" id="banner-retry" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
<span>Retry</span>
</button>
```
(the same refresh SVG the `#stale-regenerate` button at L134 uses). Add a comment block: revealed only for failed chat turns (task 01 of this phase); hidden for every other banner caller.
2. `frontend/assets/app.js`:
- `ERROR_HINT` (L362): change to `"If this persists, check the LLM is reachable."` (the action moves to the button).
- `showErrorBanner(detail, opts = {})` (L2084): keep the single-arg behavior byte-identical; when `opts.retryable` is true AND a retryable last brain bubble exists (the same lastBrainWrap lookup the `#stale-regenerate` handler at ~L1898 uses), unhide `#banner-retry` and bind its click **once** to `() => retryLastTurn(lastBrainWrap)`; re-binding on every reveal must be guarded (one listener per button lifetime). `clearErrorBanner()` re-hides the button.
- The turn-error path (L1281 `if (state === UI_STATE.error) showErrorBanner(errorDetail)`): pass `{ retryable: true }`. Every other caller (L735, L742, L1620, L1857, L1870, L1892, L1950, L1962, …) is left unchanged.
- If `retryLastTurn` would no-op (no retryable bubble), do not reveal the button — reveal only when a bubble exists.
3. `frontend/assets/styles.css` — `.banner-retry`: same pill treatment as `.stale-regenerate` (color, border, hover, `focus-visible` ring per the theme), laid out inline after the banner text (the `.kb-banner` flex row + gap already handles spacing).
4. ASSUMPTION: no banner split — `#kb-banner` exists only in `frontend/index.html` (the chat page); `document.html`/the Sources pages do not render it, so "keep dual-use" is trivially satisfied and the RAG view is untouched.
5. ASSUMPTION: the button is revealed only on the UI state-machine's turn-error path (a dropped/failed chat turn) — non-turn errors (share, save-doc, stale chat) stay text-only (locked A2).
## Testing & Quality
- Unit: `tests/unit/test_frontend_banner_retry.py` (added by task 02 — this task ships the code, task 02 ships the pin).
- Coverage: n/a (frontend) — the validate.sh `app/` gate must stay green.
## Completion Criteria
- [ ] `#kb-banner` contains `#banner-retry` (hidden by default); a failed chat turn reveals it; clicking re-asks the last question without re-typing.
- [ ] `ERROR_HINT` no longer contains "Try again".
- [ ] No non-turn call site passes `retryable` (grep the call sites).
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,23 @@
# Task 02 — Unit tests for the banner Retry button
**Phase:** `111_chat_banner_retry` · **Source:** `TODO.md:19–21` — "Acceptance: after a dropped/failed turn, the banner shows a visible Retry button that re-runs the last question without re-typing; unit test for the banner's button presence/handler in the frontend test suite."
## Objective
Pin the banner contract in the frontend unit suite: the button's presence, its handler, its reveal condition, and the new hint copy.
## Work
1. `tests/unit/test_frontend_banner_retry.py` (new) — house-style source assertions (pattern: `tests/unit/test_frontend_feedback.py`):
- `frontend/index.html`: `#banner-retry` exists inside the `#kb-banner` block, `hidden` by default, `type="button"`, with a visible "Retry" label.
- `frontend/assets/app.js`: `showErrorBanner` binds the click handler to `retryLastTurn`; the `UI_STATE.error` turn path passes the retryable flag; `clearErrorBanner` re-hides the button.
- `ERROR_HINT` does not start with "Try again".
2. Grep the whole `tests/` tree for the old hint copy (`Try again — if this persists`) and update any stale assertion (loading-feedback and banner-related frontend tests).
3. Run the full unit + integration suite — no regressions (in particular `tests/unit/test_frontend_*.py`).
## Testing & Quality
- Unit: the new file above (≥4 assertions across markup/handler/reveal/copy).
- Coverage: **>90%** on `app/` unchanged (no app/ code touched by this phase).
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_frontend_banner_retry.py -v` green.
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%; `uv run ruff check . && uv run pyright` clean.
- [ ] No test anywhere asserts the old "Try again — …" hint.
@@ -0,0 +1,46 @@
# Phase 112 — Honesty gate: FTS hits need cosine corroboration (TODO L2a/b + README nit)
**Source:** `TODO.md` L23–86 — "L2 — Deflection test: non-KB question got answered parametrically + unrelated source chips (2026-09-15, brain-of-reese interactive test)" — part a (L36–44), part b (L46–55) + the stochastic follow-up (L64–75), the README nit (L77–81), acceptance (L83–86). Part c (chips on non-grounded answers, L57–62) is delivered by phase 113.
**Story:** the completed story 04 (`04_story_honest_deflection`) — this phase fixes the A8 gate eagerness the live deflection test exposed.
**Context:** `app/api/chat.py::plan_turn` (L272–277): HIGH when `best_cosine >= settings.relevance_threshold (0.62) or fts_hits > 0` — a single weak FTS token hit (suspected: the token "capital" inside a quest file) promoted a non-KB question into grounded mode and injected two irrelevant docs into the HIGH prompt, which then tempted the model into a parametric answer. The follow-up one-tap re-run of the identical question produced a **clean, textbook deflection** with the same docs injected → HONESTY GATE compliance is stochastic across runs; the deterministic lever is the gate (don't inject irrelevant docs), not prompt copy alone.
## Objective
Make the A8 honesty gate deterministic against weak lexical hits: an FTS hit flips the turn to HIGH (grounded) only when the vector signal corroborates it (best cosine clears a new `lexical_support_floor`); below the floor the turn stays LOW (deflected) even with FTS matches. The owner-confirmed locked-prompt contract decision (treat the stochastic disclosed-general-knowledge behavior as acceptable — documented, prompt text byte-identical) and the stale README deflection copy are fixed in the same phase.
## Dependencies
- `111_chat_banner_retry` (todo) — pipeline predecessor (execution order) only; no code dependency.
## Design (shared by all tasks — the executor reads this, not the chat)
- **Gate rule (task 01):** HIGH iff `best_cosine >= relevance_threshold` OR (`fts_hits > 0` AND `best_cosine >= lexical_support_floor`). LOW otherwise — including the fts>0 / cosine<floor case (the Mongolia case). `lexical_support_floor` is a new setting (default 0.35, env `BOR_LEXICAL_SUPPORT_FLOOR`), validated `0 <= floor <= relevance_threshold`. Rationale: a real lexical match on a genuinely similar doc (cosine ≥ floor) still grounds; a single weak token match with vector-unsupported docs no longer promotes. `plan_turn`'s docstring (the A8 bullets) and the A8 entry in `.agents/PLAN.md` are updated with the revision note (house precedent: "A8 revised 2026-08-21" — an owner-confirmed change to a LOCKED decision is recorded in the plan, not silently deviated from).
- **No schema/API change:** `query_log.top_score` / `fts_hits` are recorded exactly as today (observability unchanged); `TurnPlan` shape unchanged; the LOW branch (deflect prompt, weak-hit titles, derived suggestions) unchanged.
- **Prompt contract (task 03):** owner decision (iii) — the model's stochastic disclosed-general-knowledge answer (when misleading docs are injected) is documented as acceptable; the `app/rag/prompts.py` module docstring (the house location for locked-prompt revision history, e.g. the 2026-08-22 note) gains a dated entry; the prompt strings stay byte-identical (LOCKED verbatim); the README deflection section notes the behavior.
- **README nit (task 04):** the quoted deflection opening *"I haven't done anything like that"* was removed in the 2026-08-22 locked-prompt revision — the two README spots (L11, L575) are updated to describe the current behavior (admit no notes + 2–3 concrete alternative questions).
## Tasks
1. `01_gate_meaningful_fts.md` — the `plan_turn` gate fix + `lexical_support_floor` setting + the A8 plan revision note.
2. `02_gate_tests.md` — unit pins for the HIGH/LOW quadrants on weak single-token FTS hits + the E2E deflection check.
3. `03_prompt_contract_documentation.md` — document the owner decision (iii) (prompts.py docstring + README); prompt text unchanged.
4. `04_readme_deflection_copy.md` — README stale deflection copy (L11, L575) updated.
## Testing & Quality
- Unit: `tests/unit/test_chat_gate.py` (existing — extend) + a new quadrant file (task 02): cosine ≥ threshold → HIGH regardless of FTS; fts>0 + cosine ≥ floor → HIGH; **fts>0 + cosine < floor → LOW** (the new behavior, the Mongolia regression pin); no hits → LOW.
- E2E: `tests/e2e/test_honest_deflection.py` (existing — extend, task 02): a known-out-of-KB question (mock LLM, so the test pins the gate not the model) → deflected, no citation chips, 2–3 alternative questions. Run in isolation: `uv run pytest tests/e2e/test_honest_deflection.py -v --no-cov`.
- Regression: `tests/unit/test_chat_gate.py`, `tests/e2e/test_chat_rag.py`, `tests/e2e/test_retrieval_quality.py` stay green.
- Coverage: **>90%** on `app/` (validate.sh gate).
## Completion Criteria
- [ ] A weak single-token FTS hit with vector-unsupported docs (cosine < floor) → LOW/deflected (unit-pinned).
- [ ] A known-out-of-KB question produces no false citations and 2–3 concrete alternative questions (E2E).
- [ ] `app/rag/prompts.py` prompt strings byte-identical to pre-phase (test-pinned); README deflection copy matches current behavior.
- [ ] `uv run pytest` green; coverage >90%; the e2e file green in isolation; `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase dir moved to `complete/` by the pipeline gate.
## Locked decisions
- **A1 — lever: cosine corroboration; `lexical_support_floor` default 0.35, env-tunable via `BOR_LEXICAL_SUPPORT_FLOOR` (owner-confirmed 2026-09-14, roadmap confirmation).** Of the TODO's three options (rank threshold / stopword-short-token exclusion / cosine corroboration), cosine corroboration is the deterministic one; the tests pin the decision logic, not the default value.
- **A2 — locked-prompt contract: option (iii) — treat the stochastic disclosed-general-knowledge behavior as acceptable and document it (owner-confirmed 2026-09-14, roadmap confirmation).** The prompt text is unchanged (LOCKED verbatim); the deterministic protection is the gate (A1). Options (i) tighten copy / (ii) amend the prompt via the plan remain open to a future owner decision.
- **A3 — the A8 entry in `.agents/PLAN.md` is amended in this phase with a dated revision note** (owner-confirmed 2026-09-14) — house precedent for recording owner-confirmed LOCKED-decision changes.
## Commit
```bash
git add app/ tests/ .agents/ README.md && git commit --no-gpg-sign -m "fix(rag): require cosine-corroborated FTS hits before the honesty gate flips HIGH — document the disclosed-answer behavior, refresh README deflection copy"
```
@@ -0,0 +1,30 @@
# Task 01 — Gate: FTS hits must be cosine-corroborated
**Phase:** `112_honesty_gate_weak_hits` · **Source:** `TODO.md:36–44` — "a) **Honesty gate too eager** (`app/api/chat.py` L272–277, A8 revised 2026-08-21): HIGH/grounded when `best_cosine >= threshold` OR `fts_hits > 0`. A single weak FTS token hit (suspected: the token 'capital' inside a quest file) promotes a non-KB question into grounded mode and injects two irrelevant top-docs into the HIGH prompt — which then tempts the model into a parametric answer instead of deflection. Consider: require FTS hits to be *meaningful* (e.g. rank threshold, stopword/short-token exclusion, or cosine corroboration) before flipping to HIGH; or run the LOW prompt when top-docs score below a usefulness bar." (+ the follow-up, L64–75: "the deterministic lever is the gate (don't inject irrelevant docs — part a), not prompt copy alone")
## Objective
An FTS hit flips `plan_turn` to HIGH only when the best cosine clears the new `lexical_support_floor`; a weak single-token hit with vector-unsupported docs stays LOW (deflected).
## Work
1. `app/config.py` — add `lexical_support_floor: float = Field(default=0.35)` (env `BOR_LEXICAL_SUPPORT_FLOOR`) next to `relevance_threshold` (L119), with validation `0 <= lexical_support_floor <= relevance_threshold` (mirror the file's existing validator style); document it in `.env.example`.
2. `app/api/chat.py::plan_turn` — replace the gate line (`if best_cosine >= settings.relevance_threshold or fts_hits > 0:`):
```python
lexical_supported = fts_hits > 0 and best_cosine >= settings.lexical_support_floor
if best_cosine >= settings.relevance_threshold or lexical_supported:
...
```
Update the `plan_turn` docstring's gate section (the A8 bullets): HIGH when `best_cosine >= threshold` OR (`fts_hits > 0` AND `best_cosine >= lexical_support_floor`); LOW otherwise — a lexical-only hit without vector support deflects (A8 revised 2026-09-14).
3. `.agents/PLAN.md` — amend the A8 locked-decision entry with a dated revision note (house precedent: "A8 revised 2026-08-21"): "A8 revised 2026-09-14 (owner-confirmed, TODO L2a): an FTS hit flips HIGH only when `best_cosine >= lexical_support_floor` (default 0.35, `BOR_LEXICAL_SUPPORT_FLOOR`); lexical-only hits without vector support deflect."
4. `app/rag/prompts.py` — prompt strings NOT touched (LOCKED verbatim).
5. ASSUMPTION: the chosen lever is cosine corroboration (one of the TODO's three listed options); floor default 0.35 (≈ half the 0.62 threshold) is tunable via env against the live KB — the unit/E2E tests pin the decision logic, not the default value (locked A1).
## Testing & Quality
- Unit: covered by task 02 (`tests/unit/test_chat_gate.py` extension) — this task's code must keep it green.
- Coverage: **>90%** on `app/` including the new setting and the modified gate line.
## Completion Criteria
- [ ] `plan_turn`: fts_hits>0 + best_cosine < floor → LOW (`TurnPlan.deflected` True, LOW prompt, suggestions derived from weak-hit titles).
- [ ] fts_hits>0 + floor <= best_cosine < threshold → HIGH (the new "corroborated lexical" path).
- [ ] best_cosine >= threshold → HIGH regardless of FTS (unchanged); no hits → LOW (unchanged).
- [ ] `.env.example` documents `BOR_LEXICAL_SUPPORT_FLOOR`; the config validation rejects floor > threshold.
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,27 @@
# Task 02 — Pin the gate's HIGH/LOW decision on weak FTS hits
**Phase:** `112_honesty_gate_weak_hits` · **Source:** `TODO.md:83–86` — "Acceptance: a known-out-of-KB question (LLM-known, e.g. capitals, sports results) produces no false citations, follows the HONESTY GATE (or the amended contract), and unit/E2E tests pin the gate's HIGH/LOW decision on a weak single-token FTS hit."
## Objective
Unit tests pin all four gate quadrants — especially the new fts>0 / cosine<floor → LOW quadrant (the Mongolia/"capital" regression); the E2E deflection story asserts a known-out-of-KB question deflects with no false citations and 2–3 alternatives.
## Work
1. `tests/unit/test_chat_gate.py` (existing — extend; reuse its chunk-building helpers) — the quadrants:
- cosine ≥ threshold, fts=0 → HIGH (unchanged).
- fts>0, cosine ≥ floor (e.g. 0.50 with default settings) → HIGH (corroborated lexical — the new path).
- **fts>0, cosine < floor** (e.g. one `fts_hit=True` chunk with cosine 0.10 — the "capital" case) → LOW: `deflected=True`, LOW prompt, `suggestions` non-empty, the weak docs do not enter a HIGH prompt.
- no chunks → LOW (unchanged).
- boundary: cosine exactly at the floor → HIGH (`>=`, mirroring the threshold's convention); config with floor > threshold → validation error.
2. `tests/e2e/test_honest_deflection.py` (existing — extend): a test asking a known-out-of-KB question (e.g. "What is the capital of Mongolia?" — LLM-known, absent from the fixture KB; the mock LLM's deflection path keeps the test deterministic on the gate, not on model compliance) → the done frame is `deflected: true`, `sources` is empty (no false citations), `suggestions` has 2–3 items.
3. Run the E2E in isolation: `uv run pytest tests/e2e/test_honest_deflection.py -v --no-cov` (DB up).
4. Regression: `tests/unit/test_chat_gate.py` (all), `tests/e2e/test_chat_rag.py`, `tests/e2e/test_retrieval_quality.py` green.
## Testing & Quality
- Unit: the quadrant table (the new quadrant is the regression pin for TODO L2a).
- E2E: the deflection story extension.
- Coverage: **>90%** on `app/`.
## Completion Criteria
- [ ] All quadrants green, including fts>0 + cosine<floor → LOW.
- [ ] E2E: known-out-of-KB question → deflected, zero source chips, 2–3 alternatives.
- [ ] `uv run pytest` green; coverage >90%; the e2e file green in isolation; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,21 @@
# Task 03 — Document the disclosed-answer contract (owner decision iii)
**Phase:** `112_honesty_gate_weak_hits` · **Source:** `TODO.md:46–55, 64–75` — "b) **Model violates the locked prompt** (Rule 1 'Answer ONLY from the provided document context', Rule 3 HONESTY GATE): with irrelevant docs injected it answered from general knowledge. The disclosure is better UX than silence, but the contract says no pretending to know + 2-3 concrete alternative questions — decide whether to (i) tighten the prompt copy … (ii) amend the locked prompt via the plan to explicitly permit disclosed general-knowledge answers, or (iii) treat the observed behavior as acceptable and document it. Owner decision required — the prompt text is locked verbatim (change through the plan, not here)." + the follow-up: "HONESTY GATE compliance is **stochastic** across runs (run 1: parametric answer; run 2: perfect deflection). Implication for the fix direction: the deterministic lever is the gate …, not prompt copy alone; a small local model cannot be relied on to obey Rules 1/3 100% when handed misleading context."
## Objective
Record the owner-confirmed decision (iii): the stochastic disclosed-general-knowledge answer (when misleading docs are injected) is acceptable and documented — the prompt text stays byte-identical (LOCKED verbatim); the deterministic protection is the task-01 gate fix.
## Work
1. `app/rag/prompts.py` — module docstring (the house location for the locked-prompt revision history, e.g. the 2026-08-22 note): add a dated entry recording the 2026-09-15 interactive-test finding (parametric "Ulaanbaatar" answer with a disclosure, on the Mongolia question with two irrelevant docs injected; the clean textbook deflection on the identical one-tap re-run — stochastic compliance) and the owner decision (2026-09-14, roadmap confirmation): treat the disclosed general-knowledge answer as acceptable; the deterministic lever is the gate (A8 revised, task 01); options (i)/(ii) remain open to a future plan amendment. Prompt strings: byte-identical.
2. `README.md` — in the deflection paragraph (the one task 04 rewrites), one sentence: with a small local model, a rare turn may answer from general knowledge with an explicit disclosure when retrieval was borderline — the gate (phase 112) minimizes this; the disclosure is surfaced, never silent.
3. Prompt-lock pin: if no existing test byte-pins the prompt text, add a small `tests/unit/test_prompt_lock.py` asserting the HIGH/LOW prompt constants against pre-phase anchor strings (the executor extracts the pre-phase values when writing the test — e.g. exact prefix/suffix + total length, so any byte change fails).
4. ASSUMPTION: the documentation lives in the prompts.py docstring (the existing revision-history location) + the README — no new docs file.
## Testing & Quality
- Unit: the prompt-lock pin (work item 3).
- Coverage: n/a (docs-only change) — the suite stays green.
## Completion Criteria
- [ ] `app/rag/prompts.py` docstring carries the dated decision entry; the prompt strings are byte-identical (test-pinned).
- [ ] The README deflection section notes the rare disclosed-answer behavior.
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,20 @@
# Task 04 — README: fix the stale deflection copy
**Phase:** `112_honesty_gate_weak_hits` · **Source:** `TODO.md:77–81` — "Plus a docs nit: the README still promises the exact deflection copy *'I haven't done anything like that'* — the mandated deflection opening was removed in the 2026-08-22 locked-prompt revision (`app/rag/prompts.py` module docstring). Update the README's 'If it doesn't have notes…' paragraph to match current behavior."
## Objective
The README describes the current deflection behavior (admit no notes + 2–3 concrete alternative questions) instead of the removed mandated opening.
## Work
1. `README.md` L11 ("If it doesn't have notes for your question, it admits it: *'I haven't done…*") — rewrite the quoted copy to match the current locked-prompt behavior: it admits it has no notes on that and offers 2–3 concrete alternative questions about things it DOES have notes on. No exact-copy promise (the opening is no longer mandated).
2. `README.md` L575 ("**Honest deflection** (the amber *'I haven't done anything like that'*…") — same update; keep the amber-banner description accurate.
3. Grep the README for any other occurrence of the old quoted opening and update it.
## Testing & Quality
- Docs-only: no code tests. Grep `tests/` for `haven't done anything` — if any test asserts the old copy, update it to the new behavior.
- Coverage: n/a.
## Completion Criteria
- [ ] `grep -rn "haven't done anything" README.md` → no hits (the quoted opening is gone).
- [ ] The deflection paragraph matches current behavior (admit + 2–3 concrete alternatives).
- [ ] `uv run pytest` green.
@@ -0,0 +1,46 @@
# Phase 113 — Source chip quality: usefulness bar + related-docs tier (TODO L5 + L2c)
**Source:** `TODO.md` L101–147 — "L5 — Recurring weak-hit source chips: the 2nd chip is often noise the answer never used (2026-09-15, brain-of-reese interactive test)" — root-cause chain (L125–129), suggested directions (L131–142), acceptance (L144–146) — plus `TODO.md` L57–62 (L2 part c): "c) **Misleading chips on non-grounded answers** (`chat.py:252` — `done.sources` = weak hits when deflected; by design, but visually a citation). … At minimum: never render them as answer citations."
**Story:** n/a (interactive-test follow-up fix; extends the phase-09 retrieval-quality and phase-05 chip assets).
**Context:** `top_n_docs = 2` (`app/config.py:114`) forces two docs into `plan.docs`; `done.sources` (`app/api/chat.py` ~L796, `ChatDoneEvent.sources`) carries every entry and `appendSources` (`frontend/assets/app.js:1369`) chips them all with identical visual weight — "the answer used this" vs "this also scored" is indistinguishable. Deflected turns carry weak hits in `sources` "by design" but they render as citations. Owner-confirmed direction: the deterministic **usefulness bar** (server-side) + the **visual split** (UI) — cite-gated chips (parsing the model's cited paths) are rejected for now (stochastic with a small model).
## Objective
A document earns a citation slot only when its retrieval signal is vector-corroborated (or the agent explicitly read it via a tool call); everything else that scored is demoted to a clearly secondary "nearby docs" row that never reads as a citation. For a single-document question the turn shows exactly one citation chip; a deflected turn shows no citation chips at all.
## Dependencies
- `112_honesty_gate_weak_hits` (todo) — the gate fix stops weak hits being injected into the HIGH prompt; this phase stops weak docs earning a `done.sources` slot. Same workstream, ordered after the gate.
## Design (shared by all tasks — the executor reads this, not the chat)
- **Usefulness bar (task 01):** new settings `source_usefulness_floor: float = 0.35` (env `BOR_SOURCE_USEFULNESS_FLOOR`, validated `0 <= floor <= relevance_threshold`, mirroring phase 112's floor) and `related_max_docs: int = 2` (env `BOR_RELATED_MAX_DOCS`, validated `>= 0`). In `plan_turn`, retrieval docs are tiered: **cited** = distinct parent docs (best fused-score order, at most `top_n_docs`) whose best hit-chunk **cosine** clears the floor; **related** = the next scored distinct docs (at most `related_max_docs`) that did not clear it. Agent-read docs (`holder.read_docs`, the phase-37 agent tool reads) always stay cited — the model read them via tool calls, so they were used by definition. `TurnPlan` gains `related_docs: list[Document] = []`; the tiering is a new `select_documents_tiered(chunks, n, floor, related_cap) -> tuple[list[Document], list[Document]]` in `app/rag/retriever.py`, with `select_documents` becoming a thin wrapper (legacy behavior byte-identical for existing callers/tests). `query_log.sources` is unchanged (it records retrieval, not citations — locked A3).
- **Done frame (task 01):** `ChatDoneEvent` (`app/schemas.py`) gains `related: list[SourceRef] = []` — additive; old clients ignore unknown fields (house contract, PLAN §4). Built from `plan.related_docs` with the same (source, path) dedupe against the cited list as `cited_docs` already does.
- **UI split (task 02):** `frontend/assets/app.js` — new `appendRelated(wrap, related)` renders a `.msg-meta.related-docs` row under the bubble (only when `related` is non-empty): a small de-emphasized label "Nearby docs, in case:" + one link per doc with the class `related-doc` (NOT `source-chip`) — same `documentUrl(...)` href and left-click → `openDocumentModal` behavior as citation chips, visually secondary (reduced opacity/size/dashed border via theme variables; link contrast ≥4.5:1, WCAG 2.1 AA). The done-frame handler (~L2421) also calls `appendRelated(wrap, ev.related)`; the restored-chat path (~L1561) likewise when the stored payload carries `related` (pre-phase chats don't — graceful). Deflected turns: `ev.sources` is empty (the server change) → no chips; the weak hits arrive in `ev.related` → the row only.
- **NOT touched:** the citation-chip component (`.source-chip` / `appendSources`) for the cited tier; the suggestion chips; `top_n_docs` (ceiling, not quota); the phase-37 agent-read dedupe; the Sources/RAG pages.
## Tasks
1. `01_usefulness_bar_sources.md` — retriever tiering + `TurnPlan.related_docs` + `ChatDoneEvent.related` + the two settings.
2. `02_secondary_related_docs_ui.md` — the related-docs row in the chat UI (app.js + styles.css); deflected turns show no chips.
3. `03_chip_filter_tests.md` — unit pins on the four observed live shapes + E2E chip-count assertions.
## Testing & Quality
- Unit: `tests/unit/test_retriever.py` (extend — the tiering table), `tests/unit/test_source_chip_quality.py` (new, task 03 — the four observed shapes), the done-frame schema tests (`related` defaults `[]`; old payloads without the field still parse).
- E2E: `tests/e2e/test_source_chip_quality.py` (new, task 03; run in isolation: `uv run pytest tests/e2e/test_source_chip_quality.py -v --no-cov`) — a known single-source question → exactly one citation chip; a deflected question → zero `.source-chip` elements (the row, if any, is `.related-doc`, never `.source-chip`).
- Regression: `tests/e2e/test_retrieval_quality.py`, `test_honest_deflection.py`, `test_chat_rag.py`, `test_sources_midstream_bug.py` stay green.
- Coverage: **>90%** on `app/` (validate.sh gate).
## Completion Criteria
- [ ] For a single-document question, the turn shows one citation chip (E2E).
- [ ] A weak 2nd doc renders only in the de-emphasized related row, never as a `.source-chip` (unit + E2E).
- [ ] A deflected turn renders zero citation chips (the weak hits, if any, live in the related row).
- [ ] `uv run pytest` green; coverage >90%; the e2e file green in isolation; `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase dir moved to `complete/` by the pipeline gate.
## Locked decisions
- **A1 — direction: usefulness bar (server, deterministic) + visual split (UI); cite-gated chips rejected for now (owner-confirmed 2026-09-14, roadmap confirmation).**
- **A2 — citation slot = vector-corroborated retrieval doc (best hit-chunk cosine >= `source_usefulness_floor`, default 0.35, env-tunable) OR agent-read doc; `top_n_docs` stays a ceiling, not a quota (owner-confirmed 2026-09-14).**
- **A3 — `query_log.sources` keeps recording the full retrieval (observability); `done.sources` records only the cited tier (owner-confirmed 2026-09-14).**
- **A4 — the related tier is capped at 2 docs (`related_max_docs`, env `BOR_RELATED_MAX_DOCS` — owner-confirmed 2026-09-14).**
## Commit
```bash
git add app/ tests/ frontend/ .agents/phases/ && git commit --no-gpg-sign -m "feat(rag): tier sources by a usefulness bar — weak hits become a de-emphasized related-docs row, never citation chips"
```
@@ -0,0 +1,28 @@
# Task 01 — Usefulness bar: tier done.sources into cited + related
**Phase:** `113_source_chip_quality` · **Source:** `TODO.md:125–129, 137–140` — "Root cause chain: `top_n_docs = 2` (`app/config.py` L114) forces retrieval to return two documents, `done.sources` carries both (`app/api/chat.py` L252), and the UI chips every entry without distinguishing 'the answer used this' from 'this also scored'." + "**Usefulness bar on the 2nd doc** — only include a document in `done.sources` when its fused/cosine score clears a threshold (a single weak FTS token hit should not earn a citation slot); `top_n_docs` stays a ceiling, not a quota." + `TODO.md:57–62` (L2 part c: "`done.sources` = weak hits when deflected; by design, but visually a citation … At minimum: never render them as answer citations.")
## Objective
Retrieval docs are tiered at the honesty gate: cited (vector-corroborated, ≤ `top_n_docs`) vs related (scored but under the floor, ≤ `related_max_docs`); the SSE done frame carries both; agent-read docs always stay cited.
## Work
1. `app/config.py` — add `source_usefulness_floor: float = Field(default=0.35)` (env `BOR_SOURCE_USEFULNESS_FLOOR`) and `related_max_docs: int = Field(default=2)` (env `BOR_RELATED_MAX_DOCS`); validators: `0 <= source_usefulness_floor <= relevance_threshold`, `related_max_docs >= 0`; `.env.example` entries.
2. `app/rag/retriever.py` — add `select_documents_tiered(chunks, n, floor, related_cap) -> tuple[list[Document], list[Document]]`:
- rank distinct parent docs by best fused score (the existing `select_documents` ordering), tracking each doc's best hit-chunk cosine;
- **cited** = the docs whose best-chunk cosine >= `floor`, up to `n` (ceiling — a single strong doc yields one cited doc);
- **related** = the next docs in rank order (any cosine, including 0.0 lexical-only), up to `related_cap`, never overlapping the cited list.
- `select_documents` becomes a wrapper: `cited, _ = select_documents_tiered(chunks, n, 0.0, 0)` — floor 0.0 + cap 0 keeps the legacy "any score, top-N" behavior byte-identical for existing callers/tests.
3. `app/api/chat.py` — `TurnPlan` gains `related_docs: list[Document] = []` (after `docs`); `plan_turn` calls `select_documents_tiered(chunks, settings.top_n_docs, settings.source_usefulness_floor, settings.related_max_docs)` → `docs`, `related_docs` (both the HIGH and LOW branches — deflected turns: the weak hits fall to related, cited is usually empty). The done-frame build (~L796): add `related=[SourceRef(source=d.source, path=d.path, title=d.title) for d in <plan.related_docs deduped against cited_docs by (source, path)>]` — the same dedupe pattern `cited_docs` already uses.
4. `app/schemas.py` — `ChatDoneEvent` gains `related: list[SourceRef] = []` (additive; docstring note: old clients ignore unknown fields, PLAN §4).
5. `query_log.sources` — unchanged (locked A3).
6. ASSUMPTION: the bar is on the **cosine** of the doc's best hit chunk, not the RRF fused score — the fused `score` is a rank key, not a similarity; a lexical-only hit has cosine 0.0 and is vector-unsupported by definition (consistent with the phase-112 gate; locked A2).
## Testing & Quality
- Unit: `tests/unit/test_retriever.py` (extend) — the tiering table: both clear → both cited; strong + weak → 1 cited + 1 related; both weak → 0 cited + 2 related; related cap respected; `select_documents` wrapper legacy behavior unchanged (existing tests stay green without edits).
- Unit: the existing done-frame/schema tests — `related` defaults to `[]`; a payload without the field still parses (back-compat).
- Coverage: **>90%** on `app/` including the new function.
## Completion Criteria
- [ ] `plan_turn` returns tiered docs; HIGH and LOW branches both populated correctly.
- [ ] The done frame carries `related` (≤ `related_max_docs`, deduped against cited); old frames (no field) parse.
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,27 @@
# Task 02 — UI: the related-docs row (never a citation chip)
**Phase:** `113_source_chip_quality` · **Source:** `TODO.md:141–142` — "**Visual split** — keep both, but render uncited/weak docs as a clearly secondary 'related docs' row, not citation chips." + `TODO.md:59–62` — "When the answer is disclosed general knowledge (or a deflected turn), the chips should be visually de-emphasized / labeled (e.g. 'nearby docs I have, in case'), or omitted when the brain says it didn't use them. At minimum: never render them as answer citations."
## Objective
The chat bubble renders the cited tier exactly as today (`.source-chip` via `appendSources`) and the related tier as a clearly secondary labeled row; a deflected turn renders zero citation chips.
## Work
1. `frontend/assets/app.js`:
- new `appendRelated(wrap, related)` next to `appendSources` (L1369): early-return when empty; a `.msg-meta.related-docs` row (`role="list"`, `aria-label="Nearby docs, in case"`) + a small `<span class="related-docs-label">Nearby docs, in case:</span>` + one link per doc — class `related-doc` (NOT `source-chip`), the same `documentUrl(s.source, s.path, "/")` href and left-click → `openDocumentModal(s.source, s.path, link)` behavior, `title`/`aria-label` carrying the full path.
- the done-frame handler (~L2421, next to `appendSources(wrap, ev.sources)`): also `appendRelated(wrap, ev.related)`.
- the restored-chat path (~L1561): same, when the stored payload carries `related` (pre-phase saved chats don't — the row is simply absent, graceful).
- deflected turns: `ev.sources` is empty (the task-01 server change) → `appendSources` no-ops; the weak hits arrive in `ev.related` → row only.
2. `frontend/assets/styles.css` — `.related-docs` (muted row: smaller font, theme-variable color — link text contrast ≥4.5:1, WCAG 2.1 AA), `.related-doc` (dashed border, no hover elevation of the citation chips; `focus-visible` ring), `.related-docs-label` (small caps or muted small text); the row stacks below the citation `.msg-meta` row with the existing gap.
3. Frontend unit test (house source-assertion style — lives in `tests/unit/test_source_chip_quality.py`, extended by task 03): `appendRelated` exists and uses `related-doc` (assert `source-chip` is NOT in the `appendRelated` body); the row renders only when related is non-empty; the label text is present; the done handler calls `appendRelated`.
4. ASSUMPTION: label copy "Nearby docs, in case:" (the TODO's suggested wording, trimmed).
5. ASSUMPTION: pre-phase saved chats (stored payload without `related`) restore exactly as today — no related row.
## Testing & Quality
- Unit: the frontend source-assertion tests (task 03's file).
- E2E: pinned by task 03.
- Coverage: n/a (frontend) — the `app/` gate stays green.
## Completion Criteria
- [ ] A related doc renders only as `.related-doc` in the labeled row — never as `.source-chip`.
- [ ] A deflected turn (mock) renders zero `.source-chip` elements under the bubble.
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,31 @@
# Task 03 — Pin the chip contract: the four observed shapes + E2E chip counts
**Phase:** `113_source_chip_quality` · **Source:** `TODO.md:110–123, 144–146` — the four observed live cases (1. "What is the capital of Mongolia?" → `Trooper_Nagraz.pl` + `Trooper_Begzei.pl`, both unrelated; 2. phase-gate question answered from `brain-of-reese/.agents/validate.sh` → second chip `ServMon/README.md` unused; 3. Trooper_Nagraz question answered from `Trooper_Nagraz.pl` → second chip `Trooper_Byzin.pl` uncited; 4. meta question about the conversation's own history → chips `app/api/suggestions.py` + `108_history_wire_check/00_phase.md`, neither used) + "Acceptance: for a single-document question, the turn shows one citation chip; a unit test pins `done.sources` filtering (or the chip renderer's cite-gate) on the four observed shapes; E2E asserts chip count for a known single-source question."
## Objective
A unit test pins the cited/related tiering on the four live shapes; the E2E suite asserts the visible chip counts.
## Work
1. `tests/unit/test_source_chip_quality.py` (new) — model each of the four observed shapes as a `plan_turn`/done-frame fixture (retrieval chunks with controlled cosine/`fts_hit`/fused `score`) and assert the tiering:
1. **both docs weak** (cosine < floor, the Mongolia case) → `sources` empty, `related` ≤ 2.
2. **one strong + one weak** (the validate.sh case: `validate.sh` cosine ≥ floor, `ServMon/README.md` below) → exactly 1 in `sources`, the weak one in `related`.
3. **the Nagraz case** — same shape, different fixtures (`Trooper_Nagraz.pl` strong, `Trooper_Byzin.pl` weak) → 1 cited, 1 related.
4. **the meta/history question** (no doc clears the floor, the agent reads nothing) → `sources` empty, `related` ≤ 2; assert the frame shape that the UI renders as row-only (the rendering is pinned by task 02's source tests + the E2E).
5. **agent-read exemption**: a doc under the floor that is in `holder.read_docs` (agent tool read) still lands in `sources` (cited).
- plus the frontend source-assertion tests from task 02 work item 3 (same file).
2. `tests/e2e/test_source_chip_quality.py` (new; conftest/mock-LLM/fixture-KB pattern per `tests/e2e/test_retrieval_quality.py`):
- a known single-source question (a fixture-KB question whose answer comes from one doc) → the done bubble has **exactly one** `.source-chip`.
- a deflected question (known-out-of-KB) → **zero** `.source-chip`; if a `.related-docs` row exists, its links are `.related-doc`, never `.source-chip`.
- if the fixture KB cannot produce a strong+weak two-tier shape, say so in the test docstring and rely on the unit table for that shape.
3. Run in isolation: `uv run pytest tests/e2e/test_source_chip_quality.py -v --no-cov` (DB up).
4. Regression: `tests/e2e/test_retrieval_quality.py`, `test_honest_deflection.py`, `test_chat_rag.py` green.
## Testing & Quality
- Unit: the four-shape table (the acceptance pin) + the agent-read exemption.
- E2E: the chip-count assertions (the acceptance pin).
- Coverage: **>90%** on `app/`.
## Completion Criteria
- [ ] The four observed shapes are unit-pinned (plus the agent-read exemption).
- [ ] E2E: single-source question → exactly one citation chip; deflected turn → zero.
- [ ] `uv run pytest` green; coverage >90%; the e2e file green in isolation; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,45 @@
# Phase 114 — Embed question length: truncation + accurate error (TODO L6)
**Source:** `TODO.md` L149–181 — "L6 — 4,000-char question clamp exceeds the embed model's input cap → misleading 'couldn't reach the embedding model' error (2026-09-15, brain-of-reese interactive test)"
**Story:** n/a (interactive-test follow-up fix; extends the phase-67 LLM-retry and phase-06 loading-feedback assets).
**Context:** The composer clamps at 4,000 chars; `app/api/chat.py:423` embeds the **full** question via `llm.embed_one`; aipi's litellm rejects the ~903-token input with **HTTP 500**: "input (903 tokens) is too large to process. increase the physical batch size (current batch size: 512)". The single-text path in `app/rag/llm.py` (`_embed_batch` → `_TooLarge`, ~L279–284) turns that into `EmbeddingError("a single …-char chunk exceeded the endpoint's per-request input token cap — lower BOR_CHUNK_TARGET_CHARS and re-import")` — an **import-oriented** message — and the chat endpoint's catch-all (~L428–444) maps EVERY `EmbeddingError` to "I couldn't reach the embedding model — please try again." Both diagnoses are wrong (reachability is fine; the chunker constant is irrelevant to a question). The chunker's own `HARD_MAX_CHARS = 1200` (`app/rag/chunker.py:51`, ~1024 tokens at ~1.4 chars/token) shows the question path never got the same treatment.
## Objective
Every legal question (≤ the UI clamp) is embeddable: the embed step gets a bounded prefix of the question (the chunker's 1200-char budget) while the full question still reaches the LLM prompt; and if the input is still too large (a smaller-cap model, a misconfiguration), the turn fails with an accurate "question too long" error — no false reachability diagnosis, no wasted retries — and the banner carries the phase-111 Retry button.
## Dependencies
- `111_chat_banner_retry` (todo) — L6's acceptance: "the L1 'Try again' button fix should also apply to this banner" — the too-long error flows through the same turn-error state machine, so the phase-111 Retry button is offered on it.
## Design (shared by all tasks — the executor reads this, not the chat)
- **Truncation (task 01):** new setting `embed_question_max_chars: int = 1200` (env `BOR_EMBED_QUESTION_MAX_CHARS`, default = the chunker's `HARD_MAX_CHARS` budget, validated `> 0`). The chat embed step (chat.py:423) embeds `request.message[:settings.embed_question_max_chars]`; the LLM prompt build is unchanged (the full question still reaches the model). Questions shorter than the budget are byte-identical to today.
- **Error mapping (task 02):** `app/rag/llm.py` — new `EmbeddingInputTooLargeError(EmbeddingError)` subclass; the single-text `_TooLarge` branch of `_embed_batch` raises it (same message text — the importer path is byte-identical, it still catches `EmbeddingError`). The chat endpoint catches `EmbeddingInputTooLargeError` **before** `EmbeddingError` inside the phase-67 retry loop → no retry (a deterministic failure — locked A3) → terminal `ChatErrorEvent` with `detail="Question too long — trim it and re-ask."` and a new optional `hint` field: `hint="The app reached the embedding model fine — only the question length is the problem."` `ChatErrorEvent` gains `hint: str | None = None` (additive; PLAN §4 old-client ignore contract). The frontend's phase-111 reworked `showErrorBanner(detail, opts)` shows `opts.hint` when the frame carries one, else the default `ERROR_HINT`.
- **Retry:** the too-long frame flows through the turn-error state machine → the phase-111 banner Retry button is offered (re-asking is the user's call after trimming; the composer clamp still applies).
- **NOT touched:** the importer's embed path and its batch-halving `_TooLarge` behavior/error copy, the 4,000-char composer clamp (locked A2 — truncation, not a lower clamp), the reachability-failure retry semantics (phase 67 — byte-identical).
## Tasks
1. `01_embed_truncation.md` — the bounded-prefix embed + the setting.
2. `02_too_long_error_mapping.md` — `EmbeddingInputTooLargeError`, the chat-path mapping, `ChatErrorEvent.hint`, the frontend hint support.
3. `03_embed_length_tests.md` — the unit pins + the 4,000-char E2E.
## Testing & Quality
- Unit: `tests/unit/test_embed_question_length.py` (new, task 03) — truncation (long → prefix embedded, LLM prompt carries the full text; short → byte-identical), error mapping (too-large failure → exact detail + hint, no retry frame, one attempt; transport failure → legacy reachability path with retries — the regression pin), the config validator.
- E2E: `tests/e2e/test_embed_question_length.py` (new, task 03; run in isolation: `uv run pytest tests/e2e/test_embed_question_length.py -v --no-cov`) — a 4,000-char question (the composer clamp) streams to done (mock LLM), no error banner.
- Regression: `tests/e2e/test_llm_retry.py`, `test_oneshot_llm_retry.py`, `test_chip_sizing_question_cap.py` (the 4,000-char counter) stay green.
- Coverage: **>90%** on `app/` (validate.sh gate).
## Completion Criteria
- [ ] A 4,000-char question embeds (bounded prefix) and the turn succeeds; the LLM prompt carries the full question.
- [ ] A too-large embed failure (forced in a unit test) → the accurate "Question too long" frame + the reachability-fine hint; the banner offers the phase-111 Retry button.
- [ ] A reachability embed failure behaves byte-identically to pre-phase (retries + old copy).
- [ ] `uv run pytest` green; coverage >90%; the e2e file green in isolation; `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase dir moved to `complete/` by the pipeline gate.
## Locked decisions
- **A1 — both fixes combined: 1200-char embed truncation (default = the chunker budget, env-tunable) + the precise too-long error mapping (owner-confirmed 2026-09-14, roadmap confirmation).**
- **A2 — the 4,000-char composer clamp stays (owner-confirmed 2026-09-14) — truncation, not a lower clamp.**
- **A3 — a too-large embed failure is NOT retried (deterministic failure) — it short-circuits the phase-67 retry loop (owner-confirmed 2026-09-14).**
## Commit
```bash
git add app/ tests/ frontend/ .agents/phases/ && git commit --no-gpg-sign -m "fix(rag): embed a bounded question prefix (1200-char budget) and map the embed too-large failure to an accurate too-long error with a reachability-fine hint"
```
@@ -0,0 +1,21 @@
# Task 01 — Embed a bounded question prefix
**Phase:** `114_embed_question_length` · **Source:** `TODO.md:151–164, 168–170` — "Repro: type/paste a question to the UI maximum (the composer clamps at 4,000 chars — char counter shows '4000/4000 — character limit') and send. Result, **100% reproducible**: the turn dies pre-token with the banner 'I couldn't reach the embedding model — please try again.' … a short question embeds fine (HTTP 200), but the 4,000-char question (~903 tokens) gets **HTTP 500** from aipi … So the maximum legal question length exceeds the embed model's maximum legal input — and the chunker's own 1200-char cap (set to stay under the ~1024-token per-request cap) shows the question path never got the same treatment." + "**Truncate for embedding** — embed a bounded prefix of the question (e.g. the same 1200-char budget as chunks) while the full question still reaches the LLM prompt."
## Objective
The chat embed step embeds at most `embed_question_max_chars` (default 1200 — the chunker's `HARD_MAX_CHARS` budget) of the question; the full question still reaches the LLM prompt.
## Work
1. `app/config.py` — add `embed_question_max_chars: int = Field(default=1200)` (env `BOR_EMBED_QUESTION_MAX_CHARS`), validator `> 0`; `.env.example` entry with a comment citing the chunker rationale (`app/rag/chunker.py:30–51` — ~1.4 chars/token, stays under the ~1024-token per-request cap).
2. `app/api/chat.py` — the embed step (~L423): `question_vec = await llm.embed_one(request.message[: settings.embed_question_max_chars])`. Everything downstream is unchanged: retrieval runs on the prefix vector (intended — the prefix is the question's head); the LLM prompt build (`hist` + the full `request.message`) is untouched; the per-turn log line is untouched (`question=%r` logs the full text).
3. One-line comment at the call site: the prefix is bounded to the embed model's input cap (the chunker budget); the full question still reaches the LLM prompt (TODO L6).
4. ASSUMPTION: the budget is a setting (env-tunable), default 1200 — not a hard-coded constant — so a model with a larger/smaller cap is accommodated without a code change (locked A1).
## Testing & Quality
- Unit: `tests/unit/test_embed_question_length.py` (new, task 03) — a question > the budget → `embed_one` receives exactly the prefix (mock LLM client); the LLM request messages carry the full question; a question ≤ the budget → byte-identical call.
- Coverage: **>90%** on `app/`.
## Completion Criteria
- [ ] A 4,000-char question → `embed_one` called with the 1200-char prefix; the LLM request carries the full 4,000-char message.
- [ ] A short question → no behavior change (byte-identical call).
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,31 @@
# Task 02 — Map the too-large embed failure to an accurate error
**Phase:** `114_embed_question_length` · **Source:** `TODO.md:171–173` — "**Map the 500 to a precise error** — detect the 'too large' embed failure and surface 'question too long — trim it' (and fix the ERROR_HINT for this case: reachability is fine)." + `TODO.md:179–181` — "Acceptance: a 4,000-char question either succeeds (truncated embedding) or fails with an accurate too-long error; unit test pins the error mapping; the L1 'Try again' button fix should also apply to this banner."
## Objective
A deterministic "input too large" embed failure surfaces as a precise "question too long" terminal error with a hint that reachability is fine — no false reachability diagnosis, no wasted retries; the error banner carries the phase-111 Retry button (the turn-error path).
## Work
1. `app/rag/llm.py` — add `class EmbeddingInputTooLargeError(EmbeddingError)` (near `EmbeddingError`, L42), with a docstring: the single-text input exceeded the endpoint's token cap — deterministic, not a reachability failure. In the single-text `_TooLarge` branch of `_embed_batch` (~L279–284): raise `EmbeddingInputTooLargeError(<the existing import-oriented message>)` instead of plain `EmbeddingError` — the message text is **identical** (the importer path is byte-identical; it still catches `EmbeddingError`, and the subclass is a drop-in).
2. `app/schemas.py` — `ChatErrorEvent` gains `hint: str | None = None` (additive; docstring: the client shows the hint in place of its default reachability hint when present; old clients ignore the field — PLAN §4).
3. `app/api/chat.py` — the embed-failure handling (~L428–444, inside the phase-67 retry `while` loop): catch `EmbeddingInputTooLargeError` **before** `EmbeddingError` → do NOT restart (locked A3 — deterministic) → `settled = True`, log an error line (the existing format plus a `too-large` marker), and yield:
```python
ChatErrorEvent(
detail="Question too long — trim it and re-ask.",
hint="The app reached the embedding model fine — only the question length is the problem.",
).model_dump()
```
The existing `EmbeddingError` branch (reachability) is unchanged, including the retry semantics and the old copy.
4. `frontend/assets/app.js` — the phase-111 reworked `showErrorBanner(detail, opts)`: honor `opts.hint` — `bannerText.textContent = detail ? \`${detail} ${opts.hint ?? ERROR_HINT}\` : (opts.hint ?? ERROR_HINT)`. The SSE error-frame handler in the stream state machine (~L1281): pass `{ retryable: true, hint: ev.hint }` when the frame carries a hint.
5. ASSUMPTION: detail copy "Question too long — trim it and re-ask." (the TODO's "question too long — trim it", phrased as a banner sentence); hint copy as in work item 3.
6. ASSUMPTION: no retry on too-large (locked A3) — the phase-67 retry loop is for transient failures; a size failure is guaranteed to repeat.
## Testing & Quality
- Unit: `tests/unit/test_embed_question_length.py` (new, task 03) — force the `_TooLarge` branch (a fake httpx response: HTTP 500 + a "too large to process" body) → the chat SSE stream yields exactly one error frame with the precise detail + hint and **no** retry frame; a transport failure (no "too large" signature) → the legacy reachability path with the retry loop and old copy (the regression pin).
- Coverage: **>90%** on `app/` including the new exception class and branch.
## Completion Criteria
- [ ] A too-large embed failure → the frame `{type: "error", detail: "Question too long — trim it and re-ask.", hint: "…fine…"}` — no "couldn't reach" copy, no retry frame.
- [ ] A reachability embed failure → byte-identical to pre-phase (retries + old copy).
- [ ] The frontend shows the frame's hint when present; the banner offers the phase-111 Retry button on this error.
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,25 @@
# Task 03 — Unit + E2E pins for the 4,000-char question
**Phase:** `114_embed_question_length` · **Source:** `TODO.md:179–181` — "Acceptance: a 4,000-char question either succeeds (truncated embedding) or fails with an accurate too-long error; unit test pins the error mapping; the L1 'Try again' button fix should also apply to this banner."
## Objective
The acceptance is pinned: a full-clamp (4,000-char) question succeeds end-to-end (truncated embed), and the too-long mapping is unit-pinned.
## Work
1. `tests/unit/test_embed_question_length.py` (new):
- **truncation:** a mock `LLMClient` records the `embed_one` input; a 4,000-char question → exactly the prefix (default budget); the chat request to the LLM carries the full question; a 100-char question → byte-identical call.
- **error mapping:** a fake embed transport returning HTTP 500 + "too large to process" body for the input → the chat SSE stream yields exactly one error frame with the precise detail + the reachability-fine hint and no retry frame; short input + a 500 WITHOUT the "too large" signature → the legacy reachability path (retry frames + old copy) — the regression pin.
- **config:** the `embed_question_max_chars` default (1200) and validator.
2. `tests/e2e/test_embed_question_length.py` (new; the `tests/e2e/` conftest + mock-LLM pattern): type a 4,000-char question into the composer (the counter shows "4000/4000") → send → the turn streams to done (mock LLM) — no error banner.
3. Run in isolation: `uv run pytest tests/e2e/test_embed_question_length.py -v --no-cov` (DB up).
4. Regression: `tests/e2e/test_llm_retry.py`, `test_oneshot_llm_retry.py`, `test_chip_sizing_question_cap.py` (the 4,000-char counter) stay green.
## Testing & Quality
- Unit: as above (the acceptance pin: the error mapping).
- E2E: the 4,000-char success path (the acceptance pin: the truncated embed).
- Coverage: **>90%** on `app/`.
## Completion Criteria
- [ ] A 4,000-char question → a successful turn (E2E); the embed input was the prefix (unit).
- [ ] The too-long mapping is unit-pinned (exact frame, no retry).
- [ ] `uv run pytest` green; coverage >90%; the e2e file green in isolation; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,45 @@
# Phase 115 — Doc drafts: Discard + DELETE route + title fix (TODO L7)
**Source:** `TODO.md` L183–209 — "L7 — 'Save as doc' has no Discard: orphan drafts are invisible and un-deletable (2026-09-15, brain-of-reese interactive test)"
**Story:** n/a (interactive-test follow-up fix; extends the phase-59/75 doc-draft and save-as-doc assets).
**Context:** "Save as doc" (`frontend/assets/app.js::saveAsDoc`, L720) POSTs `/api/doc-drafts` (201) → `/doc-edit.html?draft=<token>`. The edit screen offers exactly one action: **"Push to docs branch"**. The drafts router (`app/api/doc_drafts.py`) has POST/GET/PUT/POST-push only — no DELETE, no TTL/pruning; a draft created by an accidental click (or a tester) sits orphaned in the DB forever — invisible (no UI lists drafts) and only consumable by actually pushing a doc to the repo. Side observation: the draft's default title comes from `defaultDocTitle()` (app.js:619) — the **last user record** in the conversation — and after a Retry redo-in-place (phase 49) the redone answer sits at the end, so its title came from an unrelated trailing question (a junk 4,000-char test question, not the question the answer answered). The body (full session transcript) is correct by design; only the title derivation mismatches.
## Objective
An orphaned doc draft can be discarded from the edit screen (a new admin-gated `DELETE /api/doc-drafts/{token}` + a Discard control), and the draft's default title is the question the saved answer actually answered (its paired user record) — fixing the retry-redo mismatch.
## Dependencies
- `114_embed_question_length` (todo) — pipeline predecessor (execution order) only; no code dependency.
## Design (shared by all tasks — the executor reads this, not the chat)
- **DELETE route (task 01):** `DELETE /api/doc-drafts/{token}` → **204**. The whole router already sits behind `require_admin` (phase 59, `dependencies=[Depends(require_admin)]` at L63) — the new route inherits it; the uuid4 token is the screen's credential (same trust model as GET/PUT/push). Unknown token → 404 via the existing `_get_draft_or_404` helper. No migration (a row delete); no push-side state (the git push happens only on push).
- **Discard UI (task 02):** the doc-edit screen — a "Discard draft" control next to "Push to docs branch" (secondary/danger treatment per the theme). `frontend/assets/doc-edit.js`: `confirm()` (destructive + irreversible — no undo exists), `DELETE /api/doc-drafts/${token}` (the same token the screen already uses for GET/PUT) → 204 → `location.assign("/")` (back to the chat page). Non-204 → the page's existing inline-error pattern, no navigation.
- **Title fix (task 03):** `defaultDocTitle(wrap)` — takes the saved brain bubble's wrap (the `.save-as-doc-btn`'s bubble); the title is the text of the user bubble **paired** with that brain bubble (the nearest preceding user message in the DOM conversation flow), falling back to the current last-user-record-in-`conversation` logic when no wrap is given or no paired user bubble is found (first-turn edge / DOM mismatch). The `DOC_TITLE_MAX` slice + "Note" fallback are unchanged.
- **NOT touched:** the push flow (byte-identical), the draft body (`buildSessionTranscript` — full session, correct by design), the drafts schema (no field change), no TTL/pruning (locked A1 — out of scope).
## Tasks
1. `01_delete_draft_route.md` — `DELETE /api/doc-drafts/{token}` (204 / 404 / admin-gated).
2. `02_discard_ui.md` — the Discard control on the doc-edit screen wired to the route.
3. `03_draft_title_fix.md` — the title from the answer's own question (the paired user record).
4. `04_draft_discard_tests.md` — the API + frontend + E2E pins.
## Testing & Quality
- Integration: `tests/integration/test_doc_drafts_api.py` (existing — extend, task 04): DELETE removes the row (204; subsequent GET 404); unknown token → 404; the admin gate applies (same assertions the sibling routes use).
- Unit (frontend, house source-assertion style, task 04): the Discard control's presence/handler (confirm → DELETE → 204 → redirect; non-204 → inline error, no navigation); the `defaultDocTitle` pairing logic + the call site.
- E2E: `tests/e2e/test_save_doc_session.py` (existing — extend, task 04): the discard flow (save → edit screen → discard → confirm → back on the chat, draft gone); a save-as-doc after a Retry redo-in-place → the title matches the redone answer's own question. Run in isolation: `uv run pytest tests/e2e/test_save_doc_session.py -v --no-cov`.
- Coverage: **>90%** on `app/` (validate.sh gate).
## Completion Criteria
- [ ] An orphaned draft can be discarded from the edit screen; the draft row is gone afterward (integration).
- [ ] The title of a save-as-doc after a retry redo matches the redone answer's own question (E2E).
- [ ] The push flow is byte-identical (regression green).
- [ ] `uv run pytest` green; coverage >90%; the e2e file green in isolation; `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase dir moved to `complete/` by the pipeline gate.
## Locked decisions
- **A1 — scope: the Discard control + the DELETE route + the title fix; NO TTL/pruning (owner-confirmed 2026-09-14, roadmap confirmation — the discard covers the acceptance; pruning can be a future phase).**
- **A2 — the DELETE route is admin-gated by the router-level `require_admin` (phase 59) with the uuid4 token as the credential — same trust model as the sibling routes (owner-confirmed 2026-09-14).**
## Commit
```bash
git add app/ tests/ frontend/ .agents/phases/ && git commit --no-gpg-sign -m "feat(docs): discard doc drafts from the edit screen (DELETE /api/doc-drafts/{token}) + derive the draft title from the answer's own question"
```
@@ -0,0 +1,37 @@
# Task 01 — DELETE /api/doc-drafts/{token}
**Phase:** `115_doc_draft_discard` · **Source:** `TODO.md:185–199, 201–204` — "Clicking 'Save as doc' on an answer POSTs `/api/doc-drafts` (201) and navigates to `/doc-edit.html?draft=<token>`. The edit screen offers exactly one action: **'Push to docs branch'**. There is no Discard/cancel control, the drafts API has no DELETE route (`app/api/doc_drafts.py`: POST, GET, PUT, POST /push only) and no TTL/pruning. A draft created by an accidental click (or a tester) sits orphaned in the DB forever — invisible (no UI lists drafts) and only consumable by actually pushing a doc to the repo." + "Suggested fix: add a Discard control to `/doc-edit.html` wired to a new `DELETE /api/doc-drafts/{token}` (admin-gated, token = the screen's credential) …"
## Objective
A new admin-gated `DELETE /api/doc-drafts/{token}` removes a draft row (204) so an orphaned draft can be discarded instead of only pushed.
## Work
1. `app/api/doc_drafts.py` — add (after the `update_draft` PUT route, before the push route — or at the file's route-order idiom):
```python
@router.delete("/{token}", status_code=status.HTTP_204_NO_CONTENT)
def delete_draft(
token: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> None:
"""Discard a draft (the edit screen's Discard control, phase 115).
Admin-gated like the whole router (phase 59); the uuid4 token is
the screen's credential — after a successful discard, GET/PUT/push
all 404.
"""
row = _get_draft_or_404(db, token)
db.delete(row)
db.commit()
```
Match the file's existing import style (check whether `status` from `fastapi` is already imported; use the file's idiom for the 204 response).
2. No migration, no schema change, no other route touched.
3. ASSUMPTION: 204 No Content (no body) — the token is a one-way credential; nothing else references the row (no FK targets, no push-side state).
## Testing & Quality
- Integration: extended in task 04 (`tests/integration/test_doc_drafts_api.py`).
- Coverage: **>90%** on `app/` including the new route.
## Completion Criteria
- [ ] `DELETE /api/doc-drafts/{token}` → 204; the subsequent `GET` → 404.
- [ ] Unknown token → 404; the admin gate applies exactly like the sibling routes.
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,33 @@
# Task 02 — Discard control on the doc-edit screen
**Phase:** `115_doc_draft_discard` · **Source:** `TODO.md:201–204` — "Suggested fix: add a Discard control to `/doc-edit.html` wired to a new `DELETE /api/doc-drafts/{token}` (admin-gated, token = the screen's credential), or a TTL/prune for stale drafts; …" + `TODO.md:207–208` — "Acceptance: an orphaned draft can be discarded from the edit screen; the draft row is gone afterward (API test) …"
## Objective
The doc-edit screen offers a Discard control: confirm → DELETE → back to the chat.
## Work
1. The doc-edit template (find the exact HTML file — the edit screen that renders "Push to docs branch"; `frontend/doc-edit.html` or the template it uses): add next to the push control:
```html
<button type="button" id="discard-draft" class="discard-draft"
title="Delete this draft permanently — this cannot be undone">Discard draft</button>
```
visually secondary to the push button (the theme's muted/danger treatment).
2. `frontend/assets/doc-edit.js` — a handler near the push handler:
- `if (!confirm("Discard this draft? This cannot be undone.")) return;`
- `fetch(\`/api/doc-drafts/${token}\`, { method: "DELETE" })` (the same token the screen already uses for GET/PUT, per its existing load code ~L136–143);
- 204 → `location.assign("/")` (back to the chat page);
- non-204 → the page's existing inline-error pattern (message + no navigation, no crash).
3. `frontend/assets/styles.css` — `.discard-draft`: the secondary/danger button style (contrast ≥4.5:1, `focus-visible` ring per the theme), laid out next to the push button.
4. Frontend unit tests (house source-assertion style, shipped in task 04's `tests/unit/test_frontend_doc_draft_discard.py`): the button's presence; `confirm(...)` before the DELETE; 204 → redirect; non-204 → inline error, no navigation.
5. ASSUMPTION: after a successful discard the user lands on the chat page (`/`) — the draft has no other home (no drafts list exists).
6. ASSUMPTION: a native `confirm()` is acceptable for this one destructive action (the codebase has no custom dialog asset — if the executor finds one in the theme, use it instead).
## Testing & Quality
- Unit: the frontend source-assertion tests (task 04).
- E2E: the discard flow (task 04, `tests/e2e/test_save_doc_session.py`).
- Coverage: n/a (frontend) — the `app/` gate stays green.
## Completion Criteria
- [ ] The edit screen shows "Discard draft" next to the push control; the confirm dialog appears; a 204 returns the user to `/`.
- [ ] A failed DELETE (e.g. a 404 race) shows the inline error; no navigation; no crash.
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,26 @@
# Task 03 — Draft title from the answer's own question
**Phase:** `115_doc_draft_discard` · **Source:** `TODO.md:193–199` — "Side observation (edge case, same test): the draft's default title is the user record *immediately preceding* the saved answer. After a Retry redo-in-place (L-see app.js `retryLastTurn`), the redone answer sits at the end of the conversation, so its save-as-doc title came from an unrelated trailing question (the doc was titled with a junk 4,000-char test question, not the question the answer answered). Body is the full session transcript (correct by design); title derivation is just the mismatch." + `TODO.md:203–204` — "consider deriving the default title from the question the answer actually answered (its paired user record) rather than the preceding record."
## Objective
The draft's default title is the user question paired with the saved brain bubble (its own question) — after a Retry redo-in-place the title matches the redone answer's question.
## Work
1. `frontend/assets/app.js` — `defaultDocTitle()` (L619) → `defaultDocTitle(wrap)`:
- when *wrap* (the brain bubble) is given, walk the DOM conversation flow backwards from *wrap* to the **nearest user message bubble** (confirm the exact user-bubble class — the `.msg` variant used for user turns) and use its text;
- fall back to the current last-user-record-in-`conversation` logic when *wrap* is absent or no paired user bubble is found (first-turn edge / DOM mismatch);
- the `DOC_TITLE_MAX` slice + whitespace collapse + "Note" fallback are unchanged.
2. `saveAsDoc(btn)` (L720) — pass the bubble: `const title = defaultDocTitle(btn.closest(<the bubble class>))` — the save button lives in the bubble's meta (the `addSaveAsDocButton` code ~L704–710 shows the exact ancestor; use the same class `lastBrainWrap` uses at L577).
3. The `docSlug`/path logic is unchanged (it derives from the title).
4. Frontend unit tests (house source-assertion style, task 04's file): `defaultDocTitle` takes a wrap arg and prefers the paired user bubble over the last conversation record; the `saveAsDoc` call site passes the bubble ancestor.
5. ASSUMPTION: the pairing is DOM-structural (nearest preceding user bubble), not index-based — the redo-in-place reorders the DOM, and the structural pair IS the answer's question by construction.
## Testing & Quality
- Unit: the frontend source-assertion tests (task 04).
- E2E: the title-after-retry pin (task 04, `tests/e2e/test_save_doc_session.py`).
- Coverage: n/a (frontend) — the `app/` gate stays green.
## Completion Criteria
- [ ] A save-as-doc on a normal (non-redone) answer → the same title as pre-phase (no regression).
- [ ] A save-as-doc after a Retry redo-in-place → the title is the redone answer's own question (E2E).
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,30 @@
# Task 04 — API, frontend, and E2E pins for the discard + title
**Phase:** `115_doc_draft_discard` · **Source:** `TODO.md:207–209` — "Acceptance: an orphaned draft can be discarded from the edit screen; the draft row is gone afterward (API test); title of a save-as-doc after a retry redo matches the redone answer's own question."
## Objective
The acceptance is pinned at all three layers: the API (the row is gone), the frontend (the control + handler + the pairing), and the E2E (the full discard flow + the title after a retry redo).
## Work
1. `tests/integration/test_doc_drafts_api.py` (existing — extend):
- DELETE an existing draft → 204; the subsequent GET → 404.
- DELETE an unknown token → 404.
- the admin gate: the same assertions the sibling routes use (mirror the existing test's auth fixtures — anonymous → 401 / non-admin → 403 per the router's `require_admin`).
2. `tests/unit/test_frontend_doc_draft_discard.py` (new, house source-assertion style):
- the doc-edit template carries `#discard-draft`; `doc-edit.js` calls `confirm(...)` before the `DELETE /api/doc-drafts/` fetch; 204 → the redirect; non-204 → the inline error, no navigation.
- `app.js`: `defaultDocTitle` takes a wrap arg and prefers the paired user bubble; the `saveAsDoc` call site passes the bubble ancestor.
3. `tests/e2e/test_save_doc_session.py` (existing — extend):
- **the discard flow:** ask (mock LLM) → save as doc → the edit screen → click Discard → confirm → back on the chat page; an API check (test client) confirms the draft row is gone (GET 404).
- **the title after a retry:** ask → the answer → Retry (redo-in-place) → save as doc on the redone answer → the edit screen's title field value == the redone question (not an unrelated trailing question).
4. Run in isolation: `uv run pytest tests/e2e/test_save_doc_session.py -v --no-cov` (DB up).
5. Regression: `tests/integration/test_doc_drafts_api.py` (all), the existing `tests/e2e/test_save_doc_session.py` tests green.
## Testing & Quality
- Integration: the DELETE contract (the acceptance: "the draft row is gone afterward (API test)").
- Unit: the frontend pins.
- E2E: the flow + the title (the acceptance).
- Coverage: **>90%** on `app/`.
## Completion Criteria
- [ ] All the acceptance pins green (API row gone; title after a retry).
- [ ] `uv run pytest` green; coverage >90%; the e2e file green in isolation; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,39 @@
# Phase 116 — Document modal: themed code-block scrollbar (TODO L3)
**Source:** `TODO.md` L88–99 — "L3 — Document modal: native unstyled horizontal scrollbar in the code block (cosmetic) (2026-09-15, brain-of-reese interactive test)"
**Story:** `document-viewer.md` — the modal belongs to the document-viewer story (the same-page chip viewer, phase 26).
**Context:** The document modal (the almost-fullscreen chip viewer, `.doc-modal` — `frontend/assets/styles.css:4117+`) shows the raw content in a code block; long lines (e.g. a `quest::say(...)` line in a quest `.pl` file) overflow horizontally and reveal the **browser-native, unstyled scrollbar** (light-gray bar) — it clashes with the dark theme, and long lines clip at the right edge.
## Objective
The modal's code block scrolls horizontally with a themed scrollbar (`scrollbar-color` for Firefox, `::-webkit-scrollbar` pseudos for Chromium), consistent with the dark theme; long lines stay unwrapped (code stays code) and scroll instead of clipping.
## Dependencies
- `115_doc_draft_discard` (todo) — pipeline predecessor (execution order) only; no code dependency (CSS + one E2E file).
## Design (shared by all tasks — the executor reads this, not the chat)
- **Theme tokens (task 01):** two CSS custom properties in the theme's token block: `--scrollbar-thumb` (a muted theme color, ≥3:1 against the track — a scrollbar is a UI component, non-text contrast AA) and `--scrollbar-track` (near the code-block background). Scope: the modal's raw-content code element **only** (confirm the exact selector from `frontend/assets/document-modal.js` / the `.doc-modal` rules) — no global scrollbar restyle (out of scope; the TODO asks only about the modal).
- **Rules (task 01):** on the modal code block: `overflow-x: auto` (confirm present — add if the element relies on an ancestor), `scrollbar-width: thin`, `scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track)` (Firefox), and the Chromium pair: `::-webkit-scrollbar { height: 8px }`, `::-webkit-scrollbar-track { background: var(--scrollbar-track) }`, `::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 4px }`.
- **E2E (task 02):** the document-viewer story gains a check: open the modal on a document with a long line → the code block is horizontally scrollable (`scrollWidth > clientWidth`; a scroll action moves it) → a screenshot to `.agents/screenshots/` (house convention) as the visual record.
## Tasks
1. `01_themed_scrollbar.md` — the theme tokens + the scoped scrollbar rules.
2. `02_scrollbar_e2e_check.md` — the E2E scroll check + screenshot.
## Testing & Quality
- E2E: `tests/e2e/test_document_viewer.py` (existing — extend, task 02); run in isolation: `uv run pytest tests/e2e/test_document_viewer.py -v --no-cov`.
- CSS: no unit layer for CSS — the E2E check + screenshot are the gate; the dark-theme suite (`tests/e2e/test_dark_tech_theme.py`) must stay green (no token collision).
- Coverage: n/a (no app/ code change) — the validate.sh gate stays green.
## Completion Criteria
- [ ] The modal's code block scrolls horizontally; the scrollbar is themed — the screenshot in `.agents/screenshots/` shows no native light-gray bar.
- [ ] No global scrollbar change (the new selector is scoped under `.doc-modal` — grep); the other pages are visually unchanged (regression suites green).
- [ ] `uv run pytest` green; coverage >90%; the e2e file green in isolation; `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase dir moved to `complete/` by the pipeline gate.
## Locked decisions
- **A1 — keep horizontal scroll (NO line-wrap) for the code content (owner-confirmed 2026-09-14, roadmap confirmation — the TODO's "consider wrapping long lines per content type" is rejected for code: wrapping breaks code readability; the themed scrollbar is the fix).**
## Commit
```bash
git add frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "style(ui): theme the document modal's code-block horizontal scrollbar (scrollbar-color + webkit pseudos)"
```
@@ -0,0 +1,23 @@
# Task 01 — Themed scrollbar for the modal code block
**Phase:** `116_modal_scrollbar_theme` · **Source:** `TODO.md:90–96` — "In the document modal (the almost-fullscreen chip viewer), the raw-content code block overflows horizontally and reveals the **browser-native, unstyled scrollbar** (light-gray bar) — it clashes with the dark theme, and long lines clip at the right edge (observed on a quest `.pl` file whose `quest::say(...)` line exceeds the modal width). Style the scrollbar to match the theme (`scrollbar-color` for Firefox, `::-webkit-scrollbar` pseudos for Chromium), or consider wrapping long lines per content type."
## Objective
The modal's raw-content code block gets a themed horizontal scrollbar (both engine families), scoped to the modal.
## Work
1. `frontend/assets/styles.css` — find the modal code block's exact selector (the `.doc-modal` raw-content `<pre>`/code element — how `frontend/assets/document-modal.js` renders the content; the `.doc-modal-panel` rules start ~L4135):
- ensure `overflow-x: auto` on the scrolling element (add it if the element relies on an ancestor for the overflow);
- add the scoped rules: `scrollbar-width: thin`, `scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track)`, and `::-webkit-scrollbar { height: 8px }` / `::-webkit-scrollbar-track { background: var(--scrollbar-track) }` / `::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 4px }`.
2. `frontend/assets/styles.css` — define `--scrollbar-thumb` / `--scrollbar-track` in the theme's token block (derive from existing theme colors — the thumb must be visibly distinct from the track, ≥3:1 non-text contrast).
3. Do NOT restyle scrollbars elsewhere — the new selector stays scoped under `.doc-modal` (the TODO's scope).
4. ASSUMPTION: no line-wrap (locked A1, phase level) — the code stays unwrapped and scrolls.
## Testing & Quality
- E2E: the scroll check (task 02).
- Coverage: n/a (CSS) — the suite stays green.
## Completion Criteria
- [ ] The modal code block's horizontal scrollbar is themed for Chromium (webkit pseudos) and Firefox (`scrollbar-color`) — the task-02 screenshot shows no native light-gray bar.
- [ ] No other page's scrollbar changes (the new selector is scoped under `.doc-modal`).
- [ ] `uv run pytest` green (in particular `tests/e2e/test_dark_tech_theme.py`).
@@ -0,0 +1,24 @@
# Task 02 — E2E: the modal scrollbar check + screenshot
**Phase:** `116_modal_scrollbar_theme` · **Source:** `TODO.md:98–99` — "Acceptance: the modal's code block scrolls horizontally with a themed scrollbar; screenshot check in the document-viewer E2E story."
## Objective
The document-viewer E2E story asserts the modal code block scrolls horizontally and records a screenshot of the themed scrollbar.
## Work
1. `tests/e2e/test_document_viewer.py` (existing — extend) — a test:
- open the document modal on a document with a line longer than the modal width (check the fixture KB's contents first; if no fixture doc qualifies, pick the longest existing line and assert `scrollWidth > clientWidth` against it — note the choice in the test docstring);
- the code block element: `scrollWidth > clientWidth` (the overflow is real);
- a horizontal scroll action (`scrollLeft` / `scrollBy`) actually moves it (the scroll works, no clipping);
- the themed rule is in effect: `getComputedStyle` exposes `scrollbar-color` (the webkit pseudos are not exposed via computed style — that half is covered by the screenshot);
- a screenshot to `.agents/screenshots/` (house convention — check the exact path other screenshot tests use).
2. Run in isolation: `uv run pytest tests/e2e/test_document_viewer.py -v --no-cov` (DB up).
3. Regression: the existing `test_document_viewer.py` tests stay green.
## Testing & Quality
- E2E: the scroll behavior + the screenshot (the acceptance).
- Coverage: n/a.
## Completion Criteria
- [ ] The new E2E test green in isolation; the screenshot shows the themed scrollbar (no native light-gray bar).
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
-208
View File
@@ -1,209 +1 @@
# TODO
## L1 — Chat error banner: "Try again" is plain text, not a button (2026-09-15, brain-of-reese interactive test)
`showErrorBanner()` (`frontend/assets/app.js`, `ERROR_HINT` ~line 362) renders
`${detail} ${ERROR_HINT}` as **plain text** into `#kb-banner-text`. The hint
begins with "Try again — if this persists, check the LLM is reachable.", so
"Try again" reads as a clickable action but is not — the only recovery is to
re-type the question. Observed on the "The chat model dropped the connection —
try again?" error after a mid-stream LLM drop (the per-answer Retry pill does
work; the banner does not).
Suggested fix: give the chat-view error banner a real Retry control that
re-asks the last question (the stale-chat banner already has the pattern:
`#stale-regenerate` → `retryLastTurn`; the phase-49 Retry pill asset exists).
Keep `#kb-banner` dual-use working for the RAG view, or split the two banners
if the RAG view's banner has different recovery semantics.
Acceptance: after a dropped/failed turn, the banner shows a visible Retry
button that re-runs the last question without re-typing; unit test for the
banner's button presence/handler in the frontend test suite.
## L2 — Deflection test: non-KB question got answered parametrically + unrelated source chips (2026-09-15, brain-of-reese interactive test)
Test: asked "What is the capital of Mongolia?" — known to the LLM, not in
the KB. Expected (README + locked HONESTY GATE): admit no notes, offer 2-3
alternative questions about things the brain DOES have notes on.
Observed: it answered "Ulaanbaatar" with a disclosure ("that info isn't in my
knowledge base… pulled that from general knowledge") and a generic
"happy to help" — and the turn carried **two unrelated source chips**
(`EverQuest_Quests_Connected/cabeast/Trooper_Nagraz.pl`,
`Trooper_Begzei.pl`) that read as citations under a non-grounded answer.
Three parts, one root-cause chain:
a) **Honesty gate too eager** (`app/api/chat.py` L272–277, A8 revised
2026-08-21): HIGH/grounded when `best_cosine >= threshold` OR
`fts_hits > 0`. A single weak FTS token hit (suspected: the token
"capital" inside a quest file) promotes a non-KB question into grounded
mode and injects two irrelevant top-docs into the HIGH prompt — which
then tempts the model into a parametric answer instead of deflection.
Consider: require FTS hits to be *meaningful* (e.g. rank threshold,
stopword/short-token exclusion, or cosine corroboration) before flipping
to HIGH; or run the LOW prompt when top-docs score below a usefulness bar.
b) **Model violates the locked prompt** (Rule 1 "Answer ONLY from the
provided document context", Rule 3 HONESTY GATE): with irrelevant docs
injected it answered from general knowledge. The disclosure is better UX
than silence, but the contract says no pretending to know + 2-3 concrete
alternative questions — decide whether to (i) tighten the prompt copy
("if the documents do not answer the question, treat it as LOW"),
(ii) amend the locked prompt via the plan to explicitly permit disclosed
general-knowledge answers, or (iii) treat the observed behavior as
acceptable and document it. Owner decision required — the prompt text
is locked verbatim (change through the plan, not here).
c) **Misleading chips on non-grounded answers** (`chat.py:252` —
`done.sources` = weak hits when deflected; by design, but visually a
citation). When the answer is disclosed general knowledge (or a deflected
turn), the chips should be visually de-emphasized / labeled (e.g. "nearby
docs I have, in case"), or omitted when the brain says it didn't use
them. At minimum: never render them as answer citations.
Follow-up (same day, chip one-tap re-run): the *identical* question
("What is the capital of Mongolia?"), same two weak-hit chips
(Trooper_Nagraz.pl + Trooper_Begzei.pl), fresh chat, no test tuning notes —
produced a **clean, textbook deflection**: no parametric answer, explicit
"I don't have any notes on…", three concrete alternative questions (all
accurate, incl. citing the owner's own tuning note as the deployments source
of truth). So with the same retrieved docs injected, HONESTY GATE compliance
is **stochastic** across runs (run 1: parametric answer; run 2: perfect
deflection). Implication for the fix direction: the deterministic lever is
the gate (don't inject irrelevant docs — part a), not prompt copy alone;
a small local model cannot be relied on to obey Rules 1/3 100% when handed
misleading context.
Plus a docs nit: the README still promises the exact deflection copy
*"I haven't done anything like that"* — the mandated deflection opening was
removed in the 2026-08-22 locked-prompt revision (`app/rag/prompts.py`
module docstring). Update the README's "If it doesn't have notes…" paragraph
to match current behavior.
Acceptance: a known-out-of-KB question (LLM-known, e.g. capitals, sports
results) produces no false citations, follows the HONESTY GATE (or the
amended contract), and unit/E2E tests pin the gate's HIGH/LOW decision on a
weak single-token FTS hit.
## L3 — Document modal: native unstyled horizontal scrollbar in the code block (cosmetic) (2026-09-15, brain-of-reese interactive test)
In the document modal (the almost-fullscreen chip viewer), the raw-content
code block overflows horizontally and reveals the **browser-native, unstyled
scrollbar** (light-gray bar) — it clashes with the dark theme, and long
lines clip at the right edge (observed on a quest `.pl` file whose
`quest::say(...)` line exceeds the modal width). Style the scrollbar to
match the theme (`scrollbar-color` for Firefox, `::-webkit-scrollbar`
pseudos for Chromium), or consider wrapping long lines per content type.
Acceptance: the modal's code block scrolls horizontally with a themed
scrollbar; screenshot check in the document-viewer E2E story.
## L5 — Recurring weak-hit source chips: the 2nd chip is often noise the answer never used (2026-09-15, brain-of-reese interactive test)
Pattern across three live turns (owner-confirmed concern): the answer's
source chips always show **two** documents, and the second is frequently a
weak/adjacent hit the answer does not actually rely on — rendered with the
same visual weight as a real citation.
Observed cases:
1. "What is the capital of Mongolia?" (non-KB question) →
`EverQuest_Quests_Connected/cabeast/Trooper_Nagraz.pl` +
`Trooper_Begzei.pl` — both unrelated; the FTS token "capital" matched
the Iksar-Empire dialogue line in Nagraz (verified in the doc modal).
2. Phase-gate question (legit, answered from `brain-of-reese/.agents/validate.sh`) →
second chip `ServMon/README.md` — no mention of it in the answer.
3. Trooper_Nagraz question (legit, answered from `Trooper_Nagraz.pl`) →
second chip `Trooper_Byzin.pl` (a sibling NPC script) — not cited in the
answer.
4. Meta question about the conversation's own history ("What was the first
question I asked…", answered from chat history, no doc needed) → chips
`brain-of-reese/app/api/suggestions.py` +
`brain-of-reese/.agents/phases/complete/108_history_wire_check/00_phase.md`
— neither used.
Root cause chain: `top_n_docs = 2` (`app/config.py` L114) forces retrieval
to return two documents, `done.sources` carries both (`app/api/chat.py`
L252), and the UI chips every entry without distinguishing "the answer used
this" from "this also scored". Compounded by the L2 gate eagerness
(`fts_hits > 0 → HIGH`), which promotes weak token hits into the top-N.
Suggested direction (pick/combine, owner call):
* **Cite-gated chips** — the locked prompt Rule 1 already says "Cite which
document(s) you used, by path": parse the answer's cited paths and chip
only those (fall back to retrieval order when nothing is parseable);
drop or de-emphasize uncited retrievals.
* **Usefulness bar on the 2nd doc** — only include a document in
`done.sources` when its fused/cosine score clears a threshold (a single
weak FTS token hit should not earn a citation slot); `top_n_docs` stays
a ceiling, not a quota.
* **Visual split** — keep both, but render uncited/weak docs as a clearly
secondary "related docs" row, not citation chips.
Acceptance: for a single-document question, the turn shows one citation
chip; a unit test pins `done.sources` filtering (or the chip renderer's
cite-gate) on the four observed shapes; E2E asserts chip count for a
known single-source question.
## L6 — 4,000-char question clamp exceeds the embed model's input cap → misleading "couldn't reach the embedding model" error (2026-09-15, brain-of-reese interactive test)
Repro: type/paste a question to the UI maximum (the composer clamps at
4,000 chars — char counter shows "4000/4000 — character limit") and send.
Result, **100% reproducible**: the turn dies pre-token with the banner
"I couldn't reach the embedding model — please try again." + ERROR_HINT
"check the LLM is reachable" — both diagnoses are wrong.
Direct probe of the endpoint proves the real cause: a short question
embeds fine (HTTP 200), but the 4,000-char question (~903 tokens) gets
**HTTP 500** from aipi: `input (903 tokens) is too large to process.
increase the physical batch size (current batch size: 512)` (litellm).
So the maximum legal question length exceeds the embed model's maximum
legal input — and the chunker's own 1200-char cap (set to stay under the
~1024-token per-request cap) shows the question path never got the same
treatment.
Suggested fixes (pick/combine, owner call):
* **Truncate for embedding** — embed a bounded prefix of the question
(e.g. the same 1200-char budget as chunks) while the full question still
reaches the LLM prompt.
* **Map the 500 to a precise error** — detect the "too large" embed failure
and surface "question too long — trim it" (and fix the ERROR_HINT for
this case: reachability is fine).
* **Re-check the char clamp** — if the embed cap is ~512-1024 tokens and
worst-case tokenization is ~1.4 chars/token (chunker rationale), a clamp
well under 4,000 chars (or the truncation above) keeps every legal
question embeddable.
Acceptance: a 4,000-char question either succeeds (truncated embedding)
or fails with an accurate too-long error; unit test pins the error mapping;
the L1 "Try again" button fix should also apply to this banner.
## L7 — "Save as doc" has no Discard: orphan drafts are invisible and un-deletable (2026-09-15, brain-of-reese interactive test)
Clicking "Save as doc" on an answer POSTs `/api/doc-drafts` (201) and
navigates to `/doc-edit.html?draft=<token>`. The edit screen offers exactly
one action: **"Push to docs branch"**. There is no Discard/cancel control,
the drafts API has no DELETE route (`app/api/doc_drafts.py`: POST, GET,
PUT, POST /push only) and no TTL/pruning. A draft created by an accidental
click (or a tester) sits orphaned in the DB forever — invisible (no UI
lists drafts) and only consumable by actually pushing a doc to the repo.
Side observation (edge case, same test): the draft's default title is the
user record *immediately preceding* the saved answer. After a Retry
redo-in-place (L-see app.js `retryLastTurn`), the redone answer sits at the
end of the conversation, so its save-as-doc title came from an unrelated
trailing question (the doc was titled with a junk 4,000-char test question,
not the question the answer answered). Body is the full session
transcript (correct by design); title derivation is just the mismatch.
Suggested fix: add a Discard control to `/doc-edit.html` wired to a new
`DELETE /api/doc-drafts/{token}` (admin-gated, token = the screen's
credential), or a TTL/prune for stale drafts; consider deriving the default
title from the question the answer actually answered (its paired user
record) rather than the preceding record.
Acceptance: an orphaned draft can be discarded from the edit screen; the
draft row is gone afterward (API test); title of a save-as-doc after a
retry redo matches the redone answer's own question.