**Phase 118 final verification pass — complete.** All criteria verified; 4 pre-existing defects found and fixed.
- **Verified:** summary-seed wiring (`select_suggested` top-5 no-floor → summary blocks, no full text in HIGH prompt), all-doc markdown summaries + NULL backfill (`summary_backfilled`, no `sources_meta` bump), `read` adds full text with `read_docs`-only dedupe, `done.sources` = suggested+read / durable record = suggested+related+read + `suggested=N` log line (seen live in E2E), byte-locked PERSONA/LOW/TOOLS_SECTION, battery gate PASS recorded in `TOOL_CALLING_TESTING.md` §10 (turbo 2026-09-16: 1/2/4 GREEN, cond-3 reported 9/10 per A7, contract 21/21, caps 0).
- **Defects fixed (all pre-existing, none phase-118):** ① `ChatMessage` schema missing the phase-113 `related` key → `extra="forbid"` 422'd every done-time auto-save of grounded turns with a related tier, leaving `message_count=1` (root cause of `test_share_chat` 3F; browser-level instrumentation proved the PUT 422) — added the field + unit/integration pins; ② `test_theme_semantic_completion` pins stale vs phase-117 debox (border/chip removed) — re-targeted to assert border/chip *absence*; ③ `test_header_consistency` `<26`px pin red on 26.125px native date-input line — bound relaxed to `<34` (wrap-detection intent kept); ④ `test_navbar_refresh` bor.chat.v1 key set updated for `related`.
- **Test/lint/coverage:** `uv run pytest --cov=app --cov-report=term-missing` → **2506 passed, app/ 99%** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors.
- **E2E:** new story suite in isolation → **2 passed**; full 103-suite matrix sweep (each isolated) → **all 103 green** after the fixes; `test_share_chat` 4 passed, `test_theme_semantic_completion` 8 passed, `test_header_consistency` 3 passed, `test_navbar_refresh` 7 passed.
- **Deviations:** none from LOCKED decisions. Note: orphaned diagnostic uvicorn processes briefly made E2E sessions exercise stale code — killed and re-verified; a sweep-regenerated tracked screenshot was restored. No commits made (harness commits).
- **Completion criteria:** all 7 ✅ (commit/phase-move is the harness's step).
- **Next pending phase:** none — `todo/` holds only this phase's overview pending the harness move.
394 lines
17 KiB
Python
394 lines
17 KiB
Python
"""Build the controlled tool-calling test KB and snapshot it (one-off).
|
|
|
|
The fixture KB lives in ``tests/fixtures/agent_kb/`` — two source
|
|
directories (``deployments``, ``homelab``) with eight hand-written
|
|
markdown documents whose specifics (``rack7``, ``10.77.42.0/24``,
|
|
VLAN 130, port 18443, ntfy topic ``reese-uptime-7``, machine ID
|
|
``rbm-8842``, the ``17 2 * * *`` schedule, image
|
|
``ghcr.io/reese/obsidian-bor:2026.7.14``, port 18765, …) are not
|
|
guessable by any model. This script:
|
|
|
|
1. resets the app tables (one TRUNCATE — the dump's table set),
|
|
2. registers the two fixture directories as ``kind='local'``
|
|
``git_sources`` rows (so the source registry — and therefore
|
|
``ls``'s scope names — is self-contained and independent of the
|
|
``BOR_GIT_SOURCES`` env var),
|
|
3. imports the fixture documents through the real pipeline
|
|
(``import_sources`` — real chunking + real ``embed``-model
|
|
embeddings; this is the only step that burns ``embed`` calls, and
|
|
only at build time),
|
|
4. stores the static KB overview + the sources-version row,
|
|
5. generates the sync-time **folder summaries** through the real
|
|
generator (``app.rag.folder_summaries``) against the live ``lite``
|
|
endpoint — the drill-down ``ls`` (phase 94) shows the stored rows,
|
|
so the dump must carry them (the build always truncates first, so
|
|
the sync paths' table-empty first-run trigger holds; a failed batch
|
|
aborts the build — a summary-less dump would be a broken fixture),
|
|
6. prints a **retrieval report** for every fixture-battery question
|
|
(grounded or deflected, which documents would seed) — the battery
|
|
must be all-grounded for the gate to exercise the tools,
|
|
7. snapshots the resulting database state into
|
|
``tests/fixtures/test_kb.dump.sql`` — a data-only SQL script
|
|
(TRUNCATE + one multi-row ``INSERT`` per app table, generated
|
|
in-process — the same file runs in psql or psycopg, in one
|
|
transaction) — and **verifies the snapshot by restoring it and
|
|
comparing a per-table checksum**.
|
|
|
|
Re-run it only when the fixture documents, the chunker, the embedding
|
|
model, the folder-summary prompt (its ``lite`` output is baked into
|
|
the dump), or the importer's summary policy (phase 118: every document
|
|
gets a stored summary + an embedded ``is_summary`` chunk) change —
|
|
everyday iterations restore the dump in sub-second time
|
|
(``scripts/restore_test_kb`` / the gate's ``--restore``), never
|
|
re-embedding (see ``TOOL_CALLING_TESTING.md``).
|
|
|
|
Exit codes: **0** built + verified, **1** build/verification failure,
|
|
**2** precondition failure.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from dotenv import load_dotenv
|
|
from sqlalchemy import select, text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.chat import plan_turn
|
|
from app.config import get_settings
|
|
from app.db import SessionLocal, db_available
|
|
from app.models import (
|
|
Chunk,
|
|
DocDraft,
|
|
Document,
|
|
FolderSummary,
|
|
GitSource,
|
|
KbOverview,
|
|
QueryLog,
|
|
SavedChat,
|
|
SourcesMeta,
|
|
SteeringNote,
|
|
)
|
|
from app.rag.folder_summaries import generate_folder_summaries
|
|
from app.rag.importer import import_sources
|
|
from app.rag.llm import LLMClient
|
|
from app.rag.retriever import retrieve
|
|
|
|
logger = logging.getLogger("scripts.load_test_kb")
|
|
|
|
DEFAULT_KB_DIR = Path("tests/fixtures/agent_kb")
|
|
DEFAULT_DUMP = Path("tests/fixtures/test_kb.dump.sql")
|
|
|
|
#: The two fixture source directories (source name = directory basename,
|
|
#: the importer's rule). Alphabetical — the catalog order the derived
|
|
#: battery reads.
|
|
FIXTURE_SOURCES: tuple[str, ...] = ("deployments", "homelab")
|
|
|
|
#: The KB overview stored with the fixture (id=1). A plain outline of
|
|
#: the KB's basic categories — the ``<knowledge_base>`` prompt section
|
|
#: of every turn. Static on purpose: the dump must be deterministic and
|
|
#: the gate must not burn a ``lite`` call at restore time.
|
|
FIXTURE_KB_OVERVIEW: str = (
|
|
"deployments: the lab Ansible inventory (host addresses and roles), "
|
|
"the Obsidian BOR quadlet service definition, and the GitLab Runner "
|
|
"CI setup. homelab: the rack7 Proxmox cluster networking (bridges, "
|
|
"VLANs, DNS/DHCP), container notes (Uptime Kuma, Qwen 3.8 on "
|
|
"llama.cpp), and the nightly restic backup configuration."
|
|
)
|
|
|
|
#: (table, model, explicit column list — the generated ``chunks.tsv``
|
|
#: tsvector column is excluded; Postgres recomputes it).
|
|
_TABLES: tuple[tuple[str, type, tuple[str, ...]], ...] = (
|
|
("documents", Document, ("id", "source", "path", "full_path", "title",
|
|
"content", "content_hash", "indexed_at",
|
|
"created_at", "created_at_manual", "summary")),
|
|
("chunks", Chunk, ("id", "document_id", "position", "content",
|
|
"embedding", "is_summary")),
|
|
("git_sources", GitSource, ("id", "url", "kind", "path", "ignore_paths",
|
|
"include_hidden", "added_at")),
|
|
("kb_overview", KbOverview, ("id", "content", "updated_at")),
|
|
("sources_meta", SourcesMeta, ("id", "version", "updated_at")),
|
|
("steering_notes", SteeringNote, ("id", "note", "created_at")),
|
|
("query_log", QueryLog, ("id", "question", "top_score", "fts_hits",
|
|
"chunk_hits", "deflected", "sources",
|
|
"latency_ms", "created_at")),
|
|
("saved_chats", SavedChat, ("id", "title", "messages", "share_token",
|
|
"sources_version", "created_at", "updated_at")),
|
|
("doc_drafts", DocDraft, ("id", "token", "title", "path", "body",
|
|
"status", "branch", "commit_sha", "created_at",
|
|
"updated_at")),
|
|
# Phase 94 (task 05): the sync-time folder summaries the drill-down
|
|
# ``ls`` shows — generated against the live ``lite`` endpoint in
|
|
# step 5 of :func:`_build` (the build truncates first, so the sync
|
|
# paths' table-empty first-run trigger holds).
|
|
("folder_summaries", FolderSummary, ("source", "folder_path",
|
|
"summary", "manually_edited",
|
|
"updated_at")),
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# SQL serialization (the dump is plain multi-row INSERTs — the installed
|
|
# psycopg build exposes no COPY API, and a multi-statement script with
|
|
# inline ``COPY … FROM stdin`` data cannot be sent through any driver's
|
|
# simple-protocol execute. INSERT VALUES is the portable form: the same
|
|
# file runs in psql, psycopg, or anything else that speaks SQL, in one
|
|
# transaction. With ``standard_conforming_strings`` on (the Postgres
|
|
# default since 9.1), a string literal needs ONLY single-quote doubling —
|
|
# backslashes are literal and newlines may be real.
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _sql_value(value: object) -> str:
|
|
"""One value as a SQL literal (``NULL`` for None)."""
|
|
if value is None:
|
|
return "NULL"
|
|
if isinstance(value, bool):
|
|
return "TRUE" if value else "FALSE"
|
|
if isinstance(value, float):
|
|
return repr(value) # shortest round-trip double
|
|
if isinstance(value, int):
|
|
return str(value)
|
|
if isinstance(value, uuid.UUID):
|
|
return "'" + str(value) + "'"
|
|
if isinstance(value, datetime):
|
|
return "'" + value.isoformat(sep=" ") + "'"
|
|
if isinstance(value, (list, tuple)) and value and isinstance(value[0], float):
|
|
# A pgvector vector: the ``[v1, v2, …]`` text literal (pgvector
|
|
# 0.7+ format; the older ``{…}`` form is rejected). Checked
|
|
# before the JSONB branch — a JSONB array of dicts never has a
|
|
# float first element.
|
|
return "'" + "[" + ",".join(repr(v) for v in value) + "]" + "'"
|
|
if isinstance(value, (dict, list)):
|
|
# JSONB columns: the stored JSON text (Postgres re-parses it).
|
|
text_ = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
else:
|
|
text_ = str(value)
|
|
return "'" + text_.replace("'", "''") + "'"
|
|
|
|
|
|
def _dump_table(db: Session, table: str, model: type, columns: tuple[str, ...]) -> str:
|
|
"""One multi-row ``INSERT INTO <table> (…) VALUES (…), …;`` statement
|
|
(an empty table emits nothing — there is no row to write)."""
|
|
rows = db.execute(select(model)).all()
|
|
value_rows = [
|
|
"(" + ", ".join(_sql_value(getattr(row[0], name)) for name in columns) + ")"
|
|
for row in rows
|
|
]
|
|
if not value_rows:
|
|
return ""
|
|
return (
|
|
f"INSERT INTO public.{table} ({', '.join(columns)}) VALUES "
|
|
+ ",\n".join(value_rows)
|
|
+ ";\n"
|
|
)
|
|
|
|
|
|
def _table_checksum(db: Session, table: str) -> str:
|
|
"""An order-independent content checksum for *table* (row::text,
|
|
sorted aggregation) — the snapshot round-trip check."""
|
|
return db.execute(
|
|
text(
|
|
"select md5(coalesce(string_agg(r, E'\\n' order by r), '')) "
|
|
f"from (select t::text as r from public.{table} t) s"
|
|
)
|
|
).scalar_one()
|
|
|
|
|
|
async def _retrieval_report(llm: LLMClient, battery: list[str]) -> int:
|
|
"""Print, per battery question, what the real path would do:
|
|
grounded or deflected, and which documents would seed the context.
|
|
Returns the number of deflected questions (a loud warning — the
|
|
gate needs tools offered on its turns)."""
|
|
settings = get_settings()
|
|
deflected = 0
|
|
print("\nretrieval report (the honesty gate per battery question):")
|
|
with SessionLocal() as db:
|
|
for number, question in enumerate(battery, start=1):
|
|
vec = await llm.embed_one(question)
|
|
chunks = retrieve(db, question, vec)
|
|
plan = plan_turn(chunks, settings)
|
|
seed = ", ".join(f"{d.source}/{d.path}" for d in plan.suggested_docs) or "—"
|
|
if plan.deflected:
|
|
deflected += 1
|
|
print(
|
|
f" {number:02d}. {'DEFLECTED ' if plan.deflected else 'grounded '} "
|
|
f"(best={plan.top_score:.3f} fts={plan.fts_hits}) "
|
|
f"seed: {seed}\n ← {question}"
|
|
)
|
|
return deflected
|
|
|
|
|
|
async def _build(kb_dir: Path, dump_path: Path) -> int:
|
|
from scripts.agent_realmodel_check import FIXTURE_BATTERY
|
|
|
|
started = time.monotonic()
|
|
if not db_available():
|
|
print(
|
|
"load_test_kb: precondition failed — database unreachable; "
|
|
"start Postgres with `podman compose up -d db`"
|
|
)
|
|
return 2
|
|
|
|
source_dirs = [kb_dir / name for name in FIXTURE_SOURCES]
|
|
missing = [str(p) for p in source_dirs if not p.is_dir()]
|
|
if missing:
|
|
print(f"load_test_kb: precondition failed — missing source dir(s): {missing}")
|
|
return 2
|
|
|
|
# 1. Reset the dump's table set (one statement — the inter-table FKs
|
|
# resolve within it).
|
|
table_list = ", ".join(f"public.{table}" for table, _m, _c in _TABLES)
|
|
with SessionLocal() as db:
|
|
db.execute(text(f"TRUNCATE {table_list}"))
|
|
for directory in source_dirs:
|
|
absolute = str(directory.resolve())
|
|
db.add(GitSource(url=absolute, kind="local", path=absolute))
|
|
db.commit()
|
|
logger.info("load_test_kb: tables reset; %d local source rows added", len(source_dirs))
|
|
|
|
# 2. Import through the real pipeline (the only model-cost step).
|
|
llm = LLMClient()
|
|
summary = await import_sources([p.resolve() for p in source_dirs], llm)
|
|
if summary.errors:
|
|
print(f"load_test_kb: {summary.errors} file(s) failed to import — aborting")
|
|
return 1
|
|
if summary.added == 0:
|
|
print("load_test_kb: no documents imported — aborting")
|
|
return 1
|
|
logger.info(
|
|
"load_test_kb: imported added=%d chunks=%d embed_batches=%d",
|
|
summary.added, summary.chunks, summary.embed_batches,
|
|
)
|
|
|
|
# 3. The static overview + sources version.
|
|
with SessionLocal() as db:
|
|
overview = db.get(KbOverview, 1) or KbOverview(id=1)
|
|
overview.content = FIXTURE_KB_OVERVIEW
|
|
meta = db.get(SourcesMeta, 1) or SourcesMeta(id=1)
|
|
meta.version = 1
|
|
db.add(overview)
|
|
db.add(meta)
|
|
db.commit()
|
|
|
|
# 4. The sync-time folder summaries (phase 94, task 05): the
|
|
# drill-down ``ls`` shows the stored rows, so the dump carries
|
|
# them. Generated against the live ``lite`` endpoint through the
|
|
# real sync-time generator — the build just truncated the table,
|
|
# so the sync paths' table-empty first-run trigger holds (no
|
|
# change-gate bookkeeping needed on a fresh build). The generator
|
|
# only flushes; this build commits (the phase-53 convention the
|
|
# sync paths follow). A failed batch aborts: the dump's folder
|
|
# lines are part of the controlled fixture.
|
|
with SessionLocal() as db:
|
|
folder_stats = await generate_folder_summaries(db, llm)
|
|
db.commit()
|
|
logger.info(
|
|
"load_test_kb: folder summaries generated=%d failed=%d pruned=%d",
|
|
folder_stats["generated"], folder_stats["failed"], folder_stats["pruned"],
|
|
)
|
|
if folder_stats["failed"]:
|
|
print(
|
|
f"load_test_kb: {folder_stats['failed']} folder summary call(s) "
|
|
"failed — the fixture dump needs the stored rows; check the LLM "
|
|
"endpoint (BOR_LLM_SUMMARY_MODEL) and re-run"
|
|
)
|
|
return 1
|
|
|
|
# 5. Retrieval report (all battery questions must stay grounded).
|
|
n_deflected = await _retrieval_report(llm, list(FIXTURE_BATTERY))
|
|
if n_deflected:
|
|
print(
|
|
f"\nload_test_kb: WARNING — {n_deflected} battery question(s) would "
|
|
"DEFLECT in the real path (no tools offered). Adjust the fixture "
|
|
"content (a lexical anchor for the question's words) or the "
|
|
"question before running the gate."
|
|
)
|
|
|
|
# 6. Snapshot (data-only) + round-trip verification.
|
|
with SessionLocal() as db:
|
|
before = {table: _table_checksum(db, table) for table, _m, _c in _TABLES}
|
|
parts = [
|
|
"-- ============================================================",
|
|
"-- Brain of Reese — controlled tool-calling test KB (fixture dump)",
|
|
f"-- Generated by scripts/load_test_kb.py on "
|
|
f"{datetime.now().astimezone().isoformat(timespec='seconds')}",
|
|
"-- Data-only snapshot (the schema stays alembic-managed; the",
|
|
"-- generated chunks.tsv column is recomputed on restore).",
|
|
f"-- Sources: {', '.join(FIXTURE_SOURCES)} "
|
|
f"({summary.added} documents, {summary.chunks} content "
|
|
f"+ {summary.summaries + summary.summary_backfilled} summary chunks)",
|
|
"-- Restore (one transaction, sub-second):",
|
|
"-- uv run python -m scripts.restore_test_kb",
|
|
"-- psql \"$BOR_DATABASE_URL\" --single-transaction -f "
|
|
"tests/fixtures/test_kb.dump.sql",
|
|
"-- ============================================================",
|
|
f"TRUNCATE {table_list};",
|
|
"",
|
|
]
|
|
for table, model, columns in _TABLES:
|
|
parts.append(_dump_table(db, table, model, columns))
|
|
script = "\n".join(parts)
|
|
dump_path.parent.mkdir(parents=True, exist_ok=True)
|
|
dump_path.write_text(script, encoding="utf-8")
|
|
logger.info("load_test_kb: dump written: %s (%d KB)", dump_path, len(script) // 1024)
|
|
|
|
# Round-trip: restore the dump over the (identical) state and compare
|
|
# the per-table checksums — a serialization bug must fail the build.
|
|
from scripts.restore_test_kb import restore_dump
|
|
|
|
restore_dump(dump_path) # RuntimeError on failure → the build fails
|
|
with SessionLocal() as db:
|
|
after = {table: _table_checksum(db, table) for table, _m, _c in _TABLES}
|
|
mismatched = [t for t, c in before.items() if after.get(t) != c]
|
|
if mismatched:
|
|
print(f"load_test_kb: VERIFICATION FAILED — checksum mismatch: {mismatched}")
|
|
return 1
|
|
|
|
wall = time.monotonic() - started
|
|
print(
|
|
f"load_test_kb: ok — docs={summary.added} chunks={summary.chunks} "
|
|
f"sources={len(FIXTURE_SOURCES)} folder_summaries={folder_stats['generated']} "
|
|
f"dump={dump_path} "
|
|
f"({dump_path.stat().st_size // 1024} KB, verified by round-trip) "
|
|
f"in {wall:.1f}s"
|
|
)
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
# CLI-only: pick up .env without side effects on import (the house
|
|
# probe pattern, cf. scripts/llm_probe.py).
|
|
load_dotenv()
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Build the controlled tool-calling test KB from "
|
|
"tests/fixtures/agent_kb (real embeddings, once) and snapshot "
|
|
"it to tests/fixtures/test_kb.dump.sql (verified by "
|
|
"round-trip). Exit 0 built+verified, 1 failure, 2 precondition."
|
|
)
|
|
)
|
|
parser.add_argument(
|
|
"--kb-dir", type=Path, default=DEFAULT_KB_DIR,
|
|
help=f"the fixture KB root (default: {DEFAULT_KB_DIR})",
|
|
)
|
|
parser.add_argument(
|
|
"--dump", type=Path, default=DEFAULT_DUMP,
|
|
help=f"the dump file to write (default: {DEFAULT_DUMP})",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
logging.basicConfig(
|
|
level=logging.INFO, format="%(levelname)s %(name)s: %(message)s"
|
|
)
|
|
return asyncio.run(_build(args.kb_dir, args.dump))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|