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:
+368
@@ -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`.
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Brain of Reese — environment configuration
|
||||
# Copy to `.env` and adjust: cp .env.example .env
|
||||
# (`.env` is gitignored; never commit secrets.)
|
||||
|
||||
# --- App ---
|
||||
BOR_ENVIRONMENT=development
|
||||
# BOR_LOG_LEVEL=INFO
|
||||
# BOR_STATIC_DIR=frontend # dev default; container sets /app/static
|
||||
|
||||
# --- Database (matches `podman compose` db service) ---
|
||||
BOR_DATABASE_URL=postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese
|
||||
|
||||
# --- LLM (self-hosted, OpenAI-compatible "aipi") ---
|
||||
BOR_LLM_BASE_URL=https://aipi.reeseapps.com/v1
|
||||
BOR_LLM_API_KEY= # falls back to $AIPI_KEY, then "not-needed"
|
||||
BOR_LLM_CHAT_MODEL=turbo
|
||||
BOR_LLM_EMBED_MODEL=embed
|
||||
BOR_EMBEDDING_DIM=768 # verified 2026-08 via scripts/llm_probe.py
|
||||
|
||||
# --- RAG tuning ---
|
||||
BOR_TOP_K_CHUNKS=4
|
||||
BOR_TOP_N_DOCS=2
|
||||
BOR_RELEVANCE_THRESHOLD=0.30 # max cosine similarity required to answer (else honest deflection)
|
||||
BOR_MAX_CONTEXT_CHARS=24000 # cap on total document text sent to the LLM
|
||||
BOR_CHUNK_TARGET_CHARS=2000
|
||||
BOR_CHUNK_OVERLAP_CHARS=200
|
||||
BOR_EMBED_BATCH_SIZE=16
|
||||
|
||||
# --- Debugging (0/1 — 1 enables attach-on-demand debugpy on port 5678) ---
|
||||
DEBUGPY=0
|
||||
# DEBUGPY_PORT=5678
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# --- Python ---
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.pyright/
|
||||
.mypy_cache/
|
||||
.coverage
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
|
||||
# --- Environment / secrets ---
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# --- Planning / agent pipeline state (committed on demand with `git add -f .agent/...`) ---
|
||||
.agent/
|
||||
|
||||
# --- Frontend build artifacts (sources live in frontend/, builds are local-only) ---
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# --- Logs & misc ---
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,52 @@
|
||||
# AGENTS.md — Operating Rules for All Agents on This Repo
|
||||
|
||||
1. **Always read `.agent/PLAN.md` first.** It contains the architecture, the
|
||||
LOCKED DECISIONS, the UI/UX standards, the data model, and the roadmap.
|
||||
Do not design anything that contradicts it.
|
||||
2. **Follow the phased execution protocol in `.agent/phases/`.** Work through
|
||||
`.agent/phases/todo/` in numeric order. When a phase is complete, move its
|
||||
file to `.agent/phases/complete/`.
|
||||
3. **Strictly adhere to the LOCKED DECISIONS** (PLAN §2). If you believe a
|
||||
LOCKED decision is wrong, stop and flag it — do not silently deviate.
|
||||
4. **"One Story, One Phase":** each user story in `.agent/user_stories/`
|
||||
corresponds to a distinct execution phase that includes its own dedicated
|
||||
Playwright E2E test suite (one test file per story, run in isolation).
|
||||
5. **"UI Structure Check":** before finalizing any UI component, verify that
|
||||
it follows the layout principles in `.agent/PLAN.md` §7 (proper container
|
||||
usage, centered 46rem chat column, full-width tables on Sources — no
|
||||
skinny wasted-space lists) and meets WCAG 2.1 AA basics (semantic
|
||||
landmarks, labels, contrast ≥4.5:1, focus-visible, aria-live for the
|
||||
stream).
|
||||
6. **"No CDN Rule":** all CSS, JS, fonts, and images must be served statically
|
||||
from within the FastAPI application. No `<script src="https://…">` or
|
||||
`<link href="https://…">` tags in HTML templates unless bundled locally.
|
||||
An integration test enforces this on the index page.
|
||||
7. **"Debugpy Check":** any new entrypoint must keep the conditional import —
|
||||
`debugpy` is imported **only** when `DEBUGPY=1` (see
|
||||
`app/core/debugging.py`); default off, never imported otherwise.
|
||||
8. **Git protocol:** Git is mandatory. One **atomic, professional commit per
|
||||
completed phase** (Conventional Commits, e.g. `feat(rag): stream RAG
|
||||
answers over SSE with source citations`). Always append `--no-gpg-sign`
|
||||
(the repo also sets `commit.gpgsign=false`). `.agent/` is gitignored by
|
||||
design — use `git add -f .agent/…` when a commit must record plan changes.
|
||||
9. **Test gates are non-negotiable:** a phase is complete only when unit +
|
||||
integration tests pass, `app/` coverage is **>90%**
|
||||
(`uv run pytest --cov=app --cov-report=term-missing`), and the phase's
|
||||
Playwright E2E file passes **in isolation**
|
||||
(`uv run pytest tests/e2e/test_<story>.py -v --no-cov`).
|
||||
10. **Amply log, visibly feed back:** server-side, log the required
|
||||
per-turn line (PLAN §9); UI-side, honor the "never stale" feedback
|
||||
contract (PLAN §7.4).
|
||||
|
||||
## Quick reference
|
||||
```bash
|
||||
podman compose up -d db # start Postgres 17 + pgvector
|
||||
cp .env.example .env # once; then edit
|
||||
uv run alembic upgrade head # apply migrations
|
||||
uv run uvicorn app.main:app --reload # dev server (add DEBUGPY=1 to debug)
|
||||
uv run pytest # unit + integration
|
||||
uv run pytest --cov=app --cov-report=term-missing
|
||||
uv run playwright install chromium # once
|
||||
uv run pytest tests/e2e/test_smoke.py -v --no-cov
|
||||
uv run ruff check . && uv run pyright # lint + types
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Brain of Reese — production image (Podman/Docker compatible).
|
||||
#
|
||||
# Stage 1 (frontend): minify/bundle the local frontend with esbuild.
|
||||
# NO CDN — every asset is built into this image.
|
||||
# Stage 2 (python): install dependencies with uv (locked).
|
||||
# Stage 3 (runtime): slim, non-root, migrations + uvicorn.
|
||||
|
||||
# ---------- Stage 1: frontend ----------
|
||||
FROM docker.io/node:22-alpine AS frontend
|
||||
WORKDIR /build
|
||||
RUN npm install --no-audit --no-fund esbuild@0.25.5
|
||||
COPY frontend ./
|
||||
RUN mkdir -p /out/assets \
|
||||
&& esbuild ./assets/app.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/app.js \
|
||||
&& esbuild ./assets/sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/sources.js \
|
||||
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
|
||||
&& cp ./index.html ./sources.html /out/
|
||||
|
||||
# ---------- Stage 2: python dependencies ----------
|
||||
FROM docker.io/python:3.12-slim AS python
|
||||
WORKDIR /app
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev --no-cache --no-install-project
|
||||
COPY app ./app
|
||||
RUN uv sync --frozen --no-dev --no-cache
|
||||
|
||||
# ---------- Stage 3: runtime ----------
|
||||
FROM docker.io/python:3.12-slim AS runtime
|
||||
ENV PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
BOR_STATIC_DIR=/app/static \
|
||||
BOR_ENVIRONMENT=production
|
||||
RUN useradd --create-home --uid 10001 reese
|
||||
WORKDIR /app
|
||||
COPY --from=python /app/.venv /app/.venv
|
||||
COPY --from=python /app/app /app/app
|
||||
COPY --from=frontend /out /app/static
|
||||
COPY alembic ./alembic
|
||||
COPY alembic.ini ./alembic.ini
|
||||
COPY scripts/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh && chown -R reese:reese /app
|
||||
USER reese
|
||||
EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=5 \
|
||||
CMD ["python", "-c", "import sys, httpx; sys.exit(0 if httpx.get('http://127.0.0.1:8000/api/health', timeout=4).status_code == 200 else 1)"]
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
@@ -0,0 +1,208 @@
|
||||
# 🧠 Brain of Reese
|
||||
|
||||
A chippy, honest **RAG chatbot** over the `~/Homelab` and `~/Deployments`
|
||||
projects. Point it at your markdown docs, ask it anything — it retrieves
|
||||
the relevant notes with **Postgres 17 + pgvector** cosine search, feeds the
|
||||
**whole relevant document** to a **self-hosted LLM** (`turbo` via
|
||||
`https://aipi.reeseapps.com/v1`), and streams a grounded answer back.
|
||||
|
||||
If it doesn't have notes for your question, it admits it:
|
||||
*"I haven't done anything like that"* — plus suggestions for what it **does** know.
|
||||
|
||||
- **Stack:** FastAPI · Pydantic v2 · SQLAlchemy 2 · Alembic · pgvector ·
|
||||
vanilla HTML/CSS/JS (no CDN) · Playwright E2E
|
||||
- **Planning:** architecture, LOCKED decisions and the phase roadmap live
|
||||
in [`.agent/PLAN.md`](.agent/PLAN.md); per-story specs in
|
||||
[`.agent/user_stories/`](.agent/user_stories/).
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
- [uv](https://docs.astral.sh/uv/)
|
||||
- [Podman](https://podman.io/) (with the `podman compose` provider)
|
||||
- Node.js is **not** needed locally (asset minification happens in the
|
||||
container build only)
|
||||
|
||||
### 1. Install dependencies
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
### 2. Configure
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# edit .env — the defaults already match the local compose setup.
|
||||
# BOR_LLM_API_KEY: your aipi key (falls back to $AIPI_KEY if unset)
|
||||
```
|
||||
|
||||
### 3. Start the database (Postgres 17 + pgvector)
|
||||
```bash
|
||||
podman compose up -d db
|
||||
podman compose ps # wait until "healthy"
|
||||
```
|
||||
|
||||
### 4. Apply migrations
|
||||
```bash
|
||||
uv run alembic upgrade head
|
||||
```
|
||||
|
||||
### 5. Import your knowledge base
|
||||
```bash
|
||||
uv run python -m scripts.llm_probe # sanity: models + 768-dim check
|
||||
uv run python -m scripts.import_docs # defaults: ~/Homelab + ~/Deployments
|
||||
```
|
||||
|
||||
### 6. Run the app
|
||||
```bash
|
||||
uv run uvicorn app.main:app --reload
|
||||
# → http://localhost:8000 (chat) http://localhost:8000/sources.html (KB)
|
||||
```
|
||||
|
||||
## Updating the documents
|
||||
|
||||
The knowledge base is refreshed by **re-running the import**. It is
|
||||
idempotent and delta-based (sha256 per file):
|
||||
|
||||
```bash
|
||||
# After editing/adding/removing markdown in your projects:
|
||||
uv run python -m scripts.import_docs # re-index what changed
|
||||
uv run python -m scripts.import_docs --prune # also drop deleted files
|
||||
|
||||
# Point it at extra directories (repeatable):
|
||||
uv run python -m scripts.import_docs --source ~/SomeOtherDocs
|
||||
```
|
||||
|
||||
- Only **`*.md`** files are indexed. Directories like `.venv`,
|
||||
`node_modules`, `.git`, `__pycache__`, `.pytest_cache`, `dist`, `build`
|
||||
are skipped (see `.agent/PLAN.md` anchor A9).
|
||||
- Unchanged files are **not re-embedded** — only new/changed ones, so
|
||||
refreshes are cheap.
|
||||
- To sanity-check the LLM backend (models + embedding dimension) after any
|
||||
aipi change: `uv run python -m scripts.llm_probe`.
|
||||
|
||||
## Debugging
|
||||
|
||||
`debugpy` is **off by default** and *never imported* unless you opt in —
|
||||
zero overhead in normal runs.
|
||||
|
||||
```bash
|
||||
DEBUGPY=1 uv run uvicorn app.main:app
|
||||
# → log line: debugpy: remote debugging ENABLED, listening on 0.0.0.0:5678
|
||||
```
|
||||
|
||||
Then attach from VS Code (`.vscode/launch.json`):
|
||||
```json
|
||||
{
|
||||
"name": "Attach to Brain of Reese",
|
||||
"type": "debugpy",
|
||||
"request": "attach",
|
||||
"connect": { "host": "localhost", "port": 5678 },
|
||||
"pathMappings": [
|
||||
{ "localRoot": "${workspaceFolder}", "remoteRoot": "/app" }
|
||||
]
|
||||
}
|
||||
```
|
||||
The port is non-blocking and attach-on-demand: the app keeps running
|
||||
normally until you attach. Override the port with `DEBUGPY_PORT`.
|
||||
|
||||
## QA / Testing Environment
|
||||
|
||||
Three layers — the project rule is **one story, one phase, one Playwright
|
||||
suite** (see `AGENTS.md`):
|
||||
|
||||
```bash
|
||||
# Unit + integration (FastAPI TestClient)
|
||||
uv run pytest
|
||||
|
||||
# Same, with the coverage gate (phases require >90% on app/)
|
||||
uv run pytest --cov=app --cov-report=term-missing
|
||||
|
||||
# Lint + static types
|
||||
uv run ruff check .
|
||||
uv run pyright
|
||||
|
||||
# Playwright E2E — install the browser once:
|
||||
uv run playwright install chromium
|
||||
|
||||
# Each story's E2E runs IN ISOLATION (DB must be up):
|
||||
podman compose up -d db
|
||||
uv run pytest tests/e2e/test_import_documents.py -v --no-cov
|
||||
uv run pytest tests/e2e/test_chat_rag.py -v --no-cov
|
||||
# ...one file per story in .agent/user_stories/ (see .agent/phases/todo/)
|
||||
```
|
||||
|
||||
**Deterministic E2E:** by default the E2E app talks to a local **mock
|
||||
aipi** (`tests/e2e/mock_llm.py`) whose embeddings are real
|
||||
token-overlap vectors — so the cosine relevance threshold behaves like
|
||||
production (on-topic questions answer, off-topic ones deflect).
|
||||
To run E2E against the **live** self-hosted models instead:
|
||||
|
||||
```bash
|
||||
E2E_REAL_LLM=1 uv run pytest tests/e2e/test_chat_rag.py -v --no-cov
|
||||
```
|
||||
(requires a real import of your docs first).
|
||||
|
||||
## Production Deployment
|
||||
|
||||
Build the multi-stage image (frontend minified by esbuild in the builder
|
||||
stage, deps installed by `uv`, non-root runtime):
|
||||
|
||||
```bash
|
||||
podman build -t brain-of-reese/app:latest .
|
||||
```
|
||||
|
||||
Run standalone (bring your own Postgres + pgvector):
|
||||
```bash
|
||||
podman run -d --name brain-of-reese \
|
||||
-p 8000:8000 \
|
||||
-e BOR_DATABASE_URL=postgresql+psycopg://reese:SECRETPASSWORD@dbhost:5432/brain_of_reese \
|
||||
-e BOR_LLM_BASE_URL=https://aipi.reeseapps.com/v1 \
|
||||
-e BOR_LLM_API_KEY=$AIPI_KEY \
|
||||
brain-of-reese/app:latest
|
||||
```
|
||||
The entrypoint runs `alembic upgrade head` automatically on start.
|
||||
|
||||
Or run the whole stack from compose (app + db):
|
||||
```bash
|
||||
podman compose --profile prod up -d --build
|
||||
```
|
||||
|
||||
Production hardening notes: app runs as non-root (uid 10001), slim image,
|
||||
healthcheck on `/api/health`, debugpy off unless `DEBUGPY=1`, all assets
|
||||
served locally (no CDN), `BOR_ENVIRONMENT=production`.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
| Env | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `BOR_DATABASE_URL` | local compose URL | SQLAlchemy URL (psycopg) |
|
||||
| `BOR_LLM_BASE_URL` | `https://aipi.reeseapps.com/v1` | OpenAI-compatible endpoint |
|
||||
| `BOR_LLM_API_KEY` | — (falls back to `$AIPI_KEY`) | aipi API key |
|
||||
| `BOR_LLM_CHAT_MODEL` | `turbo` | chat model |
|
||||
| `BOR_LLM_EMBED_MODEL` | `embed` | embedding model |
|
||||
| `BOR_EMBEDDING_DIM` | `768` | vector dimension (fixed at table creation) |
|
||||
| `BOR_TOP_K_CHUNKS` | `4` | chunks retrieved per question |
|
||||
| `BOR_TOP_N_DOCS` | `2` | full documents fed to the LLM |
|
||||
| `BOR_RELEVANCE_THRESHOLD` | `0.30` | best cosine similarity required to answer; below ⇒ honest deflection |
|
||||
| `BOR_MAX_CONTEXT_CHARS` | `24000` | cap on total document text sent to the LLM |
|
||||
| `BOR_SUGGESTIONS` | built-in list | JSON list of onboarding chips |
|
||||
| `DEBUGPY` | `0` | `1` ⇒ attach-on-demand debugpy on `DEBUGPY_PORT` (default 5678) |
|
||||
| `BOR_LOG_LEVEL` | `INFO` | app log level |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`401` from aipi** — set `BOR_LLM_API_KEY` (or `$AIPI_KEY`).
|
||||
- **Embedding dimension mismatch** — aipi changed models; run
|
||||
`uv run python -m scripts.llm_probe`, update `BOR_EMBEDDING_DIM`, then
|
||||
drop + recreate the chunks table (new migration or manual `TRUNCATE
|
||||
chunks, documents`).
|
||||
- **Answers deflect too often / too rarely** — tune
|
||||
`BOR_RELEVANCE_THRESHOLD` (lower = answers more, higher = more honest
|
||||
deflection). Check `query_log` for the actual scores:
|
||||
`psql … -c 'SELECT question, top_score, deflected FROM query_log ORDER BY created_at DESC LIMIT 20'`
|
||||
- **KB offline banner in the chat** — Postgres isn't running:
|
||||
`podman compose up -d db`.
|
||||
- **Stuck "Thinking…"** — the LLM is slow or down; a 120s client timeout
|
||||
turns it into an error banner automatically.
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
file_template = %%(rev)s_%%(slug)s
|
||||
# sqlalchemy.url is injected at runtime from app.config (BOR_DATABASE_URL).
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Alembic migration environment (sync engine, URL from app settings)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
import app.models # noqa: F401 (registers all models on Base.metadata)
|
||||
from alembic import context
|
||||
from app.config import get_settings
|
||||
from app.db import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None and os.path.exists(config.config_file_name):
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""initial schema: documents, chunks (pgvector), query_log
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-08-21
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
EMBEDDING_DIM = 768 # keep in sync with app/models.py (PLAN anchor A6)
|
||||
|
||||
revision = "0001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
|
||||
op.create_table(
|
||||
"documents",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("source", sa.String(120), nullable=False),
|
||||
sa.Column("path", sa.String(1000), nullable=False),
|
||||
sa.Column("full_path", sa.String(2000), nullable=False),
|
||||
sa.Column("title", sa.String(500), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||
sa.Column(
|
||||
"indexed_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("source", "path", name="uq_documents_source_path"),
|
||||
)
|
||||
op.create_index("ix_documents_source", "documents", ["source"])
|
||||
op.create_index("ix_documents_path", "documents", ["path"])
|
||||
op.create_index("ix_documents_content_hash", "documents", ["content_hash"])
|
||||
|
||||
op.create_table(
|
||||
"chunks",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"document_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("documents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("position", sa.Integer(), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("embedding", Vector(EMBEDDING_DIM), nullable=True),
|
||||
)
|
||||
op.create_index("ix_chunks_document_id", "chunks", ["document_id"])
|
||||
|
||||
op.create_table(
|
||||
"query_log",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("question", sa.Text(), nullable=False),
|
||||
sa.Column("top_score", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("chunk_hits", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("deflected", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("sources", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("latency_ms", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index("ix_query_log_created_at", "query_log", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("query_log")
|
||||
op.drop_table("chunks")
|
||||
op.drop_table("documents")
|
||||
op.execute("DROP EXTENSION IF EXISTS vector")
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Brain of Reese application package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1 @@
|
||||
"""FastAPI routers."""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""POST /api/chat — placeholder (phase 01).
|
||||
|
||||
Phase 03 replaces this with the real RAG pipeline and SSE streaming
|
||||
(PLAN §4 contract: ``delta`` events + final ``done``). The placeholder
|
||||
keeps the same JSON shape the frontend already consumes, so the UI round-trip
|
||||
is exercised end-to-end from day one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.schemas import ChatRequest
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
async def chat(request: ChatRequest) -> dict[str, object]:
|
||||
"""Placeholder answer — no LLM, no DB."""
|
||||
return {
|
||||
"ok": True,
|
||||
"answer": (
|
||||
"Hey! My neurons are still wiring up — the real Brain "
|
||||
"(RAG over your docs, powered by aipi) lands in the next "
|
||||
"phases. Try me again soon! 🧠"
|
||||
),
|
||||
"deflected": False,
|
||||
"sources": [],
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Health & readiness endpoint."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import db_available
|
||||
from app.schemas import HealthResponse
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
def health() -> HealthResponse:
|
||||
settings = get_settings()
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
db="up" if db_available() else "down",
|
||||
version=settings.app_version,
|
||||
environment=settings.environment,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Suggested-question endpoint (drives the onboarding chips in the UI)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.config import get_settings
|
||||
from app.schemas import SuggestionList
|
||||
|
||||
router = APIRouter(tags=["chat"])
|
||||
|
||||
|
||||
@router.get("/suggestions", response_model=SuggestionList)
|
||||
def suggestions() -> SuggestionList:
|
||||
return SuggestionList(suggestions=get_settings().suggestions)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Application settings.
|
||||
|
||||
Every setting can be overridden with an environment variable prefixed
|
||||
``BOR_`` (or a local gitignored ``.env`` file — see ``.env.example``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
env_prefix="BOR_",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# --- App ---
|
||||
app_name: str = "Brain of Reese"
|
||||
app_version: str = "0.1.0"
|
||||
environment: str = "development"
|
||||
log_level: str = "INFO"
|
||||
static_dir: str = "frontend"
|
||||
|
||||
# --- Database (PostgreSQL 17 + pgvector) ---
|
||||
database_url: str = "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese"
|
||||
|
||||
# --- LLM (self-hosted, OpenAI-compatible "aipi" endpoint) ---
|
||||
llm_base_url: str = "https://aipi.reeseapps.com/v1"
|
||||
llm_api_key: str = ""
|
||||
llm_chat_model: str = "turbo"
|
||||
llm_embed_model: str = "embed"
|
||||
|
||||
# --- RAG tuning ---
|
||||
embedding_dim: int = 768 # verified against aipi /v1 (embed model)
|
||||
top_k_chunks: int = 4
|
||||
top_n_docs: int = 2
|
||||
relevance_threshold: float = 0.30
|
||||
max_context_chars: int = 24_000
|
||||
chunk_target_chars: int = 2_000
|
||||
chunk_overlap_chars: int = 200
|
||||
embed_batch_size: int = 16
|
||||
|
||||
# Suggested questions (onboarding + empty state).
|
||||
suggestions: list[str] = [
|
||||
"How is my Kubernetes cluster set up?",
|
||||
"What's my backup strategy?",
|
||||
"How do I deploy a new service?",
|
||||
"What's currently running in the homelab?",
|
||||
]
|
||||
|
||||
@property
|
||||
def effective_api_key(self) -> str:
|
||||
"""API key for aipi: explicit setting, then $AIPI_KEY, then a placeholder."""
|
||||
return self.llm_api_key or os.environ.get("AIPI_KEY", "") or "not-needed"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1 @@
|
||||
"""Core utilities (debugging, logging)."""
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Conditional remote debugging via ``debugpy``.
|
||||
|
||||
**Default (``DEBUGPY`` unset or ``0``):** ``debugpy`` is *never imported* —
|
||||
zero overhead, production-safe.
|
||||
|
||||
**``DEBUGPY=1``:** imports ``debugpy`` and opens a *non-blocking* listener on
|
||||
``0.0.0.0:5678`` (override with ``DEBUGPY_PORT``). The application continues
|
||||
immediately; an IDE (VS Code / PyCharm) attaches on demand at any time.
|
||||
|
||||
Usage: call :func:`configure_debugging` once, as early as possible in the
|
||||
entrypoint (see ``app/main.py``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("app.debugging")
|
||||
|
||||
_listener: Any = None # debugpy.listen() result, when enabled
|
||||
|
||||
|
||||
def _port() -> int:
|
||||
try:
|
||||
return int(os.environ.get("DEBUGPY_PORT", "5678"))
|
||||
except ValueError:
|
||||
return 5678
|
||||
|
||||
|
||||
def _is_enabled() -> bool:
|
||||
return os.environ.get("DEBUGPY", "0").strip() == "1"
|
||||
|
||||
|
||||
def configure_debugging() -> bool:
|
||||
"""Enable debugpy if and only if ``DEBUGPY=1``.
|
||||
|
||||
Returns ``True`` when the listener was started.
|
||||
"""
|
||||
if not _is_enabled():
|
||||
return False
|
||||
|
||||
global _listener
|
||||
|
||||
import debugpy # imported ONLY when explicitly enabled — no overhead otherwise
|
||||
|
||||
port = _port()
|
||||
_listener = debugpy.listen(("0.0.0.0", port))
|
||||
logger.warning(
|
||||
"debugpy: remote debugging ENABLED, listening on 0.0.0.0:%d (attach on demand)", port
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def shutdown_debugpy() -> None:
|
||||
"""Stop the debugpy listener (used by tests and graceful shutdown)."""
|
||||
global _listener
|
||||
if _listener is not None:
|
||||
sock = getattr(_listener, "local_socket", None)
|
||||
if sock is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
sock.close()
|
||||
_listener = None
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Logging configuration.
|
||||
|
||||
Human-readable, timestamped, single-line records on stdout. Every
|
||||
request-critical operation (retrieval, LLM calls, imports) logs key=value
|
||||
context at INFO level so a user is never left wondering what the system is
|
||||
doing — this pairs with the UI's loading/progress feedback (see PLAN §UI/UX).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
_FORMAT = "%(asctime)s %(levelname)-8s %(name)s :: %(message)s"
|
||||
_DATEFMT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
def configure_logging(level: str = "INFO") -> None:
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level.upper())
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(logging.Formatter(_FORMAT, _DATEFMT))
|
||||
root.handlers = [handler]
|
||||
|
||||
# Keep third-party noise down while our own loggers stay verbose.
|
||||
for noisy in ("httpx", "httpcore", "openai", "urllib3"):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Database engine & session factory (PostgreSQL 17 + pgvector).
|
||||
|
||||
The engine is created lazily: importing this module never opens a
|
||||
connection, so the app boots (and ``/api/health`` reports) even when the
|
||||
database is momentarily unavailable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_engine(get_settings().database_url, pool_pre_ping=True, future=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False, future=True)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""FastAPI dependency yielding a database session."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def db_available() -> bool:
|
||||
"""Cheap liveness probe used by ``/api/health``."""
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
"""Brain of Reese — application entrypoint.
|
||||
|
||||
Boots logging + conditional debugpy, then creates the FastAPI app:
|
||||
API routes first (so they win over the catch-all), and the static frontend
|
||||
mounted last. No CDN: everything the browser needs is served by this
|
||||
process from local files (see PLAN §UI/UX — No External Dependencies).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.api.chat import router as chat_router
|
||||
from app.api.health import router as health_router
|
||||
from app.api.suggestions import router as suggestions_router
|
||||
from app.config import get_settings
|
||||
from app.core.debugging import configure_debugging
|
||||
from app.core.logging import configure_logging
|
||||
|
||||
configure_logging()
|
||||
configure_debugging()
|
||||
|
||||
settings = get_settings()
|
||||
logger = logging.getLogger("app")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title=settings.app_name, version=settings.app_version)
|
||||
|
||||
# API routes first so they take precedence over the catch-all static mount.
|
||||
app.include_router(health_router, prefix="/api")
|
||||
app.include_router(suggestions_router, prefix="/api")
|
||||
app.include_router(chat_router, prefix="/api")
|
||||
|
||||
static_dir = Path(settings.static_dir).resolve()
|
||||
if static_dir.is_dir():
|
||||
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
||||
else:
|
||||
logger.warning("static dir %s not found — serving API only", static_dir)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,81 @@
|
||||
"""SQLAlchemy models (PostgreSQL 17 + pgvector).
|
||||
|
||||
Data model — see ``.agent/PLAN.md`` §Data Model:
|
||||
|
||||
* ``documents`` — one row per ``*.md`` file (full content, path, sha256 hash).
|
||||
* ``chunks`` — retrieval units; each chunk points at its parent document
|
||||
via ``document_id``. This is how an embedding maps back to
|
||||
a document path (the "feed the whole document" requirement).
|
||||
* ``query_log`` — observability: every question, its retrieval score, the
|
||||
deflection decision, and latency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import Base
|
||||
|
||||
# Single source of truth for the vector column size (see .agent/PLAN.md A6).
|
||||
EMBEDDING_DIM: int = get_settings().embedding_dim
|
||||
|
||||
|
||||
class Document(Base):
|
||||
__tablename__ = "documents"
|
||||
__table_args__ = (UniqueConstraint("source", "path", name="uq_documents_source_path"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
source: Mapped[str] = mapped_column(String(120), index=True) # e.g. "Homelab"
|
||||
path: Mapped[str] = mapped_column(String(1000), index=True) # relative to source dir
|
||||
full_path: Mapped[str] = mapped_column(String(2000)) # absolute path at import time
|
||||
title: Mapped[str] = mapped_column(String(500))
|
||||
content: Mapped[str] = mapped_column(Text) # full markdown — the RAG context
|
||||
content_hash: Mapped[str] = mapped_column(String(64), index=True) # sha256 for change detection
|
||||
indexed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
chunks: Mapped[list[Chunk]] = relationship(
|
||||
back_populates="document", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Chunk(Base):
|
||||
__tablename__ = "chunks"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
document_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("documents.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
position: Mapped[int] = mapped_column(Integer)
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
embedding: Mapped[list[float] | None] = mapped_column(Vector(EMBEDDING_DIM))
|
||||
|
||||
document: Mapped[Document] = relationship(back_populates="chunks")
|
||||
|
||||
|
||||
class QueryLog(Base):
|
||||
__tablename__ = "query_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
question: Mapped[str] = mapped_column(Text)
|
||||
top_score: Mapped[float] = mapped_column(Float, default=0.0) # best cosine similarity
|
||||
chunk_hits: Mapped[int] = mapped_column(Integer, default=0)
|
||||
deflected: Mapped[bool] = mapped_column(Boolean, default=False) # True = honest "no idea"
|
||||
sources: Mapped[str] = mapped_column(Text, default="") # comma-joined source paths
|
||||
latency_ms: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Pydantic request/response schemas (API contract)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
db: str
|
||||
version: str
|
||||
environment: str
|
||||
|
||||
|
||||
class SuggestionList(BaseModel):
|
||||
suggestions: list[str]
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str = Field(min_length=1, max_length=4000)
|
||||
|
||||
|
||||
class SourceRef(BaseModel):
|
||||
source: str
|
||||
path: str
|
||||
title: str
|
||||
|
||||
|
||||
class ChatDoneEvent(BaseModel):
|
||||
"""Final SSE event of a chat turn: metadata for the finished answer."""
|
||||
|
||||
type: str = "done"
|
||||
deflected: bool
|
||||
sources: list[SourceRef]
|
||||
suggestions: list[str] = []
|
||||
|
||||
|
||||
class DocSummary(BaseModel):
|
||||
"""One indexed document as shown on the Sources page / API."""
|
||||
|
||||
id: str
|
||||
source: str
|
||||
path: str
|
||||
title: str
|
||||
chunks: int
|
||||
indexed_at: str
|
||||
@@ -0,0 +1,49 @@
|
||||
# Brain of Reese — service orchestration.
|
||||
#
|
||||
# Development: podman compose up -d # starts Postgres 17 + pgvector
|
||||
# Full stack: podman compose --profile prod up -d # adds the app container
|
||||
#
|
||||
# The `db` image is built locally from `./db` (base: docker.io/postgres:17,
|
||||
# extended with the pgvector extension) so no non-official base image is used.
|
||||
|
||||
name: brain-of-reese
|
||||
|
||||
services:
|
||||
db:
|
||||
build:
|
||||
context: ./db
|
||||
image: brain-of-reese/db:pg17-vector
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: reese
|
||||
POSTGRES_PASSWORD: reese
|
||||
POSTGRES_DB: brain_of_reese
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U reese -d brain_of_reese"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
image: brain-of-reese/app:latest
|
||||
restart: unless-stopped
|
||||
profiles: ["prod"]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BOR_ENVIRONMENT: production
|
||||
BOR_DATABASE_URL: postgresql+psycopg://reese:reese@db:5432/brain_of_reese
|
||||
BOR_LLM_BASE_URL: https://aipi.reeseapps.com/v1
|
||||
# BOR_LLM_API_KEY: provide via shell env or your own env file — never commit it
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,19 @@
|
||||
# Postgres 17 + pgvector.
|
||||
# Base image is the official docker.io/postgres:17; pgvector is compiled in
|
||||
# at build time so the app gets native `vector` type + cosine (`<=>`) search.
|
||||
FROM docker.io/postgres:17
|
||||
|
||||
ARG PGVECTOR_VERSION=v0.8.0
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential git ca-certificates postgresql-server-dev-17 \
|
||||
&& git clone --branch ${PGVECTOR_VERSION} --depth 1 https://github.com/pgvector/pgvector.git /tmp/pgvector \
|
||||
&& make -C /tmp/pgvector \
|
||||
&& make -C /tmp/pgvector install \
|
||||
&& rm -rf /tmp/pgvector \
|
||||
&& apt-get purge -y --auto-remove -qq \
|
||||
build-essential git ca-certificates postgresql-server-dev-17 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
EXPOSE 5432
|
||||
@@ -0,0 +1,189 @@
|
||||
/* Brain of Reese — chat shell.
|
||||
*
|
||||
* Scaffolding-stage behavior: renders suggestions, shows KB health, and
|
||||
* echoes a friendly placeholder answer. The real RAG streaming chat is
|
||||
* implemented in the chat-rag phase (see .agent/user_stories/).
|
||||
* All DOM ids match frontend/index.html.
|
||||
*/
|
||||
|
||||
const messagesEl = document.querySelector("#messages");
|
||||
const emptyState = document.querySelector("#empty-state");
|
||||
const suggestionsEl = document.querySelector("#suggestions");
|
||||
const composer = document.querySelector("#composer");
|
||||
const input = document.querySelector("#message-input");
|
||||
const sendBtn = document.querySelector("#send-btn");
|
||||
const sendLabel = document.querySelector("#send-label");
|
||||
const sendStatus = document.querySelector("#send-status");
|
||||
const banner = document.querySelector("#kb-banner");
|
||||
const bannerText = document.querySelector("#kb-banner-text");
|
||||
const versionEl = document.querySelector("#app-version");
|
||||
|
||||
/* ---------- tiny, safe markdown renderer (no external libs, no CDN) ---------- */
|
||||
export function escapeHtml(s) {
|
||||
return s.replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[c]));
|
||||
}
|
||||
|
||||
export function renderMarkdown(md) {
|
||||
// 1. Protect fenced code blocks.
|
||||
const codeBlocks = [];
|
||||
let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
||||
codeBlocks.push(`<pre><code>${escapeHtml(code.replace(/\n$/, ""))}</code></pre>`);
|
||||
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
|
||||
});
|
||||
|
||||
// 2. Escape everything else, then apply inline + block transforms.
|
||||
text = escapeHtml(text)
|
||||
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1<em>$2</em>")
|
||||
.replace(/^### (.*)$/gm, "<h4>$1</h4>")
|
||||
.replace(/^## (.*)$/gm, "<h3>$1</h3>")
|
||||
.replace(/^# (.*)$/gm, "<h3>$1</h3>")
|
||||
.replace(/^\s*[-*] (.*)$/gm, "<li>$1</li>")
|
||||
.replace(/(<li>[\s\S]*?<\/li>)(?!\s*<li>)/g, "<ul>$1</ul>")
|
||||
.replace(/^\d+\. (.*)$/gm, "<li>$1</li>");
|
||||
|
||||
// 3. Paragraphs (double newline separated).
|
||||
text = text
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => {
|
||||
const b = block.trim();
|
||||
if (!b) return "";
|
||||
if (/^<(h\d|ul|ol|pre|li)/.test(b)) return b;
|
||||
return `<p>${b.replace(/\n/g, "<br>")}</p>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// 4. Restore code blocks.
|
||||
return text.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]);
|
||||
}
|
||||
|
||||
/* ---------- messages ---------- */
|
||||
function addMessage(who, html) {
|
||||
if (emptyState) emptyState.hidden = true;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `msg ${who}`;
|
||||
wrap.innerHTML = `
|
||||
<span class="avatar" aria-hidden="true">${who === "brain" ? "🧠" : "🧑"}</span>
|
||||
<div class="msg-body">
|
||||
<div class="bubble">${html}</div>
|
||||
</div>`;
|
||||
messagesEl.appendChild(wrap);
|
||||
wrap.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function addTyping() {
|
||||
if (emptyState) emptyState.hidden = true;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "msg brain";
|
||||
wrap.id = "typing-indicator";
|
||||
wrap.innerHTML = `
|
||||
<span class="avatar" aria-hidden="true">🧠</span>
|
||||
<div class="msg-body">
|
||||
<div class="bubble typing" role="status" aria-label="Brain of Reese is thinking">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>`;
|
||||
messagesEl.appendChild(wrap);
|
||||
wrap.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}
|
||||
|
||||
function removeTyping() {
|
||||
document.querySelector("#typing-indicator")?.remove();
|
||||
}
|
||||
|
||||
/* ---------- suggestions ---------- */
|
||||
async function loadSuggestions() {
|
||||
try {
|
||||
const r = await fetch("/api/suggestions");
|
||||
if (!r.ok) return;
|
||||
const { suggestions } = await r.json();
|
||||
suggestionsEl.innerHTML = "";
|
||||
for (const s of suggestions) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "suggestion-chip";
|
||||
btn.textContent = s;
|
||||
btn.setAttribute("role", "listitem");
|
||||
btn.addEventListener("click", () => {
|
||||
input.value = s;
|
||||
input.focus();
|
||||
});
|
||||
suggestionsEl.appendChild(btn);
|
||||
}
|
||||
} catch {
|
||||
/* suggestions are progressive enhancement */
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- health / version ---------- */
|
||||
async function loadHealth() {
|
||||
try {
|
||||
const r = await fetch("/api/health");
|
||||
const body = await r.json();
|
||||
versionEl.textContent = `v${body.version}`;
|
||||
if (body.db === "down") {
|
||||
bannerText.textContent =
|
||||
"Knowledge base is offline — start Postgres with `podman compose up -d db`.";
|
||||
banner.hidden = false;
|
||||
}
|
||||
} catch {
|
||||
/* API unreachable: page still renders, composer will explain on send */
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- composer ---------- */
|
||||
function setBusy(busy) {
|
||||
sendBtn.disabled = busy;
|
||||
sendBtn.querySelector(".spinner").hidden = !busy;
|
||||
sendLabel.textContent = busy ? "Thinking…" : "Send";
|
||||
sendStatus.textContent = busy ? "Brain of Reese is working" : "";
|
||||
}
|
||||
|
||||
function autoGrow() {
|
||||
input.style.height = "auto";
|
||||
input.style.height = `${Math.min(input.scrollHeight, 192)}px`;
|
||||
}
|
||||
|
||||
async function handleSend(e) {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim();
|
||||
if (!text || sendBtn.disabled) return;
|
||||
|
||||
addMessage("user", renderMarkdown(text));
|
||||
input.value = "";
|
||||
autoGrow();
|
||||
setBusy(true);
|
||||
addTyping();
|
||||
|
||||
try {
|
||||
// TODO(chat-rag phase): replace with POST /api/chat (SSE streaming).
|
||||
await new Promise((res) => setTimeout(res, 500));
|
||||
const reply =
|
||||
"I'm still getting my neurons wired up — the real me ships in the " +
|
||||
"next phase! Keep the questions coming, you're on a roll. 🚀";
|
||||
removeTyping();
|
||||
addMessage("brain", renderMarkdown(reply));
|
||||
} catch {
|
||||
removeTyping();
|
||||
addMessage("brain", "Something went wrong on my side — please try again in a moment.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener("input", autoGrow);
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
composer.requestSubmit();
|
||||
}
|
||||
});
|
||||
composer.addEventListener("submit", handleSend);
|
||||
|
||||
loadSuggestions();
|
||||
loadHealth();
|
||||
@@ -0,0 +1,69 @@
|
||||
/* Brain of Reese — Sources page (knowledge base index view).
|
||||
* Scaffolding-stage: fetches /api/docs (implemented in the import phase);
|
||||
* until then it renders the empty state.
|
||||
*/
|
||||
|
||||
const tbody = document.querySelector("#docs-tbody");
|
||||
const emptyEl = document.querySelector("#sources-empty");
|
||||
const tableWrap = document.querySelector(".table-wrap");
|
||||
const statDocs = document.querySelector("#stat-docs");
|
||||
const statChunks = document.querySelector("#stat-chunks");
|
||||
const statLast = document.querySelector("#stat-last");
|
||||
|
||||
function fmtDate(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDocs() {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/docs");
|
||||
} catch {
|
||||
showEmpty();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
showEmpty();
|
||||
return;
|
||||
}
|
||||
const { documents } = await r.json();
|
||||
if (!documents.length) {
|
||||
showEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = "";
|
||||
let totalChunks = 0;
|
||||
let last = "";
|
||||
for (const d of documents) {
|
||||
totalChunks += d.chunks;
|
||||
if (d.indexed_at > last) last = d.indexed_at;
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${d.source}</td>
|
||||
<td title="${d.path}">${d.path}</td>
|
||||
<td>${d.title}</td>
|
||||
<td>${d.chunks}</td>
|
||||
<td>${fmtDate(d.indexed_at)}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
statDocs.textContent = String(documents.length);
|
||||
statChunks.textContent = String(totalChunks);
|
||||
statLast.textContent = last ? fmtDate(last) : "–";
|
||||
emptyEl.hidden = true;
|
||||
tableWrap.hidden = false;
|
||||
}
|
||||
|
||||
function showEmpty() {
|
||||
statDocs.textContent = "0";
|
||||
statChunks.textContent = "0";
|
||||
statLast.textContent = "–";
|
||||
emptyEl.hidden = false;
|
||||
if (tableWrap) tableWrap.hidden = true;
|
||||
}
|
||||
|
||||
loadDocs();
|
||||
@@ -0,0 +1,460 @@
|
||||
/* ==========================================================================
|
||||
Brain of Reese — design system (no CDN; system fonts only)
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* Palette — all text/background pairs meet WCAG 2.1 AA (>= 4.5:1) */
|
||||
--bg: #f4f5fb;
|
||||
--surface: #ffffff;
|
||||
--ink: #1c2130; /* 14.9:1 on --surface */
|
||||
--ink-soft: #4a5168; /* 7.6:1 on --surface */
|
||||
--line: #e3e6f0;
|
||||
--brand: #4f46e5; /* white on brand: 6.3:1 */
|
||||
--brand-soft: #eef0fe;
|
||||
--brand-ink: #3730a3;
|
||||
--accent-bg: #fff7e8;
|
||||
--accent-ink: #92400e; /* 8.7:1 on --accent-bg */
|
||||
--accent-line: #f59e0b;
|
||||
--ok-ink: #15803d;
|
||||
--ok-bg: #f0fdf4;
|
||||
--err-ink: #b91c1c;
|
||||
--err-bg: #fef2f2;
|
||||
--err-line: #fecaca;
|
||||
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
--shadow: 0 1px 2px rgb(28 33 48 / 0.06), 0 4px 16px rgb(28 33 48 / 0.07);
|
||||
--shadow-lg: 0 4px 10px rgb(28 33 48 / 0.08), 0 12px 32px rgb(28 33 48 / 0.12);
|
||||
|
||||
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
|
||||
--header-h: 64px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 72rem;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
|
||||
/* ---------- Accessibility helpers ---------- */
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px;
|
||||
margin: -1px; padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 0 0 var(--radius-sm) 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.skip-link:focus { left: 0; }
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
.app-header {
|
||||
height: var(--header-h);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
.header-inner {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
font-size: 1.125rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand-mark { font-size: 1.4rem; }
|
||||
.brand-text strong { color: var(--brand-ink); font-weight: 700; }
|
||||
|
||||
.app-nav { display: flex; gap: 0.25rem; }
|
||||
.nav-link {
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
color: var(--ink-soft);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.nav-link:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
.nav-link.is-active { background: var(--brand); color: #fff; }
|
||||
|
||||
/* ---------- Main frame ---------- */
|
||||
.app-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-block: 1.25rem;
|
||||
}
|
||||
|
||||
/* Chat is a vertical conversation: a centered, capped column is the
|
||||
correct layout here (PLAN §UI/UX). The surrounding frame keeps it
|
||||
from collapsing into a hairline on wide screens. */
|
||||
.chat-shell {
|
||||
max-width: 46rem;
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* ---------- Messages ---------- */
|
||||
.msg { display: flex; gap: 0.6rem; max-width: 100%; }
|
||||
.msg .avatar {
|
||||
flex: 0 0 auto;
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.05rem;
|
||||
background: var(--brand-soft);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.msg-body {
|
||||
max-width: 85%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.bubble {
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.bubble p { margin: 0.2rem 0; }
|
||||
.bubble pre {
|
||||
background: #10131c;
|
||||
color: #e6e9f2;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow-x: auto;
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.bubble code { font-family: var(--mono); font-size: 0.88em; background: var(--brand-soft); padding: 0.08em 0.35em; border-radius: 5px; }
|
||||
.bubble pre code { background: none; padding: 0; }
|
||||
|
||||
.msg.user { justify-content: flex-end; }
|
||||
.msg.user .msg-body { align-items: flex-end; }
|
||||
.msg.user .bubble {
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.msg.user .bubble code { background: rgb(255 255 255 / 0.18); }
|
||||
|
||||
.msg.brain .bubble { border-bottom-left-radius: 4px; }
|
||||
.msg.brain.is-deflected .bubble {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
|
||||
.msg-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-soft);
|
||||
padding-inline: 0.25rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
.source-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.6rem;
|
||||
text-decoration: none;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.source-chip:hover { background: #e2e5fd; }
|
||||
|
||||
/* typing indicator */
|
||||
.typing { display: inline-flex; gap: 5px; padding: 0.9rem 1rem; }
|
||||
.typing span {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--ink-soft);
|
||||
opacity: 0.5;
|
||||
animation: typing 1.2s infinite ease-in-out;
|
||||
}
|
||||
.typing span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.typing span:nth-child(3) { animation-delay: 0.3s; }
|
||||
@keyframes typing {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-5px); opacity: 1; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.typing span { animation: none; opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* ---------- Empty state & suggestions ---------- */
|
||||
.empty-state {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 2.5rem 1.75rem;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.empty-state-emoji { font-size: 2.6rem; line-height: 1; }
|
||||
.empty-state-title { margin: 0.8rem 0 0.4rem; font-size: 1.5rem; color: var(--ink); }
|
||||
.empty-state-sub { margin: 0 auto 1.25rem; max-width: 34rem; color: var(--ink-soft); }
|
||||
.empty-state-sub code { font-family: var(--mono); font-size: 0.85em; background: var(--brand-soft); padding: 0.1em 0.35em; border-radius: 5px; }
|
||||
|
||||
.suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.suggestion-chip {
|
||||
font: inherit;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand-ink);
|
||||
background: var(--brand-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.55rem 1rem;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, transform 0.05s ease;
|
||||
}
|
||||
.suggestion-chip:hover { background: #e2e5fd; }
|
||||
.suggestion-chip:active { transform: scale(0.98); }
|
||||
|
||||
/* ---------- Composer ---------- */
|
||||
.composer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.6rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.6rem;
|
||||
}
|
||||
.composer:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); }
|
||||
.composer textarea {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
border: 0;
|
||||
resize: none;
|
||||
max-height: 12rem;
|
||||
padding: 0.55rem 0.5rem;
|
||||
background: transparent;
|
||||
}
|
||||
.composer textarea:focus { outline: none; }
|
||||
|
||||
.send-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
min-width: 84px;
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
.send-btn:hover:not(:disabled) { background: #4338ca; }
|
||||
.send-btn:disabled { background: #a5b4fc; cursor: not-allowed; }
|
||||
|
||||
.spinner {
|
||||
width: 16px; height: 16px;
|
||||
border: 2.5px solid rgb(255 255 255 / 0.4);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinner { animation-duration: 2s; }
|
||||
}
|
||||
|
||||
/* ---------- Banners ---------- */
|
||||
.kb-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent-ink);
|
||||
border: 1px solid var(--accent-line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.kb-banner.is-error { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||
|
||||
/* ---------- Sources page ---------- */
|
||||
.sources-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
.page-head h1 { margin: 0 0 0.25rem; font-size: 1.7rem; }
|
||||
.page-sub { margin: 0; color: var(--ink-soft); }
|
||||
.page-sub code { font-family: var(--mono); font-size: 0.85em; background: var(--brand-soft); padding: 0.1em 0.35em; border-radius: 5px; }
|
||||
|
||||
.stat-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1.1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.stat-value { font-size: 2rem; font-weight: 800; color: var(--brand-ink); line-height: 1.1; }
|
||||
.stat-value-sm { font-size: 1.15rem; font-weight: 700; }
|
||||
.stat-label { color: var(--ink-soft); font-size: 0.88rem; font-weight: 600; }
|
||||
|
||||
.table-wrap {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.docs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 640px;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
.docs-table th, .docs-table td {
|
||||
text-align: left;
|
||||
padding: 0.7rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.docs-table th {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.docs-table td:nth-child(2) { font-family: var(--mono); font-size: 0.82rem; max-width: 30rem; overflow: hidden; text-overflow: ellipsis; }
|
||||
.docs-table tbody tr:hover { background: var(--bg); }
|
||||
.docs-table tbody tr:last-child td { border-bottom: 0; }
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
.app-footer {
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
padding-block: 0.8rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.footer-inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ---------- Responsive (mobile-first adjustments) ---------- */
|
||||
@media (max-width: 640px) {
|
||||
:root { --header-h: 58px; }
|
||||
.container { padding-inline: 0.9rem; }
|
||||
.brand-text { font-size: 1rem; }
|
||||
.nav-link { padding: 0.45rem 0.7rem; font-size: 0.9rem; }
|
||||
.msg-body { max-width: 92%; }
|
||||
.empty-state { padding: 1.75rem 1.1rem; margin-top: 0.25rem; }
|
||||
.empty-state-title { font-size: 1.25rem; }
|
||||
.suggestions { flex-wrap: nowrap; overflow-x: auto; justify-content: flex-start;
|
||||
padding-bottom: 0.4rem; -webkit-overflow-scrolling: touch; scrollbar-width: thin; }
|
||||
.suggestion-chip { flex: 0 0 auto; }
|
||||
.composer { padding: 0.5rem; }
|
||||
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
||||
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Ask Brain of Reese anything about the homelab and deployments.">
|
||||
<title>Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🧠</text></svg>">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="app-header">
|
||||
<div class="container header-inner">
|
||||
<span class="brand">
|
||||
<span class="brand-mark" aria-hidden="true">🧠</span>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<nav class="app-nav" aria-label="Primary">
|
||||
<a href="/" class="nav-link is-active" aria-current="page">Chat</a>
|
||||
<a href="/sources.html" class="nav-link">Sources</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main">
|
||||
<div class="container chat-shell" data-state="empty">
|
||||
<div class="kb-banner" id="kb-banner" role="status" hidden>
|
||||
<span aria-hidden="true">⚠️</span>
|
||||
<span id="kb-banner-text"></span>
|
||||
</div>
|
||||
|
||||
<section class="messages" id="messages" aria-live="polite" aria-label="Conversation with Brain of Reese">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<div class="empty-state-emoji" aria-hidden="true">👋</div>
|
||||
<h1 class="empty-state-title">Hey! I'm Brain of Reese.</h1>
|
||||
<p class="empty-state-sub">
|
||||
I've read through the homelab and deployment notes — ask me anything,
|
||||
and I'll point you at the exact doc. You've got this.
|
||||
</p>
|
||||
<div class="suggestions" id="suggestions" role="list" aria-label="Suggested questions">
|
||||
<!-- suggestion chips rendered by app.js from /api/suggestions -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form class="composer" id="composer">
|
||||
<label class="visually-hidden" for="message-input">Ask Brain of Reese a question</label>
|
||||
<textarea
|
||||
id="message-input"
|
||||
name="message"
|
||||
rows="1"
|
||||
placeholder="Ask me about the homelab…"
|
||||
autocomplete="off"
|
||||
required
|
||||
></textarea>
|
||||
<button type="submit" class="send-btn" id="send-btn">
|
||||
<span class="spinner" aria-hidden="true" hidden></span>
|
||||
<span class="btn-label" id="send-label">Send</span>
|
||||
<span class="visually-hidden" aria-live="polite" id="send-status"></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<div class="container footer-inner">
|
||||
<span>Powered by Reese's self-hosted models</span>
|
||||
<span class="footer-version" id="app-version"></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Documents indexed in Brain of Reese.">
|
||||
<title>Sources · Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🧠</text></svg>">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="app-header">
|
||||
<div class="container header-inner">
|
||||
<span class="brand">
|
||||
<span class="brand-mark" aria-hidden="true">🧠</span>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<nav class="app-nav" aria-label="Primary">
|
||||
<a href="/" class="nav-link">Chat</a>
|
||||
<a href="/sources.html" class="nav-link is-active" aria-current="page">Sources</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main">
|
||||
<div class="container sources-shell">
|
||||
<div class="page-head">
|
||||
<h1>Knowledge base</h1>
|
||||
<p class="page-sub">
|
||||
Every <code>*.md</code> file indexed from <code>~/Homelab</code> and
|
||||
<code>~/Deployments</code>. Re-run the import to refresh.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-cards" id="stat-cards">
|
||||
<div class="stat-card" role="group" aria-label="Document statistics">
|
||||
<span class="stat-value" id="stat-docs">–</span>
|
||||
<span class="stat-label">documents</span>
|
||||
</div>
|
||||
<div class="stat-card" role="group" aria-label="Chunk statistics">
|
||||
<span class="stat-value" id="stat-chunks">–</span>
|
||||
<span class="stat-label">chunks</span>
|
||||
</div>
|
||||
<div class="stat-card" role="group" aria-label="Last indexed">
|
||||
<span class="stat-value stat-value-sm" id="stat-last">–</span>
|
||||
<span class="stat-label">last indexed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap" role="region" aria-label="Indexed documents" tabindex="0">
|
||||
<table class="docs-table" id="docs-table">
|
||||
<caption class="visually-hidden">Indexed markdown documents</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Path</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Chunks</th>
|
||||
<th scope="col">Indexed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="docs-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="sources-empty" hidden>
|
||||
<div class="empty-state-emoji" aria-hidden="true">📂</div>
|
||||
<h2 class="empty-state-title">Nothing indexed yet</h2>
|
||||
<p class="empty-state-sub">
|
||||
Run the import to pull in the markdown docs:
|
||||
<code>uv run python -m scripts.import_docs</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<div class="container footer-inner">
|
||||
<span>Powered by Reese's self-hosted models</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="/assets/sources.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
[project]
|
||||
name = "brain-of-reese"
|
||||
version = "0.1.0"
|
||||
description = "Brain of Reese — a chippy RAG chatbot that answers questions about Reese's Homelab and Deployments, powered by self-hosted models."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
# --- Web framework ---
|
||||
"fastapi>=0.115,<1.0",
|
||||
"uvicorn[standard]>=0.30,<1.0",
|
||||
"pydantic>=2.7,<3.0",
|
||||
"pydantic-settings>=2.3,<3.0",
|
||||
"python-dotenv>=1.0,<2.0",
|
||||
# --- Database (PostgreSQL 17 + pgvector) ---
|
||||
"sqlalchemy>=2.0,<2.1",
|
||||
"alembic>=1.13,<2.0",
|
||||
"psycopg[binary]>=3.1,<4.0",
|
||||
"pgvector>=0.3,<1.0",
|
||||
# --- LLM client (OpenAI-compatible, self-hosted "aipi") ---
|
||||
"httpx>=0.27,<1.0",
|
||||
"openai>=1.40,<3.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"debugpy>=1.8",
|
||||
"ruff>=0.5",
|
||||
"pyright>=1.1",
|
||||
"pytest>=8.2",
|
||||
"pytest-cov>=5.0",
|
||||
"playwright>=1.45",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
|
||||
|
||||
[tool.pyright]
|
||||
pythonVersion = "3.12"
|
||||
typeCheckingMode = "standard"
|
||||
include = ["app", "scripts", "alembic", "tests"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests/unit", "tests/integration"]
|
||||
pythonpath = ["."]
|
||||
addopts = "-q"
|
||||
@@ -0,0 +1 @@
|
||||
"""Runnable scripts (import_docs, llm_probe) — invoked via `python -m scripts.<name>`."""
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Production entrypoint: apply migrations, then serve.
|
||||
set -eu
|
||||
cd /app
|
||||
echo "[entrypoint] applying database migrations..."
|
||||
alembic upgrade head
|
||||
echo "[entrypoint] starting uvicorn on :${PORT:-8000}"
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port "${PORT:-8000}"
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Probe the self-hosted LLM endpoint (aipi).
|
||||
|
||||
Lists available models and verifies the embedding dimension of the
|
||||
configured ``embed`` model against ``BOR_EMBEDDING_DIM`` (default 768).
|
||||
Run this before the first import if the LLM backend ever changes:
|
||||
|
||||
uv run python -m scripts.llm_probe
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
base_url = os.environ.get("BOR_LLM_BASE_URL", "https://aipi.reeseapps.com/v1").rstrip("/")
|
||||
api_key = (
|
||||
os.environ.get("BOR_LLM_API_KEY")
|
||||
or os.environ.get("AIPI_KEY")
|
||||
or "not-needed"
|
||||
)
|
||||
embed_model = os.environ.get("BOR_LLM_EMBED_MODEL", "embed")
|
||||
chat_model = os.environ.get("BOR_LLM_CHAT_MODEL", "turbo")
|
||||
expected_dim = int(os.environ.get("BOR_EMBEDDING_DIM", "768"))
|
||||
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
with httpx.Client(base_url=base_url, headers=headers, timeout=30.0) as client:
|
||||
r = client.get("/models")
|
||||
r.raise_for_status()
|
||||
models = [m["id"] for m in r.json()["data"]]
|
||||
print(f"[probe] base_url : {base_url}")
|
||||
print(f"[probe] models : {', '.join(models)}")
|
||||
|
||||
for needed in (chat_model, embed_model):
|
||||
if needed not in models:
|
||||
print(f"[probe] ERROR: required model '{needed}' not available")
|
||||
return 1
|
||||
|
||||
r = client.post(
|
||||
"/embeddings",
|
||||
json={"model": embed_model, "input": "brain of reese dimension probe"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
dims = sorted({len(d["embedding"]) for d in r.json()["data"]})
|
||||
print(f"[probe] dims({embed_model}): {dims}")
|
||||
|
||||
if dims != [expected_dim]:
|
||||
print(
|
||||
f"[probe] MISMATCH: expected {expected_dim}, got {dims}. "
|
||||
"Update BOR_EMBEDDING_DIM and recreate the chunks table (see README)."
|
||||
)
|
||||
return 1
|
||||
print("[probe] OK — models present, embedding dimension matches configuration.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Shared fixtures for unit + integration tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app as fastapi_app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client() -> TestClient:
|
||||
return TestClient(fastapi_app)
|
||||
@@ -0,0 +1 @@
|
||||
"""E2E test package."""
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Playwright E2E fixtures (shared by every story's test file).
|
||||
|
||||
Each user story in ``.agent/user_stories/`` gets its own isolated E2E test
|
||||
file; this conftest provides the shared environment:
|
||||
|
||||
* ``mock_llm`` — deterministic OpenAI-compatible server (see mock_llm.py).
|
||||
Set ``E2E_REAL_LLM=1`` to point at the real aipi endpoint
|
||||
instead (requires an imported knowledge base).
|
||||
* ``app_server`` — the real FastAPI app under test (uvicorn subprocess).
|
||||
* ``browser``/``page`` — headless Chromium pointed at the app.
|
||||
|
||||
Prerequisite for story tests that touch the database:
|
||||
podman compose up -d db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Browser, Page, sync_playwright
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
APP_PORT = int(os.environ.get("E2E_APP_PORT", "8123"))
|
||||
MOCK_PORT = int(os.environ.get("E2E_MOCK_PORT", "8901"))
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
USE_REAL_LLM = os.environ.get("E2E_REAL_LLM") == "1"
|
||||
|
||||
|
||||
def _wait_http(url: str, timeout: float = 40.0) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
last_err = "unknown"
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
httpx.get(url, timeout=2.0)
|
||||
return
|
||||
except Exception as e: # noqa: BLE001 — retry until deadline
|
||||
last_err = str(e)
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError(f"server at {url} did not come up: {last_err}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def mock_llm() -> Iterator[int]:
|
||||
"""Deterministic OpenAI-compatible LLM (chat + embeddings)."""
|
||||
if USE_REAL_LLM:
|
||||
yield 0
|
||||
return
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "tests.e2e.mock_llm:app",
|
||||
"--host", "127.0.0.1", "--port", str(MOCK_PORT), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
_wait_http(f"http://127.0.0.1:{MOCK_PORT}/v1/models")
|
||||
yield MOCK_PORT
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def app_server(mock_llm: int) -> Iterator[str]:
|
||||
"""The real app under test."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_LLM_BASE_URL"] = (
|
||||
"https://aipi.reeseapps.com/v1"
|
||||
if USE_REAL_LLM
|
||||
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||
)
|
||||
env.setdefault("BOR_DATABASE_URL", "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese")
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{APP_URL}/api/health")
|
||||
yield APP_URL
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_ready(app_url: str) -> None:
|
||||
"""Skip a test with clear instructions when Postgres is not running."""
|
||||
body = httpx.get(f"{app_url}/api/health", timeout=5).json()
|
||||
if body["db"] != "up":
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser() -> Iterator[Browser]:
|
||||
with sync_playwright() as p:
|
||||
yield p.chromium.launch(headless=True)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def page(browser: Browser) -> Iterator[Page]:
|
||||
pg = browser.new_page(viewport={"width": 1280, "height": 800})
|
||||
yield pg
|
||||
pg.close()
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Deterministic OpenAI-compatible mock for E2E tests (aipi stand-in).
|
||||
|
||||
Implements just enough of the aipi surface:
|
||||
|
||||
* ``GET /v1/models``
|
||||
* ``POST /v1/embeddings`` — real bag-of-words vectors (768-dim, L2-normed).
|
||||
Because similarity is *genuine token overlap*, the relevance threshold
|
||||
behaves the same way it will in production: related questions score high,
|
||||
unrelated ones score low and trigger honest deflection.
|
||||
* ``POST /v1/chat/completions`` — streaming (SSE) or not. The content keys
|
||||
off markers in the system prompt:
|
||||
- ``DEFLECT_MODE`` -> honest "I haven't done anything like that" answer
|
||||
- otherwise -> upbeat answer quoting the provided document context
|
||||
- user message containing ``pretend to think slowly`` -> 3s warm-up delay
|
||||
(used by the loading-feedback story).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
DIM = 768
|
||||
TOKEN_RE = re.compile(r"[a-z0-9]+")
|
||||
|
||||
|
||||
def embed_text(text: str) -> list[float]:
|
||||
vec = [0.0] * DIM
|
||||
for tok in TOKEN_RE.findall(text.lower()):
|
||||
idx = int(hashlib.md5(tok.encode()).hexdigest(), 16) % DIM
|
||||
vec[idx] += 1.0
|
||||
norm = math.sqrt(sum(v * v for v in vec)) or 1.0
|
||||
return [v / norm for v in vec]
|
||||
|
||||
|
||||
def _messages(body: dict[str, Any]) -> list[dict[str, str]]:
|
||||
return body.get("messages", [])
|
||||
|
||||
|
||||
def _system(body: dict[str, Any]) -> str:
|
||||
return " ".join(m.get("content", "") for m in _messages(body) if m.get("role") == "system")
|
||||
|
||||
|
||||
def _user(body: dict[str, Any]) -> str:
|
||||
parts = [m.get("content", "") for m in _messages(body) if m.get("role") == "user"]
|
||||
return parts[-1] if parts else ""
|
||||
|
||||
|
||||
def _context(body: dict[str, Any]) -> str:
|
||||
"""The document context is the longest system/user message in practice."""
|
||||
msgs = _messages(body)
|
||||
return max((m.get("content", "") for m in msgs), key=len)
|
||||
|
||||
|
||||
def compose_answer(body: dict[str, Any]) -> str:
|
||||
system = _system(body)
|
||||
user = _user(body)
|
||||
if "DEFLECT_MODE" in system:
|
||||
return (
|
||||
"Ah — I haven't done anything like that, so I don't want to make stuff up! "
|
||||
"You're thinking bigger than my notes for a second. Try asking about "
|
||||
"kubernetes, backups, or deploying a new service — I know those inside out. "
|
||||
"You've got this!"
|
||||
)
|
||||
ctx = _context(body)
|
||||
snippet = ctx[:220].replace("\n", " ").strip()
|
||||
return (
|
||||
f"Great question — you've absolutely got this! Here's what my notes say about "
|
||||
f"“{user.strip()[:80]}”: {snippet}… That's the gist from the docs; happy to "
|
||||
"dig into any of it. (Deterministic mock answer for E2E.)"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/v1/models")
|
||||
def models() -> dict[str, Any]:
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "turbo", "object": "model"},
|
||||
{"id": "embed", "object": "model"},
|
||||
{"id": "lite", "object": "model"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/embeddings")
|
||||
def embeddings(body: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = body.get("input")
|
||||
if isinstance(raw, str):
|
||||
raw = [raw]
|
||||
inputs: list[Any] = list(raw) if isinstance(raw, list) else []
|
||||
data = [
|
||||
{"object": "embedding", "index": i, "embedding": embed_text(t)}
|
||||
for i, t in enumerate(inputs)
|
||||
]
|
||||
return {
|
||||
"object": "list",
|
||||
"data": data,
|
||||
"model": body.get("model", "embed"),
|
||||
"usage": {"prompt_tokens": 8, "total_tokens": 8},
|
||||
}
|
||||
|
||||
|
||||
def _sse_stream(answer: str, delay: float) -> Any:
|
||||
model = "turbo"
|
||||
chunk_id = f"chatcmpl-{uuid.uuid4()}"
|
||||
if delay:
|
||||
time.sleep(delay)
|
||||
for piece in re.findall(r".{1,12}", answer, re.S):
|
||||
payload = {
|
||||
"id": chunk_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": model,
|
||||
"choices": [{"index": 0, "delta": {"content": piece}, "finish_reason": None}],
|
||||
}
|
||||
yield f"data: {json_dumps(payload)}\n\n"
|
||||
time.sleep(0.02)
|
||||
yield (
|
||||
"data: "
|
||||
+ json_dumps(
|
||||
{
|
||||
"id": chunk_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": model,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
}
|
||||
)
|
||||
+ "\n\n"
|
||||
)
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
|
||||
def json_dumps(obj: dict[str, Any]) -> str:
|
||||
import json
|
||||
|
||||
return json.dumps(obj)
|
||||
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
def chat_completions(body: dict[str, Any]) -> Any:
|
||||
answer = compose_answer(body)
|
||||
delay = 3.0 if "pretend to think slowly" in _user(body) else 0.0
|
||||
|
||||
if not body.get("stream"):
|
||||
return {
|
||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": body.get("model", "turbo"),
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": answer},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
|
||||
}
|
||||
|
||||
return StreamingResponse(
|
||||
_sse_stream(answer, delay),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Phase 01 smoke E2E: the app boots, serves the local frontend, and the
|
||||
placeholder chat round-trips without a stale button.
|
||||
|
||||
Run: uv run pytest tests/e2e/test_smoke.py -v
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def test_health_endpoint(app_url: str) -> None:
|
||||
r = httpx.get(f"{app_url}/api/health", timeout=5)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_index_page_loads_locally(page: Page, app_url: str) -> None:
|
||||
page.goto(app_url)
|
||||
assert page.title() == "Brain of Reese"
|
||||
assert page.locator(".brand").is_visible()
|
||||
# No external (CDN) resources in the document.
|
||||
html = page.content()
|
||||
assert 'src="http' not in html
|
||||
assert 'href="http' not in html.replace('href="http://www.w3.org', "")
|
||||
|
||||
|
||||
def test_placeholder_chat_roundtrip(page: Page, app_url: str) -> None:
|
||||
page.goto(app_url)
|
||||
page.locator("#message-input").fill("hello brain")
|
||||
page.locator("#send-btn").click()
|
||||
|
||||
# User bubble appears, then the Brain placeholder answer arrives.
|
||||
page.locator(".msg.user .bubble").first.wait_for(state="visible", timeout=10_000)
|
||||
brain_bubble = page.locator(".msg.brain .bubble").first
|
||||
brain_bubble.wait_for(state="visible", timeout=10_000)
|
||||
# to_have_text retries until the async fetch resolves (no stale read).
|
||||
expect(brain_bubble).to_have_text(re.compile("neurons"), timeout=10_000)
|
||||
|
||||
# Button is never left stuck: back to "Send" and enabled.
|
||||
btn = page.locator("#send-btn")
|
||||
assert btn.is_enabled()
|
||||
assert "Send" in btn.inner_text()
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Deploying a New Service
|
||||
|
||||
## Steps
|
||||
1. Fork the `template/` repository.
|
||||
2. Add a cloud-init snippet for provisioning the host.
|
||||
3. Wire up the reverse proxy (Traefik) with a `reeseapps.com` label.
|
||||
4. Run the Ansible play: `ansible-playbook sites.yaml -l newhost`.
|
||||
|
||||
## Domains
|
||||
All public services live under `*.reeseapps.com`.
|
||||
|
||||
## DNS
|
||||
Managed by the ddns updater; new subdomains appear within an hour.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# Backup Strategy
|
||||
|
||||
## Philosophy
|
||||
3-2-1 rule: three copies, two media types, one offsite.
|
||||
|
||||
## Tooling
|
||||
BorgBase for offsite backups. Restic for local nightly snapshots, orchestrated via Ansible.
|
||||
|
||||
## Schedule
|
||||
- Nightly 02:00 — restic local snapshots
|
||||
- Weekly Sunday 03:00 — borg offsite push
|
||||
|
||||
## Restore
|
||||
Restores are documented per-service in each deployment README. Test a restore quarterly.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Kubernetes Homelab Cluster
|
||||
|
||||
## Overview
|
||||
The cluster runs Talos Linux on three nodes: two workers (i5-8500, 32GB) and one control plane.
|
||||
|
||||
## Networking
|
||||
Cilium handles networking and the L4/L7 proxy. Ingress is served via the Cilium Gateway API.
|
||||
|
||||
## Storage
|
||||
Local-path-provisioner provides scratch storage. Longhorn is intentionally not used.
|
||||
|
||||
## Notable Workloads
|
||||
- Gitea (source control)
|
||||
- ntfy (push notifications)
|
||||
- Homepage (dashboard)
|
||||
- Uptime Kuma (monitoring)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Integration tests: HTTP API surface (no database required)."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_health_reports_ok(client) -> None:
|
||||
r = client.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["db"] in {"up", "down"}
|
||||
assert body["version"]
|
||||
|
||||
|
||||
def test_suggestions_returns_list(client) -> None:
|
||||
r = client.get("/api/suggestions")
|
||||
assert r.status_code == 200
|
||||
suggestions = r.json()["suggestions"]
|
||||
assert isinstance(suggestions, list)
|
||||
assert all(isinstance(s, str) and s for s in suggestions)
|
||||
|
||||
|
||||
def test_index_html_served_locally(client) -> None:
|
||||
"""No-CDN check: the page is served by FastAPI and references only
|
||||
same-origin assets (no https:// script/link tags)."""
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "Brain of Reese" in r.text
|
||||
assert 'src="https://' not in r.text
|
||||
assert 'href="https://' not in r.text
|
||||
|
||||
|
||||
def test_styles_and_js_served(client) -> None:
|
||||
assert client.get("/assets/styles.css").status_code == 200
|
||||
assert client.get("/assets/app.js").status_code == 200
|
||||
|
||||
|
||||
def test_chat_placeholder_roundtrip(client) -> None:
|
||||
r = client.post("/api/chat", json={"message": "hello brain"})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
assert "neurons" in data["answer"]
|
||||
assert data["deflected"] is False
|
||||
assert data["sources"] == []
|
||||
|
||||
|
||||
def test_chat_requires_message(client) -> None:
|
||||
r = client.post("/api/chat", json={"message": ""})
|
||||
assert r.status_code == 422
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Unit tests: settings defaults & env overrides."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def _settings(**kwargs: Any) -> Settings:
|
||||
"""Build Settings without reading a .env file (deterministic tests)."""
|
||||
kwargs.setdefault("_env_file", None)
|
||||
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
|
||||
|
||||
|
||||
def test_defaults_match_locked_decisions() -> None:
|
||||
s = _settings()
|
||||
assert s.llm_chat_model == "turbo"
|
||||
assert s.llm_embed_model == "embed"
|
||||
assert s.embedding_dim == 768
|
||||
assert s.llm_base_url.endswith("/v1")
|
||||
assert 0 < s.relevance_threshold < 1
|
||||
assert s.top_k_chunks >= 1
|
||||
assert s.top_n_docs >= 1
|
||||
assert len(s.suggestions) >= 3
|
||||
|
||||
|
||||
def test_env_override(monkeypatch) -> None:
|
||||
monkeypatch.setenv("BOR_RELEVANCE_THRESHOLD", "0.42")
|
||||
monkeypatch.setenv("BOR_LLM_CHAT_MODEL", "juggernaut")
|
||||
s = _settings()
|
||||
assert s.relevance_threshold == 0.42
|
||||
assert s.llm_chat_model == "juggernaut"
|
||||
|
||||
|
||||
def test_effective_api_key_fallback(monkeypatch) -> None:
|
||||
monkeypatch.delenv("AIPI_KEY", raising=False)
|
||||
s = _settings()
|
||||
assert s.effective_api_key == "not-needed"
|
||||
|
||||
monkeypatch.setenv("AIPI_KEY", "sk-from-env")
|
||||
s2 = _settings()
|
||||
assert s2.effective_api_key == "sk-from-env"
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Unit tests: engine/session helpers (no live database required).
|
||||
|
||||
Creating a SQLAlchemy engine/session is lazy — no connection opens until the
|
||||
first query — so these run anywhere.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import db as app_db
|
||||
|
||||
|
||||
def test_engine_and_session_factory_are_lazy() -> None:
|
||||
assert isinstance(app_db.engine, Engine)
|
||||
session = app_db.SessionLocal()
|
||||
try:
|
||||
assert isinstance(session, Session)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_get_db_yields_session_and_closes_generator() -> None:
|
||||
gen = app_db.get_db()
|
||||
session = next(gen)
|
||||
assert isinstance(session, Session)
|
||||
session.close()
|
||||
gen.close() # exercises the finally: db.close()
|
||||
|
||||
|
||||
def test_db_available_true_on_select_one(monkeypatch) -> None:
|
||||
class _FakeConn:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
return None
|
||||
|
||||
def execute(self, _stmt: object) -> None:
|
||||
return None
|
||||
|
||||
class _FakeEngine:
|
||||
def connect(self) -> _FakeConn:
|
||||
return _FakeConn()
|
||||
|
||||
monkeypatch.setattr(app_db, "engine", _FakeEngine())
|
||||
assert app_db.db_available() is True
|
||||
|
||||
|
||||
def test_db_available_false_on_error(monkeypatch) -> None:
|
||||
class _BrokenEngine:
|
||||
def connect(self) -> object:
|
||||
raise ConnectionError("db is down")
|
||||
|
||||
monkeypatch.setattr(app_db, "engine", _BrokenEngine())
|
||||
assert app_db.db_available() is False
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Unit tests: conditional debugpy gating (PLAN anchor A14).
|
||||
|
||||
Rules under test:
|
||||
* DEBUGPY unset or 0 -> configure_debugging() is False, debugpy NOT imported.
|
||||
* DEBUGPY=1 -> configure_debugging() is True, listener on DEBUGPY_PORT.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import app.core.debugging as dbg
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def test_disabled_by_default(monkeypatch) -> None:
|
||||
monkeypatch.delenv("DEBUGPY", raising=False)
|
||||
monkeypatch.delenv("DEBUGPY_PORT", raising=False)
|
||||
sys.modules.pop("debugpy", None)
|
||||
assert dbg.configure_debugging() is False
|
||||
assert "debugpy" not in sys.modules # zero overhead: never imported
|
||||
|
||||
|
||||
def test_explicit_zero(monkeypatch) -> None:
|
||||
monkeypatch.setenv("DEBUGPY", "0")
|
||||
sys.modules.pop("debugpy", None)
|
||||
assert dbg.configure_debugging() is False
|
||||
assert "debugpy" not in sys.modules
|
||||
|
||||
|
||||
def test_invalid_value_treated_as_disabled(monkeypatch) -> None:
|
||||
monkeypatch.setenv("DEBUGPY", "yes-please")
|
||||
assert dbg.configure_debugging() is False
|
||||
|
||||
|
||||
def test_enabled_starts_listener(monkeypatch, free_port) -> None:
|
||||
monkeypatch.setenv("DEBUGPY", "1")
|
||||
monkeypatch.setenv("DEBUGPY_PORT", str(free_port))
|
||||
try:
|
||||
assert dbg.configure_debugging() is True
|
||||
assert "debugpy" in sys.modules
|
||||
finally:
|
||||
dbg.shutdown_debugpy()
|
||||
|
||||
|
||||
def test_port_falls_back_on_invalid_value(monkeypatch) -> None:
|
||||
monkeypatch.setenv("DEBUGPY_PORT", "not-a-port")
|
||||
assert dbg._port() == 5678
|
||||
|
||||
|
||||
def test_shutdown_is_idempotent_when_disabled() -> None:
|
||||
dbg.shutdown_debugpy() # no listener → no-op, no error
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Unit tests: app factory edge cases (no live DB needed)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main_mod
|
||||
|
||||
|
||||
def test_create_app_warns_and_serves_api_only_without_static_dir(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
"""If the frontend directory is missing, the API still boots (PLAN §7)."""
|
||||
monkeypatch.setattr(
|
||||
main_mod.settings, "static_dir", str(tmp_path / "definitely-missing")
|
||||
)
|
||||
app2 = main_mod.create_app()
|
||||
client = TestClient(app2)
|
||||
# /api still works…
|
||||
assert client.get("/api/health").status_code == 200
|
||||
# …but the static mount is absent (no index page).
|
||||
assert client.get("/").status_code == 404
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Unit tests: SQLAlchemy models register the pgvector schema on the metadata.
|
||||
|
||||
Importing :mod:`app.models` is what Alembic's ``env.py`` and the runtime rely
|
||||
on; these tests lock the table/column contract (PLAN §5) without a live DB.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import UniqueConstraint
|
||||
|
||||
import app.models # noqa: F401 (import registers all tables on Base.metadata)
|
||||
from app.db import Base
|
||||
|
||||
|
||||
def test_all_tables_registered() -> None:
|
||||
tables = Base.metadata.tables
|
||||
assert "documents" in tables
|
||||
assert "chunks" in tables
|
||||
assert "query_log" in tables
|
||||
|
||||
|
||||
def test_chunks_embedding_is_vector_768() -> None:
|
||||
chunks = Base.metadata.tables["chunks"]
|
||||
col = chunks.c["embedding"]
|
||||
assert isinstance(col.type, Vector)
|
||||
assert col.type.dim == 768
|
||||
# Embeddings are two-phase: inserted first, embedded later.
|
||||
assert col.nullable is True
|
||||
|
||||
|
||||
def test_chunks_reference_documents_cascade() -> None:
|
||||
chunks = Base.metadata.tables["chunks"]
|
||||
fkc = list(chunks.foreign_key_constraints)[0]
|
||||
assert fkc.elements[0].column.table.name == "documents"
|
||||
assert fkc.ondelete == "CASCADE"
|
||||
|
||||
|
||||
def test_documents_unique_source_path() -> None:
|
||||
documents = Base.metadata.tables["documents"]
|
||||
uq = [
|
||||
c
|
||||
for c in documents.constraints
|
||||
if isinstance(c, UniqueConstraint)
|
||||
and {col.name for col in c.columns} == {"source", "path"}
|
||||
]
|
||||
assert uq, "documents must be unique on (source, path) — the upsert key"
|
||||
Reference in New Issue
Block a user