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
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"
```