ducoterra bf64c0d7e4 docs(agent): record the lite comparison on the controlled fixture battery
2026-09-06 fixture runs: contract 92–93 %, executed 64–75 %, wall ~40.5 s (2 runs). Derived battery: FAIL, 36 % executed (38.3 s). Same pattern — copy-invariant re-read habit blocks the ≥90 % executed bar under current ALREADY_IN_CONTEXT refusal semantics. Model is working correctly; the bottleneck is the app's dedupe refusal, not the model.
2026-09-06 21:42:14 -04:00
2026-08-27 10:00:21 -04:00

🧠 Brain of Reese

A chippy, honest RAG chatbot over the ~/Homelab and ~/Deployments projects. Point it at your notes — markdown, YAML, JSON, Python, plain text — ask it anything, and it retrieves the relevant chunks with hybrid search (pgvector cosine ∪ Postgres full-text search, fused with Reciprocal Rank Fusion), 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.

Updated your notes? The knowledge base is refreshed by re-running the import — it's idempotent and only re-embeds what changed:

uv run python -m scripts.import_docs --prune

Details in Updating the documents.

  • 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 .agents/PLAN.md; per-story specs in .agents/user_stories/.

Development Setup

Prerequisites

  • uv
  • Podman (with the podman compose provider)
  • Node.js is not needed locally (asset minification happens in the container build only)

1. Install dependencies

uv sync

2. Configure

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)

podman compose up -d db
podman compose ps          # wait until "healthy"

4. Apply migrations

uv run alembic upgrade head

5. Import your knowledge base

uv run python -m scripts.llm_probe      # sanity: models + 768-dim check
uv run python -m scripts.import_docs    # import the configured sources (below)

Managed source kinds (one page, one registry) plus a manual override:

  • Git sources — managed on the admin Git sources page (/git-sources.html) and stored in Postgres (see Git-based sources). import_docs clones each repo (first run) or pulls it (subsequent runs) into BOR_SOURCES_DIR/<repo-name>/ (default ~/bor-sources) and indexes the checkouts. While the stored list is empty, the BOR_GIT_SOURCES variable in .env is the fallback — the moment the page stores a source, the variable is ignored.
  • Archive upload sources (phase 49) — a .tar/.tar.gz/.tgz/.zip uploaded on the same admin page (see Archive upload sources). The archive is unpacked under BOR_UPLOAD_DIR/<name>/ and scanned immediately; it is registered as a kind=local row, like a local directory.
  • Local directory sources (phase 38) — an existing, non-git directory on the server, registered on the same admin page (see Local directory sources). No clone, no checkout copy: the directory is walked in place. There is no env var for local paths — the DB is the registry. Since phase 49 the page's “Add a local directory” form is gone (the archive upload replaced it): a plain directory is registered via POST /api/git-sources with kind=local; existing Local rows are unchanged.
  • Manual directories — --source <path> (repeatable) imports local directories directly and always wins over the stored sources (git and local) and the env fallback.
  • If neither is set (stored list, --source, and BOR_GIT_SOURCES all empty), import_docs falls back to the previous default, ~/Homelab + ~/Deployments — kept only for backwards compatibility, now replaced by the managed sources; the UI Sync button instead fails loudly ("no sources configured (git or local)").

6. Run the app

uv run uvicorn app.main:app --reload
# → http://localhost:8000  (chat)   http://localhost:8000/sources.html (KB)

📝 After this, day-to-day is just: edit markdown → re-run the import. See Updating the documents below.

Using the UI

  • Chat (/) — ask questions; answers stream in with source chips that cite the exact documents used. Clicking a chip opens that document in an almost-fullscreen modal on the same page (no new tab).
  • Document viewer — the modal above is the viewer; the full text of any indexed document is served from the database (no filesystem access): markdown is rendered, every other format (yaml, json, py, txt, …) is shown as escaped monospace text. /document.html?source=…&path=… stays as the full-page / direct-link form (the modal's “Full page” button and the URL to share — it works without JS). Unknown documents get a designed not-found state with a link back to the index.
  • Sources (/sources.html) — the indexed document list; the Path column opens each document in the same almost-fullscreen modal (no new tab). Admin-only — anonymous visitors see a sign-in gate instead (the catalog is what the login locks; the document viewer itself stays open to everyone).
  • Git sources (/git-sources.html) — the admin-managed source registry: the git repositories the Sync sources button clones and indexes, uploaded archives it unpacks and scans, and existing local directories it imports directly (one table with a kind discriminator, one page); admin-only (the same sign-in gate as Sources). Add or remove sources here — no .env editing, no restart. The archive upload form (phase 49) accepts .tar, .tar.gz, .tgz, .zip: the archive is unpacked under BOR_UPLOAD_DIR/<name>/ (name = filename minus the archive suffix) and scanned immediately — re-uploading the same filename replaces that source in place (one folder, one row, dropped files pruned; see Archive upload sources). A local directory must be an absolute, existing directory at add-time (a missing/relative path is rejected, naming the path; so are duplicates) — since phase 49 this is an API-only operation (POST /api/git-sources with kind=local; the page's form was replaced by the upload form). List rows carry a Git or Local badge. Adding does not clone: the Sync button performs that (git + local together, one run, prune over the union) and still prunes upstream file churn — a file deleted in a repo or dropped from a local directory leaves the index on that run. Removing a source is a total removal, done immediately — a confirmation modal states it first, then the source's entry, all of its indexed documents, and — for git clones and uploaded archives — its files on disk (the checkout under BOR_SOURCES_DIR or the unpacked folder under BOR_UPLOAD_DIR) are gone in one action; files in the owner's own local directories are never touched.

Thinking

The self-hosted turbo model reasons before it answers. That reasoning is streamed with the turn as thinking SSE events and shown in a collapsible "Thinking" block above the answer bubble: it opens and fills in live while the model thinks, tucks itself away the moment the first answer token lands, and stays click-toggleable afterwards. Thinking persists with the message, so a reloaded conversation restores the block (collapsed) alongside the answer. How much the model thinks — or whether it thinks at all — is the model's call: turns without reasoning render exactly as before.

To hide it, set BOR_STREAM_THINKING=0 — the thinking events stop (the per-turn log line still counts thinking_chars).

Agent document tools (ls + read + grep)

Retrieval only puts the top documents in context. When an answer depends on a file a note references ("the exact JSON shape is in example-record-file.json"), the model can extend its own context with three server-side tools — on grounded (high-relevance) turns only. The surface mirrors the shape the chat model was trained on (the pi.dev harness tools, owner decision 2026-09-03), and the canonical document identity everywhere is the combined source/path string:

  • ls — lists every indexed document, one source: X | path: Y | title: Z line each (the same order as the Sources page); pass a source name as path to list one source's documents;
  • read(path) — appends the full text of one more indexed document to the context (never truncated); path is the combined source/path string exactly as shown in the ls output;
  • grep(pattern, path?) — searches the indexed documents for an exact string (case-insensitive fixed substring) and returns up to 20 matching source/path:line: text lines; an optional path limits the search to one document. A locator, not a context-adder: it never adds to the answer context — the model reads the winner.

Each call the model requests is executed against Postgres only (no extra LLM round trip) and streamed as an SSE tool frame ahead of the answer — {"type": "tool", "name": "ls" | "read" | "grep", "argument": … | null} (argument is the single string the model passed — read's path, grep's pattern, ls's scope — or null). In the chat, each call shows a transient calling-tool status in addition to "thinking" (the send button keeps its busy state — "Stop" — for the whole turn) and a visible tool line (🔎 Listing documents / 📄 Reading source/path / 🔎 Searching for pattern) lands above the answer, one per call, in order. The tool lines persist with the message, so a reloaded conversation re-renders them. The read document is reflected in the answer's source chips and in the query_log row.

The tools stay offered for the whole turn — the model may call them as many times as it needs (re-lists included), bounded only by a round cap that stops a pathological infinite loop:

Env Default Meaning
BOR_AGENT_MAX_ROUNDS 10 hard cap on agent tool rounds per grounded turn — every call the model emits consumes a round; at the cap the loop forces one final no-tools answer

BOR_AGENT_MAX_ROUNDS=0 reproduces the pre-agent chat behavior exactly (no tools in the request, no tool frames) — the kill switch. Deflected turns run no tools at all — the low-relevance path is unchanged.

Admin & sign-in

Brain of Reese has exactly one account: the admin (you). Signing in unlocks the full Sources catalog and the answer-tuning controls; everyone else stays anonymous and keeps chat and the document viewer (any document an answer cites can be opened by its direct URL — the catalog is gated, not the viewer).

Setup (one-time)

python -c 'import secrets;print(secrets.token_hex(32))'   # → paste into .env
BOR_ADMIN_PASSWORD=your-password        # plaintext — homelab scope, by design
BOR_SESSION_SECRET=<the hex from above> # signs the session cookie

Fail-loud: while either variable is empty the app refuses to start, naming the missing one(s):

RuntimeError: Brain of Reese cannot start: admin auth is not configured.
Set the missing variable(s): BOR_ADMIN_PASSWORD, BOR_SESSION_SECRET …

How it works

  • POST /api/login {"password": …} → 204 + signed bor_session cookie (Starlette SessionMiddleware — an itsdangerous-signed cookie, no server-side store, no new service, no DB table); any mismatch → 401 {"detail": "invalid password"} (constant-time compare, one generic message — no user enumeration, there is only one user).
  • POST /api/logout → 204 (session cleared and cookie expired; idempotent for anonymous callers).
  • GET /api/whoami → {"authenticated": bool, "role": "admin"|"anonymous"} — the single source of truth for every UI gating decision.
  • Cookie flags: same_site="lax", https_only off — no HTTPS enforcement on purpose (homelab HTTP; the cookie is single-admin convenience, not a cloud boundary). Max age BOR_SESSION_MAX_AGE (default 43200 = 12 h, refreshed while active).
  • Sign in from the chat header (Sign in) or /login.html directly; the header then offers Sign out (logout + reload).

Who can do what

Capability Anonymous Admin (signed in)
Chat (/) + suggestion chips yes yes
Document viewer (/document.html?source=…&path=…) yes — any indexed doc by direct URL yes
Sources catalog (/sources.html, GET /api/docs) sign-in gate full catalog
Tuning (Tune button, Tuning panel, /api/steering) UI hidden full

The public API endpoints stay stateless — the signed cookie is the only session state in the system.

Tuning your answers

Admin-only — sign in first (see Admin & sign-in above); anonymous visitors never see the Tune button or the Tuning panel.

If an answer isn't quite right — too chatty, wrong assumption, missing context — tune Brain right there:

  1. Press “Tune” in the meta row under any completed answer (deflected ones included).
  2. Type a short instruction (1–2000 chars), e.g. “be more concise” or “assume I'm on NixOS”, and Save.

The note is stored in Postgres (steering_notes) and read into the system prompt of every subsequent chat turn as a <tuning> section (numbered, oldest first, capped at BOR_STEERING_MAX_CHARS chars — default 8000, overflow marked […truncated…]). With no stored notes the prompt is byte-identical to the un-tuned one, so tuning is opt-in per note.

List or remove notes at any time from the “Tuning” button in the chat header (count badge, newest-first, per-note delete). The API is stateless JSON if you prefer curl:

curl -s localhost:8000/api/steering                                  # list (newest first)
curl -s -X POST localhost:8000/api/steering \
     -H 'Content-Type: application/json' -d '{"note": "be more concise"}'
curl -s -X DELETE localhost:8000/api/steering/<note-id>              # remove

Updating the documents

This is the workflow you'll use most. The knowledge base is refreshed by re-running the import. It is idempotent and delta-based (sha256 per file), so a refresh after a normal editing session takes seconds:

# After editing/adding/removing notes 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/out-of-scope files

# Point it at extra directories (repeatable):
uv run python -m scripts.import_docs --source ~/SomeOtherDocs

With git-based sources (below) each run first pulls the latest commits of your repos, so this same command is the whole update loop: commit in the repo → re-run the import.

Then check the Sources page (http://localhost:8000/sources.html): the documents / chunks counters and last indexed timestamp should reflect the new files, and each document row shows when it was last embedded.

  • The import prints one line per file (import: added|updated|unchanged| pruned …) and ends with a greppable summary (import: summary files=… added=… updated=… unchanged=… pruned=… chunks=… embed_batches=… formats=md:203,yaml:267,…), so it is safe to run from a cron job or after every commit.
  • Indexed formats (A9, revised 2026-08-27): md, markdown, txt, yaml, yml, json, py, the Podman quadlet family (container, network, volume, image, pod, kube, swap, os, endpoint), and j2 Jinja templates (case-insensitive; narrow with BOR_IMPORT_EXTENSIONS). Any path with a dot-prefixed component — hidden files or vendored caches like .esphome/.espressif/** — is skipped, along with .venv, node_modules, .git, __pycache__, .pytest_cache, dist, build. --prune also drops documents whose files no longer match the filter — that's how previously imported junk leaves the index.
  • Non-markdown files get format-aware chunking (YAML top-level keys / --- docs, JSON top-level keys, Python top-level defs/classes via stdlib ast; quadlet unit files and j2 templates are paragraph- packed as plain text) and their title comes from the file stem.
  • Unchanged files are not re-embedded — only new/changed ones, so refreshes are cheap.
  • After a run that changed the knowledge base (at least one document added or updated), the import also regenerates the stored KB overview — a plain-text outline of the KB's basic categories that every chat turn injects into the system prompt as <knowledge_base> (phase 31). The regeneration is best-effort and change-gated: unchanged re-imports and --limit debug runs skip it (no lite call), and a lite-model failure leaves the previous outline intact without failing the import. The summary line ends overview=updated|skipped|failed.
  • To sanity-check the LLM backend (models + embedding dimension) after any aipi change: uv run python -m scripts.llm_probe.

Git-based sources

Rather than pointing the import at local folders, point it at git repositories — the notes live in the repos and import_docs keeps local checkouts of them up to date for you.

Where the list lives (phase 35): the primary management surface is the admin Git sources page (/git-sources.html) — add or remove repositories there and the list is stored in Postgres (the git_sources table). BOR_GIT_SOURCES in .env is the empty-table fallback: it only applies while the stored list is empty, and is ignored once the page has any row (the page becomes the source of truth — no .env editing, no restart needed afterwards).

# .env — the fallback list (fresh setups, or until the admin page
# stores a source; phase 35 demotes this variable, it does not remove it)
BOR_GIT_SOURCES=https://git.reeseapps.com/reese/homelab.git,git@github.com:reese/deployments.git
BOR_SOURCES_DIR=~/bor-sources   # default; each repo lands in <dir>/<repo-name>/
  • The effective list (stored rows, else BOR_GIT_SOURCES while the stored list is empty) is a set of git repo URLs. Auth is whatever the machine supplies — https://… via the OS credential helper, or git@host:repo.git via your SSH key; no credentials are stored in the app or .env. Stored URLs are shape-validated on the page (https://, ssh://, git@… — scp-style host:repo is rejected).
  • Every run clones each repo (first time, shallow --depth 1) or pulls it (git pull --ff-only — fast-forward only, so a diverged or broken checkout fails loudly instead of merging) into BOR_SOURCES_DIR/<repo-name>/, then indexes the checkouts exactly like any local directory (A9 format filter, hidden-dir skip, sha256 delta). documents.source is the repo directory name (e.g. homelab).
  • --source <path> overrides: when the flag is given, the git sources (stored list and BOR_GIT_SOURCES) are ignored and the manual directory(ies) are imported.
  • A failed sync aborts the run: if any repo cannot be cloned/pulled, import_docs exits non-zero naming the failing repo and imports nothing (no partial junk). Fix the URL/connectivity and re-run — the other checkouts stay on disk and are pulled as usual.

Local directory sources

Not every set of notes lives in a git repo — a plain directory can be a first-class source too (phase 38). It shares the git sources' one table (the git_sources registry with a kind discriminator: git | local, migration 0007), one admin page, and one Sync button:

  • Register it via the API (phase 49) — the phase-38 “Add a local directory” form on the Git sources page was replaced by the archive upload form; adding a plain directory is now an API-only operation: POST /api/git-sources with {"kind": "local", "path": …}. Add-time validation fails loud: the path is trimmed, ~ is expanded, and must be an absolute, existing directory on the server — anything else (missing, relative, a file) is 422 with the path named; a duplicate path is 409 the same way. Existing Local rows are unchanged: they still list, remove, and sync exactly as before. There is no env var for local paths — the DB is the local-source registry (BOR_GIT_SOURCES stays a git-only fallback).
  • Sync walks it directly — no clone, no checkout copy: each run indexes the directory in place (A9 format filter, hidden-dir skip, sha256 delta), together with the git checkouts in the same run. documents.source is the directory's name. The directory is re-verified to exist at sync time (it may have moved or been deleted since add-time): a missing directory fails the run loudly, naming the path, and imports nothing (the same pre-import fail-loud as a failing git clone).
  • Pruning is over the union — git checkouts and local directories are imported together with prune=True, so a file removed from a local directory or a repo leaves the index on that run (upstream file churn). Removing the source on the page is a total removal, done immediately (a confirmation modal states it first): its entry and its indexed documents leave at once — but the directory itself is the owner's own and is never touched on disk (only git checkouts and uploaded archives get their files deleted).
  • import_docs (no --source) resolves the stored git and local rows — git cloned/pulled as above, local walked directly — in one run; --source still wins over everything; while the table is empty, BOR_GIT_SOURCES is the git-only fallback; no git rows, no local rows, and no env URLs fails loudly ("no sources configured (git or local)").

Archive upload sources

Upload a .tar, .tar.gz, .tgz, or .zip archive to make it a source (phase 49, owner permission 2026-08-28). The form on the admin Git sources page — and the POST /api/git-sources/upload route behind it — replaced the phase-38 “Add a local directory” form. An uploaded source is registered as a kind=local row, so everything local directory sources do (Sync, prune, remove) applies to it:

  • Accepted formats: .tar, .tar.gz, .tgz, .zip — anything else is 422 naming the accepted set. Unpacking is guarded: absolute member paths, .. traversal, symlink/hardlink targets escaping the unpack folder, and device/FIFO members are rejected (422), and the total extracted bytes count against the size cap (zip-bomb guard). A zero-entry archive is 422; an archive with only non-A9 files is a valid replacement (it indexes nothing and prunes the source's previous documents).
  • Naming rule: the source name is the filename minus the archive suffix (homelab.tar.gz → homelab, case-sensitive). The name is both the folder under BOR_UPLOAD_DIR and the row's identity; files land in the KB exactly as packed (no auto-unwrap of a single top-level folder).
  • In-place replace: re-uploading the same filename creates no second folder and no second row — the new content is unpacked to a temp sibling and atomically renamed over the existing folder (no missing window; a failed upload never touches the existing folder, row, or KB), the row is upserted by path (kind='local', added_at preserved), and the source is re-scanned with prune=True — files dropped from the archive leave the index in the same request.
  • The scan is synchronous in the request: it fails fast on the models (503 when they are down — the folder/row are already committed, so the next sync or re-upload retries idempotently), then runs the single-source import (embeddings + per-document summaries) and the change-gated KB overview refresh, and answers 200 with the sync-style counts (added, updated, unchanged, pruned, …) the page renders as its result line. One upload at a time — a concurrent upload gets 409.
  • Where + how big: archives unpack under BOR_UPLOAD_DIR (default ~/bor-sources/uploads — deliberately separate from the git checkouts in BOR_SOURCES_DIR); BOR_UPLOAD_MAX_MB (default 512) caps both the compressed upload and the total extracted bytes.

Sync from the UI

The Sync sources button on the Sources page — visible to the admin only (anonymous visitors never see it) — runs the whole git-source refresh in one click, in-process:

  1. clone/pull + walk every configured source — the git sources (the admin-managed git_sources table; BOR_GIT_SOURCES only while that list is empty) through the same clone_or_pull the CLI uses (shallow clone on first run, git pull --ff-only afterwards), and the local directories registered on the same page — including uploaded archives (their BOR_UPLOAD_DIR/<name>/ folders are kind=local rows, Archive upload sources) — walked directly (re-verified to exist at sync time — a missing directory fails the run loudly, naming the path);
  2. re-import with prune — the --prune equivalent, so files deleted upstream leave the index (the button is the canonical "mirror the repos" action); the sha256 delta still skips unchanged files, so an unchanged re-sync re-embeds nothing;
  3. regenerate the KB overview (the <knowledge_base> outline every chat turn injects) — but only when the import actually changed the knowledge base.
  • Prerequisites: at least one source must be configured — a git or local row on the admin Git sources page, or BOR_GIT_SOURCES in .env while the stored list is empty (git-only); all empty fails the sync loudly ("no sources configured (git or local)"), because the button targets the admin-managed registry (manual --source directories have no place in it) — and git must be on the app's PATH for git sources.
  • States: clicking starts the run (202) and the button goes disabled with Syncing… (spinning icon) while the page polls GET /api/sync/status every 2 s. There is deliberately no client-side timeout — a clone + embed can legitimately take minutes, so the poll is the feedback loop and the server state is authoritative. On success the button settles to Synced HH:MM with the last result in a live region (1 added, 0 added · 1 unchanged, …); on failure it re-enables (retry-ready) and a red error banner names the failure (git's stderr, with any embedded credentials masked).
  • One sync at a time: a second trigger while a run is in flight gets 409 ("a sync is already running"); the UI adopts the in-flight run instead of starting a second one, and a page reload mid-sync re-attaches to it the same way.
  • Idempotent: re-syncing unchanged repos is a no-op — fast-forward pull, hash skip, and the overview is left alone (its regeneration is change-gated).

Caching / deploys

A deploy is a commit — and the browser must see it without a hard refresh (the "the pages are too sticky" problem, phase 33). One Starlette middleware (app/core/caching.py) applies the rule at the transport layer:

  • HTML pages always revalidate — and never 304. Every page (/, /index.html, /sources.html, /document.html, /login.html, /tuning.html, /git-sources.html, /history.html, /shared.html, plus the dynamic share page /shared/<token>) ships Cache-Control: no-cache, no etag, no last-modified, so each visit re-checks the page with the server and always gets a fresh 200 body — a page never lingers in the browser's cache unchecked, and a revalidation can never be answered "not modified" (see Cache busting below for why).
  • Assets are versioned and cached for a year. The pages reference their CSS/JS with a token (/assets/styles.css?v=<token>), and every /assets/* response ships Cache-Control: public, max-age=31536000, immutable. The token is what identifies the content, so long-term caching is safe: a new token means a new URL, which the browser fetches fresh. A conditional GET on a versioned asset URL may still be answered 304 — the URL already encodes the version, so that is safe.
  • The token is the deploy. In a git checkout (the normal case) it is the short SHA of HEAD (git rev-parse --short HEAD), computed once per process start — so every commit/deploy flips the token and the versioned asset URLs change with it. A checkout without .git (or a git failure) falls back to a stable content hash of the frontend/ tree (sorted path + mtime + size), so dev checkouts still bust; a missing static dir gets the placeholder token dev.
  • The API is untouched. Nothing under /api/* — the SSE chat stream in particular — gains or loses a header or has its body read; the SSE endpoint's own Cache-Control: no-cache is set by the endpoint itself.

No CDN, no new services, no build-step change: the middleware rewrites the asset references of the known pages in flight. The unversioned asset paths keep working too (the static mount ignores the query string), so old tabs and direct links to /assets/… still resolve.

Deploy note: the very first deploy onto this scheme needs one normal page visit, so the browser revalidates the HTML once and starts requesting the versioned assets; every commit after that is picked up automatically.

Cache busting: why pages never 304 (phase 54)

The ?v=<git short SHA> token flips on the next process start — a commit is a deploy, so the restarted server's HTML references new asset URLs, and the browser fetches them fresh into its year-long asset cache.

  • /assets/* is cached immutable for a year under the versioned URL — a conditional GET may 304, because the URL already encodes the version.
  • HTML pages are served no-cache and never 304, publishing no etag / last-modified. The reason: the page body the browser receives is rewritten per process (its asset refs gain ?v=<token>), so the static file's upstream validators would describe the file, not the bytes served — a conditional GET matching them would 304 out of the rewrite and leave the browser on HTML pointing at the previous commit's CSS/JS, which the year-long asset cache serves until a hard reload (the hole measured in phase 54). Pages therefore revalidate against the bytes actually served: always a full 200.

Local development: a git commit changes the token on the next server restart. If a browser still shows an old layout after a restart, hard-reload once (Ctrl/Cmd-Shift-R) — the phase-54 fix guarantees the next navigation is current, but it cannot un-pin a document that a pre-54 deploy already 304'd into the browser's cache.

Checking retrieval quality

Ask the real pipeline (live aipi embeddings + the current KB) whether a question lands on the right document, with the gate verdict and per-document cosine / FTS / fused scores:

uv run python -m scripts.eval_retrieval "How did I install gitlab?"
uv run python -m scripts.eval_retrieval --from-file questions.txt --top 8

Requires AIPI_KEY in the environment (same convention as scripts/llm_probe.py) and an imported knowledge base.

How retrieval works (hybrid)

Every question is embedded and also lexically tokenized (OR-joined, English stemming) and searched twice against Postgres:

  1. Vector — pgvector cosine top-N (default BOR_HYBRID_VECTOR_CANDIDATES=100)
  2. Lexical — a stored tsvector (GIN-indexed) matched with to_tsquery, top-N by ts_rank (default BOR_HYBRID_LEXICAL_CANDIDATES=30)

The two ranked lists are fused with Reciprocal Rank Fusion (score = Σ 1/(k + rank), BOR_RRF_K=60) — a chunk in both lists scores nearly double, which is what lets a name-your-tool question ("gitlab") find its own document even when the question embeds close to generic templates.

The honesty gate (A8) then answers (HIGH) when the best cosine is ≥ BOR_RELEVANCE_THRESHOLD (default 0.62) or at least one chunk matched lexically (fts_hits > 0) — it deflects (LOW) only when both signals are absent. The top BOR_TOP_N_DOCS full documents are still what the LLM sees.

query_log records every turn (top_score = best cosine, fts_hits, chunk_hits, deflected, sources, latency_ms) — the raw material for tuning: psql … -c 'SELECT question, top_score, fts_hits, deflected FROM query_log ORDER BY created_at DESC LIMIT 20'.

Document summaries (non-markdown)

Raw yaml/json/py/txt embeds badly — flags and keys are not language, so retrieval can miss exactly the documents that are all configuration. At import time, every non-markdown A9 document is summarized by the aipi lite model (one-shot LLMClient.chat, BOR_LLM_SUMMARY_MODEL, default lite):

  • the summary is stored on documents.summary and indexed as one extra embedded chunk (chunks.is_summary, position −1), so hybrid search has a natural-language target to hit instead of the raw text;
  • the last line is a code-deterministic pointer — Source: <source>/<path> — appended by the app, never model-generated;
  • the model only sees the first BOR_SUMMARY_MAX_CHARS (default 12000) characters of the document; overflow is cut and marked with the shared […truncated…] marker.

A summary hit resolves through the normal chunk→document mapping: the LLM receives the full source document (never the summary alone, never truncated). Markdown files are natural language already and get no summary. Summary generation is best-effort: if lite fails for a file, the document is still indexed (without a summary), the failure is logged and counted in the import summary line (summaries=N summary_errors=N). On a chat turn, the per-turn log line records summary_hits=N — how many summary chunks of the selected context the question landed on.

Debugging

debugpy is off by default and never imported unless you opt in — zero overhead in normal runs.

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):

{
  "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):

# 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 .agents/user_stories/ (see .agents/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:

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):

podman build -t brain-of-reese/app:latest .

Run standalone (bring your own Postgres + pgvector):

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):

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_APP_NAME Brain of Reese the display name everywhere (phase 39): every page <title>, the header brand, the chat status labels ("… is thinking"), the empty-state greeting, and the aria/placeholder text. Served to the frontend by GET /api/config and applied by assets/brand.js; a name starting Brain of keeps the bold split (Brain of <strong>rest</strong>), any other name renders in normal weight. Unset ⇒ byte-identical to the default
BOR_INPUT_PLACEHOLDER Ask me anything… the chat composer placeholder (#message-input, chat page only); applied by assets/brand.js from GET /api/config; unset ⇒ the template default
BOR_FOOTER_TEXT Powered by self-hosted models the footer line on all 9 pages (the .footer-text spans); same mechanism; unset ⇒ the template default
BOR_THEME (empty) a filename under frontend/assets/themes/ (e.g. indigo.css) — a :root palette override injected after styles.css (later wins the cascade); the server refuses a malformed name at startup (bare ^[a-z0-9_-]+\.css$ filename); a missing file degrades to the built-in theme; unset ⇒ the built-in dark-tech palette
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_LLM_SUMMARY_MODEL lite one-shot (non-streaming) completions: document summaries at import (phase 30) and the KB overview (phase 31)
BOR_EMBEDDING_DIM 768 vector dimension (fixed at table creation)
BOR_TOP_N_DOCS 2 full documents fed to the LLM
BOR_RELEVANCE_THRESHOLD 0.62 answer when best cosine ≥ this or an FTS hit; below + no FTS ⇒ honest deflection
BOR_HYBRID_VECTOR_CANDIDATES 100 cosine list width for the RRF fusion
BOR_HYBRID_LEXICAL_CANDIDATES 30 FTS list width for the RRF fusion
BOR_RRF_K 60 RRF damping constant (1/(k + rank))
BOR_AGENT_MAX_ROUNDS 10 hard cap on agent tool rounds per grounded turn — every call the model emits consumes a round; at the cap the loop forces one final no-tools answer (0 = no tools, the kill switch)
BOR_IMPORT_EXTENSIONS md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2 csv of importable formats (may only narrow the A9 set)
BOR_GIT_SOURCES — (empty) csv of git repo URLs — fallback while the admin Git sources page's list (Postgres git_sources) is empty; the page is the primary management surface (see Git-based sources). Git-only: local directory sources have no env var — they are registered on the admin page (see Local directory sources)
BOR_SOURCES_DIR ~/bor-sources where the git source repos are cloned/pulled (one subdirectory per repo)
BOR_UPLOAD_DIR ~/bor-sources/uploads where uploaded source archives are unpacked — one subdirectory per source name (filename minus the archive suffix); separate from the git checkouts (see Archive upload sources)
BOR_UPLOAD_MAX_MB 512 cap (MiB) for uploaded source archives — bounds both the compressed upload and the total extracted bytes (zip-bomb guard); must be > 0
BOR_STEERING_MAX_CHARS 8000 char budget for the <tuning> (steering notes) prompt section
BOR_SUMMARY_MAX_CHARS 12000 cap on document content sent to the lite summary model at import (see Document summaries)
BOR_KB_OVERVIEW_MAX_CHARS 4000 char budget for the <knowledge_base> (KB overview) prompt section
BOR_OVERVIEW_INPUT_MAX_CHARS 40000 cap on the document list sent to the lite model when generating the KB overview
BOR_SUGGESTIONS built-in list JSON list of onboarding chips
BOR_ADMIN_PASSWORD (required) the single admin's password (plaintext, .env); app refuses to start when empty
BOR_SESSION_SECRET (required) signing key for the bor_session cookie; python -c 'import secrets;print(secrets.token_hex(32))'
BOR_SESSION_MAX_AGE 43200 session-cookie lifetime in seconds (12 h, sliding)
DEBUGPY 0 1 ⇒ attach-on-demand debugpy on DEBUGPY_PORT (default 5678)
BOR_LOG_LEVEL INFO app log level

Customizing the look

The app ships as “Brain of Reese”, but every identity string is an env var: BOR_APP_NAME (display name), BOR_INPUT_PLACEHOLDER (chat composer placeholder), and BOR_FOOTER_TEXT (the footer line on every page) — all served by GET /api/config and applied by assets/brand.js at boot. Color themes are plain CSS variable overrides: write a :root block in frontend/assets/themes/ and point BOR_THEME at the filename (the themes README.md is the authoring guide, indigo.css the working example). The server refuses a malformed BOR_THEME at startup, and a missing theme file degrades to the built-in palette — the page never breaks. Leave everything unset and the app renders the defaults byte-identically: the dark-tech palette shown throughout this README is the no-config default.

Troubleshooting

  • 401 from aipi — set BOR_LLM_API_KEY (or $AIPI_KEY).
  • litellm.UnsupportedParamsError … encoding_format from aipi — the aipi proxy (litellm openai_like) rejects the encoding_format parameter that the openai SDK injects into every embeddings request. The app already works around this by POSTing a minimal {model, input} payload through the openai client's own httpx transport (app/rag/llm.py → LLMClient._embed_batch). If you see this, you are likely calling the endpoint with a different client — drop the parameter (or set litellm.drop_params = True on the proxy).
  • 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).
  • Honest deflection (the amber “I haven't done anything like that” bubble) — every question passes the honesty gate: deflection happens only when the best cosine similarity is below BOR_RELEVANCE_THRESHOLD (default 0.62) and no chunk matched the question lexically (fts_hits = 0). A weak cosine with a lexical hit (name-your-tool questions) still gets a grounded answer. When it does deflect, the LLM prompt carries weak-hit titles only (no document content), the reply opens with “I haven't done anything like that”, the bubble renders amber with “Maybe try” chips derived from the closest indexed titles, the SSE done event carries deflected: true + suggestions[], and the query_log row records deflected=true + the weak top_score + fts_hits. This is a feature, not a bug — the KB simply has no notes that close; the chips always point at topics Brain really covers.
  • Answers deflect too often / too rarely — tune BOR_RELEVANCE_THRESHOLD (lower = answers more, higher = more honest deflection): 0.0 ⇒ the gate leans entirely on FTS hits; 1.0 ⇒ everything deflects unless a chunk matches lexically. The embed model's cosines cluster in a ~0.6–0.85 band on the live KB, so the default is 0.62; after changing it, check the real scores: psql … -c 'SELECT question, top_score, fts_hits, 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.
S
Description
No description provided
Readme
16 MiB
Languages
Python 87.9%
JavaScript 7.6%
CSS 2.6%
HTML 1.8%