feat(rag): hybrid FTS+vector retrieval and multi-format ingestion — name-your-tool questions find the right document
This commit is contained in:
@@ -0,0 +1,164 @@
|
|||||||
|
# Phase 09 — Story: Retrieval Quality — Multi-Format Ingestion + Hybrid Search
|
||||||
|
|
||||||
|
**Story:** `.agent/user_stories/retrieval-quality.md`
|
||||||
|
**Context:** `.agent/PLAN.md` §3 (data flow), §5 (data model), §6 (retrieval), §11 (import)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Fix "RAG retrieval is terrible": ingest the full text-format set (not
|
||||||
|
just `.md`), purge vendored-cache junk from the index, and replace
|
||||||
|
pure-cosine top-4 with hybrid (vector + Postgres FTS, RRF-fused)
|
||||||
|
retrieval so name-your-tool questions find the right document.
|
||||||
|
|
||||||
|
## Owner permission (recorded per phase protocol)
|
||||||
|
> "I'm giving you explicit permission to update the locked decisions and
|
||||||
|
> proceed with writing all 3 of these phases" — Reese, 2026-08-21.
|
||||||
|
|
||||||
|
This phase revises anchors **A9** (content scope: `*.md` only →
|
||||||
|
`md, markdown, txt, yaml, yml, json, py` + hidden-dir skip), **A7**
|
||||||
|
(pure-cosine top-4 → hybrid RRF retrieval; the whole-document context
|
||||||
|
contract is preserved), **A8** (gate: LOW only when best cosine <
|
||||||
|
threshold **and** zero FTS hits; threshold re-tuned 0.30 → 0.62 default).
|
||||||
|
`PLAN.md` anchors were updated 2026-08-21 under this permission.
|
||||||
|
|
||||||
|
## Evidence (measured 2026-08-21 against the live KB + `embed` model)
|
||||||
|
- "How did I install gitlab?": the best `gitlab.md` chunk ranks **7th**
|
||||||
|
(cosine 0.804) — outside the top-4 window. Ranks 1–6: a vendored-cache
|
||||||
|
README (`.esphome/.espressif/…/esp-tflite-micro/README.md`, 0.838) and
|
||||||
|
generic templates (`project_readme_template.md`, `templates/…/foobar.
|
||||||
|
md`, 0.81–0.82). The LLM therefore answered from junk docs and honestly
|
||||||
|
reported "no notes on gitlab".
|
||||||
|
- Corpus cosine range: **0.41–0.84** — the old 0.30 gate never
|
||||||
|
discriminated.
|
||||||
|
- FTS: `plainto_tsquery('english','gitlab')` matches **exactly**
|
||||||
|
gitlab.md's 4 chunks and nothing else.
|
||||||
|
- ~470 of 672 indexed docs live under dot-prefixed path components
|
||||||
|
(vendored caches) that A9's exclusion list doesn't cover.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
Phases 01–07 (02 importer, 03 retriever, 04 gate especially).
|
||||||
|
Independent of 08 (backend + E2E only). Phase 10 builds on the new
|
||||||
|
multi-format corpus.
|
||||||
|
|
||||||
|
## Implementation steps
|
||||||
|
1. **Config** (`app/config.py`): `import_extensions` (csv, default
|
||||||
|
`md,markdown,txt,yaml,yml,json,py`; env `BOR_IMPORT_EXTENSIONS`),
|
||||||
|
`hybrid_vector_candidates` (30, `BOR_HYBRID_VECTOR_CANDIDATES`),
|
||||||
|
`hybrid_lexical_candidates` (30, `BOR_HYBRID_LEXICAL_CANDIDATES`),
|
||||||
|
`rrf_k` (60, `BOR_RRF_K`), `relevance_threshold` default **0.62**
|
||||||
|
(re-tuned; env override stays). In `tests/e2e/conftest.py`'s
|
||||||
|
`app_server` fixture set `BOR_RELEVANCE_THRESHOLD=0.30` — the mock's
|
||||||
|
token-overlap embeddings need their own calibration; this keeps
|
||||||
|
stories 02–07's E2E suites green.
|
||||||
|
2. **Chunker** (`app/rag/chunker.py`): add a `chunk_document(content,
|
||||||
|
path)` dispatcher by lowercased suffix + per-format functions —
|
||||||
|
**stdlib only, no new dependencies**:
|
||||||
|
- `yaml`/`yml`: split on `---` document separators and top-level keys
|
||||||
|
(indent-0 `key:` lines); every chunk keeps its key line as anchor.
|
||||||
|
- `json`: `json.dumps(obj, indent=2)` then split at top-level keys
|
||||||
|
(track brace depth); unparseable JSON → paragraph packing.
|
||||||
|
- `py`: stdlib `ast` top-level node line ranges → split at
|
||||||
|
defs/classes; oversized functions fall back to line packing.
|
||||||
|
- `txt`: paragraph packing (reuse `_paragraph_blocks`/`_pack_blocks`).
|
||||||
|
- All formats honor `HARD_MAX_CHARS` (1200 — the aipi ~1024-token
|
||||||
|
request cap) and the target/overlap settings; the `md` path stays
|
||||||
|
byte-for-byte unchanged (existing chunker tests must stay green).
|
||||||
|
3. **Importer** (`app/rag/importer.py`, `scripts/import_docs.py`):
|
||||||
|
extension filter (case-insensitive, config-driven); **skip any path
|
||||||
|
containing a dot-prefixed component** (hidden dirs); chunker dispatch
|
||||||
|
by suffix; `--prune` now also drops docs whose files **no longer
|
||||||
|
match the filter** (this is how the ~470 junk docs leave the index);
|
||||||
|
summary log gains per-format counts
|
||||||
|
(`formats=md:203,yaml:267,…`).
|
||||||
|
4. **Migration `0002_hybrid_retrieval.py`** (alembic):
|
||||||
|
- `chunks.tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english',
|
||||||
|
content)) STORED` + `CREATE INDEX … USING gin (chunks.tsv)`.
|
||||||
|
- `query_log.fts_hits INT` (nullable; pre-existing rows stay NULL).
|
||||||
|
5. **Retriever** (`app/rag/retriever.py`) — hybrid path:
|
||||||
|
- `retrieve(db, question, question_embedding)`: vector top-N (cosine,
|
||||||
|
as today) ∪ lexical top-N — `to_tsquery('english', <OR-joined
|
||||||
|
stemmed tokens of the question>)` (skip pure-stopword/no-token
|
||||||
|
questions → empty lexical list), ordered by `ts_rank` — fused with
|
||||||
|
RRF: `score = Σ 1/(k + rank)` over the lists a chunk appears in
|
||||||
|
(single-list chunks get one term; k from config).
|
||||||
|
- `RetrievedChunk` gains `cosine` (for the gate) and `fts_hit: bool`
|
||||||
|
alongside `score` (now the fused score, used for ranking);
|
||||||
|
`select_documents` / `weak_hit_titles` keep working off `score`.
|
||||||
|
- Deterministic tie-break: `(−fused, −cosine, document.path,
|
||||||
|
chunk.position)`.
|
||||||
|
6. **Chat flow + gate** (`app/api/chat.py`): pass the raw question into
|
||||||
|
`retrieve`; **LOW only when `best_cosine < threshold and fts_hits ==
|
||||||
|
0`** (`fts_hits` = count of lexical candidates matched); per-turn log
|
||||||
|
line gains `fts_hits=…` (PLAN §9); `query_log` row stores `fts_hits`.
|
||||||
|
7. **Eval script** `scripts/eval_retrieval.py`:
|
||||||
|
`uv run python -m scripts.eval_retrieval "q1" "q2" …` (or
|
||||||
|
`--from-file questions.txt`) — embeds via aipi, runs the hybrid
|
||||||
|
search, prints top-5 docs per question with cosine/fts/fused scores +
|
||||||
|
the gate verdict. Requires `AIPI_KEY` in the environment (same
|
||||||
|
convention as `llm_probe.py`).
|
||||||
|
8. **Re-import the live KB** (one-time; expect ~15–40 min of embedding
|
||||||
|
batches — the importer logs per file):
|
||||||
|
`uv run python -m scripts.import_docs --prune`. Expect ~470
|
||||||
|
hidden-dir docs pruned and ~500 docs indexed (md + new formats). Then
|
||||||
|
verify with the eval script:
|
||||||
|
- "How did I install gitlab?" → top doc `active/container_gitlab/
|
||||||
|
gitlab.md` (the compose yaml should land in the top-2).
|
||||||
|
- "How is my Kubernetes cluster set up?" → kubernetes docs.
|
||||||
|
- "sourdough starter" → LOW (deflect).
|
||||||
|
If the gitlab case isn't #1, iterate the **fusion** (k, candidate
|
||||||
|
counts, token handling) — not the threshold — until it is, and record
|
||||||
|
the final numbers in the phase report.
|
||||||
|
9. **E2E fixtures** (`tests/fixtures/docs/`): add
|
||||||
|
`homelab/container_gitlab/gitlab.md` (H1 "Gitlab", docker install
|
||||||
|
steps, "gitlab" repeated), `homelab/container_gitlab/
|
||||||
|
gitlab-compose.yaml` (`services: gitlab: …`), a `.py` note, a `.json`
|
||||||
|
note, a `.txt` note, and `.hidden/junk.md` (must never be imported).
|
||||||
|
Follow the existing in-process seeding pattern from
|
||||||
|
`tests/e2e/test_import_documents.py`.
|
||||||
|
10. **README**: import workflow section — supported formats, hidden-dir
|
||||||
|
skip, `scripts/eval_retrieval.py`, threshold tuning; note that the
|
||||||
|
Sources count drops after the prune (intended cleanup).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- **Unit:** chunker per format (yaml top-level + `---` split, json
|
||||||
|
top-level keys + pretty-print + unparseable fallback, py ast split +
|
||||||
|
oversized-func fallback, txt paragraphs, dispatch, 1200-cap) with md
|
||||||
|
output unchanged; importer (hidden-dir skip, extension filter,
|
||||||
|
prune-when-filtered-out, per-format summary); retriever (RRF math:
|
||||||
|
both-lists / one-list / tie-break; OR tsquery construction incl.
|
||||||
|
no-token and stopword-only questions; gate: `cosine ≥ T` → HIGH;
|
||||||
|
`cosine < T` + `fts>0` → HIGH; `cosine < T` + `fts=0` → LOW; boundary
|
||||||
|
exactly `T` → HIGH).
|
||||||
|
- **Integration:** `/api/chat` hybrid against a seeded temp schema —
|
||||||
|
keyword question grounded + `fts_hits` in `query_log`; off-topic
|
||||||
|
deflected with `fts_hits=0`; migration up clean.
|
||||||
|
- **Coverage:** `uv run pytest --cov=app --cov-report=term-missing` —
|
||||||
|
**>90%** on `app/`.
|
||||||
|
- **No regressions:** existing story E2E suites (02–07) green in
|
||||||
|
isolation after the change (the conftest threshold override is what
|
||||||
|
keeps them green — verify each one).
|
||||||
|
|
||||||
|
## Playwright Execution Phase
|
||||||
|
Run ONLY this story's suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/e2e/test_retrieval_quality.py -v --no-cov
|
||||||
|
```
|
||||||
|
|
||||||
|
Implements the story mapping: multi-format fixture import (hidden doc
|
||||||
|
excluded, `/api/docs` counts); "How did I install gitlab?" → grounded,
|
||||||
|
not deflected, gitlab chip, `query_log` row; keyword-only question beats
|
||||||
|
vector ranking (FTS-OR gate end to end); "sourdough" → deflected bubble +
|
||||||
|
≥2 chips.
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
- [ ] live eval: "How did I install gitlab?" → `gitlab.md` is the top doc
|
||||||
|
- [ ] zero dot-prefixed path components in `documents` after re-import
|
||||||
|
- [ ] off-topic still deflects; on-topic still grounds (new + existing E2E)
|
||||||
|
- [ ] unit + integration green, coverage >90%, ruff + pyright green
|
||||||
|
- [ ] README documents formats / hidden-dir skip / eval / tuning
|
||||||
|
- [ ] committed
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
```bash
|
||||||
|
git add -A && git commit --no-gpg-sign -m "feat(rag): hybrid FTS+vector retrieval and multi-format ingestion — name-your-tool questions find the right document"
|
||||||
|
```
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
# Phase 08 — Story: Dark Tech Theme
|
|
||||||
|
|
||||||
**Story:** `.agent/user_stories/dark-tech-theme.md`
|
|
||||||
**Context:** `.agent/PLAN.md` §7 (UI/UX strategy), §10 (testing)
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Re-skin the whole UI to a dark, techy, emoji-free look with a subtly
|
|
||||||
animated pure-CSS background — zero behavior or layout changes, WCAG 2.1
|
|
||||||
AA re-proven on the new palette.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
Phases 01–07 (all complete). Independent of 09/10; phase 10's viewer page
|
|
||||||
inherits this theme, so 08 must land first.
|
|
||||||
|
|
||||||
## Locked decisions
|
|
||||||
No anchors changed. Replaces the §7.2 light contrast pairs with the dark
|
|
||||||
palette below (PLAN §7.2 already updated 2026-08-21 with owner
|
|
||||||
permission). No new technology — pure CSS/HTML/inline SVG (A11).
|
|
||||||
|
|
||||||
## Implementation steps
|
|
||||||
1. **Palette swap** — `frontend/assets/styles.css` `:root` tokens (all
|
|
||||||
pairs computed, ≥4.5:1):
|
|
||||||
|
|
||||||
| token | dark value | computed pair |
|
|
||||||
|---|---|---|
|
|
||||||
| `--bg` | `#0a0e17` | ink on bg 16.2:1 |
|
|
||||||
| `--surface` | `#121a2e` | ink on surface 14.5:1 |
|
|
||||||
| `--ink` | `#e8ebf4` | — |
|
|
||||||
| `--ink-soft` | `#9aa4bd` | ink-soft on surface 6.9:1 |
|
|
||||||
| `--line` | `#26304a` | decorative |
|
|
||||||
| `--brand` | `#6d78f2` | **dark ink `--bg` on brand 5.2:1** |
|
|
||||||
| `--brand-soft` | `#232b52` | brand-ink on brand-soft 6.9:1 |
|
|
||||||
| `--brand-ink` | `#a5b4fc` | brand-ink on surface 8.7:1 |
|
|
||||||
| `--accent-bg` | `#2b2110` | accent-ink on accent-bg 9.5:1 |
|
|
||||||
| `--accent-ink` | `#fbbf24` | — |
|
|
||||||
| `--accent-line` | `#f59e0b` | unchanged (8.9:1 on bg) |
|
|
||||||
| `--err-bg` / `--err-ink` | `#2d1318` / `#fca5a5` | 9.1:1 |
|
|
||||||
| `--err-line` | `#ef4444` | 4.6:1 on err-bg (UI boundary) |
|
|
||||||
| `--ok-bg` / `--ok-ink` | `#10241b` / `#6ee7a8` | 10.6:1 |
|
|
||||||
|
|
||||||
Button text is `--bg` (dark) on `--brand` — **never white on brand**
|
|
||||||
(3.7:1, fails). Busy button: keep the `#a5b4fc` background (the
|
|
||||||
`tests/unit/test_frontend_feedback.py` assertion greps this token)
|
|
||||||
with a **dark** arc (`--bg`, 9.7:1). Update derived light-mode values:
|
|
||||||
shadows (black-based, lower alpha), selection, typing dots, chip
|
|
||||||
hover.
|
|
||||||
2. **Emoji purge** — replace every emoji in chrome with inline SVG
|
|
||||||
(`aria-hidden` kept, ~16–20px, `currentColor` where sensible):
|
|
||||||
- `frontend/assets/app.js` (~L116, ~L132): avatars 🧠/🧑 → SVG
|
|
||||||
circuit-node glyph (brain) / minimal silhouette (user) as JS string
|
|
||||||
constants.
|
|
||||||
- `frontend/index.html`: favicon 🧠 data-URI → SVG tech mark (hex +
|
|
||||||
node, brand color on dark, <1 KB), still a `data:` URI;
|
|
||||||
`.brand-mark` 🧠 → same mark; ⚠️ banner icon → SVG triangle; 👋
|
|
||||||
empty state → SVG glyph.
|
|
||||||
- `frontend/sources.html`: favicon, `.brand-mark`, 📂 empty state →
|
|
||||||
SVG marks.
|
|
||||||
3. **Tech details** — mono wordmark with letter-spacing; stat values
|
|
||||||
mono; radii `10px`/`6px`; 1px `--line` borders on cards/bubbles/table;
|
|
||||||
2px gradient hairline (brand→cyan, low alpha) under the sticky header.
|
|
||||||
4. **Animated background (pure CSS, zero JS)** — working recipe:
|
|
||||||
`html { background: var(--bg) }`, `body { background: transparent;
|
|
||||||
position: relative }` (body must not create a stacking context):
|
|
||||||
- `body::before` — fine grid: two `linear-gradient`s (1px lines,
|
|
||||||
`--line` at ~35% alpha), `background-size: 44px 44px`, masked with a
|
|
||||||
radial fade (visible center-top, fading to the edges), animated
|
|
||||||
`background-position` `0 0 → 44px 44px`, 60s linear infinite
|
|
||||||
(seamless loop — the delta equals one cell).
|
|
||||||
- `body::after` — two large soft radial glows: indigo
|
|
||||||
`rgba(109,120,242,0.14)` top-left, cyan `rgba(34,211,238,0.10)`
|
|
||||||
bottom-right; 14s ease-in-out infinite alternate breathing
|
|
||||||
(opacity/scale). No `filter: blur` (perf).
|
|
||||||
- Both: `position: fixed; inset: 0; pointer-events: none; z-index:
|
|
||||||
-1`. Keep glow alpha low — subtle, never competing with text.
|
|
||||||
5. **Reduced motion** — `@media (prefers-reduced-motion: reduce)`:
|
|
||||||
`body::before, body::after { animation: none }` (static grid + glows
|
|
||||||
remain). Existing typing/spinner reduced-motion handling stays.
|
|
||||||
6. **Test updates (behavior unchanged):**
|
|
||||||
- `tests/e2e/test_honest_deflection.py` (~L104): deflection bubble
|
|
||||||
`backgroundColor` assertion `rgb(255, 247, 232)` → `rgb(43, 33,
|
|
||||||
16)`; the border assertion `rgb(245, 158, 11)` is unchanged.
|
|
||||||
- New integration test
|
|
||||||
`tests/integration/test_api.py::test_ui_chrome_has_no_emoji`: GET
|
|
||||||
`/`, `/sources.html`, `/assets/app.js`, `/assets/styles.css` — assert
|
|
||||||
no characters in the emoji code-point set (U+1F300–U+1FAFF,
|
|
||||||
U+2600–U+27BF, U+2B00–U+2BFF, U+FE0F, U+200D, plus the specific
|
|
||||||
glyphs previously used: 🧠 🧑 👋 📂 ⚠️).
|
|
||||||
7. **PLAN.md §7.2** — dark contrast table already applied (2026-08-21,
|
|
||||||
owner permission); no further plan edits in this phase.
|
|
||||||
|
|
||||||
## UI Verification
|
|
||||||
Manual screenshot pass (1280px + 375px, both pages): grid is faint
|
|
||||||
(barely-there), glows soft, no banding; brand button legible (dark
|
|
||||||
text); deflection bubble distinct from normal answers; avatars crisp at
|
|
||||||
16px; reduced-motion preview (DevTools emulation) shows the static
|
|
||||||
background.
|
|
||||||
|
|
||||||
## Testing & Quality
|
|
||||||
- Integration: no-CDN check still green on both pages (it covers
|
|
||||||
`/sources.html` since Phase 07); new emoji-guard test above.
|
|
||||||
- **Existing E2E regression check:** after the reskin, run the existing
|
|
||||||
story suites in isolation and confirm they stay green — at minimum
|
|
||||||
`test_chat_rag.py`, `test_honest_deflection.py`, `test_responsive_
|
|
||||||
polish.py` (the contrast helper computes from live styles and must pass
|
|
||||||
on the new palette).
|
|
||||||
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` — **>90%**
|
|
||||||
on `app/`.
|
|
||||||
- `uv run ruff check . && uv run pyright` green.
|
|
||||||
|
|
||||||
## Playwright Execution Phase
|
|
||||||
Run ONLY this story's suite:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest tests/e2e/test_dark_tech_theme.py -v --no-cov
|
|
||||||
```
|
|
||||||
|
|
||||||
Implements the story mapping: dark bg + computed contrast pairs ≥4.5:1;
|
|
||||||
no emoji in innerText/outerHTML on both pages; `body::before`/`::after`
|
|
||||||
animate; reduced-motion context → `animation-name: none`; on-topic smoke
|
|
||||||
(stream + chip + button recovery) unchanged; all assets local.
|
|
||||||
|
|
||||||
## Success criteria
|
|
||||||
- [ ] both pages dark; every text pair ≥4.5:1 (computed in E2E)
|
|
||||||
- [ ] zero emoji in chrome (E2E + new integration guard)
|
|
||||||
- [ ] animated background subtle, pure CSS, reduced-motion honored
|
|
||||||
- [ ] layout metrics + chat behavior unchanged (smoke E2E)
|
|
||||||
- [ ] existing story E2E suites still green in isolation
|
|
||||||
- [ ] unit + integration green, coverage >90%, ruff + pyright green
|
|
||||||
- [ ] committed (force-add `.agent/PLAN.md` + this phase record — rule 8)
|
|
||||||
|
|
||||||
## Commit
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git add -f .agent/PLAN.md .agent/phases/todo/08_story_dark_tech_theme.md
|
|
||||||
git commit --no-gpg-sign -m "feat(ui): dark tech theme — emoji-free chrome, subtle animated CSS background, WCAG AA dark palette"
|
|
||||||
```
|
|
||||||
+9
-2
@@ -18,13 +18,20 @@ BOR_LLM_EMBED_MODEL=embed
|
|||||||
BOR_EMBEDDING_DIM=768 # verified 2026-08 via scripts/llm_probe.py
|
BOR_EMBEDDING_DIM=768 # verified 2026-08 via scripts/llm_probe.py
|
||||||
|
|
||||||
# --- RAG tuning ---
|
# --- RAG tuning ---
|
||||||
BOR_TOP_K_CHUNKS=4
|
|
||||||
BOR_TOP_N_DOCS=2
|
BOR_TOP_N_DOCS=2
|
||||||
BOR_RELEVANCE_THRESHOLD=0.30 # max cosine similarity required to answer (else honest deflection)
|
BOR_RELEVANCE_THRESHOLD=0.62 # answer when best cosine >= this OR an FTS hit; else honest deflection
|
||||||
BOR_MAX_CONTEXT_CHARS=24000 # cap on total document text sent to the LLM
|
BOR_MAX_CONTEXT_CHARS=24000 # cap on total document text sent to the LLM
|
||||||
BOR_CHUNK_TARGET_CHARS=2000
|
BOR_CHUNK_TARGET_CHARS=2000
|
||||||
BOR_CHUNK_OVERLAP_CHARS=200
|
BOR_CHUNK_OVERLAP_CHARS=200
|
||||||
BOR_EMBED_BATCH_SIZE=16
|
BOR_EMBED_BATCH_SIZE=16
|
||||||
|
|
||||||
|
# --- Hybrid retrieval (vector + Postgres FTS, RRF-fused) ---
|
||||||
|
BOR_HYBRID_VECTOR_CANDIDATES=100 # cosine list width for the fusion
|
||||||
|
BOR_HYBRID_LEXICAL_CANDIDATES=30 # FTS list width for the fusion
|
||||||
|
BOR_RRF_K=60 # Reciprocal Rank Fusion damping constant
|
||||||
|
|
||||||
|
# --- Import scope (A9 formats; may only narrow, never widen) ---
|
||||||
|
# BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py
|
||||||
# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON list of onboarding chips
|
# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON list of onboarding chips
|
||||||
|
|
||||||
# --- Debugging (0/1 — 1 enables attach-on-demand debugpy on port 5678) ---
|
# --- Debugging (0/1 — 1 enables attach-on-demand debugpy on port 5678) ---
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# 🧠 Brain of Reese
|
# 🧠 Brain of Reese
|
||||||
|
|
||||||
A chippy, honest **RAG chatbot** over the `~/Homelab` and `~/Deployments`
|
A chippy, honest **RAG chatbot** over the `~/Homelab` and `~/Deployments`
|
||||||
projects. Point it at your markdown docs, ask it anything — it retrieves
|
projects. Point it at your notes — markdown, YAML, JSON, Python, plain
|
||||||
the relevant notes with **Postgres 17 + pgvector** cosine search, feeds the
|
text — ask it anything, and it retrieves the relevant chunks with
|
||||||
**whole relevant document** to a **self-hosted LLM** (`turbo` via
|
**hybrid search** (pgvector cosine ∪ Postgres full-text search, fused with
|
||||||
`https://aipi.reeseapps.com/v1`), and streams a grounded answer back.
|
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:
|
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.
|
*"I haven't done anything like that"* — plus suggestions for what it **does** know.
|
||||||
@@ -77,9 +79,9 @@ uv run uvicorn app.main:app --reload
|
|||||||
file), so a refresh after a normal editing session takes seconds:
|
file), so a refresh after a normal editing session takes seconds:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# After editing/adding/removing markdown in your projects:
|
# 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 # re-index what changed
|
||||||
uv run python -m scripts.import_docs --prune # also drop deleted files
|
uv run python -m scripts.import_docs --prune # also drop deleted/out-of-scope files
|
||||||
|
|
||||||
# Point it at extra directories (repeatable):
|
# Point it at extra directories (repeatable):
|
||||||
uv run python -m scripts.import_docs --source ~/SomeOtherDocs
|
uv run python -m scripts.import_docs --source ~/SomeOtherDocs
|
||||||
@@ -92,16 +94,61 @@ embedded.
|
|||||||
|
|
||||||
- The import prints one line per file (`import: added|updated|unchanged|
|
- The import prints one line per file (`import: added|updated|unchanged|
|
||||||
pruned …`) and ends with a greppable summary (`import: summary files=…
|
pruned …`) and ends with a greppable summary (`import: summary files=…
|
||||||
added=… updated=… unchanged=… pruned=… chunks=… embed_batches=…`), so it
|
added=… updated=… unchanged=… pruned=… chunks=… embed_batches=…
|
||||||
is safe to run from a cron job or after every commit.
|
formats=md:203,yaml:267,…`), so it is safe to run from a cron job or
|
||||||
- Only **`*.md`** files are indexed. Directories like `.venv`,
|
after every commit.
|
||||||
`node_modules`, `.git`, `__pycache__`, `.pytest_cache`, `dist`, `build`
|
- Indexed formats (A9): **`md, markdown, txt, yaml, yml, json, py`**
|
||||||
are skipped (see `.agent/PLAN.md` anchor A9).
|
(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`) and their title comes from the file stem.
|
||||||
- Unchanged files are **not re-embedded** — only new/changed ones, so
|
- Unchanged files are **not re-embedded** — only new/changed ones, so
|
||||||
refreshes are cheap.
|
refreshes are cheap.
|
||||||
- To sanity-check the LLM backend (models + embedding dimension) after any
|
- To sanity-check the LLM backend (models + embedding dimension) after any
|
||||||
aipi change: `uv run python -m scripts.llm_probe`.
|
aipi change: `uv run python -m scripts.llm_probe`.
|
||||||
|
|
||||||
|
## 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'`.
|
||||||
|
|
||||||
## Debugging
|
## Debugging
|
||||||
|
|
||||||
`debugpy` is **off by default** and *never imported* unless you opt in —
|
`debugpy` is **off by default** and *never imported* unless you opt in —
|
||||||
@@ -203,9 +250,12 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`.
|
|||||||
| `BOR_LLM_CHAT_MODEL` | `turbo` | chat model |
|
| `BOR_LLM_CHAT_MODEL` | `turbo` | chat model |
|
||||||
| `BOR_LLM_EMBED_MODEL` | `embed` | embedding model |
|
| `BOR_LLM_EMBED_MODEL` | `embed` | embedding model |
|
||||||
| `BOR_EMBEDDING_DIM` | `768` | vector dimension (fixed at table creation) |
|
| `BOR_EMBEDDING_DIM` | `768` | vector dimension (fixed at table creation) |
|
||||||
| `BOR_TOP_K_CHUNKS` | `4` | chunks retrieved per question |
|
|
||||||
| `BOR_TOP_N_DOCS` | `2` | full documents fed to the LLM |
|
| `BOR_TOP_N_DOCS` | `2` | full documents fed to the LLM |
|
||||||
| `BOR_RELEVANCE_THRESHOLD` | `0.30` | best cosine similarity required to answer; below ⇒ honest deflection |
|
| `BOR_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_IMPORT_EXTENSIONS` | `md,markdown,txt,yaml,yml,json,py` | csv of importable formats (may only narrow the A9 set) |
|
||||||
| `BOR_MAX_CONTEXT_CHARS` | `24000` | cap on total document text sent to the LLM |
|
| `BOR_MAX_CONTEXT_CHARS` | `24000` | cap on total document text sent to the LLM |
|
||||||
| `BOR_SUGGESTIONS` | built-in list | JSON list of onboarding chips |
|
| `BOR_SUGGESTIONS` | built-in list | JSON list of onboarding chips |
|
||||||
| `DEBUGPY` | `0` | `1` ⇒ attach-on-demand debugpy on `DEBUGPY_PORT` (default 5678) |
|
| `DEBUGPY` | `0` | `1` ⇒ attach-on-demand debugpy on `DEBUGPY_PORT` (default 5678) |
|
||||||
@@ -227,23 +277,26 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`.
|
|||||||
drop + recreate the chunks table (new migration or manual `TRUNCATE
|
drop + recreate the chunks table (new migration or manual `TRUNCATE
|
||||||
chunks, documents`).
|
chunks, documents`).
|
||||||
- **Honest deflection (the amber “I haven't done anything like that”
|
- **Honest deflection (the amber “I haven't done anything like that”
|
||||||
bubble)** — every question passes the honesty gate: when the best
|
bubble)** — every question passes the honesty gate: deflection happens
|
||||||
cosine similarity is below `BOR_RELEVANCE_THRESHOLD` (default `0.30`),
|
only when the best cosine similarity is below
|
||||||
Brain switches to deflection mode instead of guessing. The LLM prompt
|
`BOR_RELEVANCE_THRESHOLD` (default `0.62`) **and** no chunk matched the
|
||||||
then carries weak-hit *titles only* (no document content), the reply
|
question lexically (`fts_hits = 0`). A weak cosine with a lexical hit
|
||||||
opens with “I haven't done anything like that”, the bubble renders
|
(name-your-tool questions) still gets a grounded answer. When it does
|
||||||
amber with “Maybe try” chips derived from the closest indexed titles,
|
deflect, the LLM prompt carries weak-hit *titles only* (no document
|
||||||
the SSE `done` event carries `deflected: true` + `suggestions[]`, and
|
content), the reply opens with “I haven't done anything like that”, the
|
||||||
the `query_log` row records `deflected=true` + the weak `top_score`.
|
bubble renders amber with “Maybe try” chips derived from the closest
|
||||||
This is a feature, not a bug — the KB simply has no notes that close;
|
indexed titles, the SSE `done` event carries `deflected: true` +
|
||||||
the chips always point at topics Brain really covers.
|
`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
|
- **Answers deflect too often / too rarely** — tune
|
||||||
`BOR_RELEVANCE_THRESHOLD` (lower = answers more, higher = more honest
|
`BOR_RELEVANCE_THRESHOLD` (lower = answers more, higher = more honest
|
||||||
deflection): `0.0` ⇒ every question gets answered, even unknown topics
|
deflection): `0.0` ⇒ the gate leans entirely on FTS hits; `1.0` ⇒
|
||||||
(expect confident-sounding guesses); `1.0` ⇒ everything deflects
|
everything deflects unless a chunk matches lexically. The `embed` model's
|
||||||
(nothing but a perfect 1.0 score counts as relevant). After changing
|
cosines cluster in a ~0.6–0.85 band on the live KB, so the default is
|
||||||
it, check the real scores:
|
`0.62`; after changing it, check the real scores:
|
||||||
`psql … -c 'SELECT question, top_score, deflected FROM query_log ORDER BY created_at DESC LIMIT 20'`
|
`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:
|
- **KB offline banner in the chat** — Postgres isn't running:
|
||||||
`podman compose up -d db`.
|
`podman compose up -d db`.
|
||||||
- **Stuck "Thinking…"** — the LLM is slow or down; a 120s client timeout
|
- **Stuck "Thinking…"** — the LLM is slow or down; a 120s client timeout
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""hybrid retrieval: generated FTS column on chunks + query_log.fts_hits
|
||||||
|
|
||||||
|
Revision ID: 0002
|
||||||
|
Revises: 0001
|
||||||
|
Create Date: 2026-08-21
|
||||||
|
|
||||||
|
A7/A8 (revised 2026-08-21, owner permission): retrieval becomes hybrid
|
||||||
|
(cosine top-N + Postgres full-text top-N, RRF-fused). This adds:
|
||||||
|
|
||||||
|
* ``chunks.tsv`` — generated ``TSVECTOR`` (``to_tsvector('english',
|
||||||
|
content) STORED``) + GIN index for the lexical candidate list.
|
||||||
|
* ``query_log.fts_hits`` — INT, nullable (pre-existing rows stay NULL:
|
||||||
|
the column only carries meaning from hybrid retrieval onward).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0002"
|
||||||
|
down_revision = "0001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE chunks "
|
||||||
|
"ADD COLUMN tsv tsvector "
|
||||||
|
"GENERATED ALWAYS AS (to_tsvector('english', content)) STORED"
|
||||||
|
)
|
||||||
|
op.execute("CREATE INDEX ix_chunks_tsv ON chunks USING gin (tsv)")
|
||||||
|
op.add_column("query_log", sa.Column("fts_hits", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("query_log", "fts_hits")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_chunks_tsv")
|
||||||
|
op.execute("ALTER TABLE chunks DROP COLUMN IF EXISTS tsv")
|
||||||
+39
-26
@@ -1,18 +1,21 @@
|
|||||||
"""POST /api/chat — a RAG chat turn streamed over SSE (PLAN §3/§4).
|
"""POST /api/chat — a RAG chat turn streamed over SSE (PLAN §3/§4).
|
||||||
|
|
||||||
Flow (LOCKED A7/A15): embed the question → pgvector cosine top-K chunks →
|
Flow (LOCKED A7/A15): embed the question → hybrid retrieval (cosine
|
||||||
the **honesty gate** (A8: best score < ``BOR_RELEVANCE_THRESHOLD`` ⇒
|
top-N ∪ Postgres FTS top-N, RRF-fused) → the **honesty gate** → locked
|
||||||
deflection) → locked persona prompt (PLAN §6) → ``turbo`` streamed as
|
persona prompt (PLAN §6) → ``turbo`` streamed as ``delta`` events → final
|
||||||
``delta`` events → final ``done`` event (``deflected``, ``sources``,
|
``done`` event (``deflected``, ``sources``, ``suggestions``) +
|
||||||
``suggestions``) + ``query_log`` row + the per-turn log line (PLAN §9).
|
``query_log`` row + the per-turn log line (PLAN §9).
|
||||||
Mid-stream failures become a structured ``error`` event; a pre-stream DB
|
Mid-stream failures become a structured ``error`` event; a pre-stream DB
|
||||||
outage is a plain 503 JSON.
|
outage is a plain 503 JSON.
|
||||||
|
|
||||||
Honesty gate: a weak retrieval (score strictly below the threshold — or
|
Honesty gate (A8, revised 2026-08-21): LOW — deflection — only when the
|
||||||
an empty KB) flips the turn to deflection mode: the LOW prompt carries
|
best cosine is strictly below ``BOR_RELEVANCE_THRESHOLD`` **and** no
|
||||||
weak-hit *titles only* (never document content) plus deterministic
|
candidate chunk FTS-matches the question (``fts_hits == 0``). A
|
||||||
"Maybe try" chips, and the ``done`` event / ``query_log`` row record
|
name-your-tool question with weak vector overlap but a lexical hit still
|
||||||
``deflected=true`` with the weak score.
|
gets a grounded answer. Deflection mode carries weak-hit *titles only*
|
||||||
|
(never document content) plus deterministic "Maybe try" chips, and the
|
||||||
|
``done`` event / ``query_log`` row record ``deflected=true``, the weak
|
||||||
|
score and the ``fts_hits`` count.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -62,7 +65,8 @@ def sse_event(payload: dict[str, Any]) -> str:
|
|||||||
class TurnPlan:
|
class TurnPlan:
|
||||||
"""What one chat turn sends to the LLM and reports on ``done``."""
|
"""What one chat turn sends to the LLM and reports on ``done``."""
|
||||||
|
|
||||||
top_score: float
|
top_score: float # best cosine across candidates (query_log.top_score)
|
||||||
|
fts_hits: int # lexical (OR-tsquery) candidates matched
|
||||||
deflected: bool
|
deflected: bool
|
||||||
system_prompt: str
|
system_prompt: str
|
||||||
docs: list[Document] # cited sources (weak hits when deflected)
|
docs: list[Document] # cited sources (weak hits when deflected)
|
||||||
@@ -70,25 +74,32 @@ class TurnPlan:
|
|||||||
|
|
||||||
|
|
||||||
def plan_turn(chunks: Sequence[RetrievedChunk], settings: Settings) -> TurnPlan:
|
def plan_turn(chunks: Sequence[RetrievedChunk], settings: Settings) -> TurnPlan:
|
||||||
"""Apply the honesty gate (A8) and assemble prompt + context for a turn.
|
"""Apply the honesty gate (A8, revised) and assemble prompt + context.
|
||||||
|
|
||||||
* ``top_score >= threshold`` → grounded: HIGH prompt with the full
|
* **HIGH (grounded)** when ``best_cosine >= threshold`` **or**
|
||||||
top-N documents, no suggestions. A score exactly at the threshold
|
``fts_hits > 0``: HIGH prompt with the full top-N documents, no
|
||||||
is an answer — the gate is strict (``score < threshold``).
|
suggestions. A cosine exactly at the threshold is an answer — the
|
||||||
* ``top_score < threshold`` (or no hits at all) → deflected: LOW
|
gate is strict (``< threshold``).
|
||||||
prompt (``DEFLECT_MODE``) with weak-hit titles only — never document
|
* **LOW (deflected)** only when ``best_cosine < threshold`` **and**
|
||||||
content — plus deterministic alternative-question chips derived
|
``fts_hits == 0`` (or no hits at all): LOW prompt (``DEFLECT_MODE``)
|
||||||
from those titles.
|
with weak-hit titles only — never document content — plus
|
||||||
|
deterministic alternative-question chips derived from those titles.
|
||||||
|
|
||||||
|
``top_score`` (stored in ``query_log``) is the best cosine, so the
|
||||||
|
gate input is always a pure vector-similarity number; the lexical
|
||||||
|
signal is recorded separately as ``fts_hits``.
|
||||||
"""
|
"""
|
||||||
top_score = chunks[0].score if chunks else 0.0
|
best_cosine = max((c.cosine for c in chunks), default=0.0)
|
||||||
if top_score >= settings.relevance_threshold:
|
fts_hits = sum(1 for c in chunks if c.fts_hit)
|
||||||
|
if best_cosine >= settings.relevance_threshold or fts_hits > 0:
|
||||||
docs = select_documents(
|
docs = select_documents(
|
||||||
chunks, n=settings.top_n_docs, max_chars=settings.max_context_chars
|
chunks, n=settings.top_n_docs, max_chars=settings.max_context_chars
|
||||||
)
|
)
|
||||||
return TurnPlan(top_score, False, build_high_prompt(docs), docs, [])
|
return TurnPlan(best_cosine, fts_hits, False, build_high_prompt(docs), docs, [])
|
||||||
titles = weak_hit_titles(chunks)
|
titles = weak_hit_titles(chunks)
|
||||||
return TurnPlan(
|
return TurnPlan(
|
||||||
top_score,
|
best_cosine,
|
||||||
|
fts_hits,
|
||||||
True,
|
True,
|
||||||
build_deflect_prompt(titles),
|
build_deflect_prompt(titles),
|
||||||
select_documents(chunks, n=settings.top_n_docs, max_chars=settings.max_context_chars),
|
select_documents(chunks, n=settings.top_n_docs, max_chars=settings.max_context_chars),
|
||||||
@@ -143,7 +154,7 @@ async def chat(
|
|||||||
# HIGH (grounded) or LOW (deflected) prompt + context.
|
# HIGH (grounded) or LOW (deflected) prompt + context.
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
try:
|
try:
|
||||||
chunks = retrieve(db, question_vec)
|
chunks = retrieve(db, request.message, question_vec)
|
||||||
plan = plan_turn(chunks, settings)
|
plan = plan_turn(chunks, settings)
|
||||||
except Exception: # noqa: BLE001 — DB failure mid-turn
|
except Exception: # noqa: BLE001 — DB failure mid-turn
|
||||||
logger.exception(
|
logger.exception(
|
||||||
@@ -188,6 +199,7 @@ async def chat(
|
|||||||
QueryLog(
|
QueryLog(
|
||||||
question=request.message,
|
question=request.message,
|
||||||
top_score=plan.top_score,
|
top_score=plan.top_score,
|
||||||
|
fts_hits=plan.fts_hits,
|
||||||
chunk_hits=len(chunks),
|
chunk_hits=len(chunks),
|
||||||
deflected=plan.deflected,
|
deflected=plan.deflected,
|
||||||
sources=", ".join(source_paths),
|
sources=", ".join(source_paths),
|
||||||
@@ -199,11 +211,12 @@ async def chat(
|
|||||||
logger.exception("chat: failed to write query_log question=%r", request.message)
|
logger.exception("chat: failed to write query_log question=%r", request.message)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"question=%r embed_ms=%d top_score=%.3f threshold=%.2f deflected=%s "
|
"question=%r embed_ms=%d top_score=%.3f fts_hits=%d threshold=%.2f "
|
||||||
"sources=%r total_ms=%d",
|
"deflected=%s sources=%r total_ms=%d",
|
||||||
request.message,
|
request.message,
|
||||||
embed_ms,
|
embed_ms,
|
||||||
plan.top_score,
|
plan.top_score,
|
||||||
|
plan.fts_hits,
|
||||||
settings.relevance_threshold,
|
settings.relevance_threshold,
|
||||||
plan.deflected,
|
plan.deflected,
|
||||||
source_paths,
|
source_paths,
|
||||||
|
|||||||
+62
-2
@@ -8,8 +8,15 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic import field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
#: The A9 import formats (PLAN anchor A9, revised 2026-08-21).
|
||||||
|
#: ``BOR_IMPORT_EXTENSIONS`` may narrow — but never widen — this set.
|
||||||
|
_ALLOWED_IMPORT_EXTENSIONS: frozenset[str] = frozenset(
|
||||||
|
{"md", "markdown", "txt", "yaml", "yml", "json", "py"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
@@ -37,14 +44,58 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# --- RAG tuning ---
|
# --- RAG tuning ---
|
||||||
embedding_dim: int = 768 # verified against aipi /v1 (embed model)
|
embedding_dim: int = 768 # verified against aipi /v1 (embed model)
|
||||||
top_k_chunks: int = 4
|
|
||||||
top_n_docs: int = 2
|
top_n_docs: int = 2
|
||||||
relevance_threshold: float = 0.30
|
# Honesty gate (A8, re-tuned 2026-08-21): the ``embed`` model's cosine
|
||||||
|
# scores compress into 0.41–0.84 on the real corpus, so the old 0.30
|
||||||
|
# default never discriminated. LOW only fires when best cosine < this
|
||||||
|
# AND no candidate chunk matches the question lexically (see A8).
|
||||||
|
relevance_threshold: float = 0.62
|
||||||
max_context_chars: int = 24_000
|
max_context_chars: int = 24_000
|
||||||
chunk_target_chars: int = 2_000
|
chunk_target_chars: int = 2_000
|
||||||
chunk_overlap_chars: int = 200
|
chunk_overlap_chars: int = 200
|
||||||
embed_batch_size: int = 16
|
embed_batch_size: int = 16
|
||||||
|
|
||||||
|
# --- Hybrid retrieval (A7, revised 2026-08-21) ---
|
||||||
|
# cosine top-N ∪ Postgres FTS top-N, fused with Reciprocal Rank Fusion
|
||||||
|
# (score = Σ 1/(rrf_k + rank) over the lists a chunk appears in).
|
||||||
|
#
|
||||||
|
# The vector window is deliberately wider than the lexical one: a
|
||||||
|
# name-your-tool question's best *lexical* chunk (e.g. the "Install"
|
||||||
|
# section of gitlab.md) can sit far down the vector ranking because the
|
||||||
|
# question embeds close to generic templates. A 100-wide window is what
|
||||||
|
# lets such chunks double-hit (one RRF term per list) and outrank a
|
||||||
|
# template that owns vector rank 1 — measured 2026-08-22 against the
|
||||||
|
# live 2774-chunk KB for "How did I install gitlab?" (gitlab.md:1 at
|
||||||
|
# vrank 100 / lrank 3 → fused 0.0221 vs the template's 0.0164).
|
||||||
|
hybrid_vector_candidates: int = 100
|
||||||
|
hybrid_lexical_candidates: int = 30
|
||||||
|
rrf_k: int = 60
|
||||||
|
|
||||||
|
# --- Import scope (A9, revised 2026-08-21) ---
|
||||||
|
# Comma-separated list of lowercased file extensions (no dot) imported
|
||||||
|
# by ``scripts/import_docs.py``. Hidden (dot) path components are always
|
||||||
|
# skipped, plus the importer's exclusion list.
|
||||||
|
# Stored as a raw CSV string (env-native — no JSON) and parsed on demand
|
||||||
|
# via :py:meth:`import_extension_set`. ``mode="after"`` validation runs
|
||||||
|
# against the raw string so a typo fails loudly at startup.
|
||||||
|
import_extensions: str = "md,markdown,txt,yaml,yml,json,py"
|
||||||
|
|
||||||
|
@field_validator("import_extensions")
|
||||||
|
@classmethod
|
||||||
|
def _import_extensions_known(cls, v: str) -> str:
|
||||||
|
"""Reject unknown/empty formats loudly instead of silently importing
|
||||||
|
nothing (a typo like ``md,jsonn`` would otherwise walk zero files)."""
|
||||||
|
exts = {part.strip().lstrip(".").lower() for part in v.split(",") if part.strip()}
|
||||||
|
if not exts:
|
||||||
|
raise ValueError("import_extensions must name at least one format")
|
||||||
|
unknown = exts - _ALLOWED_IMPORT_EXTENSIONS
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"unknown import extension(s): {', '.join(sorted(unknown))} — "
|
||||||
|
f"allowed: {', '.join(sorted(_ALLOWED_IMPORT_EXTENSIONS))}"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
# Suggested questions (onboarding + empty state).
|
# Suggested questions (onboarding + empty state).
|
||||||
suggestions: list[str] = [
|
suggestions: list[str] = [
|
||||||
"How is my Kubernetes cluster set up?",
|
"How is my Kubernetes cluster set up?",
|
||||||
@@ -53,6 +104,15 @@ class Settings(BaseSettings):
|
|||||||
"What's currently running in the homelab?",
|
"What's currently running in the homelab?",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def import_extension_set(self) -> frozenset[str]:
|
||||||
|
"""Lowercased, dotted extension set (``.md``) for path filtering."""
|
||||||
|
return frozenset(
|
||||||
|
f".{part.strip().lstrip('.').lower()}"
|
||||||
|
for part in self.import_extensions.split(",")
|
||||||
|
if part.strip()
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def effective_api_key(self) -> str:
|
def effective_api_key(self) -> str:
|
||||||
"""API key for aipi: explicit setting, then $AIPI_KEY, then a placeholder."""
|
"""API key for aipi: explicit setting, then $AIPI_KEY, then a placeholder."""
|
||||||
|
|||||||
+4
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Data model — see ``.agent/PLAN.md`` §Data Model:
|
Data model — see ``.agent/PLAN.md`` §Data Model:
|
||||||
|
|
||||||
* ``documents`` — one row per ``*.md`` file (full content, path, sha256 hash).
|
* ``documents`` — one row per imported A9 file (full content, path, sha256 hash).
|
||||||
* ``chunks`` — retrieval units; each chunk points at its parent document
|
* ``chunks`` — retrieval units; each chunk points at its parent document
|
||||||
via ``document_id``. This is how an embedding maps back to
|
via ``document_id``. This is how an embedding maps back to
|
||||||
a document path (the "feed the whole document" requirement).
|
a document path (the "feed the whole document" requirement).
|
||||||
@@ -74,6 +74,9 @@ class QueryLog(Base):
|
|||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
question: Mapped[str] = mapped_column(Text)
|
question: Mapped[str] = mapped_column(Text)
|
||||||
top_score: Mapped[float] = mapped_column(Float, default=0.0) # best cosine similarity
|
top_score: Mapped[float] = mapped_column(Float, default=0.0) # best cosine similarity
|
||||||
|
#: Lexical (FTS) candidates matched — the OR-tsquery hit count (A8). NULL
|
||||||
|
#: for pre-hybrid rows (migration 0002).
|
||||||
|
fts_hits: Mapped[int | None] = mapped_column(Integer)
|
||||||
chunk_hits: Mapped[int] = mapped_column(Integer, default=0)
|
chunk_hits: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
deflected: Mapped[bool] = mapped_column(Boolean, default=False) # True = honest "no idea"
|
deflected: Mapped[bool] = mapped_column(Boolean, default=False) # True = honest "no idea"
|
||||||
sources: Mapped[str] = mapped_column(Text, default="") # comma-joined source paths
|
sources: Mapped[str] = mapped_column(Text, default="") # comma-joined source paths
|
||||||
|
|||||||
+172
-17
@@ -1,25 +1,36 @@
|
|||||||
"""Markdown-aware chunker (PLAN §5 chunking policy).
|
"""Format-aware chunker (PLAN §5 chunking policy).
|
||||||
|
|
||||||
Pure functions, no I/O — fully unit-testable.
|
Pure functions, no I/O — fully unit-testable. Stdlib only.
|
||||||
|
|
||||||
Policy
|
:func:`chunk_document` dispatches on the file's lowercased suffix;
|
||||||
------
|
per-format policies:
|
||||||
* **Sections** are split on ATX headings of level ≥ 2 (``## ``/``### ``/…).
|
|
||||||
* A section that fits in ``target_chars`` becomes a single chunk.
|
* **md / markdown** — sections are split on ATX headings of level ≥ 2
|
||||||
* A longer section is sub-split at paragraph boundaries (blank lines outside
|
(``## ``/``### ``/…); a section that fits in ``target_chars`` becomes a
|
||||||
code fences); each chunk after the first starts with the trailing
|
single chunk, a longer one is sub-split at paragraph boundaries (blank
|
||||||
``overlap_chars`` of the previous chunk so context survives the cut.
|
lines outside code fences) with ``overlap_chars`` carry-over, and every
|
||||||
* Every chunk keeps its nearest preceding heading line (the section anchor),
|
chunk keeps its nearest preceding heading line (the section anchor). Code
|
||||||
so a retrieval hit is always readable in context.
|
fences are atomic (a boundary never falls inside one) except a fence
|
||||||
* **Code fences** (``` / ~~~) are atomic: a chunk boundary never falls
|
larger than :data:`HARD_MAX_CHARS`, which is split by line.
|
||||||
inside one, and lines inside a fence are never mistaken for headings or
|
* **yaml / yml** — blocks start at ``---`` document separators and at
|
||||||
paragraph breaks. One exception: a fence *larger than* :data:`HARD_MAX_CHARS`
|
top-level (indent-0) ``key:`` lines; every chunk keeps its key lines as
|
||||||
is split by line, because aipi's local embedding model rejects requests
|
anchors, so a hit is always readable in context.
|
||||||
over ~1024 input tokens and a single 5000-char code block would blow
|
* **json** — pretty-printed (``json.dumps(obj, indent=2)``) and split on
|
||||||
past that on its own.
|
top-level keys (one ``{key: value}`` block per key); unparseable input
|
||||||
|
falls back to paragraph packing.
|
||||||
|
* **py** — split at top-level defs/classes via the stdlib ``ast`` (the
|
||||||
|
module preamble — imports, constants — is its own block); an oversized
|
||||||
|
definition falls back to line packing.
|
||||||
|
* **txt** (and any unknown suffix) — paragraph packing.
|
||||||
|
|
||||||
|
Every format honors :data:`HARD_MAX_CHARS` (1200 — the aipi ~1024-token
|
||||||
|
request cap) and the target/overlap settings; oversized blocks are split
|
||||||
|
by line so no chunk can exceed the cap.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
@@ -203,3 +214,147 @@ def chunk_markdown(
|
|||||||
for start, end in _section_ranges(lines, flags):
|
for start, end in _section_ranges(lines, flags):
|
||||||
chunks.extend(_chunk_section(lines[start:end], flags[start:end], target, overlap))
|
chunks.extend(_chunk_section(lines[start:end], flags[start:end], target, overlap))
|
||||||
return [c for c in chunks if c.strip()]
|
return [c for c in chunks if c.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Non-markdown formats (A9, revised 2026-08-21): yaml/yml, json, py, txt
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: YAML document separator (column 0).
|
||||||
|
_YAML_DOC_SEP_RE = re.compile(r"^-{3,}\s*$")
|
||||||
|
#: Top-level YAML key (column 0, no leading whitespace) — the block anchor.
|
||||||
|
_YAML_KEY_RE = re.compile(r"^[A-Za-z0-9_.\-]+\s*:")
|
||||||
|
|
||||||
|
|
||||||
|
def _yaml_blocks(lines: Sequence[str]) -> list[str]:
|
||||||
|
"""Group YAML lines into blocks: ``---`` separators and indent-0
|
||||||
|
``key:`` lines each start a new block (the key line stays the anchor)."""
|
||||||
|
blocks: list[str] = []
|
||||||
|
cur: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
if cur and (_YAML_DOC_SEP_RE.match(line) or _YAML_KEY_RE.match(line)):
|
||||||
|
blocks.append("\n".join(cur))
|
||||||
|
cur = []
|
||||||
|
cur.append(line)
|
||||||
|
if cur:
|
||||||
|
blocks.append("\n".join(cur))
|
||||||
|
return [b for b in blocks if b.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_yaml(content: str, target_chars: int = 2000, overlap_chars: int = 200) -> list[str]:
|
||||||
|
"""Split YAML on document separators + top-level keys (see module docstring)."""
|
||||||
|
target, overlap = _normalize_target_overlap(target_chars, overlap_chars)
|
||||||
|
return _pack_blocks(_yaml_blocks(content.splitlines()), target, overlap)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_blocks(content: str) -> list[str] | None:
|
||||||
|
"""Pretty-printed per-top-level-key blocks, or ``None`` if unparseable."""
|
||||||
|
try:
|
||||||
|
obj = json.loads(content)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return [json.dumps({k: v}, indent=2) for k, v in obj.items()]
|
||||||
|
# Top-level list/scalar: nothing to key on — one pretty-printed block.
|
||||||
|
return [json.dumps(obj, indent=2)]
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_json(content: str, target_chars: int = 2000, overlap_chars: int = 200) -> list[str]:
|
||||||
|
"""Pretty-print JSON and split on top-level keys (unparseable → paragraphs)."""
|
||||||
|
target, overlap = _normalize_target_overlap(target_chars, overlap_chars)
|
||||||
|
blocks = _json_blocks(content)
|
||||||
|
if blocks is None:
|
||||||
|
return chunk_text(content, target, overlap)
|
||||||
|
return _pack_blocks(blocks, target, overlap)
|
||||||
|
|
||||||
|
|
||||||
|
def _python_blocks(content: str) -> list[str] | None:
|
||||||
|
"""Line blocks: module preamble, then one per top-level def/class.
|
||||||
|
|
||||||
|
Returns ``None`` when the source does not parse (→ line/paragraph
|
||||||
|
packing fallback).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tree = ast.parse(content)
|
||||||
|
except (SyntaxError, ValueError):
|
||||||
|
return None
|
||||||
|
lines = content.splitlines()
|
||||||
|
tops = [
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
||||||
|
]
|
||||||
|
tops.sort(key=lambda n: n.lineno)
|
||||||
|
ranges: list[tuple[int, int]] = []
|
||||||
|
for node in tops:
|
||||||
|
start = node.lineno - 1
|
||||||
|
for dec in node.decorator_list:
|
||||||
|
start = min(start, dec.lineno - 1)
|
||||||
|
end = node.end_lineno or node.lineno # end_lineno is None on odd parses
|
||||||
|
ranges.append((start, end)) # 0-based start, 1-based end
|
||||||
|
blocks: list[str] = []
|
||||||
|
cursor = 0
|
||||||
|
for start, end in ranges:
|
||||||
|
if start > cursor:
|
||||||
|
blocks.append("\n".join(lines[cursor:start]))
|
||||||
|
blocks.append("\n".join(lines[start:end]))
|
||||||
|
cursor = end
|
||||||
|
if cursor < len(lines):
|
||||||
|
blocks.append("\n".join(lines[cursor:]))
|
||||||
|
return [b for b in blocks if b.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_python(content: str, target_chars: int = 2000, overlap_chars: int = 200) -> list[str]:
|
||||||
|
"""Split Python on top-level defs/classes (stdlib ``ast``; see module doc)."""
|
||||||
|
target, overlap = _normalize_target_overlap(target_chars, overlap_chars)
|
||||||
|
blocks = _python_blocks(content)
|
||||||
|
if blocks is None:
|
||||||
|
return chunk_text(content, target, overlap)
|
||||||
|
return _pack_blocks(blocks, target, overlap)
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_text(content: str, target_chars: int = 2000, overlap_chars: int = 200) -> list[str]:
|
||||||
|
"""Plain-text paragraph packing (blank lines separate paragraphs)."""
|
||||||
|
target, overlap = _normalize_target_overlap(target_chars, overlap_chars)
|
||||||
|
lines = content.splitlines()
|
||||||
|
return _pack_blocks(_paragraph_blocks(lines, [False] * len(lines)), target, overlap)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_target_overlap(target_chars: int, overlap_chars: int) -> tuple[int, int]:
|
||||||
|
"""Validate + clamp the size policy (shared by every format)."""
|
||||||
|
if target_chars <= 0:
|
||||||
|
raise ValueError("target_chars must be > 0")
|
||||||
|
if overlap_chars < 0:
|
||||||
|
raise ValueError("overlap_chars must be >= 0")
|
||||||
|
# The endpoint's token cap is absolute — a larger target is unsafe.
|
||||||
|
target = min(target_chars, HARD_MAX_CHARS)
|
||||||
|
return target, min(overlap_chars, target - 1)
|
||||||
|
|
||||||
|
|
||||||
|
#: suffix → chunker (A9, revised: md, markdown, txt, yaml, yml, json, py).
|
||||||
|
_FORMAT_CHUNKERS = {
|
||||||
|
".md": chunk_markdown,
|
||||||
|
".markdown": chunk_markdown,
|
||||||
|
".txt": chunk_text,
|
||||||
|
".yaml": chunk_yaml,
|
||||||
|
".yml": chunk_yaml,
|
||||||
|
".json": chunk_json,
|
||||||
|
".py": chunk_python,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_document(
|
||||||
|
content: str,
|
||||||
|
path: str,
|
||||||
|
target_chars: int = 2000,
|
||||||
|
overlap_chars: int = 200,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Chunk *content* according to *path*'s lowercased suffix.
|
||||||
|
|
||||||
|
Unknown suffixes fall back to plain-text paragraph packing (the
|
||||||
|
importer only passes A9-format files, so this is belt-and-braces).
|
||||||
|
"""
|
||||||
|
name = path.rsplit("/", 1)[-1]
|
||||||
|
suffix = "." + name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
||||||
|
chunker = _FORMAT_CHUNKERS.get(suffix, chunk_text)
|
||||||
|
return chunker(content, target_chars, overlap_chars)
|
||||||
|
|||||||
+54
-16
@@ -1,23 +1,30 @@
|
|||||||
"""Knowledge-base importer (PLAN §5 / §9 / §11).
|
"""Knowledge-base importer (PLAN §5 / §9 / §11).
|
||||||
|
|
||||||
Walks ``*.md`` files (A9 exclusion list), diffs by sha256 against
|
Walks the A9-format files (``md, markdown, txt, yaml, yml, json, py`` by
|
||||||
``documents.content_hash`` and, for every new or changed file, runs the
|
default — ``BOR_IMPORT_EXTENSIONS``; case-insensitive), diffs by sha256
|
||||||
two-phase upsert:
|
against ``documents.content_hash`` and, for every new or changed file, runs
|
||||||
|
the two-phase upsert:
|
||||||
|
|
||||||
1. upsert the document row and replace its chunk rows (embeddings NULL)
|
1. upsert the document row and replace its chunk rows (embeddings NULL)
|
||||||
2. embed the new chunks in batches and attach the vectors
|
2. embed the new chunks in batches and attach the vectors
|
||||||
3. commit — one transaction per file, so a failed embedding leaves the
|
3. commit — one transaction per file, so a failed embedding leaves the
|
||||||
database untouched and the file is simply retried on the next run
|
database untouched and the file is simply retried on the next run
|
||||||
|
|
||||||
|
Scope (A9, revised 2026-08-21): any path containing a dot-prefixed
|
||||||
|
component (hidden dirs — vendored caches like ``.esphome/.espressif/**`` —
|
||||||
|
or hidden files) is skipped, plus the well-known exclusion list.
|
||||||
|
|
||||||
``prune=True`` deletes documents (of the imported sources only) whose files
|
``prune=True`` deletes documents (of the imported sources only) whose files
|
||||||
no longer exist. Per-file logging uses the verbs
|
no longer exist **or no longer match the format filter** — this is how
|
||||||
``added | updated | unchanged | pruned`` plus a summary line (PLAN §9).
|
previously-imported junk (e.g. dot-dir READMEs) leaves the index. Per-file
|
||||||
|
logging uses the verbs ``added | updated | unchanged | pruned`` plus a
|
||||||
|
summary line with per-format counts (PLAN §9).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
@@ -28,7 +35,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.models import Chunk, Document
|
from app.models import Chunk, Document
|
||||||
from app.rag.chunker import chunk_markdown, extract_title
|
from app.rag.chunker import chunk_document, extract_title
|
||||||
from app.rag.llm import EmbeddingError
|
from app.rag.llm import EmbeddingError
|
||||||
|
|
||||||
logger = logging.getLogger("app.importer")
|
logger = logging.getLogger("app.importer")
|
||||||
@@ -60,11 +67,20 @@ class ImportSummary:
|
|||||||
errors: int = 0
|
errors: int = 0
|
||||||
chunks: int = 0
|
chunks: int = 0
|
||||||
embed_batches: int = 0
|
embed_batches: int = 0
|
||||||
|
#: Files walked, keyed by lowercased extension (``md``, ``yaml``, …).
|
||||||
|
formats: dict[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def format_counts(self) -> str:
|
||||||
|
"""``md:203,yaml:267,py:14`` — highest count first (PLAN §9)."""
|
||||||
|
if not self.formats:
|
||||||
|
return "none"
|
||||||
|
ordered = sorted(self.formats.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||||
|
return ",".join(f"{ext}:{count}" for ext, count in ordered)
|
||||||
|
|
||||||
def log(self) -> None:
|
def log(self) -> None:
|
||||||
logger.info(
|
logger.info(
|
||||||
"import: summary files=%d added=%d updated=%d unchanged=%d pruned=%d "
|
"import: summary files=%d added=%d updated=%d unchanged=%d pruned=%d "
|
||||||
"errors=%d chunks=%d embed_batches=%d",
|
"errors=%d chunks=%d embed_batches=%d formats=%s",
|
||||||
self.files,
|
self.files,
|
||||||
self.added,
|
self.added,
|
||||||
self.updated,
|
self.updated,
|
||||||
@@ -73,17 +89,32 @@ class ImportSummary:
|
|||||||
self.errors,
|
self.errors,
|
||||||
self.chunks,
|
self.chunks,
|
||||||
self.embed_batches,
|
self.embed_batches,
|
||||||
|
self.format_counts(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def iter_markdown_files(root: Path, excluded: frozenset[str] = EXCLUDED_DIRS) -> list[Path]:
|
def iter_importable_files(
|
||||||
"""All ``*.md`` files under *root* (sorted), skipping excluded dirs (A9)."""
|
root: Path,
|
||||||
|
extensions: frozenset[str],
|
||||||
|
excluded: frozenset[str] = EXCLUDED_DIRS,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""All importable files under *root* (sorted), per the A9 scope rules.
|
||||||
|
|
||||||
|
*extensions* is a set of lowercased dotted suffixes (``{'.md', '.py'}``).
|
||||||
|
Skips: any path with a dot-prefixed component (hidden dirs/files —
|
||||||
|
vendored caches like ``.esphome/.espressif/**``) and the well-known
|
||||||
|
non-content directories in *excluded*.
|
||||||
|
"""
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
return []
|
return []
|
||||||
files: list[Path] = []
|
files: list[Path] = []
|
||||||
for path in sorted(root.rglob("*.md")):
|
for path in sorted(root.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
rel = path.relative_to(root)
|
rel = path.relative_to(root)
|
||||||
if any(part in excluded for part in rel.parts[:-1]):
|
if any(part.startswith(".") or part in excluded for part in rel.parts):
|
||||||
|
continue
|
||||||
|
if path.suffix.lower() not in extensions:
|
||||||
continue
|
continue
|
||||||
files.append(path)
|
files.append(path)
|
||||||
return files
|
return files
|
||||||
@@ -97,7 +128,7 @@ async def import_sources(
|
|||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
session: Session | None = None,
|
session: Session | None = None,
|
||||||
) -> ImportSummary:
|
) -> ImportSummary:
|
||||||
"""Import every ``*.md`` under *sources* (see module docstring).
|
"""Import every A9-format file under *sources* (see module docstring).
|
||||||
|
|
||||||
``session`` may be supplied (tests); a private one is opened and closed
|
``session`` may be supplied (tests); a private one is opened and closed
|
||||||
otherwise. ``limit`` caps the number of files processed (debug only) and
|
otherwise. ``limit`` caps the number of files processed (debug only) and
|
||||||
@@ -120,12 +151,14 @@ async def import_sources(
|
|||||||
break
|
break
|
||||||
source = root.name
|
source = root.name
|
||||||
source_names.add(source)
|
source_names.add(source)
|
||||||
for path in iter_markdown_files(root):
|
for path in iter_importable_files(root, llm.settings.import_extension_set):
|
||||||
if limit is not None and summary.files >= limit:
|
if limit is not None and summary.files >= limit:
|
||||||
break
|
break
|
||||||
rel = path.relative_to(root).as_posix()
|
rel = path.relative_to(root).as_posix()
|
||||||
seen.add((source, rel))
|
seen.add((source, rel))
|
||||||
summary.files += 1
|
summary.files += 1
|
||||||
|
ext = path.suffix.lower().lstrip(".") or "unknown"
|
||||||
|
summary.formats[ext] = summary.formats.get(ext, 0) + 1
|
||||||
try:
|
try:
|
||||||
await _index_file(
|
await _index_file(
|
||||||
session, source=source, rel=rel, full_path=path, llm=llm,
|
session, source=source, rel=rel, full_path=path, llm=llm,
|
||||||
@@ -172,7 +205,12 @@ async def _index_file(
|
|||||||
return
|
return
|
||||||
|
|
||||||
verb = "updated" if doc is not None else "added"
|
verb = "updated" if doc is not None else "added"
|
||||||
title = extract_title(content, fallback=full_path.stem)
|
# A ``#`` line is a real heading in markdown but a comment in every
|
||||||
|
# other format — titles for those come from the file stem.
|
||||||
|
if full_path.suffix.lower() in (".md", ".markdown"):
|
||||||
|
title = extract_title(content, fallback=full_path.stem)
|
||||||
|
else:
|
||||||
|
title = full_path.stem
|
||||||
if doc is None:
|
if doc is None:
|
||||||
doc = Document(
|
doc = Document(
|
||||||
source=source,
|
source=source,
|
||||||
@@ -200,7 +238,7 @@ async def _index_file(
|
|||||||
# policy stays intact for the rest of the KB.
|
# policy stays intact for the rest of the KB.
|
||||||
target = max(400, settings.chunk_target_chars)
|
target = max(400, settings.chunk_target_chars)
|
||||||
while True:
|
while True:
|
||||||
chunks_text = chunk_markdown(content, target, settings.chunk_overlap_chars)
|
chunks_text = chunk_document(content, rel, target, settings.chunk_overlap_chars)
|
||||||
doc.chunks = [
|
doc.chunks = [
|
||||||
Chunk(document_id=doc.id, position=i, content=c) for i, c in enumerate(chunks_text)
|
Chunk(document_id=doc.id, position=i, content=c) for i, c in enumerate(chunks_text)
|
||||||
]
|
]
|
||||||
|
|||||||
+194
-23
@@ -1,19 +1,31 @@
|
|||||||
"""pgvector cosine retrieval → parent-document mapping (PLAN §3/§6, A7).
|
"""Hybrid retrieval: pgvector cosine ∪ Postgres FTS, RRF-fused (PLAN §6, A7).
|
||||||
|
|
||||||
Retrieval returns the *chunks* closest to the question embedding (top-K by
|
* **Vector list** — top-N chunks by cosine distance (``embedding <=> $1``),
|
||||||
cosine distance). The product requirement is that the LLM receives the
|
each carrying its cosine ``1 − distance`` (the honesty-gate input).
|
||||||
**entire relevant document**, not just the chunk (LOCKED A7) — so
|
* **Lexical list** — top-N chunks matching an OR-``tsquery`` over the
|
||||||
:meth:`select_documents` maps chunk hits back to their parent documents
|
question's tokens, ordered by ``ts_rank``. This is what finds
|
||||||
(``chunks.document_id → documents``), dedupes, ranks by best chunk score,
|
name-your-tool questions ("gitlab") that vector similarity buries.
|
||||||
and caps the combined context at ``BOR_MAX_CONTEXT_CHARS``.
|
* **Fusion** — Reciprocal Rank Fusion (``score = Σ 1/(k + rank)`` over the
|
||||||
|
lists a chunk appears in; chunks hit by both lists get both terms). The
|
||||||
|
fused score ranks; :meth:`select_documents` and :func:`weak_hit_titles`
|
||||||
|
keep working off ``score``.
|
||||||
|
|
||||||
|
The product requirement is unchanged (LOCKED A7): the LLM receives the
|
||||||
|
**entire relevant document**, not just the chunk — chunk hits map back to
|
||||||
|
their parents, dedupe, rank by best fused score, and the combined context
|
||||||
|
is capped at ``BOR_MAX_CONTEXT_CHARS``.
|
||||||
|
|
||||||
|
Deterministic tie-break for equal fused scores:
|
||||||
|
``(−fused, −cosine, document.path, chunk.position)``.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
@@ -22,50 +34,209 @@ from app.models import Chunk, Document
|
|||||||
#: Marker appended when the context budget is exceeded (PLAN §6).
|
#: Marker appended when the context budget is exceeded (PLAN §6).
|
||||||
TRUNCATION_MARKER = "[…truncated…]"
|
TRUNCATION_MARKER = "[…truncated…]"
|
||||||
|
|
||||||
|
#: Alphanumeric tokens of a question (``to_tsquery`` input, OR-joined).
|
||||||
|
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
||||||
|
|
||||||
|
#: One row of the lexical candidate query (all fields needed to build a
|
||||||
|
#: detached :class:`Document` plus the chunk fields and ``ts_rank``).
|
||||||
|
_LEXICAL_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT c.id AS chunk_id,
|
||||||
|
c.position AS position,
|
||||||
|
c.content AS content,
|
||||||
|
d.id AS doc_id,
|
||||||
|
d.source AS source,
|
||||||
|
d.path AS path,
|
||||||
|
d.full_path AS full_path,
|
||||||
|
d.title AS title,
|
||||||
|
d.content AS doc_content,
|
||||||
|
d.content_hash AS content_hash,
|
||||||
|
d.indexed_at AS indexed_at,
|
||||||
|
ts_rank(c.tsv, to_tsquery('english', :tsquery)) AS rank
|
||||||
|
FROM chunks c
|
||||||
|
JOIN documents d ON d.id = c.document_id
|
||||||
|
WHERE c.tsv @@ to_tsquery('english', :tsquery)
|
||||||
|
ORDER BY rank DESC, d.path ASC, c.position ASC
|
||||||
|
LIMIT :limit
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RetrievedChunk:
|
class RetrievedChunk:
|
||||||
"""One chunk hit: its cosine score plus the parent document row."""
|
"""One retrieval candidate: fused rank score + parent document row.
|
||||||
|
|
||||||
|
* ``score`` — RRF fused score (the ranking key for document selection
|
||||||
|
and weak-hit titles).
|
||||||
|
* ``cosine`` — vector similarity ``1 − distance`` (the honesty-gate
|
||||||
|
input; ``0.0`` for lexical-only hits that have no vector rank).
|
||||||
|
* ``fts_hit`` — the chunk matched the question's OR-tsquery.
|
||||||
|
"""
|
||||||
|
|
||||||
chunk_id: uuid.UUID
|
chunk_id: uuid.UUID
|
||||||
position: int
|
position: int
|
||||||
content: str
|
content: str
|
||||||
score: float # 1 − cosine_distance (higher is more similar)
|
score: float
|
||||||
document: Document
|
document: Document
|
||||||
|
cosine: float = 0.0
|
||||||
|
fts_hit: bool = False
|
||||||
|
|
||||||
|
|
||||||
def retrieve(
|
def lexical_tsquery(question: str) -> str | None:
|
||||||
db: Session, question_embedding: list[float], top_k: int | None = None
|
"""OR-joined token string for ``to_tsquery('english', …)``, or ``None``.
|
||||||
) -> list[RetrievedChunk]:
|
|
||||||
"""Top-*top_k* chunks by pgvector cosine distance (``<=>``).
|
|
||||||
|
|
||||||
``score = 1 − distance``. Results are ordered by ascending distance, so
|
Tokens are lowercased ``[a-z0-9]+`` runs, de-duplicated in order of
|
||||||
index 0 is the best hit. Chunks whose embedding is still NULL (two-phase
|
first appearance. Postgres does the lexing/stemming; a question whose
|
||||||
import in progress) are skipped.
|
tokens are all stopwords lexes to an *empty* tsquery (which matches
|
||||||
|
nothing), so no special-casing is needed there. Pure-symbol questions
|
||||||
|
("???", "🔧") yield no tokens → ``None`` → no lexical query at all.
|
||||||
|
"""
|
||||||
|
seen: set[str] = set()
|
||||||
|
tokens: list[str] = []
|
||||||
|
for tok in _TOKEN_RE.findall(question.lower()):
|
||||||
|
if tok not in seen:
|
||||||
|
seen.add(tok)
|
||||||
|
tokens.append(tok)
|
||||||
|
return " | ".join(tokens) if tokens else None
|
||||||
|
|
||||||
|
|
||||||
|
def fuse(
|
||||||
|
vector: Sequence[RetrievedChunk],
|
||||||
|
lexical: Sequence[RetrievedChunk],
|
||||||
|
k: int,
|
||||||
|
) -> list[RetrievedChunk]:
|
||||||
|
"""Reciprocal Rank Fusion over the two ranked candidate lists.
|
||||||
|
|
||||||
|
``score(chunk) = Σ 1/(k + rank)`` — one term per list the chunk appears
|
||||||
|
in (ranks are 1-based; a chunk in both lists gets both terms). Returns
|
||||||
|
the union ordered by ``(-score, -cosine, document.path, position)``.
|
||||||
|
|
||||||
|
Lexical-only hits (no vector rank) enter with ``cosine=0.0`` and
|
||||||
|
``fts_hit=True``; vector chunks matched by the lexical list get
|
||||||
|
``fts_hit=True`` in place (the input objects are mutated — callers
|
||||||
|
should not reuse them afterwards).
|
||||||
|
"""
|
||||||
|
if k <= 0:
|
||||||
|
raise ValueError("rrf k must be > 0")
|
||||||
|
by_id: dict[uuid.UUID, RetrievedChunk] = {}
|
||||||
|
fused: dict[uuid.UUID, float] = {}
|
||||||
|
for rank, rc in enumerate(vector, start=1):
|
||||||
|
by_id[rc.chunk_id] = rc
|
||||||
|
fused[rc.chunk_id] = fused.get(rc.chunk_id, 0.0) + 1.0 / (k + rank)
|
||||||
|
for rank, rc in enumerate(lexical, start=1):
|
||||||
|
term = 1.0 / (k + rank)
|
||||||
|
if rc.chunk_id in by_id:
|
||||||
|
existing = by_id[rc.chunk_id]
|
||||||
|
by_id[rc.chunk_id] = replace(existing, fts_hit=True)
|
||||||
|
fused[rc.chunk_id] += term
|
||||||
|
else:
|
||||||
|
rc = replace(rc, fts_hit=True)
|
||||||
|
by_id[rc.chunk_id] = rc
|
||||||
|
fused[rc.chunk_id] = fused.get(rc.chunk_id, 0.0) + term
|
||||||
|
out = [replace(rc, score=fused[rc.chunk_id]) for rc in by_id.values()]
|
||||||
|
out.sort(key=lambda rc: (-rc.score, -rc.cosine, rc.document.path, rc.position))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _vector_candidates(
|
||||||
|
db: Session, question_embedding: list[float], limit: int
|
||||||
|
) -> list[RetrievedChunk]:
|
||||||
|
"""Top-*limit* chunks by pgvector cosine distance (``<=>``).
|
||||||
|
|
||||||
|
``cosine = 1 − distance``. Chunks whose embedding is still NULL
|
||||||
|
(two-phase import in progress) are skipped.
|
||||||
"""
|
"""
|
||||||
k = top_k if top_k is not None else get_settings().top_k_chunks
|
|
||||||
distance = Chunk.embedding.cosine_distance(question_embedding)
|
distance = Chunk.embedding.cosine_distance(question_embedding)
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(Chunk, distance.label("distance"), Document)
|
select(Chunk, distance.label("distance"), Document)
|
||||||
.join(Document, Chunk.document_id == Document.id)
|
.join(Document, Chunk.document_id == Document.id)
|
||||||
.where(Chunk.embedding.is_not(None))
|
.where(Chunk.embedding.is_not(None))
|
||||||
.order_by(distance)
|
.order_by(distance)
|
||||||
.limit(k)
|
.limit(limit)
|
||||||
).all()
|
).all()
|
||||||
return [
|
return [
|
||||||
RetrievedChunk(
|
RetrievedChunk(
|
||||||
chunk_id=chunk.id,
|
chunk_id=chunk.id,
|
||||||
position=chunk.position,
|
position=chunk.position,
|
||||||
content=chunk.content,
|
content=chunk.content,
|
||||||
score=round(1.0 - float(dist), 6),
|
score=0.0, # fused score is filled in by :func:`fuse`
|
||||||
document=doc,
|
document=doc,
|
||||||
|
cosine=round(1.0 - float(dist), 6),
|
||||||
)
|
)
|
||||||
for chunk, dist, doc in rows
|
for chunk, dist, doc in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _lexical_candidates(db: Session, question: str, limit: int) -> list[RetrievedChunk]:
|
||||||
|
"""Top-*limit* chunks matching the question's OR-tsquery (A7).
|
||||||
|
|
||||||
|
Ordered by ``ts_rank`` (with ``d.path, c.position`` as the
|
||||||
|
deterministic tie-break); an empty tsquery (stopword-only question)
|
||||||
|
simply matches nothing.
|
||||||
|
"""
|
||||||
|
tsquery = lexical_tsquery(question)
|
||||||
|
if tsquery is None:
|
||||||
|
return []
|
||||||
|
rows = db.execute(
|
||||||
|
_LEXICAL_SQL, {"tsquery": tsquery, "limit": limit}
|
||||||
|
).all()
|
||||||
|
out: list[RetrievedChunk] = []
|
||||||
|
for row in rows:
|
||||||
|
doc = Document(
|
||||||
|
id=row.doc_id,
|
||||||
|
source=row.source,
|
||||||
|
path=row.path,
|
||||||
|
full_path=row.full_path,
|
||||||
|
title=row.title,
|
||||||
|
content=row.doc_content,
|
||||||
|
content_hash=row.content_hash,
|
||||||
|
indexed_at=row.indexed_at,
|
||||||
|
)
|
||||||
|
out.append(
|
||||||
|
RetrievedChunk(
|
||||||
|
chunk_id=row.chunk_id,
|
||||||
|
position=row.position,
|
||||||
|
content=row.content,
|
||||||
|
score=0.0, # filled in by :func:`fuse`
|
||||||
|
document=doc,
|
||||||
|
cosine=0.0, # no vector rank — lexical-only hit
|
||||||
|
fts_hit=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve(
|
||||||
|
db: Session,
|
||||||
|
question: str,
|
||||||
|
question_embedding: list[float],
|
||||||
|
vector_candidates: int | None = None,
|
||||||
|
lexical_candidates: int | None = None,
|
||||||
|
) -> list[RetrievedChunk]:
|
||||||
|
"""Hybrid retrieval (A7): vector top-N ∪ FTS top-N, RRF-fused.
|
||||||
|
|
||||||
|
Returns the fused candidate list in rank order (best first). Each
|
||||||
|
:class:`RetrievedChunk` carries the fused ``score`` (ranking), the
|
||||||
|
``cosine`` similarity (honesty gate) and the ``fts_hit`` flag.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
v_n = (
|
||||||
|
settings.hybrid_vector_candidates if vector_candidates is None else vector_candidates
|
||||||
|
)
|
||||||
|
l_n = (
|
||||||
|
settings.hybrid_lexical_candidates if lexical_candidates is None else lexical_candidates
|
||||||
|
)
|
||||||
|
if v_n <= 0:
|
||||||
|
raise ValueError("vector_candidates must be >= 1")
|
||||||
|
if l_n <= 0:
|
||||||
|
raise ValueError("lexical_candidates must be >= 1")
|
||||||
|
vector = _vector_candidates(db, question_embedding, v_n)
|
||||||
|
lexical = _lexical_candidates(db, question, l_n)
|
||||||
|
return fuse(vector, lexical, settings.rrf_k)
|
||||||
|
|
||||||
|
|
||||||
def weak_hit_titles(chunks: Sequence[RetrievedChunk]) -> list[str]:
|
def weak_hit_titles(chunks: Sequence[RetrievedChunk]) -> list[str]:
|
||||||
"""Distinct parent-document titles of *chunks*, best chunk score first.
|
"""Distinct parent-document titles of *chunks*, best fused score first.
|
||||||
|
|
||||||
Deflection mode (PLAN §6, A8) is built from these *titles only* — the
|
Deflection mode (PLAN §6, A8) is built from these *titles only* — the
|
||||||
LOW prompt and the "Maybe try" chips never see document content.
|
LOW prompt and the "Maybe try" chips never see document content.
|
||||||
@@ -85,7 +256,7 @@ def select_documents(
|
|||||||
n: int | None = None,
|
n: int | None = None,
|
||||||
max_chars: int | None = None,
|
max_chars: int | None = None,
|
||||||
) -> list[Document]:
|
) -> list[Document]:
|
||||||
"""Map chunk hits to distinct parent documents, ranked by best chunk score.
|
"""Map chunk hits to distinct parent documents, ranked by best fused score.
|
||||||
|
|
||||||
At most *n* documents are returned (default ``BOR_TOP_N_DOCS``). The
|
At most *n* documents are returned (default ``BOR_TOP_N_DOCS``). The
|
||||||
returned rows carry the full document content; if the combined content
|
returned rows carry the full document content; if the combined content
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Evaluate hybrid retrieval against the live knowledge base (phase 09).
|
||||||
|
|
||||||
|
Embeds each question via aipi, runs the same hybrid search the chat API
|
||||||
|
uses (cosine top-N + FTS top-N, RRF-fused), and prints the top-5 documents
|
||||||
|
with their cosine / fts / fused scores plus the honesty-gate verdict:
|
||||||
|
|
||||||
|
uv run python -m scripts.eval_retrieval "How did I install gitlab?"
|
||||||
|
uv run python -m scripts.eval_retrieval --from-file questions.txt
|
||||||
|
|
||||||
|
Requires ``AIPI_KEY`` in the environment (same convention as
|
||||||
|
``scripts/llm_probe.py``) and an imported knowledge base
|
||||||
|
(``python -m scripts.import_docs``). Exit code 0 when all questions were
|
||||||
|
scored (deflections are a normal result — the verdict column shows them).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
prog="python -m scripts.eval_retrieval",
|
||||||
|
description="Rank hybrid retrieval results for one or more questions.",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"questions",
|
||||||
|
nargs="*",
|
||||||
|
metavar="QUESTION",
|
||||||
|
help="one or more questions to evaluate",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--from-file",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
metavar="PATH",
|
||||||
|
help="read questions from a file (one per line, blanks/# skipped)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--top",
|
||||||
|
type=int,
|
||||||
|
default=5,
|
||||||
|
help="documents to print per question (default: 5)",
|
||||||
|
)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def read_questions(args: argparse.Namespace) -> list[str]:
|
||||||
|
questions = list(args.questions)
|
||||||
|
if args.from_file:
|
||||||
|
with open(args.from_file, encoding="utf-8") as f:
|
||||||
|
questions.extend(
|
||||||
|
line.strip() for line in f if line.strip() and not line.lstrip().startswith("#")
|
||||||
|
)
|
||||||
|
return questions
|
||||||
|
|
||||||
|
|
||||||
|
async def _embed_all(llm, questions: list[str]) -> list[list[float]]:
|
||||||
|
return await llm.embed(questions)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
api_key = os.environ.get("BOR_LLM_API_KEY") or os.environ.get("AIPI_KEY", "")
|
||||||
|
if not api_key or api_key == "not-needed":
|
||||||
|
print(
|
||||||
|
"eval_retrieval: AIPI_KEY is required in the environment "
|
||||||
|
"(same convention as scripts/llm_probe.py).",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
questions = read_questions(args)
|
||||||
|
if not questions:
|
||||||
|
print("eval_retrieval: no questions given (positional or --from-file).", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db import SessionLocal, db_available
|
||||||
|
from app.rag.llm import LLMClient
|
||||||
|
from app.rag.retriever import RetrievedChunk, retrieve
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
if not db_available():
|
||||||
|
print("eval_retrieval: Postgres is down — run `podman compose up -d db`.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
llm = LLMClient(settings)
|
||||||
|
vectors = asyncio.run(_embed_all(llm, questions))
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"eval: threshold={settings.relevance_threshold} "
|
||||||
|
f"vector_candidates={settings.hybrid_vector_candidates} "
|
||||||
|
f"lexical_candidates={settings.hybrid_lexical_candidates} rrf_k={settings.rrf_k}"
|
||||||
|
)
|
||||||
|
with SessionLocal() as db:
|
||||||
|
for question, vec in zip(questions, vectors, strict=True):
|
||||||
|
chunks = retrieve(db, question, vec)
|
||||||
|
best_cosine = max((c.cosine for c in chunks), default=0.0)
|
||||||
|
fts_hits = sum(1 for c in chunks if c.fts_hit)
|
||||||
|
verdict = (
|
||||||
|
"LOW (deflect)"
|
||||||
|
if best_cosine < settings.relevance_threshold and fts_hits == 0
|
||||||
|
else "HIGH (answer)"
|
||||||
|
)
|
||||||
|
print(f"\nquestion: {question!r}")
|
||||||
|
print(f" gate: best_cosine={best_cosine:.4f} fts_hits={fts_hits} -> {verdict}")
|
||||||
|
# Best chunk per document, in fused rank order.
|
||||||
|
best_by_doc: dict[str, RetrievedChunk] = {}
|
||||||
|
for c in chunks:
|
||||||
|
key = f"{c.document.source}/{c.document.path}"
|
||||||
|
if key not in best_by_doc:
|
||||||
|
best_by_doc[key] = c
|
||||||
|
for i, c in enumerate(list(best_by_doc.values())[: args.top], start=1):
|
||||||
|
print(
|
||||||
|
f" {i}. {c.document.source}/{c.document.path} "
|
||||||
|
f"cosine={c.cosine:.4f} fts={int(c.fts_hit)} fused={c.score:.5f} "
|
||||||
|
f"({c.document.title})"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+12
-8
@@ -1,16 +1,20 @@
|
|||||||
"""Import markdown directories into the Brain of Reese knowledge base.
|
"""Import A9-format directories into the Brain of Reese knowledge base.
|
||||||
|
|
||||||
Examples::
|
Examples::
|
||||||
|
|
||||||
uv run python -m scripts.import_docs # ~/Homelab + ~/Deployments
|
uv run python -m scripts.import_docs # ~/Homelab + ~/Deployments
|
||||||
uv run python -m scripts.import_docs --source ~/OtherDocs # extra dir (repeatable)
|
uv run python -m scripts.import_docs --source ~/OtherDocs # extra dir (repeatable)
|
||||||
uv run python -m scripts.import_docs --prune # also drop deleted files
|
uv run python -m scripts.import_docs --prune # also drop deleted/out-of-scope files
|
||||||
uv run python -m scripts.import_docs --limit 5 # debug: first 5 files only
|
uv run python -m scripts.import_docs --limit 5 # debug: first 5 files only
|
||||||
|
|
||||||
Only ``*.md`` files are imported; non-content dirs (``.venv``,
|
Imported formats (PLAN anchor A9, revised): ``md, markdown, txt, yaml,
|
||||||
|
yml, json, py`` (case-insensitive; narrow with ``BOR_IMPORT_EXTENSIONS``).
|
||||||
|
Any path with a dot-prefixed component (hidden files/dirs — vendored
|
||||||
|
caches) is skipped, along with non-content dirs (``.venv``,
|
||||||
``node_modules``, ``.git``, ``__pycache__``, ``.pytest_cache``, ``dist``,
|
``node_modules``, ``.git``, ``__pycache__``, ``.pytest_cache``, ``dist``,
|
||||||
``build``) are skipped (PLAN anchor A9). Re-runs are cheap: files are
|
``build``). Re-runs are cheap: files are diffed by sha256 and unchanged
|
||||||
diffed by sha256 and unchanged ones are not re-embedded.
|
ones are not re-embedded; ``--prune`` also drops documents whose files no
|
||||||
|
longer match the format filter.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -31,7 +35,7 @@ DEFAULT_SOURCES: list[Path] = [Path("~/Homelab"), Path("~/Deployments")]
|
|||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
p = argparse.ArgumentParser(
|
p = argparse.ArgumentParser(
|
||||||
prog="python -m scripts.import_docs",
|
prog="python -m scripts.import_docs",
|
||||||
description="Import *.md files into the Brain of Reese knowledge base.",
|
description="Import A9-format files (md/txt/yaml/json/py) into the knowledge base.",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--source",
|
"--source",
|
||||||
@@ -43,7 +47,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--prune",
|
"--prune",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="also delete documents whose files no longer exist",
|
help="also delete documents whose files no longer exist or match the format filter",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--limit",
|
"--limit",
|
||||||
@@ -74,7 +78,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
f"import_docs: files={summary.files} added={summary.added} "
|
f"import_docs: files={summary.files} added={summary.added} "
|
||||||
f"updated={summary.updated} unchanged={summary.unchanged} "
|
f"updated={summary.updated} unchanged={summary.unchanged} "
|
||||||
f"pruned={summary.pruned} errors={summary.errors} chunks={summary.chunks} "
|
f"pruned={summary.pruned} errors={summary.errors} chunks={summary.chunks} "
|
||||||
f"embed_batches={summary.embed_batches}"
|
f"embed_batches={summary.embed_batches} formats={summary.format_counts()}"
|
||||||
)
|
)
|
||||||
# Non-zero if any file failed, so cron/CI notice — the rest of the KB
|
# Non-zero if any file failed, so cron/CI notice — the rest of the KB
|
||||||
# was imported and the failed files are retried on the next run.
|
# was imported and the failed files are retried on the next run.
|
||||||
|
|||||||
+10
-2
@@ -1,14 +1,22 @@
|
|||||||
"""Shared fixtures for unit + integration tests."""
|
"""Shared fixtures for unit + integration tests."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import SessionLocal, db_available
|
# In-process integration/E2E tests drive the app with *mock* embeddings
|
||||||
from app.main import app as fastapi_app
|
# (bag-of-words, cosine ~0.1–0.8), not the live aipi model — so the honesty
|
||||||
|
# gate is calibrated to the mock's distribution, mirroring tests/e2e/
|
||||||
|
# conftest.py. Must be set before ``app.main`` (below) caches settings.
|
||||||
|
# The production default stays 0.62 (app/config.py, A8 revised).
|
||||||
|
os.environ.setdefault("BOR_RELEVANCE_THRESHOLD", "0.30")
|
||||||
|
|
||||||
|
from app.db import SessionLocal, db_available # noqa: E402
|
||||||
|
from app.main import app as fastapi_app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ def app_server(mock_llm: int) -> Iterator[str]:
|
|||||||
if USE_REAL_LLM
|
if USE_REAL_LLM
|
||||||
else f"http://127.0.0.1:{mock_llm}/v1"
|
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||||
)
|
)
|
||||||
|
# The E2E mock's token-overlap embeddings have their own score
|
||||||
|
# distribution (phase 09) — the app under test gets the mock-calibrated
|
||||||
|
# threshold so every story suite keeps its deterministic gate behavior.
|
||||||
|
# The production default stays 0.62 (re-tuned against the real
|
||||||
|
# `embed` model's 0.41–0.84 cosine range, PLAN A8).
|
||||||
|
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||||
env.setdefault("BOR_DATABASE_URL", "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese")
|
env.setdefault("BOR_DATABASE_URL", "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese")
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ def test_on_topic_question_streams_grounded_answer(
|
|||||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
summary = _reset_db(mock_llm, seed=True)
|
summary = _reset_db(mock_llm, seed=True)
|
||||||
assert summary is not None and summary.added == 3
|
assert summary is not None and summary.added == 8 # A9 formats
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
page.goto(app_url)
|
page.goto(app_url)
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ def _seed_kb(mock_port: int) -> ImportSummary:
|
|||||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||||
db.commit()
|
db.commit()
|
||||||
summary = _run_in_thread(_import_fixtures(mock_port))
|
summary = _run_in_thread(_import_fixtures(mock_port))
|
||||||
assert summary is not None and summary.added == 3
|
assert summary is not None and summary.added == 8 # A9 formats
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ def test_off_topic_question_deflects_honestly(
|
|||||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
summary = _reset_db(mock_llm, seed=True)
|
summary = _reset_db(mock_llm, seed=True)
|
||||||
assert summary is not None and summary.added == 3
|
assert summary is not None and summary.added == 8 # A9 formats
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
page.goto(app_url)
|
page.goto(app_url)
|
||||||
expect(page.locator("#kb-banner")).to_be_hidden()
|
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ EXPECTED_ROWS = (
|
|||||||
"homelab/kubernetes.md",
|
"homelab/kubernetes.md",
|
||||||
"homelab/backups.md",
|
"homelab/backups.md",
|
||||||
"deployments/new-service.md",
|
"deployments/new-service.md",
|
||||||
|
"homelab/container_gitlab/gitlab.md",
|
||||||
|
"homelab/container_gitlab/gitlab-compose.yaml",
|
||||||
|
"homelab/networking/static-dns.json",
|
||||||
|
"homelab/scripts/uptime_probe.py",
|
||||||
|
"homelab/ssh/ssh_aliases.txt",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -76,16 +81,21 @@ def test_sources_page_lists_indexed_docs(
|
|||||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
summary = _reset_db(mock_llm, seed=True)
|
summary = _reset_db(mock_llm, seed=True)
|
||||||
assert summary is not None and summary.added == 3
|
# Eight A9-format files are imported; .hidden/junk.md is out of scope
|
||||||
|
# (A9 revised — hidden path components are never walked).
|
||||||
|
assert summary is not None and summary.added == 8
|
||||||
|
assert summary.formats == {"md": 4, "yaml": 1, "json": 1, "py": 1, "txt": 1}
|
||||||
|
|
||||||
page.goto(f"{app_url}/sources.html")
|
page.goto(f"{app_url}/sources.html")
|
||||||
expect(page.locator("#stat-docs")).to_have_text("3")
|
expect(page.locator("#stat-docs")).to_have_text("8")
|
||||||
expect(page.locator("#stat-chunks")).to_have_text(str(summary.chunks))
|
expect(page.locator("#stat-chunks")).to_have_text(str(summary.chunks))
|
||||||
expect(page.locator("#stat-last")).not_to_have_text("–")
|
expect(page.locator("#stat-last")).not_to_have_text("–")
|
||||||
expect(page.locator("#sources-empty")).to_be_hidden()
|
expect(page.locator("#sources-empty")).to_be_hidden()
|
||||||
|
|
||||||
for row_path in EXPECTED_ROWS:
|
for row_path in EXPECTED_ROWS:
|
||||||
expect(page.locator("#docs-tbody tr", has_text=row_path)).to_have_count(1)
|
expect(page.locator("#docs-tbody tr", has_text=row_path)).to_have_count(1)
|
||||||
|
# The hidden junk was never indexed (A9 scope).
|
||||||
|
expect(page.locator("#docs-tbody tr", has_text=".hidden")).to_have_count(0)
|
||||||
# The path column carries the full path for hover (ellipsis is visual only).
|
# The path column carries the full path for hover (ellipsis is visual only).
|
||||||
expect(page.locator("#docs-tbody tr", has_text="homelab/kubernetes.md")
|
expect(page.locator("#docs-tbody tr", has_text="homelab/kubernetes.md")
|
||||||
.get_by_role("cell").nth(1)).to_have_attribute("title", "homelab/kubernetes.md")
|
.get_by_role("cell").nth(1)).to_have_attribute("title", "homelab/kubernetes.md")
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ def test_typing_indicator_during_slow_think(
|
|||||||
"""AC1/AC5: the 3s mock warm-up must show the typing indicator for
|
"""AC1/AC5: the 3s mock warm-up must show the typing indicator for
|
||||||
>=2s before any text appears, then it is gone once the answer lands."""
|
>=2s before any text appears, then it is gone once the answer lands."""
|
||||||
summary = _reset_db(mock_llm, seed=True)
|
summary = _reset_db(mock_llm, seed=True)
|
||||||
assert summary is not None and summary.added == 3
|
assert summary is not None and summary.added == 8 # A9 formats
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
page.goto(app_url)
|
page.goto(app_url)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Phase 09 E2E (Playwright): retrieval quality — hybrid search end to end.
|
||||||
|
|
||||||
|
Story: ``.agent/user_stories/retrieval-quality.md``
|
||||||
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||||
|
|
||||||
|
uv run pytest tests/e2e/test_retrieval_quality.py -v --no-cov
|
||||||
|
|
||||||
|
Seeding reuses the real importer against ``tests/fixtures/docs/`` with the
|
||||||
|
deterministic mock embeddings (same pattern as the earlier story suites).
|
||||||
|
The four tests map the story's acceptance criteria:
|
||||||
|
|
||||||
|
1. multi-format fixture import — hidden doc excluded, ``/api/docs`` counts
|
||||||
|
2. "How did I install gitlab?" — grounded (not deflected), gitlab chip,
|
||||||
|
``query_log`` row with the gitlab doc in ``sources``
|
||||||
|
3. keyword-only question ("kafkabridge") beats the vector ranking — the
|
||||||
|
FTS-OR gate grounds it end to end despite weak cosine
|
||||||
|
4. "sourdough" — deflected bubble + ≥2 "Maybe try" chips
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Thread
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
|
from app.config import Settings, get_settings
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import QueryLog
|
||||||
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
|
from app.rag.llm import LLMClient
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||||
|
GITLAB_QUESTION = "How did I install gitlab?"
|
||||||
|
KEYWORD_QUESTION = "How does kafkabridge work?"
|
||||||
|
OFF_TOPIC = "sourdough starter"
|
||||||
|
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||||
|
|
||||||
|
|
||||||
|
async def _import_fixtures(mock_port: int) -> ImportSummary:
|
||||||
|
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
|
||||||
|
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||||
|
return await import_sources([FIXTURES], LLMClient(settings))
|
||||||
|
|
||||||
|
|
||||||
|
def _run_in_thread(coro: Any) -> Any:
|
||||||
|
"""Run a coroutine on a worker thread (Playwright owns the test loop)."""
|
||||||
|
box: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def runner() -> None:
|
||||||
|
try:
|
||||||
|
box["value"] = asyncio.run(coro)
|
||||||
|
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
|
||||||
|
box["error"] = e
|
||||||
|
|
||||||
|
t = Thread(target=runner)
|
||||||
|
t.start()
|
||||||
|
t.join()
|
||||||
|
if "error" in box:
|
||||||
|
raise box["error"]
|
||||||
|
return box["value"]
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
|
||||||
|
"""Truncate the KB (and query log), then optionally re-import fixtures."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||||
|
db.commit()
|
||||||
|
if not seed:
|
||||||
|
return None
|
||||||
|
return _run_in_thread(_import_fixtures(mock_port))
|
||||||
|
|
||||||
|
|
||||||
|
def _ask(page: Page, message: str) -> None:
|
||||||
|
page.fill("#message-input", message)
|
||||||
|
page.click("#send-btn")
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_format_import_hidden_doc_excluded(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""A9 (revised): all seven formats import; hidden (dot) paths never do."""
|
||||||
|
summary = _reset_db(mock_llm, seed=True)
|
||||||
|
assert summary is not None
|
||||||
|
# Eight A9-format fixture files; .hidden/junk.md must never be walked.
|
||||||
|
assert summary.added == 8
|
||||||
|
assert summary.formats == {"md": 4, "yaml": 1, "json": 1, "py": 1, "txt": 1}
|
||||||
|
|
||||||
|
r = httpx.get(f"{app_url}/api/docs", timeout=10)
|
||||||
|
assert r.status_code == 200
|
||||||
|
docs = r.json()["documents"]
|
||||||
|
assert len(docs) == 8
|
||||||
|
assert all(".hidden" not in d["path"] for d in docs)
|
||||||
|
assert {d["path"] for d in docs} >= {
|
||||||
|
"homelab/container_gitlab/gitlab.md",
|
||||||
|
"homelab/container_gitlab/gitlab-compose.yaml",
|
||||||
|
"homelab/networking/static-dns.json",
|
||||||
|
"homelab/scripts/uptime_probe.py",
|
||||||
|
"homelab/ssh/ssh_aliases.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
# The Sources page reflects the same set.
|
||||||
|
page.goto(f"{app_url}/sources.html")
|
||||||
|
expect(page.locator("#stat-docs")).to_have_text("8")
|
||||||
|
expect(page.locator("#docs-tbody tr", has_text=".hidden")).to_have_count(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitlab_question_is_grounded_with_gitlab_chip(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""The ranking problem that motivated this phase: a tool-name question
|
||||||
|
must land on the tool's own document — not a generic template."""
|
||||||
|
_reset_db(mock_llm, seed=True)
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
page.goto(app_url)
|
||||||
|
|
||||||
|
_ask(page, GITLAB_QUESTION)
|
||||||
|
expect(page.locator(".msg.user .bubble")).to_contain_text(GITLAB_QUESTION)
|
||||||
|
|
||||||
|
bubble = page.locator(".msg.brain .bubble").first
|
||||||
|
bubble.wait_for(state="visible", timeout=30_000)
|
||||||
|
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||||
|
# Grounded: no deflected bubble at all.
|
||||||
|
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
|
||||||
|
|
||||||
|
# The gitlab document is cited (a chip carrying its path).
|
||||||
|
chip = page.locator(".msg.brain .source-chip", has_text="container_gitlab/gitlab.md")
|
||||||
|
expect(chip).to_have_count(1, timeout=30_000)
|
||||||
|
|
||||||
|
# Durable record: not deflected, and the gitlab doc is in sources.
|
||||||
|
with SessionLocal() as db:
|
||||||
|
row = db.scalars(select(QueryLog)).one()
|
||||||
|
assert row.question == GITLAB_QUESTION
|
||||||
|
assert row.deflected is False
|
||||||
|
assert "container_gitlab/gitlab.md" in row.sources
|
||||||
|
|
||||||
|
|
||||||
|
def test_keyword_only_question_beats_vector_ranking(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""The FTS-OR gate end to end: "kafkabridge" appears in exactly one
|
||||||
|
fixture doc (static-dns.json) and the question's cosine overlap is
|
||||||
|
weak — the lexical branch is what grounds the answer."""
|
||||||
|
_reset_db(mock_llm, seed=True)
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
page.goto(app_url)
|
||||||
|
|
||||||
|
_ask(page, KEYWORD_QUESTION)
|
||||||
|
bubble = page.locator(".msg.brain .bubble").first
|
||||||
|
bubble.wait_for(state="visible", timeout=30_000)
|
||||||
|
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||||
|
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
|
||||||
|
|
||||||
|
# The FTS-matched doc is the TOP source chip (it beats the vector rank).
|
||||||
|
first_chip = page.locator(".msg.brain .source-chip").first
|
||||||
|
first_chip.wait_for(state="visible", timeout=30_000)
|
||||||
|
expect(first_chip).to_contain_text("static-dns.json")
|
||||||
|
|
||||||
|
with SessionLocal() as db:
|
||||||
|
row = db.scalars(select(QueryLog)).one()
|
||||||
|
# Weak vector score…
|
||||||
|
assert row.top_score < get_settings().relevance_threshold
|
||||||
|
# …but a lexical hit grounded it (the FTS-OR branch).
|
||||||
|
assert (row.fts_hits or 0) >= 1
|
||||||
|
assert row.deflected is False
|
||||||
|
assert "homelab/networking/static-dns.json" in row.sources
|
||||||
|
|
||||||
|
|
||||||
|
def test_off_topic_still_deflects_with_chips(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""A8 (revised): deflection requires weak cosine AND zero FTS hits.
|
||||||
|
"sourdough" matches nothing in the KB lexically → honest deflection."""
|
||||||
|
_reset_db(mock_llm, seed=True)
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
page.goto(app_url)
|
||||||
|
|
||||||
|
_ask(page, OFF_TOPIC)
|
||||||
|
bubble = page.locator(".msg.brain.is-deflected .bubble").first
|
||||||
|
bubble.wait_for(state="visible", timeout=30_000)
|
||||||
|
expect(page.locator(".msg.brain.is-deflected")).to_have_count(1)
|
||||||
|
|
||||||
|
chips = page.locator(".msg.brain.is-deflected .maybe-try .suggestion-chip")
|
||||||
|
expect(chips.first).to_be_visible(timeout=30_000)
|
||||||
|
assert chips.count() >= 2, "deflection must offer 2-3 alternative chips"
|
||||||
|
assert all(c.strip() for c in chips.all_inner_texts())
|
||||||
|
|
||||||
|
with SessionLocal() as db:
|
||||||
|
row = db.scalars(select(QueryLog)).one()
|
||||||
|
assert row.question == OFF_TOPIC
|
||||||
|
assert row.deflected is True
|
||||||
|
assert 0.0 < row.top_score < get_settings().relevance_threshold
|
||||||
|
assert row.fts_hits == 0 # deflection is only reached with zero hits
|
||||||
@@ -63,7 +63,7 @@ def _seed_kb(mock_port: int) -> ImportSummary:
|
|||||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||||
db.commit()
|
db.commit()
|
||||||
summary = _run_in_thread(_import_fixtures(mock_port))
|
summary = _run_in_thread(_import_fixtures(mock_port))
|
||||||
assert summary is not None and summary.added == 3
|
assert summary is not None and summary.added == 8 # A9 formats
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
# Vendored Junk
|
||||||
|
|
||||||
|
This file lives under a dot-prefixed directory and must **never** be
|
||||||
|
imported into the knowledge base (A9 hidden-dir skip). It exists so the
|
||||||
|
retrieval-quality E2E can prove the filter works.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# gitlab stack — single container + gitlab-data volume
|
||||||
|
services:
|
||||||
|
gitlab:
|
||||||
|
image: gitlab/gitlab-ce:17.2.1-ce.0
|
||||||
|
container_name: gitlab
|
||||||
|
restart: unless-stopped
|
||||||
|
hostname: "gitlab.reeseapps.com"
|
||||||
|
environment:
|
||||||
|
GITLAB_OMNIBUS_CONFIG: |
|
||||||
|
external_url 'https://gitlab.reeseapps.com'
|
||||||
|
gitlab_rails['gitlab_shell_ssh_port'] = 2222
|
||||||
|
ports:
|
||||||
|
- "8929:80"
|
||||||
|
- "2222:22"
|
||||||
|
volumes:
|
||||||
|
- gitlab-data:/var/opt/gitlab
|
||||||
|
shm_size: "256m"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 4G
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gitlab-data:
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Gitlab
|
||||||
|
|
||||||
|
Gitlab CE runs as a single Docker container on the `gitlab` host
|
||||||
|
(`10.0.1.14`), managed by Ansible (`deployments/gitlab/`).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. Install Docker and the compose plugin on the host.
|
||||||
|
2. Create the `gitlab-data` volume: `docker volume create gitlab-data`.
|
||||||
|
3. Run the stack from `gitlab-compose.yaml`:
|
||||||
|
`docker compose -f gitlab-compose.yaml up -d`
|
||||||
|
4. Wait ~2 minutes for the initial gitlab migration to finish.
|
||||||
|
|
||||||
|
## Access
|
||||||
|
|
||||||
|
- Web UI: https://gitlab.reeseapps.com (Traefik routes it to port 8929).
|
||||||
|
- Root password: `gitlab-root-password` file in the repo (rotated yearly).
|
||||||
|
- Backup: nightly `gitlab-backup create` at 03:30, copy to BorgBase.
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
- Upgrade gitlab: bump the image tag in the compose file,
|
||||||
|
`docker compose up -d gitlab`, watch the logs for the version banner.
|
||||||
|
- Logs: `docker logs -f gitlab` or the gitlab admin area → Admin
|
||||||
|
area → Logs.
|
||||||
|
- If the container is OOM-killed, raise the memory limit in the compose
|
||||||
|
file (it needs 4GB free).
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"comment": "Static DNS overrides for the homelab Pi-hole (applied by the ddns updater).",
|
||||||
|
"hosts": {
|
||||||
|
"kafkabridge": "10.0.3.7",
|
||||||
|
"k3s-control": "10.0.1.10",
|
||||||
|
"gitea": "10.0.2.21",
|
||||||
|
"ntfy": "10.0.2.30"
|
||||||
|
},
|
||||||
|
"domains": [
|
||||||
|
"reeseapps.com",
|
||||||
|
"homelab.lan"
|
||||||
|
],
|
||||||
|
"expiry_days": 365
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Uptime probe — the homelab healthcheck runner.
|
||||||
|
|
||||||
|
Polls every service listed in ``CHECKS`` every 5 minutes and posts a
|
||||||
|
failure to the ntfy topic ``homelab-alerts``.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
#: (name, health URL) for every long-running service.
|
||||||
|
CHECKS: list[tuple[str, str]] = [
|
||||||
|
("k3s", "https://10.0.1.10:6443/healthz"),
|
||||||
|
("gitea", "https://gitea.reeseapps.com/api/healthz"),
|
||||||
|
("ntfy", "https://ntfy.reeseapps.com/health"),
|
||||||
|
("gitlab", "https://gitlab.reeseapps.com/-/health_check"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def probe(name: str, url: str) -> bool:
|
||||||
|
"""One HTTP check; returns True when the service answered 200."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "10", url],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.stdout.strip() == "200"
|
||||||
|
|
||||||
|
|
||||||
|
def notify_failure(name: str) -> None:
|
||||||
|
"""Push an alert to ntfy (best effort — alerting must not crash the probe)."""
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"curl",
|
||||||
|
"-s",
|
||||||
|
"-X",
|
||||||
|
"POST",
|
||||||
|
"https://ntfy.reeseapps.com/homelab-alerts",
|
||||||
|
"-H",
|
||||||
|
"Title: homelab check failed",
|
||||||
|
"-d",
|
||||||
|
f"{name} is down",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_round() -> int:
|
||||||
|
"""Probe everything once; returns the number of failing services."""
|
||||||
|
failed = 0
|
||||||
|
for name, url in CHECKS:
|
||||||
|
if not probe(name, url):
|
||||||
|
failed += 1
|
||||||
|
notify_failure(name)
|
||||||
|
return failed
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.exit(run_round())
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
SSH notes for the homelab jump host.
|
||||||
|
|
||||||
|
All admin hosts are reachable through the jump box at 10.0.1.2
|
||||||
|
(`ssh reese@jump`). The `~/.ssh/config` aliases:
|
||||||
|
|
||||||
|
k3s — the kubernetes control plane node (10.0.1.10, user talos)
|
||||||
|
gitlab — the gitlab container host (10.0.1.14)
|
||||||
|
nuc — the low-power media box (10.0.1.20)
|
||||||
|
|
||||||
|
Keys: ed25519 per host, no passwords. The old RSA key was retired in
|
||||||
|
2025 and its line removed from authorized_keys on every host.
|
||||||
|
|
||||||
|
Forwarding X11 stays off everywhere; use `ssh -L` port forwards for the
|
||||||
|
occasional GUI tool instead.
|
||||||
@@ -94,7 +94,7 @@ def seeded_kb(db) -> Iterator[FakeRagLLM]:
|
|||||||
db.commit()
|
db.commit()
|
||||||
llm = FakeRagLLM()
|
llm = FakeRagLLM()
|
||||||
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||||
assert summary.added == 3
|
assert summary.added == 8 # A9 formats; .hidden/ skipped
|
||||||
yield llm
|
yield llm
|
||||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -163,12 +163,19 @@ def test_chat_writes_query_log_row(client, db, seeded_kb: FakeRagLLM) -> None:
|
|||||||
assert row.question == QUESTION
|
assert row.question == QUESTION
|
||||||
assert row.deflected is False
|
assert row.deflected is False
|
||||||
total_chunks = db.scalar(select(func.count()).select_from(Chunk))
|
total_chunks = db.scalar(select(func.count()).select_from(Chunk))
|
||||||
assert row.chunk_hits == min(get_settings().top_k_chunks, total_chunks)
|
# chunk_hits is the fused candidate set (cosine top-N ∪ FTS top-N).
|
||||||
|
assert 1 <= row.chunk_hits <= total_chunks
|
||||||
assert row.top_score > 0.0 # genuine token-overlap cosine, best hit
|
assert row.top_score > 0.0 # genuine token-overlap cosine, best hit
|
||||||
assert row.top_score >= get_settings().relevance_threshold # why the gate answered
|
|
||||||
assert row.top_score <= 1.0
|
assert row.top_score <= 1.0
|
||||||
assert "docs/homelab/kubernetes.md" in row.sources
|
assert "docs/homelab/kubernetes.md" in row.sources
|
||||||
assert row.latency_ms >= 0
|
assert row.latency_ms >= 0
|
||||||
|
# Why the gate answered (A8 revised): cosine over the threshold OR a
|
||||||
|
# lexical hit. The mock-calibrated threshold (0.30, see tests/conftest.py)
|
||||||
|
# makes the cosine branch true here; the FTS branch is covered too —
|
||||||
|
# "kubernetes" / "cluster" match the doc's tsvector.
|
||||||
|
thr = get_settings().relevance_threshold
|
||||||
|
assert row.top_score >= thr or (row.fts_hits or 0) > 0
|
||||||
|
assert (row.fts_hits or 0) >= 1 # the lexical branch really fired
|
||||||
|
|
||||||
|
|
||||||
def test_off_topic_question_deflects_honestly(client, db, seeded_kb: FakeRagLLM) -> None:
|
def test_off_topic_question_deflects_honestly(client, db, seeded_kb: FakeRagLLM) -> None:
|
||||||
@@ -202,14 +209,46 @@ def test_off_topic_question_deflects_honestly(client, db, seeded_kb: FakeRagLLM)
|
|||||||
assert "Talos Linux" not in system["content"] # full doc content never sent
|
assert "Talos Linux" not in system["content"] # full doc content never sent
|
||||||
assert "<documents>" not in system["content"]
|
assert "<documents>" not in system["content"]
|
||||||
|
|
||||||
# Durable record: deflected=true + the weak top_score.
|
# Durable record: deflected=true + the weak top_score. Deflection is
|
||||||
|
# only reached when the cosine is under the threshold AND no chunk
|
||||||
|
# FTS-matches the question — so fts_hits must be zero here.
|
||||||
row = db.scalars(select(QueryLog)).one()
|
row = db.scalars(select(QueryLog)).one()
|
||||||
assert row.question == OFF_TOPIC
|
assert row.question == OFF_TOPIC
|
||||||
assert row.deflected is True
|
assert row.deflected is True
|
||||||
assert 0.0 < row.top_score < get_settings().relevance_threshold
|
assert 0.0 < row.top_score < get_settings().relevance_threshold
|
||||||
|
assert row.fts_hits == 0
|
||||||
assert row.chunk_hits >= 1
|
assert row.chunk_hits >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_keyword_question_grounded_by_lexical_hit_despite_weak_cosine(
|
||||||
|
client, db, seeded_kb: FakeRagLLM
|
||||||
|
) -> None:
|
||||||
|
"""Phase 09: a name-your-tool question the vector model barely ranks
|
||||||
|
("kafkabridge" only appears in static-dns.json) must still be grounded
|
||||||
|
via the FTS branch — LOW only fires at weak cosine AND zero hits."""
|
||||||
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||||
|
try:
|
||||||
|
_, _, frames = _stream_chat(client, "How does kafkabridge work?")
|
||||||
|
finally:
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
done = frames[-1]
|
||||||
|
assert done["type"] == "done"
|
||||||
|
assert done["deflected"] is False # weak cosine, but a lexical hit
|
||||||
|
assert done["suggestions"] == []
|
||||||
|
sources = done["sources"]
|
||||||
|
assert sources and sources[0]["path"] == "homelab/networking/static-dns.json"
|
||||||
|
|
||||||
|
(system, _user) = seeded_kb.seen_messages[0][0], seeded_kb.seen_messages[0][1]
|
||||||
|
assert "<relevance>HIGH</relevance>" in system["content"] # grounded prompt
|
||||||
|
|
||||||
|
row = db.scalars(select(QueryLog)).one()
|
||||||
|
assert row.deflected is False
|
||||||
|
assert row.top_score < get_settings().relevance_threshold # weak vector score
|
||||||
|
assert (row.fts_hits or 0) >= 1 # …and it is the FTS hit that grounds it
|
||||||
|
assert "docs/homelab/networking/static-dns.json" in row.sources
|
||||||
|
|
||||||
|
|
||||||
def test_chat_empty_kb_streams_empty_sources(client, db) -> None:
|
def test_chat_empty_kb_streams_empty_sources(client, db) -> None:
|
||||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ EXPECTED_DOCS = {
|
|||||||
("docs", "homelab/kubernetes.md"),
|
("docs", "homelab/kubernetes.md"),
|
||||||
("docs", "homelab/backups.md"),
|
("docs", "homelab/backups.md"),
|
||||||
("docs", "deployments/new-service.md"),
|
("docs", "deployments/new-service.md"),
|
||||||
|
("docs", "homelab/container_gitlab/gitlab.md"),
|
||||||
|
("docs", "homelab/container_gitlab/gitlab-compose.yaml"),
|
||||||
|
("docs", "homelab/networking/static-dns.json"),
|
||||||
|
("docs", "homelab/scripts/uptime_probe.py"),
|
||||||
|
("docs", "homelab/ssh/ssh_aliases.txt"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -31,14 +36,27 @@ def test_import_fixtures_end_to_end(client, db) -> None:
|
|||||||
llm = FakeEmbedder()
|
llm = FakeEmbedder()
|
||||||
|
|
||||||
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||||
assert (summary.files, summary.added, summary.unchanged) == (3, 3, 0)
|
# Eight A9-format files; .hidden/junk.md is out of scope (A9 revised).
|
||||||
assert summary.chunks >= 3
|
assert (summary.files, summary.added, summary.unchanged) == (8, 8, 0)
|
||||||
|
assert summary.chunks >= 8
|
||||||
|
assert summary.formats == {"md": 4, "yaml": 1, "json": 1, "py": 1, "txt": 1}
|
||||||
|
# PLAN §9 per-format summary line: highest count first, then alpha.
|
||||||
|
assert summary.format_counts() == "md:4,json:1,py:1,txt:1,yaml:1"
|
||||||
|
|
||||||
docs = db.scalars(select(Document)).all()
|
docs = db.scalars(select(Document)).all()
|
||||||
assert {(d.source, d.path) for d in docs} == EXPECTED_DOCS
|
assert {(d.source, d.path) for d in docs} == EXPECTED_DOCS
|
||||||
titles = {d.path: d.title for d in docs}
|
titles = {d.path: d.title for d in docs}
|
||||||
assert titles["homelab/kubernetes.md"] == "Kubernetes Homelab Cluster"
|
assert titles["homelab/kubernetes.md"] == "Kubernetes Homelab Cluster"
|
||||||
assert titles["deployments/new-service.md"] == "Deploying a New Service"
|
assert titles["deployments/new-service.md"] == "Deploying a New Service"
|
||||||
|
assert titles["homelab/container_gitlab/gitlab.md"] == "Gitlab"
|
||||||
|
# Non-markdown titles come from the file stem (a leading ``#`` or docstring
|
||||||
|
# line is a comment there, not a heading).
|
||||||
|
assert titles["homelab/container_gitlab/gitlab-compose.yaml"] == "gitlab-compose"
|
||||||
|
assert titles["homelab/scripts/uptime_probe.py"] == "uptime_probe"
|
||||||
|
assert titles["homelab/networking/static-dns.json"] == "static-dns"
|
||||||
|
assert titles["homelab/ssh/ssh_aliases.txt"] == "ssh_aliases"
|
||||||
|
# Hidden junk was never imported.
|
||||||
|
assert not any(".hidden" in d.path for d in docs)
|
||||||
# Full content is stored — that is what the RAG context will be.
|
# Full content is stored — that is what the RAG context will be.
|
||||||
k8s = next(d for d in docs if d.path == "homelab/kubernetes.md")
|
k8s = next(d for d in docs if d.path == "homelab/kubernetes.md")
|
||||||
assert "Talos Linux" in k8s.content and k8s.content_hash
|
assert "Talos Linux" in k8s.content and k8s.content_hash
|
||||||
@@ -52,13 +70,13 @@ def test_import_fixtures_end_to_end(client, db) -> None:
|
|||||||
r = client.get("/api/docs")
|
r = client.get("/api/docs")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert len(body["documents"]) == 3
|
assert len(body["documents"]) == 8
|
||||||
assert all(d["chunks"] >= 1 for d in body["documents"])
|
assert all(d["chunks"] >= 1 for d in body["documents"])
|
||||||
|
|
||||||
# Idempotent re-run: nothing re-embedded.
|
# Idempotent re-run: nothing re-embedded.
|
||||||
calls_before = len(llm.calls)
|
calls_before = len(llm.calls)
|
||||||
s2 = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
s2 = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||||
assert s2.unchanged == 3 and s2.added == 0
|
assert s2.unchanged == 8 and s2.added == 0
|
||||||
assert len(llm.calls) == calls_before # unchanged → no embedding requests
|
assert len(llm.calls) == calls_before # unchanged → no embedding requests
|
||||||
|
|
||||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Integration: migration 0002 (hybrid retrieval) schema contract.
|
||||||
|
|
||||||
|
Asserts the state the migration must leave on the live schema:
|
||||||
|
``chunks.tsv`` as a stored generated tsvector, its GIN index, and the
|
||||||
|
nullable ``query_log.fts_hits`` column (pre-0002 rows stay NULL, so it
|
||||||
|
must accept NULL and an int). Requires ``podman compose up -d db``.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_0002_schema_contract(db) -> None:
|
||||||
|
tsv_col = db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT count(*) FROM information_schema.columns"
|
||||||
|
" WHERE table_name = 'chunks' AND column_name = 'tsv'"
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
assert tsv_col == 1, "chunks.tsv (stored tsvector) is missing"
|
||||||
|
|
||||||
|
gin = db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT count(*) FROM pg_indexes"
|
||||||
|
" WHERE tablename = 'chunks' AND indexdef ILIKE '%USING gin%'"
|
||||||
|
" AND indexdef ILIKE '%tsv%'"
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
assert gin == 1, "GIN index on chunks.tsv is missing"
|
||||||
|
|
||||||
|
fts = db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT is_nullable = 'YES' FROM information_schema.columns"
|
||||||
|
" WHERE table_name = 'query_log' AND column_name = 'fts_hits'"
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
assert fts is True, "query_log.fts_hits must exist and be nullable (pre-0002 rows)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tsv_is_generated_and_lexically_queryable(db) -> None:
|
||||||
|
"""The tsvector is generated from ``content`` (not maintained by app
|
||||||
|
code) and answers a tsquery — the retrieval path's lexical branch."""
|
||||||
|
doc_id = db.execute(text("SELECT gen_random_uuid()")).scalar()
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO documents (id, source, path, full_path, title, content,"
|
||||||
|
" content_hash, indexed_at) VALUES"
|
||||||
|
" (:id, 'mig_test', 't.md', '/t.md', 'T', 'kafkabridge routes here',"
|
||||||
|
" repeat('0', 64), now())"
|
||||||
|
),
|
||||||
|
{"id": doc_id},
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO chunks (id, document_id, position, content) VALUES"
|
||||||
|
" (gen_random_uuid(), :id, 0, 'kafkabridge routes here')"
|
||||||
|
),
|
||||||
|
{"id": doc_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
hit = db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT count(*) FROM chunks"
|
||||||
|
" WHERE tsv @@ to_tsquery('english', 'kafkabridge')"
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
assert hit == 1
|
||||||
|
finally:
|
||||||
|
db.execute(text("DELETE FROM chunks WHERE document_id = :id"), {"id": doc_id})
|
||||||
|
db.execute(text("DELETE FROM documents WHERE id = :id"), {"id": doc_id})
|
||||||
|
db.commit()
|
||||||
@@ -45,13 +45,19 @@ def _doc(title: str, content: str) -> Document:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _chunk(doc: Document, score: float) -> RetrievedChunk:
|
def _chunk(
|
||||||
|
doc: Document, score: float, cosine: float | None = None, fts_hit: bool = False
|
||||||
|
) -> RetrievedChunk:
|
||||||
|
"""Fake candidate: *score* is the fused rank score; *cosine* (defaults to
|
||||||
|
*score*) is the vector-similarity gate input."""
|
||||||
return RetrievedChunk(
|
return RetrievedChunk(
|
||||||
chunk_id=uuid.uuid4(),
|
chunk_id=uuid.uuid4(),
|
||||||
position=0,
|
position=0,
|
||||||
content=doc.content[:32],
|
content=doc.content[:32],
|
||||||
score=score,
|
score=score,
|
||||||
document=doc,
|
document=doc,
|
||||||
|
cosine=score if cosine is None else cosine,
|
||||||
|
fts_hit=fts_hit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -89,6 +95,68 @@ def test_gate_is_env_tunable_via_settings() -> None:
|
|||||||
assert chat_api.plan_turn(hits, _settings(threshold=0.25)).deflected is False
|
assert chat_api.plan_turn(hits, _settings(threshold=0.25)).deflected is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- hybrid gate matrix (A8, revised: cosine AND fts) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_weak_cosine_with_fts_hit_still_answers() -> None:
|
||||||
|
"""cosine < threshold but a lexical hit ⇒ HIGH — the FTS-OR branch.
|
||||||
|
This is the name-your-tool case: "kafkabridge" grounds despite weak
|
||||||
|
vector overlap."""
|
||||||
|
doc = _doc("Static DNS", "DNS_DOC_CONTENT")
|
||||||
|
plan = chat_api.plan_turn(
|
||||||
|
[_chunk(doc, 0.02, cosine=0.10, fts_hit=True)], _settings(threshold=0.30)
|
||||||
|
)
|
||||||
|
assert plan.deflected is False
|
||||||
|
assert plan.top_score == pytest.approx(0.10) # gate input is the cosine
|
||||||
|
assert plan.fts_hits == 1
|
||||||
|
assert "DNS_DOC_CONTENT" in plan.system_prompt
|
||||||
|
assert plan.suggestions == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_weak_cosine_zero_fts_deflects() -> None:
|
||||||
|
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_CONTENT")
|
||||||
|
plan = chat_api.plan_turn([_chunk(doc, 0.02, cosine=0.10)], _settings(threshold=0.30))
|
||||||
|
assert plan.deflected is True
|
||||||
|
assert plan.top_score == pytest.approx(0.10)
|
||||||
|
assert plan.fts_hits == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_strong_cosine_without_fts_answers() -> None:
|
||||||
|
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_CONTENT")
|
||||||
|
plan = chat_api.plan_turn([_chunk(doc, 0.90, cosine=0.90)], _settings(threshold=0.30))
|
||||||
|
assert plan.deflected is False
|
||||||
|
assert plan.fts_hits == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_fts_hits_counts_all_lexical_candidates() -> None:
|
||||||
|
a = _doc("Alpha", "ALPHA_CONTENT")
|
||||||
|
b = _doc("Beta", "BETA_CONTENT")
|
||||||
|
chunks = [
|
||||||
|
_chunk(a, 0.03, cosine=0.05, fts_hit=True),
|
||||||
|
_chunk(a, 0.02, cosine=0.04, fts_hit=True), # same doc, second chunk
|
||||||
|
_chunk(b, 0.01, cosine=0.03),
|
||||||
|
]
|
||||||
|
plan = chat_api.plan_turn(chunks, _settings(threshold=0.30))
|
||||||
|
assert plan.deflected is False
|
||||||
|
assert plan.fts_hits == 2 # per chunk, not per doc
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_lexical_only_chunk_does_not_inflate_cosine() -> None:
|
||||||
|
"""top_score stays the best *vector* cosine even when a lexical-only
|
||||||
|
chunk (cosine 0.0 by construction) carries the highest fused score."""
|
||||||
|
a = _doc("Alpha", "ALPHA_CONTENT")
|
||||||
|
b = _doc("Beta", "BETA_CONTENT")
|
||||||
|
chunks = [
|
||||||
|
_chunk(a, 0.50, cosine=0.55), # vector rank 1
|
||||||
|
_chunk(b, 0.90, cosine=0.0, fts_hit=True), # lexical rank 1 wins the ranking
|
||||||
|
]
|
||||||
|
plan = chat_api.plan_turn(chunks, _settings(threshold=0.30))
|
||||||
|
assert plan.top_score == pytest.approx(0.55)
|
||||||
|
assert plan.deflected is False # 0.55 >= 0.30 anyway
|
||||||
|
# ranking follows the fused score: Beta's doc is the top source
|
||||||
|
assert plan.docs[0].title == "Beta"
|
||||||
|
|
||||||
|
|
||||||
def test_gate_zero_chunks_deflects_with_fallback_chips() -> None:
|
def test_gate_zero_chunks_deflects_with_fallback_chips() -> None:
|
||||||
plan = chat_api.plan_turn([], _settings())
|
plan = chat_api.plan_turn([], _settings())
|
||||||
assert plan.deflected is True
|
assert plan.deflected is True
|
||||||
@@ -233,6 +301,13 @@ def gate_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[_FakeSession, _C
|
|||||||
llm = _CannedLLM()
|
llm = _CannedLLM()
|
||||||
monkeypatch.setitem(fastapi_app.dependency_overrides, chat_api.get_db, lambda: session)
|
monkeypatch.setitem(fastapi_app.dependency_overrides, chat_api.get_db, lambda: session)
|
||||||
monkeypatch.setitem(fastapi_app.dependency_overrides, chat_api.get_llm, lambda: llm)
|
monkeypatch.setitem(fastapi_app.dependency_overrides, chat_api.get_llm, lambda: llm)
|
||||||
|
# These tests assert against a specific gate threshold; keep it stable
|
||||||
|
# regardless of the production default (0.62) or any .env.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chat_api,
|
||||||
|
"get_settings",
|
||||||
|
lambda: Settings(_env_file=None, relevance_threshold=0.30), # pyright: ignore[reportCallIssue]
|
||||||
|
)
|
||||||
yield session, llm
|
yield session, llm
|
||||||
fastapi_app.dependency_overrides.clear()
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
@@ -254,7 +329,7 @@ def _ask(client: TestClient, message: str) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
def _fake_retriever(chunks: list[RetrievedChunk]) -> Any:
|
def _fake_retriever(chunks: list[RetrievedChunk]) -> Any:
|
||||||
def retrieve(_db: Any, _vec: list[float]) -> list[RetrievedChunk]:
|
def retrieve(_db: Any, _question: str, _vec: list[float]) -> list[RetrievedChunk]:
|
||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
return retrieve
|
return retrieve
|
||||||
|
|||||||
+226
-2
@@ -1,11 +1,25 @@
|
|||||||
"""Unit tests: markdown-aware chunker (PLAN §5 policy)."""
|
"""Unit tests: format-aware chunker (PLAN §5 policy, A9 formats).
|
||||||
|
|
||||||
|
The markdown policy tests are the original contract (md output stays
|
||||||
|
unchanged); the per-format tests cover the phase-09 dispatcher
|
||||||
|
(yaml/yml, json, py, txt) and the 1200-char hard cap for every format.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from itertools import pairwise
|
from itertools import pairwise
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.rag.chunker import HARD_MAX_CHARS, chunk_markdown, extract_title
|
from app.rag.chunker import (
|
||||||
|
HARD_MAX_CHARS,
|
||||||
|
chunk_document,
|
||||||
|
chunk_json,
|
||||||
|
chunk_markdown,
|
||||||
|
chunk_python,
|
||||||
|
chunk_text,
|
||||||
|
chunk_yaml,
|
||||||
|
extract_title,
|
||||||
|
)
|
||||||
|
|
||||||
ANCHOR = "## Big"
|
ANCHOR = "## Big"
|
||||||
ANCHOR_PREFIX = f"{ANCHOR}\n\n"
|
ANCHOR_PREFIX = f"{ANCHOR}\n\n"
|
||||||
@@ -157,3 +171,213 @@ def test_extract_title_prefers_h1() -> None:
|
|||||||
assert extract_title("## not a title\n\nbody") == ""
|
assert extract_title("## not a title\n\nbody") == ""
|
||||||
assert extract_title("## sub only", fallback="stem") == "stem"
|
assert extract_title("## sub only", fallback="stem") == "stem"
|
||||||
assert extract_title("", fallback="fallback") == "fallback"
|
assert extract_title("", fallback="fallback") == "fallback"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Format dispatcher (chunk_document) — A9 multi-format ingestion
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_by_lowercased_suffix() -> None:
|
||||||
|
md = "# T\n\n## A\n\nbody\n"
|
||||||
|
assert chunk_document(md, "notes/Doc.MD") == chunk_markdown(md)
|
||||||
|
assert chunk_document(md, "notes/doc.MARKDOWN") == chunk_markdown(md)
|
||||||
|
assert chunk_document("p1\n\np2\n", "x.TXT") == chunk_text("p1\n\np2\n")
|
||||||
|
assert chunk_document("a: 1\n", "x.YAML") == chunk_yaml("a: 1\n")
|
||||||
|
assert chunk_document("a: 1\n", "x.Yml") == chunk_yaml("a: 1\n")
|
||||||
|
assert chunk_document('{"a": 1}', "x.Json") == chunk_json('{"a": 1}')
|
||||||
|
assert chunk_document("def f(): pass\n", "x.PY") == chunk_python("def f(): pass\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_unknown_suffix_falls_back_to_paragraphs() -> None:
|
||||||
|
assert chunk_document("hello\n\nworld", "data.csv") == ["hello\nworld"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_ignores_directory_part_of_path() -> None:
|
||||||
|
assert chunk_document("def f(): pass\n", "a/b/c/script.py") == chunk_python("def f(): pass\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# yaml / yml
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaml_splits_on_top_level_keys_and_keeps_key_anchors() -> None:
|
||||||
|
doc = (
|
||||||
|
"# leading comment\n"
|
||||||
|
"services:\n"
|
||||||
|
" gitlab:\n"
|
||||||
|
" image: gitlab/gitlab-ce\n"
|
||||||
|
" prometheus:\n"
|
||||||
|
" image: prom/prometheus\n"
|
||||||
|
"volumes:\n"
|
||||||
|
" gitlab-data:\n"
|
||||||
|
)
|
||||||
|
chunks = chunk_yaml(doc)
|
||||||
|
joined = "\n".join(chunks)
|
||||||
|
for key in ("services:", "volumes:"):
|
||||||
|
assert key in joined
|
||||||
|
# Indented keys are NOT block starts — they stay inside their parent block.
|
||||||
|
assert not any(c.startswith(" gitlab:") for c in chunks)
|
||||||
|
# The leading comment stays with the first block (preamble).
|
||||||
|
assert chunks[0].startswith("# leading comment")
|
||||||
|
assert "gitlab/gitlab-ce" in joined and "prom/prometheus" in joined
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaml_document_separators_start_new_blocks() -> None:
|
||||||
|
a = "site_a: " + "a" * 500 + "\n"
|
||||||
|
b = "site_b: " + "b" * 500 + "\n"
|
||||||
|
chunks = chunk_yaml(a + "---\n" + b, target_chars=600, overlap_chars=0)
|
||||||
|
# Each site is long enough to force its own chunk; the separator must not
|
||||||
|
# glue them into one over-budget chunk.
|
||||||
|
assert len(chunks) >= 2
|
||||||
|
assert all(len(c) <= 600 for c in chunks)
|
||||||
|
assert not any("site_a" in c and "site_b" in c for c in chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaml_oversized_key_block_is_split_under_hard_cap() -> None:
|
||||||
|
doc = "big_list:\n" + (" - " + "x" * 60 + "\n") * 60 # one ~3800-char block
|
||||||
|
chunks = chunk_yaml(doc)
|
||||||
|
assert len(chunks) >= 2
|
||||||
|
assert all(len(c) <= HARD_MAX_CHARS for c in chunks)
|
||||||
|
# Overlap re-prints (≤50 chars per split), so only a little content is
|
||||||
|
# re-stated — the bulk of the block must survive.
|
||||||
|
assert sum(len(c) for c in chunks) >= len(doc) - 300
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaml_empty_content() -> None:
|
||||||
|
assert chunk_yaml("") == []
|
||||||
|
assert chunk_yaml("\n\n \n") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# json
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_splits_on_top_level_keys_pretty_printed() -> None:
|
||||||
|
doc = '{"hosts": {"kafkabridge": "10.0.3.7"}, "count": 3}'
|
||||||
|
chunks = chunk_json(doc, target_chars=45, overlap_chars=0) # force 1 chunk/block
|
||||||
|
assert len(chunks) == 2
|
||||||
|
first, second = chunks
|
||||||
|
assert '"hosts"' in first and "kafkabridge" in first
|
||||||
|
assert '"count"' in second
|
||||||
|
# Pretty-printed (indent=2), not the compact input form.
|
||||||
|
assert '"kafkabridge": "10.0.3.7"' in first
|
||||||
|
assert not any('{"hosts"' in c for c in chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_each_key_block_is_self_contained() -> None:
|
||||||
|
doc = '{"a": "x", "b": "y"}'
|
||||||
|
chunks = chunk_json(doc, target_chars=13, overlap_chars=0) # force 1 chunk/block
|
||||||
|
assert [c for c in chunks if '"a"' in c] and [c for c in chunks if '"b"' in c]
|
||||||
|
assert not any('"a"' in c and '"b"' in c for c in chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_oversized_value_falls_under_hard_cap() -> None:
|
||||||
|
doc = '{"blob": "' + "z" * 4000 + '"}'
|
||||||
|
chunks = chunk_json(doc)
|
||||||
|
assert len(chunks) >= 2
|
||||||
|
assert all(len(c) <= HARD_MAX_CHARS for c in chunks)
|
||||||
|
assert "".join(chunks).count("z") >= 4000
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_top_level_list_is_one_pretty_block() -> None:
|
||||||
|
chunks = chunk_json("[1, 2, 3]")
|
||||||
|
assert chunks == ["[\n 1,\n 2,\n 3\n]"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_unparseable_falls_back_to_paragraph_packing() -> None:
|
||||||
|
doc = "{broken json\n\nsecond paragraph here\n"
|
||||||
|
assert chunk_json(doc) == chunk_text(doc)
|
||||||
|
assert chunk_json("not json at all") == chunk_text("not json at all")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# python
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_splits_on_top_level_defs_and_classes() -> None:
|
||||||
|
doc = (
|
||||||
|
'"""Module doc."""\n'
|
||||||
|
"import asyncio\n"
|
||||||
|
"\n"
|
||||||
|
"CONST = 1\n"
|
||||||
|
"\n"
|
||||||
|
"def alpha():\n"
|
||||||
|
" return 1\n"
|
||||||
|
"\n"
|
||||||
|
"class Beta:\n"
|
||||||
|
" def run(self):\n"
|
||||||
|
" return 2\n"
|
||||||
|
)
|
||||||
|
chunks = chunk_python(doc, target_chars=60, overlap_chars=0) # force 1 chunk/block
|
||||||
|
assert len(chunks) == 3
|
||||||
|
assert chunks[0].startswith('"""Module doc."""')
|
||||||
|
assert "CONST = 1" in chunks[0] # preamble ends at the first def/class
|
||||||
|
assert chunks[1].startswith("def alpha")
|
||||||
|
assert chunks[2].startswith("class Beta")
|
||||||
|
assert "def run" in chunks[2] # nested def stays inside the class block
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_decorators_stay_with_their_definition() -> None:
|
||||||
|
doc = "@app.get('/x')\ndef handler():\n return 'x'\n"
|
||||||
|
chunks = chunk_python(doc)
|
||||||
|
assert chunks[0].startswith("@app.get")
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_oversized_function_falls_back_to_line_packing() -> None:
|
||||||
|
doc = "def big():\n" + "\n".join(f" val_{i:03d} = {i} # padding" for i in range(80))
|
||||||
|
chunks = chunk_python(doc)
|
||||||
|
assert len(chunks) >= 2
|
||||||
|
assert all(len(c) <= HARD_MAX_CHARS for c in chunks)
|
||||||
|
assert "val_000" in chunks[0]
|
||||||
|
assert "val_079" in chunks[-1]
|
||||||
|
assert sum(len(c) for c in chunks) >= len(doc) - 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_unparseable_source_falls_back_to_paragraphs() -> None:
|
||||||
|
src = "def broken(:\n\nstill text\n"
|
||||||
|
assert chunk_python(src) == chunk_text(src)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# txt
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_txt_paragraph_packing() -> None:
|
||||||
|
doc = "alpha\n\nbeta\n\ngamma\n"
|
||||||
|
chunks = chunk_text(doc)
|
||||||
|
assert chunks == ["alpha\nbeta\ngamma"] # all three fit the target
|
||||||
|
|
||||||
|
|
||||||
|
def test_txt_long_doc_packs_with_overlap() -> None:
|
||||||
|
doc = "\n\n".join(f"para {i} " + "l" * 300 for i in range(6))
|
||||||
|
chunks = chunk_text(doc, target_chars=800, overlap_chars=100)
|
||||||
|
assert len(chunks) >= 2
|
||||||
|
assert all(len(c) <= 800 for c in chunks)
|
||||||
|
assert all(f"para {i}" in "\n".join(chunks) for i in range(6))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hard cap across every format (aipi ~1024-token request cap)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("content", "path"),
|
||||||
|
[
|
||||||
|
("# T\n\n" + "word " * 1200, "big.md"),
|
||||||
|
("key: " + "v" * 5000 + "\n", "big.yaml"),
|
||||||
|
('{"blob": "' + "z" * 5000 + '"}', "big.json"),
|
||||||
|
("def f():\n" + " x = 1\n" * 1000, "big.py"),
|
||||||
|
("line of text\n\n" * 800, "big.txt"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_hard_cap_holds_for_every_format(content: str, path: str) -> None:
|
||||||
|
chunks = chunk_document(content, path)
|
||||||
|
assert chunks, "expected at least one chunk"
|
||||||
|
for c in chunks:
|
||||||
|
assert len(c) <= HARD_MAX_CHARS, f"{path}: {len(c)} chars"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import json
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
from pydantic_settings import SettingsError
|
from pydantic_settings import SettingsError
|
||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
@@ -16,16 +17,28 @@ def _settings(**kwargs: Any) -> Settings:
|
|||||||
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
|
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
|
||||||
|
|
||||||
|
|
||||||
def test_defaults_match_locked_decisions() -> None:
|
def test_defaults_match_locked_decisions(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# The test process sets BOR_RELEVANCE_THRESHOLD=0.30 for the mock-
|
||||||
|
# calibrated in-process suites (see tests/conftest.py) — the *default*
|
||||||
|
# under test is the production one.
|
||||||
|
monkeypatch.delenv("BOR_RELEVANCE_THRESHOLD", raising=False)
|
||||||
s = _settings()
|
s = _settings()
|
||||||
assert s.llm_chat_model == "turbo"
|
assert s.llm_chat_model == "turbo"
|
||||||
assert s.llm_embed_model == "embed"
|
assert s.llm_embed_model == "embed"
|
||||||
assert s.embedding_dim == 768
|
assert s.embedding_dim == 768
|
||||||
assert s.llm_base_url.endswith("/v1")
|
assert s.llm_base_url.endswith("/v1")
|
||||||
assert 0 < s.relevance_threshold < 1
|
# A8 (revised): the honesty gate input is the best cosine, default 0.62.
|
||||||
assert s.top_k_chunks >= 1
|
assert s.relevance_threshold == 0.62
|
||||||
|
# A7 (revised): hybrid retrieval — cosine top-N ∪ FTS top-N, RRF-fused.
|
||||||
|
assert s.hybrid_vector_candidates >= 1
|
||||||
|
assert s.hybrid_lexical_candidates >= 1
|
||||||
|
assert s.rrf_k >= 1
|
||||||
assert s.top_n_docs >= 1
|
assert s.top_n_docs >= 1
|
||||||
assert len(s.suggestions) >= 3
|
assert len(s.suggestions) >= 3
|
||||||
|
# A9 (revised): the import scope covers the seven A9 formats.
|
||||||
|
assert s.import_extension_set == {
|
||||||
|
".md", ".markdown", ".txt", ".yaml", ".yml", ".json", ".py"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_env_override(monkeypatch) -> None:
|
def test_env_override(monkeypatch) -> None:
|
||||||
@@ -36,6 +49,26 @@ def test_env_override(monkeypatch) -> None:
|
|||||||
assert s.llm_chat_model == "juggernaut"
|
assert s.llm_chat_model == "juggernaut"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_extensions_env_override_is_a_csv_list(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,yml")
|
||||||
|
s = _settings()
|
||||||
|
assert s.import_extension_set == {".md", ".yml"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_extensions_rejects_unknown_format(monkeypatch) -> None:
|
||||||
|
"""A typo in the CSV fails at startup (loudly), not by silently
|
||||||
|
walking zero files."""
|
||||||
|
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,docx")
|
||||||
|
with pytest.raises(ValidationError, match="docx"):
|
||||||
|
_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_extensions_rejects_empty(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", " ")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
_settings()
|
||||||
|
|
||||||
|
|
||||||
def test_suggestions_default_is_three_plus_real_questions() -> None:
|
def test_suggestions_default_is_three_plus_real_questions() -> None:
|
||||||
s = _settings()
|
s = _settings()
|
||||||
assert len(s.suggestions) >= 3
|
assert len(s.suggestions) >= 3
|
||||||
|
|||||||
+107
-7
@@ -16,11 +16,15 @@ from app.models import Chunk, Document
|
|||||||
from app.rag.importer import (
|
from app.rag.importer import (
|
||||||
EXCLUDED_DIRS,
|
EXCLUDED_DIRS,
|
||||||
import_sources,
|
import_sources,
|
||||||
iter_markdown_files,
|
iter_importable_files,
|
||||||
)
|
)
|
||||||
from app.rag.llm import EmbeddingError
|
from app.rag.llm import EmbeddingError
|
||||||
from tests.fakes import FakeEmbedder
|
from tests.fakes import FakeEmbedder
|
||||||
|
|
||||||
|
#: A9 default extension set as dotted suffixes (what the importer passes to
|
||||||
|
#: the walker when no override is configured).
|
||||||
|
DEFAULT_EXTS = frozenset({".md", ".markdown", ".txt", ".yaml", ".yml", ".json", ".py"})
|
||||||
|
|
||||||
|
|
||||||
class _PoisonEmbedder(FakeEmbedder):
|
class _PoisonEmbedder(FakeEmbedder):
|
||||||
"""Fails (like a real endpoint) on any text containing 'poison'."""
|
"""Fails (like a real endpoint) on any text containing 'poison'."""
|
||||||
@@ -50,7 +54,9 @@ def _cleanup_source(db, source: str) -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def test_iter_markdown_files_excludes_noncontent_dirs(tmp_path: Path) -> None:
|
def test_iter_importable_files_excludes_noncontent_dirs_and_hidden(tmp_path: Path) -> None:
|
||||||
|
"""Well-known non-content dirs, hidden (dot-) dirs/files, and non-A9
|
||||||
|
extensions are all skipped; the A9 formats pass."""
|
||||||
root = tmp_path / "proj"
|
root = tmp_path / "proj"
|
||||||
for d in (
|
for d in (
|
||||||
"notes/sub",
|
"notes/sub",
|
||||||
@@ -61,11 +67,20 @@ def test_iter_markdown_files_excludes_noncontent_dirs(tmp_path: Path) -> None:
|
|||||||
".pytest_cache",
|
".pytest_cache",
|
||||||
"dist",
|
"dist",
|
||||||
"build",
|
"build",
|
||||||
|
".esphome/.espressif", # vendored hidden cache — the real A9 case
|
||||||
):
|
):
|
||||||
(root / d).mkdir(parents=True)
|
(root / d).mkdir(parents=True)
|
||||||
files = {
|
files = {
|
||||||
|
# content that must be found:
|
||||||
"README.md": "readme",
|
"README.md": "readme",
|
||||||
"notes/sub/deep.md": "deep",
|
"notes/sub/deep.md": "deep",
|
||||||
|
"compose.yaml": "services: {}",
|
||||||
|
"legacy.YML": "a: b", # case-insensitive suffix
|
||||||
|
"notes/sub/agent.py": "x = 1",
|
||||||
|
"config.json": "{}",
|
||||||
|
"README.txt": "plain",
|
||||||
|
"notes/sub/deep.markdown": "md2",
|
||||||
|
# must be skipped:
|
||||||
".venv/lib/junk.md": "junk",
|
".venv/lib/junk.md": "junk",
|
||||||
"node_modules/x/j.md": "j",
|
"node_modules/x/j.md": "j",
|
||||||
".git/c.md": "g",
|
".git/c.md": "g",
|
||||||
@@ -73,17 +88,40 @@ def test_iter_markdown_files_excludes_noncontent_dirs(tmp_path: Path) -> None:
|
|||||||
".pytest_cache/c.md": "pc",
|
".pytest_cache/c.md": "pc",
|
||||||
"dist/d.md": "d",
|
"dist/d.md": "d",
|
||||||
"build/b.md": "b",
|
"build/b.md": "b",
|
||||||
|
".esphome/.espressif/secret.md": "vendor",
|
||||||
|
".secret.md": "hidden file", # dot-prefixed FILE, not just dir
|
||||||
|
"notes/sub/notes.csv": "a,b", # not an A9 format
|
||||||
|
"notes/sub/file.md.bak": "x",
|
||||||
}
|
}
|
||||||
for rel, text in files.items():
|
for rel, text in files.items():
|
||||||
(root / rel).write_text(text)
|
(root / rel).write_text(text)
|
||||||
(root / "notes" / "not-md.txt").write_text("skip me")
|
|
||||||
|
|
||||||
found = {p.relative_to(root).as_posix() for p in iter_markdown_files(root)}
|
found = {p.relative_to(root).as_posix() for p in iter_importable_files(root, DEFAULT_EXTS)}
|
||||||
assert found == {"README.md", "notes/sub/deep.md"}
|
assert found == {
|
||||||
|
"README.md",
|
||||||
|
"notes/sub/deep.md",
|
||||||
|
"compose.yaml",
|
||||||
|
"legacy.YML",
|
||||||
|
"notes/sub/agent.py",
|
||||||
|
"config.json",
|
||||||
|
"README.txt",
|
||||||
|
"notes/sub/deep.markdown",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_iter_markdown_files_missing_dir_yields_nothing(tmp_path: Path) -> None:
|
def test_iter_importable_files_missing_dir_yields_nothing(tmp_path: Path) -> None:
|
||||||
assert iter_markdown_files(tmp_path / "definitely-missing") == []
|
assert iter_importable_files(tmp_path / "definitely-missing", DEFAULT_EXTS) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_iter_importable_files_respects_custom_extension_filter(tmp_path: Path) -> None:
|
||||||
|
"""A narrower filter (e.g. md only) excludes the other A9 formats."""
|
||||||
|
root = tmp_path / "filtered"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "a.md").write_text("a")
|
||||||
|
(root / "b.yaml").write_text("a: b")
|
||||||
|
(root / "c.py").write_text("x = 1")
|
||||||
|
found = {p.name for p in iter_importable_files(root, frozenset([".md"]))}
|
||||||
|
assert found == {"a.md"}
|
||||||
|
|
||||||
|
|
||||||
def test_excluded_dirs_match_plan_anchor_a9() -> None:
|
def test_excluded_dirs_match_plan_anchor_a9() -> None:
|
||||||
@@ -277,3 +315,65 @@ def test_chunk_positions_and_titles(db, tmp_path: Path) -> None:
|
|||||||
assert positions == list(range(len(doc.chunks))) and len(doc.chunks) >= 2
|
assert positions == list(range(len(doc.chunks))) and len(doc.chunks) >= 2
|
||||||
finally:
|
finally:
|
||||||
_cleanup_source(db, root.name)
|
_cleanup_source(db, root.name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_format_import_counts_per_format_and_titles_stem(db, tmp_path: Path) -> None:
|
||||||
|
"""A9 formats all import; the summary records per-format counts, and
|
||||||
|
non-markdown titles come from the file stem (a ``#`` line is a comment
|
||||||
|
there, not a heading)."""
|
||||||
|
root = tmp_path / "multi"
|
||||||
|
(root / "svc").mkdir(parents=True)
|
||||||
|
(root / "guide.md").write_text("# Real Heading\n\nbody\n")
|
||||||
|
(root / "svc" / "compose.yaml").write_text("# a comment\nservices:\n gitlab: {}\n")
|
||||||
|
(root / "svc" / "agent.py").write_text("# docstring-like comment\ndef ping():\n return 1\n")
|
||||||
|
(root / "inventory.json").write_text('{"hosts": []}\n')
|
||||||
|
(root / "notes.txt").write_text("plain text notes\n")
|
||||||
|
llm = FakeEmbedder()
|
||||||
|
try:
|
||||||
|
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||||
|
assert summary.files == 5
|
||||||
|
assert summary.added == 5
|
||||||
|
assert summary.formats == {"md": 1, "yaml": 1, "py": 1, "json": 1, "txt": 1}
|
||||||
|
# PLAN §9 summary line: counts, highest first, ext:name pairs.
|
||||||
|
assert summary.format_counts() == "json:1,md:1,py:1,txt:1,yaml:1"
|
||||||
|
|
||||||
|
titles = {
|
||||||
|
d.path: d.title
|
||||||
|
for d in db.scalars(select(Document).where(Document.source == root.name)).all()
|
||||||
|
}
|
||||||
|
assert titles["guide.md"] == "Real Heading" # markdown keeps the H1
|
||||||
|
assert titles["svc/compose.yaml"] == "compose" # …comment is not a heading
|
||||||
|
assert titles["svc/agent.py"] == "agent"
|
||||||
|
assert titles["inventory.json"] == "inventory"
|
||||||
|
assert titles["notes.txt"] == "notes"
|
||||||
|
finally:
|
||||||
|
_cleanup_source(db, root.name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_removes_files_now_excluded_by_format_filter(db, tmp_path: Path) -> None:
|
||||||
|
"""Previously-imported junk leaves the index: a file that no longer
|
||||||
|
matches the A9 extension filter is pruned on the next ``prune=True`` run.
|
||||||
|
This is how dot-dir READMEs imported before the scope fix get cleaned up."""
|
||||||
|
root = tmp_path / "cleanup"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "keep.md").write_text("# Keep\n\nkept\n")
|
||||||
|
(root / "junk.md.bak").write_text("old junk that was once imported\n")
|
||||||
|
llm = FakeEmbedder()
|
||||||
|
try:
|
||||||
|
# Seed: import both files as if they were valid at the time.
|
||||||
|
(root / "junk.md").write_text("old junk that was once imported\n")
|
||||||
|
(root / "junk.md.bak").unlink()
|
||||||
|
asyncio.run(import_sources([root], llm, session=db))
|
||||||
|
# Rename the junk out of the A9 formats, then prune.
|
||||||
|
(root / "junk.md").rename(root / "junk.md.bak")
|
||||||
|
summary = asyncio.run(import_sources([root], llm, session=db, prune=True))
|
||||||
|
assert summary.pruned == 1
|
||||||
|
assert summary.unchanged == 1 # keep.md survived
|
||||||
|
assert db.scalar(
|
||||||
|
select(Document).where(Document.source == root.name, Document.path == "junk.md")
|
||||||
|
) is None
|
||||||
|
assert db.scalar(
|
||||||
|
select(Document).where(Document.source == root.name, Document.path == "keep.md")
|
||||||
|
) is not None
|
||||||
|
finally:
|
||||||
|
_cleanup_source(db, root.name)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.models import Document
|
from app.models import Document
|
||||||
from app.rag.retriever import TRUNCATION_MARKER, RetrievedChunk, select_documents
|
from app.rag.retriever import TRUNCATION_MARKER, RetrievedChunk, select_documents
|
||||||
|
|
||||||
@@ -94,3 +96,102 @@ def test_under_budget_no_truncation() -> None:
|
|||||||
|
|
||||||
def test_empty_hits_yield_no_documents() -> None:
|
def test_empty_hits_yield_no_documents() -> None:
|
||||||
assert select_documents([], n=2, max_chars=24_000) == []
|
assert select_documents([], n=2, max_chars=24_000) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hybrid retrieval (A7): RRF fusion + lexical tsquery
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from app.rag.retriever import fuse, lexical_tsquery # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _rc(
|
||||||
|
doc_path: str, cosine: float = 0.0, fts_hit: bool = False, position: int = 0
|
||||||
|
) -> RetrievedChunk:
|
||||||
|
return RetrievedChunk(
|
||||||
|
chunk_id=uuid.uuid4(),
|
||||||
|
position=position,
|
||||||
|
content="x" * 20,
|
||||||
|
score=0.0,
|
||||||
|
document=_doc(doc_path, "x" * 20),
|
||||||
|
cosine=cosine,
|
||||||
|
fts_hit=fts_hit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lexical_tsquery_tokens_lowercased_deduped_in_order() -> None:
|
||||||
|
assert lexical_tsquery("How did I Install GITLAB gitlab?") == "how | did | i | install | gitlab"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lexical_tsquery_punctuation_and_umlauts_ignored() -> None:
|
||||||
|
assert lexical_tsquery("c3-r00t? -- what's up!") == "c3 | r00t | what | s | up"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lexical_tsquery_pure_symbols_return_none() -> None:
|
||||||
|
assert lexical_tsquery("??? ???") is None
|
||||||
|
assert lexical_tsquery("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_lexical_tsquery_stopwords_left_to_postgres() -> None:
|
||||||
|
# lexical_tsquery passes raw tokens through; Postgres's to_tsquery
|
||||||
|
# lexing drops the stopwords (verified against real PG in
|
||||||
|
# test_retrieve_empty_kb / integration tests).
|
||||||
|
assert lexical_tsquery("how do i") == "how | do | i"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuse_combines_both_lists_for_double_hits() -> None:
|
||||||
|
v1 = _rc("a.md", cosine=0.9)
|
||||||
|
v2 = _rc("b.md", cosine=0.5)
|
||||||
|
l1 = _rc("a.md", cosine=0.1) # same chunk id -> matched in place
|
||||||
|
a_id = v1.chunk_id
|
||||||
|
l1.chunk_id = a_id
|
||||||
|
out = fuse([v1, v2], [l1], k=60)
|
||||||
|
by_id = {rc.chunk_id: rc for rc in out}
|
||||||
|
# a: 1/61 (vector rank 1) + 1/61 (lexical rank 1); b: 1/62 only.
|
||||||
|
assert by_id[a_id].score == pytest.approx(2 / 61)
|
||||||
|
assert by_id[a_id].fts_hit is True
|
||||||
|
assert by_id[v2.chunk_id].score == pytest.approx(1 / 62)
|
||||||
|
assert by_id[v2.chunk_id].fts_hit is False
|
||||||
|
assert [rc.chunk_id for rc in out] == [a_id, v2.chunk_id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuse_lexical_only_chunks_enter_with_zero_cosine() -> None:
|
||||||
|
vector = [_rc("a.md", cosine=0.8)]
|
||||||
|
lexical = [_rc("b.md", cosine=0.0, fts_hit=True)]
|
||||||
|
out = fuse(vector, lexical, k=60)
|
||||||
|
assert len(out) == 2
|
||||||
|
b = next(rc for rc in out if rc.document.path == "b.md")
|
||||||
|
assert b.cosine == 0.0
|
||||||
|
assert b.fts_hit is True
|
||||||
|
# Still ranked by its (only) RRF term.
|
||||||
|
assert b.score == pytest.approx(1 / 61)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuse_orders_by_score_then_cosine_then_path() -> None:
|
||||||
|
# Two chunks share an RRF score (both rank 1 in different lists):
|
||||||
|
# the higher-cosine one must sort first.
|
||||||
|
hi = _rc("z.md", cosine=0.9)
|
||||||
|
lo = _rc("a.md", cosine=0.2)
|
||||||
|
out = fuse([hi], [lo], k=60)
|
||||||
|
assert [rc.document.path for rc in out] == ["z.md", "a.md"]
|
||||||
|
# Equal score AND cosine -> path order.
|
||||||
|
p1 = _rc("b.md", cosine=0.5)
|
||||||
|
p2 = _rc("a.md", cosine=0.5)
|
||||||
|
out = fuse([p1], [p2], k=60)
|
||||||
|
assert [rc.document.path for rc in out] == ["a.md", "b.md"]
|
||||||
|
# Equal score, cosine, path -> position order.
|
||||||
|
s1 = _rc("a.md", cosine=0.5, position=1)
|
||||||
|
s2 = _rc("a.md", cosine=0.5, position=0)
|
||||||
|
out = fuse([s1], [s2], k=60)
|
||||||
|
assert [rc.position for rc in out] == [0, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuse_rejects_nonpositive_k() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
fuse([], [], k=0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
fuse([], [], k=-1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuse_empty_lists() -> None:
|
||||||
|
assert fuse([], [], k=60) == []
|
||||||
|
|||||||
Reference in New Issue
Block a user