# Brain of Reese — Master Plan > **Status:** Phase 1–3 complete (scaffolded, designed, decomposed). > **Rule:** Every agent reads this file first. Decisions marked `LOCKED` in the > Anchors table are settled — do not re-litigate them in a phase. > **Revisions (2026-08-21, owner permission):** A7/A8/A9 revised (multi-format > ingestion, hybrid FTS+vector retrieval, re-tuned honesty gate); dark tech > theme (Phase 08); clickable document viewer (Phase 10); thinking display > (Phase 17, owner permission 2026-08-23); follow-the-bottom scroll > (Phase 18, owner choice 2026-08-23); shared header (Phase 19); > whole-document context (Phase 24 — A7's 24k context cap removed, > owner permission 2026-08-24). See roadmap §12. --- ## 1. Mission A **knowledge base chatbot** that embeds the `~/Homelab` and `~/Deployments` projects into a Postgres vector database and lets anyone ask *Reese* (the bot) questions about them. **Product feel:** a chippy, upbeat assistant that is optimistic about the user's ability ("you've got this") and **radically honest** — if retrieval didn't surface anything relevant it says *"I haven't done anything like that"* and offers alternatives instead of hallucinating. ### In scope (v1) - Chat UI (mobile-friendly, well-styled, no auth, no CDN). - RAG over text knowledge files — `md, markdown, txt, yaml, yml, json, py` by default (A9, revised 2026-08-21) — from `~/Homelab` + `~/Deployments` (and any future directory the importer is pointed at). - Self-hosted models via `https://aipi.reeseapps.com/v1` — `turbo` (chat), `embed` (embeddings, **768 dims — verified**). - Postgres 17 + pgvector, cosine similarity, chunk→document mapping so the LLM receives the **entire relevant document** as context. - Idempotent import/update script, documented in the README. - Ample server logging + explicit UI loading/progress feedback (never a stale submit button). ### Out of scope (v1) - Auth / multi-user (API is stateless under `/api` so it can be added later). - Binary / non-text content, file uploads, caching layer, message persistence. - Real-time document watching (manual re-import for now). --- ## 2. Architectural Anchors (LOCKED DECISIONS) | # | Component | Decision | Rationale | Status | |---|-----------|----------|-----------|--------| | A1 | Runtime | Python 3.12+, `uv` for all package management | Fast, reproducible envs; one language for API + tooling | LOCKED | | A2 | Web framework | FastAPI + Pydantic v2 + Uvicorn | Async, typed, SSE-friendly for LLM streaming, free OpenAPI docs | LOCKED | | A3 | Database | **PostgreSQL 17** (`docker.io/postgres:17`, pgvector compiled in via `db/Containerfile`) with **cosine** (`<=>`) search | One system for relational + vectors; pgvector is mature; official base image kept per project standard | LOCKED | | A4 | Orchestration | `compose.yaml`, started with **`podman compose up -d`** | Matches Reese's toolchain | LOCKED | | A5 | LLM backend | OpenAI-compatible `https://aipi.reeseapps.com/v1`; models **`turbo`** (chat) & **`embed`** (embeddings); `openai` async client | Self-hosted, offline from cloud; no new model management | LOCKED | | A6 | Embedding dim | **768** (verified 2026-08-21 against live endpoint via `scripts/llm_probe.py`); configured by `BOR_EMBEDDING_DIM` | User recalled 768 — probe confirmed; dimension is fixed at table creation, so mismatch must fail loudly at import time | LOCKED | | A7 | Retrieval→context | **Hybrid:** cosine top-30 + Postgres FTS top-30 (OR tsquery, `ts_rank`) fused with **RRF (k=60)** → map to parent documents ranked by best fused chunk score → feed the **full text of top-N=2 documents** (deduped) to the LLM | Owner permission 2026-08-21: pure-cosine top-4 missed real docs (gitlab case — best chunk ranked 7th behind vendored-cache junk; score compression 0.41–0.84); the lexical signal finds name-your-tool questions; whole-document context contract preserved. A7 revised 2026-08-24 — matched documents never truncated (owner: "this should never happen"; emergency-valve variant rejected) | LOCKED (revised 2026-08-24) | | A8 | Honesty gate | **Deflection mode** (LLM must open with a variant of *"I haven't done anything like that"* and offer 2–3 alternative questions) when best cosine < `BOR_RELEVANCE_THRESHOLD` **and** no candidate chunk FTS-matches the question; threshold re-tuned for the `embed` model's compressed score range (default **0.62**, calibrated via `scripts/eval_retrieval.py`; the E2E mock uses its own 0.30 calibration via the app fixture) | Owner permission 2026-08-21: at 0.30 the gate never discriminated (measured corpus range 0.41–0.84); the FTS-OR keeps name-your-tool questions honest-positive; deflection product behavior unchanged | LOCKED (revised 2026-08-21) | | A9 | Content scope | Text formats **`md, markdown, txt, yaml, yml, json, py`** (default, `BOR_IMPORT_EXTENSIONS`), **hidden (dot) directories skipped by default**, plus the exclusion list (`node_modules`, `__pycache__`, `.pytest_cache`, `dist`, `build`, …) | Owner permission 2026-08-21: real notes live in yaml/py/json/txt too; the dot-dir skip removes the ~470 vendored-cache junk docs (`.esphome/.espressif/**`, …) that outranked real content | LOCKED (revised 2026-08-21) | | A10 | Auth | **None in v1**; all endpoints stateless under `/api` | Per user (auth later); statelessness keeps the future migration cheap | LOCKED | | A11 | Frontend | Vanilla HTML/CSS/JS in git; **no CDN** — everything served by FastAPI `StaticFiles`; minified by esbuild in the `Containerfile` build stage; system font stack | No external deps at runtime; tiny, auditable surface; mobile-friendly by construction | LOCKED | | A12 | Aux services | **None in v1** (no Valkey, no SeaweedFS) | No sessions/auth (no store), no uploads (no object storage); add later only if a need appears | LOCKED | | A13 | Migrations | Alembic + SQLAlchemy 2.0 (sync) + psycopg 3 | Standard, reversible, reviewable schema history | LOCKED | | A14 | Debugging | `debugpy` **only when `DEBUGPY=1`** (env var read directly, not via settings); listen `0.0.0.0:5678` (override `DEBUGPY_PORT`), non-blocking, attach-on-demand; **not imported at all when off** | Zero overhead by default per project standard; attach-on-demand keeps production runs clean | LOCKED | | A15 | Chat transport | **SSE streaming** from `POST /api/chat` (deltas + final `done` event with metadata) | Local LLM latency is 10–30s; live token stream + explicit completion event power the UI's feedback states | LOCKED | | A16 | Testing | Per phase: unit + integration (pytest, **coverage >90%** on `app/`) + **one dedicated Playwright E2E file per user story**, run in isolation; E2E uses a deterministic mock LLM by default (`E2E_REAL_LLM=1` opts into live aipi) | One story, one phase, one E2E gate — the pipeline's core invariant | LOCKED | | A17 | Git | Conventional Commits, **always `--no-gpg-sign`**, repo-local `commit.gpgsign=false`; one atomic commit per completed phase | Subsequent agents may lack the GPG key | LOCKED | > **A10 revision (phase 16, owner permission 2026-08-22):** single-admin > signed-cookie auth — public: chat / documents / suggestions / health; > admin-only: docs catalog + steering. The row above keeps the original v1 > decision text; the public API surface stays stateless (the signed > session cookie is the only session state) — recorded as a revision, > not a silent deviation. > > **A10 UI revision (phase 19, owner permission 2026-08-23):** the > "Sources" nav link is hidden from anonymous users on all pages — the > soft-gate page and the API split above are unchanged. > > **A7 revision (phase 24, owner permission 2026-08-24):** the > `[…truncated…]` cap on document context is removed — > `select_documents` always returns the full top-N texts; > `BOR_MAX_CONTEXT_CHARS` is gone. The steering section > (`BOR_STEERING_MAX_CHARS`, phase 15) keeps its budget and the shared > marker. --- ## 3. High-Level Architecture ``` ┌────────────────────────────────────────────┐ │ Podman Compose │ Browser │ ┌──────────────────────────────────────┐ │ ┌──────────┐ HTTP │ │ brain-of-reese/app (FastAPI) │ │ │ index.html│◄──────┼─►│ • static frontend (no CDN) │ │ │ app.js │ SSE │ │ • /api/chat /api/suggestions │ │ └──────────┘ │ │ • /api/health /api/docs │ │ │ │ • RAG pipeline (embed→retrieve→gen) │ │ │ └──────┬──────────────────┬───────────┘ │ │ │ SQL (psycopg) │ OpenAI-compat│ │ ┌──────▼──────┐ ┌───────▼────────────┐ │ │ │ db: │ └─────────┬──────────┘ │ │ │ postgres:17 │ │ │ │ │ + pgvector │ │ │ │ └─────────────┘ │ │ └──────────────────────────────┼────────────┘ ▼ https://aipi.reeseapps.com/v1 (self-hosted: turbo, embed) Offline tooling (same repo, same venv): scripts/import_docs.py → walks A9-format dirs, chunks, embeds, upserts scripts/eval_retrieval.py → ranks hybrid results for a question (tuning) scripts/llm_probe.py → verifies models + embedding dim ``` ### Component breakdown | Component | Responsibility | Lives in | |-----------|----------------|----------| | **App (FastAPI)** | Serves frontend + `/api`; RAG pipeline; logging | `app/` | | **RAG pipeline** | `embed` → pgvector cosine top-K → doc mapping → context assembly → `turbo` (streamed) with persona/honesty prompt | `app/rag/` (added in story phases) | | **Importer** | Directory walk (A9 formats, hidden dirs skipped, exclusions), sha256 delta detection, format-aware chunking, batched embedding, upsert/prune | `scripts/import_docs.py` (story phase) | | **DB** | `documents`, `chunks`, `query_log` + `vector` extension | `db/` image, `alembic/` | | **Frontend** | Chat shell, sources view, loading/feedback states | `frontend/` | ### Chat data flow ``` user question → POST /api/chat {message} → embed(question) [aipi /v1/embeddings, model=embed] → cosine top-30 + FTS top-30 (OR tsquery, ts_rank) [pgvector + PG FTS] → RRF fuse (k=60) → docs ranked by best fused chunk score ├─ best cosine >= 0.62 OR fts_hits > 0 → top-2 documents' FULL content │ → system prompt (persona + HONESTY rules + docs) │ → turbo, stream=True → SSE deltas └─ else → DEFLECT_MODE system prompt (weak hits as topics) → turbo, stream=True → SSE deltas (honest reply) → query_log row (question, score, deflected, sources, latency) → final SSE "done" event: {deflected, sources[], suggestions[]} ``` --- ## 4. API Design All endpoints stateless (A10). Errors: standard JSON `{detail: str}`. | Method | Path | Purpose | Story | |--------|------|---------|-------| | GET | `/api/health` | Liveness + db up/down + version | 01 | | GET | `/api/suggestions` | Onboarding suggestion strings | 01 (05 refines) | | GET | `/api/docs` | Indexed document list (source, path, title, chunks, indexed_at) | 02 | | GET | `/api/documents/content?source=…&path=…` | One indexed document's full content (feeds the viewer page) | 10 | | POST | `/api/chat` | RAG chat turn → **SSE stream** | 03/04 | ### SSE contract (`POST /api/chat`) ``` data: {"type":"thinking","text":"…"}\n\n data: {"type":"thinking","text":"…"}\n\n data: {"type":"delta","text":"Hey! "}\n\n data: {"type":"delta","text":"Good "}\n\n ... data: {"type":"done","deflected":false,"sources":[{"source":"Homelab","path":"kubernetes.md","title":"Kubernetes Homelab Cluster"}],"suggestions":[]}\n\n ``` Client rules: render deltas as they arrive; render `thinking` text in a collapsible block above the answer; auto-collapse on the first `delta`; tolerate interleaved `thinking` events (append — never reopen once the answer started); the `done` shape is unchanged (thinking never travels on `done`); on `done` append source chips / suggestion chips and clear the busy state; on HTTP/stream error show the error banner + retry (never a stuck button). > **SSE revision (phase 17, owner permission 2026-08-23):** the contract > gains one event type — `{"type":"thinking","text":"…"}` — carrying the > model's reasoning ahead of the `delta` events (the `turbo` model emits > `delta.reasoning_content` chunks before the first content chunk, verified > live 2026-08-23; `BOR_STREAM_THINKING=0` suppresses the frames > server-side). `delta` and `done` shapes are unchanged — a recorded > extension of A15, not a silent deviation. --- ## 5. Data Model (PostgreSQL 17) Created by `alembic/versions/0001_initial_schema.py` (idempotent `CREATE EXTENSION IF NOT EXISTS vector`). ### `documents` | Column | Type | Notes | |--------|------|-------| | id | `UUID` PK | | | source | `VARCHAR(120)` | source dir basename, e.g. `Homelab` | | path | `VARCHAR(1000)` | relative to source dir, e.g. `ansible/roles/k3s.md` | | full_path | `VARCHAR(2000)` | absolute path at import time (diagnostics) | | title | `VARCHAR(500)` | first markdown H1, else file stem | | content | `TEXT` | **full markdown — the RAG context** | | content_hash | `VARCHAR(64)` | sha256 of content — change detection | | indexed_at | `TIMESTAMPTZ` | | | — | `UNIQUE (source, path)` | upsert key | ### `chunks` | Column | Type | Notes | |--------|------|-------| | id | `UUID` PK | | | document_id | `UUID` FK→documents CASCADE | **embedding→document mapping** | | position | `INT` | 0-based order within the doc | | content | `TEXT` | chunk text (heading-aware) | | embedding | `VECTOR(768)` | nullable until embedded (two-phase import) | | tsv | `TSVECTOR` | **generated** `to_tsvector('english', content) STORED` + GIN index (hybrid retrieval, A7) | > No vector index in v1: sequential scan is fine at this corpus size > (~100–500 docs). Revisit with an HNSW index if retrieval latency grows. ### `query_log` `id UUID PK, question TEXT, top_score FLOAT, fts_hits INT, chunk_hits INT, deflected BOOL, sources TEXT, latency_ms INT, created_at TIMESTAMPTZ` ### Document state transitions ``` unseen ──import──▶ indexed ──hash changed + re-import──▶ reindexed │ └──file deleted + --prune──▶ removed (chunks cascade) ``` ### Chunking policy (markdown-aware) Split on `## `/`### ` headings into sections; sub-split any section longer than `BOR_CHUNK_TARGET_CHARS` (2000) at paragraph boundaries with `BOR_CHUNK_OVERLAP_CHARS` (200) overlap; each chunk keeps its nearest preceding heading in the text for retrieval quality. **Format-aware (A9, revised):** `yaml`/`yml` split on top-level keys and `---` separators (key line kept as anchor); `json` pretty-printed, split on top-level keys; `py` split on top-level defs/classes (stdlib `ast`); `txt` on paragraphs; markdown unchanged. Every format honors the 1200-char hard cap (aipi ~1024-token request limit). --- ## 6. RAG Pipeline & Persona ### Locked system prompt (sent with every chat turn) ``` You are "Brain of Reese" — the digital brain of Reese, a self-hoster and homelab tinkerer. Personality: chippy, upbeat, warm, and genuinely optimistic about the user's ability to do things ("you've got this"). Rules: 1. Answer ONLY from the provided document context. Cite which document(s) you used, by path. 2. Be concrete: names, versions, ports, hosts, schedules — the specifics in the docs are the value. 3. HONESTY GATE: if 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. {HIGH|LOW} ``` - `HIGH` mode appends the full document text under `…`. - `LOW` mode (deflection) appends only the **titles** of the weak hits so the model can suggest real alternatives (marker used by the E2E mock: `DEFLECT_MODE` appears in the system prompt). ### Retrieval (hybrid — A7/A8, revised 2026-08-21) - Embed the question (`embed`, 768-d) → cosine top-30 candidates. - Lexical: OR tsquery over the question's tokens → FTS top-30 by `ts_rank`. - **Reciprocal Rank Fusion** (`Σ 1/(k+rank)`, k=60) → distinct parent docs ranked by best chunk's fused score → top 2 → full content, concatenated — **never truncated** (A7 revised, phase 24, owner permission 2026-08-24). - Honesty gate: LOW only when `best cosine < BOR_RELEVANCE_THRESHOLD` (default 0.62, calibrated against the `embed` model's measured 0.41–0.84 distribution) **and** zero FTS hits among the candidates. --- ## 7. UI/UX Strategy ### 7.1 Layout structure - **App frame:** sticky header (64px) + `
` (flex-grow) + footer. Container: `max-width: 72rem; margin-inline: auto; padding-inline: 1.25rem`. - **Shared header (Phase 19, owner permission 2026-08-23):** the bar is a shared contract across chat / sources / viewer — one bar per page, same controls (brand + nav [Chat, Sources — admin only] + New Chat + Sign in / Sign out on chat & sources; the viewer bar = back + title + the same actions in `.doc-header-actions`). One shared module (`frontend/assets/header.js`) toggles the existing controls on each page, so they can never "disappear" between pages again; the heights stay pinned at 64px / 58px (phase 12). - **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: `
`, `