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.
+2 -1
View File
@@ -18,9 +18,10 @@ RUN mkdir -p /out/assets \
&& esbuild ./assets/sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/sources.js \ && esbuild ./assets/sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/sources.js \
&& esbuild ./assets/document.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/document.js \ && esbuild ./assets/document.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/document.js \
&& esbuild ./assets/login.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/login.js \ && esbuild ./assets/login.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/login.js \
&& esbuild ./assets/tuning.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/tuning.js \
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \ && esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \ && esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
&& cp ./index.html ./sources.html ./document.html ./login.html /out/ && cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html /out/
# ---------- Stage 2: python dependencies ---------- # ---------- Stage 2: python dependencies ----------
FROM docker.io/python:3.12-slim AS python FROM docker.io/python:3.12-slim AS python
+30 -5
View File
@@ -5,10 +5,13 @@ Admin-only CRUD under ``/api/steering`` (phase 16, A10 revised): notes
are owner instructions stored in Postgres (``steering_notes``) and read are owner instructions stored in Postgres (``steering_notes``) and read
into the system prompt of **every** chat turn as the ``<tuning>`` section into the system prompt of **every** chat turn as the ``<tuning>`` section
(see :func:`app.rag.prompts.build_steering_section` and (see :func:`app.rag.prompts.build_steering_section` and
:func:`app.api.chat.chat`). The whole router sits behind :func:`app.api.chat.chat`). Routes: ``GET`` (list, newest first),
:func:`app.core.auth.require_admin` — anonymous callers get 403 on every ``POST`` (create), ``PUT /{note_id}`` (update — phase 27; full
steering route (the chat turn itself reads the table in-process and replacement of ``note``, ``created_at`` preserved), ``DELETE /{note_id}``.
stays public). The whole router sits behind :func:`app.core.auth.require_admin` —
anonymous callers get 403 on every steering route (the chat turn itself
reads the table in-process and stays public); the PUT route adds no auth
surface of its own, it reuses that router-level dependency.
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,7 +25,7 @@ from app.core.auth import require_admin
from app.db import get_db from app.db import get_db
from app.models import SteeringNote from app.models import SteeringNote
from app.schemas import SteeringNote as SteeringNoteOut from app.schemas import SteeringNote as SteeringNoteOut
from app.schemas import SteeringNoteIn, SteeringNoteList from app.schemas import SteeringNoteIn, SteeringNoteList, SteeringNoteUpdate
router = APIRouter( router = APIRouter(
prefix="/steering", prefix="/steering",
@@ -68,6 +71,28 @@ def create_steering_note(
return SteeringNoteOut(id=row.id, note=row.note, created_at=row.created_at) return SteeringNoteOut(id=row.id, note=row.note, created_at=row.created_at)
@router.put("/{note_id}", response_model=SteeringNoteOut)
def update_steering_note(
note_id: uuid.UUID,
payload: SteeringNoteUpdate,
db: Session = Depends(get_db), # noqa: B008
) -> SteeringNoteOut:
"""Replace a note's text in place (phase 27); 404 when the id is unknown.
``created_at`` is preserved — editing a note does not redate it, so the
list order (newest first) and the ``<tuning>`` numbering (oldest first)
stay stable across edits. The router-level ``require_admin`` dependency
gates this route like every other steering route.
"""
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)
@router.delete("/{note_id}", status_code=204) @router.delete("/{note_id}", status_code=204)
def delete_steering_note( def delete_steering_note(
note_id: uuid.UUID, note_id: uuid.UUID,
+16
View File
@@ -126,6 +126,22 @@ class SteeringNoteIn(BaseModel):
return v.strip() if isinstance(v, str) else v return v.strip() if isinstance(v, str) else v
class SteeringNoteUpdate(BaseModel):
"""``PUT /api/steering/{id}`` body: a new tuning instruction (phase 27).
Mirrors :class:`SteeringNoteIn` — the note is trimmed *before* the
length constraints run, so an empty/whitespace body is a 422 and a
full replacement that is ≤2000 chars after the trim still passes.
"""
note: str = Field(min_length=1, max_length=2000)
@field_validator("note", mode="before")
@classmethod
def _trim_note(cls, v: object) -> object:
return v.strip() if isinstance(v, str) else v
class SteeringNote(BaseModel): class SteeringNote(BaseModel):
"""One stored steering note (API shape — ISO-8601 ``created_at``).""" """One stored steering note (API shape — ISO-8601 ``created_at``)."""
+12 -7
View File
@@ -6,17 +6,18 @@
* *
* • the Sign in / Sign out auth pair (phase 16, exactly one visible — * • the Sign in / Sign out auth pair (phase 16, exactly one visible —
* decided by /api/whoami at load); * decided by /api/whoami at load);
* • the "Sources" nav link (#nav-sources) — phase 19 UX revision * • the admin-only nav links — "Sources" (#nav-sources) and "Tuning"
* (owner permission 2026-08-23): hidden for anonymous on every page * (#nav-tuning, on the Global Tuning page, phase 27) — phase 19 UX
* that has a nav (chat, sources, login), revealed for admin. The * revision (owner permission 2026-08-23): hidden for anonymous on
* link SHIPS hidden in the HTML (anonymous-safe default — the * every page that has a nav (chat, sources, tuning, login),
* phase-16 "absent, not hidden" spirit), so no anonymous user ever * revealed for admin. The links SHIP hidden in the HTML
* sees it for a frame; * (anonymous-safe default — the phase-16 "absent, not hidden"
* spirit), so no anonymous user ever sees one for a frame;
* • the sign-out click binding (POST /api/logout → reload) — moved * • the sign-out click binding (POST /api/logout → reload) — moved
* here from app.js so there is exactly one implementation; * here from app.js so there is exactly one implementation;
* • clearChatStorage() — the phase-14 conversation key, for the * • clearChatStorage() — the phase-14 conversation key, for the
* New Chat buttons on the NON-CHAT pages (sources / document * New Chat buttons on the NON-CHAT pages (sources / document
* viewer): a new chat means going to the chat, fresh. * viewer / tuning): a new chat means going to the chat, fresh.
* *
* Every page loads this module (type="module", before its page script) * Every page loads this module (type="module", before its page script)
* and its page script calls initSharedHeader() once at boot. init… * and its page script calls initSharedHeader() once at boot. init…
@@ -63,6 +64,10 @@ export async function initSharedHeader() {
if (signOut) signOut.hidden = !admin; if (signOut) signOut.hidden = !admin;
const navSources = document.querySelector("#nav-sources"); const navSources = document.querySelector("#nav-sources");
if (navSources) navSources.hidden = !admin; if (navSources) navSources.hidden = !admin;
// Phase 27: the Global Tuning page's own nav link — admin-only, the
// same ship-hidden / reveal-for-admin contract as the Sources link.
const navTuning = document.querySelector("#nav-tuning");
if (navTuning) navTuning.hidden = !admin;
return admin; return admin;
} }
+193
View File
@@ -714,6 +714,194 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.steering-delete:disabled { opacity: 0.5; cursor: wait; } .steering-delete:disabled { opacity: 0.5; cursor: wait; }
.steering-empty { margin: 0.65rem 0 0; color: var(--ink-soft); font-size: 0.88rem; } .steering-empty { margin: 0.65rem 0 0; color: var(--ink-soft); font-size: 0.88rem; }
/* ---------- Global tuning page (phase 27) ---------- */
/* /tuning.html: create / edit / delete steering notes without a chat
conversation. Same width discipline as the chat column — a centered,
capped column on the 72rem frame; the form and the note list span its
FULL width (no skinny lists). Every interactive target is >=44px;
text pairs reuse the Phase-08 AA palette (dark ink on brand 5.2:1,
brand-ink/brand-soft 6.9:1, ok 10.6:1, err 9.1:1, ink-soft >=6.9:1).
No filter: blur, no CDN, system font stack. */
.tuning-shell {
max-width: 46rem;
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
/* Create form — the composer's surface as a vertical card: labeled
textarea (visually-hidden label; the placeholder carries the visible
hint) + the brand "Add note" button (dark ink on brand: 5.2:1 —
never white on brand, 3.7:1, fails). */
#tune-form {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.6rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.9rem 1rem 1rem;
}
#tune-form:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); }
#tune-note {
width: 100%;
font: inherit;
font-size: 0.95rem;
color: var(--ink);
background: transparent;
border: 0;
padding: 0.2rem 0.1rem;
resize: vertical;
min-height: 4.6rem;
}
#tune-note::placeholder { color: var(--ink-soft); }
#tune-save {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.4rem 1.2rem;
border: 0;
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg); /* dark ink on brand: 5.2:1 */
font: inherit;
font-weight: 700;
cursor: pointer;
}
#tune-save:hover:not(:disabled) { background: #7d88f5; }
#tune-save:disabled { opacity: 0.6; cursor: wait; }
/* Notes list — the phase-15 steering panel's language at full column
width: rows are flex (text flexes + ellipsizes, actions shrink-0)
with a bottom divider (the last row keeps none). */
.tuning-panel {
background: var(--surface);
border: 1px solid var(--brand-soft);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.9rem 1.1rem 1rem;
}
.tuning-panel-title { margin: 0; font-size: 1rem; font-weight: 700; color: var(--ink); }
.tuning-list {
list-style: none;
margin: 0.65rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
}
.tuning-note {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 0;
border-bottom: 1px solid var(--line);
}
.tuning-note:last-child { border-bottom: 0; }
/* The note text is the row's flex citizen: it takes every spare pixel
and ellipsizes (the full text is one Edit away — the inline form). */
.tuning-note-text {
color: var(--ink);
font-size: 0.9rem;
line-height: 1.45;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Row actions: icon + label, >=44px, shrink-0. Edit = brand pair on
hover (brand-ink/brand-soft 6.9:1); Delete = err pair on hover
(9.1:1), consistent with .steering-delete. */
.tuning-edit,
.tuning-delete {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 44px;
min-width: 44px;
flex: 0 0 auto;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.tuning-edit svg, .tuning-delete svg { width: 14px; height: 14px; display: block; }
.tuning-edit:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand-soft); }
.tuning-delete:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.tuning-edit:disabled, .tuning-delete:disabled { opacity: 0.5; cursor: wait; }
/* Inline edit: the row swaps to a vertical card — the text span is
hidden and replaced by the .tuning-edit-form (textarea pre-filled +
Save/Cancel, reusing the phase-15 .tune-save/.tune-cancel styles);
the row-level Edit/Delete buttons step aside for the form's own. */
.tuning-note.is-editing {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
background: #0d1120;
border: 1px solid var(--brand-soft);
border-radius: var(--radius-sm);
padding: 0.6rem 0.7rem;
}
.tuning-note.is-editing .tuning-note-text,
.tuning-note.is-editing .tuning-edit,
.tuning-note.is-editing .tuning-delete { display: none; }
.tuning-edit-form { display: flex; flex-direction: column; gap: 0.5rem; width: 100%; }
.tuning-edit-input {
font: inherit;
font-size: 0.9rem;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.5rem 0.6rem;
resize: vertical;
min-height: 2.6rem;
}
.tuning-edit-input::placeholder { color: var(--ink-soft); }
.tuning-edit-form-actions { display: flex; gap: 0.5rem; }
/* Per-action status pills (role=status / role=alert), the phase-15
pairs: ok 10.6:1, err 9.1:1. */
.tuning-saved {
background: var(--ok-bg);
color: var(--ok-ink);
border: 1px solid rgb(110 231 168 / 0.35);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
font-weight: 600;
}
.tuning-error {
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
font-weight: 600;
}
#tune-empty {
margin: 0.65rem 0 0;
color: var(--ink-soft);
font-size: 0.88rem;
font-style: italic;
text-align: center;
}
/* typing indicator */ /* typing indicator */
.typing { display: inline-flex; gap: 5px; padding: 0.9rem 1rem; } .typing { display: inline-flex; gap: 5px; padding: 0.9rem 1rem; }
.typing span { .typing span {
@@ -1416,6 +1604,11 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
} }
.steering-note { padding: 0.3rem 0.3rem 0.3rem 0.7rem; } .steering-note { padding: 0.3rem 0.3rem 0.3rem 0.7rem; }
.tune-btn { min-height: 44px; } .tune-btn { min-height: 44px; }
/* Phase 27: the tuning page squeezes like the other cards — the row
padding tightens; the icon+label actions keep their 44px floor and
the note text ellipsizes (min-width: 0). */
.tuning-note { gap: 0.4rem; padding: 0.3rem; }
.tuning-edit, .tuning-delete { padding: 0.35rem 0.5rem; }
.msg-body { max-width: 92%; } .msg-body { max-width: 92%; }
.empty-state { padding: 1.75rem 1.1rem; margin-top: 0.25rem; } .empty-state { padding: 1.75rem 1.1rem; margin-top: 0.25rem; }
.empty-state-title { font-size: 1.25rem; } .empty-state-title { font-size: 1.25rem; }
+349
View File
@@ -0,0 +1,349 @@
/* Brain of Reese — Global Tuning page (phase 27, task 03).
*
* The standalone manager for steering notes: create / list / edit /
* delete WITHOUT a chat conversation. This module is the single owner
* of the page's behaviour:
*
* • loadNotes() — GET /api/steering → the newest-first note list
* (#tune-list) + the empty state. A failed fetch (API down, or the
* anonymous direct-URL 403) keeps the LAST RENDERED list —
* progressive enhancement, never a blanked panel.
* • create — #tune-form submit → POST /api/steering. 201 clears the
* textarea, announces through the live region, and reloads the
* list; any failure keeps the form (the instruction survives) and
* shows the API detail inline under the button (role=alert).
* #tune-save is disabled while the request is out.
* • edit — a row's Edit button swaps the text for an inline
* .tuning-edit-form: a prefilled textarea (maxlength 2000) +
* Save / Cancel. Save → PUT /api/steering/{id}; 200 replaces the
* form with the .tuning-saved status (role=status) and announces;
* a failure keeps the form + an inline error; Cancel reverts to
* the text span. The note id rides on the form (data attribute).
* • delete — DELETE /api/steering/{id}. 204 removes the row
* immediately (optimistic) and announces; 404 also drops the row
* and reloads to resync; other failures re-enable the button and
* announce a retry. The empty state is re-checked on every removal.
* • announce(msg) — #tune-announcer (role=status, aria-live=polite),
* the screen-reader confirmation for create / edit / delete.
* • header boot (task 02) — initSharedHeader(): Sign in / Sign out,
* the admin-only Sources link, and this page's own admin-only
* "Tuning" nav link (#nav-tuning), all decided by the module's
* cached whoami promise (exactly one /api/whoami request per
* page); plus the non-chat New chat binding — "new chat" means
* going to the chat, fresh (clear the phase-14 conversation key,
* then navigate to "/"), the same contract as sources.js /
* document.js.
*
* Anonymous-safe (task 03): the header already hides the "Tuning" nav
* link for anonymous visitors; a DIRECT anonymous URL still gets a safe
* page — loadNotes() only runs when the cached whoami says admin (the
* Sources page gate pattern), the list stays on its empty state, and
* the create form 403s gracefully on submit (the inline error carries
* the API detail). Note text is always rendered with textContent —
* never innerHTML (XSS-safe, like app.js's steering panel).
*
* The shared header module loads through this script's own relative
* import ("./header.js") — a hoisted import evaluated before this body
* runs (single-evaluation design: no direct <script> tag; esbuild
* inlines it into the page bundle in the image build).
*/
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js";
/* ---------- page elements (tuning.html, task 02) ---------- */
const tuneForm = document.querySelector("#tune-form");
const tuneNote = document.querySelector("#tune-note");
const tuneSave = document.querySelector("#tune-save");
const tuneList = document.querySelector("#tune-list");
const tuneEmpty = document.querySelector("#tune-empty");
const tuneAnnouncer = document.querySelector("#tune-announcer");
/* The create form's inline error (role=alert) — created once, hidden
by default, and kept between attempts: a failed POST keeps the form
AND its message until the next submit. */
const createError = document.createElement("p");
createError.className = "tuning-error";
createError.setAttribute("role", "alert");
createError.hidden = true;
if (tuneForm) tuneForm.appendChild(createError);
/* Polite live region: the screen-reader confirmation for create /
edit / delete (task 03). */
function announce(message) {
if (tuneAnnouncer) tuneAnnouncer.textContent = message;
}
/* Row-action icons — inline SVG constants (aria-hidden; the buttons
carry their own labels), the same marks as app.js's steering panel. */
const EDIT_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20l1.2-4.2L16.7 4.3a2.1 2.1 0 0 1 3 3L8.2 18.8 4 20Z"/><path d="M14.7 6.3l3 3"/></svg>';
const DELETE_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
/* FastAPI error bodies: a string detail or the validation-error array
(the first entry's msg is the human line). Same extraction as app.js. */
async function apiDetail(r, fallback) {
try {
const data = await r.json();
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
return String(data.detail[0].msg);
}
if (typeof data.detail === "string" && data.detail) return data.detail;
} catch {
/* non-JSON error body */
}
return fallback;
}
/* ---------- load / render (newest first — the API's list order) ---------- */
/* GET /api/steering → render. A failed fetch (API down, or the 403 on
an anonymous direct-URL visit) keeps the last rendered list —
progressive enhancement, never a blanked panel. */
async function loadNotes() {
let r;
try {
r = await fetch("/api/steering");
} catch {
return; // API unreachable: keep the last rendered list
}
if (!r.ok) return; // e.g. anonymous 403: keep the last rendered list
let notes;
try {
notes = (await r.json()).notes || [];
} catch {
return; // corrupt body: keep the last rendered list
}
renderNotes(notes);
}
function renderNotes(notes) {
if (!tuneList) return;
tuneList.textContent = "";
for (const n of notes) tuneList.appendChild(makeNoteRow(n));
syncEmptyState(notes.length);
}
/* The empty state tracks the list's rendered rows (the HTML ships on
the "No tuning notes yet" text; it hides as soon as one row shows). */
function syncEmptyState(count) {
if (!tuneEmpty || !tuneList) return;
const rows = typeof count === "number" ? count : tuneList.children.length;
tuneEmpty.hidden = rows > 0;
}
/* One list row: the note text (textContent — XSS-safe, never
innerHTML) + the Edit and Delete buttons. */
function makeNoteRow(n) {
const li = document.createElement("li");
li.className = "tuning-note";
const text = document.createElement("span");
text.className = "tuning-note-text";
text.textContent = n.note; // rendered as text, never as HTML
li.appendChild(text);
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "tuning-edit";
editBtn.innerHTML = EDIT_ICON + "<span>Edit</span>";
editBtn.addEventListener("click", () => openEditForm(li, n));
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.className = "tuning-delete";
delBtn.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
delBtn.innerHTML = DELETE_ICON + "<span>Delete</span>";
delBtn.addEventListener("click", () => deleteNote(n.id, delBtn, li));
li.append(editBtn, delBtn);
return li;
}
/* ---------- create (POST /api/steering) ---------- */
if (tuneForm) {
tuneForm.addEventListener("submit", async (e) => {
e.preventDefault();
if (tuneSave) tuneSave.disabled = true; // one note per click
createError.hidden = true;
try {
const r = await fetch("/api/steering", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: tuneNote ? tuneNote.value : "" }),
});
if (r.ok) {
if (tuneNote) tuneNote.value = ""; // 201: the note is stored
announce("Tuning note added. Future answers will follow it.");
await loadNotes(); // the new note lands in the list, newest first
} else {
createError.textContent = await apiDetail(r, "Could not add the note — try again.");
createError.hidden = false; // form kept — the instruction survives
}
} catch {
createError.textContent = "Could not add the note — is the app reachable?";
createError.hidden = false;
} finally {
if (tuneSave) tuneSave.disabled = false;
}
});
}
/* ---------- edit (inline form → PUT /api/steering/{id}) ----------
* The row swaps to the inline form — the is-editing class does the
* visual swap (styles.css hides the text + the row buttons). One open
* form page-wide: opening a new one reverts the others. Cancel reverts
* to the text span; a failed save keeps the form + the inline error.
*/
let editSeq = 0; // unique ids for the edit forms' labeled textareas
function openEditForm(li, n) {
if (li.classList.contains("is-editing")) return; // one per row
// One open form page-wide: close any other row's first.
document.querySelectorAll(".tuning-note.is-editing").forEach((other) => {
other.classList.remove("is-editing");
other.querySelector(".tuning-edit-form")?.remove();
});
li.querySelector(".tuning-saved")?.remove(); // a stale "Saved" pill
li.classList.add("is-editing");
editSeq += 1;
const inputId = `tuning-edit-input-${editSeq}`;
const form = document.createElement("form");
form.className = "tuning-edit-form";
form.dataset.noteId = n.id; // the note id rides on the form
const label = document.createElement("label");
label.className = "visually-hidden";
label.htmlFor = inputId;
label.textContent = `Edit tuning note: ${n.note}`;
const textarea = document.createElement("textarea");
textarea.id = inputId;
textarea.className = "tuning-edit-input";
textarea.rows = 2;
textarea.maxLength = 2000; // client-side 1–2000 contract (server re-validates)
textarea.required = true;
textarea.value = n.note; // prefilled with the current text
const actions = document.createElement("div");
actions.className = "tuning-edit-form-actions";
const saveBtn = document.createElement("button");
saveBtn.type = "submit";
saveBtn.className = "tune-save";
saveBtn.textContent = "Save";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "tune-cancel";
cancelBtn.textContent = "Cancel";
actions.append(saveBtn, cancelBtn);
const error = document.createElement("p");
error.className = "tuning-error";
error.setAttribute("role", "alert");
error.hidden = true;
form.append(label, textarea, actions, error);
form.addEventListener("submit", (e) => handleEditSave(e, li, form, textarea, saveBtn, error));
cancelBtn.addEventListener("click", () => {
li.classList.remove("is-editing"); // revert to the text span
form.remove();
li.querySelector(".tuning-edit")?.focus();
});
li.insertBefore(form, li.querySelector(".tuning-edit"));
textarea.focus();
}
async function handleEditSave(e, li, form, textarea, saveBtn, error) {
e.preventDefault();
saveBtn.disabled = true;
error.hidden = true;
const id = form.dataset.noteId;
try {
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: textarea.value }),
});
if (r.ok) {
let saved = textarea.value.trim();
try {
saved = (await r.json()).note ?? saved;
} catch {
/* keep the trimmed local text */
}
const textEl = li.querySelector(".tuning-note-text");
if (textEl) textEl.textContent = saved; // the list shows the stored text
const savedPill = document.createElement("p");
savedPill.className = "tuning-saved";
savedPill.setAttribute("role", "status");
savedPill.textContent = "Saved";
form.replaceWith(savedPill);
li.classList.remove("is-editing"); // updated text + row buttons come back
announce("Tuning note updated.");
return;
}
error.textContent = await apiDetail(r, "Could not update the note — try again.");
error.hidden = false; // form kept — the edit survives the failure
saveBtn.disabled = false;
} catch {
error.textContent = "Could not update the note — is the app reachable?";
error.hidden = false;
saveBtn.disabled = false;
}
}
/* ---------- delete (DELETE /api/steering/{id}, optimistic) ----------
* The row leaves the DOM the moment the server agrees (204); a 404
* (already gone) also drops the row and reloads to resync; any other
* failure re-enables the button and says to retry. */
async function deleteNote(id, btn, li) {
btn.disabled = true;
try {
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
if (r.status === 404) {
li.remove(); // already gone on the server — drop it and resync
syncEmptyState();
announce("That note was already removed.");
await loadNotes();
return;
}
if (!r.ok) {
announce("Could not delete the note — try again.");
btn.disabled = false;
return;
}
li.remove(); // 204: the server confirmed — the row goes now
syncEmptyState();
announce("Tuning note deleted.");
} catch {
announce("Could not delete the note — is the app reachable?");
btn.disabled = false;
}
}
/* ---------- non-chat New chat + header boot (task 02) ---------- */
/* Phase 19 contract: New chat on a non-chat page means "go to the
* chat, fresh": clear the phase-14 conversation key, then land on the
* chat page — its empty state, since the conversation is gone from
* storage (the same contract as sources.js / document.js). */
const newChatBtn = document.querySelector("#new-chat-btn");
if (newChatBtn) {
newChatBtn.addEventListener("click", () => {
clearChatStorage();
window.location.href = "/";
});
}
/* Boot: the shared header FIRST (Sign in/out + the admin-only nav
links — one cached whoami), then the note list — admin data only
(the Sources page gate pattern): an anonymous visitor gets the page
frame with the empty state, and the create form 403s gracefully on
submit if one tries. */
(async () => {
await initSharedHeader(); // phase 19: whoami + Sign in/out + nav links
if (await fetchIsAdmin()) loadNotes(); // phase 27: the list is admin-only
})();
+116
View File
@@ -0,0 +1,116 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Manage the global tuning notes that steer every Brain of Reese answer.">
<title>Global Tuning · Brain of Reese</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="app-header">
<div class="container header-inner">
<span class="brand">
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
<span class="brand-text">Brain of <strong>Reese</strong></span>
</span>
<nav class="app-nav" aria-label="Primary">
<a href="/" class="nav-link">Chat</a>
<!-- Phase 19: the Sources link is admin-only (owner permission
2026-08-23) — hidden by default, header.js reveals it once
whoami says admin. The soft-gated page itself is unchanged. -->
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
<!-- Phase 27: this page's own nav link. Admin-only, exactly like
the Sources link: it SHIPS hidden (anonymous-safe default —
the phase-16 "absent, not hidden" spirit) and header.js
reveals it once whoami says admin, so no anonymous user ever
sees it for a frame. -->
<a href="/tuning.html" class="nav-link is-active" id="nav-tuning" aria-current="page" hidden>Tuning</a>
</nav>
<!-- Phase 19: the shared header controls reach the tuning page —
same markup, ids, and aria as the other pages (one consistent
bar on every page). header.js (assets/header.js) reveals
exactly one of Sign in / Sign out after whoami; the New chat
button here means "go to the chat, fresh" (tuning.js). -->
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
<span class="new-chat-label">New chat</span>
</button>
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign out
is visible; /api/whoami decides at load (header.js). Icon-only
below 640px (aria-labels keep the accessible names). -->
<a href="/login.html?next=/tuning.html" class="auth-link" id="sign-in-link" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
<span class="auth-label">Sign in</span>
</a>
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
<span class="auth-label">Sign out</span>
</button>
</div>
</header>
<main id="main" class="app-main" tabindex="-1">
<div class="container tuning-shell">
<div class="page-head">
<h1>Global Tuning</h1>
<p class="page-sub">
Every note below is read into the system prompt of
<strong>every</strong> chat turn. Add, edit, or remove them here —
no conversation required.
</p>
</div>
<!-- Phase 27: create a note without a chat. The label is
visually-hidden (the heading + placeholder carry the visible
context); the 1–2000-char contract mirrors the chat-page tune
form — the server re-validates (422). -->
<form id="tune-form">
<label class="visually-hidden" for="tune-note">Add a global tuning note</label>
<textarea
id="tune-note"
name="note"
rows="3"
maxlength="2000"
placeholder="e.g. be more concise — or: assume I'm on NixOS"
required
></textarea>
<button type="submit" id="tune-save">Add note</button>
</form>
<!-- Live announcer for create / edit / delete — tuning.js (phase 27,
task 03) owns the message text. -->
<p class="visually-hidden" id="tune-announcer" role="status" aria-live="polite"></p>
<!-- Phase 27: the note list — the phase-15 steering panel's
language, full column width. tuning.js fills it newest-first;
each row is an <li class="tuning-note"> with a
.tuning-note-text span + an Edit and a Delete button (styles:
styles.css "Global tuning page"). The empty state toggles with
the list. -->
<section class="tuning-panel" aria-labelledby="tuning-panel-title">
<h2 id="tuning-panel-title" class="tuning-panel-title">Tuning notes</h2>
<ul id="tune-list" class="tuning-list" role="list"></ul>
<p id="tune-empty">No tuning notes yet — add one above.</p>
</section>
</div>
</main>
<footer class="app-footer">
<div class="container footer-inner">
<span>Powered by Reese's self-hosted models</span>
</div>
</footer>
<!-- Phase 27: markdown.js (the classic global renderMarkdown) loads
BEFORE the module script; the shared header module loads through
tuning.js's own `import "./header.js"` — a hoisted import that is
evaluated before the page script body calls initSharedHeader() at
boot. No direct header.js <script> tag (single-evaluation design). -->
<script src="assets/markdown.js"></script>
<script type="module" src="/assets/tuning.js"></script>
</body>
</html>
+550
View File
@@ -0,0 +1,550 @@
"""Phase 27 E2E (Playwright): global tuning manager — steering notes without
a chat.
The admin manages steering notes on a dedicated page (``/tuning.html``):
create, edit inline, list, and delete — **without any chat conversation**.
Notes are stored in Postgres (``steering_notes``) and read into the system
prompt of every future turn as the ``<tuning>`` section. The mock LLM
echoes the first tuning note into its answer (`` (tuning: <first note
line>)``), so prompt injection — created OR edited on this page — is
observable in the chat UI deterministically. The page's header carries the
admin-only "Tuning" nav link (``#nav-tuning`` — is-active on this page):
like the Sources link it ships hidden and ``header.js`` reveals it once
whoami says admin, so an anonymous visitor never sees it.
Story: ``.agent/user_stories/global-tuning.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_global_tuning.py -v --no-cov
Test → story mapping (Playwright Mapping Rule):
1. ``test_create_note_without_chat``
2. ``test_edit_note_inline``
3. ``test_delete_note``
4. ``test_edit_note_steers_answer``
5. ``test_tuning_page_a11y_and_no_cdn``
6. ``test_anonymous_cannot_manage``
"""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import Page, expect
from sqlalchemy import select, text
from app.config import Settings
from app.db import SessionLocal
from app.models import SteeringNote
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
TUNING_URL = "/tuning.html"
QUESTION = "How is my Kubernetes cluster set up?"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
NOTE = "TUNE-MARKER be concise"
EDITED_NOTE = "EDIT-MARKER assume I'm on NixOS"
SEED_NOTE = "SEED-MARKER an existing note"
# ---------------------------------------------------------------------------
# KB seeding + DB reset (same harness pattern as test_steering.py /
# test_document_viewer.py)
# ---------------------------------------------------------------------------
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
"""Truncate the KB (and query log + steering notes), re-import fixtures."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
def _open_tuning(page: Page, app_url: str) -> None:
"""Form-login and land on /tuning.html, admin state fully wired.
The page's own admin-only "Tuning" nav link is the sync point: it
ships hidden and ``header.js`` reveals it once whoami says admin (the
exact Sources-link contract), so a visible ``#nav-tuning`` proves the
shared-header wiring on this page.
"""
login(page, app_url, next=TUNING_URL)
expect(page.locator("#nav-tuning")).to_be_visible(timeout=15_000)
expect(page.locator("#nav-sources")).to_be_visible()
expect(page.locator("#sign-out-btn")).to_be_visible()
expect(page.locator("#sign-in-link")).to_be_hidden()
# The link marks the current page.
expect(page.locator("#nav-tuning")).to_have_attribute("aria-current", "page")
expect(page.locator("#nav-tuning")).to_have_class(re.compile(r"\bis-active\b"))
def _create_note(page: Page, note: str) -> None:
"""Type *note* into the page's create form and submit it."""
page.fill("#tune-note", note)
page.click("#tune-save")
expect(page.locator("#tune-list .tuning-note-text")).to_have_text(note, timeout=15_000)
def _edit_note_inline(page: Page, new_note: str) -> None:
"""Open the row's inline edit form, replace the text, and Save."""
page.locator(".tuning-edit").click()
form = page.locator(".tuning-edit-form")
expect(form).to_be_visible()
form.locator(".tuning-edit-input").fill(new_note)
form.locator(".tune-save").click()
expect(page.locator("#tune-list .tuning-note-text")).to_have_text(new_note, timeout=15_000)
expect(page.locator(".tuning-edit-form")).to_have_count(0)
def _ask(page: Page, question: str) -> None:
"""Send one chat turn and wait until the grounded answer has fully landed."""
page.fill("#message-input", question)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Send")
# ---------------------------------------------------------------------------
# :focus-visible Tab walk (same pattern as test_responsive_polish.py) and
# WCAG 2.1 contrast (same helpers as test_dark_tech_theme.py)
# ---------------------------------------------------------------------------
def _tab_outline_walk(page: Page, max_tabs: int = 60) -> list[dict[str, str]]:
"""Real keyboard Tab walk; returns each focused element's outline."""
first_key: str | None = None
seen: list[dict[str, str]] = []
for _ in range(max_tabs):
page.keyboard.press("Tab")
info = page.evaluate(
"""() => {
const el = document.activeElement;
const cs = getComputedStyle(el);
const cls = String(el.className).split(" ")[0];
const label = (el.getAttribute("aria-label")
|| el.textContent || "").trim().slice(0, 24);
return {
key: el.tagName + "#" + (el.id || "") + "." + cls + ":" + label,
outline_style: cs.outlineStyle,
outline_width: cs.outlineWidth,
};
}"""
)
if info["key"].startswith("BODY"):
continue # focus has not entered the document yet
if first_key is None:
first_key = info["key"]
seen.append(info)
if len(seen) > 1 and info["key"] == first_key:
break # wrapped back to the first focusable
return seen
def _rgb(value: str) -> tuple[int, int, int]:
value = value.strip()
hex_match = re.match(r"^#([0-9a-f]{6})$", value, re.IGNORECASE)
if hex_match:
h = hex_match.group(1)
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
match = re.match(r"^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", value)
assert match, f"unparsable color: {value!r}"
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def _rel_luminance(rgb: tuple[int, int, int]) -> float:
def chan(c: int) -> float:
s = c / 255
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
r, g, b = (chan(c) for c in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast_ratio(fg: str, bg: str) -> float:
l1, l2 = _rel_luminance(_rgb(fg)), _rel_luminance(_rgb(bg))
if l1 < l2:
l1, l2 = l2, l1
return (l1 + 0.05) / (l2 + 0.05)
def _assert_aa(pair: Any, label: str) -> None:
fg, bg = str(pair[0]), str(pair[1])
ratio = contrast_ratio(fg, bg)
assert ratio >= 4.5, f"contrast {label}: {fg} on {bg} = {ratio:.2f}:1 (< 4.5:1)"
# ---------------------------------------------------------------------------
# 1. Create a note without any chat
# ---------------------------------------------------------------------------
def test_create_note_without_chat(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_port=0, seed=False)
page.set_default_timeout(30_000)
_open_tuning(page, app_url)
# Fresh page: the empty state, no notes — and no chat anywhere in
# sight (the story's point: a note can exist before any conversation).
expect(page.locator("#tune-list .tuning-note")).to_have_count(0)
expect(page.locator("#tune-empty")).to_be_visible()
assert page.locator(".msg").count() == 0, "the tuning page carries no chat"
# Create: type + submit. The row lands in the list and the empty
# state steps aside…
_create_note(page, NOTE)
expect(page.locator("#tune-list .tuning-note")).to_have_count(1)
expect(page.locator("#tune-empty")).to_be_hidden()
# …the live region announces it, and the form is cleared (201).
expect(page.locator("#tune-announcer")).to_contain_text("Tuning note added.")
expect(page.locator("#tune-note")).to_have_value("")
# Persisted in Postgres — with zero chat turns in this whole test.
with SessionLocal() as db:
rows = db.scalars(select(SteeringNote)).all()
assert [r.note for r in rows] == [NOTE]
# ---------------------------------------------------------------------------
# 2. Edit a note inline (Cancel reverts, Save persists)
# ---------------------------------------------------------------------------
def test_edit_note_inline(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_port=0, seed=False)
page.set_default_timeout(30_000)
_open_tuning(page, app_url)
_create_note(page, NOTE)
# Open the inline edit form: the row swaps to a prefilled textarea
# with a (visually-hidden) label, while the row's text span and its
# Edit/Delete buttons step aside.
page.locator(".tuning-edit").click()
form = page.locator(".tuning-edit-form")
expect(form).to_be_visible()
edit_input = form.locator(".tuning-edit-input")
expect(edit_input).to_have_value(NOTE)
expect(form.locator("label.visually-hidden")).to_have_count(1)
expect(page.locator(".tuning-note-text")).to_be_hidden()
expect(page.locator(".tuning-note > .tuning-edit")).to_be_hidden()
expect(page.locator(".tuning-note > .tuning-delete")).to_be_hidden()
# Cancel reverts to the text span — the note is untouched.
form.locator(".tune-cancel").click()
expect(page.locator(".tuning-edit-form")).to_have_count(0)
expect(page.locator("#tune-list .tuning-note-text")).to_have_text(NOTE)
# Now for real: open, change the text, Save.
page.locator(".tuning-edit").click()
form = page.locator(".tuning-edit-form")
expect(form).to_be_visible()
form.locator(".tuning-edit-input").fill(EDITED_NOTE)
form.locator(".tune-save").click()
# The list shows the NEW text, the edit form is gone, and a "Saved"
# pill + the live region confirm the update.
expect(page.locator("#tune-list .tuning-note-text")).to_have_text(
EDITED_NOTE, timeout=15_000
)
expect(page.locator(".tuning-edit-form")).to_have_count(0)
expect(page.locator(".tuning-note-text")).to_be_visible()
expect(page.locator(".tuning-saved")).to_contain_text("Saved")
expect(page.locator("#tune-announcer")).to_contain_text("Tuning note updated.")
# Postgres: still exactly one note, with the updated text (edited in
# place — no duplicate row).
with SessionLocal() as db:
rows = db.scalars(select(SteeringNote)).all()
assert [r.note for r in rows] == [EDITED_NOTE]
# ---------------------------------------------------------------------------
# 3. Delete a note → row removed, empty state back
# ---------------------------------------------------------------------------
def test_delete_note(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_port=0, seed=False)
page.set_default_timeout(30_000)
_open_tuning(page, app_url)
_create_note(page, NOTE)
expect(page.locator("#tune-empty")).to_be_hidden()
# Delete: the row leaves the DOM and the empty state comes back.
page.locator(".tuning-delete").click()
expect(page.locator("#tune-list .tuning-note")).to_have_count(0, timeout=15_000)
expect(page.locator("#tune-empty")).to_be_visible()
expect(page.locator("#tune-announcer")).to_contain_text("Tuning note deleted.")
# Gone from Postgres.
with SessionLocal() as db:
assert db.scalars(select(SteeringNote)).all() == []
# ---------------------------------------------------------------------------
# 4. A note created + edited on /tuning.html steers the next chat answer
# ---------------------------------------------------------------------------
def test_edit_note_steers_answer(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
summary = _reset_db(mock_llm, seed=True)
assert summary is not None and summary.added == 8 # A9 formats
page.set_default_timeout(30_000)
_open_tuning(page, app_url)
# Create the note without a chat…
_create_note(page, NOTE)
# …and edit it to the final wording (inline — still no chat).
_edit_note_inline(page, EDITED_NOTE)
expect(page.locator("#tune-announcer")).to_contain_text("Tuning note updated.")
# Now to the chat via the header's "Chat" nav link (a real navigation):
# the next answer must carry the EDITED note. The mock echoes the
# first <tuning> line, so the marker proves the edited note reached
# the system prompt.
page.click('a.nav-link[href="/"]')
expect(page.locator("#message-input")).to_be_visible(timeout=15_000)
_ask(page, QUESTION)
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER)
expect(bubble).to_contain_text(f"(tuning: {EDITED_NOTE})")
# ---------------------------------------------------------------------------
# 5. Page accessibility + no-CDN
# ---------------------------------------------------------------------------
def test_tuning_page_a11y_and_no_cdn(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_port=0, seed=False)
# One note in the DB before load so the page renders a real row (the
# row's Edit/Delete buttons are JS-built — they need a note).
with SessionLocal() as db:
db.add(SteeringNote(note=SEED_NOTE))
db.commit()
page.set_default_timeout(30_000)
_open_tuning(page, app_url)
expect(page.locator("#tune-list .tuning-note")).to_have_count(1)
page.wait_for_load_state("networkidle")
# Landmarks (PLAN §7.2).
assert page.locator("header.app-header").count() == 1, "header missing"
assert page.locator("nav[aria-label]").count() == 1, "labeled nav missing"
assert page.locator("main#main").count() == 1, "main#main missing"
assert page.locator("footer.app-footer").count() == 1, "footer missing"
# Skip link: present, first Tab lands on it, Enter moves focus to #main.
skip = page.locator('a.skip-link[href="#main"]')
assert skip.count() == 1, "skip link missing"
page.keyboard.press("Tab")
assert page.evaluate("() => document.activeElement.className") == "skip-link", (
"first Tab must land on the skip link"
)
page.keyboard.press("Enter")
assert page.evaluate("() => document.activeElement.id") == "main", (
"skip link must move focus to #main"
)
# Labeled controls: the create textarea has its (visually-hidden)
# label, every button has an accessible name, and the per-row Delete
# is labeled with the note text (Edit carries visible text).
expect(page.get_by_label("Add a global tuning note")).to_have_count(1)
unnamed = page.evaluate(
"""() => [...document.querySelectorAll("button")]
.filter((b) => !(b.getAttribute("aria-label") || b.textContent.trim()))
.length"""
)
assert unnamed == 0, f"{unnamed} button(s) without an accessible name"
delete_btn = page.locator("#tune-list .tuning-delete")
assert (delete_btn.get_attribute("aria-label") or "").startswith(
"Delete tuning note:"
), "row Delete must be labeled with the note text"
expect(page.locator("#tune-list .tuning-edit")).to_have_text("Edit")
# The live region that announces create / edit / delete.
announcer = page.locator("#tune-announcer")
assert announcer.get_attribute("role") == "status"
assert announcer.get_attribute("aria-live") == "polite"
# ≥44px touch targets on every interactive control.
for selector in (
"#tune-save",
"#tune-note",
"#nav-tuning",
".new-chat-btn",
"#sign-out-btn",
".tuning-edit",
".tuning-delete",
):
box = page.locator(selector).first.bounding_box()
assert box is not None and box["height"] >= 44, f"target too small: {selector} {box}"
# :focus-visible — a real keyboard Tab walk: every focused element
# shows a visible outline (solid, ≥2px; the design uses 3px).
page.evaluate(
"() => { if (document.activeElement instanceof HTMLElement)"
" document.activeElement.blur(); }"
)
seen = _tab_outline_walk(page)
assert len(seen) >= 6, f"expected several focusables, tabbed {len(seen)}"
for info in seen:
width_px = float(info["outline_width"].replace("px", ""))
assert info["outline_style"] == "solid" and width_px >= 2, (
f"no visible focus outline on {info['key']} "
f"({info['outline_style']} {info['outline_width']})"
)
# Dark theme: the page canvas is the phase-08 dark bg (body stays
# transparent for the background layers).
bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor")
assert bg == "rgb(10, 14, 23)", f"expected the dark page bg, got {bg}"
# Contrast: the live text/background pairs compute ≥ 4.5:1 (AA).
pairs = page.evaluate(
"""() => {
const cs = (sel, prop) => getComputedStyle(document.querySelector(sel))[prop];
return {
save_btn: [cs("#tune-save", "color"), cs("#tune-save", "backgroundColor")],
active_nav: [
cs(".nav-link.is-active", "color"),
cs(".nav-link.is-active", "backgroundColor"),
],
note_text: [
cs(".tuning-note-text", "color"),
cs(".tuning-panel", "backgroundColor"),
],
empty_state: [
cs("#tune-empty", "color"),
cs(".tuning-panel", "backgroundColor"),
],
};
}"""
)
_assert_aa(pairs["save_btn"], "dark ink on brand (Add note)")
_assert_aa(pairs["active_nav"], "dark ink on brand (active nav)")
_assert_aa(pairs["note_text"], "ink on surface (note text)")
_assert_aa(pairs["empty_state"], "ink-soft on surface (empty state)")
# No CDN: every script/link reference is same-origin or a data: URI.
refs = page.evaluate(
"""() => [...document.querySelectorAll("script[src], link[href]")]
.map((el) => el.src || el.href)"""
)
assert refs, "expected local asset references"
for ref in refs:
assert ref.startswith(app_url) or ref.startswith("data:"), (
f"non-local asset reference: {ref}"
)
# ---------------------------------------------------------------------------
# 6. Anonymous: no list, no PUT, no create
# ---------------------------------------------------------------------------
def test_anonymous_cannot_manage(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_port=0, seed=False)
# A note exists in the DB (the admin saved it at some point) — an
# anonymous visitor must never see it or touch it.
note = SteeringNote(note=SEED_NOTE)
with SessionLocal() as db:
db.add(note)
db.commit()
db.refresh(note)
note_id = note.id
page.set_default_timeout(30_000)
page.goto(app_url + TUNING_URL)
# Anonymous header state: Sign in visible, and the admin-only nav
# links — Sources AND this page's own Tuning link — stay hidden.
expect(page.locator("#sign-in-link")).to_be_visible()
expect(page.locator("#sign-out-btn")).to_be_hidden()
expect(page.locator("#nav-sources")).to_be_hidden()
expect(page.locator("#nav-tuning")).to_be_hidden()
# The list stays on its empty state even though a note exists…
expect(page.locator("#tune-list .tuning-note")).to_have_count(0)
expect(page.locator("#tune-empty")).to_be_visible()
# …and the API is gated: GET and a PUT on a REAL note id are 403.
get_status = page.evaluate("async () => (await fetch('/api/steering')).status")
assert get_status == 403, f"anonymous GET /api/steering → {get_status}"
put_status = page.evaluate(
"""async (id) => (await fetch('/api/steering/' + id, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({note: 'anonymous overwrite attempt'}),
})).status""",
str(note_id),
)
assert put_status == 403, f"anonymous PUT /api/steering/{note_id} → {put_status}"
with SessionLocal() as db:
row = db.get(SteeringNote, note_id)
assert row is not None and row.note == SEED_NOTE, "the note must stay untouched"
# The create form 403s gracefully: an inline error (role=alert) with
# the API detail, and the typed instruction survives in the textarea.
page.fill("#tune-note", "an anonymous attempt")
page.click("#tune-save")
error = page.locator("#tune-form .tuning-error")
expect(error).to_have_attribute("role", "alert")
expect(error).to_be_visible(timeout=15_000)
assert "admin only" in (error.inner_text() or "").lower()
expect(page.locator("#tune-note")).to_have_value("an anonymous attempt")
# Nothing was created: the DB still holds only the seeded note.
with SessionLocal() as db:
assert [r.note for r in db.scalars(select(SteeringNote)).all()] == [SEED_NOTE]
+3
View File
@@ -57,6 +57,7 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
("/sources.html", "Knowledge base"), ("/sources.html", "Knowledge base"),
("/document.html", "Brain of Reese"), # phase 10: viewer page ("/document.html", "Brain of Reese"), # phase 10: viewer page
("/login.html", "Sign in"), # phase 16: admin sign-in page ("/login.html", "Sign in"), # phase 16: admin sign-in page
("/tuning.html", "Global Tuning"), # phase 27: global tuning page
], ],
) )
def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None: def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
@@ -78,6 +79,7 @@ def test_styles_and_js_served(client) -> None:
assert client.get("/assets/document.js").status_code == 200 # phase 10: viewer page assert client.get("/assets/document.js").status_code == 200 # phase 10: viewer page
assert client.get("/assets/login.js").status_code == 200 # phase 16: login page assert client.get("/assets/login.js").status_code == 200 # phase 16: login page
assert client.get("/assets/document-modal.js").status_code == 200 # phase 26: modal module assert client.get("/assets/document-modal.js").status_code == 200 # phase 26: modal module
assert client.get("/assets/tuning.js").status_code == 200 # phase 27: tuning page
# Emoji code points banned from UI chrome (phase 08): the pictograph # Emoji code points banned from UI chrome (phase 08): the pictograph
@@ -108,6 +110,7 @@ def _find_emoji(text: str) -> list[str]:
"/sources.html", "/sources.html",
"/document.html", "/document.html",
"/login.html", # phase 16 "/login.html", # phase 16
"/tuning.html", # phase 27
"/assets/app.js", "/assets/app.js",
"/assets/sources.js", "/assets/sources.js",
"/assets/markdown.js", "/assets/markdown.js",
@@ -22,9 +22,11 @@ from sqlalchemy import select, text
from test_chat_api import FakeRagLLM, _stream_chat from test_chat_api import FakeRagLLM, _stream_chat
from app.api import chat as chat_api from app.api import chat as chat_api
from app.api.steering import load_steering_notes
from app.main import app as fastapi_app from app.main import app as fastapi_app
from app.models import SteeringNote from app.models import SteeringNote
from app.rag.importer import import_sources from app.rag.importer import import_sources
from app.rag.prompts import build_steering_section
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "docs" FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "docs"
QUESTION = "How is my Kubernetes cluster set up?" QUESTION = "How is my Kubernetes cluster set up?"
@@ -131,6 +133,92 @@ def test_create_enforces_2000_char_limit(admin_client: TestClient) -> None:
assert len(r.json()["note"]) == 2000 assert len(r.json()["note"]) == 2000
# ---------- update (PUT) — phase 27: edit a note in place ----------
def test_update_note_returns_200_and_replaces_text(admin_client: TestClient, db) -> None:
created = admin_client.post("/api/steering", json={"note": NOTE}).json()
r = admin_client.put(f"/api/steering/{created['id']}", json={"note": f" {NOTE}-v2 "})
assert r.status_code == 200
body = r.json()
assert body["id"] == created["id"]
assert body["note"] == f"{NOTE}-v2" # trimmed, full replacement
# Editing does not redate the note.
assert datetime.fromisoformat(body["created_at"]) == datetime.fromisoformat(
created["created_at"]
)
row = db.get(SteeringNote, uuid.UUID(created["id"]))
assert row is not None
assert row.note == f"{NOTE}-v2"
def test_update_is_reflected_in_list_load_and_prompt(admin_client: TestClient, db) -> None:
base = datetime.now(UTC)
db.add_all(
[
SteeringNote(note="oldest note", created_at=base),
SteeringNote(note="newest note", created_at=base + timedelta(hours=1)),
]
)
db.commit()
oldest = db.scalars(select(SteeringNote).order_by(SteeringNote.created_at.asc())).first()
assert oldest is not None
updated = "oldest note — updated"
assert admin_client.put(f"/api/steering/{oldest.id}", json={"note": updated}).status_code == 200
# The GET list keeps its newest-first order and carries the new text.
body = admin_client.get("/api/steering").json()
assert [n["note"] for n in body["notes"]] == ["newest note", updated]
# The chat path reads the updated note back, oldest first… (expire the
# test session's identity map so the fresh DB values are loaded).
db.expire_all()
notes = load_steering_notes(db)
assert notes == [updated, "newest note"]
# …and it appears, in order, in the <tuning> prompt section.
section = build_steering_section(notes)
assert f"1. {updated}" in section
assert "2. newest note" in section
assert section.index("1. ") < section.index("2. ")
def test_update_unknown_note_returns_404(admin_client: TestClient) -> None:
r = admin_client.put(f"/api/steering/{uuid.uuid4()}", json={"note": "x"})
assert r.status_code == 404
assert r.json() == {"detail": "steering note not found"}
def test_update_rejects_invalid_bodies(admin_client: TestClient) -> None:
created = admin_client.post("/api/steering", json={"note": NOTE}).json()
note_id = created["id"]
assert admin_client.put(f"/api/steering/{note_id}", json={"note": ""}).status_code == 422
assert admin_client.put(f"/api/steering/{note_id}", json={"note": " "}).status_code == 422
assert (
admin_client.put(f"/api/steering/{note_id}", json={"note": "x" * 2001}).status_code == 422
)
# The 2000-char boundary passes and is stored as-is.
r = admin_client.put(f"/api/steering/{note_id}", json={"note": "y" * 2000})
assert r.status_code == 200
assert len(r.json()["note"]) == 2000
def test_update_anonymous_returns_403(admin_client: TestClient, db) -> None:
created = admin_client.post("/api/steering", json={"note": NOTE}).json()
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
r = anon.put(f"/api/steering/{created['id']}", json={"note": "anonymous edit"})
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
# The text is untouched, in both the API and the chat path.
body = admin_client.get("/api/steering").json()
assert [n["note"] for n in body["notes"]] == [NOTE]
assert load_steering_notes(db) == [NOTE]
# ---------- chat turn: note reaches the system prompt ---------- # ---------- chat turn: note reaches the system prompt ----------
+29 -8
View File
@@ -20,12 +20,14 @@ APP_JS = ASSETS / "app.js"
SOURCES_JS = ASSETS / "sources.js" SOURCES_JS = ASSETS / "sources.js"
DOCUMENT_JS = ASSETS / "document.js" DOCUMENT_JS = ASSETS / "document.js"
LOGIN_JS = ASSETS / "login.js" LOGIN_JS = ASSETS / "login.js"
TUNING_JS = ASSETS / "tuning.js"
STYLES_CSS = ASSETS / "styles.css" STYLES_CSS = ASSETS / "styles.css"
INDEX_HTML = FRONTEND / "index.html" INDEX_HTML = FRONTEND / "index.html"
SOURCES_HTML = FRONTEND / "sources.html" SOURCES_HTML = FRONTEND / "sources.html"
DOCUMENT_HTML = FRONTEND / "document.html" DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html" LOGIN_HTML = FRONTEND / "login.html"
TUNING_HTML = FRONTEND / "tuning.html"
def _text(path: Path) -> str: def _text(path: Path) -> str:
@@ -74,7 +76,7 @@ def test_init_shared_header_toggles_only_elements_that_exist() -> None:
assert fn != -1 assert fn != -1
body = js[fn : js.find("\n}", fn)] body = js[fn : js.find("\n}", fn)]
assert "await fetchIsAdmin()" in body assert "await fetchIsAdmin()" in body
for selector in ('#sign-in-link', '#sign-out-btn', '#nav-sources'): for selector in ("#sign-in-link", "#sign-out-btn", "#nav-sources", "#nav-tuning"):
assert f'querySelector("{selector}")' in body assert f'querySelector("{selector}")' in body
assert "return admin" in body, "callers may reuse the flag" assert "return admin" in body, "callers may reuse the flag"
@@ -111,13 +113,32 @@ def test_nav_sources_ships_hidden_on_every_nav_page() -> None:
nav link is hidden for anonymous — so it SHIPS with the hidden nav link is hidden for anonymous — so it SHIPS with the hidden
attribute (anonymous-safe default) on every page that has a nav attribute (anonymous-safe default) on every page that has a nav
(chat, sources, login).""" (chat, sources, login)."""
for html in (INDEX_HTML, SOURCES_HTML, LOGIN_HTML): for html in (INDEX_HTML, SOURCES_HTML, LOGIN_HTML, TUNING_HTML):
text = _text(html) text = _text(html)
assert re.search(r'id="nav-sources"[^>]*\bhidden\b', text), ( assert re.search(r'id="nav-sources"[^>]*\bhidden\b', text), (
f"{html.name}: #nav-sources must ship hidden" f"{html.name}: #nav-sources must ship hidden"
) )
def test_nav_tuning_ships_hidden_on_the_tuning_page() -> None:
"""Phase 27: the Global Tuning page reuses the shared header — the
"Tuning" nav link is admin-only, so it SHIPS hidden (revealed by
initSharedHeader once whoami says admin), is the page's active link
(is-active + aria-current), and the page loads markdown.js (classic)
+ the tuning.js module with NO direct header.js <script> tag
(single-evaluation design)."""
text = _text(TUNING_HTML)
tag = re.search(r'<a[^>]*id="nav-tuning"[^>]*>', text)
assert tag, "tuning.html must carry the #nav-tuning nav link"
assert 'class="nav-link is-active"' in tag.group(0), "the Tuning link is the active one"
assert 'aria-current="page"' in tag.group(0)
assert "hidden" in tag.group(0), "#nav-tuning must ship hidden (admin-only)"
srcs = _script_srcs(TUNING_HTML)
assert [s for s in srcs if "header.js" in s] == [], "no direct header.js <script> tag"
assert [s for s in srcs if "markdown.js" in s]
assert [s for s in srcs if "tuning.js" in s]
def test_nav_sources_is_absent_from_the_viewer() -> None: def test_nav_sources_is_absent_from_the_viewer() -> None:
"""The document viewer has no nav — no #nav-sources element there (the """The document viewer has no nav — no #nav-sources element there (the
module's missing-element no-op keeps it out).""" module's missing-element no-op keeps it out)."""
@@ -229,12 +250,12 @@ def test_login_js_uses_the_shared_fetch_is_admin() -> None:
def test_non_chat_pages_bind_new_chat_to_the_chat_page() -> None: def test_non_chat_pages_bind_new_chat_to_the_chat_page() -> None:
"""On sources and the viewer, New Chat means "go to the chat, """On sources, the viewer, and the tuning page, New Chat means "go to
fresh": the binding clears the phase-14 key (clearChatStorage) and the chat, fresh": the binding clears the phase-14 key
navigates to "/" — and both pages run initSharedHeader() at boot (clearChatStorage) and navigates to "/" — and each page runs
on the shared cached whoami. Phase 23: the import is relative initSharedHeader() at boot on the shared cached whoami. Phase 23:
(`./header.js`).""" the import is relative (`./header.js`)."""
for js_file in (SOURCES_JS, DOCUMENT_JS): for js_file in (SOURCES_JS, DOCUMENT_JS, TUNING_JS):
js = _text(js_file) js = _text(js_file)
assert 'from "./header.js"' in js assert 'from "./header.js"' in js
assert "initSharedHeader()" in js assert "initSharedHeader()" in js
+139
View File
@@ -0,0 +1,139 @@
"""Unit: steering notes PUT endpoint (phase 27 — Global Tuning).
``PUT /api/steering/{note_id}`` is driven **via the router** with a
stubbed session (FastAPI dependency override — no Postgres required):
200 with the updated note (trimmed, ``created_at`` preserved), 404 for an
unknown id, 422 for empty/whitespace/over-2000 bodies, and the
router-level 403 for anonymous callers (the real phase-16
``require_admin`` gate — no auth surface added by the PUT route itself).
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from fastapi.testclient import TestClient
from app.config import get_settings
from app.db import get_db
from app.main import create_app
from app.models import SteeringNote
CREATED_AT = datetime(2026, 8, 24, 12, 0, 0, tzinfo=UTC)
class _FakeSession:
"""Just enough of a SQLAlchemy session for the PUT route."""
def __init__(self, rows: dict[uuid.UUID, SteeringNote]) -> None:
self.rows = rows
self.commits = 0
def get(self, _model: object, pk: object) -> SteeringNote | None:
if isinstance(pk, uuid.UUID):
return self.rows.get(pk)
return None
def commit(self) -> None:
self.commits += 1
def refresh(self, _row: object) -> None:
pass
def _note() -> SteeringNote:
return SteeringNote(id=uuid.uuid4(), note="be concise", created_at=CREATED_AT)
def _put_client(rows: dict[uuid.UUID, SteeringNote]) -> tuple[TestClient, _FakeSession]:
"""A fresh app whose ``get_db`` is a stub holding ``rows``."""
session = _FakeSession(rows)
app = create_app()
app.dependency_overrides[get_db] = lambda: session
return TestClient(app), session
def _sign_in(client: TestClient) -> None:
"""Real login route (no DB): sets the signed admin session cookie."""
r = client.post("/api/login", json={"password": get_settings().admin_password})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
# ---------- 200: update in place ----------
def test_update_note_returns_200_with_new_text() -> None:
note = _note()
client, session = _put_client({note.id: note})
_sign_in(client)
r = client.put(f"/api/steering/{note.id}", json={"note": " be MORE concise "})
assert r.status_code == 200
body = r.json()
assert body["note"] == "be MORE concise" # trimmed before storage
assert body["id"] == str(note.id)
assert datetime.fromisoformat(body["created_at"]) == CREATED_AT # preserved, not redated
assert note.note == "be MORE concise" # updated in place
assert session.commits == 1
def test_update_accepts_full_length_note() -> None:
note = _note()
client, _session = _put_client({note.id: note})
_sign_in(client)
r = client.put(f"/api/steering/{note.id}", json={"note": "x" * 2000})
assert r.status_code == 200
assert len(r.json()["note"]) == 2000
# ---------- 404 / 422 ----------
def test_update_unknown_id_returns_404() -> None:
note = _note()
client, _session = _put_client({note.id: note})
_sign_in(client)
r = client.put(f"/api/steering/{uuid.uuid4()}", json={"note": "anything"})
assert r.status_code == 404
assert r.json() == {"detail": "steering note not found"}
assert note.note == "be concise" # untouched
def test_update_invalid_id_returns_422() -> None:
note = _note()
client, _session = _put_client({note.id: note})
_sign_in(client)
assert client.put("/api/steering/not-a-uuid", json={"note": "x"}).status_code == 422
def test_update_rejects_empty_blank_and_overlong_bodies() -> None:
note = _note()
client, session = _put_client({note.id: note})
_sign_in(client)
assert client.put(f"/api/steering/{note.id}", json={"note": ""}).status_code == 422
assert client.put(f"/api/steering/{note.id}", json={"note": " \t\n "}).status_code == 422
assert client.put(f"/api/steering/{note.id}", json={"note": "x" * 2001}).status_code == 422
assert note.note == "be concise" # rejected bodies never touch the row
assert session.commits == 0
# ---------- 403: router-level require_admin (phase 16) ----------
def test_update_anonymous_returns_403_admin_only() -> None:
note = _note()
client, session = _put_client({note.id: note})
r = client.put(f"/api/steering/{note.id}", json={"note": "sneaky"})
assert r.status_code == 403
assert r.json() == {"detail": "admin only"} # the require_admin message
assert note.note == "be concise" # anonymous callers never mutate
assert session.commits == 0