-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathrun_evals.py
More file actions
228 lines (186 loc) · 8.67 KB
/
Copy pathrun_evals.py
File metadata and controls
228 lines (186 loc) · 8.67 KB
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
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
import asyncio
import json
import math
import pandas as pd
import numpy as np
from typing import Dict, Any, Optional
from agent_self_reflection import build_graph, AgentState
from langchain_ollama import OllamaLLM
from mcpsim.tracing import init_tracing
import phoenix as px
from phoenix.trace import SpanEvaluations
import argparse
import itertools
# Import OpenTelemetry trace API to capture span context
from opentelemetry import trace
# Initialize a global tracer
tracer_provider = init_tracing(project_name="mcp-agent-evaluation", endpoint="http://localhost:6006")
tracer = tracer_provider.get_tracer("eval-runner-tracer")
# ----------------- Comparison helpers -----------------
def floats_close(a: Any, b: Any, rtol: float = 1e-3, atol: float = 1e-6) -> bool:
if a is None or b is None: return a is None and b is None
try:
fa, fb = float(a), float(b)
if math.isnan(fa) and math.isnan(fb): return True
return math.isclose(fa, fb, rel_tol=rtol, abs_tol=atol)
except (TypeError, ValueError): return a == b
def compare_results(
got: Dict[str, Any],
expected: Dict[str, Any],
rtol: float = 1e-3,
atol: float = 1e-6
) -> bool:
"""Compare two dictionaries of numerical simulation results.
This function provides a robust way to check if two dictionaries,
representing simulation outputs, are numerically equivalent within a
specified tolerance. It enforces that all metrics must be numeric
and that both dictionaries must have the exact same set of keys.
Parameters
----------
got : dict
The dictionary of actual results obtained from a simulation run.
All values are expected to be numeric (int or float).
expected : dict
The dictionary of expected results to compare against.
All values are expected to be numeric (int or float).
rtol : float, optional
The relative tolerance parameter for `numpy.allclose`.
Default is 1e-3.
atol : float, optional
The absolute tolerance parameter for `numpy.allclose`.
Default is 1e-6.
Returns
-------
bool
True if the dictionaries are a match, False otherwise. A match
requires that:
1. Both dictionaries have the identical set of keys.
2. All values in both dictionaries are numeric.
3. All corresponding numeric values are close, as determined by
`numpy.allclose` with the given tolerances.
Examples
--------
>>> got = {'metric_a': 1.0001, 'metric_b': 200.0}
>>> expected = {'metric_a': 1.0, 'metric_b': 200.5}
>>> compare_results(got, expected, rtol=1e-2)
True
>>> got = {'metric_a': 1.01, 'metric_b': 200.0}
>>> expected = {'metric_a': 1.0, 'metric_b': 200.0}
>>> compare_results(got, expected, rtol=1e-3)
False
>>> got = {'metric_a': 1.0, 'metric_b': 'fail'}
>>> expected = {'metric_a': 1.0, 'metric_b': 2.0}
>>> compare_results(got, expected)
False
>>> got = {'metric_a': 1.0}
>>> expected = {'metric_a': 1.0, 'metric_b': 2.0}
>>> compare_results(got, expected)
False
"""
if expected is None or got is None:
return False
s_got = pd.Series(got)
s_expected = pd.Series(expected)
# 1. Check for structural differences (different keys).
if set(s_got.index) != set(s_expected.index):
return False
# 2. Verify that ALL values in BOTH series are numeric.
# pd.api.types.is_number is a robust way to check for int/float.
if not (s_got.apply(pd.api.types.is_number).all() and
s_expected.apply(pd.api.types.is_number).all()):
return False
# 3. Align and compare using NumPy's tolerance-based function.
# We already checked for key equality, so we can align `expected` to `got`.
s_expected_aligned = s_expected.loc[s_got.index]
# `np.allclose` is the gold standard for comparing arrays of floats.
return np.allclose(
s_got.values,
s_expected_aligned.values,
rtol=rtol,
atol=atol,
equal_nan=True # Considers two NaN values to be equal.
)
# ----------------- Agent run helpers ----------------
async def run_agent_once(compiled_graph, user_input: str, llm: OllamaLLM) -> AgentState:
state_in: AgentState = {"user_input": user_input, "retry_count": 0, "validation_history": []}
return await compiled_graph.ainvoke(state_in)
def extract_sim_result(state: AgentState) -> Optional[Dict[str, Any]]:
return state.get("simulation_result")
# ---------------- Bulk Ingest Function for Phoenix ----------------
def bulk_ingest_to_phoenix(json_path: str, eval_name: str = "Simulation Agent Eval"):
"""
Loads an enriched evals.json file and bulk-ingests into Phoenix,
now including score, label, and explanation columns.
"""
with open(json_path, "r") as f:
evals = json.load(f)
eval_records = []
for ex_name, case in evals.items():
context = case.get("context")
if not context or "span_id" not in context or "trace_id" not in context:
print(f"⚠️ Skipping '{ex_name}': missing trace/span context in {json_path}")
continue
is_passed = bool(case.get("passed"))
# **FIX:** Add all three required columns: score, label, and explanation.
eval_records.append({
"context.trace_id": context["trace_id"],
"context.span_id": context["span_id"],
"example_id": ex_name,
"score": 1 if is_passed else 0,
"label": "Pass" if is_passed else "Fail",
"explanation": "Agent result matched expected values within tolerance." if is_passed
else "Agent result did not match expected values.",
})
if not eval_records:
print("No valid records found to ingest. Did you run the agent first to generate evals.json?")
return
eval_df = pd.DataFrame(eval_records)
eval_df = eval_df.set_index("context.span_id")
client = px.Client()
client.log_evaluations(SpanEvaluations(eval_name=eval_name, dataframe=eval_df))
print(f"[✓] Pushed {len(eval_df)} eval rows to Phoenix under '{eval_name}'")
# ---------------- Main eval runner ----------------
async def run_all_and_save(model_name: str = "gemma3:27b", limit: int = None):
"""
Runs the full evaluation pipeline and saves an enriched evals.json
that now includes the necessary trace/span context for Phoenix.
"""
with open("evals/evals.json", "r") as f:
evals = json.load(f)
llm = OllamaLLM(model=model_name, base_url="http://localhost:11434")
compiled_graph = build_graph(llm)
# Use islice to limit the loop if a limit is provided
items_to_process = itertools.islice(evals.items(), limit) if limit else evals.items()
for ex_name, case in items_to_process:
# **FIX:** Create a parent span for each eval run to capture its context
with tracer.start_as_current_span(f"eval_run: {ex_name}") as span:
# Capture the context from the currently active span
span_context = span.get_span_context()
trace_id = f"{span_context.trace_id:032x}"
span_id = f"{span_context.span_id:016x}"
# Run the agent pipeline
final_state = await run_agent_once(compiled_graph, case["user_input"], llm)
# Process results
got = extract_sim_result(final_state)
passed = compare_results(got, case.get("expected_results"))
# Store results and the new context back into the dictionary
case["agent_result"] = got
case["passed"] = passed
case["context"] = {"trace_id": trace_id, "span_id": span_id}
# Optionally add attributes to the span
span.set_attribute("eval.passed", passed)
span.set_attribute("eval.example_id", ex_name)
with open("evals/evals_output.json", "w") as f:
json.dump(evals, f, indent=2)
print("[✓] Saved enriched evals.json with trace/span context.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run evals and/or bulk-ingest into Phoenix")
parser.add_argument("--skip-run", action="store_true", help="Skip agent runs and just bulk-ingest existing evals.json")
parser.add_argument("--eval-name", default="Simulation Agent Eval")
parser.add_argument("--limit", type=int, default=None, help="Limit the number of evaluations to run for debugging.")
args = parser.parse_args()
if args.skip_run:
bulk_ingest_to_phoenix("evals/evals_output.json", eval_name=args.eval_name)
else:
asyncio.run(run_all_and_save(model_name="gpt-oss:20b", limit=args.limit))
bulk_ingest_to_phoenix("evals/evals_output.json", eval_name=args.eval_name)