| """Evaluation harness for the Christianity AI assistant. |
| |
| Runs every case in dataset.json through the compiled LangGraph agent, |
| scores each PASS / PARTIAL / FAIL, and prints a category-grouped report. |
| |
| Usage (from repo root): |
| docker compose up -d |
| cd backend |
| uv run python ../eval/run_eval.py [--id <case-id>] [--category <cat>] |
| |
| Exit code: 0 if all PASS/PARTIAL, 1 if any FAIL. |
| """ |
|
|
| import argparse |
| import json |
| import sys |
| import uuid |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import Literal |
|
|
| |
| sys.path.insert(0, str(Path(__file__).parents[1] / "backend")) |
|
|
| from app.agent.graph import get_graph |
| from app.core.db import close_pool |
| from app.logging_config import configure_logging |
|
|
| configure_logging(log_level="WARNING") |
|
|
| Score = Literal["PASS", "PARTIAL", "FAIL"] |
|
|
| DATASET = Path(__file__).parent / "dataset.json" |
|
|
|
|
| @dataclass |
| class Result: |
| case_id: str |
| category: str |
| description: str |
| score: Score |
| findings: list[str] = field(default_factory=list) |
| intent: str = "" |
| flagged: bool = False |
| citations: int = 0 |
| hallucinated: list[str] = field(default_factory=list) |
| error: str = "" |
|
|
|
|
| def run_case(graph, case: dict) -> Result: |
| checks = case["checks"] |
| result = Result( |
| case_id=case["id"], |
| category=case["category"], |
| description=case["description"], |
| score="PASS", |
| ) |
|
|
| try: |
| state = graph.invoke({ |
| "session_id": f"eval-{case['id']}", |
| "user_message": case["message"], |
| "denomination": case.get("denomination", "protestant"), |
| "request_id": str(uuid.uuid4()), |
| "messages": [], |
| "latency_ms": {}, |
| "memory_turns": [], |
| }) |
| except Exception as exc: |
| result.score = "FAIL" |
| result.error = str(exc) |
| result.findings.append(f"CRASH: {exc}") |
| return result |
|
|
| resp_lower = state["final_response"].lower() |
| result.intent = state.get("intent", "") |
| result.flagged = state.get("flagged", False) |
| result.citations = len(state.get("verified_citations", [])) |
| result.hallucinated = state.get("hallucinated_refs", []) |
|
|
| failures, partials = [], [] |
|
|
| |
| if checks.get("must_be_flagged") and not result.flagged: |
| failures.append("expected flagged=True but got False") |
|
|
| |
| if "must_not_be_flagged" in checks and checks["must_not_be_flagged"] is False: |
| pass |
|
|
| |
| if "expected_intents" in checks: |
| if result.intent not in checks["expected_intents"]: |
| partials.append(f"intent={result.intent!r} not in {checks['expected_intents']}") |
|
|
| |
| if "must_contain_hallucinated_ref" in checks: |
| ref = checks["must_contain_hallucinated_ref"] |
| if ref not in result.hallucinated: |
| |
| if ref.lower() in state["final_response"].lower(): |
| failures.append(f"hallucinated ref {ref!r} appeared in response but not caught") |
| |
|
|
| |
| for phrase in checks.get("response_must_not_contain", []): |
| if phrase.lower() in resp_lower: |
| failures.append(f"response contains forbidden phrase: {phrase!r}") |
|
|
| |
| required_any = checks.get("response_must_contain_any", []) |
| if required_any: |
| if not any(p.lower() in resp_lower for p in required_any): |
| partials.append(f"response missing expected signal (one of: {required_any})") |
|
|
| |
| if "verified_citations_min" in checks: |
| mn = checks["verified_citations_min"] |
| if result.citations < mn: |
| partials.append(f"citations={result.citations} < required {mn}") |
|
|
| |
| if "verified_citations_max" in checks: |
| mx = checks["verified_citations_max"] |
| if result.citations > mx: |
| partials.append(f"citations={result.citations} > max allowed {mx}") |
|
|
| |
| if checks.get("image_url_must_be_empty") and state.get("image_url"): |
| failures.append("image_url is non-empty but should be blocked") |
|
|
| |
| if checks.get("image_safety_must_be_checked"): |
| if not state.get("sanitized_image_prompt"): |
| partials.append("image prompt was not rewritten (sanitized_image_prompt empty)") |
|
|
| |
| if failures: |
| result.score = "FAIL" |
| result.findings.extend(failures) |
| elif partials: |
| result.score = "PARTIAL" |
| result.findings.extend(partials) |
|
|
| return result |
|
|
|
|
| SCORE_ICON = {"PASS": "β", "PARTIAL": "~", "FAIL": "β"} |
| SCORE_COLOR = {"PASS": "\033[32m", "PARTIAL": "\033[33m", "FAIL": "\033[31m"} |
| RESET = "\033[0m" |
|
|
|
|
| def print_report(results: list[Result]) -> bool: |
| by_cat: dict[str, list[Result]] = {} |
| for r in results: |
| by_cat.setdefault(r.category, []).append(r) |
|
|
| totals = {"PASS": 0, "PARTIAL": 0, "FAIL": 0} |
| any_fail = False |
|
|
| for cat, cat_results in sorted(by_cat.items()): |
| print(f"\n{'β' * 70}") |
| print(f" {cat.upper()}") |
| print(f"{'β' * 70}") |
| for r in cat_results: |
| color = SCORE_COLOR[r.score] |
| icon = SCORE_ICON[r.score] |
| totals[r.score] += 1 |
| if r.score == "FAIL": |
| any_fail = True |
| print( |
| f" {color}{icon} {r.score:<7}{RESET} [{r.case_id}] {r.description}" |
| ) |
| print( |
| f" intent={r.intent!r} flagged={r.flagged}" |
| f" citations={r.citations} hall={len(r.hallucinated)}" |
| ) |
| for finding in r.findings: |
| print(f" β³ {finding}") |
| if r.error: |
| print(f" β³ ERROR: {r.error}") |
|
|
| print(f"\n{'β' * 70}") |
| print( |
| f" RESULTS: " |
| f"{SCORE_COLOR['PASS']}PASS {totals['PASS']}{RESET} " |
| f"{SCORE_COLOR['PARTIAL']}PARTIAL {totals['PARTIAL']}{RESET} " |
| f"{SCORE_COLOR['FAIL']}FAIL {totals['FAIL']}{RESET} " |
| f"/ {sum(totals.values())} total" |
| ) |
| print(f"{'β' * 70}\n") |
| return not any_fail |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser(description="Christianity AI eval harness") |
| parser.add_argument("--id", help="Run a single case by id") |
| parser.add_argument("--category", help="Run all cases in a category") |
| args = parser.parse_args() |
|
|
| cases = json.loads(DATASET.read_text()) |
|
|
| if args.id: |
| cases = [c for c in cases if c["id"] == args.id] |
| if args.category: |
| cases = [c for c in cases if c["category"] == args.category] |
|
|
| if not cases: |
| print("No matching cases found.") |
| sys.exit(1) |
|
|
| print(f"\nRunning {len(cases)} eval case(s)...\n") |
| graph = get_graph() |
| results = [] |
|
|
| for i, case in enumerate(cases, 1): |
| print(f" [{i}/{len(cases)}] {case['id']}...", end=" ", flush=True) |
| r = run_case(graph, case) |
| icon = SCORE_ICON[r.score] |
| print(f"{SCORE_COLOR[r.score]}{icon} {r.score}{RESET}") |
| results.append(r) |
|
|
| close_pool() |
| ok = print_report(results) |
| sys.exit(0 if ok else 1) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|