aeval-framework 0.1.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.
- aeval_framework-0.1.0.dist-info/METADATA +42 -0
- aeval_framework-0.1.0.dist-info/RECORD +63 -0
- aeval_framework-0.1.0.dist-info/WHEEL +4 -0
- aeval_framework-0.1.0.dist-info/entry_points.txt +2 -0
- agent_eval/__init__.py +14 -0
- agent_eval/api/__init__.py +14 -0
- agent_eval/api/app.py +82 -0
- agent_eval/api/events.py +96 -0
- agent_eval/api/routes/__init__.py +0 -0
- agent_eval/api/routes/datasets.py +441 -0
- agent_eval/api/routes/graders.py +19 -0
- agent_eval/api/routes/metrics.py +49 -0
- agent_eval/api/routes/runs.py +573 -0
- agent_eval/api/routes/suites.py +84 -0
- agent_eval/api/routes/tasks.py +114 -0
- agent_eval/api/standalone.py +105 -0
- agent_eval/cli.py +455 -0
- agent_eval/core/__init__.py +48 -0
- agent_eval/core/contract.py +296 -0
- agent_eval/core/metrics.py +184 -0
- agent_eval/core/runner.py +868 -0
- agent_eval/core/suite.py +60 -0
- agent_eval/core/types.py +227 -0
- agent_eval/dataset/__init__.py +31 -0
- agent_eval/dataset/models.py +199 -0
- agent_eval/dataset/quality.py +194 -0
- agent_eval/dataset/sources/__init__.py +45 -0
- agent_eval/dataset/sources/llm_generator.py +219 -0
- agent_eval/dataset/sources/manual.py +172 -0
- agent_eval/dataset/sources/regression.py +201 -0
- agent_eval/dataset/sources/trace_mining.py +277 -0
- agent_eval/dataset/storage.py +342 -0
- agent_eval/dataset/version.py +72 -0
- agent_eval/examples/__init__.py +0 -0
- agent_eval/examples/basic_usage.py +175 -0
- agent_eval/examples/mock_runner.py +195 -0
- agent_eval/graders/__init__.py +91 -0
- agent_eval/graders/artifact_check.py +114 -0
- agent_eval/graders/code_based.py +101 -0
- agent_eval/graders/human.py +77 -0
- agent_eval/graders/metric.py +142 -0
- agent_eval/graders/model_based.py +179 -0
- agent_eval/graders/state_check.py +106 -0
- agent_eval/graders/step_level.py +116 -0
- agent_eval/graders/tool_calls.py +102 -0
- agent_eval/graders/transcript.py +86 -0
- agent_eval/metrics/__init__.py +110 -0
- agent_eval/metrics/answer_relevancy.py +57 -0
- agent_eval/metrics/base.py +155 -0
- agent_eval/metrics/batch_evaluation.py +267 -0
- agent_eval/metrics/context_precision.py +62 -0
- agent_eval/metrics/context_recall.py +71 -0
- agent_eval/metrics/faithfulness.py +72 -0
- agent_eval/metrics/llm_judge.py +100 -0
- agent_eval/metrics/prompt_metric.py +150 -0
- agent_eval/metrics/pytest_plugin.py +308 -0
- agent_eval/metrics/report.py +149 -0
- agent_eval/metrics/synthetic_data.py +203 -0
- agent_eval/storage/__init__.py +17 -0
- agent_eval/storage/memory.py +95 -0
- agent_eval/storage/sqlite.py +240 -0
- agent_eval/trace/__init__.py +16 -0
- agent_eval/trace/phoenix.py +144 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Run management routes.
|
|
3
|
+
|
|
4
|
+
GET /runs — List run history
|
|
5
|
+
POST /runs — Start a new run
|
|
6
|
+
GET /runs/{run_id} — Get run details
|
|
7
|
+
DELETE /runs/{run_id} — Delete a run
|
|
8
|
+
GET /runs/{run_id}/trials — Get trials for a run
|
|
9
|
+
GET /runs/{run_id}/stream — SSE event stream (realtime progress)
|
|
10
|
+
POST /runs/{run_id}/cancel — Cancel a running run
|
|
11
|
+
POST /runs/{run_id}/human-scores — Submit a human score for a pending trial
|
|
12
|
+
|
|
13
|
+
Separate router (mounted at the API root, no /runs prefix):
|
|
14
|
+
POST /compare — Compare two runs
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import time
|
|
23
|
+
import uuid
|
|
24
|
+
from contextlib import suppress
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from fastapi import APIRouter, HTTPException
|
|
28
|
+
from pydantic import BaseModel, Field
|
|
29
|
+
from sse_starlette.sse import EventSourceResponse
|
|
30
|
+
|
|
31
|
+
from agent_eval.api.events import run_event_bus
|
|
32
|
+
|
|
33
|
+
router = APIRouter()
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# run_id → 后台 asyncio task 注册表 (取消运行用)
|
|
38
|
+
_background_tasks: dict[str, asyncio.Task] = {}
|
|
39
|
+
|
|
40
|
+
# SSE 心跳间隔 (秒) — 防代理空闲断连 (§17.3)
|
|
41
|
+
_STREAM_HEARTBEAT_SECONDS = 15.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.get("")
|
|
45
|
+
async def list_runs(suite_name: str | None = None, limit: int = 50):
|
|
46
|
+
"""列出运行历史"""
|
|
47
|
+
from agent_eval.api.app import _get_runner
|
|
48
|
+
|
|
49
|
+
runner = _get_runner()
|
|
50
|
+
if runner is None:
|
|
51
|
+
raise HTTPException(status_code=503, detail="EvalRunner not configured")
|
|
52
|
+
|
|
53
|
+
runs = await runner.storage.list_runs(suite_name=suite_name, limit=limit)
|
|
54
|
+
return {
|
|
55
|
+
"runs": [
|
|
56
|
+
{
|
|
57
|
+
"run_id": r.run_id,
|
|
58
|
+
"suite_name": r.suite_name,
|
|
59
|
+
"status": r.status,
|
|
60
|
+
"started_at": r.started_at,
|
|
61
|
+
"completed_at": r.completed_at,
|
|
62
|
+
"duration_ms": r.duration_ms,
|
|
63
|
+
"task_count": len(r.trials),
|
|
64
|
+
"summary": r.summary.model_dump() if r.summary else None,
|
|
65
|
+
}
|
|
66
|
+
for r in runs
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CreateRunRequest(BaseModel):
|
|
72
|
+
"""启动运行的请求体"""
|
|
73
|
+
suite_name: str
|
|
74
|
+
config: dict[str, Any] = {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.post("")
|
|
78
|
+
async def create_run(request: CreateRunRequest):
|
|
79
|
+
"""启动一次 suite 运行 (立即返回 run_id, 后台执行)"""
|
|
80
|
+
from agent_eval.api.app import _get_runner
|
|
81
|
+
|
|
82
|
+
runner = _get_runner()
|
|
83
|
+
if runner is None:
|
|
84
|
+
raise HTTPException(status_code=503, detail="EvalRunner not configured")
|
|
85
|
+
|
|
86
|
+
# Load suite
|
|
87
|
+
suite = await runner.storage.get_suite(request.suite_name)
|
|
88
|
+
if suite is None:
|
|
89
|
+
raise HTTPException(
|
|
90
|
+
status_code=404,
|
|
91
|
+
detail=f"Suite '{request.suite_name}' not found. Create it first.",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# 预生成 run_id, 便于客户端立即轮询
|
|
95
|
+
run_id = f"run_{uuid.uuid4().hex[:12]}"
|
|
96
|
+
|
|
97
|
+
# 先落一条 pending 记录, 保证 POST 返回后 GET /runs/{run_id} 立即可查
|
|
98
|
+
from agent_eval.core.types import RunResult
|
|
99
|
+
|
|
100
|
+
await runner.storage.save_run(
|
|
101
|
+
RunResult(run_id=run_id, suite_name=suite.name, status="pending")
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
async def _run():
|
|
105
|
+
# 事件总线: runner 进度回调 → per-run fan-out (SSE 增量, 不落库)
|
|
106
|
+
async def _emit(event: str, data: dict[str, Any]) -> None:
|
|
107
|
+
run_event_bus.publish(run_id, event, data)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
result = await runner.run_suite(suite, callback=_emit, run_id=run_id)
|
|
111
|
+
if result.status == "failed":
|
|
112
|
+
run_event_bus.publish(run_id, "error", {"error": result.error or "run failed"})
|
|
113
|
+
run_event_bus.publish(
|
|
114
|
+
run_id,
|
|
115
|
+
"run_complete",
|
|
116
|
+
{
|
|
117
|
+
"status": result.status,
|
|
118
|
+
"summary": result.summary.model_dump() if result.summary else None,
|
|
119
|
+
"error": result.error,
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
except asyncio.CancelledError:
|
|
123
|
+
# run_suite 已将状态置为 cancelled 并保存; 观察端也需要终态
|
|
124
|
+
run_event_bus.publish(run_id, "run_complete", {"status": "cancelled"})
|
|
125
|
+
except Exception as e:
|
|
126
|
+
logger.warning("Run %s failed: %s", run_id, e)
|
|
127
|
+
run_event_bus.publish(run_id, "error", {"error": str(e)})
|
|
128
|
+
run_event_bus.publish(run_id, "run_complete", {"status": "failed", "error": str(e)})
|
|
129
|
+
finally:
|
|
130
|
+
_background_tasks.pop(run_id, None)
|
|
131
|
+
|
|
132
|
+
# Create task (runs in background)
|
|
133
|
+
task = asyncio.create_task(_run())
|
|
134
|
+
_background_tasks[run_id] = task
|
|
135
|
+
|
|
136
|
+
# Return immediately with run info
|
|
137
|
+
return {
|
|
138
|
+
"run_id": run_id,
|
|
139
|
+
"message": "Run started",
|
|
140
|
+
"suite_name": request.suite_name,
|
|
141
|
+
"task_count": len(suite.tasks),
|
|
142
|
+
"status": "running",
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@router.get("/{run_id}")
|
|
147
|
+
async def get_run(run_id: str):
|
|
148
|
+
"""获取运行详情"""
|
|
149
|
+
from agent_eval.api.app import _get_runner
|
|
150
|
+
|
|
151
|
+
runner = _get_runner()
|
|
152
|
+
if runner is None:
|
|
153
|
+
raise HTTPException(status_code=503, detail="EvalRunner not configured")
|
|
154
|
+
|
|
155
|
+
run = await runner.storage.get_run(run_id)
|
|
156
|
+
if run is None:
|
|
157
|
+
raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found")
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
"run_id": run.run_id,
|
|
161
|
+
"suite_name": run.suite_name,
|
|
162
|
+
"status": run.status,
|
|
163
|
+
"started_at": run.started_at,
|
|
164
|
+
"completed_at": run.completed_at,
|
|
165
|
+
"duration_ms": run.duration_ms,
|
|
166
|
+
"error": run.error,
|
|
167
|
+
"trials": {
|
|
168
|
+
task_id: [
|
|
169
|
+
{
|
|
170
|
+
"trial_index": t.trial_index,
|
|
171
|
+
"trace_id": t.trace_id,
|
|
172
|
+
"success": t.success,
|
|
173
|
+
"score": t.avg_score(),
|
|
174
|
+
"duration_ms": t.duration_ms,
|
|
175
|
+
"error": t.error,
|
|
176
|
+
"grader_results": [
|
|
177
|
+
{
|
|
178
|
+
"grader_name": gr.grader_name,
|
|
179
|
+
"score": gr.score,
|
|
180
|
+
"passed": gr.passed,
|
|
181
|
+
"explanation": gr.explanation,
|
|
182
|
+
}
|
|
183
|
+
for gr in t.grader_results
|
|
184
|
+
],
|
|
185
|
+
}
|
|
186
|
+
for t in trials
|
|
187
|
+
]
|
|
188
|
+
for task_id, trials in run.trials.items()
|
|
189
|
+
},
|
|
190
|
+
"summary": run.summary.model_dump() if run.summary else None,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@router.delete("/{run_id}")
|
|
195
|
+
async def delete_run(run_id: str):
|
|
196
|
+
"""删除运行"""
|
|
197
|
+
from agent_eval.api.app import _get_runner
|
|
198
|
+
|
|
199
|
+
runner = _get_runner()
|
|
200
|
+
if runner is None:
|
|
201
|
+
raise HTTPException(status_code=503, detail="EvalRunner not configured")
|
|
202
|
+
|
|
203
|
+
deleted = await runner.storage.delete_run(run_id)
|
|
204
|
+
if not deleted:
|
|
205
|
+
raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found")
|
|
206
|
+
|
|
207
|
+
return {"deleted": True}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@router.get("/{run_id}/trials")
|
|
211
|
+
async def get_trials(run_id: str, task_id: str | None = None):
|
|
212
|
+
"""获取 trial 列表"""
|
|
213
|
+
from agent_eval.api.app import _get_runner
|
|
214
|
+
|
|
215
|
+
runner = _get_runner()
|
|
216
|
+
if runner is None:
|
|
217
|
+
raise HTTPException(status_code=503, detail="EvalRunner not configured")
|
|
218
|
+
|
|
219
|
+
run = await runner.storage.get_run(run_id)
|
|
220
|
+
if run is None:
|
|
221
|
+
raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found")
|
|
222
|
+
|
|
223
|
+
if task_id:
|
|
224
|
+
trials = run.trials.get(task_id, [])
|
|
225
|
+
return {
|
|
226
|
+
"task_id": task_id,
|
|
227
|
+
"trials": [t.model_dump() for t in trials],
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# Return all trials
|
|
231
|
+
all_trials = {}
|
|
232
|
+
for tid, trials in run.trials.items():
|
|
233
|
+
all_trials[tid] = [t.model_dump() for t in trials]
|
|
234
|
+
return {"trials": all_trials}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ─── SSE 事件流 (任务 3.2, 协议 §17.3) ───────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _terminal_event_from_run(run) -> dict[str, Any]:
|
|
241
|
+
"""从存储的 run 记录合成 run_complete 终态事件 (进程重启后 bus 无缓存)。"""
|
|
242
|
+
return {
|
|
243
|
+
"type": "run_complete",
|
|
244
|
+
"run_id": run.run_id,
|
|
245
|
+
"timestamp": time.time() * 1000,
|
|
246
|
+
"status": run.status,
|
|
247
|
+
"summary": run.summary.model_dump() if run.summary else None,
|
|
248
|
+
"error": run.error,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
async def _stream_events(run_id: str):
|
|
253
|
+
"""SSE 事件生成器: 订阅即推增量; run_complete 收尾关流; 心跳注释行。
|
|
254
|
+
|
|
255
|
+
断线恢复不在此处理 — 客户端按快照+增量协议重拉快照后重新订阅。
|
|
256
|
+
"""
|
|
257
|
+
queue = run_event_bus.subscribe(run_id)
|
|
258
|
+
try:
|
|
259
|
+
while True:
|
|
260
|
+
try:
|
|
261
|
+
event = await asyncio.wait_for(
|
|
262
|
+
queue.get(), timeout=_STREAM_HEARTBEAT_SECONDS
|
|
263
|
+
)
|
|
264
|
+
except TimeoutError:
|
|
265
|
+
yield {"comment": "heartbeat"}
|
|
266
|
+
continue
|
|
267
|
+
yield {"data": json.dumps(event, default=str)}
|
|
268
|
+
if event.get("type") == "run_complete":
|
|
269
|
+
return
|
|
270
|
+
finally:
|
|
271
|
+
run_event_bus.unsubscribe(run_id, queue)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@router.get("/{run_id}/stream")
|
|
275
|
+
async def stream_run(run_id: str):
|
|
276
|
+
"""SSE 推送运行事件 (task/trial 生命周期 + 终态)。
|
|
277
|
+
|
|
278
|
+
- 运行中 run: 订阅即推增量, run_complete 后关流
|
|
279
|
+
- 已完成 run: 立即推 run_complete 后关流 (bus 终态缓存或由存储合成)
|
|
280
|
+
- 连接只是观察窗口: 断开不影响 run 执行 (后台任务持有生命周期)
|
|
281
|
+
"""
|
|
282
|
+
from agent_eval.api.app import _get_runner
|
|
283
|
+
|
|
284
|
+
runner = _get_runner()
|
|
285
|
+
if runner is None:
|
|
286
|
+
raise HTTPException(status_code=503, detail="EvalRunner not configured")
|
|
287
|
+
|
|
288
|
+
run = await runner.storage.get_run(run_id)
|
|
289
|
+
if run is None:
|
|
290
|
+
raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found")
|
|
291
|
+
|
|
292
|
+
if run.status in ("completed", "failed", "cancelled"):
|
|
293
|
+
cached = run_event_bus.terminal_event(run_id)
|
|
294
|
+
|
|
295
|
+
async def _terminal_only():
|
|
296
|
+
yield {"data": json.dumps(cached or _terminal_event_from_run(run), default=str)}
|
|
297
|
+
|
|
298
|
+
return EventSourceResponse(
|
|
299
|
+
_terminal_only(),
|
|
300
|
+
headers={"Cache-Control": "no-cache, no-transform"},
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
return EventSourceResponse(
|
|
304
|
+
_stream_events(run_id),
|
|
305
|
+
headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"},
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@router.post("/{run_id}/cancel")
|
|
310
|
+
async def cancel_run(run_id: str):
|
|
311
|
+
"""取消一个运行中的 run (已完成 trial 保留)"""
|
|
312
|
+
import time
|
|
313
|
+
|
|
314
|
+
from agent_eval.api.app import _get_runner
|
|
315
|
+
|
|
316
|
+
runner = _get_runner()
|
|
317
|
+
if runner is None:
|
|
318
|
+
raise HTTPException(status_code=503, detail="EvalRunner not configured")
|
|
319
|
+
|
|
320
|
+
task = _background_tasks.get(run_id)
|
|
321
|
+
if task is None or task.done():
|
|
322
|
+
run = await runner.storage.get_run(run_id)
|
|
323
|
+
if run is None:
|
|
324
|
+
raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found")
|
|
325
|
+
raise HTTPException(
|
|
326
|
+
status_code=409, detail=f"Run '{run_id}' is not running"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
task.cancel()
|
|
330
|
+
# 等后台任务收尾: run_suite 捕获 CancelledError 后保存 cancelled 状态;
|
|
331
|
+
# 若任务尚未启动就被取消 (协程未执行), 这里兜底更新 run 记录。
|
|
332
|
+
with suppress(asyncio.CancelledError):
|
|
333
|
+
await asyncio.wait({task})
|
|
334
|
+
run = await runner.storage.get_run(run_id)
|
|
335
|
+
if run is not None and run.status not in ("completed", "failed", "cancelled"):
|
|
336
|
+
run.status = "cancelled"
|
|
337
|
+
run.completed_at = time.time() * 1000
|
|
338
|
+
await runner.storage.save_run(run)
|
|
339
|
+
|
|
340
|
+
return {"run_id": run_id, "cancelled": True}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class HumanScoreRequest(BaseModel):
|
|
344
|
+
"""人工评分回传请求体"""
|
|
345
|
+
|
|
346
|
+
task_id: str
|
|
347
|
+
trial_index: int = Field(..., ge=0)
|
|
348
|
+
score: float = Field(..., ge=0.0, le=1.0, description="人工评分 0.0-1.0")
|
|
349
|
+
explanation: str = Field("", description="评分理由/反馈")
|
|
350
|
+
grader_name: str = Field("human", description="人工评分器名称")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@router.post("/{run_id}/human-scores")
|
|
354
|
+
async def submit_human_score(run_id: str, request: HumanScoreRequest):
|
|
355
|
+
"""人工评分回传: 更新已存 GraderResult 并重算该 task 汇总"""
|
|
356
|
+
from agent_eval.api.app import _get_runner
|
|
357
|
+
|
|
358
|
+
runner = _get_runner()
|
|
359
|
+
if runner is None:
|
|
360
|
+
raise HTTPException(status_code=503, detail="EvalRunner not configured")
|
|
361
|
+
|
|
362
|
+
run = await runner.storage.get_run(run_id)
|
|
363
|
+
if run is None:
|
|
364
|
+
raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found")
|
|
365
|
+
|
|
366
|
+
trials = run.trials.get(request.task_id)
|
|
367
|
+
if not trials:
|
|
368
|
+
raise HTTPException(
|
|
369
|
+
status_code=404,
|
|
370
|
+
detail=f"Task '{request.task_id}' not found in run '{run_id}'",
|
|
371
|
+
)
|
|
372
|
+
trial = next(
|
|
373
|
+
(t for t in trials if t.trial_index == request.trial_index), None
|
|
374
|
+
)
|
|
375
|
+
if trial is None:
|
|
376
|
+
raise HTTPException(
|
|
377
|
+
status_code=404,
|
|
378
|
+
detail=(
|
|
379
|
+
f"Trial {request.trial_index} not found for task "
|
|
380
|
+
f"'{request.task_id}'"
|
|
381
|
+
),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
grader_result = next(
|
|
385
|
+
(
|
|
386
|
+
gr
|
|
387
|
+
for gr in trial.grader_results
|
|
388
|
+
if gr.grader_name == request.grader_name
|
|
389
|
+
),
|
|
390
|
+
None,
|
|
391
|
+
)
|
|
392
|
+
if grader_result is None:
|
|
393
|
+
raise HTTPException(
|
|
394
|
+
status_code=404,
|
|
395
|
+
detail=(
|
|
396
|
+
f"No grader result '{request.grader_name}' on task "
|
|
397
|
+
f"'{request.task_id}' trial {request.trial_index}"
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# 从 suite 中取 task 定义 (评分策略/阈值); suite 缺失时用默认阈值
|
|
402
|
+
suite = await runner.storage.get_suite(run.suite_name)
|
|
403
|
+
task = None
|
|
404
|
+
if suite is not None:
|
|
405
|
+
task = next((t for t in suite.tasks if t.id == request.task_id), None)
|
|
406
|
+
|
|
407
|
+
threshold = task.score_threshold if task is not None else 0.7
|
|
408
|
+
if task is not None:
|
|
409
|
+
threshold = task.get_grader_config(request.grader_name).get(
|
|
410
|
+
"threshold", threshold
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# 更新已存的 GraderResult
|
|
414
|
+
grader_result.score = request.score
|
|
415
|
+
grader_result.passed = request.score >= threshold
|
|
416
|
+
grader_result.explanation = request.explanation or "人工评分"
|
|
417
|
+
grader_result.confidence = 1.0
|
|
418
|
+
grader_result.details = {
|
|
419
|
+
**grader_result.details,
|
|
420
|
+
"status": "scored",
|
|
421
|
+
"human_score": request.score,
|
|
422
|
+
"human_explanation": request.explanation,
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
# 重算 trial 成功状态与 run 汇总 (含该 task 汇总)
|
|
426
|
+
if task is not None:
|
|
427
|
+
trial.success = runner._compute_trial_success(task, trial.grader_results)
|
|
428
|
+
run.summary = runner._compute_summary(run, suite) if suite is not None else run.summary
|
|
429
|
+
await runner.storage.save_run(run)
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
"run_id": run_id,
|
|
433
|
+
"task_id": request.task_id,
|
|
434
|
+
"trial_index": request.trial_index,
|
|
435
|
+
"grader_name": request.grader_name,
|
|
436
|
+
"score": grader_result.score,
|
|
437
|
+
"passed": grader_result.passed,
|
|
438
|
+
"trial_success": trial.success,
|
|
439
|
+
"summary": run.summary.model_dump() if run.summary else None,
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class CompareRequest(BaseModel):
|
|
444
|
+
"""对比请求体"""
|
|
445
|
+
run_id_a: str
|
|
446
|
+
run_id_b: str
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# compare 挂载在 /api/eval/compare (spec §REST API), 不带 /runs 前缀
|
|
450
|
+
compare_router = APIRouter()
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@compare_router.post("/compare")
|
|
454
|
+
async def compare_runs(request: CompareRequest):
|
|
455
|
+
"""对比两次运行"""
|
|
456
|
+
from agent_eval.api.app import _get_runner
|
|
457
|
+
|
|
458
|
+
runner = _get_runner()
|
|
459
|
+
if runner is None:
|
|
460
|
+
raise HTTPException(status_code=503, detail="EvalRunner not configured")
|
|
461
|
+
|
|
462
|
+
run_a = await runner.storage.get_run(request.run_id_a)
|
|
463
|
+
run_b = await runner.storage.get_run(request.run_id_b)
|
|
464
|
+
|
|
465
|
+
if run_a is None:
|
|
466
|
+
raise HTTPException(status_code=404, detail=f"Run '{request.run_id_a}' not found")
|
|
467
|
+
if run_b is None:
|
|
468
|
+
raise HTTPException(status_code=404, detail=f"Run '{request.run_id_b}' not found")
|
|
469
|
+
|
|
470
|
+
if not run_a.summary or not run_b.summary:
|
|
471
|
+
raise HTTPException(status_code=400, detail="Both runs must be completed")
|
|
472
|
+
|
|
473
|
+
# Build comparison
|
|
474
|
+
comparison = _build_comparison(run_a, run_b)
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
"run_a": {
|
|
478
|
+
"run_id": run_a.run_id,
|
|
479
|
+
"suite_name": run_a.suite_name,
|
|
480
|
+
"started_at": run_a.started_at,
|
|
481
|
+
},
|
|
482
|
+
"run_b": {
|
|
483
|
+
"run_id": run_b.run_id,
|
|
484
|
+
"suite_name": run_b.suite_name,
|
|
485
|
+
"started_at": run_b.started_at,
|
|
486
|
+
},
|
|
487
|
+
"comparison": comparison,
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _build_comparison(run_a, run_b) -> dict[str, Any]:
|
|
492
|
+
"""构建两次运行的对比"""
|
|
493
|
+
summary_a = run_a.summary
|
|
494
|
+
summary_b = run_b.summary
|
|
495
|
+
|
|
496
|
+
# 全局指标对比
|
|
497
|
+
all_k_values = set()
|
|
498
|
+
if summary_a.pass_at_k:
|
|
499
|
+
all_k_values.update(summary_a.pass_at_k.keys())
|
|
500
|
+
if summary_b.pass_at_k:
|
|
501
|
+
all_k_values.update(summary_b.pass_at_k.keys())
|
|
502
|
+
|
|
503
|
+
pass_at_k_comparison = {}
|
|
504
|
+
for k in sorted(all_k_values):
|
|
505
|
+
a_val = summary_a.pass_at_k.get(k, 0.0)
|
|
506
|
+
b_val = summary_b.pass_at_k.get(k, 0.0)
|
|
507
|
+
pass_at_k_comparison[f"pass_at_{k}"] = {
|
|
508
|
+
"a": a_val,
|
|
509
|
+
"b": b_val,
|
|
510
|
+
"delta": round(b_val - a_val, 4),
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
pass_power_k_comparison = {}
|
|
514
|
+
for k in sorted(all_k_values):
|
|
515
|
+
a_val = summary_a.pass_power_k.get(k, 0.0)
|
|
516
|
+
b_val = summary_b.pass_power_k.get(k, 0.0)
|
|
517
|
+
pass_power_k_comparison[f"pass_power_{k}"] = {
|
|
518
|
+
"a": a_val,
|
|
519
|
+
"b": b_val,
|
|
520
|
+
"delta": round(b_val - a_val, 4),
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
# 逐 task 对比
|
|
524
|
+
task_a_map = {ts.task_id: ts for ts in summary_a.task_summaries}
|
|
525
|
+
task_b_map = {ts.task_id: ts for ts in summary_b.task_summaries}
|
|
526
|
+
|
|
527
|
+
all_task_ids = set(task_a_map.keys()) | set(task_b_map.keys())
|
|
528
|
+
regressions = []
|
|
529
|
+
improvements = []
|
|
530
|
+
task_comparisons = {}
|
|
531
|
+
|
|
532
|
+
for task_id in sorted(all_task_ids):
|
|
533
|
+
ts_a = task_a_map.get(task_id)
|
|
534
|
+
ts_b = task_b_map.get(task_id)
|
|
535
|
+
|
|
536
|
+
if ts_a and ts_b:
|
|
537
|
+
a_score = ts_a.avg_score
|
|
538
|
+
b_score = ts_b.avg_score
|
|
539
|
+
delta = round(b_score - a_score, 4)
|
|
540
|
+
|
|
541
|
+
task_comparisons[task_id] = {
|
|
542
|
+
"a": a_score,
|
|
543
|
+
"b": b_score,
|
|
544
|
+
"delta": delta,
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if delta < -0.1:
|
|
548
|
+
regressions.append({
|
|
549
|
+
"task_id": task_id,
|
|
550
|
+
"a": a_score,
|
|
551
|
+
"b": b_score,
|
|
552
|
+
"delta": delta,
|
|
553
|
+
})
|
|
554
|
+
elif delta > 0.1:
|
|
555
|
+
improvements.append({
|
|
556
|
+
"task_id": task_id,
|
|
557
|
+
"a": a_score,
|
|
558
|
+
"b": b_score,
|
|
559
|
+
"delta": delta,
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
"pass_at_k": pass_at_k_comparison,
|
|
564
|
+
"pass_power_k": pass_power_k_comparison,
|
|
565
|
+
"avg_score": {
|
|
566
|
+
"a": summary_a.avg_score,
|
|
567
|
+
"b": summary_b.avg_score,
|
|
568
|
+
"delta": round(summary_b.avg_score - summary_a.avg_score, 4),
|
|
569
|
+
},
|
|
570
|
+
"regressions": regressions,
|
|
571
|
+
"improvements": improvements,
|
|
572
|
+
"tasks": task_comparisons,
|
|
573
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Suite management routes.
|
|
3
|
+
|
|
4
|
+
GET /suites — List all suites
|
|
5
|
+
POST /suites — Create a suite (JSON body)
|
|
6
|
+
GET /suites/{name} — Get suite details
|
|
7
|
+
DELETE /suites/{name} — Delete a suite
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, HTTPException
|
|
13
|
+
|
|
14
|
+
from agent_eval.core.types import EvalSuite
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.get("")
|
|
20
|
+
async def list_suites():
|
|
21
|
+
"""列出所有评测套件"""
|
|
22
|
+
from agent_eval.api.app import _get_runner
|
|
23
|
+
|
|
24
|
+
runner = _get_runner()
|
|
25
|
+
if runner is None:
|
|
26
|
+
raise HTTPException(status_code=503, detail="EvalRunner not configured")
|
|
27
|
+
|
|
28
|
+
suites = await runner.storage.list_suites()
|
|
29
|
+
return {
|
|
30
|
+
"suites": [
|
|
31
|
+
{
|
|
32
|
+
"name": s.name,
|
|
33
|
+
"description": s.description,
|
|
34
|
+
"task_count": len(s.tasks),
|
|
35
|
+
"metadata": s.metadata,
|
|
36
|
+
}
|
|
37
|
+
for s in suites
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.post("")
|
|
43
|
+
async def create_suite(suite: EvalSuite):
|
|
44
|
+
"""创建评测套件"""
|
|
45
|
+
from agent_eval.api.app import _get_runner
|
|
46
|
+
|
|
47
|
+
runner = _get_runner()
|
|
48
|
+
if runner is None:
|
|
49
|
+
raise HTTPException(status_code=503, detail="EvalRunner not configured")
|
|
50
|
+
|
|
51
|
+
await runner.storage.save_suite(suite)
|
|
52
|
+
return {"name": suite.name, "task_count": len(suite.tasks)}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.get("/{name}")
|
|
56
|
+
async def get_suite(name: str):
|
|
57
|
+
"""获取评测套件详情"""
|
|
58
|
+
from agent_eval.api.app import _get_runner
|
|
59
|
+
|
|
60
|
+
runner = _get_runner()
|
|
61
|
+
if runner is None:
|
|
62
|
+
raise HTTPException(status_code=503, detail="EvalRunner not configured")
|
|
63
|
+
|
|
64
|
+
suite = await runner.storage.get_suite(name)
|
|
65
|
+
if suite is None:
|
|
66
|
+
raise HTTPException(status_code=404, detail=f"Suite '{name}' not found")
|
|
67
|
+
|
|
68
|
+
return suite.model_dump()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.delete("/{name}")
|
|
72
|
+
async def delete_suite(name: str):
|
|
73
|
+
"""删除评测套件"""
|
|
74
|
+
from agent_eval.api.app import _get_runner
|
|
75
|
+
|
|
76
|
+
runner = _get_runner()
|
|
77
|
+
if runner is None:
|
|
78
|
+
raise HTTPException(status_code=503, detail="EvalRunner not configured")
|
|
79
|
+
|
|
80
|
+
deleted = await runner.storage.delete_suite(name)
|
|
81
|
+
if not deleted:
|
|
82
|
+
raise HTTPException(status_code=404, detail=f"Suite '{name}' not found")
|
|
83
|
+
|
|
84
|
+
return {"deleted": True}
|