uncommit .agent
This commit is contained in:
-437
@@ -1,437 +0,0 @@
|
||||
# Brain of Reese — Master Plan
|
||||
|
||||
> **Status:** Phase 1–3 complete (scaffolded, designed, decomposed).
|
||||
> **Rule:** Every agent reads this file first. Decisions marked `LOCKED` in the
|
||||
> Anchors table are settled — do not re-litigate them in a phase.
|
||||
> **Revisions (2026-08-21, owner permission):** A7/A8/A9 revised (multi-format
|
||||
> ingestion, hybrid FTS+vector retrieval, re-tuned honesty gate); dark tech
|
||||
> theme (Phase 08); clickable document viewer (Phase 10); thinking display
|
||||
> (Phase 17, owner permission 2026-08-23); follow-the-bottom scroll
|
||||
> (Phase 18, owner choice 2026-08-23). See roadmap §12.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mission
|
||||
|
||||
A **knowledge base chatbot** that embeds the `~/Homelab` and `~/Deployments`
|
||||
projects into a Postgres vector database and lets anyone ask *Reese* (the
|
||||
bot) questions about them.
|
||||
|
||||
**Product feel:** a chippy, upbeat assistant that is optimistic about the
|
||||
user's ability ("you've got this") and **radically honest** — if retrieval
|
||||
didn't surface anything relevant it says *"I haven't done anything like
|
||||
that"* and offers alternatives instead of hallucinating.
|
||||
|
||||
### In scope (v1)
|
||||
- Chat UI (mobile-friendly, well-styled, no auth, no CDN).
|
||||
- RAG over text knowledge files — `md, markdown, txt, yaml, yml, json, py`
|
||||
by default (A9, revised 2026-08-21) — from `~/Homelab` + `~/Deployments`
|
||||
(and any future directory the importer is pointed at).
|
||||
- Self-hosted models via `https://aipi.reeseapps.com/v1` — `turbo` (chat),
|
||||
`embed` (embeddings, **768 dims — verified**).
|
||||
- Postgres 17 + pgvector, cosine similarity, chunk→document mapping so the
|
||||
LLM receives the **entire relevant document** as context.
|
||||
- Idempotent import/update script, documented in the README.
|
||||
- Ample server logging + explicit UI loading/progress feedback (never a
|
||||
stale submit button).
|
||||
|
||||
### Out of scope (v1)
|
||||
- Auth / multi-user (API is stateless under `/api` so it can be added later).
|
||||
- Binary / non-text content, file uploads, caching layer, message persistence.
|
||||
- Real-time document watching (manual re-import for now).
|
||||
|
||||
---
|
||||
|
||||
## 2. Architectural Anchors (LOCKED DECISIONS)
|
||||
|
||||
| # | Component | Decision | Rationale | Status |
|
||||
|---|-----------|----------|-----------|--------|
|
||||
| A1 | Runtime | Python 3.12+, `uv` for all package management | Fast, reproducible envs; one language for API + tooling | LOCKED |
|
||||
| A2 | Web framework | FastAPI + Pydantic v2 + Uvicorn | Async, typed, SSE-friendly for LLM streaming, free OpenAPI docs | LOCKED |
|
||||
| A3 | Database | **PostgreSQL 17** (`docker.io/postgres:17`, pgvector compiled in via `db/Containerfile`) with **cosine** (`<=>`) search | One system for relational + vectors; pgvector is mature; official base image kept per project standard | LOCKED |
|
||||
| A4 | Orchestration | `compose.yaml`, started with **`podman compose up -d`** | Matches Reese's toolchain | LOCKED |
|
||||
| A5 | LLM backend | OpenAI-compatible `https://aipi.reeseapps.com/v1`; models **`turbo`** (chat) & **`embed`** (embeddings); `openai` async client | Self-hosted, offline from cloud; no new model management | LOCKED |
|
||||
| A6 | Embedding dim | **768** (verified 2026-08-21 against live endpoint via `scripts/llm_probe.py`); configured by `BOR_EMBEDDING_DIM` | User recalled 768 — probe confirmed; dimension is fixed at table creation, so mismatch must fail loudly at import time | LOCKED |
|
||||
| A7 | Retrieval→context | **Hybrid:** cosine top-30 + Postgres FTS top-30 (OR tsquery, `ts_rank`) fused with **RRF (k=60)** → map to parent documents ranked by best fused chunk score → feed the **full text of top-N=2 documents** (deduped, capped at 24k chars) to the LLM | Owner permission 2026-08-21: pure-cosine top-4 missed real docs (gitlab case — best chunk ranked 7th behind vendored-cache junk; score compression 0.41–0.84); the lexical signal finds name-your-tool questions; whole-document context contract preserved | LOCKED (revised 2026-08-21) |
|
||||
| A8 | Honesty gate | **Deflection mode** (LLM must open with a variant of *"I haven't done anything like that"* and offer 2–3 alternative questions) when best cosine < `BOR_RELEVANCE_THRESHOLD` **and** no candidate chunk FTS-matches the question; threshold re-tuned for the `embed` model's compressed score range (default **0.62**, calibrated via `scripts/eval_retrieval.py`; the E2E mock uses its own 0.30 calibration via the app fixture) | Owner permission 2026-08-21: at 0.30 the gate never discriminated (measured corpus range 0.41–0.84); the FTS-OR keeps name-your-tool questions honest-positive; deflection product behavior unchanged | LOCKED (revised 2026-08-21) |
|
||||
| A9 | Content scope | Text formats **`md, markdown, txt, yaml, yml, json, py`** (default, `BOR_IMPORT_EXTENSIONS`), **hidden (dot) directories skipped by default**, plus the exclusion list (`node_modules`, `__pycache__`, `.pytest_cache`, `dist`, `build`, …) | Owner permission 2026-08-21: real notes live in yaml/py/json/txt too; the dot-dir skip removes the ~470 vendored-cache junk docs (`.esphome/.espressif/**`, …) that outranked real content | LOCKED (revised 2026-08-21) |
|
||||
| A10 | Auth | **None in v1**; all endpoints stateless under `/api` | Per user (auth later); statelessness keeps the future migration cheap | LOCKED |
|
||||
| A11 | Frontend | Vanilla HTML/CSS/JS in git; **no CDN** — everything served by FastAPI `StaticFiles`; minified by esbuild in the `Containerfile` build stage; system font stack | No external deps at runtime; tiny, auditable surface; mobile-friendly by construction | LOCKED |
|
||||
| A12 | Aux services | **None in v1** (no Valkey, no SeaweedFS) | No sessions/auth (no store), no uploads (no object storage); add later only if a need appears | LOCKED |
|
||||
| A13 | Migrations | Alembic + SQLAlchemy 2.0 (sync) + psycopg 3 | Standard, reversible, reviewable schema history | LOCKED |
|
||||
| A14 | Debugging | `debugpy` **only when `DEBUGPY=1`** (env var read directly, not via settings); listen `0.0.0.0:5678` (override `DEBUGPY_PORT`), non-blocking, attach-on-demand; **not imported at all when off** | Zero overhead by default per project standard; attach-on-demand keeps production runs clean | LOCKED |
|
||||
| A15 | Chat transport | **SSE streaming** from `POST /api/chat` (deltas + final `done` event with metadata) | Local LLM latency is 10–30s; live token stream + explicit completion event power the UI's feedback states | LOCKED |
|
||||
| A16 | Testing | Per phase: unit + integration (pytest, **coverage >90%** on `app/`) + **one dedicated Playwright E2E file per user story**, run in isolation; E2E uses a deterministic mock LLM by default (`E2E_REAL_LLM=1` opts into live aipi) | One story, one phase, one E2E gate — the pipeline's core invariant | LOCKED |
|
||||
| A17 | Git | Conventional Commits, **always `--no-gpg-sign`**, repo-local `commit.gpgsign=false`; one atomic commit per completed phase | Subsequent agents may lack the GPG key | LOCKED |
|
||||
|
||||
---
|
||||
|
||||
## 3. High-Level Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Podman Compose │
|
||||
Browser │ ┌──────────────────────────────────────┐ │
|
||||
┌──────────┐ HTTP │ │ brain-of-reese/app (FastAPI) │ │
|
||||
│ index.html│◄──────┼─►│ • static frontend (no CDN) │ │
|
||||
│ app.js │ SSE │ │ • /api/chat /api/suggestions │ │
|
||||
└──────────┘ │ │ • /api/health /api/docs │ │
|
||||
│ │ • RAG pipeline (embed→retrieve→gen) │ │
|
||||
│ └──────┬──────────────────┬───────────┘ │
|
||||
│ │ SQL (psycopg) │ OpenAI-compat│
|
||||
│ ┌──────▼──────┐ ┌───────▼────────────┐ │
|
||||
│ │ db: │ └─────────┬──────────┘ │
|
||||
│ │ postgres:17 │ │ │
|
||||
│ │ + pgvector │ │ │
|
||||
│ └─────────────┘ │ │
|
||||
└──────────────────────────────┼────────────┘
|
||||
▼
|
||||
https://aipi.reeseapps.com/v1
|
||||
(self-hosted: turbo, embed)
|
||||
|
||||
Offline tooling (same repo, same venv):
|
||||
scripts/import_docs.py → walks A9-format dirs, chunks, embeds, upserts
|
||||
scripts/eval_retrieval.py → ranks hybrid results for a question (tuning)
|
||||
scripts/llm_probe.py → verifies models + embedding dim
|
||||
```
|
||||
|
||||
### Component breakdown
|
||||
| Component | Responsibility | Lives in |
|
||||
|-----------|----------------|----------|
|
||||
| **App (FastAPI)** | Serves frontend + `/api`; RAG pipeline; logging | `app/` |
|
||||
| **RAG pipeline** | `embed` → pgvector cosine top-K → doc mapping → context assembly → `turbo` (streamed) with persona/honesty prompt | `app/rag/` (added in story phases) |
|
||||
| **Importer** | Directory walk (A9 formats, hidden dirs skipped, exclusions), sha256 delta detection, format-aware chunking, batched embedding, upsert/prune | `scripts/import_docs.py` (story phase) |
|
||||
| **DB** | `documents`, `chunks`, `query_log` + `vector` extension | `db/` image, `alembic/` |
|
||||
| **Frontend** | Chat shell, sources view, loading/feedback states | `frontend/` |
|
||||
|
||||
### Chat data flow
|
||||
```
|
||||
user question
|
||||
→ POST /api/chat {message}
|
||||
→ embed(question) [aipi /v1/embeddings, model=embed]
|
||||
→ cosine top-30 + FTS top-30 (OR tsquery, ts_rank) [pgvector + PG FTS]
|
||||
→ RRF fuse (k=60) → docs ranked by best fused chunk score
|
||||
├─ best cosine >= 0.62 OR fts_hits > 0 → top-2 documents' FULL content
|
||||
│ → system prompt (persona + HONESTY rules + docs)
|
||||
│ → turbo, stream=True → SSE deltas
|
||||
└─ else → DEFLECT_MODE system prompt (weak hits as topics)
|
||||
→ turbo, stream=True → SSE deltas (honest reply)
|
||||
→ query_log row (question, score, deflected, sources, latency)
|
||||
→ final SSE "done" event: {deflected, sources[], suggestions[]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API Design
|
||||
|
||||
All endpoints stateless (A10). Errors: standard JSON `{detail: str}`.
|
||||
|
||||
| Method | Path | Purpose | Story |
|
||||
|--------|------|---------|-------|
|
||||
| GET | `/api/health` | Liveness + db up/down + version | 01 |
|
||||
| GET | `/api/suggestions` | Onboarding suggestion strings | 01 (05 refines) |
|
||||
| GET | `/api/docs` | Indexed document list (source, path, title, chunks, indexed_at) | 02 |
|
||||
| GET | `/api/documents/content?source=…&path=…` | One indexed document's full content (feeds the viewer page) | 10 |
|
||||
| POST | `/api/chat` | RAG chat turn → **SSE stream** | 03/04 |
|
||||
|
||||
### SSE contract (`POST /api/chat`)
|
||||
```
|
||||
data: {"type":"thinking","text":"…"}\n\n
|
||||
data: {"type":"thinking","text":"…"}\n\n
|
||||
data: {"type":"delta","text":"Hey! "}\n\n
|
||||
data: {"type":"delta","text":"Good "}\n\n
|
||||
...
|
||||
data: {"type":"done","deflected":false,"sources":[{"source":"Homelab","path":"kubernetes.md","title":"Kubernetes Homelab Cluster"}],"suggestions":[]}\n\n
|
||||
```
|
||||
Client rules: render deltas as they arrive; render `thinking` text in a
|
||||
collapsible block above the answer; auto-collapse on the first `delta`;
|
||||
tolerate interleaved `thinking` events (append — never reopen once the
|
||||
answer started); the `done` shape is unchanged (thinking never travels on
|
||||
`done`); on `done` append source chips / suggestion chips and clear the
|
||||
busy state; on HTTP/stream error show the error banner + retry (never a
|
||||
stuck button).
|
||||
|
||||
> **SSE revision (phase 17, owner permission 2026-08-23):** the contract
|
||||
> gains one event type — `{"type":"thinking","text":"…"}` — carrying the
|
||||
> model's reasoning ahead of the `delta` events (the `turbo` model emits
|
||||
> `delta.reasoning_content` chunks before the first content chunk, verified
|
||||
> live 2026-08-23; `BOR_STREAM_THINKING=0` suppresses the frames
|
||||
> server-side). `delta` and `done` shapes are unchanged — a recorded
|
||||
> extension of A15, not a silent deviation.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Model (PostgreSQL 17)
|
||||
|
||||
Created by `alembic/versions/0001_initial_schema.py` (idempotent
|
||||
`CREATE EXTENSION IF NOT EXISTS vector`).
|
||||
|
||||
### `documents`
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | `UUID` PK | |
|
||||
| source | `VARCHAR(120)` | source dir basename, e.g. `Homelab` |
|
||||
| path | `VARCHAR(1000)` | relative to source dir, e.g. `ansible/roles/k3s.md` |
|
||||
| full_path | `VARCHAR(2000)` | absolute path at import time (diagnostics) |
|
||||
| title | `VARCHAR(500)` | first markdown H1, else file stem |
|
||||
| content | `TEXT` | **full markdown — the RAG context** |
|
||||
| content_hash | `VARCHAR(64)` | sha256 of content — change detection |
|
||||
| indexed_at | `TIMESTAMPTZ` | |
|
||||
| — | `UNIQUE (source, path)` | upsert key |
|
||||
|
||||
### `chunks`
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | `UUID` PK | |
|
||||
| document_id | `UUID` FK→documents CASCADE | **embedding→document mapping** |
|
||||
| position | `INT` | 0-based order within the doc |
|
||||
| content | `TEXT` | chunk text (heading-aware) |
|
||||
| embedding | `VECTOR(768)` | nullable until embedded (two-phase import) |
|
||||
| tsv | `TSVECTOR` | **generated** `to_tsvector('english', content) STORED` + GIN index (hybrid retrieval, A7) |
|
||||
|
||||
> No vector index in v1: sequential scan is fine at this corpus size
|
||||
> (~100–500 docs). Revisit with an HNSW index if retrieval latency grows.
|
||||
|
||||
### `query_log`
|
||||
`id UUID PK, question TEXT, top_score FLOAT, fts_hits INT, chunk_hits INT, deflected BOOL, sources TEXT, latency_ms INT, created_at TIMESTAMPTZ`
|
||||
|
||||
### Document state transitions
|
||||
```
|
||||
unseen ──import──▶ indexed ──hash changed + re-import──▶ reindexed
|
||||
│
|
||||
└──file deleted + --prune──▶ removed (chunks cascade)
|
||||
```
|
||||
|
||||
### Chunking policy (markdown-aware)
|
||||
Split on `## `/`### ` headings into sections; sub-split any section longer
|
||||
than `BOR_CHUNK_TARGET_CHARS` (2000) at paragraph boundaries with
|
||||
`BOR_CHUNK_OVERLAP_CHARS` (200) overlap; each chunk keeps its nearest
|
||||
preceding heading in the text for retrieval quality.
|
||||
|
||||
**Format-aware (A9, revised):** `yaml`/`yml` split on top-level keys and
|
||||
`---` separators (key line kept as anchor); `json` pretty-printed, split on
|
||||
top-level keys; `py` split on top-level defs/classes (stdlib `ast`);
|
||||
`txt` on paragraphs; markdown unchanged. Every format honors the 1200-char
|
||||
hard cap (aipi ~1024-token request limit).
|
||||
|
||||
---
|
||||
|
||||
## 6. RAG Pipeline & Persona
|
||||
|
||||
### Locked system prompt (sent with every chat turn)
|
||||
```
|
||||
You are "Brain of Reese" — the digital brain of Reese, a self-hoster and
|
||||
homelab tinkerer. Personality: chippy, upbeat, warm, and genuinely
|
||||
optimistic about the user's ability to do things ("you've got this").
|
||||
|
||||
Rules:
|
||||
1. Answer ONLY from the provided document context. Cite which document(s)
|
||||
you used, by path.
|
||||
2. Be concrete: names, versions, ports, hosts, schedules — the specifics in
|
||||
the docs are the value.
|
||||
3. HONESTY GATE: if <relevance> is "LOW", you must NOT pretend to know.
|
||||
Start your answer with a variant of: "I haven't done anything like that."
|
||||
Then offer 2-3 alternative questions about things you DO have notes on.
|
||||
4. Never invent facts, hosts, or steps that are not in the context.
|
||||
5. Keep answers tight: short paragraphs, bullets where helpful.
|
||||
|
||||
<relevance>{HIGH|LOW}</relevance>
|
||||
```
|
||||
- `HIGH` mode appends the full document text under `<documents>…</documents>`.
|
||||
- `LOW` mode (deflection) appends only the **titles** of the weak hits so the
|
||||
model can suggest real alternatives (marker used by the E2E mock:
|
||||
`DEFLECT_MODE` appears in the system prompt).
|
||||
|
||||
### Retrieval (hybrid — A7/A8, revised 2026-08-21)
|
||||
- Embed the question (`embed`, 768-d) → cosine top-30 candidates.
|
||||
- Lexical: OR tsquery over the question's tokens → FTS top-30 by `ts_rank`.
|
||||
- **Reciprocal Rank Fusion** (`Σ 1/(k+rank)`, k=60) → distinct parent docs
|
||||
ranked by best chunk's fused score → top 2 → full content, concatenated,
|
||||
truncated to `BOR_MAX_CONTEXT_CHARS` (24k) with a `[…truncated…]` marker.
|
||||
- Honesty gate: LOW only when `best cosine < BOR_RELEVANCE_THRESHOLD`
|
||||
(default 0.62, calibrated against the `embed` model's measured 0.41–0.84
|
||||
distribution) **and** zero FTS hits among the candidates.
|
||||
|
||||
---
|
||||
|
||||
## 7. UI/UX Strategy
|
||||
|
||||
### 7.1 Layout structure
|
||||
- **App frame:** sticky header (64px) + `<main>` (flex-grow) + footer.
|
||||
Container: `max-width: 72rem; margin-inline: auto; padding-inline: 1.25rem`.
|
||||
- **Chat:** a *centered column capped at 46rem*. This is deliberate: chat is
|
||||
a vertical conversation — a centered, capped column is the correct pattern
|
||||
(NOT a layout bug). The 72rem frame + header/footer ensure the column
|
||||
never reads as a hairline in a sea of whitespace.
|
||||
- **Sources page:** full-width responsive **table** (min 640px, horizontal
|
||||
scroll wrapper on small screens) + stat cards in
|
||||
`grid-template-columns: repeat(auto-fit, minmax(170px, 1fr))`.
|
||||
No skinny single-column lists anywhere: lists/tables/grids use ≥80–90% of
|
||||
the container width.
|
||||
- **Mobile (≤640px):** suggestion chips become a horizontally scrollable row;
|
||||
composer stays reachable with `safe-area-inset-bottom`; touch targets ≥44px.
|
||||
|
||||
### 7.2 Accessibility (WCAG 2.1 AA)
|
||||
- Semantic landmarks on every page: `<header>`, `<nav aria-label>`,
|
||||
`<main>`, `<footer>`; skip-link to `#main`.
|
||||
- Every control labeled: visible `<label>` or `aria-label` (icon-only
|
||||
buttons always get `aria-label`); form input has a (visually-hidden) label.
|
||||
- Live regions: message stream `aria-live="polite"`; typing indicator
|
||||
`role="status"`; banner `role="status"`; errors `role="alert"`.
|
||||
- **Dark tech theme (Phase 08, 2026-08-21)** — page `#0a0e17`, surface
|
||||
`#121a2e`; ink `#e8ebf4` on surface ≈14.5:1; ink-soft `#9aa4bd` on
|
||||
surface ≈6.9:1; **dark ink `#0a0e17` on brand `#6d78f2` ≈5.2:1** (white
|
||||
on brand ≈3.7:1 — never used for text); brand-ink `#a5b4fc` on
|
||||
brand-soft `#232b52` ≈6.9:1; deflection `#fbbf24` on `#2b2110` ≈9.5:1
|
||||
(border `#f59e0b`); error `#fca5a5` on `#2d1318` ≈9.1:1. All computed,
|
||||
all ≥4.5:1. `prefers-reduced-motion` also stills the Phase-08 background
|
||||
layer.
|
||||
- `:focus-visible` outline 3px; `prefers-reduced-motion` respected by the
|
||||
typing/spinner animations.
|
||||
|
||||
### 7.3 No external dependencies
|
||||
- System font stack only (no font files to bundle, no CDN fonts).
|
||||
- Zero `<script src="https://…">` / `<link href="https://…">` — enforced by
|
||||
an integration test (`tests/integration/test_api.py::test_index_html_served_locally`)
|
||||
and re-checked by every UI phase's verification step.
|
||||
- Markdown rendering is a ~60-line local function (escape-first, then
|
||||
transform) — XSS-safe, no library.
|
||||
|
||||
### 7.4 Visual feedback standard (the "never stale" contract)
|
||||
| State | UI |
|
||||
|-------|----|
|
||||
| **Idle** | Send button enabled, label "Send". |
|
||||
| **Thinking (pre-token)** | 3-dot typing bubble + button disabled with spinner, label "Thinking…". |
|
||||
| **Thinking (model reasoning)** | Collapsible `.thinking` block streams open (replaces the typing dots as the live indicator), auto-collapses on the first answer token, toggleable afterwards, persisted with the message (phase 14); 120s guard clears on the first `thinking` *or* `delta` event. |
|
||||
| **Streaming** | Deltas append live into the brain bubble; button stays busy. |
|
||||
| **Done (answer)** | Source chips under the bubble (mono, path-based); button re-enabled. |
|
||||
| **Done (deflected)** | Amber-bordered bubble + "Maybe try:" suggestion chips. |
|
||||
| **Error** | Red banner (`role="alert"`) with retry hint; button re-enabled. |
|
||||
| **KB offline** | Amber banner at top of chat ("start Postgres…"); chat disabled with explanation. |
|
||||
| **Guard** | 120s client-side timeout → error state (a button can never sit "stuck" forever). |
|
||||
| **Scroll (follow-the-bottom, phase 18)** | The page auto-scrolls only while the user is pinned to the bottom (≤200px band, `NEAR_BOTTOM_PX` — the composer zone; submitting reveals the user's message through the same gate, which holds in real use); scrolling up holds the viewport for the rest of the turn (thinking and answer alike); restore lands one-shot on the latest message. |
|
||||
|
||||
> The **Thinking (model reasoning)** row is a phase-17 addition (owner
|
||||
> permission 2026-08-23) — see the §4 SSE revision.
|
||||
>
|
||||
> The **Scroll** row is a phase-18 addition (owner choice 2026-08-23 —
|
||||
> option 1: follow-the-bottom, no "↓ new content" pill).
|
||||
|
||||
### 7.5 Component inventory (ids used by tests)
|
||||
`#messages` (stream), `#empty-state`, `#suggestions`, `.suggestion-chip`,
|
||||
`#composer`, `#message-input`, `#send-btn` / `#send-label`, `#typing-indicator`,
|
||||
`.msg.user/.msg.brain .bubble`, `.source-chip`, `.msg.brain.is-deflected`,
|
||||
`#kb-banner`, `#app-version`; sources: `#stat-docs`, `#stat-chunks`,
|
||||
`#stat-last`, `#docs-table`, `#docs-tbody`, `#sources-empty`; viewer
|
||||
(Phase 10): `/document.html`, `#doc-title`, `#doc-meta`, `#doc-content`,
|
||||
`.doc-raw`, `.format-badge`, `#doc-not-found`, `.doc-link` (Sources table
|
||||
path links); thinking (phase 17, owner permission 2026-08-23):
|
||||
`.thinking`, `.thinking-text` (collapsible thinking block; plain
|
||||
`<summary>`, no id).
|
||||
|
||||
---
|
||||
|
||||
## 8. Debugging (debugpy protocol)
|
||||
|
||||
- `DEBUGPY` unset/`0` → **`debugpy` is never imported** (verified by unit test).
|
||||
- `DEBUGPY=1` → listener on `0.0.0.0:${DEBUGPY_PORT:-5678}`, **non-blocking**,
|
||||
app continues; IDE attaches on demand.
|
||||
- Entry point: `app/core/debugging.py::configure_debugging()` called at the top
|
||||
of `app/main.py` module import — so `uv run uvicorn app.main:app`,
|
||||
`python -m scripts.…`, and tests all honor it.
|
||||
- VS Code: `"type": "debugpy", "request": "attach", "connect": {"host": "localhost", "port": 5678}`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Observability
|
||||
|
||||
- **App logs:** single-line `timestamp LEVEL logger :: message` on stdout;
|
||||
uvicorn access logs on. INFO by default (`BOR_LOG_LEVEL`).
|
||||
- **Per-chat-turn log line (required):**
|
||||
`question=… embed_ms=… top_score=… fts_hits=… tuning=N threshold=… deflected=… sources=… thinking_chars=… total_ms=…`
|
||||
(`thinking_chars=` counts the turn's reasoning chars — phase 17, owner
|
||||
permission 2026-08-23 — and is counted even when `BOR_STREAM_THINKING=0`
|
||||
suppresses the frames.)
|
||||
- **Importer logs:** per-file `added|updated|unchanged|pruned` + summary
|
||||
(counts, embedding batches, total time).
|
||||
- **`query_log` table:** durable record of every question (score, deflection,
|
||||
sources, latency) for tuning the threshold and finding gaps in the docs.
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing Strategy (LOCKED — A16)
|
||||
|
||||
| Layer | Tooling | Runs | Gate |
|
||||
|-------|---------|------|------|
|
||||
| Unit | pytest | `uv run pytest tests/unit` | pass |
|
||||
| Integration | pytest + FastAPI TestClient | `uv run pytest tests/integration` | pass |
|
||||
| Coverage | pytest-cov on `app/` | `uv run pytest --cov=app --cov-report=term-missing` | **>90%** per phase |
|
||||
| E2E | Playwright (sync API), one file per story | `uv run pytest tests/e2e/test_<story>.py -v --no-cov` | passes **in isolation** |
|
||||
|
||||
- **E2E determinism:** `tests/e2e/mock_llm.py` serves a deterministic
|
||||
OpenAI-compatible API. Embeddings are genuine L2-normalized token-overlap
|
||||
vectors, so the cosine threshold behaves like production: on-topic
|
||||
questions retrieve, off-topic questions deflect. `E2E_REAL_LLM=1` switches
|
||||
the app fixture to live aipi (needs imported KB).
|
||||
- **E2E prerequisites:** `podman compose up -d db`; Chromium installed via
|
||||
`uv run playwright install chromium`.
|
||||
- DB isolation: story E2E fixtures truncate `query_log` (and re-import
|
||||
fixtures for import-dependent stories) per test module.
|
||||
|
||||
---
|
||||
|
||||
## 11. Import & Update Workflow (documented in README)
|
||||
|
||||
```
|
||||
# first import (and any future refresh):
|
||||
uv run python -m scripts.import_docs # defaults: ~/Homelab ~/Deployments
|
||||
uv run python -m scripts.import_docs --source ~/OtherProject # extra dirs
|
||||
uv run python -m scripts.import_docs --prune # drop deleted / filtered-out files
|
||||
uv run python -m scripts.eval_retrieval "How did I install gitlab?"
|
||||
uv run python -m scripts.llm_probe # sanity: models + dim
|
||||
```
|
||||
Behavior: sha256 delta per `(source, path)` — unchanged files are skipped
|
||||
(no re-embedding); changed files are re-chunked + re-embedded (chunks
|
||||
replaced atomically); `--prune` removes docs whose files disappeared or no
|
||||
longer match the format filter. Formats per A9 (revised): `md, markdown,
|
||||
txt, yaml, yml, json, py` (`BOR_IMPORT_EXTENSIONS`), hidden (dot)
|
||||
directories skipped, exclusion list applied. `scripts/eval_retrieval.py`
|
||||
ranks live hybrid results for a question (retrieval tuning).
|
||||
|
||||
---
|
||||
|
||||
## 12. Roadmap (one story → one phase → one Playwright gate)
|
||||
|
||||
| Phase | File | Story | Playwright gate |
|
||||
|-------|------|-------|-----------------|
|
||||
| 01 | `01_infrastructure.md` | — (foundation) | `tests/e2e/test_smoke.py` |
|
||||
| 02 | `02_story_import_documents.md` | `import-documents.md` | `tests/e2e/test_import_documents.py` |
|
||||
| 03 | `03_story_chat_rag.md` | `chat-rag-answer.md` | `tests/e2e/test_chat_rag.py` |
|
||||
| 04 | `04_story_honest_deflection.md` | `honest-deflection.md` | `tests/e2e/test_honest_deflection.py` |
|
||||
| 05 | `05_story_suggestion_chips.md` | `suggestion-chips.md` | `tests/e2e/test_suggestion_chips.py` |
|
||||
| 06 | `06_story_loading_feedback.md` | `loading-feedback.md` | `tests/e2e/test_loading_feedback.py` |
|
||||
| 07 | `07_story_responsive_polish.md` | `responsive-polish.md` | `tests/e2e/test_responsive_polish.py` |
|
||||
| 08 | `08_story_dark_tech_theme.md` | `dark-tech-theme.md` | `tests/e2e/test_dark_tech_theme.py` |
|
||||
| 09 | `09_story_retrieval_quality.md` | `retrieval-quality.md` | `tests/e2e/test_retrieval_quality.py` |
|
||||
| 10 | `10_story_document_viewer.md` | `document-viewer.md` | `tests/e2e/test_document_viewer.py` |
|
||||
| 17 | `17_thinking_display.md` | `thinking-display.md` | `tests/e2e/test_thinking_display.py` |
|
||||
| 18 | `18_follow_bottom_scroll.md` | `follow-bottom-scroll.md` | `tests/e2e/test_follow_bottom_scroll.py` |
|
||||
|
||||
> Row 17 (thinking display) added 2026-08-23 with owner permission — the
|
||||
> A15 SSE extension recorded in §4.
|
||||
>
|
||||
> Row 18 (follow-the-bottom scroll) added 2026-08-23 with owner choice —
|
||||
> option 1: follow-the-bottom, no "↓ new content" pill (UI-behavior-only
|
||||
> change; no anchor revised).
|
||||
|
||||
Completion = unit+integration green, coverage >90%, story E2E green in
|
||||
isolation, UI verification passed, **one `--no-gpg-sign` commit**.
|
||||
|
||||
---
|
||||
|
||||
## 13. Future (post-v1 hooks, deliberately not built)
|
||||
- Auth (stateless API makes this a drop-in: sessions → Valkey).
|
||||
- HNSW index on `chunks.embedding` at scale.
|
||||
- Conversation persistence (messages tables).
|
||||
- Watchdog auto-re-import (inotify) — until then the script is the truth.
|
||||
- More sources: any directory of A9-format files via `--source`.
|
||||
@@ -1,61 +0,0 @@
|
||||
# Story: Chat RAG Answer (happy path)
|
||||
|
||||
**Phase:** `03_story_chat_rag.md` · **E2E:** `tests/e2e/test_chat_rag.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user** (friend, colleague, future me), I want to ask Brain a question
|
||||
about Reese's setup and get a grounded, chippy answer that points me at the
|
||||
exact documentation — so I can actually *do* the thing.
|
||||
|
||||
- **Given** the knowledge base is imported and I type "How is my Kubernetes
|
||||
cluster set up?"
|
||||
- **When** Brain embeds the question, retrieves the top chunks by cosine
|
||||
similarity, maps them to their parent documents, and feeds the **full
|
||||
document text** to `turbo`
|
||||
- **Then** I see a streamed, upbeat answer that cites the source
|
||||
(`Homelab/kubernetes.md`), grounded in the doc's specifics (Talos,
|
||||
Cilium, the node list) — and never in anything the docs don't say.
|
||||
|
||||
## Acceptance criteria
|
||||
1. `POST /api/chat` streams SSE: `delta` events then a final `done` event
|
||||
carrying `{deflected, sources[], suggestions[]}` (PLAN §4).
|
||||
2. Retrieval: top-4 chunks (`BOR_TOP_K_CHUNKS`), cosine via pgvector
|
||||
`<=>`, score = 1 − distance.
|
||||
3. Context assembly: top-2 **distinct documents** by best-chunk score, full
|
||||
content, capped at `BOR_MAX_CONTEXT_CHARS` with truncation marker.
|
||||
4. System prompt = locked persona + HONESTY GATE rules (PLAN §6), with
|
||||
`<relevance>HIGH</relevance>` and `<documents>…</documents>`.
|
||||
5. The answer arrives **streamed** (multiple deltas), rendered live.
|
||||
6. Source chips (mono, `source/path`) render under the answer bubble.
|
||||
7. Per-turn log line emitted (PLAN §9) and a `query_log` row inserted
|
||||
(`deflected=false`, top_score, sources, latency).
|
||||
8. LLM/embedding failure → JSON/SSE error the UI turns into the error banner
|
||||
(no hang, no stale button).
|
||||
|
||||
## UI Visualization & Structure
|
||||
- Chat column centered at 46rem (PLAN §7.1); user bubble right (brand
|
||||
indigo, white text ≥4.5:1), Brain bubble left (white, ink text, avatar 🧠).
|
||||
- While generating: typing indicator → live-appended text (see
|
||||
loading-feedback story for the full state machine — this story only needs
|
||||
"deltas render as they arrive and the button is busy throughout").
|
||||
- **Source chips:** pill, `font-family: mono`, `bg --brand-soft`,
|
||||
`color --brand-ink` (6.3:1), `max-width` + ellipsis; each shows
|
||||
`Homelab/kubernetes.md`. `aria-label` when truncated.
|
||||
- Bubble content is safe-rendered markdown (escape-first local renderer —
|
||||
`<script>` in an LLM answer must NOT execute).
|
||||
- On mobile the bubbles expand to ~92% width; chips wrap.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_chat_rag.py`** (mock LLM, seeded KB):
|
||||
1. `test_on_topic_question_streams_grounded_answer` — type "How is my
|
||||
Kubernetes cluster set up?", submit; assert: answer bubble appears with
|
||||
streamed content (mock's answer references the question), a `.source-chip`
|
||||
containing `kubernetes.md` is present, send button returns to enabled
|
||||
"Send".
|
||||
2. `test_chat_logs_query` — after the turn, `GET /api/health` is still ok AND
|
||||
(via a test-only detail: query the DB directly) a `query_log` row exists
|
||||
with `deflected=false` and sources including `kubernetes.md`.
|
||||
3. `test_sse_stream_shape` — raw `httpx` streaming request to `/api/chat`:
|
||||
assert multiple `data:` delta events precede a `done` event with
|
||||
`deflected: false` and a non-empty `sources` list.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Story: Honest Deflection
|
||||
|
||||
**Phase:** `04_story_honest_deflection.md` · **E2E:** `tests/e2e/test_honest_deflection.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, when I ask something Brain genuinely has no notes about, I
|
||||
want it to **admit it plainly** and still be helpful — so I never walk away
|
||||
with a confident-sounding hallucination.
|
||||
|
||||
- **Given** the knowledge base is about homelab/infra topics
|
||||
- **When** I ask "How do I bake sourdough bread?"
|
||||
- **Then** retrieval's best similarity is below `BOR_RELEVANCE_THRESHOLD`,
|
||||
Brain switches to deflection mode, opens with a variant of
|
||||
**"I haven't done anything like that"**, stays chippy, and offers 2–3
|
||||
alternative questions about things it *does* know (from the weak hits).
|
||||
|
||||
## Acceptance criteria
|
||||
1. Gate: `max(1 − cosine_distance) < BOR_RELEVANCE_THRESHOLD` ⇒
|
||||
`<relevance>LOW</relevance>` + `DEFLECT_MODE` system prompt (weak-hit
|
||||
**titles only**, no full docs).
|
||||
2. The LLM is still called (voice stays chippy); the prompt forces the
|
||||
honesty phrasing + alternative suggestions (PLAN §6).
|
||||
3. `done` event carries `deflected: true` and `suggestions[]` (2–3 strings).
|
||||
4. `query_log` row has `deflected=true` + the weak `top_score`.
|
||||
5. UI: the deflected bubble is visually distinct (amber border/background),
|
||||
and "Maybe try:" chips render below it; clicking a chip asks that
|
||||
question (delegated to the suggestion-chips story for chip behavior;
|
||||
here only rendering).
|
||||
6. Threshold is env-tunable; lowering it to ~0 makes every question an
|
||||
"answer" (documented in README troubleshooting).
|
||||
7. Unit tests cover the gate boundary (score == threshold → answer mode;
|
||||
just below → deflect) using a fake retriever — no LLM needed.
|
||||
|
||||
## UI Visualization & Structure
|
||||
- Deflected brain bubble: `background: var(--accent-bg) #fff7e8`,
|
||||
`border: 1px solid var(--accent-line) #f59e0b`, text stays `var(--ink)`
|
||||
(or accent-ink for emphasis ≥4.5:1) — clearly "different" from a normal
|
||||
answer without being alarm-red (it's honesty, not an error).
|
||||
- Below the bubble: `Maybe try:` label (visually hidden for SR, `aria-label`
|
||||
on the chip group) + 2–3 `.suggestion-chip` pills (same chip component as
|
||||
onboarding: ≥44px height, brand-soft bg, brand-ink text).
|
||||
- Bubble may include the model's alternative list in text too; chips are the
|
||||
one-click affordance.
|
||||
- Contrast audit: `#92400e` on `#fff7e8` ≈ 8.7:1 ✓; chip text on chip bg ≥6:1 ✓.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_honest_deflection.py`** (mock LLM, seeded KB):
|
||||
1. `test_off_topic_question_deflects_honestly` — ask "How do I bake
|
||||
sourdough bread?"; assert the answer bubble is `.is-deflected`, its text
|
||||
matches /haven't done anything like that/i, and ≥2 "Maybe try:" chips
|
||||
render below it.
|
||||
2. `test_deflection_suggestions_are_clickable` — click the first deflection
|
||||
chip; assert the input is populated/focus behavior per chip contract and a
|
||||
new user bubble is created.
|
||||
3. `test_threshold_gate_unit_boundary` is a **unit** test (not Playwright):
|
||||
retriever returns score 0.30 → HIGH; 0.2999 → LOW (mocked components).
|
||||
@@ -1,62 +0,0 @@
|
||||
# Story: Import Documents
|
||||
|
||||
**Phase:** `02_story_import_documents.md` · **E2E:** `tests/e2e/test_import_documents.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **Reese** (the owner), I want to point the importer at one or more
|
||||
directories of markdown files and have them chunked, embedded, and stored in
|
||||
Postgres — so that Brain's answers always reflect my *current* documentation.
|
||||
|
||||
- **Given** the `~/Homelab` and `~/Deployments` trees (or any `--source` dirs)
|
||||
- **When** I run `uv run python -m scripts.import_docs`
|
||||
- **Then** every `*.md` file (after the exclusion list) is present in the
|
||||
`documents` table with its full content, a sha256 hash, and chunk rows with
|
||||
768-dim embeddings; unchanged files are skipped on re-runs; and the
|
||||
Sources page in the browser shows the indexed documents.
|
||||
|
||||
## Acceptance criteria
|
||||
1. `scripts/import_docs.py` accepts repeatable `--source PATH` (default
|
||||
`~/Homelab` `~/Deployments`), `--prune`, and `--limit N` (debug).
|
||||
2. Only `*.md` files are imported; excluded dirs: `.venv`, `node_modules`,
|
||||
`.git`, `__pycache__`, `.pytest_cache`, `dist`, `build` (PLAN A9).
|
||||
3. Delta detection by sha256 on `(source, path)`: unchanged → skipped
|
||||
(no re-embedding); changed → re-chunked + re-embedded, old chunks
|
||||
replaced atomically.
|
||||
4. Embeddings are batched (`BOR_EMBED_BATCH_SIZE`) against `aipi /v1/embeddings`
|
||||
(`embed`); a dimension mismatch fails loudly with an actionable message.
|
||||
5. Rich per-file logging (`added|updated|unchanged|pruned`) + summary.
|
||||
6. `GET /api/docs` returns the document list; the Sources page renders it
|
||||
(stat cards + table) or the designed empty state when none exist.
|
||||
7. The whole flow works against the **mock LLM** in E2E (deterministic),
|
||||
and against real aipi for manual runs.
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **Sources page (`/sources.html`), desktop:** header row (h1 + sub), then
|
||||
stat cards in `repeat(auto-fit, minmax(170px,1fr))` (documents / chunks /
|
||||
last indexed), then a **full-width table** inside a scroll wrapper
|
||||
(min-width 640px → horizontal scroll, never a squeezed hairline list).
|
||||
Columns: Source · Path (mono, ellipsized w/ `title`) · Title · Chunks ·
|
||||
Indexed. Uses ≥85% of the 72rem container width.
|
||||
- **Empty state (no docs):** centered card with 📂, "Nothing indexed yet",
|
||||
and the exact import command in a `<code>` pill. No dead links, no
|
||||
placeholder tables.
|
||||
- **Accessibility:** `<caption class="visually-hidden">` on the table,
|
||||
`scope="col"` on headers, `role="region"` + `tabindex="0"` on the scroll
|
||||
wrapper (keyboard scrollable), stat values have visible labels.
|
||||
- **Mobile:** stat cards stack (auto-fit), table scrolls horizontally,
|
||||
no content below the fold is unreachable.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_import_documents.py`** (one isolated
|
||||
Playwright suite for this story):
|
||||
1. *Seeding:* run the import function in-process against
|
||||
`tests/fixtures/docs/` (mock embeddings, temp DB state) — a fixture, not
|
||||
the test's subject.
|
||||
2. `test_sources_page_lists_indexed_docs` — goto `/sources.html`, assert stat
|
||||
cards show the fixture counts and the table rows include
|
||||
`homelab/kubernetes.md`, `homelab/backups.md`, `deployments/new-service.md`.
|
||||
3. `test_sources_table_layout` — table wrapper width ≥80% of container;
|
||||
`caption` present; on a 375px viewport the wrapper scrolls horizontally.
|
||||
4. `test_empty_state_when_no_docs` (fresh/truncated DB) — empty state visible
|
||||
with the import command; table hidden.
|
||||
@@ -1,61 +0,0 @@
|
||||
# Story: Loading Feedback & Progress
|
||||
|
||||
**Phase:** `06_story_loading_feedback.md` · **E2E:** `tests/e2e/test_loading_feedback.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, local LLM answers can take 10–30+ seconds. I want to *always*
|
||||
know Brain is working — a clear "thinking" state, live progress as tokens
|
||||
arrive, and a definitive end — so I never stare at a stale Send button
|
||||
wondering if it's stuck.
|
||||
|
||||
- **Given** I submit a question
|
||||
- **When** the answer is in flight (pre-token, streaming, or erroring)
|
||||
- **Then** the UI shows an unambiguous in-progress state, transitions
|
||||
cleanly to done/error, and the send button is never left in a zombie state.
|
||||
|
||||
## Acceptance criteria
|
||||
1. **Pre-token:** typing-indicator bubble (3 animated dots, `role="status"`,
|
||||
`aria-label="Brain of Reese is thinking"`) + send button disabled with
|
||||
spinner and label "Thinking…".
|
||||
2. **Streaming:** first delta replaces the typing indicator; text appends
|
||||
live; button stays busy until `done`.
|
||||
3. **Done:** button re-enabled, label "Send", input focused back.
|
||||
4. **Error paths:** (a) LLM/DB error → red banner `role="alert"` with retry
|
||||
hint, button re-enabled; (b) **120s client timeout** → same error state
|
||||
(guard against a hung stream); (c) page reload mid-stream loses the
|
||||
stream but the composer is usable again (state is turn-local).
|
||||
5. **Slow-model E2E:** the mock LLM's 3s warm-up (message containing
|
||||
"pretend to think slowly") must show the typing indicator for ≥2s before
|
||||
any text appears.
|
||||
6. Server side: per-turn log includes `embed_ms` / total `total_ms` (PLAN
|
||||
§9) so "slow" is diagnosable.
|
||||
7. `prefers-reduced-motion`: dots/spinner still visible (slower/static) —
|
||||
feedback is never removed, only calmed.
|
||||
|
||||
## UI Visualization & Structure
|
||||
- State machine (single source of truth in `app.js`):
|
||||
`idle → thinking → streaming → done | error → idle`.
|
||||
- Typing indicator: 8px dots, `--ink-soft`, staggered 1.2s bounce; inside a
|
||||
normal brain bubble (same geometry as answers) so the layout doesn't jump.
|
||||
- Send button busy style: `background: #a5b4fc` (disabled contrast still
|
||||
fine — it's a disabled state), 16px spinner (2.5px ring, white top
|
||||
arc), label swap "Send" ↔ "Thinking…".
|
||||
- Error banner: `--err-bg/--err-ink/--err-line`, top of chat shell,
|
||||
`role="alert"`, includes the actionable hint ("Try again — if this
|
||||
persists, check the LLM is reachable").
|
||||
- Elapsed-time hint: after 10s still pre-token, the typing bubble's aria
|
||||
label becomes "…still thinking (12s)" — SR users are never left guessing.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_loading_feedback.py`** (mock LLM):
|
||||
1. `test_typing_indicator_during_slow_think` — ask "pretend to think slowly
|
||||
then tell me about kubernetes"; assert `#typing-indicator` visible within
|
||||
500ms of submit, still visible at ~2s, gone by the time the answer text
|
||||
is present.
|
||||
2. `test_button_state_machine` — during the in-flight turn: `#send-btn`
|
||||
disabled + label "Thinking…"; after done: enabled + "Send".
|
||||
3. `test_streaming_appends_live` — capture bubble text at two timestamps
|
||||
during the stream; second length > first (progress is visible).
|
||||
4. `test_error_banner_on_llm_down` (fixture stops the mock) — submit;
|
||||
assert `role=alert` banner visible and button re-enabled within timeout.
|
||||
@@ -1,34 +0,0 @@
|
||||
# Story: Long Answers (No Truncation)
|
||||
|
||||
**Phase:** `11_long_answers.md` · **E2E:** `tests/e2e/test_long_answers.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, I want Brain to be able to answer at full length (up to
|
||||
32 768 output tokens) so complex questions ("walk me through the whole
|
||||
setup", "list every service and its config") get a **complete** answer
|
||||
instead of one that stops mid-sentence.
|
||||
|
||||
- **Given** any question that deserves a long answer
|
||||
- **When** Brain streams its reply
|
||||
- **Then** the reply runs to its natural end — the model is allowed up to
|
||||
32 768 output tokens, not a hard 700-token cap.
|
||||
|
||||
## Acceptance criteria
|
||||
1. `LLMClient.chat_stream` sends `max_tokens` from settings
|
||||
(`BOR_MAX_OUTPUT_TOKENS`, default **32 768**) — the hard-coded 700 is
|
||||
gone.
|
||||
2. A genuinely long streamed answer (several thousand words) arrives
|
||||
**complete** in the browser — final line intact (E2E).
|
||||
3. Setting is overridable via env; unit-tested.
|
||||
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_long_answers.py`** (mock LLM, seeded KB):
|
||||
1. `test_long_answer_streams_to_completion` — question with the
|
||||
"write a long answer" trigger → mock emits a ~4 000-word deterministic
|
||||
answer and **honors `max_tokens`** (word-based) → the browser shows the
|
||||
final line of the answer; under the old 700 cap the tail is missing.
|
||||
2. `test_normal_answer_unaffected` — a normal question still streams a
|
||||
complete, short answer.
|
||||
@@ -1,65 +0,0 @@
|
||||
# Story: Responsive, Polished, Accessible UI
|
||||
|
||||
**Phase:** `07_story_responsive_polish.md` · **E2E:** `tests/e2e/test_responsive_polish.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user on any device** — phone at the coffee shop, laptop at the
|
||||
desk — I want the chat to be comfortable to read and drive: no pinched
|
||||
layout, no tiny tap targets, no contrast failures, no wasted whitespace —
|
||||
so asking Brain feels effortless everywhere.
|
||||
|
||||
- **Given** any viewport from 360px to 1600px+
|
||||
- **When** I use the chat and the Sources page
|
||||
- **Then** the layout follows the PLAN §7 standards (containers, chat
|
||||
column, full-width table), all interactive elements are reachable by
|
||||
keyboard, and every color pair meets WCAG 2.1 AA.
|
||||
|
||||
## Acceptance criteria
|
||||
1. **Layout:** container 72rem centered with side padding; chat column
|
||||
capped at 46rem centered; Sources table uses full container width with
|
||||
horizontal scroll below 640px (never a squeezed single hairline column).
|
||||
2. **Mobile (375px):** header condenses, composer reachable above the home
|
||||
indicator (`safe-area-inset-bottom`), chips scroll horizontally, bubbles
|
||||
≤92% width, no horizontal page overflow (document `scrollWidth ==
|
||||
clientWidth`).
|
||||
3. **A11y sweep:** landmarks present on both pages (`header/nav/main/
|
||||
footer`); skip link works (focus `#main`); all inputs have labels
|
||||
(visible or programmatically associated); all icon-only buttons have
|
||||
`aria-label`; `:focus-visible` outline on every control (Tab through).
|
||||
4. **Contrast:** automated check of the key pairs (ink/surface,
|
||||
ink-soft/surface, white/brand, chip-ink/chip-bg, deflection pairs) ≥4.5:1
|
||||
(test computes from computed styles; PLAN §7.2 table is the baseline).
|
||||
5. **No-CDN re-verification** on both pages (no `http(s)://` src/href
|
||||
except same-origin `/…`).
|
||||
6. **Reduced motion:** with `prefers-reduced-motion`, typing dots and
|
||||
spinner do not animate (computed `animation: none` or duration ≥2s).
|
||||
7. Long words/paths (e.g. a 60-char file path) wrap or ellipsize without
|
||||
breaking the bubble (overflow-wrap anywhere).
|
||||
|
||||
## UI Visualization & Structure
|
||||
- This phase is the **visual audit + fix pass**: it does not add features,
|
||||
it enforces PLAN §7 end-to-end on chat + sources.
|
||||
- Desktop 1440px screenshot pass: header 64px, chat centered with balanced
|
||||
margins, sources table edge-to-edge within the container.
|
||||
- Tablet 768px: chat column uses most of the width (≤46rem cap), no
|
||||
mid-column dead zones; stat cards 3-across.
|
||||
- Phone 375px: one-column flow, 44px+ targets, thumb-zone composer.
|
||||
- Any deviation found → fix in `frontend/assets/styles.css` (tokens first),
|
||||
re-verify with the E2E below.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_responsive_polish.py`**:
|
||||
1. `test_no_horizontal_overflow_at_viewports` — for 360/375/768/1280/1600:
|
||||
`document.documentElement.scrollWidth <= clientWidth` on both pages.
|
||||
2. `test_chat_column_capped_and_centered` — at 1600px, `.chat-shell`
|
||||
width ≤ 46rem (736px) + 2% and horizontally centered (±2%).
|
||||
3. `test_sources_table_full_width` — at 1280px, `.table-wrap` width ≥ 80%
|
||||
of `.container` width.
|
||||
4. `test_a11y_landmarks_and_labels` — both pages: landmarks present,
|
||||
skip link target `#main` focusable, `#message-input` has an associated
|
||||
label, no `<img>`/icon buttons without accessible name.
|
||||
5. `test_contrast_pairs_pass_aa` — computed-color contrast assertions for
|
||||
the PLAN §7.2 pairs (helper computes WCAG relative luminance).
|
||||
6. `test_reduced_motion_respected` — emulate `reducedMotion: 'reduce'`;
|
||||
typing dots have no running animation (or ≥2s duration).
|
||||
@@ -1,58 +0,0 @@
|
||||
# Story: Suggestion Chips
|
||||
|
||||
**Phase:** `05_story_suggestion_chips.md` · **E2E:** `tests/e2e/test_suggestion_chips.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user who opens the chat for the first time** (or after a deflection),
|
||||
I want a few **concrete example questions** right in front of me — so I
|
||||
immediately understand what Brain is good at and can start with zero
|
||||
friction.
|
||||
|
||||
- **Given** I land on the chat page
|
||||
- **When** the app is healthy
|
||||
- **Then** I see 3–4 suggestion chips drawn from `GET /api/suggestions`
|
||||
(defaults in settings, tuned to the real Homelab topics), and clicking one
|
||||
fills the composer and submits it.
|
||||
|
||||
## Acceptance criteria
|
||||
1. `GET /api/suggestions` returns the configured list (settings-driven,
|
||||
overridable via `BOR_SUGGESTIONS` JSON env).
|
||||
2. Chips render in the empty state as `<button class="suggestion-chip">`
|
||||
(real buttons, not links/divs) with `role="list"` container +
|
||||
`role="listitem"` items; `aria-label="Suggested questions"` on the group.
|
||||
3. Click behavior: fills `#message-input`, focuses it, **and submits**
|
||||
(one tap → answer). Keyboard: Tab to chip, Enter activates.
|
||||
4. After the first user message the empty state (and its chips) is replaced
|
||||
by the conversation; chips re-appear only on deflection (see
|
||||
honest-deflection story).
|
||||
5. If `/api/suggestions` fails, the chat still works (progressive
|
||||
enhancement — no chips, no error spam).
|
||||
6. Mobile: chips become a horizontally scrollable single row
|
||||
(no wrapping into the composer's territory).
|
||||
|
||||
## UI Visualization & Structure
|
||||
- Chips: pill (`border-radius: 999px`), `bg --brand-soft`, `text --brand-ink`
|
||||
(≥6:1), 1px `--line` border, **min-height 44px**, comfortable
|
||||
`padding 0.55rem 1rem`; hover deepens bg; `:active` scales 0.98.
|
||||
- Desktop: `flex-wrap: wrap`, centered under the empty-state subcopy, gap 0.5rem.
|
||||
- Mobile (≤640px): `flex-wrap: nowrap; overflow-x: auto` single row,
|
||||
`scrollbar-width: thin`, chips `flex: 0 0 auto` (thumb-friendly, no
|
||||
accidental double-tap on wrapped lines).
|
||||
- Default suggestion copy (tune to real docs in this phase):
|
||||
1. "How is my Kubernetes cluster set up?"
|
||||
2. "What's my backup strategy?"
|
||||
3. "How do I deploy a new service?"
|
||||
4. "What's currently running in the homelab?"
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_suggestion_chips.py`**:
|
||||
1. `test_onboarding_chips_render` — goto `/`, assert ≥3 `.suggestion-chip`
|
||||
visible inside `#suggestions` (role=list) with non-empty text.
|
||||
2. `test_chip_click_submits` — click the first chip; assert a user bubble
|
||||
with the chip's exact text appears and the brain reply (mock) follows.
|
||||
3. `test_chips_keyboard_accessible` — Tab from the page start reaches the
|
||||
first chip; Enter submits it.
|
||||
4. `test_chips_mobile_row` — at 375px viewport, the chip row is
|
||||
horizontally scrollable (`scrollWidth > clientWidth` or single-line
|
||||
height check) and no chip is cut vertically.
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# .agent/validate.sh — validation gate for the phased-execution pipeline.
|
||||
#
|
||||
# A phase is only moved to .agent/phases/complete/ if this script exits 0.
|
||||
# Gates (PLAN §10 / AGENTS.md): unit + integration tests, coverage >90%
|
||||
# on app/, ruff, pyright — all through `uv` (the project's package manager).
|
||||
set -uo pipefail
|
||||
rc=0
|
||||
|
||||
if [[ -f pyproject.toml || -f pytest.ini || -f setup.py ]]; then
|
||||
out="$(uv run pytest -q --cov=app --cov-report=term 2>&1)"; pytest_rc=$?
|
||||
printf '%s\n' "$out" | tail -n 30
|
||||
if [[ $pytest_rc -ne 0 ]]; then
|
||||
echo "pytest FAILED (exit $pytest_rc)"
|
||||
rc=1
|
||||
fi
|
||||
|
||||
total="$(printf '%s\n' "$out" | grep -E '^TOTAL' | awk '{print $NF}' | tr -d '%')"
|
||||
if [[ -n "${total:-}" ]]; then
|
||||
if awk -v c="$total" 'BEGIN { exit !(c > 90.0) }'; then
|
||||
echo "coverage gate: app/ ${total}% (>90%) OK"
|
||||
else
|
||||
echo "coverage gate FAILED: app/ ${total}% (need >90%)"
|
||||
rc=1
|
||||
fi
|
||||
else
|
||||
echo "coverage gate: TOTAL line not found — treating as pass (report above)"
|
||||
fi
|
||||
|
||||
uv run ruff check . || rc=1
|
||||
uv run pyright || rc=1
|
||||
fi
|
||||
|
||||
if [[ $rc -ne 0 ]]; then
|
||||
echo "validation FAILED (see output above)"
|
||||
else
|
||||
echo "validation OK"
|
||||
fi
|
||||
exit "$rc"
|
||||
Reference in New Issue
Block a user