# 🧠 Brain of Reese A chippy, honest **RAG chatbot** over your `~/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?** Re-run the import β€” it's idempotent and only > re-embeds what changed: > ```bash > uv run python -m scripts.import_docs --prune > ``` > See [Updating the documents](#updating-the-documents) for details. --- ## Quick Start ```bash # 1. Dependencies uv sync # 2. Configure (copy and edit) cp .env.example .env # Set BOR_LLM_API_KEY β€” your aipi key (falls back to $AIPI_KEY) # 3. Start the database podman compose up -d db podman compose ps # wait until "healthy" # 4. Apply migrations & import uv run alembic upgrade head uv run python -m scripts.import_docs # 5. Run the app uv run uvicorn app.main:app --reload # β†’ http://localhost:8000 (chat) ``` **Stack:** FastAPI Β· Pydantic v2 Β· SQLAlchemy 2 Β· Alembic Β· pgvector Β· vanilla HTML/CSS/JS (no CDN) Β· Playwright E2E --- ## Pages at a glance The app is a single-page application β€” all views load from `/` with a client-side router. Direct URLs deep-link to the matching view. | Page | URL | Who can see it | |------|-----|----------------| | **Chat** | `/` | Everyone (token gate for non-shared) | | **RAG / Knowledge base** | `/sources.html` | Admin only | | **Git sources** | `/git-sources.html` | Admin only | | **Tuning** | `/tuning.html` | Admin only | | **Saved chats** | `/history.html` | Admin only | | **Access tokens** | `/tokens.html` | Admin only | | **Document viewer** | `/document.html?source=X&path=Y` | Token users & admins | | **Login** | `/login.html` | Everyone | | **Shared chat** | `/shared/` | Everyone (anonymous) | | **Edit doc** | `/doc-edit.html` | Admin only | ### Chat (`/`) Ask questions; answers stream in with **source chips** that cite the exact documents used. Clicking a chip opens the document in an **almost-fullscreen modal** on the same page (no new tab). The onboarding suggestion chips follow the last 3 questions asked β€” on a fresh deployment they seed from `BOR_SUGGESTIONS`. ### RAG / Knowledge base (`/sources.html`) The indexed document list. The *Path* column opens each document in the same **almost-fullscreen modal**. Admin-only β€” anonymous visitors see a sign-in gate instead. ### Git sources (`/git-sources.html`) The admin-managed source registry: add or remove git repositories, upload source archives (`.tar`, `.tar.gz`, `.tgz`, `.zip`), and register local directories β€” all in one table with a `kind` discriminator. No `.env` editing, no restart. The **Sync sources** button clones/pulls/walks every source and re-imports in one click. ### Tuning (`/tuning.html`) Set global instructions that are injected into the system prompt of every chat turn. Add notes like *"be more concise"* or *"assume I'm on NixOS"*. List, edit, or remove them here β€” no conversation required. ### Saved chats (`/history.html`) Every conversation is saved automatically. Click a title to return to that chat. Admin-only view. ### Access tokens (`/tokens.html`) Generate tokens and hand them out β€” a token opens chat, the answers, and the documents they cite. Shared chats stay open to everyone. ### Shared chat (`/shared/`) A read-only snapshot of any conversation, shareable via a link. Open to everyone β€” no login or token required. ### Edit doc (`/doc-edit.html`) Review and adjust an AI-generated answer before committing it to the docs repository. Admin-only flow page. --- ## Admin & Sign-in Brain of Reese has exactly **one account: the admin (you)**. Signing in unlocks the **full Sources catalog**, the **answer-tuning** controls, and **token management**. **Shared chats** are the only content that stays open to anonymous visitors. ### Setup (one-time) ```bash python -c 'import secrets;print(secrets.token_hex(32))' # β†’ paste into .env ``` ```env BOR_ADMIN_PASSWORD=your-password # plaintext β€” homelab scope, by design BOR_SESSION_SECRET= # signs the session cookie ``` **Fail-loud:** while either variable is empty the app refuses to start, naming the missing one(s). ### How authentication works | Endpoint | Purpose | |----------|---------| | `POST /api/login` | Password β†’ signed `bor_session` cookie | | `POST /api/logout` | Clears session cookie | | `GET /api/whoami` | `{"authenticated": bool, "role": "admin"|"user"|"anonymous"}` | | `POST /api/token-auth` | Token (`bor_…`) β†’ same cookie, role `user` | The cookie uses `same_site="lax"`, `https_only` off β€” **no HTTPS enforcement** (homelab HTTP; the cookie is single-admin convenience, not a cloud boundary). Max age is `BOR_SESSION_MAX_AGE` (default `43200` = 12 h). ### Access tokens The admin can hand out access without sharing the admin password. 1. **Generate:** sign in β†’ **Tokens** in the navbar. Type a label and hit **Generate** β€” a `bor_` + 32-hex token appears in the *shown once* block. Copy it now: only its SHA-256 hash is stored. 2. **Use:** the holder enters the token at the in-app gate. The browser caches it in `localStorage["bor.token"]` for silent re-auth on reloads. 3. **Revoke:** click **Revoke** on the token's row. Revocation is immediate. A token user can chat and open documents β€” nothing else. The Sources catalog, git sources, tuning, and history stay admin-only. --- ## Using the App ### Updating the documents 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: ```bash # After editing/adding/removing notes: 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 ``` With **git-based sources** each run first pulls the latest commits of your repos, so this same command is the whole update loop: *commit β†’ re-run*. Then check the **Sources** page (`http://localhost:8000/sources.html`): the *documents* / *chunks* counters and *last indexed* timestamp should reflect the new files. - The import prints one line per file and ends with a greppable summary, so it is safe to run from cron or after every commit. - Indexed formats: **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). - Hidden files and common cache dirs (`.venv`, `node_modules`, `.git`, `__pycache__`, etc.) are skipped. - Non-markdown files get format-aware chunking (YAML keys, JSON keys, Python classes/defs). ### Git-based sources Rather than pointing the import at local folders, point it at **git repositories**. The admin **Git sources page** (`/git-sources.html`) is the primary management surface β€” add or remove repositories there and the list is stored in Postgres. ```env # .env β€” the fallback list (fresh setups, or until the admin page # stores a source; the page becomes the source of truth) 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 // ``` - Auth is whatever the machine supplies β€” HTTPS via the OS credential helper, or SSH via your key; no credentials are stored in the app. - Every run **clones** (first time, shallow `--depth 1`) or **pulls** (`git pull --ff-only`) each repo, then indexes the checkout. - A failed sync aborts the run β€” no partial junk. ### Archive upload sources Upload a `.tar`, `.tar.gz`, `.tgz`, or `.zip` archive to make it a source. The form on the Git sources page β€” and the `POST /api/git-sources/upload` route behind it β€” accepts archives and unpacks + scans them immediately. - Re-uploading the same filename replaces the source **in place** (no second folder, no duplicate row). - Zip-bomb guard: absolute member paths, `..` traversal, and symlinks escaping the unpack folder are rejected. - Archives unpack under `BOR_UPLOAD_DIR` (default `~/bor-sources/uploads`). - `BOR_UPLOAD_MAX_MB` (default 512) caps both compressed and extracted size. ### Local directory sources A plain directory can be a first-class source too. It shares the git sources' **one table** (the `git_sources` registry with a `kind` discriminator: `git` | `local`), **one admin page**, and **one Sync button**. - Register via API: `POST /api/git-sources` with `{"kind": "local", "path": …}`. - Sync walks it directly β€” no clone, no checkout copy. - The directory is re-verified to exist at sync time; a missing directory fails the run loudly. ### Sync from the UI The **Sync sources** button on the **Sources** page (admin only) runs the whole git-source refresh in one click: 1. **clone/pull + walk** every configured source β€” git repos and local directories, including uploaded archives. 2. **re-import with prune** β€” files deleted upstream leave the index. 3. **regenerate the KB overview** (the `` outline every chat turn injects) β€” only when the import changed the knowledge base. - States: the button shows **Syncing…** with a spinner while polling `GET /api/sync/status` every 2 s. No client-side timeout β€” a clone + embed can legitimately take minutes. - One sync at a time: a second trigger while a run is in flight gets a `409`. --- ## 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. To hide it, set `BOR_STREAM_THINKING=0`. --- ## 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: | Tool | Description | |------|-------------| | **`ls`** | Lists every indexed document (`source: X \| path: Y \| title: Z`); pass a source name as `path` to list one source | | **`read(path)`** | Appends the **full** text of one indexed document to context (never truncated); `path` is the combined `source/path` string | | **`grep(pattern, path?)`** | Searches indexed documents for an exact string (case-insensitive fixed substring); returns up to 20 `source/path:line: text` matches; an optional `path` limits to one document | Each call is executed against Postgres only (no extra LLM round trip) and streamed as an SSE `tool` frame. In the chat, each call shows a transient **calling-tool status** alongside "thinking" (the send button keeps its busy state β€” "Stop" β€” for the whole turn). The model may call tools as many times as needed, bounded by a round cap: | Env | Default | Meaning | |-----|---------|---------| | `BOR_AGENT_MAX_ROUNDS` | `10` | hard cap on agent tool rounds per grounded turn (0 = no tools, the kill switch) | --- ## Tuning your answers *Admin-only* β€” sign in first. 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 `` section (numbered, oldest first, capped at `BOR_STEERING_MAX_CHARS` chars β€” default 8000, overflow marked `[…truncated…]`). List or remove notes from the **"Tuning"** button in the chat header (count badge, newest-first, per-note delete). The API is stateless JSON if you prefer curl: ```bash curl -s localhost:8000/api/steering # list 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/ # remove ``` --- ## 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 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 what the LLM sees. --- ## 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 (`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: /` β€” appended by the app, never model-generated. * The model only sees the first `BOR_SUMMARY_MAX_CHARS` (default 12000) characters; overflow is cut and marked with `[…truncated…]`. Summary generation is best-effort: if `lite` fails, the document is still indexed (without a summary), the failure is logged, and counted in the import summary line (`summaries=N summary_errors=N`). --- ## Caching & deploys A deploy is a commit β€” and the browser must see it **without a hard refresh**. One Starlette middleware (`app/core/caching.py`) applies the rule at the transport layer: - **HTML pages** ship `Cache-Control: no-cache`, **no `etag`**, **no `last-modified`** β€” each visit always gets a fresh 200 body. - **Assets are versioned and cached for a year.** Pages reference their CSS/JS with a token (`/assets/styles.css?v=`), and every `/assets/*` response ships `Cache-Control: public, max-age=31536000, immutable`. - **The token is the deploy.** In a git checkout it is the short SHA of `HEAD` β€” so every commit/deploy flips the token and the versioned asset URLs change with it. A checkout without `.git` falls back to a stable content hash of the `frontend/` tree. No CDN, no new services, no build-step change: the middleware rewrites the asset references of the known pages in flight. > **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. --- ## Configuration reference | Env | Default | Meaning | |-----|---------|---------| | `BOR_APP_NAME` | `Brain of Reese` | Display name everywhere (page titles, header brand, status labels) | | `BOR_INPUT_PLACEHOLDER` | `Ask me anything…` | Chat composer placeholder | | `BOR_FOOTER_TEXT` | `Powered by self-hosted models` | Footer line on every page | | `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 completions: document summaries at import, KB overview | | `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 (0 = no tools) | | `BOR_IMPORT_EXTENSIONS` | csv (see below) | 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 is empty** | | `BOR_SOURCES_DIR` | `~/bor-sources` | Where git repos are cloned/pulled | | `BOR_UPLOAD_DIR` | `~/bor-sources/uploads` | Where uploaded source archives are unpacked | | `BOR_UPLOAD_MAX_MB` | `512` | Cap (MiB) for uploaded source archives (zip-bomb guard) | | `BOR_STEERING_MAX_CHARS` | `8000` | Char budget for the `` prompt section | | `BOR_SUMMARY_MAX_CHARS` | `12000` | Cap on document content sent to the `lite` summary model | | `BOR_KB_OVERVIEW_MAX_CHARS` | `4000` | Char budget for the `` prompt section | | `BOR_OVERVIEW_INPUT_MAX_CHARS` | `40000` | Cap on the document list sent to `lite` for KB overview generation | | `BOR_SUGGESTIONS` | built-in list | JSON seed for onboarding chips (shown before the first saved question) | | `BOR_ADMIN_PASSWORD` | *(required)* | The single admin's password β€” app refuses to start when empty | | `BOR_SESSION_SECRET` | *(required)* | Signing key for the `bor_session` cookie | | `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 Every identity string is an env var: `BOR_APP_NAME` (display name), `BOR_INPUT_PLACEHOLDER` (chat composer placeholder), and `BOR_FOOTER_TEXT` (footer line) β€” all served by `GET /api/config` and applied by `assets/brand.js` at boot. The colors are set from the admin **Theme tab** (`/theme.html`, admin-only β€” phase 91): the 8 identity colors plus the three strings above are edited with text fields and color pickers, persisted in the `ui_settings` table, and injected into every served page as an inline `