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,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()
|