File size: 6,619 Bytes
48ee375
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e637cd
48ee375
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e637cd
48ee375
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e637cd
 
48ee375
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e637cd
48ee375
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
#!/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())