chore(agent): track .agent/ planning tree in git
Remove the blanket .agent/ gitignore so the phase roadmap, user stories, reports, and PLAN.md are versioned with the code. Only runtime artifacts (.agent/phase-sessions/, .agent/pipeline.log) remain ignored. Update AGENTS.md git protocol rule to match.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
# Story: Admin Sign-In (Single-Admin Auth)
|
||||
|
||||
**Phase:** `16_admin_auth.md` · **E2E:** `tests/e2e/test_admin_auth.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the one admin (the owner)**, I want to **sign in with a password** so
|
||||
that I — and only I — can **tune how Brain answers** (Phase 15 steering
|
||||
notes) and **browse the entire Sources catalog**, while **anyone else stays
|
||||
anonymous** and can still **chat** and **open the documents the chat cites**.
|
||||
|
||||
- **Given** I am not signed in
|
||||
- **When** I open the app
|
||||
- **Then** chat, suggestion chips, and the document viewer all work, but the
|
||||
tuning UI is gone (no Tune button, no Tuning panel) and the Sources page
|
||||
shows a "sign in to view" state; a **Sign in** link is in the header.
|
||||
- **When** I sign in with the admin password
|
||||
- **Then** the tuning UI and the full Sources page are unlocked, and the
|
||||
header offers **Sign out**.
|
||||
- Anonymous users may open **any indexed document by direct URL** (soft
|
||||
rule — the catalog is what is gated, not the viewer).
|
||||
|
||||
## Acceptance criteria
|
||||
1. **Config (fail-loud):** `BOR_ADMIN_PASSWORD` (plaintext, `.env`) and
|
||||
`BOR_SESSION_SECRET` (random hex) — if either is unset the app **refuses
|
||||
to start** with a message naming the missing variable(s).
|
||||
2. `POST /api/login {password}` → **204** + signed session cookie
|
||||
(Starlette `SessionMiddleware`, `same_site=lax`, 12h default max age);
|
||||
wrong password → **401** `{"detail": "invalid password"}` (constant-time
|
||||
compare, one generic message — no user enumeration).
|
||||
3. `POST /api/logout` → **204** (session cleared, idempotent);
|
||||
`GET /api/whoami` → `{"authenticated": bool, "role": "admin"|"anonymous"}`.
|
||||
4. **Admin-only:** `GET /api/docs` and `GET`/`POST`/`DELETE` `/api/steering`
|
||||
→ **403** for anonymous. **Public (unchanged):** `/api/chat`,
|
||||
`/api/documents/content`, `/api/suggestions`, `/api/health`, all static
|
||||
pages.
|
||||
5. **Login page** `/login.html` (no CDN, Phase-08 tokens, WCAG 2.1 AA):
|
||||
app-frame header, labeled password field, submit ≥44px, `role=alert`
|
||||
error region; on success redirects to `?next` (same-origin only, default
|
||||
`/sources.html`); an already-signed-in visit redirects immediately.
|
||||
6. **Chat page:** header shows **Sign in** when anonymous (→
|
||||
`/login.html?next=/sources.html`) or **Sign out** when admin (logout →
|
||||
reload); when anonymous the Tune button (including on localStorage
|
||||
restored messages, Phase 14) and the Tuning panel/toggle are completely
|
||||
hidden; the chat flow itself is unchanged.
|
||||
7. **Sources page:** anonymous sees `#sources-gate` ("sign in to view the
|
||||
full catalog" + sign-in link) with stat cards and `#docs-table` hidden and
|
||||
no `/api/docs` call; admin sees the current full Sources page.
|
||||
8. **Document viewer:** unchanged — anonymous can open any indexed document
|
||||
by direct URL.
|
||||
9. Non-goals: no rate limiting / account lockout, no HTTPS enforcement
|
||||
(homelab HTTP), no multi-user, no per-user history, no schema change.
|
||||
10. Unit + integration green, `app/` coverage >90%, story E2E green in
|
||||
isolation, regression suites adapted + green in isolation, one
|
||||
`--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_admin_auth.py`** (the E2E app server is
|
||||
started with `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` set in
|
||||
`tests/e2e/conftest.py`; the shared `tests/e2e/auth_helpers.py::login`
|
||||
performs the real form login):
|
||||
1. `test_anonymous_chat_without_tuning` — question → streamed answer renders;
|
||||
no `.tune-btn` under the brain bubble; `#steering-toggle` absent;
|
||||
`#sign-in-link` visible, `#sign-out-btn` not.
|
||||
2. `test_anonymous_sources_gated_viewer_open` — `/sources.html`:
|
||||
`#sources-gate` visible, `#docs-table` hidden; then
|
||||
`/document.html?source=…&path=…` (seeded fixture doc) renders its content
|
||||
while anonymous.
|
||||
3. `test_login_wrong_password_shows_error` — wrong password → `#login-error`
|
||||
(role=alert) visible, no redirect, still anonymous.
|
||||
4. `test_admin_login_unlocks_sources_and_tuning` — correct password →
|
||||
redirect to `/sources.html` with stats + table visible; chat page shows
|
||||
`#steering-toggle` (count badge) and `.tune-btn` under the answer;
|
||||
`#sign-out-btn` visible, `#sign-in-link` not.
|
||||
5. `test_logout_returns_to_anonymous` — Sign out → tuning hidden,
|
||||
`#sign-in-link` back, `/sources.html` gated again.
|
||||
6. `test_login_page_a11y` — password field labeled, error announced via
|
||||
role=alert, targets ≥44px, focus-visible; signed-in visit to
|
||||
`/login.html?next=/` redirects immediately.
|
||||
|
||||
**Regression adaptations (this phase makes their preconditions
|
||||
admin-only):** `test_steering.py` (log in before tuning),
|
||||
`test_import_documents.py` + `test_document_back_navigation.py` (log in for
|
||||
Sources-table assertions), `test_header_consistency.py` (header gains the
|
||||
auth link — extend presence assertions; heights unchanged).
|
||||
@@ -0,0 +1,65 @@
|
||||
# Story: Agent document tools (list + read)
|
||||
|
||||
**Phase:** `37_agent_document_tools` · **E2E:** `tests/e2e/test_agent_document_tools.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, when my question depends on content the retriever did not put
|
||||
in context — e.g. a document that *references* another file ("the exact JSON
|
||||
shape is in example-record-file.json") — today I get a guess or an apology
|
||||
("I don't want to invent it!"). I want the answer agent to have **two
|
||||
tools**: list the available sources, and read the one extra document it
|
||||
thinks is relevant — with the number of opportunities configured by
|
||||
environment variables — and the UI to show a "calling tool" state in
|
||||
addition to "thinking".
|
||||
|
||||
- **Given** a grounded (HIGH-gate) chat turn
|
||||
- **When** the model calls `list_documents` (≤ `BOR_AGENT_LIST_CALLS`,
|
||||
default 1) or `read_document(source, path)` (≤ `BOR_AGENT_READ_CALLS`,
|
||||
default 1)
|
||||
- **Then** the app executes the tool server-side (DB-only), streams a
|
||||
`tool` SSE event the UI renders as a "calling tool" line + button state,
|
||||
and — once both budgets are spent — the model is required to answer with
|
||||
the augmented context; `done` sources include the read document.
|
||||
|
||||
## Acceptance criteria
|
||||
1. Live probe (task 01): `scripts/llm_probe.py --tools` verifies
|
||||
`turbo`'s tool-calling support (non-streaming + streaming) against the
|
||||
aipi endpoint; the verdict is recorded in the `app/rag/agent.py`
|
||||
docstring + the commit message (phase-17 "verified live" convention).
|
||||
2. `app/rag/llm.py`: `chat_stream(messages, tools=None)` — `tools` passed
|
||||
through to the API; streaming `tool_calls` deltas (partial `name` /
|
||||
fragmented `arguments`, indexed) accumulated into
|
||||
`ToolCallPiece(id, name, arguments)`; `tools=None` → byte-identical
|
||||
request to today.
|
||||
3. `app/rag/agent.py`: the loop — budgets from
|
||||
`BOR_AGENT_LIST_CALLS` / `BOR_AGENT_READ_CALLS` (default 1/1); the
|
||||
`list_documents` tool returns the DB catalog
|
||||
(`source/path — title` lines, `/api/docs` order); `read_document`
|
||||
appends the **full** document text (A7-revised: never truncated); after
|
||||
both budgets are spent the tools are dropped and the model must answer;
|
||||
safety cap on model rounds; the deflection (LOW) path is byte-identical.
|
||||
4. `app/api/chat.py` + `app/schemas.py`: new SSE
|
||||
`{"type":"tool","name":…,"argument":…}` event (PLAN §4 revision note,
|
||||
owner permission 2026-08-26); `done.sources` + `query_log.sources`
|
||||
include the read document (deduped); the per-turn log line gains
|
||||
`tool_calls=N` (PLAN §9 note).
|
||||
5. Frontend: "calling tool" button/label state + visible `.tool-call`
|
||||
lines in the bubble (distinct from the Thinking block), aria-live,
|
||||
tool lines persisted with the chat record (phase 14/17 convention);
|
||||
UI Structure Check + no CDN.
|
||||
6. `mock_llm.py` deterministic: marker-driven list → read → answer
|
||||
sequence (the mock parses the catalog out of the tool result it was
|
||||
given); the story E2E asserts the tool UI, the answer quoting the read
|
||||
document, the read document as a source chip, and reload re-render;
|
||||
existing suites stay green (no tool events without the marker).
|
||||
7. Unit + integration green, `app/` coverage >90%, story E2E green in
|
||||
isolation, ruff + pyright clean, one `--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
`tests/e2e/test_agent_document_tools.py` — one story, one file, run in
|
||||
isolation: the marker question triggers two tool events; the UI shows the
|
||||
"calling tool" state while tools run; the final answer quotes the read
|
||||
document; the source chips include it; after a reload the tool lines
|
||||
re-render; a plain question and a deflected question produce no tool
|
||||
events.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Story: Chat Survives a Refresh (localStorage)
|
||||
|
||||
**Phase:** `14_chat_persistence.md` · **E2E:** `tests/e2e/test_chat_persistence.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, I want my conversation to **survive a browser refresh**
|
||||
and a trip to another page — the previous session should be waiting for
|
||||
me when I come back.
|
||||
|
||||
- **Given** I have asked a few questions
|
||||
- **When** I refresh the page (or go to Sources and come back)
|
||||
- **Then** the whole conversation is restored: my questions, Brain's
|
||||
answers (rendered markdown), the source chips, and deflected answers
|
||||
with their "Maybe try" chips — amber style included.
|
||||
|
||||
## Acceptance criteria
|
||||
1. Conversation state (user + brain messages, with sources/deflection/
|
||||
suggestions metadata) is stored in `localStorage` under a versioned
|
||||
key; user messages are saved when sent, brain answers when the turn
|
||||
completes.
|
||||
2. On load, stored messages are re-rendered (markdown, source chips,
|
||||
deflected styling + maybe-try chips); the empty state is hidden when
|
||||
a conversation exists.
|
||||
3. "New chat" control (chat header) clears the stored conversation and
|
||||
returns to the empty state with suggestions.
|
||||
4. Storage failures (private mode / quota) degrade silently — chat still
|
||||
works, nothing throws.
|
||||
5. Size-bounded: oldest messages are dropped if the serialized
|
||||
conversation outgrows the quota budget.
|
||||
6. Unit + integration green, `app/` coverage >90%, story E2E green in
|
||||
isolation, one `--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_chat_persistence.py`** (mock LLM, seeded KB):
|
||||
1. `test_conversation_survives_reload` — ask → answer with source chip →
|
||||
`page.reload()` → both bubbles restored (text + chip href), empty
|
||||
state hidden.
|
||||
2. `test_deflected_turn_restores_styling` — off-topic question →
|
||||
deflected answer → reload → amber bubble + "Maybe try" chips back.
|
||||
3. `test_new_chat_clears_conversation` — with a conversation, "New
|
||||
chat" → messages gone, empty state + suggestions back, storage key
|
||||
cleared.
|
||||
4. `test_persists_across_page_navigation` — ask → go to Sources → back
|
||||
to `/` → conversation still there.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Story: Configurable app name
|
||||
|
||||
**Phase:** `39_configurable_brand` · **E2E:** `tests/e2e/test_configurable_brand.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the owner**, the app is "Brain of Reese" because that's what I named
|
||||
it — but the name is hard-coded across every template and JS string. I want
|
||||
to be able to **rename the app with one env var** (`BOR_APP_NAME`) so every
|
||||
page title, the header brand, the status labels, and the aria text carry
|
||||
the new name without touching code.
|
||||
|
||||
- **Given** the app is running with `BOR_APP_NAME="Brain of Testy"`
|
||||
- **When** I open any page (chat, sources, tuning, login, document viewer)
|
||||
- **Then** the title, the header brand, the status labels
|
||||
("Brain of Testy is thinking"), the empty-state greeting, and the
|
||||
document-viewer titles all say "Brain of Testy" — and with the variable
|
||||
unset the app is byte-identical to today ("Brain of Reese").
|
||||
|
||||
## Acceptance criteria
|
||||
1. `GET /api/config` (public, stateless) → `{app_name, version}` from
|
||||
settings — exactly those two keys (no secrets, no other settings);
|
||||
unit + integration tested.
|
||||
2. `frontend/assets/brand.js` (classic script, loaded first on all five
|
||||
pages): sets `window.BOR_BRAND` synchronously (default "Brain of
|
||||
Reese"), fetches `/api/config` (`cache: "no-store"`), and applies the
|
||||
name — `document.title` (global replace of the literal), every
|
||||
`.brand-text` (bold split: a name starting "Brain of " bolds the
|
||||
remainder, else plain; the name is HTML-escaped), every text node and
|
||||
the `aria-label` / `placeholder` / meta-`content` attributes containing
|
||||
the literal; a fetch failure falls back to the default (console.warn,
|
||||
the page never breaks).
|
||||
3. The JS hard-codes move off the literal: `app.js` (status labels, the
|
||||
typing label, the elapsed-hint aria) + `document.js` (page titles) read
|
||||
`window.BOR_BRAND` (default "Brain of Reese" if unset).
|
||||
4. `Containerfile`: an esbuild line for brand.js next to the others.
|
||||
5. The story E2E boots its **own** app instance with `BOR_APP_NAME`
|
||||
overridden (distinct port; the shared conftest server keeps the default
|
||||
name so the existing title assertions stay green) and asserts
|
||||
title/brand/greeting/aria/label.
|
||||
6. `.env.example` + README document `BOR_APP_NAME` (what it affects, the
|
||||
default, the bold-split rule).
|
||||
7. Unit + integration green, `app/` coverage >90%, story E2E green in
|
||||
isolation, ruff + pyright clean, one `--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
`tests/e2e/test_configurable_brand.py` — one story, one file, run in
|
||||
isolation: the overridden-name instance shows the new brand in the title,
|
||||
the header `.brand-text` (with the bold split), the empty-state greeting,
|
||||
the messages aria-label, and the chat status label; the default-name
|
||||
shared server still renders "Brain of Reese" (the no-op regression).
|
||||
@@ -0,0 +1,81 @@
|
||||
# Story: Dark Tech Theme
|
||||
|
||||
**Phase:** `08_story_dark_tech_theme.md` · **E2E:** `tests/e2e/test_dark_tech_theme.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, I want Brain of Reese to look like a serious piece of my
|
||||
infrastructure — dark, technical, free of emojis, with a subtly animated
|
||||
background — so the app feels at home next to my dashboards and is
|
||||
comfortable to use at night, instead of a generic light-mode chat widget.
|
||||
|
||||
- **Given** the v1 UI (light palette, emoji avatars/brand/favicon, static background)
|
||||
- **When** the theme phase is complete
|
||||
- **Then** every page is dark with a tech aesthetic, no emoji appear anywhere
|
||||
in the UI chrome, a subtle pure-CSS animated background (drifting grid +
|
||||
breathing glows) is visible but never distracting, all layout metrics and
|
||||
behavior are unchanged, and WCAG 2.1 AA still holds on the new palette.
|
||||
|
||||
## Acceptance criteria
|
||||
1. Dark palette (tokens in the phase file) applied to **both** pages
|
||||
(`/`, `/sources.html`); every text/background pair ≥4.5:1 — computed,
|
||||
not eyeballed.
|
||||
2. **Zero emoji in UI chrome** — HTML templates, JS-rendered chrome,
|
||||
favicon, CSS — replaced by inline SVG glyphs. An integration test
|
||||
enforces this as a permanent regression guard.
|
||||
3. Subtly animated background: a fine drifting grid + two slow breathing
|
||||
radial glows, **pure CSS, zero JS**, `pointer-events: none`, never
|
||||
blocking or dimming content.
|
||||
4. `prefers-reduced-motion: reduce` → background animations disabled
|
||||
(static grid + glows remain).
|
||||
5. No-CDN invariant holds on both pages (all script/link refs local or
|
||||
`data:`).
|
||||
6. Layout metrics unchanged (PLAN §7.1): 72rem frame, 46rem centered chat
|
||||
column, full-width Sources table, mobile rules; behavior unchanged
|
||||
(chat state machine, chips, banners).
|
||||
7. WCAG 2.1 AA: landmarks, labels, skip link, `:focus-visible`, live
|
||||
regions intact on the new palette.
|
||||
8. LLM answer content is out of scope (the model may output anything; the
|
||||
rule binds UI chrome only).
|
||||
9. Whole pytest suite green, `app/` coverage >90%, story E2E green in
|
||||
isolation.
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **Palette (all pairs computed, ≥4.5:1):** page `#0a0e17`, surface
|
||||
`#121a2e`, ink `#e8ebf4` (14.5:1 on surface), ink-soft `#9aa4bd`
|
||||
(6.9:1), line `#26304a`, brand `#6d78f2` with **dark** text `#0a0e17`
|
||||
(5.2:1 — white on brand is 3.7:1, forbidden for text), brand-soft
|
||||
`#232b52` + brand-ink `#a5b4fc` (6.9:1), deflection `#2b2110`/`#fbbf24`
|
||||
(9.5:1) + border `#f59e0b`, error `#2d1318`/`#fca5a5` (9.1:1), ok
|
||||
`#10241b`/`#6ee7a8` (10.6:1).
|
||||
- **Busy button** keeps its `#a5b4fc` background (the existing unit test
|
||||
asserts the token) with a **dark** arc (`#0a0e17`, 9.7:1).
|
||||
- **Avatars:** brain → inline SVG circuit-node glyph; user → minimal
|
||||
silhouette SVG; `aria-hidden` retained.
|
||||
- **Favicon / brand mark:** tech SVG mark (hex + node), still an inline
|
||||
`data:` URI (no CDN).
|
||||
- **Tech details:** mono wordmark + stat values, 1px `--line` borders,
|
||||
radii 10px/6px, a 2px brand→cyan gradient hairline under the sticky
|
||||
header.
|
||||
- **Background:** `body::before` grid (44px cells, ~35% alpha 1px lines,
|
||||
radial mask fade, 60s seamless diagonal drift); `body::after` two radial
|
||||
glows (indigo ~14% top-left, cyan ~10% bottom-right, 14s breathing).
|
||||
Both `position: fixed; inset: 0; pointer-events: none`.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_dark_tech_theme.py`** (mock LLM, seeded KB):
|
||||
1. `test_dark_palette_and_contrast` — body background is
|
||||
`rgb(10, 14, 23)`; computed ink-on-surface and brand-button
|
||||
text/background pairs ≥4.5:1 (same contrast helper as Phase 07).
|
||||
2. `test_no_emoji_in_chrome` — neither page's `document.body.innerText`
|
||||
nor raw `outerHTML` contains any emoji code point.
|
||||
3. `test_animated_background` — computed style of `body::before` has a
|
||||
non-none `background-image` and `animation-name`; `body::after`
|
||||
animates too.
|
||||
4. `test_reduced_motion_honored` — a browser context created with
|
||||
`reduced_motion="reduce"` → `animation-name: none` on both layers.
|
||||
5. `test_behavior_unchanged_smoke` — on-topic question streams an answer +
|
||||
a source chip + the send button recovers (state machine intact under
|
||||
the new skin).
|
||||
6. `test_all_assets_local` — every `script[src]` / `link[href]` on both
|
||||
pages is relative or `data:` (no CDN).
|
||||
@@ -0,0 +1,41 @@
|
||||
# Story: Document Back Button Returns to Where You Came From
|
||||
|
||||
**Phase:** `13_document_back_navigation.md` · **E2E:** `tests/e2e/test_document_back_navigation.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, when I open a document **from the chat tab** (a source
|
||||
chip), the viewer's back button must take me **back to the chat** — not
|
||||
to Sources. When I open it from Sources, it takes me to Sources.
|
||||
|
||||
Today every chip/link opens the viewer in a **new tab**, where there is
|
||||
no browser history, so the fallback always lands on `/sources.html` —
|
||||
which is wrong for chat-originated visits.
|
||||
|
||||
- **Given** I open a document from chat (chip) or from Sources (table)
|
||||
- **When** I click the viewer's back button
|
||||
- **Then** I land on the page I opened it from — Chat or Sources.
|
||||
|
||||
## Acceptance criteria
|
||||
1. Chat source chips carry `&back=%2F` (chat) in the viewer URL; Sources
|
||||
table links keep the default (no `back` param → `/sources.html`).
|
||||
2. The viewer resolves the back target: `back` param wins when it is a
|
||||
same-origin relative URL (starts with `/`, not `//`); anything else
|
||||
(missing, absolute, protocol-relative, garbage) falls back to
|
||||
`/sources.html`. The back link's **label** matches (Chat / Sources).
|
||||
3. Clicking back navigates to the resolved target (deterministic — no
|
||||
`history.length` heuristics).
|
||||
4. Phase-10 viewer behavior and a11y frame unchanged.
|
||||
5. Unit + integration green, `app/` coverage >90%, story E2E green in
|
||||
isolation, one `--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_document_back_navigation.py`** (seeded KB):
|
||||
1. `test_back_from_chat_returns_to_chat` — question → click source chip →
|
||||
new tab at `/document.html?…&back=%2F` → back link href is `/` and
|
||||
labeled "Chat" → click → URL is the chat page.
|
||||
2. `test_back_from_sources_returns_to_sources` — Sources → click path
|
||||
link → new tab (no `back` param) → back link href is `/sources.html`,
|
||||
labeled "Sources" → click → URL is Sources.
|
||||
3. `test_malicious_back_param_is_rejected` — `back=https%3A%2F%2Fevil.com`
|
||||
and `back=%2F%2Fevil.com` → back link falls back to `/sources.html`.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Story: Clickable Document Viewer
|
||||
|
||||
**Phase:** `10_story_document_viewer.md` · **E2E:** `tests/e2e/test_document_viewer.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, I want to click any document Brain cites (the source
|
||||
chips under an answer) or any row in the Sources table and have the
|
||||
**actual document open in the browser** — in whatever format it is — so I
|
||||
can read the full context and verify the answer against my real notes.
|
||||
|
||||
- **Given** an answer with source chips (`Homelab/active/container_gitlab/
|
||||
gitlab.md`) or the Sources page table
|
||||
- **When** I click a chip or a path
|
||||
- **Then** a new tab opens the viewer: title, source/format/path meta, and
|
||||
the full content — markdown rendered with the existing local
|
||||
escape-first renderer, other formats as escaped monospace text.
|
||||
|
||||
## Acceptance criteria
|
||||
1. `GET /api/documents/content?source=…&path=…` → 200
|
||||
`{source, path, title, format, content, indexed_at, chunks}`; 404
|
||||
`{detail}` when the pair is not indexed. Served **from the database
|
||||
only** — no filesystem access, hence no path-traversal surface.
|
||||
2. `GET /document.html?source=…&path=…` renders the document: back link,
|
||||
title, meta row (source, format badge, mono path, indexed date, chunk
|
||||
count), content — md via the shared local renderer; other formats as
|
||||
escaped `<pre>` (mono, horizontal scroll).
|
||||
3. Chat source chips link to the viewer **in a new tab**
|
||||
(`target="_blank" rel="noopener"`), accessible name intact.
|
||||
4. Sources table: the path cell is a link to the viewer (new tab).
|
||||
5. XSS-safe: document content is never injected as raw HTML (renderer
|
||||
escapes first; raw formats use text nodes).
|
||||
6. The viewer inherits the dark tech theme; the no-CDN invariant is
|
||||
extended to `/document.html`; landmarks/labels/`aria-live` on the new
|
||||
page.
|
||||
7. Missing doc → designed not-found state with a link to the Sources
|
||||
page.
|
||||
8. Unit + integration green, `app/` coverage >90%, story E2E green in
|
||||
isolation, one `--no-gpg-sign` commit.
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **Viewer page:** sticky header (back link + title + meta badges) +
|
||||
`<main>`: markdown column centered at ≤46rem; raw content in a
|
||||
full-width `pre` with `overflow-x: auto`. Dark tokens from Phase 08.
|
||||
- **Chips/links:** source chips keep the mono pill; path links get
|
||||
hover/focus underline; `:focus-visible` ring; ≥44px touch targets on
|
||||
mobile.
|
||||
- **Not-found state:** centered card (no emoji — SVG or plain text),
|
||||
message + "Open Sources" link.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_document_viewer.py`** (mock LLM, seeded KB):
|
||||
1. `test_source_chip_opens_document` — on-topic question → click the
|
||||
`.source-chip` → a **new tab** at `/document.html?…` shows the fixture
|
||||
doc's title + a known content string + the format badge.
|
||||
2. `test_sources_row_links_to_viewer` — `/sources.html` → click a path
|
||||
link for a `.yaml` fixture → viewer shows its raw content in a `pre`.
|
||||
3. `test_markdown_renders_and_stays_xss_safe` — an md fixture containing
|
||||
`<script>alert(1)</script>` renders as visible escaped text (no
|
||||
execution).
|
||||
4. `test_missing_doc_shows_not_found` — unknown path → not-found state +
|
||||
Sources link; no console crash.
|
||||
5. `test_viewer_theme_and_no_cdn` — dark background + every
|
||||
`script[src]` / `link[href]` local or `data:`.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Story: Follow-the-Bottom Scroll
|
||||
|
||||
**Phase:** `18_follow_bottom_scroll` · **E2E:** `tests/e2e/test_follow_bottom_scroll.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, Brain's answers (and its thinking) stream for 10–30 seconds.
|
||||
I want to scroll up and read earlier messages — or the top of a long
|
||||
thinking block — without the page dragging me back to the bottom token by
|
||||
token; but when I submit, I should still see my message and the reply
|
||||
appear.
|
||||
|
||||
- **Given** a turn is streaming (thinking, or answering) and I scroll up
|
||||
to read
|
||||
- **When** the stream keeps going
|
||||
- **Then** the viewport holds still for the rest of the turn — nothing
|
||||
auto-scrolls — while submitting (I'm pinned at the composer) still
|
||||
reveals my message, and the reply follows into view while I stay
|
||||
pinned.
|
||||
|
||||
## Acceptance criteria
|
||||
1. Submitting a question always reveals the user's own message (explicit
|
||||
action, unconditional).
|
||||
2. While the user is pinned to the bottom (within 200px of it — the
|
||||
composer zone), the typing indicator, thinking chunks, and answer
|
||||
deltas follow into view (smooth, or instant under
|
||||
`prefers-reduced-motion`).
|
||||
3. Once the user scrolls up (more than 200px from the bottom), nothing
|
||||
auto-scrolls for the rest of the turn — thinking or answer; the
|
||||
viewport position is unchanged at turn end.
|
||||
4. The thinking block's *internal* text still bottom-pins itself while
|
||||
open (that is the block's own overflow, not the page).
|
||||
5. Restoring a stored conversation still lands on the latest message
|
||||
(one-shot, non-smooth).
|
||||
6. No new UI surface (no "new content" pill — owner chose the minimal
|
||||
contract, 2026-08-23); no other page is affected.
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **Scroller:** the document itself — there is no inner overflow
|
||||
container (`body` is `min-height: 100dvh`; the page scrolls on the
|
||||
window). All measurements go through `window.scrollY`,
|
||||
`document.documentElement.scrollHeight`, and `window.innerHeight`.
|
||||
- **The gate:** `scrollReveal(wrap, behavior = SCROLL, force = false)` is
|
||||
the **single** `scrollIntoView` call site in `app.js` — it fires only
|
||||
when `force` is set or `isNearBottom()` is true. `NEAR_BOTTOM_PX = 200`
|
||||
is exported and unit-pinned (same pattern as `TURN_TIMEOUT_MS`); the
|
||||
200px band ≈ the composer zone (the textarea auto-grows to 192px plus
|
||||
the button row), so "the composer is in view" counts as pinned —
|
||||
exactly where the user sits when they submit. Scrolling up into the
|
||||
conversation (≫200px from the bottom) leaves the band.
|
||||
- **Call sites:** the user's own message on submit (the plain gated
|
||||
default — no force; the user submits from the composer, i.e. pinned,
|
||||
so the message reveals), the typing indicator, the streaming `delta`
|
||||
branch, and the phase-17 `thinking` branch — all go through the gate.
|
||||
The only `force`d callers are the two phase-14 restore landings
|
||||
(`"auto", true` — one-shot, load-time, non-smooth).
|
||||
- **Reused unchanged:** the `SCROLL` constant (smooth, or `auto` under
|
||||
`prefers-reduced-motion` — "calm, don't remove") still controls the
|
||||
*feel* of a follow scroll; reduced-motion handling is untouched. The
|
||||
thinking block's internal `.thinking-text` bottom-pinning (scrollTop of
|
||||
the block's own overflow) stays as-is — that is the block's element,
|
||||
not the page.
|
||||
- **No new UI surface:** no "↓ new content" pill (owner chose the minimal
|
||||
contract, 2026-08-23 — a follow-up phase if ever wanted), no CSS or
|
||||
HTML template changes, no new DOM nodes, no other page affected
|
||||
(Sources, document viewer, and login have no vertical auto-scroll).
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_follow_bottom_scroll.py`** (mock LLM —
|
||||
`write a long answer` long-answer trigger, plus the phase-17
|
||||
`think out loud` trigger for scenario 4; mock-only by design, since
|
||||
`E2E_REAL_LLM=1` would make the scroll-away windows unpredictable):
|
||||
1. `test_submit_reveals_new_message` — fresh chat page (pinned at the
|
||||
bottom by default); submit `LONG_QUESTION`; once settled, the last
|
||||
`.msg.brain` is inside the viewport (bounding box) and
|
||||
`near_bottom(scroll_state(page))`.
|
||||
2. `test_stream_follows_while_pinned_at_bottom` — fresh page; submit
|
||||
`LONG_QUESTION`; ~2s into the stream (poll until the last brain
|
||||
bubble's text length > 200), assert `near_bottom(scroll_state(page))`
|
||||
— the follow behavior is alive, not accidentally removed; at settle,
|
||||
still near bottom.
|
||||
3. `test_no_yank_while_scrolled_up_during_answer_stream` — submit
|
||||
`LONG_QUESTION` (fresh page, pinned — normal flow; once settled, the
|
||||
document overflows the 800px viewport — assert `sh > ch`); submit a
|
||||
second `LONG_QUESTION`; wait until the new brain bubble's text length
|
||||
> 200 (streaming has started); `window.scrollTo(0, 0)` (the user goes
|
||||
up to read); wait until the bubble's text length > 600 (the stream
|
||||
kept running while the viewport was at the top); assert
|
||||
`scroll_state(page)["y"] <= 5` (viewport held); wait for settle;
|
||||
assert `y <= 5` again (no scroll happened for the rest of the turn —
|
||||
the answer finished off-screen below, by design).
|
||||
4. `test_no_yank_while_scrolled_up_during_thinking` — one settled turn
|
||||
first (overflow exists); submit `THINK_LONG_QUESTION` (normal flow,
|
||||
pinned); wait for `details.thinking` in the last `.msg.brain` to
|
||||
attach (thinking is streaming — phase-17 behavior) and is open;
|
||||
`window.scrollTo(0, 0)`; wait until the answer `.bubble` text is
|
||||
non-empty (the whole thinking stream plus the answer's start happened
|
||||
at the top); assert `y <= 5`; wait for settle; assert `y <= 5` and
|
||||
that the thinking text contains `Step 2: Check my notes` and the
|
||||
bubble is filled (all present but off-screen — the point of the
|
||||
story).
|
||||
5. `test_restore_lands_on_latest_message` — two settled turns (user +
|
||||
brain × 2, overflow); `page.reload()`; after restore, the last
|
||||
`.msg.brain` is inside the viewport and
|
||||
`near_bottom(scroll_state(page))` (phase-14 one-shot landing
|
||||
preserved — pinned so a future "remove all scrolling" change fails
|
||||
loudly instead of silently).
|
||||
@@ -0,0 +1,38 @@
|
||||
# Story: One Header, Same Size Everywhere
|
||||
|
||||
**Phase:** `12_header_consistency.md` · **E2E:** `tests/e2e/test_header_consistency.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, I want the top bar to be the **same height on every page**
|
||||
so moving between Chat, Sources, and a document never feels like the
|
||||
app "grew".
|
||||
|
||||
Today the document viewer (the page whose back button says "Sources")
|
||||
uses a content-sized header (≈66px desktop / ≈63px+ mobile) while Chat
|
||||
and Sources use the fixed 64px bar (58px mobile) — the perceived
|
||||
"header changes size between sources and chat".
|
||||
|
||||
- **Given** any of the three pages at any viewport
|
||||
- **When** I look at the sticky top bar
|
||||
- **Then** it is exactly `var(--header-h)` tall on all of them.
|
||||
|
||||
## Acceptance criteria
|
||||
1. `.doc-header` is exactly `var(--header-h)` tall (64px desktop,
|
||||
58px ≤640px) — same box as `.app-header` on `/` and `/sources.html`.
|
||||
2. Title + meta still fit: title stays single-line (ellipsis), meta row
|
||||
clips instead of growing the header on narrow screens.
|
||||
3. Existing phase-10 viewer behavior (back link, title, meta badges,
|
||||
a11y frame) unchanged.
|
||||
4. Unit + integration green, `app/` coverage >90%, story E2E green in
|
||||
isolation, one `--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_header_consistency.py`**:
|
||||
1. `test_header_height_identical_across_pages_desktop` — measured
|
||||
bounding boxes of `.app-header` (chat), `.app-header` (sources),
|
||||
`.doc-header` (viewer) are all exactly 64px tall.
|
||||
2. `test_header_height_identical_across_pages_mobile` — same at a 375px
|
||||
viewport: all exactly 58px.
|
||||
3. `test_viewer_header_content_still_fits` — title ellipsizes, meta row
|
||||
visible, back link + badges present (regression guard for phase 10).
|
||||
@@ -0,0 +1,49 @@
|
||||
# Story: Local directory sources
|
||||
|
||||
**Phase:** `38_local_directory_sources` · **E2E:** `tests/e2e/test_local_directory_sources.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the admin (owner)**, some of my documents live in plain directories
|
||||
that are **not git repos** — today the only way to index them is the CLI's
|
||||
`--source` flag, which the web app's Sync path ignores. I want to **add an
|
||||
existing local directory as a source** from the same admin page as the git
|
||||
sources, and have the Sync button and `import_docs` import it alongside the
|
||||
git checkouts.
|
||||
|
||||
- **Given** I am signed in as admin, and `~/Notes` is a plain (non-git)
|
||||
directory
|
||||
- **When** I add it on the sources page and click Sync
|
||||
- **Then** the directory is walked and imported together with the git
|
||||
checkouts in one run (pruning over the union), and a missing directory
|
||||
fails the run loudly instead of importing a partial KB.
|
||||
|
||||
## Acceptance criteria
|
||||
1. Migration 0007: `git_sources.kind TEXT NOT NULL DEFAULT 'git'` (check:
|
||||
`git`|`local`) + `git_sources.path TEXT NULL` (unique; non-null only for
|
||||
local) — reversible, up/down integration-tested (the 0004–0006 pattern).
|
||||
2. The admin API (phase 35) accepts `kind`: `git` rows validate `url`
|
||||
exactly as today; `local` rows require `path` (trimmed, expanded,
|
||||
absolute, existing directory on the server → else 422 naming the path);
|
||||
409 on duplicate (per kind); GET rows carry `kind` + `path` (env
|
||||
fallback rows are git-only).
|
||||
3. The sync pipeline and `import_docs` resolve DB **git + local** rows
|
||||
together (git → clone/pull; local → direct directory walk; a missing
|
||||
local dir aborts the run before importing, with the path named);
|
||||
`--source` still wins; `BOR_GIT_SOURCES` stays a git-only fallback;
|
||||
both-empty fails loudly ("no sources configured").
|
||||
4. The page: a second "Local directory" add form (labeled path input,
|
||||
inline error, never-stale button per §7.4); list rows show a kind badge
|
||||
(Git/Local, distinguishable by more than color); the hint says Sync
|
||||
imports git checkouts and local directories together (union prune).
|
||||
5. README: the two source types + add-time validation + "the DB is the
|
||||
local-source registry"; no new env var.
|
||||
6. Unit + integration green, `app/` coverage >90%, story E2E green in
|
||||
isolation, ruff + pyright clean, one `--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
`tests/e2e/test_local_directory_sources.py` — one story, one file, run in
|
||||
isolation: the admin adds a temp local directory → it appears with the
|
||||
Local badge; a missing path gets an inline error; Sync imports its fixture
|
||||
file (visible via `GET /api/docs`); deleting the file and syncing again
|
||||
prunes it; removing the row removes it from the list.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Story: Retrieval Quality — Multi-Format Ingestion + Hybrid Search
|
||||
|
||||
**Phase:** `09_story_retrieval_quality.md` · **E2E:** `tests/e2e/test_retrieval_quality.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, I want to ask questions that name my tools ("How did I
|
||||
install gitlab?") and get a grounded answer citing the right document —
|
||||
even when the answer lives in a `.yaml`, `.py`, `.json` or `.txt` file —
|
||||
so Brain actually covers my notes instead of deflecting around them.
|
||||
|
||||
- **Given** my notes are a mix of markdown write-ups and config/code
|
||||
files, and today's index is polluted with vendored-cache docs
|
||||
(`.esphome/.espressif/**`, merge-request templates, …) that outrank
|
||||
real content, while retrieval is pure cosine top-4
|
||||
- **When** the importer covers the full text-format set, skips hidden
|
||||
directories, and retrieval fuses vector similarity with Postgres
|
||||
full-text search
|
||||
- **Then** "How did I install gitlab?" surfaces `active/container_gitlab/
|
||||
gitlab.md` as the top document, the junk docs are pruned from the
|
||||
index, and genuinely off-topic questions still get the honest
|
||||
deflection.
|
||||
|
||||
## Acceptance criteria
|
||||
1. **Ingestion scope (A9, revised with owner permission 2026-08-21):**
|
||||
default extensions `md, markdown, txt, yaml, yml, json, py`,
|
||||
overridable via `BOR_IMPORT_EXTENSIONS`; **hidden (dot) directories
|
||||
skipped by default**; the A9 exclusion list stays.
|
||||
2. **Format-aware chunking:** md unchanged (heading-aware); yaml/yml
|
||||
split on top-level keys and `---` document separators (key kept as
|
||||
chunk anchor); json pretty-printed and split on top-level keys; py
|
||||
split on top-level defs/classes (stdlib `ast`); txt on paragraphs.
|
||||
Every format honors the 1200-char hard cap (aipi ~1024-token request
|
||||
cap).
|
||||
3. **Hybrid retrieval (A7, revised with owner permission 2026-08-21):**
|
||||
cosine top-30 + FTS top-30 (OR tsquery over the question's tokens,
|
||||
`ts_rank`-ordered) fused with Reciprocal Rank Fusion (k=60);
|
||||
document-level ranking by best fused chunk score; the LLM still
|
||||
receives the **full top-2 documents** capped at 24k chars (contract
|
||||
preserved).
|
||||
4. **Honesty gate (A8, revised with owner permission 2026-08-21):**
|
||||
deflect when `best cosine < BOR_RELEVANCE_THRESHOLD` **and** no
|
||||
candidate chunk FTS-matches the question; threshold re-tuned for the
|
||||
`embed` model's measured 0.41–0.84 score compression (default 0.62,
|
||||
documented tuning procedure); `fts_hits` logged per turn and stored in
|
||||
`query_log`.
|
||||
5. **Live verification:** `scripts/eval_retrieval.py` run against the
|
||||
imported KB shows `gitlab.md` as the top document for "How did I
|
||||
install gitlab?"; a known off-topic question gates LOW.
|
||||
6. **Index hygiene:** after a `--prune` re-import, **zero** documents
|
||||
with a dot-prefixed path component exist in `documents`; prune also
|
||||
drops docs that no longer match the format filter.
|
||||
7. **No regressions:** off-topic deflection (story 04) and grounded
|
||||
answers (story 03) still pass their E2E suites in isolation; the E2E
|
||||
mock keeps its own threshold calibration via the app fixture.
|
||||
8. Unit + integration green, `app/` coverage >90%, story E2E green in
|
||||
isolation, one `--no-gpg-sign` commit.
|
||||
|
||||
## UI Visualization & Structure
|
||||
No UI change. The Sources page document count drops after the junk prune
|
||||
(intended cleanup — noted in the README).
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_retrieval_quality.py`** (mock LLM, seeded KB):
|
||||
1. `test_multiformat_docs_indexed` — fixture tree with `.md`, `.yaml`,
|
||||
`.py`, `.json`, `.txt` plus `.hidden/junk.md`: import in-process →
|
||||
`/api/docs` lists every non-hidden doc and none of the hidden ones.
|
||||
2. `test_named_tool_question_retrieves_right_doc` — "How did I install
|
||||
gitlab?" → not deflected, streamed answer, a `.source-chip` containing
|
||||
the gitlab doc, `query_log` row `deflected=false` with the gitlab doc
|
||||
in sources.
|
||||
3. `test_keyword_only_question_beats_vector_ranking` — a question whose
|
||||
rare token exists in exactly one fixture doc (weak mock-embedding
|
||||
overlap, e.g. "kafkabridge") → grounded answer citing that doc
|
||||
(FTS-OR gate + lexical RRF lift, end to end).
|
||||
4. `test_off_topic_still_deflects` — "sourdough starter" →
|
||||
`.is-deflected` bubble matching /haven't done anything like that/i +
|
||||
≥2 "Maybe try:" chips (story 04 behavior under the new gate).
|
||||
@@ -0,0 +1,121 @@
|
||||
# Story: Shared Header (Auth + New Chat on Every Page, Sources Link Admin-Only)
|
||||
|
||||
**Phase:** 19_shared_header · **E2E:** tests/e2e/test_shared_header.py
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, the top bar should feel like **one shared component**: when
|
||||
I move to Sources or open a document, I should still see **Sign in** (or
|
||||
**Sign out**) and **New Chat** — and the **Sources** link should not offer
|
||||
me a page I can't use until I sign in.
|
||||
|
||||
- **Given** I am on any page (chat, sources, or the document viewer)
|
||||
- **When** the bar renders
|
||||
- **Then** I always see the same controls — New Chat plus exactly one of
|
||||
Sign in (anonymous) / Sign out (admin) — and the "Sources" nav link is
|
||||
visible only to the admin.
|
||||
- **When** I am anonymous and I look at the nav
|
||||
- **Then** the "Sources" link is hidden on every page that has a nav —
|
||||
the soft-gated page is still reachable by direct URL (phase-16 gate
|
||||
intact), but nothing in the UI points me at a page I can't use.
|
||||
|
||||
## Acceptance criteria
|
||||
1. Chat, Sources, and the document viewer each show **New Chat** +
|
||||
**Sign in** (anonymous) or **Sign out** (admin) in the header; the
|
||||
login page shows neither (it is the auth page, not an app page).
|
||||
2. The **"Sources" nav link is hidden for anonymous users on every page
|
||||
that has a nav** (chat, sources, login), and visible for the admin
|
||||
(chat, sources — the viewer has no nav).
|
||||
3. Anonymous **direct-URL access to `/sources.html`** still shows the
|
||||
phase-16 soft gate (link hidden, gate intact); the API split is
|
||||
unchanged (`/api/docs` → **403** for anonymous).
|
||||
4. **New Chat on chat: in-place reset (unchanged).** New Chat on
|
||||
sources/viewer: clears the local conversation (`bor.chat.v1`) and
|
||||
navigates to the chat page (the empty state).
|
||||
5. **Sign out works from any page** (logout + reload → anonymous state
|
||||
restored on that page).
|
||||
6. The bar stays exactly **64px (desktop) / 58px (≤640px)** on all three
|
||||
pages in both auth states, with **no horizontal overflow at 360px**
|
||||
(phase-12 contract, phase-07 overflow guard).
|
||||
7. **Exactly one `/api/whoami` request per page load** (shared cached
|
||||
fetch — every gate and toggle on the page shares the same promise).
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **Shared module `frontend/assets/header.js`** (new, ES module — all
|
||||
pages already load JS as `type="module"`), loaded before each page
|
||||
script:
|
||||
- `fetchIsAdmin()` — one `GET /api/whoami`, cached in a module-level
|
||||
promise (`adminPromise`; anonymous-safe: non-2xx or network failure
|
||||
→ `false`); the single whoami call site for the whole frontend, so a
|
||||
page makes exactly one request no matter how many gates reuse it.
|
||||
- `initSharedHeader()` — awaits `fetchIsAdmin()`, then toggles
|
||||
**only the elements that exist on the page** (missing → no-op, which
|
||||
is how the login page reuses the module without gaining chat
|
||||
controls): `#sign-in-link` / `#sign-out-btn` (exactly one visible —
|
||||
phase-16 semantics) and `#nav-sources` (shown for admin); returns
|
||||
the admin flag so callers reuse it.
|
||||
- `clearChatStorage()` — removes the `bor.chat.v1` key in a
|
||||
try/catch (same key + fail-silence contract as app.js's
|
||||
`clearStoredConversation`).
|
||||
- The `#sign-out-btn` binding lives here (POST `/api/logout`, disable
|
||||
during the call, `location.reload()`) — app.js deleted its copy, so
|
||||
there is exactly one implementation.
|
||||
- **Element ids** (existing patterns): `#nav-sources` ships
|
||||
**hidden-by-default** in the HTML (anonymous-safe — revealed once
|
||||
whoami says admin, never flashed for anonymous); `#sign-in-link` /
|
||||
`#sign-out-btn` follow the phase-16 pattern (exactly one visible,
|
||||
decided by whoami at load — both start hidden so the wrong state is
|
||||
never shown for a frame).
|
||||
- **Viewer bar:** a `.doc-header-actions` wrapper (New Chat + Sign in /
|
||||
Sign out) appended to the right of the title block — `margin-left:
|
||||
auto` flex row; the title block keeps its `min-width: 0` truncation
|
||||
(phase-12 "clip, don't wrap") while the two pills fit. The bar height
|
||||
and the back-link/title contract (phase 13) are untouched.
|
||||
- **Mobile:** the new controls reuse `.new-chat-btn` / `.auth-link`, so
|
||||
the phase-14/16 icon-only rules (labels hidden, 16px icon shown at
|
||||
≤640px) apply automatically — the pills never grow the 58px bar and
|
||||
never overflow at 360px.
|
||||
- **Login page boundary:** `#nav-sources` (hidden) + `header.js` only —
|
||||
no chat controls are added to its markup, so none appear (it is the
|
||||
auth page, not an app page).
|
||||
- **Non-chat New Chat:** on sources.js / document.js, click →
|
||||
`clearChatStorage()` → `location.href = "/"` (a new chat means going
|
||||
to the chat, fresh); chat keeps app.js's `startNewChat` (in-place
|
||||
reset + focus + announce).
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_shared_header.py`** (DB seeded with the
|
||||
fixture docs for the viewer URL and the admin catalog; real form logins
|
||||
via `tests/e2e/auth_helpers.py::login`; the `assert_shared_bar(admin,
|
||||
page_kind)` helper waits for the settled whoami state — exactly one of
|
||||
Sign in / Sign out visible — before asserting the bar):
|
||||
1. `test_anonymous_bar_on_all_pages` — fresh (anonymous) page: the shared
|
||||
bar on chat, sources (phase-16 `#sources-gate` still visible above
|
||||
it), and the viewer, in the anonymous state (desktop viewport).
|
||||
2. `test_admin_bar_on_all_pages` — `login(page, app_url, next="/")`: the
|
||||
bar on all three pages in the signed-in state (Sign out + Sources
|
||||
link where a nav exists) — the login → `next` flow still lands right.
|
||||
3. `test_sources_nav_hidden_for_anonymous_everywhere` — anonymous:
|
||||
`#nav-sources` hidden on chat, sources, and the login page; after a
|
||||
real login on the chat page, `#nav-sources` is visible (the toggle
|
||||
works, not just the initial state).
|
||||
4. `test_new_chat_from_sources_clears_and_navigates` — a seeded
|
||||
`bor.chat.v1` conversation; on sources, `#new-chat-btn` → navigation
|
||||
to `/` with the empty state visible and the localStorage key removed.
|
||||
5. `test_sign_out_from_viewer_returns_to_anonymous` — log in with
|
||||
`next=/sources.html`; open the viewer (admin bar); `#sign-out-btn` →
|
||||
after the reload the same page shows the anonymous bar.
|
||||
6. `test_mobile_bar_fits_and_heights_held` — viewport 375×812,
|
||||
anonymous: 58px bars on all three pages with
|
||||
`documentElement.scrollWidth <= clientWidth` (no horizontal
|
||||
overflow; the pills are icon-only); repeat after login (Sign out +
|
||||
Sources link present) — the bar never grows.
|
||||
|
||||
**Regression suites (adapted / re-run in isolation by this phase):**
|
||||
`test_header_consistency.py` (64/58px with the new pills on sources +
|
||||
viewer), `test_responsive_polish.py` (no 360px overflow),
|
||||
`test_admin_auth.py` (phase-16 flows with `#nav-sources` hidden for
|
||||
anonymous), `test_document_back_navigation.py` (viewer back-link/title
|
||||
with the actions wrapper), `test_chat_persistence.py` (chat New Chat
|
||||
in-place; the "New chat is chat-page-only" pin adapted — the shared bar
|
||||
now puts it on sources/viewer too, by owner permission).
|
||||
@@ -0,0 +1,73 @@
|
||||
# Story: Sources Mid-Stream — an in-flight answer is not lost on navigation
|
||||
|
||||
**Phase:** `20_sources_midstream_bug` · **E2E:** `tests/e2e/test_sources_midstream_bug.py`
|
||||
|
||||
## Bug report (TODO.md L3, verbatim)
|
||||
|
||||
> *"Clicking "sources" while chat is generating clears chat and result
|
||||
> will never show up"*
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user with a question in flight**, I want to **click "Sources"
|
||||
while Brain is still generating** without losing the answer — when I
|
||||
come back to the chat, what had already streamed should be waiting for
|
||||
me.
|
||||
|
||||
- **Given** I asked a question and Brain's answer is still streaming
|
||||
- **When** I click the "Sources" nav link (or otherwise leave the chat
|
||||
page) mid-stream
|
||||
- **Then** the stream is aborted by the navigation (no server-side
|
||||
resume — A10 stays stateless), but returning to `/` shows my question
|
||||
**and** the partial answer that had already streamed, rendered exactly
|
||||
like any brain message (thinking block included, if any had streamed).
|
||||
|
||||
## Owner-confirmed (2026-08-24, roadmap A1)
|
||||
1. **A partial answer is persisted as a plain brain message** — no
|
||||
"(partial)" marker, no sources/suggestions (the turn is dead; the
|
||||
user can re-ask for the full answer).
|
||||
2. Navigation **before the first answer token** (pure thinking) persists
|
||||
nothing brain-side: the question is restored, no empty/partial
|
||||
bubble.
|
||||
3. The deliberate **New Chat** `clearChatStorage()` (sources/viewer
|
||||
pages) is untouched — that clear is by design (phase 14/19).
|
||||
4. No server-side resume (A10 stays stateless) and no "leave page?"
|
||||
confirmation dialog.
|
||||
|
||||
## Acceptance criteria
|
||||
1. `pagehide` (navigate-away / bfcache store) with an in-flight turn
|
||||
(`thinking` or `streaming`) and non-empty streamed text persists the
|
||||
partial raw answer via the existing `rememberBrainTurn` save-point
|
||||
helper (idempotent per turn — `persistedOnLeave`).
|
||||
2. Returning to `/` restores the question and the partial brain message
|
||||
through the existing restore path (escape-first markdown, optional
|
||||
collapsed thinking block) — no new UI surface, no error banner.
|
||||
3. Navigation during pure thinking (no answer tokens) persists nothing
|
||||
brain-side: exactly the user message is restored.
|
||||
4. A completed turn persists exactly as before (done metadata intact;
|
||||
no duplicate brain message).
|
||||
5. New Chat from the sources page still clears the conversation
|
||||
(`clearChatStorage`, phase 14/19 — untouched).
|
||||
6. Unit + integration green, `app/` coverage ≥ today's number (>90%
|
||||
gate), story E2E green in isolation, one `--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_sources_midstream_bug.py`** (mock LLM,
|
||||
seeded KB):
|
||||
1. `test_partial_answer_survives_sources_nav_midstream` — admin (login,
|
||||
`next=/`), send the ~9s long-answer question, wait for the first
|
||||
streamed frames to render, click `#nav-sources` (lands on
|
||||
`/sources.html`), `page.goto("/")` → question + partial answer
|
||||
rendered, no `role="alert"`, `bor.chat.v1` holds the partial brain
|
||||
message (starts with the first streamed chunk, shorter than the full
|
||||
answer, no done metadata).
|
||||
2. `test_no_orphan_brain_message_when_navigated_before_first_token` —
|
||||
the mock streams the phase-17 thinking scratchpad, then a 4s
|
||||
pre-token pause; navigate during the pause, return to `/` → exactly
|
||||
one user message, zero brain messages, in the DOM and in storage.
|
||||
3. `test_completed_turn_unaffected` — a fully settled turn → trip to
|
||||
Sources and back → full answer, source chip, and a byte-identical
|
||||
`bor.chat.v1` payload (done metadata intact, no duplicate).
|
||||
4. `test_new_chat_still_clears_conversation` — completed turn →
|
||||
`/sources.html` → click the sources-page New Chat button → lands on
|
||||
`/` with the empty state and `bor.chat.v1` removed.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Story: Tune How Brain Answers (Steering Notes)
|
||||
|
||||
**Phase:** `15_steering_notes.md` · **E2E:** `tests/e2e/test_steering.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, when an answer **isn't quite right** (too chatty, wrong
|
||||
assumption, missing context), I want to **tune** Brain right there: a
|
||||
short instruction ("be more concise", "assume I'm on NixOS") that is
|
||||
**stored in the database** and **read into the system prompt** so every
|
||||
future reply follows it.
|
||||
|
||||
- **Given** an answer I don't like
|
||||
- **When** I click "Tune" under it and save an instruction
|
||||
- **Then** the note is persisted in Postgres, shown in the tuning
|
||||
panel (where it can be removed), and injected into the system prompt
|
||||
of every subsequent turn — verifiable in the answer.
|
||||
|
||||
## Acceptance criteria
|
||||
1. New `steering_notes` table (id, note, created_at) via Alembic
|
||||
migration 0003.
|
||||
2. Stateless API: `GET /api/steering` (newest first),
|
||||
`POST /api/steering` (1–2000 chars, 201 / 422),
|
||||
`DELETE /api/steering/{id}` (204 / 404).
|
||||
3. System prompt: when notes exist, a `<tuning>` section (numbered notes,
|
||||
char-budgeted) is appended to **both** HIGH and DEFLECT prompts; when
|
||||
none exist, the prompt is byte-identical to today's.
|
||||
4. Per-turn log line gains `tuning=N`.
|
||||
5. UI: "Tune" button under every completed brain answer (deflected too)
|
||||
→ inline form → save → confirmation; header "Tuning" toggle lists
|
||||
notes with per-note delete; all labeled, live regions, ≥44px targets,
|
||||
Phase-08 tokens.
|
||||
6. Notes render as text (XSS-safe).
|
||||
7. Unit + integration green, `app/` coverage >90%, story E2E green in
|
||||
isolation, one `--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_steering.py`** (mock LLM, seeded KB —
|
||||
the mock echoes the first tuning note into its answer when `<tuning>` is
|
||||
present, so prompt injection is observable in the UI):
|
||||
1. `test_tune_under_answer_persists_and_steers` — answer → Tune → note
|
||||
"STEEER-MARKER be concise" → saved → next answer contains the marker
|
||||
(note reached the system prompt) → note visible in panel with count.
|
||||
2. `test_delete_note_stops_steering` — delete from panel → count 0 →
|
||||
next answer has no marker.
|
||||
3. `test_note_rendered_as_text_xss_safe` — note with `<script>` renders
|
||||
as visible escaped text, never executes.
|
||||
4. `test_tuning_panel_a11y` — toggle aria-expanded, region label,
|
||||
delete buttons labeled, live region announces save/delete.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Story: Thinking Display
|
||||
|
||||
**Phase:** `17_thinking_display` · **E2E:** `tests/e2e/test_thinking_display.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, my local reasoning model *thinks* before it answers — and
|
||||
today those 10–30 seconds are pure silence. I want to *see* Brain think:
|
||||
its reasoning streaming live, tucked away once the answer starts — so long
|
||||
turns feel transparent instead of frozen.
|
||||
|
||||
- **Given** I submit a question the model reasons about
|
||||
- **When** the turn streams reasoning ahead of (or around) the answer
|
||||
- **Then** a collapsible "Thinking" block streams open above the answer
|
||||
bubble, auto-collapses on the first answer token, and stays
|
||||
user-toggleable afterwards — while turns that emit no reasoning render
|
||||
exactly as before.
|
||||
|
||||
## Acceptance criteria
|
||||
1. Turns whose model stream carries reasoning show a "Thinking" block
|
||||
(collapsible, above the answer bubble) that streams open and
|
||||
auto-collapses on the first answer token; always user-toggleable
|
||||
afterwards.
|
||||
2. Turns without reasoning render exactly as before (no block, no layout
|
||||
shift).
|
||||
3. Thinking-without-answer (reasoning exhausted the token budget) shows
|
||||
the existing empty-answer fallback with the thinking block preserved.
|
||||
4. Thinking persists with the message (phase 14) and restores collapsed
|
||||
after reload; "New chat" clears it with everything else.
|
||||
5. Deflected turns show the amber bubble + "Maybe try" chips alongside the
|
||||
thinking block (honesty gate untouched).
|
||||
6. A stream that dies mid-thinking/mid-answer ends in the error state
|
||||
(retry hint) — never a silent half bubble.
|
||||
7. `BOR_STREAM_THINKING=0` suppresses `thinking` events server-side (log
|
||||
line still counts `thinking_chars`).
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **DOM contract** (built dynamically — `index.html` is unchanged): inside
|
||||
`.msg-body`, **before** `.bubble`:
|
||||
```html
|
||||
<details class="thinking" open>
|
||||
<summary>Thinking</summary>
|
||||
<div class="thinking-text"></div>
|
||||
</details>
|
||||
```
|
||||
The block is created **open** at the first `thinking` event and
|
||||
auto-collapses at the first answer token; a late/interleaved `thinking`
|
||||
event only appends to the block — it never reopens once the answer
|
||||
started. Restored messages (phase 14) render the block **collapsed**.
|
||||
- **Styling (Phase-08 tokens):** `details.thinking` — `--surface`
|
||||
background, 1px `--line` border, 3px `--brand-soft` left accent,
|
||||
`--radius-sm`; `summary` — `--brand-ink` on surface (≈**8.7:1**), 0.9rem,
|
||||
**min-height 44px** touch target, CSS chevron `▸` rotating 90° when open
|
||||
(0.15s transform, stills under `prefers-reduced-motion`),
|
||||
`:focus-visible` 3px `--brand` outline; `.thinking-text` — `--ink-soft`
|
||||
on surface (≈**6.9:1**), 0.875rem/1.55, **max-height 320px** with
|
||||
`overflow-y: auto` (long scratchpads scroll instead of pushing the
|
||||
answer off-screen) and tightened paragraph/list margins.
|
||||
- **Accessibility:** native `<details>/<summary>` — a real,
|
||||
keyboard-focusable control with native open/closed announcements. **No
|
||||
live region on the scratchpad** (announcing every reasoning chunk would
|
||||
be hostile to screen readers); the existing `#send-status` region
|
||||
announces turn state as before.
|
||||
- **Feedback contract:** while reasoning streams, the block *is* the
|
||||
visible "thinking" feedback (the typing dots are removed); the UI state
|
||||
stays `thinking` (button disabled with "Thinking…", status text
|
||||
unchanged). The 120s pre-token guard clears on the first `thinking`
|
||||
**or** `delta` event.
|
||||
- **Stream-drop guard:** a stream that ends without a `done` event after
|
||||
at least one thinking/delta frame settles into the error state ("The
|
||||
stream ended before my answer finished — try again?") — never a silent
|
||||
half bubble.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_thinking_display.py`** (mock LLM,
|
||||
`"think out loud"` trigger):
|
||||
1. `test_thinking_block_streams_open_then_collapses` — submit
|
||||
`THINK_QUESTION`. Assert `details.thinking` inside the last
|
||||
`.msg.brain` attaches within 10s (it appears at the first `thinking`
|
||||
event); immediately after attach, assert it is open (the mock's
|
||||
~800-char thinking stream gives a multi-second open window — see the
|
||||
suite's determinism note) and `.thinking-text` is non-empty; once
|
||||
`.bubble` text is non-empty, assert the block is **closed**; at
|
||||
settle: `.thinking-text` contains `Step 2: Check my notes`, the bubble
|
||||
contains the mock's deterministic answer sentence, `.source-chip`
|
||||
count ≥ 1, send button re-enabled.
|
||||
2. `test_thinking_toggle_after_done` — after a settled `THINK_QUESTION`
|
||||
turn, the block is closed; click `summary` → `details[open]` and the
|
||||
full thinking text is visible; click again → closed. (Real
|
||||
keyboard-focusable control.)
|
||||
3. `test_thinking_restored_after_reload` — settle a `THINK_QUESTION`
|
||||
turn; capture the thinking text; `page.reload()`; the restored
|
||||
conversation contains the brain message with a **closed**
|
||||
`details.thinking` whose `.thinking-text` matches the captured text,
|
||||
and the answer bubble + source chips are intact (phase-14 restore
|
||||
path).
|
||||
4. `test_no_thinking_block_without_trigger` — submit `PLAIN_QUESTION`; at
|
||||
settle: `page.locator("details.thinking")` count is 0 (a model that
|
||||
doesn't think renders exactly as before — no layout regression).
|
||||
5. `test_thinking_with_deflection` — submit `THINK_DEFLECT_QUESTION`; at
|
||||
settle: the brain message has `.is-deflected`, a `.maybe-try` group
|
||||
with chips, and a closed `details.thinking` whose text contains
|
||||
`Step 2: Check my notes` (thinking and the honesty gate coexist).
|
||||
Reference in New Issue
Block a user