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,868 @@
1
+ """
2
+ EvalRunner — the core orchestration engine.
3
+
4
+ Coordinates the full evaluation lifecycle:
5
+ 1. Load suite
6
+ 2. For each task, run N trials (TransientError → exponential-backoff retry)
7
+ 3. For each trial: snapshot → setup → run agent → get traces → grade → teardown → leak check
8
+ 4. Aggregate results into a RunSummary (pass@k / pass^k / consistency / saturation)
9
+
10
+ Usage:
11
+ runner = EvalRunner(agent_runner=my_runner)
12
+ result = await runner.run_suite(suite)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import contextlib
19
+ import hashlib
20
+ import json
21
+ import logging
22
+ import time
23
+ import uuid
24
+ from collections.abc import Awaitable, Callable
25
+ from dataclasses import replace
26
+ from typing import Any
27
+
28
+ from agent_eval.core.contract import (
29
+ AgentRunner,
30
+ EnvironmentManager,
31
+ EvalContext,
32
+ Grader,
33
+ Storage,
34
+ TraceProvider,
35
+ TransientError,
36
+ )
37
+ from agent_eval.core.metrics import aggregate_metrics, extract_metrics, pass_at_k, pass_power_k
38
+ from agent_eval.core.types import (
39
+ EvalSuite,
40
+ EvalTask,
41
+ GraderConfig,
42
+ GraderResult,
43
+ GraderType,
44
+ RunResult,
45
+ RunSummary,
46
+ TaskSummary,
47
+ TrialResult,
48
+ )
49
+ from agent_eval.graders import DEFAULT_GRADERS
50
+ from agent_eval.metrics.base import Metric
51
+ from agent_eval.metrics.llm_judge import LLMFn
52
+ from agent_eval.storage import MemoryStorage
53
+ from agent_eval.trace import PhoenixProvider
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+
58
+ # ─── Default Environment (NoOp) ──────────────────────────────────────────────
59
+
60
+
61
+ class NoOpEnvironment:
62
+ """默认无操作环境管理器"""
63
+
64
+ async def setup(self, task: EvalTask) -> None:
65
+ pass
66
+
67
+ async def teardown(self, task: EvalTask) -> None:
68
+ pass
69
+
70
+ async def snapshot(self) -> dict[str, Any]:
71
+ return {}
72
+
73
+ async def verify_clean(self, baseline: dict[str, Any]) -> dict[str, Any]:
74
+ return {"clean": True, "differences": []}
75
+
76
+ async def restore(self, baseline: dict[str, Any]) -> None:
77
+ return None
78
+
79
+
80
+ # ─── Progress Callback Type ──────────────────────────────────────────────────
81
+
82
+ ProgressCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
83
+
84
+
85
+ # ─── EvalRunner ───────────────────────────────────────────────────────────────
86
+
87
+
88
+ class EvalRunner:
89
+ """
90
+ 核心编排器。
91
+
92
+ 接收项目注入的组件, 执行评测。
93
+ 所有组件都有默认值, 只需配置你关心的部分。
94
+
95
+ 使用方式:
96
+ runner = EvalRunner(
97
+ agent_runner=MyAgentRunner(), # 必选
98
+ trace_provider=PhoenixProvider(...), # 可选, 默认 Phoenix
99
+ storage=SqliteStorage(...), # 可选, 默认 Memory
100
+ environment=MyEnvironment(), # 可选, 默认 NoOp
101
+ graders=[MyCustomGrader()], # 可选, 与内置合并 (同名覆盖)
102
+ concurrency=1, # trial 并发数
103
+ )
104
+ result = await runner.run_suite(suite)
105
+ """
106
+
107
+ def __init__(
108
+ self,
109
+ agent_runner: AgentRunner,
110
+ trace_provider: TraceProvider | None = None,
111
+ storage: Storage | None = None,
112
+ environment: EnvironmentManager | None = None,
113
+ graders: list[Grader] | None = None,
114
+ concurrency: int = 1,
115
+ max_concurrent_graders: int = 4,
116
+ per_trial_timeout: float = 300.0,
117
+ max_trial_retries: int = 2,
118
+ retry_base_delay: float = 1.0,
119
+ grader_timeout: float = 60.0,
120
+ verify_environment: bool = True,
121
+ enable_grader_cache: bool = True,
122
+ metrics_registry: dict[str, Metric] | None = None,
123
+ llm_fn: LLMFn | None = None,
124
+ ):
125
+ """
126
+ Args:
127
+ agent_runner: Agent 运行器 (必选)
128
+ trace_provider: Trace 数据提供者 (默认 Phoenix)
129
+ storage: 结果存储 (默认 Memory; 组合暴露 storage.datasets)
130
+ environment: 环境管理器 (默认 NoOp)
131
+ graders: 额外评分器 (与内置合并, 同名覆盖)
132
+ concurrency: trial 并发数
133
+ max_concurrent_graders: 评分器最大并发数
134
+ per_trial_timeout: 单个 trial 超时 (秒)
135
+ max_trial_retries: TransientError 最大重试次数
136
+ retry_base_delay: 重试指数退避基础延迟 (秒)
137
+ grader_timeout: 单个 grader 评分超时 (秒)
138
+ verify_environment: 是否做环境泄漏检测
139
+ enable_grader_cache: 是否启用 grader 结果缓存 (prompt-hash)
140
+ metrics_registry: LLM 输出质量指标注册表 (name → Metric),
141
+ 供 metric grader 分发; None = metric 类 grader 得配置错误结果
142
+ llm_fn: LLM 函数 (system, user) → text, 注入未配置的指标与
143
+ metric grader; None 不改变既有行为
144
+ """
145
+ self.agent_runner = agent_runner
146
+ self.trace_provider = trace_provider or PhoenixProvider()
147
+ self.storage = storage or MemoryStorage()
148
+ self.environment = environment or NoOpEnvironment()
149
+ self.concurrency = max(1, concurrency)
150
+ self.max_concurrent_graders = max(1, max_concurrent_graders)
151
+ self.per_trial_timeout = per_trial_timeout
152
+ self.max_trial_retries = max(0, max_trial_retries)
153
+ self.retry_base_delay = max(0.0, retry_base_delay)
154
+ self.grader_timeout = grader_timeout
155
+ self.verify_environment = verify_environment
156
+ self.enable_grader_cache = enable_grader_cache
157
+ self.metrics_registry: dict[str, Metric] = dict(metrics_registry or {})
158
+ self.llm_fn = llm_fn
159
+
160
+ # 注册 grader: 内置 + 自定义 (自定义覆盖同名)
161
+ self._graders: dict[str, Grader] = {}
162
+ for g in DEFAULT_GRADERS:
163
+ self._graders[g.name] = g
164
+ if graders:
165
+ for g in graders:
166
+ self._graders[g.name] = g
167
+
168
+ # 注入 storage 到需要它的 grader (如 HumanGrader 的评分请求落库)。
169
+ # 覆盖式注入: runner 是组合根, grader 实例可能被多个 runner 复用。
170
+ for g in self._graders.values():
171
+ if hasattr(g, "storage"):
172
+ g.storage = self.storage
173
+ # metric 分发 grader: 注入指标注册表与 LLM 函数 (D1/D2)
174
+ if hasattr(g, "metrics_registry"):
175
+ g.metrics_registry = self.metrics_registry
176
+ if hasattr(g, "llm_fn") and self.llm_fn is not None and g.llm_fn is None:
177
+ g.llm_fn = self.llm_fn
178
+
179
+ # LLM 函数注入未自行配置的指标 (指标实现持有 llm_fn 属性)
180
+ if self.llm_fn is not None:
181
+ for metric in self.metrics_registry.values():
182
+ if getattr(metric, "llm_fn", None) is None:
183
+ metric.llm_fn = self.llm_fn
184
+
185
+ # Grader 结果缓存 (runner 生命周期内, 内容寻址)
186
+ self._grader_cache: dict[str, GraderResult] = {}
187
+
188
+ # ── Public API ────────────────────────────────────────────────────────
189
+
190
+ async def run_suite(
191
+ self,
192
+ suite: EvalSuite,
193
+ callback: ProgressCallback | None = None,
194
+ run_id: str | None = None,
195
+ ) -> RunResult:
196
+ """
197
+ 执行整个 suite。
198
+
199
+ Args:
200
+ suite: 评测套件
201
+ callback: 进度回调 (用于 SSE 推送)
202
+ run_id: 指定 run ID (API 层预生成, 便于启动即返回)
203
+
204
+ Returns:
205
+ RunResult: 完整运行结果
206
+ """
207
+ run = RunResult(
208
+ run_id=run_id or f"run_{uuid.uuid4().hex[:12]}",
209
+ suite_name=suite.name,
210
+ status="running",
211
+ started_at=time.time() * 1000,
212
+ )
213
+
214
+ try:
215
+ # 保存 suite 定义与初始 run 记录 (保证启动后立即可查询)
216
+ await self.storage.save_suite(suite)
217
+ await self.storage.save_run(run)
218
+
219
+ for task in suite.tasks:
220
+ await self._emit(callback, "task_start", {
221
+ "task_id": task.id,
222
+ "task_description": task.description,
223
+ })
224
+
225
+ trials = await self._run_task_with_retries(task, callback, run_id=run.run_id)
226
+ run.trials[task.id] = trials
227
+
228
+ pass_rate = (
229
+ sum(1 for t in trials if t.success) / len(trials)
230
+ if trials else 0.0
231
+ )
232
+ await self._emit(callback, "task_complete", {
233
+ "task_id": task.id,
234
+ "trials": len(trials),
235
+ "pass_rate": pass_rate,
236
+ })
237
+
238
+ # 计算汇总
239
+ run.summary = self._compute_summary(run, suite)
240
+ run.status = "completed"
241
+
242
+ except asyncio.CancelledError:
243
+ run.status = "cancelled"
244
+ raise
245
+
246
+ except Exception as e:
247
+ run.status = "failed"
248
+ run.error = str(e)
249
+
250
+ finally:
251
+ # 所有退出路径 (含启动阶段被取消) 都落盘最终状态
252
+ run.completed_at = time.time() * 1000
253
+ await self.storage.save_run(run)
254
+
255
+ return run
256
+
257
+ async def cancel_run(self, run_id: str) -> bool:
258
+ """
259
+ 取消正在运行的 suite。
260
+
261
+ 注意: 正在执行的 trial 会继续完成, 但后续 task 不再启动。
262
+ """
263
+ # TODO: 实现取消逻辑 (通过事件/标志位)
264
+ return False
265
+
266
+ # ── Task Execution ───────────────────────────────────────────────────
267
+
268
+ async def _run_task_with_retries(
269
+ self,
270
+ task: EvalTask,
271
+ callback: ProgressCallback | None,
272
+ run_id: str = "",
273
+ ) -> list[TrialResult]:
274
+ """执行单个任务的多个 trial (TransientError 指数退避重试)"""
275
+ semaphore = asyncio.Semaphore(self.concurrency)
276
+
277
+ async def _trial(index: int) -> TrialResult:
278
+ async with semaphore:
279
+ # trial_start 发一次 (重试不重复发; 事件按 (task_id, trial_index) 幂等)
280
+ await self._emit(callback, "trial_start", {
281
+ "task_id": task.id,
282
+ "trial_index": index,
283
+ })
284
+ for attempt in range(self.max_trial_retries + 1):
285
+ try:
286
+ result = await self._run_trial(task, index, run_id=run_id)
287
+ await self._emit(callback, "trial_complete", {
288
+ "task_id": task.id,
289
+ "trial_index": index,
290
+ "success": result.success,
291
+ })
292
+ return result
293
+ except TransientError as e:
294
+ if attempt < self.max_trial_retries:
295
+ delay = self.retry_base_delay * (2**attempt)
296
+ logger.warning(
297
+ "TransientError in task %s trial %d (attempt %d), "
298
+ "retrying in %.2fs: %s",
299
+ task.id, index, attempt + 1, delay, e,
300
+ )
301
+ await asyncio.sleep(delay)
302
+ continue
303
+ # 重试用尽, 返回失败结果 (suite 继续)
304
+ return TrialResult(
305
+ trial_index=index,
306
+ trace_id="",
307
+ success=False,
308
+ grader_results=[],
309
+ metrics={},
310
+ transcript=[],
311
+ outcome={},
312
+ duration_ms=0.0,
313
+ error=(
314
+ f"TransientError after {self.max_trial_retries} "
315
+ f"retries: {e}"
316
+ ),
317
+ )
318
+
319
+ trials = await asyncio.gather(
320
+ *[_trial(i) for i in range(task.max_trials)],
321
+ return_exceptions=False,
322
+ )
323
+ return list(trials)
324
+
325
+ async def _run_trial(
326
+ self,
327
+ task: EvalTask,
328
+ index: int,
329
+ run_id: str = "",
330
+ ) -> TrialResult:
331
+ """执行单次 trial (含环境基线快照与泄漏检测)"""
332
+ # 1. 拍摄环境基线快照 (setup 前, 即"干净"状态)
333
+ try:
334
+ baseline = await self.environment.snapshot()
335
+ except Exception as e:
336
+ logger.warning("Environment snapshot failed for task %s: %s", task.id, e)
337
+ baseline = {}
338
+
339
+ # 2. 准备环境
340
+ await self.environment.setup(task)
341
+ start_time = time.time() * 1000
342
+
343
+ try:
344
+ # 3. 运行 Agent (带超时)
345
+ trace_id, transcript, outcome = await asyncio.wait_for(
346
+ self.agent_runner.run(task),
347
+ timeout=self.per_trial_timeout,
348
+ )
349
+
350
+ # 4. 获取 trace spans
351
+ spans = await self.trace_provider.get_spans(trace_id)
352
+
353
+ # 5. 提取过程指标
354
+ metrics = extract_metrics(spans, task.tracked_metrics)
355
+ elapsed = time.time() * 1000 - start_time
356
+ metrics["latency_ms"] = elapsed
357
+
358
+ # 6. 构建 trial result (临时 success=True, 评分后更新)
359
+ trial = TrialResult(
360
+ trial_index=index,
361
+ trace_id=trace_id,
362
+ success=True,
363
+ grader_results=[],
364
+ metrics=metrics,
365
+ transcript=transcript,
366
+ outcome=outcome,
367
+ duration_ms=elapsed,
368
+ )
369
+
370
+ # 7. 运行评分器 (EvalContext 贯穿, shared_state 在 grader 间共享)
371
+ context = EvalContext(
372
+ run_id=run_id,
373
+ task=task,
374
+ trial=trial,
375
+ spans=spans,
376
+ shared_state={},
377
+ )
378
+ trial = await self._grade_trial(trial, spans, task, context)
379
+
380
+ return trial
381
+
382
+ except TimeoutError:
383
+ elapsed = time.time() * 1000 - start_time
384
+ return TrialResult(
385
+ trial_index=index,
386
+ trace_id="",
387
+ success=False,
388
+ grader_results=[],
389
+ metrics={"latency_ms": elapsed},
390
+ transcript=[],
391
+ outcome={},
392
+ duration_ms=elapsed,
393
+ error=f"Trial timed out after {self.per_trial_timeout}s",
394
+ )
395
+
396
+ except asyncio.CancelledError:
397
+ raise
398
+
399
+ except TransientError:
400
+ # 交由 _run_task_with_retries 处理重试
401
+ raise
402
+
403
+ except Exception as e:
404
+ elapsed = time.time() * 1000 - start_time
405
+ return TrialResult(
406
+ trial_index=index,
407
+ trace_id="",
408
+ success=False,
409
+ grader_results=[],
410
+ metrics={"latency_ms": elapsed},
411
+ transcript=[],
412
+ outcome={},
413
+ duration_ms=elapsed,
414
+ error=str(e),
415
+ )
416
+
417
+ finally:
418
+ # 8. 清理环境
419
+ await self.environment.teardown(task)
420
+
421
+ # 9. 泄漏检测: 与基线比对, 泄漏则告警并恢复 (不判 trial 失败 —
422
+ # 泄漏是环境问题不是 Agent 问题)
423
+ if self.verify_environment:
424
+ try:
425
+ verify = await self.environment.verify_clean(baseline)
426
+ if not verify.get("clean", False):
427
+ logger.warning(
428
+ "Environment leak detected in task %s trial %d: %s",
429
+ task.id,
430
+ index,
431
+ verify.get("differences"),
432
+ )
433
+ await self.environment.restore(baseline)
434
+ except Exception as e:
435
+ logger.warning(
436
+ "Environment leak check failed for task %s trial %d: %s",
437
+ task.id, index, e,
438
+ )
439
+
440
+ # ── Grading ──────────────────────────────────────────────────────────
441
+
442
+ async def _grade_trial(
443
+ self,
444
+ trial: TrialResult,
445
+ spans: list[dict[str, Any]],
446
+ task: EvalTask,
447
+ context: EvalContext | None = None,
448
+ ) -> TrialResult:
449
+ """
450
+ 对一次 trial 运行所有评分器 (依赖拓扑 Pipeline)。
451
+
452
+ 1. 按 GraderConfig.dependencies 拓扑排序
453
+ 2. 依赖 grader 未通过 (或未配置/未运行) → 跳过记 0 分
454
+ 3. 单个 grader 超时 → 记 0 分失败
455
+ 4. MODEL 类 grader sample_count > 1 → 多采样聚合 (平均分/不确定性/置信度)
456
+ 5. sample_count == 1 且缓存开启 → prompt-hash 结果缓存
457
+ """
458
+ grader_results: dict[str, GraderResult] = {}
459
+
460
+ for config in self._topological_sort(task.graders):
461
+ grader = self._resolve_grader(config)
462
+
463
+ if grader is None:
464
+ grader_results[config.name] = GraderResult(
465
+ grader_name=config.name,
466
+ grader_type=config.type,
467
+ score=0.0,
468
+ passed=False,
469
+ explanation=f"Unknown grader: {config.name}",
470
+ )
471
+ continue
472
+
473
+ # 依赖检查: 所有依赖的 grader 必须已运行且通过
474
+ unsatisfied_dep = self._first_unsatisfied_dependency(config, grader_results)
475
+ if unsatisfied_dep is not None:
476
+ grader_results[config.name] = GraderResult(
477
+ grader_name=config.name,
478
+ grader_type=config.type,
479
+ score=0.0,
480
+ passed=False,
481
+ explanation=(
482
+ f"依赖未满足: grader '{unsatisfied_dep}' 未通过或未配置"
483
+ ),
484
+ )
485
+ continue
486
+
487
+ # 缓存命中 (多采样 deliberate 重试绕过缓存)
488
+ use_cache = self.enable_grader_cache and config.sample_count <= 1
489
+ if use_cache:
490
+ cache_key = self._grader_cache_key(config, trial)
491
+ cached = self._grader_cache.get(cache_key)
492
+ if cached is not None:
493
+ hit = cached.model_copy(deep=True)
494
+ hit.details = {**hit.details, "cached": True}
495
+ grader_results[config.name] = hit
496
+ continue
497
+
498
+ try:
499
+ # 分发型 grader (MetricGrader) 经 context.grader_config 感知
500
+ # 当前生效配置; 其余 grader 不读取该字段, 行为不变
501
+ call_context = (
502
+ replace(context, grader_config=config)
503
+ if context is not None
504
+ else None
505
+ )
506
+ result = await asyncio.wait_for(
507
+ grader.grade(trial, spans, task, call_context),
508
+ timeout=self.grader_timeout,
509
+ )
510
+ except TimeoutError:
511
+ result = GraderResult(
512
+ grader_name=config.name,
513
+ grader_type=config.type,
514
+ score=0.0,
515
+ passed=False,
516
+ explanation=f"Grader timeout after {self.grader_timeout}s",
517
+ )
518
+ except Exception as e:
519
+ result = GraderResult(
520
+ grader_name=config.name,
521
+ grader_type=config.type,
522
+ score=0.0,
523
+ passed=False,
524
+ explanation=f"Grader error: {e}",
525
+ )
526
+
527
+ # LLM Judge 多采样: 计算平均分/不确定性/置信度
528
+ if config.type == GraderType.MODEL and config.sample_count > 1:
529
+ result = await self._multi_sample(grader, result, config, trial, spans, task, context)
530
+
531
+ if use_cache:
532
+ self._grader_cache[cache_key] = result.model_copy(deep=True)
533
+
534
+ grader_results[config.name] = result
535
+
536
+ trial.grader_results = [
537
+ grader_results[config.name] for config in task.graders
538
+ ]
539
+
540
+ # 根据评分策略计算最终成功状态
541
+ trial.success = self._compute_trial_success(task, trial.grader_results)
542
+
543
+ return trial
544
+
545
+ def _resolve_grader(self, config: GraderConfig) -> Grader | None:
546
+ """按配置名解析 grader 实例。
547
+
548
+ metric 类配置 (type: metric) 的 name 即指标名, 不在 grader 注册表
549
+ 内时回退到 "metric" 分发器 (D1), 由其按 metric_name/config.name
550
+ 路由到注入的 Metric 实例; 其余类型保持未知 grader 语义。
551
+ """
552
+ grader = self._graders.get(config.name)
553
+ if grader is not None:
554
+ return grader
555
+ if config.type == GraderType.METRIC:
556
+ return self._graders.get("metric")
557
+ return None
558
+
559
+ async def _multi_sample(
560
+ self,
561
+ grader: Grader,
562
+ first_result: GraderResult,
563
+ config: GraderConfig,
564
+ trial: TrialResult,
565
+ spans: list[dict[str, Any]],
566
+ task: EvalTask,
567
+ context: EvalContext | None,
568
+ ) -> GraderResult:
569
+ """多采样评分: 平均分 + 不确定性 (极差/2) + 置信度 (1 - 不确定性)"""
570
+ samples = [first_result]
571
+ for _ in range(config.sample_count - 1):
572
+ try:
573
+ samples.append(await grader.grade(trial, spans, task, context))
574
+ except Exception as e:
575
+ samples.append(GraderResult(
576
+ grader_name=config.name,
577
+ grader_type=config.type,
578
+ score=0.0,
579
+ passed=False,
580
+ explanation=f"Grader error: {e}",
581
+ ))
582
+
583
+ scores = [s.score for s in samples]
584
+ avg_score = sum(scores) / len(scores)
585
+ uncertainty = (max(scores) - min(scores)) / 2
586
+ confidence = max(0.0, 1.0 - uncertainty)
587
+ # 多数样本通过才视为通过 (偶数采样平票判不通过)
588
+ passed = sum(1 for s in samples if s.passed) * 2 > len(samples)
589
+
590
+ return GraderResult(
591
+ grader_name=first_result.grader_name,
592
+ grader_type=first_result.grader_type,
593
+ score=avg_score,
594
+ passed=passed,
595
+ explanation=f"Multi-sample avg over {len(samples)} samples",
596
+ details={
597
+ **first_result.details,
598
+ "sample_scores": scores,
599
+ "sample_explanations": [s.explanation for s in samples],
600
+ },
601
+ confidence=confidence,
602
+ uncertainty=uncertainty,
603
+ sample_count=len(samples),
604
+ )
605
+
606
+ def _first_unsatisfied_dependency(
607
+ self,
608
+ config: GraderConfig,
609
+ grader_results: dict[str, GraderResult],
610
+ ) -> str | None:
611
+ """返回第一个未满足的依赖名; 全部满足返回 None。
612
+
613
+ 依赖未在本 task 中配置 (因而没有结果) 视为未满足。
614
+ """
615
+ for dep in config.dependencies:
616
+ result = grader_results.get(dep)
617
+ if result is None or not result.passed:
618
+ return dep
619
+ return None
620
+
621
+ def _topological_sort(
622
+ self,
623
+ configs: list[GraderConfig],
624
+ ) -> list[GraderConfig]:
625
+ """按 dependencies 拓扑排序 (保持声明顺序稳定, 环依赖按声明序兜底)"""
626
+ config_map = {c.name: c for c in configs}
627
+ visited: set[str] = set()
628
+ visiting: set[str] = set()
629
+ ordered: list[GraderConfig] = []
630
+
631
+ def visit(name: str) -> None:
632
+ if name in visited or name not in config_map:
633
+ return
634
+ if name in visiting:
635
+ # 环依赖: 跳过 (由依赖检查兜底记 0 分)
636
+ return
637
+ visiting.add(name)
638
+ for dep in config_map[name].dependencies:
639
+ visit(dep)
640
+ visiting.discard(name)
641
+ visited.add(name)
642
+ ordered.append(config_map[name])
643
+
644
+ for config in configs:
645
+ visit(config.name)
646
+ return ordered
647
+
648
+ def _grader_cache_key(self, config: GraderConfig, trial: TrialResult) -> str:
649
+ """prompt-hash 缓存 key: sha256(grader name + config + transcript/outcome)"""
650
+ payload = json.dumps(
651
+ {
652
+ "grader": config.name,
653
+ "config": config.config,
654
+ "transcript": trial.transcript,
655
+ "outcome": trial.outcome,
656
+ },
657
+ sort_keys=True,
658
+ default=str,
659
+ )
660
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
661
+
662
+ def _compute_trial_success(
663
+ self,
664
+ task: EvalTask,
665
+ grader_results: list[GraderResult],
666
+ ) -> bool:
667
+ """根据评分策略判断 trial 是否成功"""
668
+ strategy = task.score_strategy
669
+ threshold = task.score_threshold
670
+
671
+ if strategy == "all_pass":
672
+ return all(r.passed for r in grader_results) if grader_results else True
673
+
674
+ elif strategy == "weighted":
675
+ if not grader_results:
676
+ return True
677
+ # 使用 grader config 中的权重
678
+ total_weight = sum(
679
+ gc.weight for gc in task.graders
680
+ )
681
+ if total_weight == 0:
682
+ return True
683
+ weighted_score = sum(
684
+ r.score * gc.weight
685
+ # strict=False: replay 路径 (runs.py) 的 grader_results 可能来自
686
+ # 旧版本 suite, 长度不保证一致 — 保持截断语义
687
+ for r, gc in zip(grader_results, task.graders, strict=False)
688
+ ) / total_weight
689
+ return weighted_score >= threshold
690
+
691
+ elif strategy == "hybrid":
692
+ # required 必须通过
693
+ required_pass = all(
694
+ r.passed
695
+ for r, gc in zip(grader_results, task.graders, strict=False)
696
+ if gc.required
697
+ )
698
+ if not required_pass:
699
+ return False
700
+
701
+ # 非 required 加权
702
+ non_required = [
703
+ (r, gc) for r, gc in zip(grader_results, task.graders, strict=False)
704
+ if not gc.required
705
+ ]
706
+ if not non_required:
707
+ return True
708
+
709
+ total_weight = sum(gc.weight for _, gc in non_required)
710
+ if total_weight == 0:
711
+ return True
712
+ weighted_score = sum(
713
+ r.score * gc.weight for r, gc in non_required
714
+ ) / total_weight
715
+ return weighted_score >= threshold
716
+
717
+ return True
718
+
719
+ # ── Summary Computation ──────────────────────────────────────────────
720
+
721
+ def _compute_summary(
722
+ self,
723
+ run: RunResult,
724
+ suite: EvalSuite,
725
+ ) -> RunSummary:
726
+ """计算 suite 级别的汇总 (含一致性检查 / 饱和度检测 / pending 单列)"""
727
+ task_summaries: list[TaskSummary] = []
728
+ all_trials: list[TrialResult] = []
729
+
730
+ # 构建 task_id → description 映射
731
+ task_descriptions = {t.id: t.description for t in suite.tasks}
732
+
733
+ # 预计算 k_values (避免空 trials 时 KeyError)
734
+ max_trials = max(
735
+ (len(trials) for trials in run.trials.values()), default=0
736
+ )
737
+ k_values = list(range(1, max_trials + 1)) if max_trials > 0 else [1]
738
+
739
+ for task_id, trials in run.trials.items():
740
+ all_trials.extend(trials)
741
+
742
+ # pending (人工评分未回传) 的 trial 单列, 不计入通过率
743
+ pending_indices = {
744
+ i for i, t in enumerate(trials) if self._trial_pending(t)
745
+ }
746
+ counted = [
747
+ t for i, t in enumerate(trials) if i not in pending_indices
748
+ ]
749
+
750
+ p_at_k = {k: pass_at_k(counted, k) for k in k_values}
751
+ p_pow_k = {k: pass_power_k(counted, k) for k in k_values}
752
+
753
+ # 平均分 (仅计有评分结果的 trial)
754
+ scores = [t.avg_score() for t in counted if t.grader_results]
755
+
756
+ # trial 间一致性
757
+ consistency = self._check_trial_consistency(counted)
758
+
759
+ task_summaries.append(TaskSummary(
760
+ task_id=task_id,
761
+ task_description=task_descriptions.get(task_id, ""),
762
+ total_trials=len(trials),
763
+ pass_at_k=p_at_k,
764
+ pass_power_k=p_pow_k,
765
+ avg_score=sum(scores) / len(scores) if scores else 0.0,
766
+ avg_metrics=aggregate_metrics(trials),
767
+ failures=[
768
+ i for i, t in enumerate(trials)
769
+ if not t.success and i not in pending_indices
770
+ ],
771
+ pending_trials=sorted(pending_indices),
772
+ consistent=consistency["consistent"],
773
+ score_std_dev=consistency["std_dev"],
774
+ ))
775
+
776
+ # 全局汇总
777
+ return RunSummary(
778
+ total_tasks=len(run.trials),
779
+ total_trials=len(all_trials),
780
+ pass_at_k={
781
+ k: sum(
782
+ ts.pass_at_k.get(k, 0.0) for ts in task_summaries
783
+ ) / len(task_summaries)
784
+ if task_summaries else 0.0
785
+ for k in k_values
786
+ },
787
+ pass_power_k={
788
+ k: sum(
789
+ ts.pass_power_k.get(k, 0.0) for ts in task_summaries
790
+ ) / len(task_summaries)
791
+ if task_summaries else 0.0
792
+ for k in k_values
793
+ },
794
+ avg_score=(
795
+ sum(ts.avg_score for ts in task_summaries) / len(task_summaries)
796
+ if task_summaries else 0.0
797
+ ),
798
+ avg_metrics=aggregate_metrics(all_trials),
799
+ task_summaries=task_summaries,
800
+ failures=[ts.task_id for ts in task_summaries if ts.failures],
801
+ saturation=self._detect_saturation(task_summaries),
802
+ )
803
+
804
+ @staticmethod
805
+ def _trial_pending(trial: TrialResult) -> bool:
806
+ """trial 是否在等待人工评分 (含 pending 状态的 grader 结果)"""
807
+ return any(
808
+ gr.details.get("status") == "pending" for gr in trial.grader_results
809
+ )
810
+
811
+ @staticmethod
812
+ def _check_trial_consistency(trials: list[TrialResult]) -> dict[str, Any]:
813
+ """检查 trial 间分数一致性 (std < 0.2 视为一致)"""
814
+ scores = [
815
+ t.avg_score() for t in trials if t.grader_results
816
+ ]
817
+
818
+ if len(scores) < 2:
819
+ return {"consistent": True, "std_dev": 0.0, "scores": scores}
820
+
821
+ avg = sum(scores) / len(scores)
822
+ variance = sum((s - avg) ** 2 for s in scores) / len(scores)
823
+ std_dev = variance**0.5
824
+
825
+ return {
826
+ "consistent": std_dev < 0.2,
827
+ "std_dev": std_dev,
828
+ "scores": scores,
829
+ }
830
+
831
+ @staticmethod
832
+ def _detect_saturation(
833
+ task_summaries: list[TaskSummary],
834
+ threshold: float = 0.95,
835
+ ) -> dict[str, Any]:
836
+ """饱和度检测: 超过半数 task 的 pass@1 ≥ 0.95 → 建议加难"""
837
+ if not task_summaries:
838
+ return {"is_saturated": False, "saturation_ratio": 0.0}
839
+
840
+ saturated_tasks = [
841
+ ts.task_id for ts in task_summaries
842
+ if ts.pass_at_k.get(1, 0.0) >= threshold
843
+ ]
844
+ saturation_ratio = len(saturated_tasks) / len(task_summaries)
845
+ is_saturated = saturation_ratio > 0.5
846
+
847
+ return {
848
+ "is_saturated": is_saturated,
849
+ "saturation_ratio": saturation_ratio,
850
+ "saturated_tasks": saturated_tasks,
851
+ "recommendation": (
852
+ "评测已饱和, 建议增加更有挑战性的任务" if is_saturated else None
853
+ ),
854
+ }
855
+
856
+ # ── Helpers ──────────────────────────────────────────────────────────
857
+
858
+ async def _emit(
859
+ self,
860
+ callback: ProgressCallback | None,
861
+ event: str,
862
+ data: dict[str, Any],
863
+ ) -> None:
864
+ """发送进度事件"""
865
+ if callback is not None:
866
+ # 回调不应中断主流程
867
+ with contextlib.suppress(Exception):
868
+ await callback(event, data)