Standardize on the .agents/ directory (shared with project skills): phases/, user_stories/, reports/, screenshots/, validate.sh, and phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves history; runtime artifacts move alongside). Updates every reference in AGENTS.md, README.md, .gitignore, app docstrings, and test story headers. Historical KB content in data/ and the runtime pipeline.log transcript are left untouched.
844 lines
44 KiB
Markdown
844 lines
44 KiB
Markdown
# 🧠 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:
|
||
> ```bash
|
||
> uv run python -m scripts.import_docs --prune
|
||
> ```
|
||
> Details in [Updating the documents](#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`](.agents/PLAN.md); per-story specs in
|
||
[`.agents/user_stories/`](.agents/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 # 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](#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](#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](#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
|
||
```bash
|
||
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](#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](#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 `read`s 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)
|
||
|
||
```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=<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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
# 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
|
||
# .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:
|
||
|
||
```bash
|
||
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.
|
||
|
||
```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 .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:
|
||
|
||
```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_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`](frontend/assets/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.
|