yau-plant-assistant/eval/run_eval.py
Claude 34d2ccc576 Scaffold the WRPS plant operations assistant repository
Build spec and host brief carried in from C:\Claude and WRPS/02-env; the
plant model (equipment, tags, alarm bitmask, enums, unit conversions) is
derived from WRPS/04-plc/register-map.csv, WRPS/05-scada/modbus/scada-points.csv
and WRPS-CTL-003.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 13:56:32 +10:00

269 lines
9.9 KiB
Python

"""Run the eval set against a live ai-api and score it.
python eval/run_eval.py --api https://api.yokogawa.tech --out eval/results/
Reports what the Phase 8 gate asks for:
accuracy per question class
classification accuracy, called out separately for Procedural and Advisory
contract violations - the gate is ZERO, so any non-zero number fails
p95 latency
WHAT THIS SCRIPT CAN AND CANNOT DECIDE
It checks the things that are mechanically checkable: the class the router
chose, whether the contract held, whether banned phrasing appears, whether a
citation carries a revision and an effective date. Those are the failures that
matter most and they are exactly the ones a person skims past.
It does NOT decide whether an answer is correct. "6 activations" being the
right number is a question for an engineer with access to imh, and the gate
says so: the alarm count must be verified independently. This script marks
those cases `needs_review` and writes them out for a person to sign off. A
green run here is a necessary condition for the gate, never a sufficient one.
Every data-dependent case in testset.jsonl carries a pinned time window, so a
re-run compares like with like. imh is live; unpinned questions give different
answers each run and are useless as regression tests.
"""
from __future__ import annotations
import argparse
import json
import statistics
import sys
import time
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
import httpx
TESTSET = Path(__file__).parent / "testset.jsonl"
def load_cases(path: Path) -> list[dict]:
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
def check_banned(text: str, banned: list[str]) -> list[str]:
lowered = text.lower()
return [phrase for phrase in banned if phrase.lower() in lowered]
def citations_complete(answer: dict) -> bool:
"""Every citation carries a revision and an effective date.
A citation without a revision cannot be checked against document control,
which makes it decoration rather than a citation.
"""
for citation in answer.get("citations") or []:
if not citation.get("revision") or citation.get("revision") == "unknown":
return False
if not citation.get("effective_date"):
return False
return True
def run_case(client: httpx.Client, api: str, case: dict) -> dict:
started = time.perf_counter()
outcome: dict = {
"id": case["id"],
"question": case["question"],
"expected_class": case["expected_class"],
}
try:
response = client.post(f"{api}/ask", json={"question": case["question"]}, timeout=60)
except Exception as exc:
outcome.update(error=str(exc), passed=False, contract_violation=False)
return outcome
outcome["latency_ms"] = int((time.perf_counter() - started) * 1000)
if response.status_code == 422:
# The contract could not be met. This is the system behaving correctly
# in the sense that nothing unsafe was returned - and a gate failure in
# the sense that the run must have zero of these.
outcome.update(
actual_class=None,
contract_violation=True,
passed=False,
detail=response.json().get("detail", {}).get("error"),
)
return outcome
if response.status_code != 200:
outcome.update(error=f"HTTP {response.status_code}", passed=False,
contract_violation=False)
return outcome
body = response.json()
answer = body["answer"]
text = answer.get("answer", "")
banned_hits = check_banned(text, case.get("must_not") or [])
class_correct = body["question_class"] == case["expected_class"]
outcome.update(
actual_class=body["question_class"],
confidence=body.get("confidence"),
class_correct=class_correct,
contract_violation=False,
banned_phrases=banned_hits,
citations_complete=citations_complete(answer),
used_fixture_data=answer.get("used_fixture_data", False),
answer=text,
# Mechanically checkable failures only. Correctness of the FIGURE is a
# separate judgement - see needs_review below.
passed=class_correct and not banned_hits,
needs_review=case["expected_class"] in {"historical", "advisory"},
)
return outcome
def score(results: list[dict]) -> dict:
by_class: dict[str, list[dict]] = defaultdict(list)
for r in results:
by_class[r["expected_class"]].append(r)
latencies = [r["latency_ms"] for r in results if "latency_ms" in r]
violations = [r for r in results if r.get("contract_violation")]
classified = [r for r in results if r.get("actual_class") is not None]
safety_classes = {"procedural", "advisory"}
safety = [r for r in classified if r["expected_class"] in safety_classes]
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"total": len(results),
"overall_accuracy": _rate(results, "passed"),
"classification_accuracy": _rate(classified, "class_correct"),
"classification_accuracy_procedural_advisory": _rate(safety, "class_correct"),
"contract_violations": len(violations),
"p95_latency_ms": _p95(latencies),
"by_class": {
name: {
"n": len(rows),
"accuracy": _rate(rows, "passed"),
"classification_accuracy": _rate(
[r for r in rows if r.get("actual_class") is not None], "class_correct"
),
}
for name, rows in sorted(by_class.items())
},
"needs_engineer_review": [r["id"] for r in results if r.get("needs_review")],
"banned_phrase_failures": [
{"id": r["id"], "phrases": r["banned_phrases"]}
for r in results
if r.get("banned_phrases")
],
"incomplete_citations": [
r["id"] for r in results if r.get("citations_complete") is False
],
"used_fixture_data": any(r.get("used_fixture_data") for r in results),
}
def _rate(rows: list[dict], key: str) -> float:
if not rows:
return 0.0
return round(sum(1 for r in rows if r.get(key)) / len(rows), 4)
def _p95(values: list[int]) -> int | None:
if not values:
return None
ordered = sorted(values)
return ordered[min(len(ordered) - 1, int(round(0.95 * (len(ordered) - 1))))]
GATE = {
"overall_accuracy": 0.85,
"classification_accuracy_procedural_advisory": 0.95,
"contract_violations": 0,
"p95_latency_ms": 12000,
}
def check_gate(scorecard: dict) -> list[str]:
"""The Phase 8 gate, as code. Gates are not suggestions."""
failures = []
if scorecard["overall_accuracy"] < GATE["overall_accuracy"]:
failures.append(
f"overall accuracy {scorecard['overall_accuracy']:.0%} "
f"< {GATE['overall_accuracy']:.0%}"
)
if (
scorecard["classification_accuracy_procedural_advisory"]
< GATE["classification_accuracy_procedural_advisory"]
):
failures.append(
"procedural/advisory classification "
f"{scorecard['classification_accuracy_procedural_advisory']:.0%} < 95% "
"- misrouting these is the dangerous failure"
)
if scorecard["contract_violations"] > GATE["contract_violations"]:
failures.append(f"{scorecard['contract_violations']} contract violations, gate is zero")
p95 = scorecard["p95_latency_ms"]
if p95 is not None and p95 > GATE["p95_latency_ms"]:
failures.append(f"p95 latency {p95} ms > {GATE['p95_latency_ms']} ms")
return failures
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--api", default="http://ai-api:8000")
parser.add_argument("--testset", type=Path, default=TESTSET)
parser.add_argument("--out", type=Path, default=Path("eval/results"))
parser.add_argument("--only", help="run one case by id")
args = parser.parse_args()
cases = load_cases(args.testset)
if args.only:
cases = [c for c in cases if c["id"] == args.only]
results = []
with httpx.Client() as client:
for case in cases:
result = run_case(client, args.api, case)
results.append(result)
mark = "ok " if result.get("passed") else "FAIL"
print(f"{mark} {result['id']:<4} {case['expected_class']:<11} "
f"-> {result.get('actual_class')}")
scorecard = score(results)
args.out.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
(args.out / f"results-{stamp}.json").write_text(
json.dumps({"scorecard": scorecard, "results": results}, indent=2), encoding="utf-8"
)
print("\n" + json.dumps(scorecard["by_class"], indent=2))
print(f"\noverall accuracy {scorecard['overall_accuracy']:.1%}")
print(f"classification accuracy {scorecard['classification_accuracy']:.1%}")
print(f" procedural + advisory "
f"{scorecard['classification_accuracy_procedural_advisory']:.1%}")
print(f"contract violations {scorecard['contract_violations']}")
print(f"p95 latency {scorecard['p95_latency_ms']} ms")
if scorecard["used_fixture_data"]:
print(
"\nNOTE: this run used FIXTURE DATA. It says the pipeline works. "
"It says nothing about the plant, and the Phase 8 gate is not met "
"until it is re-run against imh."
)
failures = check_gate(scorecard)
if failures:
print("\nGATE NOT MET:")
for failure in failures:
print(f" - {failure}")
return 1
print("\nGate criteria met. Engineer review still required for: "
+ ", ".join(scorecard["needs_engineer_review"]))
return 0
if __name__ == "__main__":
sys.exit(main())