evalrun 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/__init__.py +6 -0
- agents/auditor/__init__.py +13 -0
- agents/auditor/budget_auditor.py +91 -0
- agents/auditor/parser.py +139 -0
- agents/auditor/prompts.py +64 -0
- agents/auditor/schema.py +67 -0
- agents/base.py +20 -0
- agents/reflection/__init__.py +3 -0
- agents/reflection/agent.py +77 -0
- agents/reflection/prompts.py +18 -0
- agents/research/__init__.py +4 -0
- agents/research/agent.py +45 -0
- agents/research/planner.py +52 -0
- agents/research/prompts.py +14 -0
- agents/support/__init__.py +5 -0
- agents/support/triage_agent.py +45 -0
- agents/travel/__init__.py +11 -0
- agents/travel/agent.py +377 -0
- agents/travel/prompts.py +30 -0
- agents/travel/session.py +110 -0
- cli/__init__.py +6 -0
- cli/demo.py +47 -0
- cli/formatter.py +93 -0
- cli/html_reporter.py +647 -0
- cli/main.py +423 -0
- cli/progress.py +38 -0
- cli/resolver.py +99 -0
- evalrun-0.4.0.dist-info/METADATA +268 -0
- evalrun-0.4.0.dist-info/RECORD +100 -0
- evalrun-0.4.0.dist-info/WHEEL +5 -0
- evalrun-0.4.0.dist-info/entry_points.txt +2 -0
- evalrun-0.4.0.dist-info/licenses/LICENSE +0 -0
- evalrun-0.4.0.dist-info/top_level.txt +4 -0
- framework/__init__.py +70 -0
- framework/core/__init__.py +17 -0
- framework/core/adapters.py +118 -0
- framework/core/contracts.py +88 -0
- framework/core/suite.py +44 -0
- framework/evaluation/__init__.py +22 -0
- framework/evaluation/base.py +29 -0
- framework/evaluation/dimensions.py +7 -0
- framework/evaluation/engine.py +110 -0
- framework/evaluation/evaluators/__init__.py +7 -0
- framework/evaluation/evaluators/adaptability.py +27 -0
- framework/evaluation/evaluators/base_llm.py +104 -0
- framework/evaluation/evaluators/constraint.py +27 -0
- framework/evaluation/evaluators/information_accuracy.py +41 -0
- framework/evaluation/evaluators/personalization.py +27 -0
- framework/evaluation/evaluators/planning.py +27 -0
- framework/evaluation/evaluators/support.py +81 -0
- framework/evaluation/prompts/__init__.py +11 -0
- framework/evaluation/prompts/adaptability.py +57 -0
- framework/evaluation/prompts/base.py +52 -0
- framework/evaluation/prompts/constraint.py +41 -0
- framework/evaluation/prompts/information_accuracy.py +79 -0
- framework/evaluation/prompts/personalization.py +57 -0
- framework/evaluation/prompts/planning.py +61 -0
- framework/evaluation/runner.py +363 -0
- framework/evaluation/testing.py +25 -0
- framework/exceptions.py +49 -0
- framework/llms/__init__.py +8 -0
- framework/llms/base.py +37 -0
- framework/llms/factory.py +38 -0
- framework/llms/gemini.py +85 -0
- framework/llms/mock.py +25 -0
- framework/llms/openai.py +94 -0
- framework/llms/openai_compatible.py +139 -0
- framework/mcp/__init__.py +20 -0
- framework/mcp/client.py +62 -0
- framework/mcp/constraints.py +125 -0
- framework/mcp/revision_summary.py +122 -0
- framework/mcp/server.py +49 -0
- framework/memory/__init__.py +3 -0
- framework/memory/base.py +17 -0
- framework/models.py +83 -0
- framework/parser.py +45 -0
- framework/parsers/__init__.py +12 -0
- framework/parsers/frontmatter.py +28 -0
- framework/parsers/mapper.py +66 -0
- framework/parsers/markdown.py +122 -0
- framework/parsers/transformers.py +112 -0
- framework/profiles/__init__.py +28 -0
- framework/profiles/registry.py +104 -0
- framework/profiles/support.py +27 -0
- framework/profiles/travel.py +78 -0
- framework/regression/__init__.py +17 -0
- framework/regression/comparator.py +273 -0
- framework/regression/loader.py +145 -0
- framework/sdk.py +151 -0
- framework/utils.py +41 -0
- framework/verification/__init__.py +13 -0
- framework/verification/base.py +32 -0
- framework/verification/extractor.py +105 -0
- framework/verification/local.py +122 -0
- framework/verification/models.py +112 -0
- framework/verification/pipeline.py +36 -0
- framework/verification/prompts.py +24 -0
- framework/verification/utils.py +54 -0
- ui/__init__.py +1 -0
- ui/server.py +255 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Benchmark runner for orchestrating end-to-end agent evaluation pipelines."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Dict
|
|
11
|
+
from framework.core import RunTrace
|
|
12
|
+
from framework.models import AgentOutput, Benchmark, EvaluationResult
|
|
13
|
+
from framework.parser import parse_benchmark
|
|
14
|
+
from framework.profiles import PROFILE_REGISTRY, TRAVEL_PROFILE
|
|
15
|
+
from framework.evaluation.engine import EvaluationEngine
|
|
16
|
+
from framework.evaluation.dimensions import (
|
|
17
|
+
CONSTRAINT_SATISFACTION,
|
|
18
|
+
PLANNING_QUALITY,
|
|
19
|
+
INFORMATION_ACCURACY,
|
|
20
|
+
PERSONALIZATION,
|
|
21
|
+
ADAPTABILITY,
|
|
22
|
+
)
|
|
23
|
+
from framework.evaluation.evaluators import (
|
|
24
|
+
ConstraintEvaluator,
|
|
25
|
+
PlanningQualityEvaluator,
|
|
26
|
+
InformationAccuracyEvaluator,
|
|
27
|
+
PersonalizationEvaluator,
|
|
28
|
+
AdaptabilityEvaluator,
|
|
29
|
+
)
|
|
30
|
+
from framework.evaluation.evaluators.support import (
|
|
31
|
+
SupportAdaptabilityEvaluator,
|
|
32
|
+
SupportInformationAccuracyEvaluator,
|
|
33
|
+
SupportPersonalizationEvaluator,
|
|
34
|
+
SupportPlanningEvaluator,
|
|
35
|
+
)
|
|
36
|
+
from framework.verification.extractor import ClaimExtractor
|
|
37
|
+
from framework.verification.local import LocalKnowledgeBaseVerifier
|
|
38
|
+
from framework.verification.pipeline import VerificationPipeline
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BenchmarkRunner:
|
|
42
|
+
"""Automates the entire evaluation pipeline for a given agent under test.
|
|
43
|
+
|
|
44
|
+
Orchestrates benchmark ingestion, profile lookup, agent execution, evaluation via LLM judges,
|
|
45
|
+
and report generation.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
agent,
|
|
51
|
+
judge_llm,
|
|
52
|
+
local_verifier_path: str = "ground_truth/japan_demo.json",
|
|
53
|
+
output_dir: str = "scratch",
|
|
54
|
+
auditor: Any = None,
|
|
55
|
+
):
|
|
56
|
+
"""Initializes the BenchmarkRunner.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
agent: The agent under test (must expose a run(prompt: str) method returning AgentOutput).
|
|
60
|
+
judge_llm: The reference judge LLM client.
|
|
61
|
+
local_verifier_path: Path to the local database file for factual verification.
|
|
62
|
+
output_dir: Folder to store reports and itineraries.
|
|
63
|
+
auditor: Optional IndependentBudgetAuditor instance for v3 read-only gate checks.
|
|
64
|
+
"""
|
|
65
|
+
self.agent = agent
|
|
66
|
+
self.judge_llm = judge_llm
|
|
67
|
+
self.local_verifier_path = local_verifier_path
|
|
68
|
+
self.output_dir = output_dir
|
|
69
|
+
self.auditor = auditor
|
|
70
|
+
|
|
71
|
+
# Initialize the verification pipeline
|
|
72
|
+
self.verifier = LocalKnowledgeBaseVerifier(local_verifier_path)
|
|
73
|
+
self.extractor = ClaimExtractor(judge_llm)
|
|
74
|
+
self.pipeline = VerificationPipeline(self.extractor, self.verifier)
|
|
75
|
+
|
|
76
|
+
# Initialize the evaluation engine
|
|
77
|
+
evaluators = {
|
|
78
|
+
CONSTRAINT_SATISFACTION: ConstraintEvaluator(judge_llm),
|
|
79
|
+
PLANNING_QUALITY: PlanningQualityEvaluator(judge_llm),
|
|
80
|
+
INFORMATION_ACCURACY: InformationAccuracyEvaluator(judge_llm, self.pipeline),
|
|
81
|
+
PERSONALIZATION: PersonalizationEvaluator(judge_llm),
|
|
82
|
+
ADAPTABILITY: AdaptabilityEvaluator(judge_llm),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Merge dynamic evaluator plugins from registry
|
|
86
|
+
try:
|
|
87
|
+
from framework.profiles.registry import _DYNAMIC_EVALUATOR_REGISTRY
|
|
88
|
+
for dim_name, plugin_instance in _DYNAMIC_EVALUATOR_REGISTRY.items():
|
|
89
|
+
evaluators[dim_name] = plugin_instance
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
self.engine = EvaluationEngine(evaluators=evaluators)
|
|
94
|
+
|
|
95
|
+
def _configure_profile_evaluators(self, profile) -> None:
|
|
96
|
+
"""Apply domain-specific rubrics after the scenario profile is known."""
|
|
97
|
+
if getattr(profile, "name", "").lower() != "support-triage":
|
|
98
|
+
return
|
|
99
|
+
self.engine.evaluators.update(
|
|
100
|
+
{
|
|
101
|
+
PLANNING_QUALITY: SupportPlanningEvaluator(self.judge_llm),
|
|
102
|
+
INFORMATION_ACCURACY: SupportInformationAccuracyEvaluator(
|
|
103
|
+
self.judge_llm, self.pipeline
|
|
104
|
+
),
|
|
105
|
+
PERSONALIZATION: SupportPersonalizationEvaluator(self.judge_llm),
|
|
106
|
+
ADAPTABILITY: SupportAdaptabilityEvaluator(self.judge_llm),
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def run(self, filepath: str) -> EvaluationResult:
|
|
111
|
+
"""Runs the complete evaluation pipeline for a single benchmark file.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
filepath: Path to the benchmark markdown file.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The final EvaluationResult containing overall and dimension scores.
|
|
118
|
+
"""
|
|
119
|
+
# 1. Parse scenario file
|
|
120
|
+
benchmark = parse_benchmark(filepath)
|
|
121
|
+
|
|
122
|
+
# 2. Resolve evaluation profile (Static -> Dynamic -> Fallback/Error)
|
|
123
|
+
prof_name = benchmark.profile
|
|
124
|
+
profile = PROFILE_REGISTRY.get(prof_name)
|
|
125
|
+
if not profile:
|
|
126
|
+
try:
|
|
127
|
+
from framework.profiles.registry import get_custom_profile
|
|
128
|
+
profile = get_custom_profile(prof_name)
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
if not profile:
|
|
132
|
+
if prof_name in ("travel-agent", "travel", "default"):
|
|
133
|
+
profile = TRAVEL_PROFILE
|
|
134
|
+
else:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"Evaluation profile '{prof_name}' not found in static or dynamic profile registries."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Dimension names are shared across domains, but their rubrics are not.
|
|
140
|
+
# Configure support prompts only after this scenario's profile resolves.
|
|
141
|
+
self._configure_profile_evaluators(profile)
|
|
142
|
+
|
|
143
|
+
# 3. Execute agent with RunTrace instrumentation
|
|
144
|
+
t0 = time.time()
|
|
145
|
+
start_time = datetime.now(timezone.utc)
|
|
146
|
+
run_kwargs = {}
|
|
147
|
+
run_parameters = inspect.signature(self.agent.run).parameters
|
|
148
|
+
if "planning_mode" in run_parameters:
|
|
149
|
+
run_kwargs["planning_mode"] = "closed_world_evaluation"
|
|
150
|
+
if "validation_scenario_id" in run_parameters:
|
|
151
|
+
run_kwargs["validation_scenario_id"] = benchmark.benchmark_id
|
|
152
|
+
|
|
153
|
+
agent_output = None
|
|
154
|
+
status = "success"
|
|
155
|
+
err_msg = None
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
agent_output = self.agent.run(benchmark.prompt, **run_kwargs)
|
|
159
|
+
|
|
160
|
+
# 4. Optional v3 Independent Budget Auditor Gate Pass
|
|
161
|
+
if self.auditor is not None:
|
|
162
|
+
audit_report = self.auditor.audit(
|
|
163
|
+
scenario_prompt=benchmark.prompt,
|
|
164
|
+
itinerary_content=agent_output.content,
|
|
165
|
+
)
|
|
166
|
+
agent_output.metadata["audit_report"] = audit_report.to_dict()
|
|
167
|
+
agent_output.metadata["audit_gate_decision"] = audit_report.status
|
|
168
|
+
|
|
169
|
+
# 5. Evaluate using the engine
|
|
170
|
+
result = self.engine.evaluate(benchmark, agent_output, profile)
|
|
171
|
+
result.agent_metadata = agent_output.metadata
|
|
172
|
+
|
|
173
|
+
latency = time.time() - t0
|
|
174
|
+
finished_time = datetime.now(timezone.utc)
|
|
175
|
+
|
|
176
|
+
# 6. Record RunTrace metadata
|
|
177
|
+
llm_obj = getattr(self.agent, "llm", None)
|
|
178
|
+
raw_usage = getattr(llm_obj, "last_token_usage", None) if llm_obj else None
|
|
179
|
+
token_usage = raw_usage if isinstance(raw_usage, dict) else None
|
|
180
|
+
|
|
181
|
+
trace = RunTrace(
|
|
182
|
+
trace_id=f"tr-{uuid.uuid4().hex[:8]}",
|
|
183
|
+
scenario_id=benchmark.benchmark_id,
|
|
184
|
+
agent_id=getattr(self.agent, "agent_name", self.agent.__class__.__name__),
|
|
185
|
+
model_name=getattr(llm_obj, "model_name", "unknown") if llm_obj else "unknown",
|
|
186
|
+
started_at_utc=start_time.isoformat(),
|
|
187
|
+
finished_at_utc=finished_time.isoformat(),
|
|
188
|
+
latency_seconds=round(latency, 2),
|
|
189
|
+
status="success",
|
|
190
|
+
error=None,
|
|
191
|
+
token_usage=token_usage,
|
|
192
|
+
metadata=agent_output.metadata,
|
|
193
|
+
)
|
|
194
|
+
agent_output.metadata["run_trace"] = {
|
|
195
|
+
"trace_id": trace.trace_id,
|
|
196
|
+
"started_at_utc": trace.started_at_utc,
|
|
197
|
+
"finished_at_utc": trace.finished_at_utc,
|
|
198
|
+
"latency_seconds": trace.latency_seconds,
|
|
199
|
+
"status": trace.status,
|
|
200
|
+
"token_usage": trace.token_usage,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# 7. Save reports and outputs
|
|
204
|
+
self._save_execution_files(benchmark, agent_output, result, profile)
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
except Exception as e:
|
|
208
|
+
latency = time.time() - t0
|
|
209
|
+
finished_time = datetime.now(timezone.utc)
|
|
210
|
+
status = "timeout" if isinstance(e, (TimeoutError, TimeoutError)) or "timeout" in str(e).lower() else "error"
|
|
211
|
+
err_msg = str(e)
|
|
212
|
+
|
|
213
|
+
llm_obj = getattr(self.agent, "llm", None)
|
|
214
|
+
raw_usage = getattr(llm_obj, "last_token_usage", None) if llm_obj else None
|
|
215
|
+
token_usage = raw_usage if isinstance(raw_usage, dict) else None
|
|
216
|
+
|
|
217
|
+
agent_name = getattr(self.agent, "agent_name", None) or self.agent.__class__.__name__
|
|
218
|
+
model_name = getattr(llm_obj, "model_name", "unknown") if llm_obj and hasattr(llm_obj, "model_name") else "unknown"
|
|
219
|
+
|
|
220
|
+
trace = RunTrace(
|
|
221
|
+
trace_id=f"tr-{uuid.uuid4().hex[:8]}",
|
|
222
|
+
scenario_id=benchmark.benchmark_id,
|
|
223
|
+
agent_id=str(agent_name),
|
|
224
|
+
model_name=str(model_name),
|
|
225
|
+
started_at_utc=start_time.isoformat(),
|
|
226
|
+
finished_at_utc=finished_time.isoformat(),
|
|
227
|
+
latency_seconds=round(latency, 2),
|
|
228
|
+
status=status,
|
|
229
|
+
error=err_msg,
|
|
230
|
+
token_usage=token_usage,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Persist error trace metadata file
|
|
234
|
+
self._save_error_trace_file(benchmark, trace)
|
|
235
|
+
raise
|
|
236
|
+
|
|
237
|
+
def run_directory(self, dirpath: str) -> Dict[str, EvaluationResult]:
|
|
238
|
+
"""Runs evaluations for all benchmark files in the specified directory.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
dirpath: Path to the folder containing benchmark markdown files.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
A dictionary mapping benchmark ID to the EvaluationResult.
|
|
245
|
+
"""
|
|
246
|
+
results = {}
|
|
247
|
+
for filename in sorted(os.listdir(dirpath)):
|
|
248
|
+
if filename.endswith(".md"):
|
|
249
|
+
filepath = os.path.join(dirpath, filename)
|
|
250
|
+
try:
|
|
251
|
+
result = self.run(filepath)
|
|
252
|
+
results[result.benchmark_id] = result
|
|
253
|
+
except Exception as e:
|
|
254
|
+
print(f"Error evaluating benchmark '{filename}': {e}", file=sys.stderr)
|
|
255
|
+
return results
|
|
256
|
+
|
|
257
|
+
def _save_execution_files(
|
|
258
|
+
self,
|
|
259
|
+
benchmark: Benchmark,
|
|
260
|
+
agent_output: AgentOutput,
|
|
261
|
+
result: EvaluationResult,
|
|
262
|
+
profile: Any,
|
|
263
|
+
):
|
|
264
|
+
"""Helper to serialize agent output, JSON data reports, and Markdown summaries."""
|
|
265
|
+
os.makedirs(self.output_dir, exist_ok=True)
|
|
266
|
+
agent_name = getattr(self.agent, "llm", self.agent).__class__.__name__.lower()
|
|
267
|
+
if hasattr(self.agent, "llm") and hasattr(self.agent.llm, "model_name"):
|
|
268
|
+
model_slug = self.agent.llm.model_name.replace("/", "_").replace(".", "_")
|
|
269
|
+
else:
|
|
270
|
+
model_slug = agent_name
|
|
271
|
+
|
|
272
|
+
base_name = f"{model_slug}_{benchmark.benchmark_id}"
|
|
273
|
+
|
|
274
|
+
# 1. Save Raw Agent Output/Itinerary
|
|
275
|
+
itinerary_path = os.path.join(self.output_dir, f"{base_name}_itinerary.md")
|
|
276
|
+
with open(itinerary_path, "w", encoding="utf-8") as f:
|
|
277
|
+
f.write(agent_output.content)
|
|
278
|
+
|
|
279
|
+
# 2. Save Detailed JSON Report
|
|
280
|
+
report_data = {
|
|
281
|
+
"benchmark_id": result.benchmark_id,
|
|
282
|
+
"benchmark_name": result.benchmark_name,
|
|
283
|
+
"agent_model": getattr(self.agent, "llm", self.agent).__class__.__name__,
|
|
284
|
+
"model_name": getattr(getattr(self.agent, "llm", None), "model_name", "unknown"),
|
|
285
|
+
"profile": profile.name,
|
|
286
|
+
"overall_score": result.overall_score,
|
|
287
|
+
"passed": result.passed,
|
|
288
|
+
"agent_metadata": agent_output.metadata,
|
|
289
|
+
"dimension_scores": [
|
|
290
|
+
{
|
|
291
|
+
"dimension": ds.dimension,
|
|
292
|
+
"score": ds.score,
|
|
293
|
+
"reason": ds.reason,
|
|
294
|
+
}
|
|
295
|
+
for ds in result.dimension_scores
|
|
296
|
+
],
|
|
297
|
+
}
|
|
298
|
+
json_path = os.path.join(self.output_dir, f"{base_name}_report.json")
|
|
299
|
+
with open(json_path, "w", encoding="utf-8") as f:
|
|
300
|
+
json.dump(report_data, f, indent=2)
|
|
301
|
+
|
|
302
|
+
# 3. Save Summary Markdown Report
|
|
303
|
+
markdown_path = os.path.join(self.output_dir, f"{base_name}_report.md")
|
|
304
|
+
with open(markdown_path, "w", encoding="utf-8") as f:
|
|
305
|
+
f.write(f"# Evaluation Summary Report\n\n")
|
|
306
|
+
f.write(f"- **Benchmark**: {result.benchmark_name} (`{result.benchmark_id}`)\n")
|
|
307
|
+
f.write(f"- **Agent Model**: {report_data['model_name']}\n")
|
|
308
|
+
f.write(f"- **Evaluation Profile**: `{profile.name}`\n")
|
|
309
|
+
f.write(f"- **Overall Score**: **{result.overall_score:.2f} / 100**\n")
|
|
310
|
+
f.write(f"- **Outcome**: {'PASSED' if result.passed else 'FAILED'}\n\n")
|
|
311
|
+
|
|
312
|
+
# Render Auditor Gate metadata section if present
|
|
313
|
+
if "audit_report" in agent_output.metadata:
|
|
314
|
+
audit = agent_output.metadata["audit_report"]
|
|
315
|
+
f.write(f"## Independent Auditor Gate Report\n\n")
|
|
316
|
+
f.write(f"- **Gate Decision**: `{'PASS' if audit.get('passed') else 'BLOCK'}`\n")
|
|
317
|
+
f.write(f"- **Audit Score**: {audit.get('audit_score', 0.0):.2f} / 100\n")
|
|
318
|
+
f.write(f"- **Audit Confidence**: {audit.get('audit_confidence', 0.0):.2f}\n")
|
|
319
|
+
f.write(f"- **Reasoning Summary**: {audit.get('reasoning_summary', 'N/A')}\n\n")
|
|
320
|
+
if audit.get("violations"):
|
|
321
|
+
f.write("### Detected Budget Violations\n\n")
|
|
322
|
+
for v in audit["violations"]:
|
|
323
|
+
f.write(f"- **{v['violation_type']}**: {v['description']} (Est. Discrepancy: ₹{v.get('estimated_discrepancy_inr', 0.0):,.2f})\n")
|
|
324
|
+
f.write("\n")
|
|
325
|
+
|
|
326
|
+
# Render MCP metadata trace section if present
|
|
327
|
+
if "mcp_validation" in agent_output.metadata:
|
|
328
|
+
mcp = agent_output.metadata["mcp_validation"]
|
|
329
|
+
f.write(f"## MCP Validation Trace\n\n")
|
|
330
|
+
f.write(f"- **Status**: `{mcp.get('status', 'unknown')}`\n")
|
|
331
|
+
f.write(f"- **Locked-Booking Check**: `{'valid' if mcp.get('locked_valid') else 'invalid'}`\n")
|
|
332
|
+
f.write(f"- **Savings Check**: ₹{mcp.get('savings_realized_inr', 0):,} verified (target: ₹{mcp.get('target_savings_inr', 0):,})\n\n")
|
|
333
|
+
|
|
334
|
+
f.write(f"## Dimension Breakdown\n\n")
|
|
335
|
+
f.write(f"| Dimension | Score | Weight |\n")
|
|
336
|
+
f.write(f"| :--- | :---: | :---: |\n")
|
|
337
|
+
for ds in result.dimension_scores:
|
|
338
|
+
weight = profile.weights.get(ds.dimension, 0.0)
|
|
339
|
+
f.write(f"| {ds.dimension} | {ds.score:.1f} | {weight:.1f}% |\n")
|
|
340
|
+
f.write(f"\n## Reasoning Details\n\n")
|
|
341
|
+
for ds in result.dimension_scores:
|
|
342
|
+
f.write(f"### {ds.dimension} (Score: {ds.score:.1f})\n\n")
|
|
343
|
+
f.write(f"{ds.reason}\n\n")
|
|
344
|
+
|
|
345
|
+
def _save_error_trace_file(self, benchmark: Benchmark, trace: RunTrace) -> None:
|
|
346
|
+
"""Persists an error/timeout trace JSON record when an evaluation fails."""
|
|
347
|
+
os.makedirs(self.output_dir, exist_ok=True)
|
|
348
|
+
model_name = trace.model_name.replace("/", "_").replace(".", "_")
|
|
349
|
+
json_path = os.path.join(self.output_dir, f"{model_name}_{benchmark.benchmark_id}_error_trace.json")
|
|
350
|
+
trace_data = {
|
|
351
|
+
"trace_id": trace.trace_id,
|
|
352
|
+
"scenario_id": trace.scenario_id,
|
|
353
|
+
"agent_id": trace.agent_id,
|
|
354
|
+
"model_name": trace.model_name,
|
|
355
|
+
"started_at_utc": trace.started_at_utc,
|
|
356
|
+
"finished_at_utc": trace.finished_at_utc,
|
|
357
|
+
"latency_seconds": trace.latency_seconds,
|
|
358
|
+
"status": trace.status,
|
|
359
|
+
"error": trace.error,
|
|
360
|
+
"token_usage": trace.token_usage,
|
|
361
|
+
}
|
|
362
|
+
with open(json_path, "w", encoding="utf-8") as f:
|
|
363
|
+
json.dump(trace_data, f, indent=2)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Mock and testing utilities for evaluation pipelines."""
|
|
2
|
+
|
|
3
|
+
from framework.evaluation.base import BaseEvaluator
|
|
4
|
+
from framework.models import AgentOutput, Benchmark, DimensionScore
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DummyEvaluator(BaseEvaluator):
|
|
8
|
+
"""Simple evaluator returning a static score for profile verification."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, dimension: str, score: float, reason: str):
|
|
11
|
+
"""Initializes the DummyEvaluator.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
dimension: Dimension name key this evaluator represents.
|
|
15
|
+
score: Static numeric score to return.
|
|
16
|
+
reason: Static reason explanation to return.
|
|
17
|
+
"""
|
|
18
|
+
self._dimension = dimension
|
|
19
|
+
self.score = score
|
|
20
|
+
self.reason = reason
|
|
21
|
+
|
|
22
|
+
def evaluate(self, benchmark: Benchmark, output: AgentOutput) -> DimensionScore:
|
|
23
|
+
return DimensionScore(
|
|
24
|
+
dimension=self._dimension, score=self.score, reason=self.reason
|
|
25
|
+
)
|
framework/exceptions.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Common exceptions for the evaluation framework."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BenchmarkError(Exception):
|
|
5
|
+
"""Base class for all benchmark-related exceptions."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FrontmatterParseError(BenchmarkError):
|
|
11
|
+
"""Raised when parsing YAML frontmatter from content fails."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MarkdownParseError(BenchmarkError):
|
|
17
|
+
"""Raised when parsing markdown body into sections fails."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TransformerError(BenchmarkError):
|
|
23
|
+
"""Raised when transforming raw markdown sections into Python structures fails."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MapperError(BenchmarkError):
|
|
29
|
+
"""Raised when mapping parsed content to a Benchmark dataclass fails."""
|
|
30
|
+
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EvaluationError(Exception):
|
|
35
|
+
"""Raised when evaluation orchestration or execution fails."""
|
|
36
|
+
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class VerificationError(EvaluationError):
|
|
41
|
+
"""Base class for factual claim verification errors."""
|
|
42
|
+
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class KnowledgeBaseLoadError(VerificationError):
|
|
47
|
+
"""Raised when loading or parsing a verifier knowledge base fails."""
|
|
48
|
+
|
|
49
|
+
pass
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""LLM abstractions package containing wrapper clients and factory methods."""
|
|
2
|
+
|
|
3
|
+
from framework.llms.base import BaseLLM, Message
|
|
4
|
+
from framework.llms.mock import MockLLM
|
|
5
|
+
from framework.llms.openai import OpenAILLM
|
|
6
|
+
from framework.llms.gemini import GeminiLLM
|
|
7
|
+
from framework.llms.openai_compatible import OpenAICompatibleLLM
|
|
8
|
+
from framework.llms.factory import create_llm
|
framework/llms/base.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Base interfaces and data classes for LLM clients."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Message:
|
|
10
|
+
"""Represents a chat message sent to/from an LLM."""
|
|
11
|
+
|
|
12
|
+
role: str
|
|
13
|
+
content: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class LLMResponse:
|
|
18
|
+
"""Represents a structured response from an LLM."""
|
|
19
|
+
|
|
20
|
+
text: str
|
|
21
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BaseLLM(ABC):
|
|
25
|
+
"""Abstract base class representing an LLM client wrapper."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def generate(self, messages: List[Message]) -> LLMResponse:
|
|
29
|
+
"""Generates a structured completion for the given list of chat messages.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
messages: A list of Message dataclasses.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
An LLMResponse containing the text outcome.
|
|
36
|
+
"""
|
|
37
|
+
pass
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Factory module for creating LLM client wrappers."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Type
|
|
4
|
+
from framework.llms.base import BaseLLM
|
|
5
|
+
from framework.llms.mock import MockLLM
|
|
6
|
+
from framework.llms.openai import OpenAILLM
|
|
7
|
+
from framework.llms.gemini import GeminiLLM
|
|
8
|
+
|
|
9
|
+
# Registry of supported providers mapping provider name to class wrapper
|
|
10
|
+
_PROVIDERS: Dict[str, Type[BaseLLM]] = {
|
|
11
|
+
"openai": OpenAILLM,
|
|
12
|
+
"gemini": GeminiLLM,
|
|
13
|
+
"mock": MockLLM,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_llm(provider: str, **kwargs: Any) -> BaseLLM:
|
|
18
|
+
"""Factory function to instantiate an LLM wrapper by provider name.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
provider: Name of the LLM provider ('openai', 'gemini', 'mock').
|
|
22
|
+
**kwargs: Arguments passed to the provider class constructor.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
An instance of BaseLLM.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
ValueError: If an unknown provider name is supplied.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
provider_cls = _PROVIDERS[provider.lower()]
|
|
32
|
+
except KeyError:
|
|
33
|
+
supported = ", ".join(sorted(_PROVIDERS.keys()))
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"Unknown LLM provider '{provider}'. Supported providers: {supported}"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return provider_cls(**kwargs)
|
framework/llms/gemini.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Google Gemini client wrapper implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from framework.llms.base import BaseLLM, LLMResponse, Message
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GeminiLLM(BaseLLM):
|
|
8
|
+
"""Google Gemini API client wrapper."""
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self, model_name: str = "gemini-1.5-pro", api_key: Optional[str] = None
|
|
12
|
+
):
|
|
13
|
+
"""Initializes the GeminiLLM.
|
|
14
|
+
|
|
15
|
+
Validates that the 'google-generativeai' library is installed immediately.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
model_name: The target model name.
|
|
19
|
+
api_key: The Gemini API key. If not provided, configuration is skipped.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
ImportError: If the 'google-generativeai' library is not installed.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
# pyrefly: ignore [missing-import]
|
|
26
|
+
import google.generativeai as genai
|
|
27
|
+
except ImportError:
|
|
28
|
+
raise ImportError(
|
|
29
|
+
"The 'google-generativeai' library is required to use GeminiLLM. "
|
|
30
|
+
"Please install it using 'pip install google-generativeai'."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if api_key:
|
|
34
|
+
genai.configure(api_key=api_key)
|
|
35
|
+
|
|
36
|
+
self._genai = genai
|
|
37
|
+
# Ensure name is prefixed correctly (e.g. models/gemini-1.5-pro)
|
|
38
|
+
if model_name and not model_name.startswith("models/"):
|
|
39
|
+
self.model_name = f"models/{model_name}"
|
|
40
|
+
else:
|
|
41
|
+
self.model_name = model_name
|
|
42
|
+
|
|
43
|
+
def generate(self, messages: List[Message]) -> LLMResponse:
|
|
44
|
+
system_instruction = None
|
|
45
|
+
gemini_contents = []
|
|
46
|
+
|
|
47
|
+
for msg in messages:
|
|
48
|
+
if msg.role == "system":
|
|
49
|
+
system_instruction = msg.content
|
|
50
|
+
else:
|
|
51
|
+
# Map standard roles: user -> user, assistant/model -> model
|
|
52
|
+
role = "user" if msg.role == "user" else "model"
|
|
53
|
+
gemini_contents.append(
|
|
54
|
+
{"role": role, "parts": [{"text": msg.content}]}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
config = {}
|
|
58
|
+
if system_instruction:
|
|
59
|
+
config["system_instruction"] = system_instruction
|
|
60
|
+
|
|
61
|
+
import time
|
|
62
|
+
|
|
63
|
+
model = self._genai.GenerativeModel(self.model_name, **config)
|
|
64
|
+
|
|
65
|
+
max_retries = 4
|
|
66
|
+
for attempt in range(max_retries):
|
|
67
|
+
try:
|
|
68
|
+
response = model.generate_content(
|
|
69
|
+
gemini_contents,
|
|
70
|
+
request_options={"timeout": 300.0},
|
|
71
|
+
)
|
|
72
|
+
response_text = response.text or ""
|
|
73
|
+
return LLMResponse(text=response_text)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
err_str = str(e).lower()
|
|
76
|
+
is_transient = any(
|
|
77
|
+
err in err_str
|
|
78
|
+
for err in ["504", "deadline", "timeout", "resourceexhausted", "429", "503", "unavailable"]
|
|
79
|
+
)
|
|
80
|
+
if is_transient and attempt < max_retries - 1:
|
|
81
|
+
sleep_seconds = 5 * (attempt + 1)
|
|
82
|
+
print(f"[GeminiLLM] Transient error ({e}). Retrying in {sleep_seconds}s (attempt {attempt + 1}/{max_retries})...")
|
|
83
|
+
time.sleep(sleep_seconds)
|
|
84
|
+
else:
|
|
85
|
+
raise e
|
framework/llms/mock.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Mock LLM client wrapper for testing and offline validation."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Union
|
|
4
|
+
from framework.llms.base import BaseLLM, LLMResponse, Message
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MockLLM(BaseLLM):
|
|
8
|
+
"""A mock LLM wrapper for testing and offline execution."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, responses: Union[str, List[str]]):
|
|
11
|
+
"""Initializes the MockLLM.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
responses: A single response string or a list of response strings
|
|
15
|
+
to return sequentially on each generate call.
|
|
16
|
+
"""
|
|
17
|
+
self.responses = [responses] if isinstance(responses, str) else list(responses)
|
|
18
|
+
self.call_count = 0
|
|
19
|
+
|
|
20
|
+
def generate(self, messages: List[Message]) -> LLMResponse:
|
|
21
|
+
if not self.responses:
|
|
22
|
+
return LLMResponse(text="Mock response")
|
|
23
|
+
response = self.responses[self.call_count % len(self.responses)]
|
|
24
|
+
self.call_count += 1
|
|
25
|
+
return LLMResponse(text=response)
|