From f37c51759002ecbf44ff47865695f21139a4aa0c Mon Sep 17 00:00:00 2001 From: ducoterra Date: Mon, 14 Sep 2026 22:07:32 -0400 Subject: [PATCH] =?UTF-8?q?chore(agent):=20phase=20roadmap=20from=20TODO.m?= =?UTF-8?q?d=20=E2=80=94=206=20phases=20(111=E2=80=93116):=20banner=20retr?= =?UTF-8?q?y,=20honesty=20gate,=20chip=20quality,=20embed=20length,=20draf?= =?UTF-8?q?t=20discard,=20modal=20scrollbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/111_chat_banner_retry/00_phase.md | 42 ++++ .../01_banner_retry_button.md | 34 +++ .../02_banner_retry_tests.md | 23 ++ .../112_honesty_gate_weak_hits/00_phase.md | 46 ++++ .../01_gate_meaningful_fts.md | 30 +++ .../02_gate_tests.md | 27 +++ .../03_prompt_contract_documentation.md | 21 ++ .../04_readme_deflection_copy.md | 20 ++ .../todo/113_source_chip_quality/00_phase.md | 46 ++++ .../01_usefulness_bar_sources.md | 28 +++ .../02_secondary_related_docs_ui.md | 27 +++ .../03_chip_filter_tests.md | 31 +++ .../114_embed_question_length/00_phase.md | 45 ++++ .../01_embed_truncation.md | 21 ++ .../02_too_long_error_mapping.md | 31 +++ .../03_embed_length_tests.md | 25 +++ .../todo/115_doc_draft_discard/00_phase.md | 45 ++++ .../01_delete_draft_route.md | 37 ++++ .../115_doc_draft_discard/02_discard_ui.md | 33 +++ .../03_draft_title_fix.md | 26 +++ .../04_draft_discard_tests.md | 30 +++ .../116_modal_scrollbar_theme/00_phase.md | 39 ++++ .../01_themed_scrollbar.md | 23 ++ .../02_scrollbar_e2e_check.md | 24 ++ TODO.md | 208 ------------------ 25 files changed, 754 insertions(+), 208 deletions(-) create mode 100644 .agents/phases/todo/111_chat_banner_retry/00_phase.md create mode 100644 .agents/phases/todo/111_chat_banner_retry/01_banner_retry_button.md create mode 100644 .agents/phases/todo/111_chat_banner_retry/02_banner_retry_tests.md create mode 100644 .agents/phases/todo/112_honesty_gate_weak_hits/00_phase.md create mode 100644 .agents/phases/todo/112_honesty_gate_weak_hits/01_gate_meaningful_fts.md create mode 100644 .agents/phases/todo/112_honesty_gate_weak_hits/02_gate_tests.md create mode 100644 .agents/phases/todo/112_honesty_gate_weak_hits/03_prompt_contract_documentation.md create mode 100644 .agents/phases/todo/112_honesty_gate_weak_hits/04_readme_deflection_copy.md create mode 100644 .agents/phases/todo/113_source_chip_quality/00_phase.md create mode 100644 .agents/phases/todo/113_source_chip_quality/01_usefulness_bar_sources.md create mode 100644 .agents/phases/todo/113_source_chip_quality/02_secondary_related_docs_ui.md create mode 100644 .agents/phases/todo/113_source_chip_quality/03_chip_filter_tests.md create mode 100644 .agents/phases/todo/114_embed_question_length/00_phase.md create mode 100644 .agents/phases/todo/114_embed_question_length/01_embed_truncation.md create mode 100644 .agents/phases/todo/114_embed_question_length/02_too_long_error_mapping.md create mode 100644 .agents/phases/todo/114_embed_question_length/03_embed_length_tests.md create mode 100644 .agents/phases/todo/115_doc_draft_discard/00_phase.md create mode 100644 .agents/phases/todo/115_doc_draft_discard/01_delete_draft_route.md create mode 100644 .agents/phases/todo/115_doc_draft_discard/02_discard_ui.md create mode 100644 .agents/phases/todo/115_doc_draft_discard/03_draft_title_fix.md create mode 100644 .agents/phases/todo/115_doc_draft_discard/04_draft_discard_tests.md create mode 100644 .agents/phases/todo/116_modal_scrollbar_theme/00_phase.md create mode 100644 .agents/phases/todo/116_modal_scrollbar_theme/01_themed_scrollbar.md create mode 100644 .agents/phases/todo/116_modal_scrollbar_theme/02_scrollbar_e2e_check.md diff --git a/.agents/phases/todo/111_chat_banner_retry/00_phase.md b/.agents/phases/todo/111_chat_banner_retry/00_phase.md new file mode 100644 index 0000000..3c99d8a --- /dev/null +++ b/.agents/phases/todo/111_chat_banner_retry/00_phase.md @@ -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 ` + ``` + (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. diff --git a/.agents/phases/todo/111_chat_banner_retry/02_banner_retry_tests.md b/.agents/phases/todo/111_chat_banner_retry/02_banner_retry_tests.md new file mode 100644 index 0000000..4b57f3c --- /dev/null +++ b/.agents/phases/todo/111_chat_banner_retry/02_banner_retry_tests.md @@ -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. diff --git a/.agents/phases/todo/112_honesty_gate_weak_hits/00_phase.md b/.agents/phases/todo/112_honesty_gate_weak_hits/00_phase.md new file mode 100644 index 0000000..1fd5c19 --- /dev/null +++ b/.agents/phases/todo/112_honesty_gate_weak_hits/00_phase.md @@ -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 / cosine0 + 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" +``` diff --git a/.agents/phases/todo/112_honesty_gate_weak_hits/01_gate_meaningful_fts.md b/.agents/phases/todo/112_honesty_gate_weak_hits/01_gate_meaningful_fts.md new file mode 100644 index 0000000..6932f1b --- /dev/null +++ b/.agents/phases/todo/112_honesty_gate_weak_hits/01_gate_meaningful_fts.md @@ -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. diff --git a/.agents/phases/todo/112_honesty_gate_weak_hits/02_gate_tests.md b/.agents/phases/todo/112_honesty_gate_weak_hits/02_gate_tests.md new file mode 100644 index 0000000..3e99633 --- /dev/null +++ b/.agents/phases/todo/112_honesty_gate_weak_hits/02_gate_tests.md @@ -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 / cosine0, 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 + cosine90%; the e2e file green in isolation; `uv run ruff check . && uv run pyright` clean. diff --git a/.agents/phases/todo/112_honesty_gate_weak_hits/03_prompt_contract_documentation.md b/.agents/phases/todo/112_honesty_gate_weak_hits/03_prompt_contract_documentation.md new file mode 100644 index 0000000..7a5a70a --- /dev/null +++ b/.agents/phases/todo/112_honesty_gate_weak_hits/03_prompt_contract_documentation.md @@ -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. diff --git a/.agents/phases/todo/112_honesty_gate_weak_hits/04_readme_deflection_copy.md b/.agents/phases/todo/112_honesty_gate_weak_hits/04_readme_deflection_copy.md new file mode 100644 index 0000000..93648f4 --- /dev/null +++ b/.agents/phases/todo/112_honesty_gate_weak_hits/04_readme_deflection_copy.md @@ -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. diff --git a/.agents/phases/todo/113_source_chip_quality/00_phase.md b/.agents/phases/todo/113_source_chip_quality/00_phase.md new file mode 100644 index 0000000..2f9a62c --- /dev/null +++ b/.agents/phases/todo/113_source_chip_quality/00_phase.md @@ -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" +``` diff --git a/.agents/phases/todo/113_source_chip_quality/01_usefulness_bar_sources.md b/.agents/phases/todo/113_source_chip_quality/01_usefulness_bar_sources.md new file mode 100644 index 0000000..4ee71d0 --- /dev/null +++ b/.agents/phases/todo/113_source_chip_quality/01_usefulness_bar_sources.md @@ -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 ]` — 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. diff --git a/.agents/phases/todo/113_source_chip_quality/02_secondary_related_docs_ui.md b/.agents/phases/todo/113_source_chip_quality/02_secondary_related_docs_ui.md new file mode 100644 index 0000000..8383531 --- /dev/null +++ b/.agents/phases/todo/113_source_chip_quality/02_secondary_related_docs_ui.md @@ -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 `Nearby docs, in case:` + 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. diff --git a/.agents/phases/todo/113_source_chip_quality/03_chip_filter_tests.md b/.agents/phases/todo/113_source_chip_quality/03_chip_filter_tests.md new file mode 100644 index 0000000..a064bf2 --- /dev/null +++ b/.agents/phases/todo/113_source_chip_quality/03_chip_filter_tests.md @@ -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. diff --git a/.agents/phases/todo/114_embed_question_length/00_phase.md b/.agents/phases/todo/114_embed_question_length/00_phase.md new file mode 100644 index 0000000..0137e41 --- /dev/null +++ b/.agents/phases/todo/114_embed_question_length/00_phase.md @@ -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" +``` diff --git a/.agents/phases/todo/114_embed_question_length/01_embed_truncation.md b/.agents/phases/todo/114_embed_question_length/01_embed_truncation.md new file mode 100644 index 0000000..108a1f2 --- /dev/null +++ b/.agents/phases/todo/114_embed_question_length/01_embed_truncation.md @@ -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. diff --git a/.agents/phases/todo/114_embed_question_length/02_too_long_error_mapping.md b/.agents/phases/todo/114_embed_question_length/02_too_long_error_mapping.md new file mode 100644 index 0000000..37985a6 --- /dev/null +++ b/.agents/phases/todo/114_embed_question_length/02_too_long_error_mapping.md @@ -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()` 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. diff --git a/.agents/phases/todo/114_embed_question_length/03_embed_length_tests.md b/.agents/phases/todo/114_embed_question_length/03_embed_length_tests.md new file mode 100644 index 0000000..55ad7cb --- /dev/null +++ b/.agents/phases/todo/114_embed_question_length/03_embed_length_tests.md @@ -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. diff --git a/.agents/phases/todo/115_doc_draft_discard/00_phase.md b/.agents/phases/todo/115_doc_draft_discard/00_phase.md new file mode 100644 index 0000000..4e05fd9 --- /dev/null +++ b/.agents/phases/todo/115_doc_draft_discard/00_phase.md @@ -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=`. 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" +``` diff --git a/.agents/phases/todo/115_doc_draft_discard/01_delete_draft_route.md b/.agents/phases/todo/115_doc_draft_discard/01_delete_draft_route.md new file mode 100644 index 0000000..db48d2f --- /dev/null +++ b/.agents/phases/todo/115_doc_draft_discard/01_delete_draft_route.md @@ -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=`. 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. diff --git a/.agents/phases/todo/115_doc_draft_discard/02_discard_ui.md b/.agents/phases/todo/115_doc_draft_discard/02_discard_ui.md new file mode 100644 index 0000000..447f6b1 --- /dev/null +++ b/.agents/phases/todo/115_doc_draft_discard/02_discard_ui.md @@ -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 + + ``` + 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. diff --git a/.agents/phases/todo/115_doc_draft_discard/03_draft_title_fix.md b/.agents/phases/todo/115_doc_draft_discard/03_draft_title_fix.md new file mode 100644 index 0000000..e1dc075 --- /dev/null +++ b/.agents/phases/todo/115_doc_draft_discard/03_draft_title_fix.md @@ -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 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. diff --git a/.agents/phases/todo/115_doc_draft_discard/04_draft_discard_tests.md b/.agents/phases/todo/115_doc_draft_discard/04_draft_discard_tests.md new file mode 100644 index 0000000..843aa6f --- /dev/null +++ b/.agents/phases/todo/115_doc_draft_discard/04_draft_discard_tests.md @@ -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. diff --git a/.agents/phases/todo/116_modal_scrollbar_theme/00_phase.md b/.agents/phases/todo/116_modal_scrollbar_theme/00_phase.md new file mode 100644 index 0000000..0e54cec --- /dev/null +++ b/.agents/phases/todo/116_modal_scrollbar_theme/00_phase.md @@ -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)" +``` diff --git a/.agents/phases/todo/116_modal_scrollbar_theme/01_themed_scrollbar.md b/.agents/phases/todo/116_modal_scrollbar_theme/01_themed_scrollbar.md new file mode 100644 index 0000000..4e7f46d --- /dev/null +++ b/.agents/phases/todo/116_modal_scrollbar_theme/01_themed_scrollbar.md @@ -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 `
`/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`).
diff --git a/.agents/phases/todo/116_modal_scrollbar_theme/02_scrollbar_e2e_check.md b/.agents/phases/todo/116_modal_scrollbar_theme/02_scrollbar_e2e_check.md
new file mode 100644
index 0000000..0608dd8
--- /dev/null
+++ b/.agents/phases/todo/116_modal_scrollbar_theme/02_scrollbar_e2e_check.md
@@ -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.
diff --git a/TODO.md b/TODO.md
index c1b0954..4640904 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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=`. 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.