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.
Files changed (100) hide show
  1. agents/__init__.py +6 -0
  2. agents/auditor/__init__.py +13 -0
  3. agents/auditor/budget_auditor.py +91 -0
  4. agents/auditor/parser.py +139 -0
  5. agents/auditor/prompts.py +64 -0
  6. agents/auditor/schema.py +67 -0
  7. agents/base.py +20 -0
  8. agents/reflection/__init__.py +3 -0
  9. agents/reflection/agent.py +77 -0
  10. agents/reflection/prompts.py +18 -0
  11. agents/research/__init__.py +4 -0
  12. agents/research/agent.py +45 -0
  13. agents/research/planner.py +52 -0
  14. agents/research/prompts.py +14 -0
  15. agents/support/__init__.py +5 -0
  16. agents/support/triage_agent.py +45 -0
  17. agents/travel/__init__.py +11 -0
  18. agents/travel/agent.py +377 -0
  19. agents/travel/prompts.py +30 -0
  20. agents/travel/session.py +110 -0
  21. cli/__init__.py +6 -0
  22. cli/demo.py +47 -0
  23. cli/formatter.py +93 -0
  24. cli/html_reporter.py +647 -0
  25. cli/main.py +423 -0
  26. cli/progress.py +38 -0
  27. cli/resolver.py +99 -0
  28. evalrun-0.4.0.dist-info/METADATA +268 -0
  29. evalrun-0.4.0.dist-info/RECORD +100 -0
  30. evalrun-0.4.0.dist-info/WHEEL +5 -0
  31. evalrun-0.4.0.dist-info/entry_points.txt +2 -0
  32. evalrun-0.4.0.dist-info/licenses/LICENSE +0 -0
  33. evalrun-0.4.0.dist-info/top_level.txt +4 -0
  34. framework/__init__.py +70 -0
  35. framework/core/__init__.py +17 -0
  36. framework/core/adapters.py +118 -0
  37. framework/core/contracts.py +88 -0
  38. framework/core/suite.py +44 -0
  39. framework/evaluation/__init__.py +22 -0
  40. framework/evaluation/base.py +29 -0
  41. framework/evaluation/dimensions.py +7 -0
  42. framework/evaluation/engine.py +110 -0
  43. framework/evaluation/evaluators/__init__.py +7 -0
  44. framework/evaluation/evaluators/adaptability.py +27 -0
  45. framework/evaluation/evaluators/base_llm.py +104 -0
  46. framework/evaluation/evaluators/constraint.py +27 -0
  47. framework/evaluation/evaluators/information_accuracy.py +41 -0
  48. framework/evaluation/evaluators/personalization.py +27 -0
  49. framework/evaluation/evaluators/planning.py +27 -0
  50. framework/evaluation/evaluators/support.py +81 -0
  51. framework/evaluation/prompts/__init__.py +11 -0
  52. framework/evaluation/prompts/adaptability.py +57 -0
  53. framework/evaluation/prompts/base.py +52 -0
  54. framework/evaluation/prompts/constraint.py +41 -0
  55. framework/evaluation/prompts/information_accuracy.py +79 -0
  56. framework/evaluation/prompts/personalization.py +57 -0
  57. framework/evaluation/prompts/planning.py +61 -0
  58. framework/evaluation/runner.py +363 -0
  59. framework/evaluation/testing.py +25 -0
  60. framework/exceptions.py +49 -0
  61. framework/llms/__init__.py +8 -0
  62. framework/llms/base.py +37 -0
  63. framework/llms/factory.py +38 -0
  64. framework/llms/gemini.py +85 -0
  65. framework/llms/mock.py +25 -0
  66. framework/llms/openai.py +94 -0
  67. framework/llms/openai_compatible.py +139 -0
  68. framework/mcp/__init__.py +20 -0
  69. framework/mcp/client.py +62 -0
  70. framework/mcp/constraints.py +125 -0
  71. framework/mcp/revision_summary.py +122 -0
  72. framework/mcp/server.py +49 -0
  73. framework/memory/__init__.py +3 -0
  74. framework/memory/base.py +17 -0
  75. framework/models.py +83 -0
  76. framework/parser.py +45 -0
  77. framework/parsers/__init__.py +12 -0
  78. framework/parsers/frontmatter.py +28 -0
  79. framework/parsers/mapper.py +66 -0
  80. framework/parsers/markdown.py +122 -0
  81. framework/parsers/transformers.py +112 -0
  82. framework/profiles/__init__.py +28 -0
  83. framework/profiles/registry.py +104 -0
  84. framework/profiles/support.py +27 -0
  85. framework/profiles/travel.py +78 -0
  86. framework/regression/__init__.py +17 -0
  87. framework/regression/comparator.py +273 -0
  88. framework/regression/loader.py +145 -0
  89. framework/sdk.py +151 -0
  90. framework/utils.py +41 -0
  91. framework/verification/__init__.py +13 -0
  92. framework/verification/base.py +32 -0
  93. framework/verification/extractor.py +105 -0
  94. framework/verification/local.py +122 -0
  95. framework/verification/models.py +112 -0
  96. framework/verification/pipeline.py +36 -0
  97. framework/verification/prompts.py +24 -0
  98. framework/verification/utils.py +54 -0
  99. ui/__init__.py +1 -0
  100. ui/server.py +255 -0
@@ -0,0 +1,78 @@
1
+ """Evaluation profiles for travel planning agents."""
2
+
3
+ from framework.models import EvaluationProfile
4
+ from framework.evaluation.dimensions import (
5
+ CONSTRAINT_SATISFACTION,
6
+ PLANNING_QUALITY,
7
+ INFORMATION_ACCURACY,
8
+ PERSONALIZATION,
9
+ ADAPTABILITY,
10
+ )
11
+
12
+ TRAVEL_PROFILE = EvaluationProfile(
13
+ name="travel-agent",
14
+ weights={
15
+ CONSTRAINT_SATISFACTION: 25.0,
16
+ PLANNING_QUALITY: 20.0,
17
+ INFORMATION_ACCURACY: 20.0,
18
+ PERSONALIZATION: 20.0,
19
+ ADAPTABILITY: 15.0,
20
+ },
21
+ pass_threshold=75.0,
22
+ )
23
+
24
+ TRAVEL_ROUTE_OPTIMIZATION_PROFILE = EvaluationProfile(
25
+ name="travel-route-optimization",
26
+ weights={
27
+ CONSTRAINT_SATISFACTION: 25.0,
28
+ PLANNING_QUALITY: 55.0,
29
+ INFORMATION_ACCURACY: 10.0,
30
+ PERSONALIZATION: 5.0,
31
+ ADAPTABILITY: 5.0,
32
+ },
33
+ pass_threshold=70.0,
34
+ )
35
+
36
+ TRAVEL_REMOTE_WORKER_TIMEZONES_PROFILE = EvaluationProfile(
37
+ name="travel-remote-worker-timezones",
38
+ weights={
39
+ CONSTRAINT_SATISFACTION: 35.0,
40
+ PLANNING_QUALITY: 20.0,
41
+ INFORMATION_ACCURACY: 10.0,
42
+ PERSONALIZATION: 30.0,
43
+ ADAPTABILITY: 5.0,
44
+ },
45
+ pass_threshold=75.0,
46
+ )
47
+
48
+ TRAVEL_MID_TRIP_REPLANNING_PROFILE = EvaluationProfile(
49
+ name="travel-mid-trip-replanning",
50
+ weights={
51
+ ADAPTABILITY: 55.0,
52
+ CONSTRAINT_SATISFACTION: 20.0,
53
+ PLANNING_QUALITY: 15.0,
54
+ INFORMATION_ACCURACY: 5.0,
55
+ PERSONALIZATION: 5.0,
56
+ },
57
+ pass_threshold=75.0,
58
+ )
59
+
60
+ TRAVEL_INFORMATION_GATHERING_UNCERTAINTY_PROFILE = EvaluationProfile(
61
+ name="travel-information-gathering-uncertainty",
62
+ weights={
63
+ CONSTRAINT_SATISFACTION: 50.0,
64
+ INFORMATION_ACCURACY: 25.0,
65
+ PLANNING_QUALITY: 15.0,
66
+ PERSONALIZATION: 5.0,
67
+ ADAPTABILITY: 5.0,
68
+ },
69
+ pass_threshold=75.0,
70
+ )
71
+
72
+ PROFILE_REGISTRY = {
73
+ TRAVEL_PROFILE.name: TRAVEL_PROFILE,
74
+ TRAVEL_ROUTE_OPTIMIZATION_PROFILE.name: TRAVEL_ROUTE_OPTIMIZATION_PROFILE,
75
+ TRAVEL_REMOTE_WORKER_TIMEZONES_PROFILE.name: TRAVEL_REMOTE_WORKER_TIMEZONES_PROFILE,
76
+ TRAVEL_MID_TRIP_REPLANNING_PROFILE.name: TRAVEL_MID_TRIP_REPLANNING_PROFILE,
77
+ TRAVEL_INFORMATION_GATHERING_UNCERTAINTY_PROFILE.name: TRAVEL_INFORMATION_GATHERING_UNCERTAINTY_PROFILE,
78
+ }
@@ -0,0 +1,17 @@
1
+ """Regression analysis and baseline comparison package."""
2
+
3
+ from framework.regression.loader import load_baseline_manifest
4
+ from framework.regression.comparator import (
5
+ compare_runs,
6
+ DimensionDelta,
7
+ ScenarioComparison,
8
+ RegressionReport,
9
+ )
10
+
11
+ __all__ = [
12
+ "load_baseline_manifest",
13
+ "compare_runs",
14
+ "DimensionDelta",
15
+ "ScenarioComparison",
16
+ "RegressionReport",
17
+ ]
@@ -0,0 +1,273 @@
1
+ """Baseline comparator engine for score delta calculation and release gate enforcement."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, List, Optional
5
+ from framework.models import EvaluationResult
6
+
7
+
8
+ @dataclass
9
+ class DimensionDelta:
10
+ """Stores score delta for a single evaluation dimension."""
11
+
12
+ dimension: str
13
+ baseline_score: float
14
+ candidate_score: float
15
+ delta: float
16
+ is_regression: bool
17
+
18
+
19
+ @dataclass
20
+ class ScenarioComparison:
21
+ """Stores regression comparison metrics for a single scenario."""
22
+
23
+ scenario_id: str
24
+ scenario_name: str
25
+ status: str # PASSED, FAILED, BLOCKED, REGRESSED, MISSING_IN_CANDIDATE, NEW
26
+ baseline_score: Optional[float]
27
+ candidate_score: Optional[float]
28
+ overall_delta: Optional[float]
29
+ evaluator_passed: bool
30
+ auditor_gate: str
31
+ is_regression: bool
32
+ dimension_deltas: List[DimensionDelta] = field(default_factory=list)
33
+
34
+ def to_dict(self) -> Dict[str, Any]:
35
+ return {
36
+ "scenario_id": self.scenario_id,
37
+ "scenario_name": self.scenario_name,
38
+ "status": self.status,
39
+ "baseline_score": round(self.baseline_score, 2) if self.baseline_score is not None else None,
40
+ "candidate_score": round(self.candidate_score, 2) if self.candidate_score is not None else None,
41
+ "overall_delta": round(self.overall_delta, 2) if self.overall_delta is not None else None,
42
+ "evaluator_passed": self.evaluator_passed,
43
+ "auditor_gate": self.auditor_gate,
44
+ "is_regression": self.is_regression,
45
+ "dimension_deltas": [
46
+ {
47
+ "dimension": d.dimension,
48
+ "baseline_score": round(d.baseline_score, 2),
49
+ "candidate_score": round(d.candidate_score, 2),
50
+ "delta": round(d.delta, 2),
51
+ "is_regression": d.is_regression,
52
+ }
53
+ for d in self.dimension_deltas
54
+ ],
55
+ }
56
+
57
+
58
+ @dataclass
59
+ class RegressionReport:
60
+ """Encapsulates full run baseline comparison report."""
61
+
62
+ run_id: str
63
+ baseline_run_id: Optional[str]
64
+ regression_detected: bool
65
+ release_blocked: bool
66
+ max_allowed_overall_regression: float
67
+ max_allowed_dimension_regression: float
68
+ scenario_comparisons: List[ScenarioComparison]
69
+ summary: Dict[str, Any]
70
+
71
+ def to_dict(self) -> Dict[str, Any]:
72
+ return {
73
+ "run_id": self.run_id,
74
+ "baseline_run_id": self.baseline_run_id,
75
+ "regression_detected": self.regression_detected,
76
+ "release_blocked": self.release_blocked,
77
+ "max_allowed_overall_regression": self.max_allowed_overall_regression,
78
+ "max_allowed_dimension_regression": self.max_allowed_dimension_regression,
79
+ "summary": self.summary,
80
+ "scenarios": [sc.to_dict() for sc in self.scenario_comparisons],
81
+ }
82
+
83
+
84
+ def compare_runs(
85
+ candidate_results: List[EvaluationResult],
86
+ baseline_data: Dict[str, Any],
87
+ max_overall_drop: float = 5.0,
88
+ max_dim_drop: float = 10.0,
89
+ candidate_run_id: str = "candidate-run",
90
+ ) -> RegressionReport:
91
+ """Compares candidate evaluation results against baseline data.
92
+
93
+ Calculates overall and dimension score deltas (candidate - baseline).
94
+ Enforces non-short-circuiting 3-tier release gates:
95
+ 1. Evaluator Threshold Gate
96
+ 2. Independent Auditor Gate (not BLOCK)
97
+ 3. Regression Delta Gate (overall delta >= -max_overall_drop AND dim deltas >= -max_dim_drop)
98
+
99
+ Args:
100
+ candidate_results: List of candidate EvaluationResult objects.
101
+ baseline_data: Dictionary returned by load_baseline_manifest().
102
+ max_overall_drop: Maximum allowed overall score drop before regressed.
103
+ max_dim_drop: Maximum allowed per-dimension score drop before regressed.
104
+ candidate_run_id: Identifier for candidate run.
105
+
106
+ Returns:
107
+ A RegressionReport containing comparison details and release block status.
108
+ """
109
+ baseline_manifest = baseline_data.get("manifest", {})
110
+ baseline_scenarios = baseline_data.get("scenarios", {})
111
+ baseline_run_id = baseline_manifest.get("run_id")
112
+
113
+ candidate_by_id = {res.benchmark_id: res for res in candidate_results}
114
+
115
+ comparisons: List[ScenarioComparison] = []
116
+ regression_detected = False
117
+ release_blocked = False
118
+
119
+ # 1. Check for baseline scenarios missing from candidate run
120
+ for b_id, b_info in baseline_scenarios.items():
121
+ if b_id not in candidate_by_id:
122
+ regression_detected = True
123
+ release_blocked = True
124
+ comparisons.append(
125
+ ScenarioComparison(
126
+ scenario_id=b_id,
127
+ scenario_name=b_info.get("scenario_name", b_id),
128
+ status="MISSING_IN_CANDIDATE",
129
+ baseline_score=b_info.get("overall_score"),
130
+ candidate_score=None,
131
+ overall_delta=None,
132
+ evaluator_passed=False,
133
+ auditor_gate="UNKNOWN",
134
+ is_regression=True,
135
+ )
136
+ )
137
+
138
+ # 2. Evaluate all candidate scenarios
139
+ for cand_res in candidate_results:
140
+ s_id = cand_res.benchmark_id
141
+ cand_score = cand_res.overall_score
142
+ eval_passed = cand_res.passed
143
+
144
+ meta = getattr(cand_res, "agent_metadata", {})
145
+ auditor_gate = meta.get("audit_gate_decision", "PASS")
146
+
147
+ if auditor_gate != "PASS":
148
+ release_blocked = True
149
+
150
+ if not eval_passed:
151
+ release_blocked = True
152
+
153
+ if s_id in baseline_scenarios:
154
+ b_info = baseline_scenarios[s_id]
155
+ base_score = b_info["overall_score"]
156
+ overall_delta = cand_score - base_score
157
+
158
+ # Formula: delta < -max_overall_drop
159
+ overall_regressed = overall_delta < (-abs(max_overall_drop))
160
+
161
+ # Per-dimension deltas
162
+ base_dims = b_info.get("dimension_scores", {})
163
+ cand_dims = {ds.dimension: ds.score for ds in cand_res.dimension_scores}
164
+
165
+ dimension_deltas: List[DimensionDelta] = []
166
+ dim_regressed = False
167
+ all_dimensions = set(base_dims.keys()).union(cand_dims.keys())
168
+
169
+ for d_name in sorted(all_dimensions):
170
+ if d_name in base_dims and d_name in cand_dims:
171
+ b_dscore = base_dims[d_name]
172
+ c_dscore = cand_dims[d_name]
173
+ ddelta = c_dscore - b_dscore
174
+ d_is_regressed = ddelta < (-abs(max_dim_drop))
175
+ if d_is_regressed:
176
+ dim_regressed = True
177
+ dimension_deltas.append(
178
+ DimensionDelta(
179
+ dimension=d_name,
180
+ baseline_score=b_dscore,
181
+ candidate_score=c_dscore,
182
+ delta=ddelta,
183
+ is_regression=d_is_regressed,
184
+ )
185
+ )
186
+ elif d_name in base_dims and d_name not in cand_dims:
187
+ b_dscore = base_dims[d_name]
188
+ dim_regressed = True
189
+ dimension_deltas.append(
190
+ DimensionDelta(
191
+ dimension=f"{d_name} (Missing in Candidate)",
192
+ baseline_score=b_dscore,
193
+ candidate_score=0.0,
194
+ delta=-b_dscore,
195
+ is_regression=True,
196
+ )
197
+ )
198
+ else:
199
+ c_dscore = cand_dims[d_name]
200
+ dimension_deltas.append(
201
+ DimensionDelta(
202
+ dimension=f"{d_name} (New Dimension)",
203
+ baseline_score=0.0,
204
+ candidate_score=c_dscore,
205
+ delta=c_dscore,
206
+ is_regression=False,
207
+ )
208
+ )
209
+
210
+ is_scenario_regression = overall_regressed or dim_regressed
211
+ if is_scenario_regression:
212
+ regression_detected = True
213
+ release_blocked = True
214
+ status_str = "REGRESSED"
215
+ elif not eval_passed:
216
+ status_str = "FAILED"
217
+ elif auditor_gate != "PASS":
218
+ status_str = "BLOCKED"
219
+ else:
220
+ status_str = "PASSED"
221
+
222
+ comparisons.append(
223
+ ScenarioComparison(
224
+ scenario_id=s_id,
225
+ scenario_name=cand_res.benchmark_name,
226
+ status=status_str,
227
+ baseline_score=base_score,
228
+ candidate_score=cand_score,
229
+ overall_delta=overall_delta,
230
+ evaluator_passed=eval_passed,
231
+ auditor_gate=auditor_gate,
232
+ is_regression=is_scenario_regression,
233
+ dimension_deltas=dimension_deltas,
234
+ )
235
+ )
236
+ else:
237
+ # Candidate scenario is new
238
+ if not eval_passed or auditor_gate != "PASS":
239
+ release_blocked = True
240
+
241
+ status_str = "PASSED" if (eval_passed and auditor_gate == "PASS") else ("BLOCKED" if auditor_gate != "PASS" else "FAILED")
242
+ comparisons.append(
243
+ ScenarioComparison(
244
+ scenario_id=s_id,
245
+ scenario_name=cand_res.benchmark_name,
246
+ status=f"NEW ({status_str})",
247
+ baseline_score=None,
248
+ candidate_score=cand_score,
249
+ overall_delta=None,
250
+ evaluator_passed=eval_passed,
251
+ auditor_gate=auditor_gate,
252
+ is_regression=False,
253
+ )
254
+ )
255
+
256
+ summary = {
257
+ "total_scenarios": len(comparisons),
258
+ "passed": sum(1 for c in comparisons if c.status == "PASSED"),
259
+ "failed": sum(1 for c in comparisons if not c.evaluator_passed),
260
+ "blocked": sum(1 for c in comparisons if c.auditor_gate != "PASS"),
261
+ "regressed": sum(1 for c in comparisons if c.is_regression),
262
+ }
263
+
264
+ return RegressionReport(
265
+ run_id=candidate_run_id,
266
+ baseline_run_id=baseline_run_id,
267
+ regression_detected=regression_detected,
268
+ release_blocked=release_blocked,
269
+ max_allowed_overall_regression=max_overall_drop,
270
+ max_allowed_dimension_regression=max_dim_drop,
271
+ scenario_comparisons=comparisons,
272
+ summary=summary,
273
+ )
@@ -0,0 +1,145 @@
1
+ """Baseline manifest and report loader for regression comparison."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Optional
7
+
8
+
9
+ def load_baseline_manifest(path_str: str) -> Dict[str, Any]:
10
+ """Loads baseline manifest and per-scenario evaluation reports.
11
+
12
+ Args:
13
+ path_str: Path to a baseline directory containing manifest.json or path to manifest.json file.
14
+
15
+ Returns:
16
+ A dictionary structured as:
17
+ {
18
+ "manifest": dict,
19
+ "scenarios": {
20
+ "<scenario_id>": {
21
+ "scenario_id": str,
22
+ "scenario_name": str,
23
+ "overall_score": float,
24
+ "passed": bool,
25
+ "audit_gate_decision": str,
26
+ "dimension_scores": { "<dim_name>": float },
27
+ "report_path": str,
28
+ "itinerary_path": str
29
+ }
30
+ }
31
+ }
32
+
33
+ Raises:
34
+ FileNotFoundError: If baseline manifest file or directory does not exist.
35
+ ValueError: If manifest format is invalid.
36
+ """
37
+ path = Path(path_str)
38
+ if not path.exists():
39
+ raise FileNotFoundError(f"Baseline path '{path_str}' does not exist.")
40
+
41
+ is_dedicated_dir = path.is_dir()
42
+ if is_dedicated_dir:
43
+ manifest_file = path / "manifest.json"
44
+ base_dir = path
45
+ else:
46
+ manifest_file = path
47
+ base_dir = path.parent
48
+
49
+ if not manifest_file.exists():
50
+ raise FileNotFoundError(f"Baseline manifest file '{manifest_file}' not found.")
51
+
52
+ with open(manifest_file, "r", encoding="utf-8") as f:
53
+ manifest_data = json.load(f)
54
+
55
+ scenarios_by_id: Dict[str, Dict[str, Any]] = {}
56
+
57
+ # 1. Parse scenarios array if present in manifest.json
58
+ if "scenarios" in manifest_data and isinstance(manifest_data["scenarios"], list):
59
+ for item in manifest_data["scenarios"]:
60
+ s_id = item.get("scenario_id") or item.get("benchmark_id")
61
+ if not s_id:
62
+ continue
63
+
64
+ dim_scores = item.get("dimension_scores", {})
65
+ if isinstance(dim_scores, list):
66
+ dim_scores = {d["dimension"]: d["score"] for d in dim_scores if "dimension" in d and "score" in d}
67
+
68
+ rep_path = item.get("report_path", "")
69
+
70
+ # If report_path is specified and file exists, load detailed report file
71
+ if rep_path and not dim_scores:
72
+ full_rep = base_dir / rep_path
73
+ # Older manifests used the adapter class name in report_path.
74
+ # Fall back to the actual scenario-id filename so those
75
+ # baselines remain usable after report naming was corrected.
76
+ if not full_rep.exists() and base_dir.exists():
77
+ matches = sorted(base_dir.glob(f"*_{s_id}_report.json"))
78
+ if matches:
79
+ full_rep = matches[0]
80
+ if full_rep.exists():
81
+ try:
82
+ with open(full_rep, "r", encoding="utf-8") as rf:
83
+ rep = json.load(rf)
84
+ dim_scores = {
85
+ ds["dimension"]: float(ds["score"])
86
+ for ds in rep.get("dimension_scores", [])
87
+ if isinstance(ds, dict) and "dimension" in ds and "score" in ds
88
+ }
89
+ except Exception:
90
+ pass
91
+
92
+ scenarios_by_id[s_id] = {
93
+ "scenario_id": s_id,
94
+ "scenario_name": item.get("scenario_name") or item.get("name") or s_id,
95
+ "overall_score": float(item.get("overall_score", 0.0)),
96
+ "passed": bool(item.get("passed", False)),
97
+ "audit_gate_decision": item.get("audit_gate_decision") or item.get("auditor_gate") or "PASS",
98
+ "dimension_scores": dim_scores,
99
+ "report_path": rep_path,
100
+ "itinerary_path": item.get("itinerary_path", ""),
101
+ }
102
+
103
+ # 2. Only scan directory for report files if path_str was a dedicated directory
104
+ if is_dedicated_dir and base_dir.exists():
105
+ for fname in os.listdir(base_dir):
106
+ if fname.endswith("_report.json") and fname not in ("manifest.json", "regression_report.json"):
107
+ report_filepath = base_dir / fname
108
+ try:
109
+ with open(report_filepath, "r", encoding="utf-8") as f:
110
+ rep = json.load(f)
111
+ s_id = rep.get("benchmark_id") or rep.get("scenario_id")
112
+ if not s_id or s_id in scenarios_by_id:
113
+ continue
114
+
115
+ dim_scores = {
116
+ ds["dimension"]: float(ds["score"])
117
+ for ds in rep.get("dimension_scores", [])
118
+ if isinstance(ds, dict) and "dimension" in ds and "score" in ds
119
+ }
120
+ agent_meta = rep.get("agent_metadata", {})
121
+ audit_gate = agent_meta.get("audit_gate_decision", "PASS")
122
+
123
+ scenarios_by_id[s_id] = {
124
+ "scenario_id": s_id,
125
+ "scenario_name": rep.get("benchmark_name") or rep.get("scenario_name") or s_id,
126
+ "overall_score": float(rep.get("overall_score", 0.0)),
127
+ "passed": bool(rep.get("passed", False)),
128
+ "audit_gate_decision": audit_gate,
129
+ "dimension_scores": dim_scores,
130
+ "report_path": fname,
131
+ "itinerary_path": rep.get("itinerary_path", ""),
132
+ }
133
+ except Exception:
134
+ pass
135
+
136
+ if not scenarios_by_id:
137
+ raise ValueError(
138
+ f"Baseline manifest '{manifest_file}' contains no scenario results. "
139
+ "Pass a dedicated run directory containing scenario reports or a manifest with scenario/report entries."
140
+ )
141
+
142
+ return {
143
+ "manifest": manifest_data,
144
+ "scenarios": scenarios_by_id,
145
+ }
framework/sdk.py ADDED
@@ -0,0 +1,151 @@
1
+ """Public Python SDK API for programmatic agent evaluation and regression testing."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional, Union
7
+
8
+ from framework.core.adapters import PythonAgentAdapter, HttpAgentAdapter, CliAgentAdapter
9
+ from framework.evaluation.runner import BenchmarkRunner
10
+ from framework.llms.openai_compatible import OpenAICompatibleLLM
11
+ from framework.models import EvaluationResult
12
+ from framework.regression import load_baseline_manifest, compare_runs, RegressionReport
13
+ from cli.resolver import resolve_agent
14
+
15
+
16
+ def evaluate(
17
+ scenario: str,
18
+ agent: str,
19
+ model: str,
20
+ base_url: str = "https://api.openai.com/v1",
21
+ api_key: Optional[str] = None,
22
+ judge_model: Optional[str] = None,
23
+ judge_base_url: Optional[str] = None,
24
+ judge_api_key: Optional[str] = None,
25
+ ground_truth: str = "ground_truth/japan_demo.json",
26
+ output_dir: str = "./eval_results",
27
+ ) -> List[EvaluationResult]:
28
+ """Programmatically runs agent evaluation on a scenario or suite directory.
29
+
30
+ Args:
31
+ scenario: Path to a scenario file (.md) or suite directory.
32
+ agent: Agent specifier string ('module:Class' or 'module:factory').
33
+ model: Target agent model name.
34
+ base_url: Target agent model OpenAI-compatible base URL.
35
+ api_key: Target agent API key.
36
+ judge_model: Judge model name (defaults to target model).
37
+ judge_base_url: Judge base URL (defaults to target base_url).
38
+ judge_api_key: Judge API key (defaults to target api_key).
39
+ ground_truth: Ground truth knowledge base path.
40
+ output_dir: Target output directory for evaluation artifacts.
41
+
42
+ Returns:
43
+ List of EvaluationResult objects.
44
+ """
45
+ target_api_key = api_key or os.getenv("OPENAI_API_KEY", "EMPTY")
46
+ judge_model_name = judge_model or model
47
+ j_base_url = judge_base_url or base_url
48
+ j_api_key = judge_api_key or target_api_key
49
+
50
+ # Initialize Target LLM
51
+ target_llm = OpenAICompatibleLLM(
52
+ model_name=model,
53
+ base_url=base_url,
54
+ api_key=target_api_key,
55
+ )
56
+
57
+ # Initialize Judge LLM
58
+ judge_llm = OpenAICompatibleLLM(
59
+ model_name=judge_model_name,
60
+ base_url=j_base_url,
61
+ api_key=j_api_key,
62
+ )
63
+
64
+ # Resolve target agent
65
+ resolved_agent = resolve_agent(agent, target_llm)
66
+
67
+ out_path = Path(output_dir)
68
+ out_path.mkdir(parents=True, exist_ok=True)
69
+
70
+ # Instantiate BenchmarkRunner with output_dir
71
+ runner = BenchmarkRunner(
72
+ agent=resolved_agent,
73
+ judge_llm=judge_llm,
74
+ local_verifier_path=ground_truth,
75
+ output_dir=str(out_path),
76
+ )
77
+
78
+ scenario_path = Path(scenario)
79
+ results: List[EvaluationResult] = []
80
+
81
+ if scenario_path.is_dir():
82
+ for fname in os.listdir(scenario_path):
83
+ if fname.endswith(".md") or fname.endswith(".json"):
84
+ s_file = scenario_path / fname
85
+ try:
86
+ res = runner.run(str(s_file))
87
+ results.append(res)
88
+ except Exception as e:
89
+ print(f"Warning: Failed to evaluate scenario '{s_file}': {e}")
90
+ else:
91
+ res = runner.run(str(scenario_path))
92
+ results.append(res)
93
+
94
+ # Write SDK evaluation artifacts
95
+ try:
96
+ from datetime import datetime, timezone
97
+ import uuid
98
+ from cli.formatter import redact_credentials
99
+ from cli.html_reporter import generate_html_report
100
+
101
+ run_id = f"evalrun-sdk-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
102
+ manifest = {
103
+ "run_id": run_id,
104
+ "timestamp_utc": datetime.now(timezone.utc).isoformat(),
105
+ "target_agent_spec": agent,
106
+ # Never place the actual credential in the artifact structure.
107
+ # The key is used only by the in-process model client.
108
+ "target_model": {"model_name": model, "base_url": base_url, "api_key": "[REDACTED]"},
109
+ "judge_model": {"model_name": judge_model_name, "base_url": j_base_url, "api_key": "[REDACTED]"},
110
+ "ground_truth_path": ground_truth,
111
+ "output_dir": str(out_path),
112
+ "total_scenarios": len(results),
113
+ "overall_passed": all(r.passed for r in results),
114
+ }
115
+ with open(out_path / "manifest.json", "w", encoding="utf-8") as f:
116
+ json.dump(redact_credentials(manifest), f, indent=2)
117
+
118
+ generate_html_report(results, redact_credentials(manifest), str(out_path))
119
+ except Exception as e:
120
+ print(f"Warning: Failed to generate SDK report artifacts: {e}")
121
+
122
+ return results
123
+
124
+
125
+ def compare(
126
+ candidate_results: List[EvaluationResult],
127
+ baseline: str,
128
+ max_regression: float = 5.0,
129
+ max_dimension_regression: float = 10.0,
130
+ candidate_run_id: str = "candidate-run",
131
+ ) -> RegressionReport:
132
+ """Programmatically compares candidate evaluation results against a baseline.
133
+
134
+ Args:
135
+ candidate_results: List of candidate EvaluationResult objects.
136
+ baseline: Path to baseline directory or manifest.json file.
137
+ max_regression: Maximum allowed overall score drop before release blocked.
138
+ max_dimension_regression: Maximum allowed per-dimension score drop before release blocked.
139
+ candidate_run_id: Identifier for candidate run.
140
+
141
+ Returns:
142
+ A RegressionReport object.
143
+ """
144
+ baseline_data = load_baseline_manifest(baseline)
145
+ return compare_runs(
146
+ candidate_results=candidate_results,
147
+ baseline_data=baseline_data,
148
+ max_overall_drop=max_regression,
149
+ max_dim_drop=max_dimension_regression,
150
+ candidate_run_id=candidate_run_id,
151
+ )