"""Stage 10: run the golden eval set against the live pipeline. For each query in eval/golden_set.jsonl: - Recall@K: did any expected_book / expected_page show up in top-K retrieval? - Coverage (COMPARISON): fraction of expected_books that appear in the answer. - must_mention: do we observe each required phrase in the cited passages? - hallucination: count of `ungrounded_claims` in the response. Writes results to eval/results_{ISO_TIMESTAMP}.json so each run is its own row of history. Always commit results — the user uses them as a changelog. Run: python -m src.eval.run_eval python -m src.eval.run_eval --no-llm # retrieval-only (skip Stage 8 calls) python -m src.eval.run_eval --top-k 20 """ from __future__ import annotations import argparse import json from datetime import datetime, timezone from pathlib import Path from src.config import load_config def _load_golden(path: Path) -> list[dict]: rows: list[dict] = [] with path.open("r", encoding="utf-8") as f: for line in f: line = line.strip() if line: rows.append(json.loads(line)) return rows def _check_recall(retrieved_books: set[str], retrieved_pages: set[tuple[str, int]], expected: dict) -> dict: exp_books = set(expected.get("expected_books") or []) exp_pages = {(b, p) for b in exp_books for p in (expected.get("expected_pages") or [])} book_hit = bool(exp_books & retrieved_books) if exp_books else None page_hit = bool(exp_pages & retrieved_pages) if exp_pages else None return {"book_recall": book_hit, "page_recall": page_hit} def _check_must_mention(retrieved_text: str, must: list[str] | None) -> dict: if not must: return {"must_mention_present": None, "missing": []} missing = [m for m in must if m not in retrieved_text] return {"must_mention_present": len(missing) == 0, "missing": missing} def run_eval(no_llm: bool = False, top_k: int = 10) -> dict: cfg = load_config() golden_path = Path(cfg.section("eval").get("golden_set", "eval/golden_set.jsonl")) if not golden_path.is_absolute(): golden_path = Path.cwd() / golden_path rows = _load_golden(golden_path) # Lazy imports so --no-llm works even if the router LLM key isn't set. from src.stage7_retrieval.retrieve import retrieve per_query: list[dict] = [] for q in rows: hits = retrieve(q["query"], top_k=top_k) retrieved_books = {h.book_id for h in hits} retrieved_pages = {(h.book_id, h.page_number) for h in hits} retrieved_text = "\n".join(h.text for h in hits) record: dict = { "query_id": q["query_id"], "query": q["query"], "expected_books": q.get("expected_books") or [], "retrieval": { "k": top_k, "retrieved_books": sorted(retrieved_books), **_check_recall(retrieved_books, retrieved_pages, q), **_check_must_mention(retrieved_text, q.get("must_mention")), }, } if not no_llm: from src.stage8_router.router import answer try: resp = answer(q["query"], force_mode=q.get("mode")) consulted = set(resp.get("books_consulted") or []) exp_books = set(q.get("expected_books") or []) coverage = ( (len(consulted & exp_books) / len(exp_books)) if exp_books else None ) record["generation"] = { "mode": resp.get("mode"), "coverage": coverage, "ungrounded_claims_count": len(resp.get("ungrounded_claims") or []), "confidence": resp.get("confidence"), } except Exception as e: # noqa: BLE001 record["generation"] = {"error": f"{type(e).__name__}: {e}"} per_query.append(record) # Aggregate n = len(per_query) book_recall = sum(1 for r in per_query if r["retrieval"]["book_recall"]) / n if n else 0 page_recall_eligible = [r for r in per_query if r["retrieval"]["page_recall"] is not None] page_recall = ( sum(1 for r in page_recall_eligible if r["retrieval"]["page_recall"]) / len(page_recall_eligible) ) if page_recall_eligible else None summary = { "n_queries": n, "book_recall_at_k": book_recall, "page_recall_at_k": page_recall, "k": top_k, } out = { "timestamp": datetime.now(timezone.utc).isoformat(), "summary": summary, "per_query": per_query, } out_path = cfg.paths.eval_dir / f"results_{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}.json" out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8") print(f"\nResults: {out_path}") print(json.dumps(summary, indent=2)) return out def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description="Run the eval harness") ap.add_argument("--no-llm", action="store_true", help="retrieval only; skip Stage 8 LLM calls") ap.add_argument("--top-k", type=int, default=10) args = ap.parse_args(argv) run_eval(no_llm=args.no_llm, top_k=args.top_k) return 0 if __name__ == "__main__": raise SystemExit(main())