feat(rag): global tuning manager — /tuning.html + PUT /api/steering/{id}: create, edit, list, delete steering notes without a chat

This commit is contained in:
2026-08-25 13:46:32 -04:00
parent fcde1fd37b
commit 589e26dbe9
16 changed files with 1697 additions and 21 deletions
@@ -0,0 +1,46 @@
# Task 01 — Steering update endpoint
**Phase:** `27_global_tuning` · **Source:** `TODO.md:3 — "Add a way to add 'global tuning' without having a chat to reply to. Also previous tunes should be editable."`
**Story:** `.agent/user_stories/global-tuning.md`
## Objective
Add a `PUT /api/steering/{note_id}` endpoint so an existing steering note can be updated in place (the chat-page + header UI currently support create + delete only).
## Work
1. `app/schemas.py` — add `SteeringNoteUpdate(BaseModel)`:
```python
class SteeringNoteUpdate(BaseModel):
"""``PUT /api/steering/{id}`` body: a new tuning instruction."""
note: str = Field(min_length=1, max_length=2000)
@field_validator("note", mode="before")
@classmethod
def _trim_note(cls, v): return v.strip() if isinstance(v, str) else v
```
Mirror `SteeringNoteIn`'s trim-before-length-constraint behaviour so an empty/whitespace body is a 422.
2. `app/api/steering.py` — add the route:
```python
@router.put("/{note_id}", response_model=SteeringNoteOut)
def update_steering_note(note_id, payload: SteeringNoteUpdate, db):
row = db.get(SteeringNote, note_id)
if row is None:
raise HTTPException(status_code=404, detail="steering note not found")
row.note = payload.note.strip()
db.commit(); db.refresh(row)
return SteeringNoteOut(id=row.id, note=row.note, created_at=row.created_at)
```
The router already carries `dependencies=[Depends(require_admin)]` (phase 16), so anonymous callers get 403 automatically — no extra guard. Keep the existing `list`/`create`/`delete` routes unchanged.
3. Update the module docstring to name the new `PUT` route and that it reuses the router-level admin dependency (no new auth surface).
## ASSUMPTIONS
- The update reuses the same 1–2000-char, trimmed contract as create (no separate validation policy).
- `created_at` is preserved on update (editing a note doesn't redate it; the list order by newest-first is stable for edits).
- The endpoint is `PUT` (idempotent, full replacement of `note`), matching the "edit" semantics.
## Testing & Quality
- Unit (`tests/unit/test_steering.py`): `update_steering_note` via the router — 200 with updated `note`; 404 unknown id; 422 empty/whitespace/over-2000; and the router-level 403 for anonymous (via `require_admin`).
- Integration: `PUT /api/steering/{id}` → 200 returns the new note; the updated note is then read back by `load_steering_notes` (oldest-first order preserved) and appears in `build_steering_section`; anonymous → 403.
## Completion Criteria
- [ ] `PUT /api/steering/{id} {note}` → 200 with the updated note; `GET /api/steering` reflects the new text and order.
- [ ] Unknown id → 404; invalid body → 422; anonymous → 403.
- [ ] `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,52 @@
# Task 02 — Tuning page HTML + CSS
**Phase:** `27_global_tuning` · **Source:** `TODO.md:3 — "Add a way to add 'global tuning' without having a chat to reply to. Also previous tunes should be editable."`
**Story:** `.agent/user_stories/global-tuning.md`
## Objective
Create the `/tuning.html` page: a title, a "Add a global tuning note" form, and a list of existing notes each with an inline edit control and a delete control. Follow the Phase-08 tokens, WCAG 2.1 AA, and the standard app frame (landmarks, skip-link, sticky header reuse via `header.js`).
## Work
1. `frontend/tuning.html` (new) — standard app frame:
- `<header class="app-header">` reusing the same markup as `index.html`'s header (brand + nav + New Chat + Sign in/out) so `header.js` wires it identically. The page gains an extra admin-only link:
```html
<a href="/tuning.html" class="nav-link is-active" id="nav-tuning" aria-current="page">Tuning</a>
```
(placed after the Sources link; it is always present but only visible to admins because the whole nav is admin-scoped on non-chat pages — the Sources link is already admin-only, so this is consistent).
- `<main id="main" class="app-main" tabindex="-1">` with a centered `container` column:
- `<h1>Global Tuning</h1>` + a sub-heading explaining every note steers all future answers.
- A create form `#tune-form`: a visually-hidden `<label for="tune-note">` + `<textarea id="tune-note" maxlength="2000" rows="3" placeholder="e.g. be more concise — or: assume I'm on NixOS">` + a submit button `#tune-save` ("Add note").
- A status/announcer `<p class="visually-hidden" id="tune-announcer" role="status" aria-live="polite"></p>`.
- A list `<ul id="tune-list">` (role=list) for existing notes; each `<li class="tuning-note">` holds:
- a `.tuning-note-text` span (textContent, XSS-safe),
- an `#edit` button (`.tuning-edit`, icon + "Edit"),
- a `.tuning-delete` button (icon + "Delete").
- An empty-state `<p id="tune-empty">No tuning notes yet — add one above.</p>` toggled by `tuning.js`.
- `<footer class="app-footer">` as on other pages.
- Load `assets/markdown.js` (classic) and `assets/tuning.js` (module) + the shared header (module).
2. `frontend/assets/styles.css` — add a `--tuning-*` block mirroring the `.steering-*` / `.tune-*` tokens:
- `.tuning-note` — flex row, align-items center, gap; text flexes, actions shrink-0; border-bottom divider.
- `.tuning-note-text` — `--ink`; ellipsis overflow if very long.
- `.tuning-edit`, `.tuning-delete` — icon + label buttons, ≥44px targets, `:focus-visible` ring; edit uses brand colour, delete uses the error colour (`--err-ink`/`--err-line`) consistent with `.steering-delete`.
- `.tuning-note.is-editing .tuning-note-text` — hidden while editing (replaced by the inline form).
- Inline edit form (`.tuning-edit-form`) — a `<textarea class="tuning-edit-input" maxlength="2000">` pre-filled + Save/Cancel, styled like the existing `.tune-form` / `.tune-save` / `.tune-cancel`.
- `#tune-form` + `#tune-note` — styled like the existing composer/`#message-input`; `#tune-save` styled like `.tune-save`.
- `#tune-empty` — `--ink-soft`, centered, italic.
- Ensure the centered column matches the chat/sources width discipline (≥80–90% of container; not a skinny list).
- No `filter: blur`, no CDN, system font stack.
3. Verify no selector collision (grep `.tuning-note`, `#tune-form`, `.tuning-edit-*`).
## ASSUMPTIONS
- The page title is "Global Tuning"; the nav link label is "Tuning" (consistent with the chat header's "Tuning" panel).
- Editing is inline (swap the text for a textarea + Save/Cancel in the same list row) — the owner's "previous tunes should be editable" is satisfied without a separate editor page.
- The list is newest-first (same as the header panel) for consistency.
## Testing & Quality
- No unit/integration test for static CSS/HTML.
- Coverage: frontend-only; the >90% `app/` gate is unaffected.
## Completion Criteria
- [ ] `/tuning.html` renders the title, the create form, the note list, and the empty state.
- [ ] All controls are labeled, ≥44px, `:focus-visible`, contrast ≥4.5:1; landmarks + skip-link present.
- [ ] No selector collision; no CDN tags; the page loads `tuning.js` + `markdown.js`.
- [ ] The admin-only "Tuning" nav link is present in the header.
@@ -0,0 +1,38 @@
# Task 03 — Tuning page CRUD logic
**Phase:** `27_global_tuning` · **Source:** `TODO.md:3 — "Add a way to add 'global tuning' without having a chat to reply to. Also previous tunes should be editable."`
**Story:** `.agent/user_stories/global-tuning.md`
## Objective
Create `frontend/assets/tuning.js` — the single owner of the tuning page's behaviour: load notes, create, edit (inline), cancel, and delete, all announced through a polite live region.
## Work
1. `frontend/assets/tuning.js` (new, module) — mirrors the structure of `app.js`'s steering section but for the standalone page:
- Cache the elements: `#tune-form`, `#tune-note`, `#tune-save`, `#tune-list`, `#tune-empty`, `#tune-announcer`.
- `announce(msg)` — sets `#tune-announcer.textContent` (role=status, aria-live=polite).
- `loadNotes()` — `GET /api/steering`; on `r.ok` render the list, else keep the last rendered list (progressive enhancement). Populates `#tune-list` (role=list) with `<li>` rows; toggles `#tune-empty` (`hidden = notes.length > 0`); each row:
- `.tuning-note-text` span (`textContent`, XSS-safe),
- `.tuning-edit` button (icon + "Edit"),
- `.tuning-delete` button (icon + "Delete", aria-label "Delete tuning note: <note>").
- Create handler (`#tune-form` submit): `POST /api/steering {note}`; 201 → clear the textarea, announce "Tuning note added. Future answers will follow it.", reload; non-2xx → inline error under the form (kept, form not cleared) with the API detail; network error → friendly message. Disable `#tune-save` during the request.
- Edit flow (`tuning-edit` click): swap the row's `.tuning-note-text` span for an inline `.tuning-edit-form` containing a `<textarea class="tuning-edit-input" maxlength="2000">` pre-filled with the note + Save/Cancel; focus the textarea. Track the note id on the form (data attribute).
- Save-edit handler: `PUT /api/steering/{id} {note}`; 200 → replace the form with a `.tuning-saved` status (role=status) + announce "Tuning note updated."; non-2xx → keep the form, show inline error; cancel → revert to the text span.
- Delete handler (`tuning-delete` click): disable the button; `DELETE /api/steering/{id}`; 204/404 → remove the `<li>` from the DOM immediately (optimistic), announce, and if 404 reload; non-2xx → re-enable the button + announce retry.
- Keep the 1–2000-char contract on the client (maxlength on the textareas); the server re-validates.
2. Wire the shared header + whoami gate: `tuning.js` imports `{ initSharedHeader, fetchIsAdmin }` from `./header.js` and at boot awaits `initSharedHeader()` then `loadNotes()` only if `fetchIsAdmin()` is true (anonymous users see the page frame but the list stays empty / the create form 403s gracefully — consistent with the Sources page gate pattern). Actually simpler: the header already hides the "Tuning" nav link for anonymous (it's a nav link like Sources); but a direct anonymous URL should still be safe — `loadNotes()` swallows non-2xx, and the create form 403s. So the page is anonymous-safe without a hard gate.
## ASSUMPTIONS
- The edit is inline in the same list row (no separate editor page) — the owner's "editable" is satisfied.
- Optimistic delete (remove the `<li>` before the server confirms) matches the chat page's `deleteSteeringNote` UX.
- The page is anonymous-safe: the list won't render for anonymous (the create/delete calls 403), and the nav link is hidden for anonymous.
## Testing & Quality
- No unit/integration test (frontend-only).
- Coverage: frontend-only; the >90% `app/` gate is unaffected.
## Completion Criteria
- [ ] Create a note on `/tuning.html` → it appears in the list; the announcer announces the change.
- [ ] Edit a note inline → the saved text is updated in the list; the `<tuning>` prompt reflects it (verified via the integration test on the endpoint).
- [ ] Delete a note → the row is removed and the list updates.
- [ ] XSS-safe: note text rendered via `textContent`, never `innerHTML`.
- [ ] Empty state shows when there are no notes.
@@ -0,0 +1,34 @@
# Task 04 — Header "Tuning" button + E2E suite
**Phase:** `27_global_tuning` · **Source:** `TODO.md:3 — "Add a way to add 'global tuning' without having a chat to reply to. Also previous tunes should be editable."`
**Story:** `.agent/user_stories/global-tuning.md`
## Objective
Expose the tuning page via an admin-only header link and add the E2E story suite.
## Work
1. `frontend/tuning.html` header — add the admin-only "Tuning" nav link (is-active) in the `<nav class="app-nav">`, alongside the existing Sources link (also admin-only). The header markup mirrors `index.html`/`sources.html` (brand + nav + New Chat + Sign in/out) so `header.js` wires it identically. The "Tuning" link ships visible on the tuning page (it's the current page); on other pages the admin-only nav links are revealed by `header.js` whoami, consistent with Sources.
2. No change to `app.js`/`sources.html` nav needed — the tuning page is reached from its own header link. (The chat-page "Tune" button and header panel from phase 15 remain unchanged.)
3. `tests/e2e/test_global_tuning.py` (new — the story gate). Reuse the seeding harness pattern from `test_steering.py` / `test_document_viewer.py` (`_import_fixtures` / `_reset_db` / `_run_in_thread`) so the endpoint-under-test is exercised against a seeded KB. Test → mapping (Playwright Mapping Rule):
1. `test_create_note_without_chat` — log in, go to `/tuning.html`, type a note, submit; assert it appears in `#tune-list` and the announcer announced it. No chat turn was made.
2. `test_edit_note_inline` — create a note, click "Edit", change the text, Save; assert the list shows the new text and the edit form is gone.
3. `test_delete_note` — create a note, delete it; the row is removed and `#tune-empty` shows again.
4. `test_edit_note_steers_answer` — create a note via `/tuning.html`, then go to the chat, ask the QUESTION, and assert the note's marker leaks into the answer (same echo trick `test_steering.py` uses: `(tuning: <first note line>)`), proving the edited note is read into the system prompt.
5. `test_tuning_page_a11y_and_no_cdn` — landmarks, skip-link, labeled controls, ≥44px targets, `:focus-visible`; every `script[src]`/`link[href]` is same-origin or `data:`; dark theme.
6. `test_anonymous_cannot_manage` — anonymous `/tuning.html`: the list is empty, and a scripted `PUT /api/steering/{id}` returns 403 (or the create form 403s).
4. Regressions to run green in isolation: `test_steering.py` (the chat-page Tune button + panel still work — create + delete), `test_header_consistency.py` (the tuning page's header is consistent; the new nav link doesn't break the height/consistency assertions on the other pages), `test_document_viewer.py` (unrelated — modal still works), `test_smoke.py`.
5. `.agent/user_stories/global-tuning.md` — write the story file.
## ASSUMPTIONS
- The tuning page's "Tuning" nav link is admin-only (consistent with the Sources link — the catalog and tuning are admin-only).
- The chat-page "Tune" button + header panel are **not** removed (nothing regresses); the tuning page is the primary global manager, the button is a quick-add affordance.
- The edit-steers-answer test reuses the mock-LLM echo behaviour already established in `test_steering.py`.
## Testing & Quality
- E2E: `tests/e2e/test_global_tuning.py` — the story gate, green **in isolation** (prereq `podman compose up -d db`).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_global_tuning.py -v --no-cov` green in isolation.
- [ ] `test_steering.py`, `test_header_consistency.py`, `test_document_viewer.py`, `test_smoke.py` green in isolation.
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL ≥ pre-change number.
- [ ] `uv run ruff check . && uv run pyright` clean.