SafeRoute-RAG / scripts /run_route_eval.py
github-actions[bot]
Deploy 89e9e17ad054631045a5d010983e6f06027259e0
6fd034e
Raw History Blame Contribute Delete
4.76 kB
#!/usr/bin/env python3
"""Run the route regression over manifest demo_questions and emit a result table.
Usage:
uv run python scripts/run_route_eval.py # routing only
uv run python scripts/run_route_eval.py --retrieval # also check Top-K expected_docs
Hard-rule cases run fully offline. Semantic-audit cases require AIAND_API_KEY;
without it they are reported as SKIPPED rather than failing.
"""
from __future__ import annotations
import argparse
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.config_loader import ConfigLoader # noqa: E402
from src.llm_client import LLMClient # noqa: E402
from src.router import HybridRouter # noqa: E402
MANIFEST_PATH: Path = ROOT / "sample_docs" / "manifest.yaml"
OUTPUT_PATH: Path = ROOT / "eval_results.md"
@dataclass
class RowResult:
case_id: str
bucket: str
expected: str
actual: str
layer: str
detail: str
status: str
retrieval: str = "-"
def _load_cases() -> list[dict[str, Any]]:
with MANIFEST_PATH.open("r", encoding="utf-8") as fh:
manifest = yaml.safe_load(fh)
dq = manifest.get("demo_questions", {})
cases: list[dict[str, Any]] = []
for bucket in ("safe", "unsafe"):
for item in dq.get(bucket, []):
if "expected_route" in item:
item = {**item, "_bucket": bucket}
cases.append(item)
return cases
def _evaluate(retrieval: bool) -> list[RowResult]:
config = ConfigLoader().get()
router = HybridRouter(config, LLMClient(config))
has_key = bool(os.environ.get("AIAND_API_KEY"))
kb = None
if retrieval:
from src.database import LocalKnowledgeBase
kb = LocalKnowledgeBase(config.rag)
if kb.count() == 0:
kb.ingest_manifest(reset=True)
rows: list[RowResult] = []
for case in _load_cases():
cid = case["id"]
bucket = case["_bucket"]
expected = case["expected_route"]
if not has_key:
rows.append(RowResult(cid, bucket, expected, "-", "semantic_audit", "no API key", "SKIPPED"))
continue
try:
decision = router.route(case["question"])
except Exception as exc: # noqa: BLE001 - surface as table cell, not crash
rows.append(RowResult(cid, bucket, expected, "ERROR", "-", str(exc)[:60], "ERROR"))
continue
status = "PASS" if decision.label == expected else "FAIL"
detail = decision.reason or (", ".join(decision.matched_keywords) or "-")
retrieval_cell = "-"
if kb is not None:
expected_docs = set(case.get("expected_docs", []))
allowed = None if decision.label == "UNSAFE" else {"public", "uploaded"}
hit_docs = [c.doc_id for c in kb.retrieve(case["question"], allowed_tiers=allowed)]
ok = bool(expected_docs & set(hit_docs))
retrieval_cell = ("OK " if ok else "MISS ") + "/".join(hit_docs[:3])
rows.append(RowResult(cid, bucket, expected, decision.label, decision.layer, detail, status, retrieval_cell))
return rows
def _render_table(rows: list[RowResult], retrieval: bool) -> str:
header = "| ID | Bucket | Expected | Actual | Layer | Detail | Status |"
sep = "| --- | --- | --- | --- | --- | --- | --- |"
if retrieval:
header = header[:-1] + " Retrieval (expected_docs hit) |"
sep = sep[:-1] + " --- |"
lines = [header, sep]
for r in rows:
cells = [r.case_id, r.bucket, r.expected, r.actual, r.layer, r.detail.replace("|", "/"), r.status]
if retrieval:
cells.append(r.retrieval)
lines.append("| " + " | ".join(cells) + " |")
graded = [r for r in rows if r.status in {"PASS", "FAIL"}]
passed = sum(1 for r in graded if r.status == "PASS")
skipped = sum(1 for r in rows if r.status == "SKIPPED")
summary = (
f"\n**Summary:** {passed}/{len(graded)} routed cases passed"
f" | {skipped} skipped (no API key)"
f" | {len(rows)} total\n"
)
return "# SafeRoute-RAG Route Evaluation\n\n" + "\n".join(lines) + "\n" + summary
def main() -> None:
parser = argparse.ArgumentParser(description="SafeRoute-RAG route regression runner")
parser.add_argument("--retrieval", action="store_true", help="also check Top-K expected_docs")
args = parser.parse_args()
rows = _evaluate(retrieval=args.retrieval)
table = _render_table(rows, retrieval=args.retrieval)
OUTPUT_PATH.write_text(table, encoding="utf-8")
print(table)
print(f"\nSaved to {OUTPUT_PATH}")
if __name__ == "__main__":
main()