Judge / backend /scripts /eval.py
GonzaViss's picture
fix(eval): remove erroneous await on synchronous answer_question
3ff865a
Raw
History Blame Contribute Delete
17.7 kB
"""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()}
# Distribute the rounding remainder to the largest fractional parts; tie-break
# by str(key) so the allocation is fully deterministic.
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 capping a stratum left us short, top up from any leftover, deterministically.
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:
# Config resolved. Distinguish the dedicated JUDGE_* endpoint from the
# LLM_* fallback, which shares rate-limit quota with the pipeline.
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()