my-homelab-configs/scripts/ai-evals

202 lines
6.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Deterministic eval harness for Jeannie and homelab RAG behavior."""
from __future__ import annotations
import argparse
import csv
import json
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CASES_FILE = REPO_ROOT / "infra" / "ai-evals" / "cases.tsv"
FIXTURES_DIR = REPO_ROOT / "infra" / "ai-evals" / "fixtures"
@dataclass
class Case:
case_id: str
category: str
mode: str
fixture: str
required_terms: list[str]
forbidden_terms: list[str]
notes: str
def split_terms(value: str) -> list[str]:
return [item.strip() for item in value.split(";") if item.strip()]
def load_cases() -> list[Case]:
with CASES_FILE.open(encoding="utf-8", newline="") as handle:
rows = csv.DictReader(handle, delimiter="\t")
return [
Case(
case_id=row["id"],
category=row["category"],
mode=row["mode"],
fixture=row["fixture"],
required_terms=split_terms(row["required_terms"]),
forbidden_terms=split_terms(row["forbidden_terms"]),
notes=row["notes"],
)
for row in rows
]
def fixture_text(case: Case) -> str:
path = FIXTURES_DIR / case.fixture
if not path.is_file():
raise SystemExit(f"Missing fixture for {case.case_id}: {path}")
return path.read_text(encoding="utf-8")
def run_explain_output(case: Case) -> str:
process = subprocess.run(
[str(REPO_ROOT / "scripts" / "explain"), "output"],
input=fixture_text(case),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=REPO_ROOT,
check=False,
)
return process.stdout
def run_repo_scan(case: Case) -> str:
haystack_parts: list[str] = [fixture_text(case), "\nRETRIEVED CONTEXT\n"]
for path in [
REPO_ROOT / "README.md",
REPO_ROOT / "docs" / "jeannie.1.md",
REPO_ROOT / "docs" / "runbooks" / "cluster-stop-start-failures.md",
REPO_ROOT / "docs" / "runbooks" / "edge-failures.md",
REPO_ROOT / "docs" / "runbooks" / "gitea-failures.md",
]:
if path.is_file():
rel = path.relative_to(REPO_ROOT)
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
matches: list[str] = []
for index, line in enumerate(lines):
lowered = line.lower()
if any(term.lower() in lowered for term in case.required_terms):
start = max(index - 1, 0)
end = min(index + 2, len(lines))
matches.extend(lines[start:end])
if matches:
haystack_parts.append(f"\nSOURCE {rel}\n")
haystack_parts.extend(matches[:24])
return "\n".join(haystack_parts)
def run_case(case: Case) -> dict[str, object]:
if case.mode == "explain-output":
output = run_explain_output(case)
elif case.mode == "repo-scan":
output = run_repo_scan(case)
else:
return {
"id": case.case_id,
"status": "fail",
"reason": f"unknown mode {case.mode}",
"missing": [],
"forbidden_hits": [],
"output": "",
}
lowered = output.lower()
missing = [term for term in case.required_terms if term.lower() not in lowered]
forbidden_hits = [term for term in case.forbidden_terms if term.lower() in lowered]
status = "pass" if not missing and not forbidden_hits else "fail"
reason = "ok" if status == "pass" else "term checks failed"
return {
"id": case.case_id,
"category": case.category,
"status": status,
"reason": reason,
"missing": missing,
"forbidden_hits": forbidden_hits,
"notes": case.notes,
"output": output,
}
def print_list(cases: list[Case]) -> int:
for case in cases:
print(f"{case.case_id}\t{case.category}\t{case.mode}\t{case.notes}")
return 0
def print_show(cases: list[Case], case_id: str) -> int:
for case in cases:
if case.case_id == case_id:
print(f"id: {case.case_id}")
print(f"category: {case.category}")
print(f"mode: {case.mode}")
print(f"fixture: {case.fixture}")
print(f"required: {', '.join(case.required_terms)}")
print(f"forbidden: {', '.join(case.forbidden_terms)}")
print(f"notes: {case.notes}")
return 0
print(f"Unknown eval case: {case_id}", file=sys.stderr)
return 2
def print_run(cases: list[Case], case_id: str | None, as_json: bool, details: bool) -> int:
selected = [case for case in cases if case_id in (None, case.case_id)]
if not selected:
print(f"Unknown eval case: {case_id}", file=sys.stderr)
return 2
results = [run_case(case) for case in selected]
if as_json:
print(json.dumps(results, indent=2, sort_keys=True))
else:
failures = sum(1 for result in results if result["status"] != "pass")
print("Homelab AI Evals")
print("================")
print(f"cases={len(results)} failures={failures}")
print()
for result in results:
print(f"{result['status']:5} {result['id']} - {result['reason']}")
if result["missing"]:
print(f" missing: {', '.join(result['missing'])}")
if result["forbidden_hits"]:
print(f" forbidden: {', '.join(result['forbidden_hits'])}")
if details:
print(" output:")
for line in str(result["output"]).splitlines()[:40]:
print(f" {line}")
return 1 if any(result["status"] != "pass" for result in results) else 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("list")
show_parser = subparsers.add_parser("show")
show_parser.add_argument("case_id")
run_parser = subparsers.add_parser("run")
run_parser.add_argument("--case", dest="case_id")
run_parser.add_argument("--json", action="store_true")
run_parser.add_argument("--details", action="store_true")
args = parser.parse_args()
cases = load_cases()
if args.command == "list":
return print_list(cases)
if args.command == "show":
return print_show(cases, args.case_id)
if args.command == "run":
return print_run(cases, args.case_id, args.json, args.details)
return 2
if __name__ == "__main__":
raise SystemExit(main())