ControlAI-Agent / scripts /eval_answer_quality.py
atakan
refactor: Collapse four inference backends into one MLX path
9e637cd
Raw
History Blame Contribute Delete
6.62 kB
#!/usr/bin/env python3
"""Broad answer-quality smoke test across every control-engineering domain.
Retrieval tests (scripts/test_rag_knowledge.py) only prove the right passage was
found. This runs the FULL agent and grades the answer that actually reaches the
user, checking the failure modes seen in practice:
* rendering -- doubled LaTeX backslashes, orphaned <tool_call> tags,
raw JSON leaking into prose
* citations -- raw indexed filenames / extensions / course codes shown to
the user instead of clean source names
* substance -- the canned "analysis complete" placeholder, empty answers,
answers too short to be useful
* tool hygiene -- the same tool re-run over and over, failed tool calls
Usage:
python3 scripts/eval_answer_quality.py # all cases
python3 scripts/eval_answer_quality.py --filter robot # subset
python3 scripts/eval_answer_quality.py --limit 5
"""
from __future__ import annotations
import argparse
import re
import sys
import time
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from controlai_agent.agent import ControlAgent
PLACEHOLDER = "computational analysis has been completed"
# (domain, question). Deliberately spans applied domains and answer *shapes*:
# numeric-with-tool, conceptual-from-RAG, design/applied prose, and definitional.
CASES: list[tuple[str, str]] = [
("classical", "Compute the gain margin, phase margin and crossover frequencies for G(s) = 10/(s*(s+1)*(s+5))."),
("classical", "Explain what the phase margin tells you about a closed-loop system's damping."),
("modern", "Design an LQR controller for A=[[0,1],[-2,-3]], B=[[0],[1]], Q=diag([10,1]), R=1 and simulate the step response."),
("modern", "What is the difference between controllability and stabilizability?"),
("mpc", "What is quasi-infinite horizon MPC and why is the terminal region needed?"),
("mpc", "How does MPC handle actuator saturation compared to anti-windup PID?"),
("estimation", "Explain the difference between the Kalman filter time update and measurement update."),
("nonlinear", "What is a control barrier function and how does it enforce safety?"),
("robust", "State the small gain theorem and when it is conservative."),
("aerospace", "How is gain scheduling used in an aircraft flight control system across the flight envelope?"),
("aerospace", "Explain the short-period and phugoid modes of aircraft longitudinal dynamics."),
("automotive", "How does an electronic stability program use yaw rate feedback to correct oversteer?"),
("automotive", "Design considerations for an adaptive cruise control spacing policy."),
("robotics", "Explain impedance control for a robot manipulator in contact with the environment."),
("robotics", "How does a mobile robot fuse odometry and lidar for localization?"),
("automation", "What is cascade control in process automation and when does it help?"),
("automation", "Explain integral windup in an industrial PID loop and how to prevent it."),
("power", "How is field-oriented control used for induction motor drives?"),
]
RAW_FILENAME_PAT = re.compile(
r"\.pdf\b|\.md\b|\.jsonl?\b|\bJD_|\bB&B_|\bBBB[_\s]|\bLP_|\bEC_|\bAC_|\bFG_|\bRB_|\bXX_|txtbk|DEFINITIVO",
re.IGNORECASE,
)
DOUBLE_BS_PAT = re.compile(r"\\\\(?=[a-zA-Z|{}()])")
def grade(question: str, result) -> tuple[list[str], dict]:
"""Return (list of problems, metrics) for one answer."""
text = result.answer or ""
problems: list[str] = []
if not text.strip():
problems.append("EMPTY answer")
elif PLACEHOLDER in text.lower():
problems.append("PLACEHOLDER non-answer")
elif len(text.split()) < 40:
problems.append(f"THIN answer ({len(text.split())} words)")
if DOUBLE_BS_PAT.search(text):
problems.append("DOUBLED LaTeX backslash")
if "<tool_call>" in text or "</tool_call>" in text:
problems.append("LEAKED tool_call tag")
if re.search(r'\{\s*"name"\s*:', text):
problems.append("LEAKED raw JSON")
if RAW_FILENAME_PAT.search(text):
problems.append("RAW filename in citation")
names = [t.name for t in result.traces]
failed = [t.name for t in result.traces if t.result.get("status") == "error"]
if failed:
problems.append(f"TOOL ERROR: {', '.join(sorted(set(failed)))}")
for n in set(names):
if names.count(n) > 2:
problems.append(f"REPEATED tool x{names.count(n)}: {n}")
return problems, {"tools": names, "words": len(text.split()), "plots": len(result.plots)}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--filter", default="", help="only run cases whose domain or text matches")
ap.add_argument("--limit", type=int, default=0)
args = ap.parse_args()
cases = [c for c in CASES if args.filter.lower() in (c[0] + " " + c[1]).lower()]
if args.limit:
cases = cases[: args.limit]
print(f"Loading agent...\n")
agent = ControlAgent()
failures: list[tuple[str, str, list[str]]] = []
for i, (domain, q) in enumerate(cases, 1):
t0 = time.time()
try:
res = agent.run(q)
except Exception as exc: # a crash is itself a finding
failures.append((domain, q, [f"EXCEPTION: {exc}"]))
print(f"[{i}/{len(cases)}] {domain:<11} EXCEPTION {exc}")
continue
problems, meta = grade(q, res)
status = "ok " if not problems else "FAIL"
print(
f"[{i}/{len(cases)}] {domain:<11} {status} "
f"{time.time()-t0:5.1f}s words={meta['words']:<4} "
f"plots={meta['plots']} tools={','.join(meta['tools']) or '-'}"
)
for p in problems:
print(f" - {p}")
if problems:
failures.append((domain, q, problems))
print("\n" + "=" * 74)
print(f"{len(cases) - len(failures)}/{len(cases)} answers clean")
if failures:
print("\nIssues by type:")
counts: dict[str, int] = {}
for _, _, ps in failures:
for p in ps:
key = p.split(":")[0].split("(")[0].strip()
counts[key] = counts.get(key, 0) + 1
for k, v in sorted(counts.items(), key=lambda kv: -kv[1]):
print(f" {v:>3} {k}")
print("=" * 74)
return 0 if not failures else 1
if __name__ == "__main__":
raise SystemExit(main())