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.
Files changed (63) hide show
  1. aeval_framework-0.1.0.dist-info/METADATA +42 -0
  2. aeval_framework-0.1.0.dist-info/RECORD +63 -0
  3. aeval_framework-0.1.0.dist-info/WHEEL +4 -0
  4. aeval_framework-0.1.0.dist-info/entry_points.txt +2 -0
  5. agent_eval/__init__.py +14 -0
  6. agent_eval/api/__init__.py +14 -0
  7. agent_eval/api/app.py +82 -0
  8. agent_eval/api/events.py +96 -0
  9. agent_eval/api/routes/__init__.py +0 -0
  10. agent_eval/api/routes/datasets.py +441 -0
  11. agent_eval/api/routes/graders.py +19 -0
  12. agent_eval/api/routes/metrics.py +49 -0
  13. agent_eval/api/routes/runs.py +573 -0
  14. agent_eval/api/routes/suites.py +84 -0
  15. agent_eval/api/routes/tasks.py +114 -0
  16. agent_eval/api/standalone.py +105 -0
  17. agent_eval/cli.py +455 -0
  18. agent_eval/core/__init__.py +48 -0
  19. agent_eval/core/contract.py +296 -0
  20. agent_eval/core/metrics.py +184 -0
  21. agent_eval/core/runner.py +868 -0
  22. agent_eval/core/suite.py +60 -0
  23. agent_eval/core/types.py +227 -0
  24. agent_eval/dataset/__init__.py +31 -0
  25. agent_eval/dataset/models.py +199 -0
  26. agent_eval/dataset/quality.py +194 -0
  27. agent_eval/dataset/sources/__init__.py +45 -0
  28. agent_eval/dataset/sources/llm_generator.py +219 -0
  29. agent_eval/dataset/sources/manual.py +172 -0
  30. agent_eval/dataset/sources/regression.py +201 -0
  31. agent_eval/dataset/sources/trace_mining.py +277 -0
  32. agent_eval/dataset/storage.py +342 -0
  33. agent_eval/dataset/version.py +72 -0
  34. agent_eval/examples/__init__.py +0 -0
  35. agent_eval/examples/basic_usage.py +175 -0
  36. agent_eval/examples/mock_runner.py +195 -0
  37. agent_eval/graders/__init__.py +91 -0
  38. agent_eval/graders/artifact_check.py +114 -0
  39. agent_eval/graders/code_based.py +101 -0
  40. agent_eval/graders/human.py +77 -0
  41. agent_eval/graders/metric.py +142 -0
  42. agent_eval/graders/model_based.py +179 -0
  43. agent_eval/graders/state_check.py +106 -0
  44. agent_eval/graders/step_level.py +116 -0
  45. agent_eval/graders/tool_calls.py +102 -0
  46. agent_eval/graders/transcript.py +86 -0
  47. agent_eval/metrics/__init__.py +110 -0
  48. agent_eval/metrics/answer_relevancy.py +57 -0
  49. agent_eval/metrics/base.py +155 -0
  50. agent_eval/metrics/batch_evaluation.py +267 -0
  51. agent_eval/metrics/context_precision.py +62 -0
  52. agent_eval/metrics/context_recall.py +71 -0
  53. agent_eval/metrics/faithfulness.py +72 -0
  54. agent_eval/metrics/llm_judge.py +100 -0
  55. agent_eval/metrics/prompt_metric.py +150 -0
  56. agent_eval/metrics/pytest_plugin.py +308 -0
  57. agent_eval/metrics/report.py +149 -0
  58. agent_eval/metrics/synthetic_data.py +203 -0
  59. agent_eval/storage/__init__.py +17 -0
  60. agent_eval/storage/memory.py +95 -0
  61. agent_eval/storage/sqlite.py +240 -0
  62. agent_eval/trace/__init__.py +16 -0
  63. agent_eval/trace/phoenix.py +144 -0
@@ -0,0 +1,114 @@
1
+ """
2
+ Task management routes.
3
+
4
+ GET /tasks — List all tasks (across all suites)
5
+ GET /tasks/{id} — Get task details
6
+ GET /tasks/{id}/history — Aggregate trial results of a task across runs
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from fastapi import APIRouter, HTTPException
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ @router.get("")
17
+ async def list_tasks():
18
+ """列出所有任务 (跨 suite)"""
19
+ from agent_eval.api.app import _get_runner
20
+
21
+ runner = _get_runner()
22
+ if runner is None:
23
+ raise HTTPException(status_code=503, detail="EvalRunner not configured")
24
+
25
+ suites = await runner.storage.list_suites()
26
+ all_tasks = []
27
+ for suite in suites:
28
+ for task in suite.tasks:
29
+ all_tasks.append({
30
+ "id": task.id,
31
+ "description": task.description,
32
+ "suite_name": suite.name,
33
+ "max_trials": task.max_trials,
34
+ "grader_count": len(task.graders),
35
+ })
36
+
37
+ return {"tasks": all_tasks, "total": len(all_tasks)}
38
+
39
+
40
+ @router.get("/{task_id}")
41
+ async def get_task(task_id: str):
42
+ """获取任务详情"""
43
+ from agent_eval.api.app import _get_runner
44
+
45
+ runner = _get_runner()
46
+ if runner is None:
47
+ raise HTTPException(status_code=503, detail="EvalRunner not configured")
48
+
49
+ # Search across all suites
50
+ suites = await runner.storage.list_suites()
51
+ for suite in suites:
52
+ for task in suite.tasks:
53
+ if task.id == task_id:
54
+ return {
55
+ "task": task.model_dump(),
56
+ "suite_name": suite.name,
57
+ }
58
+
59
+ raise HTTPException(status_code=404, detail=f"Task '{task_id}' not found")
60
+
61
+
62
+ # history 覆盖的最近 run 数上限 (与列表页 limit 一致)
63
+ HISTORY_RUN_LIMIT = 50
64
+
65
+
66
+ @router.get("/{task_id}/history")
67
+ async def get_task_history(task_id: str):
68
+ """跨 run 聚合该 task 的 trial 结果 (倒序; 只读, 不改变持久化行为)"""
69
+ from agent_eval.api.app import _get_runner
70
+
71
+ runner = _get_runner()
72
+ if runner is None:
73
+ raise HTTPException(status_code=503, detail="EvalRunner not configured")
74
+
75
+ # 404 语义: task 不存在于任何 suite
76
+ suites = await runner.storage.list_suites()
77
+ suite_name = next(
78
+ (
79
+ s.name
80
+ for s in suites
81
+ if any(t.id == task_id for t in s.tasks)
82
+ ),
83
+ None,
84
+ )
85
+ if suite_name is None:
86
+ raise HTTPException(status_code=404, detail=f"Task '{task_id}' not found")
87
+
88
+ history = []
89
+ runs = await runner.storage.list_runs(limit=HISTORY_RUN_LIMIT)
90
+ for run in runs:
91
+ trials = run.trials.get(task_id)
92
+ if not trials:
93
+ continue
94
+ grader_scores: dict[str, list[float]] = {}
95
+ for trial in trials:
96
+ for gr in trial.grader_results:
97
+ grader_scores.setdefault(gr.grader_name, []).append(gr.score)
98
+ history.append({
99
+ "run_id": run.run_id,
100
+ "suite_name": run.suite_name,
101
+ "started_at": run.started_at,
102
+ "trials_passed": sum(1 for t in trials if t.success),
103
+ "trials_total": len(trials),
104
+ "avg_score": (
105
+ round(sum(t.avg_score() for t in trials) / len(trials), 4)
106
+ ),
107
+ "graders": {
108
+ name: round(sum(scores) / len(scores), 4)
109
+ for name, scores in grader_scores.items()
110
+ },
111
+ })
112
+
113
+ history.sort(key=lambda h: h["started_at"], reverse=True)
114
+ return {"task_id": task_id, "suite_name": suite_name, "history": history}
@@ -0,0 +1,105 @@
1
+ """
2
+ Standalone API deployment for Aeval.
3
+
4
+ Exposes the full eval route set under the `/v1` prefix as a self-contained
5
+ FastAPI application, with a version header on every response and a `/v1/meta`
6
+ capabilities endpoint. The existing `create_app()` is reused unchanged —
7
+ host-app mounts (e.g. AChat's `/api/eval`) keep their behaviour.
8
+
9
+ Usage:
10
+ # In-process (tests / mounting)
11
+ app = create_standalone_app(runner=my_runner)
12
+
13
+ # Service
14
+ eval-suite serve --port 8900
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from importlib.metadata import PackageNotFoundError
20
+ from importlib.metadata import version as _metadata_version
21
+
22
+ from fastapi import FastAPI
23
+
24
+ from agent_eval.api.app import create_app
25
+ from agent_eval.core.runner import EvalRunner
26
+ from agent_eval.graders import get_grader_catalog
27
+
28
+ PACKAGE_NAME = "agent-eval"
29
+
30
+ # /v1 下暴露的路由组 (与 create_app 内的挂载一致; /health 为探活)
31
+ CAPABILITY_ENDPOINTS = [
32
+ "/suites",
33
+ "/tasks",
34
+ "/runs",
35
+ "/compare",
36
+ "/graders",
37
+ "/datasets",
38
+ "/metrics",
39
+ "/health",
40
+ ]
41
+
42
+
43
+ def package_version() -> str:
44
+ """包版本: 优先取已安装元数据, 未安装 (源码直跑) 时回退模块常量。"""
45
+ try:
46
+ return _metadata_version(PACKAGE_NAME)
47
+ except PackageNotFoundError:
48
+ from agent_eval import __version__
49
+
50
+ return __version__
51
+
52
+
53
+ def _meta_payload() -> dict:
54
+ """GET /v1/meta 响应体: 版本 + 能力清单。"""
55
+ return {
56
+ "name": "Aeval",
57
+ "package": PACKAGE_NAME,
58
+ "version": package_version(),
59
+ "api_prefix": "/v1",
60
+ "endpoints": CAPABILITY_ENDPOINTS,
61
+ "capabilities": {
62
+ "graders": [g["name"] for g in get_grader_catalog()],
63
+ "storage": ["memory", "sqlite"],
64
+ "trace_providers": ["phoenix (optional, lazily imported)"],
65
+ "sse": True,
66
+ "datasets": True,
67
+ "metrics": True,
68
+ },
69
+ }
70
+
71
+
72
+ def create_standalone_app(runner: EvalRunner | None = None) -> FastAPI:
73
+ """
74
+ Create the standalone Aeval API app (all routes under /v1).
75
+
76
+ Args:
77
+ runner: EvalRunner instance. If None, run routes return 503 while
78
+ registry/storage-backed endpoints stay usable.
79
+
80
+ Returns:
81
+ FastAPI application serving /v1/* with X-Aeval-Version headers.
82
+ """
83
+ version = package_version()
84
+ app = FastAPI(
85
+ title="Aeval API",
86
+ version=version,
87
+ description="Standalone Agent Evaluation Framework API (/v1)",
88
+ )
89
+
90
+ @app.get("/v1/meta")
91
+ async def meta() -> dict:
92
+ # 注册在 mount 之前: Starlette 按注册顺序匹配, /v1/meta 优先于子应用
93
+ return _meta_payload()
94
+
95
+ @app.middleware("http")
96
+ async def add_version_header(request, call_next):
97
+ response = await call_next(request)
98
+ response.headers["X-Aeval-Version"] = version
99
+ return response
100
+
101
+ # 复用既有 create_app 的全部路由集合, 整体挂 /v1 (既有 create_app 零改动,
102
+ # 寄宿部署的 /api/eval 挂载不受影响 — 设计 D5)
103
+ app.mount("/v1", create_app(runner=runner))
104
+
105
+ return app
agent_eval/cli.py ADDED
@@ -0,0 +1,455 @@
1
+ """
2
+ eval-suite — the Aeval command line.
3
+
4
+ Commands:
5
+ run Execute a suite (default runner: built-in MockAgentRunner)
6
+ validate Validate a suite YAML without running it
7
+ list List runs or suites from the storage DB
8
+ show Show one run's details (--task drills into a single task)
9
+ compare A/B compare two runs (metric deltas + regressions/improvements)
10
+ serve Serve the standalone API (/v1) via uvicorn
11
+
12
+ Runner selection (run): --runner option > AEVAL_RUNNER env var > "mock".
13
+ Custom runners register via the "agent_eval.runners" entry-point group
14
+ (name → zero-arg factory returning an AgentRunner).
15
+
16
+ Storage (list/show/compare and run persistence): SQLite, ./aeval.db by
17
+ default; override with --db or the AEVAL_DB environment variable.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import os
24
+ from datetime import datetime
25
+
26
+ import typer
27
+
28
+ DEFAULT_DB = "./aeval.db"
29
+ RUNNERS_ENTRY_POINT_GROUP = "agent_eval.runners"
30
+
31
+ app = typer.Typer(
32
+ help="Aeval — agent evaluation framework (https://github.com/agent-eval/agent-eval)",
33
+ no_args_is_help=True,
34
+ add_completion=False,
35
+ )
36
+
37
+ LINE = "─" * 56
38
+
39
+
40
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
41
+
42
+
43
+ def _db_path(db: str | None) -> str:
44
+ return db or os.environ.get("AEVAL_DB") or DEFAULT_DB
45
+
46
+
47
+ def _format_ts(ms: float | None) -> str:
48
+ if not ms:
49
+ return "-"
50
+ return datetime.fromtimestamp(ms / 1000).strftime("%Y-%m-%d %H:%M:%S")
51
+
52
+
53
+ def _k_display(d: dict) -> list[tuple[int, float]]:
54
+ """pass@k / pass^k dict → 排序后的 (k, rate) 列表 (容错 str key)。"""
55
+ items = []
56
+ for k, v in (d or {}).items():
57
+ try:
58
+ items.append((int(k), float(v)))
59
+ except (TypeError, ValueError):
60
+ continue
61
+ return sorted(items)
62
+
63
+
64
+ def _rate_pct(rate: float) -> str:
65
+ return f"{rate * 100:.1f}%"
66
+
67
+
68
+ def _resolve_agent_runner(name: str | None):
69
+ """解析 AgentRunner: --runner > AEVAL_RUNNER > 内置 mock。
70
+
71
+ 自定义注册: entry-point group "agent_eval.runners" (name → 零参工厂)。
72
+ """
73
+ resolved = name or os.environ.get("AEVAL_RUNNER") or "mock"
74
+
75
+ if resolved == "mock":
76
+ from agent_eval.examples.mock_runner import MockAgentRunner
77
+
78
+ return MockAgentRunner(success_rate=1.0, latency_range=(0.0, 0.01))
79
+
80
+ from importlib.metadata import entry_points
81
+
82
+ eps = entry_points(group=RUNNERS_ENTRY_POINT_GROUP)
83
+ ep = next((e for e in eps if e.name == resolved), None)
84
+ if ep is None:
85
+ registered = ", ".join(sorted(["mock", *(e.name for e in eps)]))
86
+ typer.echo(
87
+ f"error: unknown runner '{resolved}' (registered: {registered}).\n"
88
+ f"Point --runner at a name published under the "
89
+ f"'{RUNNERS_ENTRY_POINT_GROUP}' entry-point group, or set "
90
+ f"AEVAL_RUNNER."
91
+ )
92
+ raise typer.Exit(code=2)
93
+
94
+ factory = ep.load()
95
+ return factory()
96
+
97
+
98
+ def _build_storage(db: str | None):
99
+ from agent_eval.storage.sqlite import SqliteStorage
100
+
101
+ return SqliteStorage(_db_path(db))
102
+
103
+
104
+ def _trace_provider_for(agent_runner):
105
+ """CLI 默认离线: 内置 mock trace; 注册的 runner 可暴露自己的 provider。"""
106
+ if agent_runner is not None and hasattr(agent_runner, "trace_provider"):
107
+ return agent_runner.trace_provider
108
+ from agent_eval.examples.mock_runner import MockTraceProvider
109
+
110
+ return MockTraceProvider()
111
+
112
+
113
+ def _print_run_summary(run) -> None:
114
+ """§11.2 形态的汇总输出 (无 emoji, 兼容非 UTF-8 终端)。"""
115
+ summary = run.summary
116
+ typer.echo(LINE)
117
+ typer.echo("Results Summary")
118
+ typer.echo(LINE)
119
+ duration = run.duration_ms
120
+ typer.echo(
121
+ f" Run: {run.run_id} Status: {run.status}"
122
+ + (f" Duration: {duration / 1000:.1f}s" if duration else "")
123
+ )
124
+ for k, rate in _k_display(summary.pass_at_k):
125
+ typer.echo(f" Pass@{k}: {_rate_pct(rate)}")
126
+ for k, rate in _k_display(summary.pass_power_k):
127
+ typer.echo(f" Pass^{k}: {_rate_pct(rate)}")
128
+ typer.echo(f" Avg Score: {summary.avg_score:.4f}")
129
+ typer.echo(f" Tasks: {summary.total_tasks} Trials: {summary.total_trials}")
130
+
131
+ if summary.failures:
132
+ typer.echo("")
133
+ typer.echo(" Failures:")
134
+ for ts in summary.task_summaries:
135
+ if ts.task_id in summary.failures:
136
+ passed = ts.total_trials - len(ts.failures)
137
+ typer.echo(f" - {ts.task_id}: {passed}/{ts.total_trials} trials passed")
138
+ typer.echo(LINE)
139
+
140
+
141
+ # ─── run ─────────────────────────────────────────────────────────────────────
142
+
143
+
144
+ @app.command()
145
+ def run(
146
+ suite_path: str = typer.Argument(..., help="Suite YAML 文件路径"),
147
+ trials: int | None = typer.Option(None, "--trials", help="覆盖每个任务的 trial 数"),
148
+ concurrency: int | None = typer.Option(
149
+ None, "--concurrency", min=1, help="trial 并发数 (默认串行)"
150
+ ),
151
+ runner: str | None = typer.Option(
152
+ None,
153
+ "--runner",
154
+ envvar="AEVAL_RUNNER",
155
+ help="AgentRunner 名称 (内置 mock, 或 agent_eval.runners entry-point 注册名)",
156
+ ),
157
+ db: str | None = typer.Option(
158
+ None, "--db", envvar="AEVAL_DB", help="SQLite 结果库路径 (默认 ./aeval.db)"
159
+ ),
160
+ ) -> None:
161
+ """加载并执行 suite, 打印汇总; 存在失败任务时退出码非 0。"""
162
+ from agent_eval.core.runner import EvalRunner
163
+ from agent_eval.core.suite import SuiteLoadError, load_suite
164
+
165
+ try:
166
+ suite = load_suite(suite_path)
167
+ except SuiteLoadError as e:
168
+ typer.echo(f"error: {e}", err=True)
169
+ raise typer.Exit(code=1) from None
170
+
171
+ if trials is not None and trials < 1:
172
+ typer.echo("error: --trials must be >= 1", err=True)
173
+ raise typer.Exit(code=2)
174
+
175
+ max_trials = trials or max(t.max_trials for t in suite.tasks)
176
+ typer.echo(
177
+ f"Starting eval run: {suite.name} v{suite.version} "
178
+ f"({len(suite.tasks)} tasks, up to {max_trials} trials each)"
179
+ )
180
+
181
+ agent_runner = _resolve_agent_runner(runner)
182
+ storage = _build_storage(db)
183
+
184
+ eval_runner = EvalRunner(
185
+ agent_runner=agent_runner,
186
+ trace_provider=_trace_provider_for(agent_runner),
187
+ storage=storage,
188
+ **({"concurrency": concurrency} if concurrency else {}),
189
+ )
190
+
191
+ async def _execute():
192
+ await storage.initialize()
193
+ counter = {"n": 0}
194
+
195
+ async def _progress(event: str, data: dict) -> None:
196
+ if event == "task_complete":
197
+ counter["n"] += 1
198
+ total = data.get("trials", 0)
199
+ passed = round(data.get("pass_rate", 0.0) * total)
200
+ typer.echo(
201
+ f" [{counter['n']}/{len(suite.tasks)}] "
202
+ f"{data.get('task_id', '?')}: {passed}/{total} trials passed"
203
+ )
204
+
205
+ return await eval_runner.run_suite(suite, callback=_progress)
206
+
207
+ run_result = asyncio.run(_execute())
208
+
209
+ if run_result.status != "completed":
210
+ typer.echo(f"error: run ended with status '{run_result.status}': "
211
+ f"{run_result.error}", err=True)
212
+ raise typer.Exit(code=1)
213
+
214
+ _print_run_summary(run_result)
215
+
216
+ if run_result.summary and run_result.summary.failures:
217
+ raise typer.Exit(code=1)
218
+
219
+
220
+ # ─── validate ────────────────────────────────────────────────────────────────
221
+
222
+
223
+ @app.command()
224
+ def validate(
225
+ suite_path: str = typer.Argument(..., help="Suite YAML 文件路径"),
226
+ ) -> None:
227
+ """只做加载校验: 输出结论, 校验失败退出码非 0。"""
228
+ from agent_eval.core.suite import SuiteLoadError, load_suite
229
+
230
+ try:
231
+ suite = load_suite(suite_path)
232
+ except SuiteLoadError as e:
233
+ typer.echo(f"INVALID: {e}", err=True)
234
+ raise typer.Exit(code=1) from None
235
+
236
+ typer.echo(
237
+ f"VALID: {suite.name} v{suite.version} — {len(suite.tasks)} task(s), "
238
+ f"{sum(len(t.graders) for t in suite.tasks)} grader config(s)"
239
+ )
240
+
241
+
242
+ # ─── list ────────────────────────────────────────────────────────────────────
243
+
244
+
245
+ @app.command(name="list")
246
+ def list_cmd(
247
+ kind: str = typer.Argument(..., help="runs | suites"),
248
+ db: str | None = typer.Option(
249
+ None, "--db", envvar="AEVAL_DB", help="SQLite 结果库路径 (默认 ./aeval.db)"
250
+ ),
251
+ limit: int = typer.Option(50, "--limit", min=1, help="runs 列表条数上限"),
252
+ ) -> None:
253
+ """列出运行历史 (runs) 或套件清单 (suites)。"""
254
+ from agent_eval.storage.sqlite import SqliteStorage
255
+
256
+ kind = kind.strip().lower()
257
+ if kind not in ("runs", "suites"):
258
+ typer.echo("error: kind must be 'runs' or 'suites'", err=True)
259
+ raise typer.Exit(code=2)
260
+
261
+ storage = SqliteStorage(_db_path(db))
262
+
263
+ async def _query():
264
+ await storage.initialize()
265
+ if kind == "runs":
266
+ return await storage.list_runs(limit=limit)
267
+ return await storage.list_suites()
268
+
269
+ rows = asyncio.run(_query())
270
+
271
+ if not rows:
272
+ typer.echo(f"No {kind} found in {_db_path(db)}")
273
+ return
274
+
275
+ if kind == "runs":
276
+ typer.echo(f"{'RUN ID':<20} {'SUITE':<24} {'STATUS':<10} STARTED")
277
+ for r in rows:
278
+ typer.echo(
279
+ f"{r.run_id:<20} {r.suite_name:<24} {r.status:<10} "
280
+ f"{_format_ts(r.started_at)}"
281
+ )
282
+ else:
283
+ typer.echo(f"{'NAME':<32} {'VERSION':<10} TASKS DESCRIPTION")
284
+ for s in rows:
285
+ typer.echo(
286
+ f"{s.name:<32} {s.version:<10} {len(s.tasks):<6} {s.description}"
287
+ )
288
+
289
+
290
+ # ─── show ────────────────────────────────────────────────────────────────────
291
+
292
+
293
+ @app.command()
294
+ def show(
295
+ run_id: str = typer.Argument(..., help="Run ID"),
296
+ task: str | None = typer.Option(
297
+ None, "--task", help="下钻单个任务: 输出逐 trial 明细"
298
+ ),
299
+ db: str | None = typer.Option(
300
+ None, "--db", envvar="AEVAL_DB", help="SQLite 结果库路径 (默认 ./aeval.db)"
301
+ ),
302
+ ) -> None:
303
+ """输出 run 详情; --task 下钻单任务。"""
304
+ storage = _build_storage(db)
305
+ run = asyncio.run(_get_run(storage, run_id))
306
+
307
+ if run is None:
308
+ typer.echo(f"error: run '{run_id}' not found in {_db_path(db)}", err=True)
309
+ raise typer.Exit(code=1)
310
+
311
+ typer.echo(f"Run: {run.run_id} Suite: {run.suite_name} Status: {run.status}")
312
+ typer.echo(f"Started: {_format_ts(run.started_at)} "
313
+ f"Completed: {_format_ts(run.completed_at)}")
314
+
315
+ summary = run.summary
316
+ if summary is None:
317
+ typer.echo("(no summary — run did not complete)")
318
+ if run.error:
319
+ typer.echo(f"Error: {run.error}")
320
+ return
321
+
322
+ for k, rate in _k_display(summary.pass_at_k):
323
+ typer.echo(f" Pass@{k}: {_rate_pct(rate)}")
324
+ for k, rate in _k_display(summary.pass_power_k):
325
+ typer.echo(f" Pass^{k}: {_rate_pct(rate)}")
326
+ typer.echo(f" Avg Score: {summary.avg_score:.4f}")
327
+
328
+ typer.echo("")
329
+ typer.echo(f"{'TASK':<24} {'TRIALS':<8} {'PASS@1':<8} {'AVG':<8} RESULT")
330
+ for ts in summary.task_summaries:
331
+ passed = ts.total_trials - len(ts.failures)
332
+ pending = f" (+{len(ts.pending_trials)} pending)" if ts.pending_trials else ""
333
+ result = "PASS" if ts.task_id not in summary.failures else "FAIL"
334
+ typer.echo(
335
+ f"{ts.task_id:<24} {passed}/{ts.total_trials:<6} "
336
+ f"{_rate_pct(ts.pass_at_k.get(1, ts.pass_at_k.get('1', 0.0))):<8} "
337
+ f"{ts.avg_score:<8.4f} {result}{pending}"
338
+ )
339
+
340
+ if task is not None:
341
+ trials = run.trials.get(task)
342
+ if trials is None:
343
+ typer.echo(f"error: task '{task}' not in run {run.run_id}", err=True)
344
+ raise typer.Exit(code=1)
345
+ typer.echo("")
346
+ typer.echo(f"Task '{task}' — {len(trials)} trial(s):")
347
+ for t in trials:
348
+ typer.echo(
349
+ f" trial {t.trial_index}: {'PASS' if t.success else 'FAIL'} "
350
+ f"(score {t.avg_score():.4f}, {t.duration_ms:.0f}ms"
351
+ + (f", error: {t.error}" if t.error else "") + ")"
352
+ )
353
+ for gr in t.grader_results:
354
+ typer.echo(
355
+ f" - {gr.grader_name} [{gr.grader_type.value}]: "
356
+ f"{gr.score:.4f} {'passed' if gr.passed else 'FAILED'}"
357
+ + (f" — {gr.explanation}" if gr.explanation else "")
358
+ )
359
+
360
+
361
+ async def _get_run(storage, run_id: str):
362
+ await storage.initialize()
363
+ return await storage.get_run(run_id)
364
+
365
+
366
+ # ─── compare ─────────────────────────────────────────────────────────────────
367
+
368
+
369
+ @app.command()
370
+ def compare(
371
+ run_a: str = typer.Argument(..., help="基准 run ID (A)"),
372
+ run_b: str = typer.Argument(..., help="对比 run ID (B)"),
373
+ db: str | None = typer.Option(
374
+ None, "--db", envvar="AEVAL_DB", help="SQLite 结果库路径 (默认 ./aeval.db)"
375
+ ),
376
+ ) -> None:
377
+ """输出两 run 的指标 delta 与退化/提升任务清单。"""
378
+ storage = _build_storage(db)
379
+
380
+ async def _load():
381
+ await storage.initialize()
382
+ return await storage.get_run(run_a), await storage.get_run(run_b)
383
+
384
+ a, b = asyncio.run(_load())
385
+ if a is None:
386
+ typer.echo(f"error: run '{run_a}' not found", err=True)
387
+ raise typer.Exit(code=1)
388
+ if b is None:
389
+ typer.echo(f"error: run '{run_b}' not found", err=True)
390
+ raise typer.Exit(code=1)
391
+ if not a.summary or not b.summary:
392
+ typer.echo("error: both runs must be completed to compare", err=True)
393
+ raise typer.Exit(code=1)
394
+
395
+ # 与 API 同语义 (agent_eval.api.routes.runs._build_comparison)
396
+ from agent_eval.api.routes.runs import _build_comparison
397
+
398
+ comparison = _build_comparison(a, b)
399
+
400
+ typer.echo(f"Comparing: {run_a} (A) vs {run_b} (B)")
401
+ typer.echo(LINE)
402
+ typer.echo(f"{'METRIC':<16} {'A':>8} {'B':>8} {'DELTA':>9}")
403
+ metric_rows: dict[str, dict] = {}
404
+ metric_rows.update(comparison["pass_at_k"])
405
+ metric_rows.update(comparison["pass_power_k"])
406
+ metric_rows["Avg Score"] = comparison["avg_score"]
407
+ for label in sorted(
408
+ metric_rows, key=lambda k: (k not in ("Avg Score",), k)
409
+ ):
410
+ entry = metric_rows[label]
411
+ label = label.replace("pass_at_", "Pass@").replace("pass_power_", "Pass^")
412
+ typer.echo(
413
+ f"{label:<16} {entry['a']:>8.4f} {entry['b']:>8.4f} {entry['delta']:>+9.4f}"
414
+ )
415
+
416
+ for label, key, mark in (
417
+ ("Regressions", "regressions", "-"),
418
+ ("Improvements", "improvements", "+"),
419
+ ):
420
+ typer.echo("")
421
+ typer.echo(f"{label}:")
422
+ items = comparison[key]
423
+ if not items:
424
+ typer.echo(" (none)")
425
+ for it in items:
426
+ typer.echo(
427
+ f" {mark} {it['task_id']}: {it['a']:.4f} -> {it['b']:.4f} "
428
+ f"(delta {it['delta']:+.4f})"
429
+ )
430
+
431
+
432
+ # ─── serve ───────────────────────────────────────────────────────────────────
433
+
434
+
435
+ @app.command()
436
+ def serve(
437
+ host: str = typer.Option("127.0.0.1", "--host", help="监听地址 (默认本机回环)"),
438
+ port: int = typer.Option(8000, "--port", help="监听端口"),
439
+ ) -> None:
440
+ """启动独立 API 服务: 全部评测路由挂 /v1, 默认仅本机回环。"""
441
+ import uvicorn
442
+
443
+ from agent_eval.api.standalone import create_standalone_app
444
+
445
+ typer.echo(f"Aeval standalone API on http://{host}:{port}/v1 (meta: /v1/meta)")
446
+ uvicorn.run(create_standalone_app(), host=host, port=port)
447
+
448
+
449
+ def main() -> None:
450
+ """Console-script entry point (pyproject [project.scripts])."""
451
+ app()
452
+
453
+
454
+ if __name__ == "__main__":
455
+ main()