feat: scaffold Brain of Reese — FastAPI RAG chat over Postgres 17 + pgvector

Foundation (phase 01, verified):
- FastAPI app: /api/health, /api/suggestions, /api/chat (placeholder),
  static frontend served locally (no CDN)
- Postgres 17 + pgvector via db/Containerfile + compose.yaml
  (podman compose up -d db), Alembic initial migration (documents,
  chunks with vector(768), query_log)
- LLM client targeting https://aipi.reeseapps.com/v1 (turbo/embed);
  scripts/llm_probe.py verified models + 768-dim embeddings live
- Conditional debugpy: imported only when DEBUGPY=1 (attach on demand,
  :5678); logging config for clean single-line logs
- Frontend shell: mobile-first chat + Sources pages, tokens, a11y baselines
- Tests: 24 unit+integration (99% coverage on app/), ruff + pyright clean,
  Playwright smoke E2E (3 tests) against a deterministic mock LLM
- Planning: .agent/PLAN.md (architecture + LOCKED decisions), AGENTS.md,
  6 user stories, 7 phase files (one story / one phase / one Playwright
  suite each)
This commit is contained in:
2026-08-21 13:42:21 -04:00
commit 022da8e2bc
63 changed files with 5225 additions and 0 deletions
+368
View File
@@ -0,0 +1,368 @@
# 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.
---
## 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 `*.md` files **only** 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).
- Non-markdown 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 | Cosine **top-K=4 chunks** → map to parent documents → feed the **full text of top-N=2 documents** (deduped, capped at 24k chars) to the LLM | User requirement: whole-document context; mapping via `chunks.document_id → documents.path` | LOCKED |
| A8 | Honesty gate | If best cosine similarity < `BOR_RELEVANCE_THRESHOLD` (0.30) → **deflection mode**: LLM must open with a variant of *"I haven't done anything like that"* and offer 2–3 alternative questions | Required product behavior; threshold is tunable without code change | LOCKED |
| A9 | Content scope | **`*.md` only**, with an exclusion list for non-content dirs (`.venv`, `node_modules`, `.git`, `__pycache__`, `.pytest_cache`, `dist`, `build`) | Simplicity per user; prevents indexing dependency license files (~1.6k junk files in `~/Homelab/.venv`) | LOCKED |
| 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 *.md dirs, chunks, embeds, upserts
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 (exclusions), sha256 delta detection, markdown 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]
→ SELECT chunks ORDER BY embedding <=> $1 LIMIT 4 [pgvector cosine]
→ best_score = max(1 - distance)
├─ best_score >= 0.30 → top-2 documents' FULL content
│ → system prompt (persona + HONESTY rules + docs)
│ → turbo, stream=True → SSE deltas
└─ best_score < 0.30 → 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 |
| POST | `/api/chat` | RAG chat turn → **SSE stream** | 03/04 |
### SSE contract (`POST /api/chat`)
```
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; 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).
---
## 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) |
> 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, 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.
---
## 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
- Embed the question (`embed`, 768-d) → `ORDER BY embedding <=> $1 LIMIT 4`.
- `score = 1 − cosine_distance`. Gate on `max(score) >= 0.30`.
- Distinct parent docs ranked by best chunk score → top 2 → full content,
concatenated, truncated to `BOR_MAX_CONTEXT_CHARS` (24k) with a
`[…truncated…]` marker.
---
## 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"`.
- Contrast (verified pairs): ink `#1c2130` on `#fff` ≈14.9:1; ink-soft
`#4a5168` ≈7.6:1; white on brand `#4f46e5` ≈6.3:1; deflection text
`#92400e` on `#fff7e8` ≈8.7:1. All ≥4.5:1.
- `: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…". |
| **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). |
### 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`.
---
## 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=… threshold=… deflected=… sources=… total_ms=…`
- **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 # also drop deleted files
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.
Only `*.md` (A9) with the exclusion list (A9).
---
## 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` |
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 `*.md` via `--source`.
View File
+59
View File
@@ -0,0 +1,59 @@
# Phase 01 — Infrastructure Foundation
**Story:** — (foundation; no user story)
**Context:** `.agent/PLAN.md` §2–§5, §8–§10 · `AGENTS.md`
## Goal
A verified, reproducible foundation: environment installs, Postgres 17 +
pgvector running via `podman compose up -d db`, migrations applied, app
booting with `/api/health`, lint/types clean, and the smoke E2E green.
The scaffolding already exists in the repo — this phase **verifies and
completes** it (fix gaps rather than rewrite).
## Implementation steps
1. `uv sync` — confirm all deps resolve from `uv.lock`.
2. `podman compose up -d db` — build the `db/` image (postgres:17 +
pgvector) and start the container; `podman compose ps` must show
`healthy`.
3. `uv run alembic upgrade head` — schema applied (`documents`, `chunks`,
`query_log`, `vector` extension). Verify with
`psql -h localhost -U reese -d brain_of_reese -c '\d chunks'`
(embedding column `vector(768)`).
4. `uv run python -m scripts.llm_probe` — live aipi check: `turbo` + `embed`
present, dim 768. (If the endpoint is unreachable, record it and continue;
E2E uses the mock.)
5. `DEBUGPY=0 uv run uvicorn app.main:app` boots and serves `/`,
`/api/health`, `/api/suggestions` (Ctrl-C to stop). Also verify
`DEBUGPY=1` prints the debugpy listen warning and the app stays
responsive.
6. Fix anything broken in scaffold files (keep PLAN-conformant).
## Testing & Quality
- Unit + integration: `uv run pytest` — green.
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` — record the
number; the >90% gate is enforced from the first feature phase onward
(skeleton coverage should already be high).
- Lint/types: `uv run ruff check .` and `uv run pyright` — zero errors.
## Playwright Execution Phase
Run ONLY the smoke suite (verify the browser is installed first —
`uv run playwright install chromium` if missing):
```bash
uv run pytest tests/e2e/test_smoke.py -v --no-cov
```
Expected: 3 passed (health, index page, placeholder round-trip).
## Success criteria
- [ ] `podman compose up -d db` → healthy
- [ ] `alembic upgrade head` clean; `vector(768)` column present
- [ ] app boots; `/api/health` `db: up`
- [ ] unit + integration green; ruff + pyright clean
- [ ] smoke E2E green in isolation
- [ ] committed (see below)
## Commit
```bash
git add -A && git commit --no-gpg-sign -m "chore(infra): verify foundation — pg17+pgvector, migrations, debugpy gating, smoke E2E"
```
@@ -0,0 +1,76 @@
# Phase 02 — Story: Import Documents
**Story:** `.agent/user_stories/import-documents.md`
**Context:** `.agent/PLAN.md` §5 (data model), §9 (logging), §11 (import workflow)
## Goal
The importer (`scripts/import_docs.py`) + `GET /api/docs` + the Sources page
rendering the indexed documents — the knowledge base becomes refreshable.
## Implementation steps
1. `app/rag/__init__.py`, `app/rag/chunker.py` — markdown-aware chunker
(PLAN §5 policy: heading splits, 2000-char target, 200 overlap, keep
nearest heading). Pure functions, fully unit-testable.
2. `app/rag/llm.py` — `LLMClient` (openai async) with `embed(texts) ->
list[list[float]]` (batched, `BOR_EMBED_BATCH_SIZE`) and a
`embed_one`; dimension check vs `settings.embedding_dim` with a loud,
actionable error. (Chat streaming is added in Phase 03 on this client.)
3. `app/rag/importer.py` — the core: directory walk (exclusion list, PLAN
A9; `*.md` only), sha256 delta vs `documents.content_hash`,
upsert-or-skip, two-phase chunk replace (insert doc → replace chunks →
embed → commit), `--prune` support, per-file + summary logging.
4. `scripts/import_docs.py` — CLI wrapper (argparse): repeatable
`--source` (default `~/Homelab` `~/Deployments`, `expanduser`),
`--prune`, `--limit`.
5. `app/api/docs.py` — `GET /api/docs` → `{"documents": [DocSummary]}`
(include `chunks` count via `func.count`); mount in `app/main.py`
**before** the static mount.
6. `frontend/assets/sources.js` + `sources.html` polish — wire the real
endpoint (already scaffolded to expect this shape); keep the empty state.
7. Update `README.md` §Knowledge Base Import with the final commands +
exclusion list + "update your docs → re-run the script" workflow.
## UI Verification
Compare `/sources.html` against the story's "UI Visualization & Structure":
stat cards `auto-fit minmax(170px,1fr)`; full-width table (≥85% container);
mono path column with `title` ellipsis; empty state with the exact command;
`<caption class="visually-hidden">`, `scope="col"`, scroll wrapper
`role="region" tabindex="0"`. No CDN refs. Take a 1280px and 375px
screenshot pass before finishing.
## Testing & Quality
- Unit: chunker (heading splits, overlap, short-doc single chunk, code
fences kept intact), exclusion walk (temp tree with `.venv` junk),
delta logic (unchanged/changed/pruned via tmp Postgres or in-memory fakes
— real DB preferred since compose runs locally).
- Integration: `GET /api/docs` empty shape + populated shape; importer
end-to-end against `tests/fixtures/docs/` into a test schema.
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` — **>90%**
on `app/` (importer + chunker + client are the bulk; test them hard).
## Playwright Execution Phase
Run ONLY this story's suite (DB must be up: `podman compose up -d db`):
```bash
uv run pytest tests/e2e/test_import_documents.py -v --no-cov
```
The test file implements the story's Playwright Mapping Rule (seed via the
import function against `tests/fixtures/docs/` with the mock LLM; assert
Sources page rows, layout width, and the empty state).
## Success criteria
- [ ] `uv run python -m scripts.import_docs` (fixtures) imports all 3 docs,
re-run reports `unchanged`
- [ ] `GET /api/docs` + Sources page show the docs (real run: `~/Homelab`
+ `~/Deployments` counts logged)
- [ ] unit + integration green, coverage >90%
- [ ] UI verification passed (screenshots attached to the phase record)
- [ ] story E2E green in isolation
- [ ] README import section updated
- [ ] committed
## Commit
```bash
git add -A && git commit --no-gpg-sign -m "feat(kb): markdown importer with sha256 deltas, chunking, batched embeddings, and Sources page"
```
+74
View File
@@ -0,0 +1,74 @@
# Phase 03 — Story: Chat RAG Answer (happy path)
**Story:** `.agent/user_stories/chat-rag-answer.md`
**Context:** `.agent/PLAN.md` §3 (data flow), §4 (SSE contract), §6 (persona), §9 (logging)
## Goal
The core product loop: question → embed → cosine top-4 → full top-2
documents → `turbo` (streamed) → chippy grounded answer with source chips.
## Implementation steps
1. `app/rag/retriever.py` — `retrieve(db, question_embedding) ->
list[RetrievedChunk]` (score = 1 − distance, `ORDER BY embedding <=> $1
LIMIT BOR_TOP_K_CHUNKS`) + `select_documents(chunks, n) -> list[Document]`
(distinct by `document_id`, ranked by best chunk score, cap content at
`BOR_MAX_CONTEXT_CHARS` with `[…truncated…]`).
2. `app/rag/prompts.py` — locked persona + HONESTY GATE prompt builder
(PLAN §6 verbatim, `<relevance>HIGH|LOW</relevance>`, `<documents>`
block; LOW mode includes the `DEFLECT_MODE` marker + weak-hit titles).
3. `app/rag/llm.py` — add `chat_stream(messages) -> AsyncIterator[str]`
(openai async, `stream=True`, `model=turbo`, temperature 0.4,
max_tokens ~700).
4. `app/api/chat.py` — `POST /api/chat` (ChatRequest) → `StreamingResponse`
(SSE): emit `delta` events from the stream, then the `done` event
(deflected, sources, suggestions); insert `query_log` row (deflected=
false this phase); per-turn log line (PLAN §9); structured error events
(`{"type":"error","detail":…}`) on LLM/DB failure.
5. `frontend/assets/app.js` — replace the placeholder handler: `fetch` +
`ReadableStream` SSE parser; render deltas live into a brain bubble
(reuse the typing-indicator → streaming handoff); on `done`, append
`.source-chip`s under the bubble; on error, show the banner (full
state machine is Phase 06 — keep it simple-correct here).
6. Tune `settings.suggestions` if the real Homelab import revealed better
defaults (optional here; Phase 05 owns the chips).
## UI Verification
Against the story's "UI Visualization & Structure": bubbles right/left
(brand vs surface, ≥4.5:1 text), avatar 🧠, source chips mono/brand-soft
with `source/path` and ellipsis, safe markdown (paste an answer containing
`<script>alert(1)</script>` from the mock to prove it's escaped). Chat
column 46rem centered. 1280px + 375px screenshot pass.
## Testing & Quality
- Unit: retriever ordering/dedup/cap (fake rows), prompt builder (HIGH
contains documents + `HIGH`, LOW contains `DEFLECT_MODE` + titles only,
persona rules present verbatim), SSE event serialization.
- Integration: `/api/chat` against the mock LLM with a seeded temp schema —
assert SSE delta sequence, `done` payload (sources non-empty,
deflected false), `query_log` row, error event when LLM unreachable.
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` — **>90%**.
## Playwright Execution Phase
Run ONLY this story's suite:
```bash
uv run pytest tests/e2e/test_chat_rag.py -v --no-cov
```
Implements the story mapping: streamed grounded answer + `kubernetes.md`
source chip + button recovery; DB `query_log` assertion; raw SSE shape
check via `httpx`.
## Success criteria
- [ ] end-to-end: question → streamed chippy answer citing `kubernetes.md`
- [ ] `query_log` row per turn; per-turn log line in stdout
- [ ] LLM-down path shows error banner, no stuck button
- [ ] unit + integration green, coverage >90%
- [ ] UI verification passed
- [ ] story E2E green in isolation
- [ ] committed
## Commit
```bash
git add -A && git commit --no-gpg-sign -m "feat(rag): stream grounded chat answers via pgvector cosine retrieval with source citations"
```
@@ -0,0 +1,66 @@
# Phase 04 — Story: Honest Deflection
**Story:** `.agent/user_stories/honest-deflection.md`
**Context:** `.agent/PLAN.md` §4, §6 (honesty gate), §9
## Goal
When retrieval finds nothing relevant, Brain says so — plainly, chippily —
and offers real alternatives. No hallucinated confidence.
## Implementation steps
1. `app/api/chat.py` — apply the gate: `best_score < settings.relevance_
threshold` ⇒ build LOW prompt (`DEFLECT_MODE`, weak-hit titles only),
else HIGH prompt. Set `deflected` on the `done` event + `query_log`.
2. Deflection `suggestions[]`: ask `turbo` (same stream) to include 2–3
alternative questions; simplest robust approach — have the LLM emit them
inline in the answer AND have the server derive 2–3 chips from the
weak-hit document titles (deterministic fallback if the model doesn't
produce a parsable list). Ship the deterministic title-derived chips as
the v1 behavior; model-generated list is a bonus if trivially parseable.
3. `frontend/assets/app.js` — on `done.deflected`: add `.is-deflected`
class to the bubble, render "Maybe try:" chips below it (same
`.suggestion-chip` component; clicking fills the input — full submit
behavior lands with Phase 05's chip component; wire what exists).
4. `README.md` — document `BOR_RELEVANCE_THRESHOLD` tuning + the
deflection behavior in Troubleshooting.
## UI Verification
Against the story: amber bubble (`#fff7e8` bg / `#f59e0b` border) distinct
from normal answers; "Maybe try:" chips ≥44px, brand-soft/brand-ink;
contrast pairs verified (ink on accent-bg ≥ 9:1, accent-ink ≥ 8:1);
chip group has an accessible name; mobile wraps cleanly.
## Testing & Quality
- Unit: gate boundary with a fake retriever — score exactly 0.30 → HIGH;
0.2999 → LOW; LOW prompt contains `DEFLECT_MODE` + titles, no full docs;
HIGH unaffected. Suggestions derivation (2–3, non-empty, derived from
titles).
- Integration: mock LLM — off-topic question ("sourdough") ⇒ `done`
`deflected: true`, `query_log.deflected=true`, weak `top_score` stored;
on-topic question ⇒ `deflected: false`.
- Coverage: **>90%** on `app/`.
## Playwright Execution Phase
Run ONLY this story's suite:
```bash
uv run pytest tests/e2e/test_honest_deflection.py -v --no-cov
```
Implements the story mapping: off-topic question ⇒ `.is-deflected` bubble
matching /haven't done anything like that/i + ≥2 "Maybe try:" chips; chip
click behavior; (unit boundary test lives in pytest, not here).
## Success criteria
- [ ] off-topic question never gets a confident fake answer
- [ ] deflected bubble visually distinct + alternative chips render
- [ ] `query_log.deflected` accurate; threshold env-tunable
- [ ] unit + integration green, coverage >90%
- [ ] UI verification passed
- [ ] story E2E green in isolation
- [ ] committed
## Commit
```bash
git add -A && git commit --no-gpg-sign -m "feat(rag): honest deflection gate with amber UI state and alternative-question chips"
```
@@ -0,0 +1,59 @@
# Phase 05 — Story: Suggestion Chips
**Story:** `.agent/user_stories/suggestion-chips.md`
**Context:** `.agent/PLAN.md` §7 (UI/UX), story file for chip spec
## Goal
Zero-friction onboarding: 3–4 real example questions on first load,
clickable → filled → submitted, keyboard-first, mobile-scrollable.
## Implementation steps
1. `app/config.py` — confirm `suggestions` is env-overridable
(`BOR_SUGGESTIONS` as JSON list via pydantic-settings) and tune the
defaults against the *actually imported* Homelab/Deployments topics
(read a sample of `documents` titles; pick questions real answers
exist for).
2. `app.js` — extract a `renderChips(container, items, {onSelect})` helper;
real `<button type="button" class="suggestion-chip" role="listitem">`
inside `#suggestions[role="list"]`; onboarding `onSelect` = fill
`#message-input` + focus + `composer.requestSubmit()`. Reuse the same
helper for deflection chips (Phase 04) with the same submit behavior.
3. Empty-state lifecycle: first user message hides `#empty-state` (already
done in `addMessage`) — verify chips don't linger in the conversation.
4. Mobile CSS check: chip row `nowrap + overflow-x auto` at ≤640px (tokens
already exist — verify, don't duplicate).
## UI Verification
Against the story: pills 999px radius, ≥44px, brand-soft/brand-ink (≥6:1),
hover/active states; desktop centered wrap vs mobile single scroll row;
Tab order reaches chips before the composer input is required; screen
reader: group labeled "Suggested questions". Screenshot pass 1280px + 375px.
## Testing & Quality
- Unit/integration: `GET /api/suggestions` honors `BOR_SUGGESTIONS` env
override (JSON list); default list has ≥3 non-empty strings.
- Coverage: **>90%** on `app/` (JS is covered by E2E).
## Playwright Execution Phase
Run ONLY this story's suite:
```bash
uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov
```
Implements the story mapping: chips render (≥3, role=list); chip click
submits (user bubble with exact chip text + mock reply); keyboard Tab+Enter
activates; 375px chip row is a horizontal scroll row.
## Success criteria
- [ ] onboarding chips render from the API; click = one-tap question
- [ ] keyboard + SR usable; mobile scroll row
- [ ] deflection chips share the component + submit behavior
- [ ] unit + integration green, coverage >90%
- [ ] story E2E green in isolation
- [ ] committed
## Commit
```bash
git add -A && git commit --no-gpg-sign -m "feat(ui): onboarding suggestion chips with one-tap submit, keyboard access, and mobile scroll row"
```
@@ -0,0 +1,64 @@
# Phase 06 — Story: Loading Feedback & Progress
**Story:** `.agent/user_stories/loading-feedback.md`
**Context:** `.agent/PLAN.md` §7.4 ("never stale" contract), §9
## Goal
An unambiguous state machine — `idle → thinking → streaming → done |
error → idle` — so the user always knows what's happening, and a stale
Send button is impossible.
## Implementation steps
1. `app.js` — formalize the state machine (single `setUiState(state)`
function driving: typing indicator, send button disabled/spinner/label,
`#send-status` live text). Replace ad-hoc busy handling from Phase 03.
2. Pre-token: typing indicator (`role="status"`,
`aria-label="Brain of Reese is thinking"`); after 10s pre-token, update
the label with elapsed seconds (setInterval, cleared on state change).
3. Streaming: first `delta` removes the typing indicator and starts
appending to the answer bubble; button stays busy.
4. Error paths: `{"type":"error"}` SSE event, non-2xx response, or
**120s client-side timeout** (clear on first delta) → red banner
`role="alert"` ("Try again — if this persists, check the LLM is
reachable") + state → idle.
5. `prefers-reduced-motion`: CSS already slows animations — verify; add a
static fallback for the dots if needed.
6. Server: confirm the per-turn log line includes `embed_ms` and
`total_ms` (add if Phase 03 omitted it).
## UI Verification
Walk the full state machine by hand (dev server + mock LLM slow path):
submit → indicator + "Thinking…" disabled button → live tokens → done
(enabled, focused input). Kill the mock mid-stream → banner + recovery.
Contrast of disabled button + spinner OK; reduced-motion pass.
## Testing & Quality
- Unit/integration: SSE error event serialization; timeout constant
exported/testable; (JS logic is E2E-covered).
- Coverage: **>90%** on `app/`.
## Playwright Execution Phase
Run ONLY this story's suite:
```bash
uv run pytest tests/e2e/test_loading_feedback.py -v --no-cov
```
Implements the story mapping (mock LLM's 3s "pretend to think slowly"
warm-up + a fixture that stops the mock): typing indicator visible during
pre-token and gone by answer; button disabled→"Thinking…"→enabled "Send";
streaming appends (two-timestamp length check); LLM-down ⇒ `role=alert`
banner + button recovered.
## Success criteria
- [ ] every in-flight state has a visible indicator; button never zombies
- [ ] error + 120s timeout paths both recover cleanly
- [ ] reduced-motion respected
- [ ] unit + integration green, coverage >90%
- [ ] story E2E green in isolation
- [ ] committed
## Commit
```bash
git add -A && git commit --no-gpg-sign -m "feat(ui): explicit chat state machine — typing indicator, streaming progress, timeout and error recovery"
```
@@ -0,0 +1,57 @@
# Phase 07 — Story: Responsive, Polished, Accessible UI
**Story:** `.agent/user_stories/responsive-polish.md`
**Context:** `.agent/PLAN.md` §7 (the whole UI/UX strategy)
## Goal
The final visual + accessibility audit pass across chat and Sources. No
new features — enforce PLAN §7 end-to-end and fix every deviation.
## Implementation steps
1. Viewport sweep (360 / 375 / 768 / 1280 / 1600) on both pages: fix
overflow, pinched columns, dead whitespace. Chat column stays ≤46rem
centered; Sources table full-width with horizontal scroll <640px.
2. A11y sweep (both pages): landmarks, skip link, labels on every input,
`aria-label` on every icon-only control, `:focus-visible` outline on
every focusable, `aria-live` regions intact, no contrast <4.5:1
(compute, don't eyeball — use the E2E helper).
3. Reduced-motion + long-content pass (60-char paths, long answers).
4. No-CDN re-verification on **both** pages (extend the integration test
to `/sources.html` if it only covers `/`).
5. Final README polish pass: screenshots section (optional), quickstart
sanity, "Update your documents" workflow prominent.
## UI Verification
This phase IS the verification: the E2E below is the acceptance test.
Additionally, manual screenshot pass at 1280px + 375px for both pages,
reviewed against PLAN §7.1–7.4 before committing.
## Testing & Quality
- Integration: no-CDN check extended to Sources page; health unchanged.
- Coverage: **>90%** on `app/` (final state of the whole app).
- Whole suite green: `uv run pytest` (unit+integration) — the entire
repo must be green at this phase.
## Playwright Execution Phase
Run ONLY this story's suite:
```bash
uv run pytest tests/e2e/test_responsive_polish.py -v --no-cov
```
Implements the story mapping: no horizontal overflow at 5 viewports (both
pages); chat column capped + centered at 1600px; Sources table ≥80%
container at 1280px; landmarks/labels/skip-link sweep; WCAG contrast
pairs ≥4.5:1 (computed); reduced-motion honored.
## Success criteria
- [ ] all six mapping tests pass at every viewport
- [ ] zero known a11y deviations against PLAN §7.2
- [ ] whole pytest suite green + coverage >90%
- [ ] README polished
- [ ] committed (this commit marks v1.0 feature-complete)
## Commit
```bash
git add -A && git commit --no-gpg-sign -m "feat(ui): responsive + WCAG AA polish pass across chat and sources — v1 feature complete"
```
+61
View File
@@ -0,0 +1,61 @@
# 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.
+57
View File
@@ -0,0 +1,57 @@
# 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).
+62
View File
@@ -0,0 +1,62 @@
# 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.
+61
View File
@@ -0,0 +1,61 @@
# 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.
+65
View File
@@ -0,0 +1,65 @@
# 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).
+58
View File
@@ -0,0 +1,58 @@
# 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.