| """Eval harness for the Judge RAG pipeline (LLM-as-judge + retrieval recall). |
| |
| Usage (from backend/): |
| python -m scripts.eval |
| |
| Requires: DB with corpus ingestado, GEMINI_API_KEY (or JUDGE_* + LLM_* env vars). |
| Redis cache is intentionally NOT initialised — every question hits generation fresh. |
| """ |
| import argparse |
| import asyncio |
| import json |
| import os |
| import random |
| import sys |
| import time |
| from collections import OrderedDict |
| from datetime import datetime, timezone |
| from pathlib import Path |
|
|
| from dotenv import load_dotenv |
|
|
| load_dotenv() |
|
|
| from app.config import Settings |
| from app.db import close_pool, get_conn, init_pool |
| from app.rag.embedder import Embedder |
| from app.rag.pipeline import answer_question |
| from app.rag.provider import create_provider |
| from scripts.eval_judge import ( |
| _get_judge_config, |
| aggregate_by_difficulty, |
| aggregate_by_source, |
| compute_recall, |
| judge_answer, |
| match_rule_reference, |
| ) |
|
|
| _EVAL_SET = Path(__file__).parent.parent / "data" / "eval_set.json" |
| _RESULTS_DIR = Path(__file__).parent.parent / "data" |
|
|
| _VERDICT_ICON = {"correct": "OK", "partial": "~~", "wrong": "NO", "error": "ER"} |
|
|
|
|
| def _load_questions(path: Path) -> list[dict]: |
| """Load a question list from JSON, unwrapping a {"questions": [...]} envelope. |
| |
| Shared by the eval set and saved results-file loaders so the unwrap rule lives |
| in ONE place — a schema change can't leave the two paths reading different shapes. |
| """ |
| data = json.loads(path.read_text(encoding="utf-8")) |
| return data["questions"] if isinstance(data, dict) and "questions" in data else data |
|
|
|
|
| def _load_eval_set() -> list[dict]: |
| return _load_questions(_EVAL_SET) |
|
|
|
|
| def stratified_subset( |
| questions: list[dict], limit: int | None, *, key: str = "difficulty", seed: int = 42 |
| ) -> list[dict]: |
| """Deterministic stratified sample of *limit* questions. |
| |
| Preserves the proportion of each *key* stratum via largest-remainder |
| allocation, so a small run stays representative across difficulty (or |
| whatever *key* is) instead of biasing toward whatever comes first. Within a |
| stratum the pick is a seeded sample, so runs are reproducible. The result |
| keeps the original question order. |
| |
| Returns the full list unchanged when *limit* is None or >= the set size. |
| Why this exists: the LLM free tier can't absorb all 40 questions per run |
| without exhausting quota mid-eval and contaminating the results. |
| """ |
| if limit is None or limit >= len(questions): |
| return list(questions) |
| if limit <= 0: |
| return [] |
|
|
| groups: "OrderedDict[object, list[dict]]" = OrderedDict() |
| for q in questions: |
| groups.setdefault(q.get(key), []).append(q) |
|
|
| total = len(questions) |
| raw = {k: limit * len(v) / total for k, v in groups.items()} |
| alloc = {k: int(v) for k, v in raw.items()} |
|
|
| |
| |
| remainder = limit - sum(alloc.values()) |
| for k in sorted(groups, key=lambda k: (-(raw[k] - alloc[k]), str(k)))[:remainder]: |
| alloc[k] += 1 |
|
|
| chosen_ids: set = set() |
| deficit = 0 |
| for k, members in groups.items(): |
| n = min(alloc[k], len(members)) |
| deficit += alloc[k] - n |
| ordered = sorted(members, key=lambda q: str(q.get("id", ""))) |
| rnd = random.Random(f"{seed}:{k}") |
| picked = ordered if n >= len(ordered) else rnd.sample(ordered, n) |
| chosen_ids.update(q.get("id") for q in picked) |
|
|
| |
| if deficit: |
| leftovers = sorted( |
| (q for q in questions if q.get("id") not in chosen_ids), |
| key=lambda q: str(q.get("id", "")), |
| ) |
| chosen_ids.update(q.get("id") for q in leftovers[:deficit]) |
|
|
| return [q for q in questions if q.get("id") in chosen_ids] |
|
|
|
|
| def select_by_ids(questions: list[dict], ids) -> list[dict]: |
| """Filter to the questions whose id is in *ids*, preserving the original |
| order. Unknown ids are ignored. Used to run explicit disjoint batches so a |
| full clean eval can be assembled across several runs without exceeding the |
| LLM daily token budget in any single run.""" |
| wanted = set(ids) |
| return [q for q in questions if q.get("id") in wanted] |
|
|
|
|
| def _resolve_corpus_version(pool, settings: Settings) -> str: |
| if settings.corpus_version and settings.corpus_version != "latest": |
| return settings.corpus_version |
| with get_conn(pool) as conn: |
| with conn.cursor() as cur: |
| cur.execute("SELECT MAX(corpus_version) FROM corpus_chunks") |
| row = cur.fetchone() |
| if row is None or row[0] is None: |
| print("WARNING: corpus_chunks is empty — retrieval will return nothing.", file=sys.stderr) |
| return "unknown" |
| return row[0] |
|
|
|
|
| async def _pipeline_run(question: str, embedder, pool, provider, settings): |
| t0 = time.time() |
| try: |
| response = answer_question(question, embedder, pool, provider, settings) |
| return { |
| "ok": True, |
| "answer": response.answer, |
| "citations": response.citations, |
| "confidence": response.confidence, |
| "latency_ms": response.latency_ms, |
| } |
| except Exception as e: |
| return { |
| "ok": False, |
| "error": str(e), |
| "answer": "", |
| "citations": [], |
| "confidence": 0.0, |
| "latency_ms": round((time.time() - t0) * 1000), |
| } |
|
|
|
|
| def _build_question_result( |
| q: dict, idx: int, pipeline_result: dict, |
| *, has_ref: bool, retrieval_hit: bool, judgment: dict, |
| ) -> dict: |
| """Shape one question's eval record. Pure (no I/O) so it is unit-testable. |
| |
| Persists the FULL answer and canonical_answer — not just a preview — so a |
| later run can re-judge saved answers with a different judge WITHOUT |
| regenerating. Regeneration doubles the LLM calls per question and is what |
| exhausts the free-tier quota mid-experiment. |
| """ |
| return { |
| "id": q.get("id", f"q{idx}"), |
| "question": q["question"], |
| "difficulty": q.get("difficulty", "unknown"), |
| "source": q.get("source", "unknown"), |
| "rule_reference": q.get("rule_reference"), |
| "has_ref": has_ref, |
| "verdict": judgment["verdict"], |
| "justification": judgment["justification"], |
| "retrieval_hit": retrieval_hit, |
| "confidence": pipeline_result["confidence"], |
| "latency_ms": pipeline_result["latency_ms"], |
| "canonical_answer": q.get("canonical_answer", ""), |
| "answer": pipeline_result["answer"], |
| "answer_preview": pipeline_result["answer"][:300], |
| } |
|
|
|
|
| async def run_eval(questions: list[dict], embedder, pool, provider, settings) -> list[dict]: |
| results = [] |
| total = len(questions) |
|
|
| for i, q in enumerate(questions, 1): |
| label = q["question"][:55] + ("..." if len(q["question"]) > 55 else "") |
| print(f"[{i:2}/{total}] {label}", end=" ", flush=True) |
|
|
| pipeline_result = await _pipeline_run(q["question"], embedder, pool, provider, settings) |
|
|
| if not pipeline_result["ok"]: |
| print(f" [pipeline error] {pipeline_result.get('error', '')[:120]}") |
|
|
| has_ref = q.get("rule_reference") is not None |
| retrieval_hit = False |
| if has_ref and pipeline_result["ok"]: |
| retrieval_hit = match_rule_reference( |
| q["rule_reference"], pipeline_result["citations"] |
| ) |
|
|
| if pipeline_result["ok"]: |
| judgment = judge_answer( |
| q["question"], q["canonical_answer"], pipeline_result["answer"] |
| ) |
| else: |
| judgment = { |
| "verdict": "error", |
| "justification": f"Pipeline error: {pipeline_result.get('error', 'unknown')}", |
| } |
|
|
| verdict = judgment["verdict"] |
| icon = _VERDICT_ICON.get(verdict, "?") |
| hit_str = ("H" if retrieval_hit else "M") if has_ref else "-" |
| print(f"{icon} ret={hit_str} conf={pipeline_result['confidence']:.2f} {pipeline_result['latency_ms']}ms") |
|
|
| if i < total: |
| await asyncio.sleep(2) |
|
|
| results.append(_build_question_result( |
| q, i, pipeline_result, |
| has_ref=has_ref, retrieval_hit=retrieval_hit, judgment=judgment, |
| )) |
|
|
| return results |
|
|
|
|
| def rejudge_results(saved: list[dict], *, judge=judge_answer) -> list[dict]: |
| """Re-score saved answers with the current judge, WITHOUT regenerating. |
| |
| Reads back each persisted full answer + canonical_answer and re-runs only the |
| judge — no DB, embedder, or generation. Retrieval-derived fields (has_ref, |
| retrieval_hit) are deterministic and carried over unchanged. Records that |
| predate full-answer persistence (no 'answer'/'canonical_answer') are marked |
| verdict='error' since they cannot be faithfully re-judged. |
| |
| Why this exists: swapping the judge (e.g. weak local vs strong cloud) used to |
| require a full re-run, doubling LLM calls and exhausting the free-tier quota. |
| """ |
| out = [] |
| total = len(saved) |
| for i, q in enumerate(saved, 1): |
| question = q.get("question", "") |
| label = question[:55] + ("..." if len(question) > 55 else "") |
| print(f"[{i:2}/{total}] {label}", end=" ", flush=True) |
|
|
| answer = q.get("answer") |
| canonical = q.get("canonical_answer") |
| if not answer or not canonical: |
| judgment = { |
| "verdict": "error", |
| "justification": "Cannot re-judge: saved result lacks full " |
| "answer/canonical_answer (re-run generation first)", |
| } |
| else: |
| judgment = judge(question, canonical, answer) |
|
|
| prev = q.get("verdict", "?") |
| rec = {**q, "verdict": judgment["verdict"], "justification": judgment["justification"]} |
| print(f"{_VERDICT_ICON.get(rec['verdict'], '?')} (was {prev})") |
| out.append(rec) |
|
|
| return out |
|
|
|
|
| def _judge_mode_label() -> str: |
| """Human label for which judge will run. |
| |
| Derived from the SAME resolution _get_judge_config performs, so the banner can |
| never contradict the judge actually used. A bare JUDGE_BASE_URL (without |
| JUDGE_API_KEY/JUDGE_MODEL) does NOT yield an openai_compat config — the judge |
| falls through to Gemini — and the label must say so. |
| """ |
| if os.getenv("JUDGE_PROVIDER", "").lower() == "gemini": |
| return "gemini (forced via JUDGE_PROVIDER)" |
| if _get_judge_config() is not None: |
| |
| |
| if os.getenv("JUDGE_BASE_URL") and os.getenv("JUDGE_API_KEY") and os.getenv("JUDGE_MODEL"): |
| return "openai_compat (JUDGE_*)" |
| return "openai_compat (LLM_* fallback — shares quota with pipeline!)" |
| if os.getenv("GEMINI_API_KEY"): |
| return "gemini (GEMINI_API_KEY)" |
| return "NONE — judge will error (set JUDGE_*/LLM_*/GEMINI_API_KEY)" |
|
|
|
|
| def _print_report(results: list[dict]) -> None: |
| total = len(results) |
| verdicts = {v: sum(1 for r in results if r["verdict"] == v) for v in ("correct", "partial", "wrong", "error")} |
| recall = compute_recall(results) |
| avg_conf = sum(r["confidence"] for r in results) / total if total else 0.0 |
| avg_latency = sum(r["latency_ms"] for r in results) / total if total else 0.0 |
|
|
| print("\n" + "=" * 60) |
| print("EVAL RESULTS") |
| print("=" * 60) |
| print(f" Total questions : {total}") |
| if not total: |
| print(" (no results to report)") |
| print("=" * 60) |
| return |
| print(f" Accuracy (judge): correct={verdicts['correct']} partial={verdicts['partial']} wrong={verdicts['wrong']} error={verdicts['error']}") |
| print(f" Correct rate : {verdicts['correct'] / total:.0%} (correct+partial: {(verdicts['correct'] + verdicts['partial']) / total:.0%})") |
| print(f" Retrieval recall: {recall['hits']}/{recall['evaluable']} evaluable questions = {recall['recall']:.0%}") |
| print(f" ({recall['null_ref']} questions excluded — no rule_reference)") |
| print(f" Avg confidence : {avg_conf:.3f}") |
| print(f" Avg latency : {avg_latency:.0f}ms") |
|
|
| print("\n By difficulty:") |
| for diff, counts in sorted(aggregate_by_difficulty(results).items()): |
| acc = counts["correct"] / counts["total"] if counts["total"] else 0.0 |
| print(f" {diff:8s}: {counts['correct']}ok {counts['partial']}~~ {counts['wrong']}no {counts['error']}er / {counts['total']} ({acc:.0%})") |
|
|
| print("\n By source:") |
| for src, counts in sorted(aggregate_by_source(results).items()): |
| acc = counts["correct"] / counts["total"] if counts["total"] else 0.0 |
| print(f" {src:12s}: {counts['correct']}ok {counts['partial']}~~ {counts['wrong']}no {counts['error']}er / {counts['total']} ({acc:.0%})") |
|
|
| print("\n NOTE: judge verdicts are non-deterministic (LLM). Retrieval recall is deterministic.") |
| print("=" * 60) |
|
|
|
|
| def _save_results(results: list[dict]) -> Path: |
| ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") |
| out_path = _RESULTS_DIR / f"eval_results_{ts}.json" |
| payload = { |
| "timestamp": ts, |
| "total": len(results), |
| "recall": compute_recall(results), |
| "by_difficulty": aggregate_by_difficulty(results), |
| "by_source": aggregate_by_source(results), |
| "questions": results, |
| } |
| out_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") |
| return out_path |
|
|
|
|
| def _parse_args(argv=None) -> argparse.Namespace: |
| p = argparse.ArgumentParser(description="Eval harness for the Judge RAG pipeline.") |
| p.add_argument( |
| "--limit", type=int, default=None, |
| help="Run a stratified subset of N questions (preserves difficulty mix). " |
| "Default: all questions. Use when the LLM free tier can't absorb the full set.", |
| ) |
| p.add_argument( |
| "--seed", type=int, default=42, |
| help="Seed for the stratified subset pick (reproducible runs). Default: 42.", |
| ) |
| p.add_argument( |
| "--ids", type=str, default=None, |
| help="Comma-separated question ids to run (explicit disjoint batch). " |
| "Overrides --limit. Lets you assemble a clean full eval across runs.", |
| ) |
| p.add_argument( |
| "--rejudge", type=str, default=None, metavar="RESULTS_JSON", |
| help="Re-score the saved answers in a previous eval_results_*.json with " |
| "the CURRENT judge (set via JUDGE_*/JUDGE_PROVIDER), WITHOUT " |
| "regenerating. No DB/embedder/generation. Needs a results file that " |
| "has full answers (produced by this harness version).", |
| ) |
| return p.parse_args(argv) |
|
|
|
|
| def _load_results_file(path: str) -> list[dict]: |
| return _load_questions(Path(path)) |
|
|
|
|
| def main() -> None: |
| args = _parse_args() |
|
|
| if args.rejudge: |
| print(f"Re-judging saved results: {args.rejudge}") |
| saved = _load_results_file(args.rejudge) |
| print(f" {len(saved)} saved questions loaded.") |
| print(f" Judge: {_judge_mode_label()}") |
| print("\nRe-judging (no generation — scoring the saved answers):\n") |
| results = rejudge_results(saved) |
| _print_report(results) |
| out_path = _save_results(results) |
| print(f"\nResults saved: {out_path}") |
| return |
|
|
| print("Loading eval set...") |
| questions = _load_eval_set() |
| print(f" {len(questions)} questions loaded.") |
|
|
| from collections import Counter |
| if args.ids: |
| ids = [s.strip() for s in args.ids.split(",") if s.strip()] |
| questions = select_by_ids(questions, ids) |
| if not questions: |
| print(f" No questions matched --ids {args.ids!r} — nothing to run.") |
| return |
| mix = dict(Counter(q.get("difficulty", "unknown") for q in questions)) |
| print(f" Explicit ids: {len(questions)} questions, difficulty mix {mix}") |
| print(f" ids: {', '.join(q.get('id', '?') for q in questions)}") |
| elif args.limit is not None and args.limit < len(questions): |
| questions = stratified_subset(questions, args.limit, seed=args.seed) |
| mix = dict(Counter(q.get("difficulty", "unknown") for q in questions)) |
| print(f" Stratified subset: {len(questions)} questions (seed={args.seed}), difficulty mix {mix}") |
| print(f" ids: {', '.join(q.get('id', '?') for q in questions)}") |
|
|
| print("Loading settings...") |
| settings = Settings() |
|
|
| print("Initialising DB pool...") |
| pool = init_pool(settings.database_url, minconn=1, maxconn=3) |
|
|
| corpus_version = _resolve_corpus_version(pool, settings) |
| settings.corpus_version = corpus_version |
| print(f" corpus_version = {corpus_version}") |
|
|
| print("Loading embedder (takes ~5-10s)...") |
| embedder = Embedder.load(settings.model_name) |
| print(" Embedder ready.") |
|
|
| print("Initialising LLM provider...") |
| if settings.llm_provider == "gemini": |
| from google import genai |
| llm_client = genai.Client(api_key=settings.gemini_api_key) |
| else: |
| llm_client = None |
| provider = create_provider(settings, llm_client) |
| print(f" Provider: {settings.llm_provider}") |
|
|
| print(f" Judge: {_judge_mode_label()}") |
|
|
| print("\nRunning eval (no Redis cache — fresh generation per question):\n") |
|
|
| try: |
| results = asyncio.run(run_eval(questions, embedder, pool, provider, settings)) |
| finally: |
| close_pool(pool) |
|
|
| _print_report(results) |
| out_path = _save_results(results) |
| print(f"\nResults saved: {out_path}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|