Files
brain-of-reese/scripts/eval_retrieval.py
T
ducoterra ee3efb28c9
Build and Push Containers / build-and-push-app (push) Successful in 4m35s
Build and Push Containers / build-and-push-db (push) Successful in 14s
phase: 106_document_dates
Everything is verified green. Final report:

**Phase 106 — Document dates (final verification pass; all 10 tasks already complete)**

- Verified all phase artifacts: alembic `0020` (dev DB at `0020`), `doc_dates.py`, git `file_commit_dates`, importer `doc_dates_by_root`/`dates_updated`, both entry-point wirings, date APIs + tree `created_at`/`updated_at`, LLM surfaces (prompt block, `read` line 2, appended `ls` field), `apply_recency_boost` in `retrieve()`, UI columns/badge, admin editor, mock-LLM regex — all present and correct; no defects found, no fixes needed.
- `uv run pytest --cov=app --cov-report=term-missing` → **2299 passed, TOTAL 99%** (>90% ✓)
- `uv run pytest tests/e2e/test_document_dates.py -v --no-cov` → **6/6 passed** in isolation (DB up)
- 12 regression E2E suites (retrieval_quality, whole_document_context, agent_document_tools, ls_tree_drilldown, read_truncation_cap, kb_tree, kb_tree_nav, document_viewer, edit_summaries, import_documents, sync_button, hidden_folders_toggle, smoke) → **all green in isolation**
- `uv run ruff check .` → clean; `uv run pyright` → **0 errors, 0 warnings**

**Completion criteria:** 1) non-null `created_at` + 0020 upgrade/downgrade on dev DB ✓ (real-Alembic integration tests) 2) sync refresh/older/manual-persists/content-reset/no sources_meta bump ✓ 3) zip/tar mtime + future→today ✓ 4) LLM date surfaces + cross-check ✓ 5) UI Created/Updated/badge positions ✓ 6) admin editor set+revert round-trip ✓ 7) old-correct-beats-new-similar (defaults & boost-off) + near-tie + `BOR_RECENCY_BOOST=0` byte-identical ✓ 8) full gate ✓ 9) commit/phase-move — left to harness per instructions.

- **Notable:** recency default tuned 0.001 → **0.0007** (task 07 step 5 explicitly permits; measured margins recorded in `test_recency_boost.py` docstring).
- **Next pending phase:** none — `todo/` holds only this phase.
2026-09-13 19:28:05 -04:00

142 lines
5.2 KiB
Python

"""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 — plus the phase-106 recency
boost, which ``retrieve()`` applies after the fusion), and prints the
top-5 documents with their cosine / fts / fused scores (labelled
``effective`` when the recency boost is on — the post-boost score)
and the document's creation date, 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} "
f"recency_boost={settings.recency_boost} "
f"recency_half_life_days={settings.recency_half_life_days}"
)
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
# Phase 106, D6: the post-recency-boost score is the
# effective score ``retrieve()`` ranked by — labelled
# ``effective`` while the boost is on (``fused`` when the
# kill switch is off), with the document's creation date
# alongside (the boost's input).
score_label = "effective" if settings.recency_boost > 0 else "fused"
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)} "
f"{score_label}={c.score:.5f} created={c.document.created_at:%Y-%m-%d} "
f"({c.document.title})"
)
return 0
if __name__ == "__main__":
sys.exit(main())