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,86 @@
1
+ """
2
+ Transcript grader — transcript analysis.
3
+
4
+ Analyzes the efficiency of an agent's execution:
5
+ - Number of turns vs limit
6
+ - Token usage vs limit
7
+ - Tool call redundancy
8
+
9
+ Config schema:
10
+ {
11
+ "max_turns": 20,
12
+ "max_tokens": 10000,
13
+ "threshold": 0.5
14
+ }
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ from agent_eval.core.contract import EvalContext
22
+ from agent_eval.core.types import EvalTask, GraderResult, GraderType, TrialResult
23
+
24
+
25
+ class TranscriptGrader:
26
+ """转录记录分析评分器"""
27
+
28
+ name = "transcript"
29
+
30
+ async def grade(
31
+ self,
32
+ trial: TrialResult,
33
+ spans: list[dict[str, Any]],
34
+ task: EvalTask,
35
+ context: EvalContext | None = None,
36
+ ) -> GraderResult:
37
+ config = task.get_grader_config(self.name)
38
+ max_turns = config.get("max_turns", 20)
39
+ max_tokens = config.get("max_tokens", 10000)
40
+ threshold = config.get("threshold", 0.5)
41
+
42
+ # 从 metrics 获取实际值
43
+ n_turns = trial.metrics.get("n_turns", 0)
44
+ n_tokens = trial.metrics.get("n_total_tokens", 0)
45
+
46
+ # 计算分数 (线性衰减)
47
+ turns_score = max(0.0, 1.0 - n_turns / max_turns) if max_turns > 0 else 1.0
48
+ tokens_score = max(0.0, 1.0 - n_tokens / max_tokens) if max_tokens > 0 else 1.0
49
+
50
+ # 计算工具调用冗余度
51
+ redundancy = self._calc_redundancy(spans)
52
+
53
+ # 综合分数
54
+ score = (turns_score + tokens_score + (1.0 - redundancy)) / 3.0
55
+
56
+ return GraderResult(
57
+ grader_name=self.name,
58
+ grader_type=GraderType.TRANSCRIPT,
59
+ score=score,
60
+ passed=score >= threshold,
61
+ explanation=(
62
+ f"turns={n_turns}/{max_turns}, "
63
+ f"tokens={n_tokens}/{max_tokens}, "
64
+ f"redundancy={redundancy:.1%}"
65
+ ),
66
+ details={
67
+ "turns_score": turns_score,
68
+ "tokens_score": tokens_score,
69
+ "redundancy": redundancy,
70
+ },
71
+ )
72
+
73
+ def _calc_redundancy(self, spans: list[dict[str, Any]]) -> float:
74
+ """计算工具调用冗余度"""
75
+ tool_calls = [
76
+ span.get("attributes", {}).get("agenthub.tool_name", "")
77
+ for span in spans
78
+ if "tool.call" in span.get("name", "")
79
+ ]
80
+
81
+ if not tool_calls:
82
+ return 0.0
83
+
84
+ unique_calls = set(tool_calls)
85
+ # 冗余度 = 1 - (唯一调用数 / 总调用数)
86
+ return 1.0 - len(unique_calls) / len(tool_calls)
@@ -0,0 +1,110 @@
1
+ """LLM output quality metrics for the Aeval evaluation framework.
2
+
3
+ Modules:
4
+ base — MetricResult / Metric ABC / BaseLLMMetric / to_grader()
5
+ llm_judge — LLMFn protocol + tolerant JSON parsing + retry
6
+ answer_relevancy / faithfulness / context_recall / context_precision — P0 metrics
7
+ synthetic_data — Golden + SyntheticDataGenerator (documents → dataset items)
8
+ batch_evaluation — BatchEvaluator (对已有输出批量打分, P1)
9
+ prompt_metric — PromptMetric (Prompt 变体 A/B, P1)
10
+ report — 批量/run 结果渲染为 Markdown/JSON 报告 (P1)
11
+ pytest_plugin — pytest 集成 (fixtures + suite 门禁; 只依赖 pytest, 不在
12
+ 此处导入以保持框架可无 pytest 运行 — 用例侧按需注册)
13
+
14
+ LLM functions are injected as protocols (async (system, user) -> str);
15
+ no LLM SDK is bound in the framework core.
16
+ """
17
+
18
+ from agent_eval.metrics.answer_relevancy import AnswerRelevancyMetric
19
+ from agent_eval.metrics.base import (
20
+ BaseLLMMetric,
21
+ Metric,
22
+ MetricError,
23
+ MetricGraderAdapter,
24
+ MetricResult,
25
+ )
26
+ from agent_eval.metrics.batch_evaluation import (
27
+ BatchCaseResult,
28
+ BatchEvaluationRequest,
29
+ BatchEvaluationResult,
30
+ BatchEvaluator,
31
+ BatchMetricSummary,
32
+ BatchTestCase,
33
+ MetricScore,
34
+ UnknownMetricsError,
35
+ )
36
+ from agent_eval.metrics.context_precision import ContextPrecisionMetric
37
+ from agent_eval.metrics.context_recall import ContextRecallMetric
38
+ from agent_eval.metrics.faithfulness import FaithfulnessMetric
39
+ from agent_eval.metrics.llm_judge import (
40
+ DEFAULT_MAX_RETRIES,
41
+ LLMFn,
42
+ LLMJudgeError,
43
+ LLMNotConfiguredError,
44
+ extract_json_object,
45
+ judge_json,
46
+ )
47
+ from agent_eval.metrics.prompt_metric import (
48
+ PromptComparisonResult,
49
+ PromptMetric,
50
+ PromptTemplateError,
51
+ PromptTrialDetail,
52
+ PromptVariant,
53
+ )
54
+ from agent_eval.metrics.report import render_batch_report, render_run_report
55
+ from agent_eval.metrics.synthetic_data import Golden, SyntheticDataGenerator
56
+
57
+
58
+ def build_default_metrics_registry(
59
+ llm_fn: LLMFn | None = None,
60
+ threshold: float = 0.5,
61
+ ) -> dict[str, Metric]:
62
+ """
63
+ 构建 P0 指标注册表 (name → Metric), 供 EvalRunner(metrics_registry=...) 注入。
64
+
65
+ llm_fn 可为 None (metric grader 届时返回明确配置错误而非崩溃)。
66
+ """
67
+ metrics: list[Metric] = [
68
+ AnswerRelevancyMetric(llm_fn=llm_fn, threshold=threshold),
69
+ FaithfulnessMetric(llm_fn=llm_fn, threshold=threshold),
70
+ ContextRecallMetric(llm_fn=llm_fn, threshold=threshold),
71
+ ContextPrecisionMetric(llm_fn=llm_fn, threshold=threshold),
72
+ ]
73
+ return {m.name: m for m in metrics}
74
+
75
+
76
+ __all__ = [
77
+ "Metric",
78
+ "MetricResult",
79
+ "MetricError",
80
+ "BaseLLMMetric",
81
+ "MetricGraderAdapter",
82
+ "LLMFn",
83
+ "LLMJudgeError",
84
+ "LLMNotConfiguredError",
85
+ "DEFAULT_MAX_RETRIES",
86
+ "judge_json",
87
+ "extract_json_object",
88
+ "AnswerRelevancyMetric",
89
+ "FaithfulnessMetric",
90
+ "ContextRecallMetric",
91
+ "ContextPrecisionMetric",
92
+ "Golden",
93
+ "SyntheticDataGenerator",
94
+ "build_default_metrics_registry",
95
+ "BatchTestCase",
96
+ "BatchEvaluationRequest",
97
+ "BatchEvaluationResult",
98
+ "BatchCaseResult",
99
+ "BatchMetricSummary",
100
+ "MetricScore",
101
+ "BatchEvaluator",
102
+ "UnknownMetricsError",
103
+ "PromptVariant",
104
+ "PromptTrialDetail",
105
+ "PromptComparisonResult",
106
+ "PromptMetric",
107
+ "PromptTemplateError",
108
+ "render_batch_report",
109
+ "render_run_report",
110
+ ]
@@ -0,0 +1,57 @@
1
+ """P0 metric — Answer Relevancy (回答与问题的相关度)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agent_eval.metrics.base import BaseLLMMetric, MetricError, MetricResult
6
+
7
+
8
+ class AnswerRelevancyMetric(BaseLLMMetric):
9
+ """
10
+ 回答相关度: Agent 回答是否切题, 是否解决了用户的问题。
11
+
12
+ 评分逻辑: LLM 从回答中提取独立陈述并逐一判断与问题的相关度,
13
+ 输出平均相关度分数。
14
+ """
15
+
16
+ name = "answer_relevancy"
17
+
18
+ _SYSTEM_PROMPT = """你是一个评测专家。请评估 Agent 的回答与用户问题的相关度。
19
+
20
+ 步骤:
21
+ 1. 从 Agent 回答中提取所有独立陈述
22
+ 2. 对每个陈述, 判断它与用户问题的相关度 (0-1)
23
+ 3. 计算平均相关度分数
24
+
25
+ 以 JSON 格式返回:
26
+ {
27
+ "statements": ["陈述1", "陈述2"],
28
+ "relevancies": [0.9, 0.3],
29
+ "score": 0.85,
30
+ "reason": "一句话理由"
31
+ }"""
32
+
33
+ async def measure(
34
+ self,
35
+ input: str,
36
+ actual_output: str,
37
+ expected_output: str | None = None,
38
+ context: list[str] | None = None,
39
+ retrieval_context: list[str] | None = None,
40
+ ) -> MetricResult:
41
+ user_prompt = f"用户问题: {input}\n\nAgent 回答: {actual_output}"
42
+ data = await self._llm_judge(self._SYSTEM_PROMPT, user_prompt)
43
+
44
+ score = self._score_of(data)
45
+ if "score" not in data:
46
+ raise MetricError(f"judge response missing 'score': {data!r}")
47
+
48
+ return MetricResult(
49
+ name=self.name,
50
+ score=score,
51
+ reason=str(data.get("reason", "")),
52
+ details={
53
+ "statements": data.get("statements", []),
54
+ "relevancies": data.get("relevancies", []),
55
+ },
56
+ threshold=self.threshold,
57
+ )
@@ -0,0 +1,155 @@
1
+ """Metric protocol — MetricResult / Metric ABC / BaseLLMMetric / to_grader bridge.
2
+
3
+ A Metric measures LLM output quality (score 0-1 + reason). `to_grader()`
4
+ adapts a Metric into the Grader protocol so it can be injected directly into
5
+ EvalRunner; the registry-dispatch path (type: metric grader config) lives in
6
+ graders/metric_check.py.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ from agent_eval.metrics.llm_judge import (
16
+ LLMFn,
17
+ LLMJudgeError,
18
+ LLMNotConfiguredError,
19
+ judge_json,
20
+ )
21
+
22
+
23
+ @dataclass
24
+ class MetricResult:
25
+ """单个指标的计算结果"""
26
+
27
+ name: str # 指标名称
28
+ score: float # 分数 (0-1)
29
+ reason: str = "" # 评分理由 (LLM Judge 生成)
30
+ details: dict[str, Any] = field(default_factory=dict) # 中间数据 (statements/verdicts...)
31
+ threshold: float = 0.5 # 通过阈值
32
+ success: bool = False # 是否通过
33
+
34
+ def __post_init__(self):
35
+ self.success = self.score >= self.threshold
36
+
37
+
38
+ class MetricError(Exception):
39
+ """指标计算失败 (LLM 输出结构不符合预期等)。"""
40
+
41
+
42
+ class Metric(ABC):
43
+ """指标基类 — 所有 LLM 输出质量指标的抽象"""
44
+
45
+ name: str = "base_metric"
46
+ threshold: float = 0.5
47
+
48
+ @abstractmethod
49
+ async def measure(
50
+ self,
51
+ input: str,
52
+ actual_output: str,
53
+ expected_output: str | None = None,
54
+ context: list[str] | None = None,
55
+ retrieval_context: list[str] | None = None,
56
+ ) -> MetricResult:
57
+ """
58
+ 核心测量方法。
59
+
60
+ Args:
61
+ input: 用户输入/问题
62
+ actual_output: Agent 实际输出
63
+ expected_output: 期望输出 (可选)
64
+ context: 回答所依据的上下文 (RAG)
65
+ retrieval_context: 检索到的原始文档 (RAG)
66
+
67
+ Raises:
68
+ LLMNotConfiguredError: 未注入 LLM 函数 (明确配置错误, 非 0 分)
69
+ MetricError / LLMJudgeError: 计算失败
70
+ """
71
+ ...
72
+
73
+ def to_grader(self) -> MetricGraderAdapter:
74
+ """将 Metric 转换为 Grader 适配器, 融入评分流水线 (grader 类型 metric)"""
75
+ return MetricGraderAdapter(self)
76
+
77
+
78
+ class BaseLLMMetric(Metric):
79
+ """基于 LLM Judge 的指标基类"""
80
+
81
+ def __init__(self, llm_fn: LLMFn | None = None, threshold: float = 0.5):
82
+ self.llm_fn = llm_fn
83
+ self.threshold = threshold
84
+
85
+ async def _llm_judge(
86
+ self,
87
+ system_prompt: str,
88
+ user_prompt: str,
89
+ ) -> dict[str, Any]:
90
+ """调用 LLM 并解析结构化 JSON (容错 + 重试在 llm_judge.judge_json)"""
91
+ return await judge_json(self.llm_fn, system_prompt, user_prompt)
92
+
93
+ @staticmethod
94
+ def _score_of(data: dict[str, Any], fallback: float = 0.0) -> float:
95
+ """从 judge 输出中取 score 并夹取到 [0, 1]"""
96
+ try:
97
+ score = float(data.get("score", fallback))
98
+ except (TypeError, ValueError):
99
+ return fallback
100
+ return max(0.0, min(1.0, score))
101
+
102
+
103
+ class MetricGraderAdapter:
104
+ """
105
+ Metric → Grader 协议适配器 (metric.to_grader() 产物)。
106
+
107
+ 从 trial 提取 input/actual_output (transcript 首末条), 调用
108
+ metric.measure 映射为 GraderResult。grader name = metric.name,
109
+ task 配置须使用同名 grader (config 可覆盖 threshold)。
110
+ """
111
+
112
+ def __init__(self, metric: Metric):
113
+ self.metric = metric
114
+ self.name = metric.name
115
+
116
+ async def grade(self, trial, spans, task, context=None):
117
+ from agent_eval.core.types import GraderResult, GraderType
118
+
119
+ config = task.get_grader_config(self.name)
120
+ threshold = config.get("threshold", self.metric.threshold)
121
+
122
+ first = trial.transcript[0] if trial.transcript else {}
123
+ last = trial.transcript[-1] if trial.transcript else {}
124
+ prompt = first.get("content", "") if isinstance(first, dict) else ""
125
+ output = last.get("content", "") if isinstance(last, dict) else ""
126
+
127
+ try:
128
+ result = await self.metric.measure(
129
+ input=prompt,
130
+ actual_output=output,
131
+ expected_output=config.get("expected_output"),
132
+ context=config.get("context"),
133
+ retrieval_context=config.get("retrieval_context"),
134
+ )
135
+ except (LLMNotConfiguredError, LLMJudgeError, MetricError) as e:
136
+ return GraderResult(
137
+ grader_name=self.name,
138
+ grader_type=GraderType.METRIC,
139
+ score=0.0,
140
+ passed=False,
141
+ explanation=f"配置/计算错误: {e}",
142
+ )
143
+
144
+ return GraderResult(
145
+ grader_name=self.name,
146
+ grader_type=GraderType.METRIC,
147
+ score=result.score,
148
+ passed=result.score >= threshold,
149
+ explanation=result.reason,
150
+ details={
151
+ **result.details,
152
+ "metric": result.name,
153
+ "metric_threshold": result.threshold,
154
+ },
155
+ )
@@ -0,0 +1,267 @@
1
+ """Batch evaluation over pre-existing outputs — BatchEvaluator + request/result models.
2
+
3
+ 对**已有输出** (历史对话 / 日志, 非运行 Agent 产生) 的用例集批量计算指标:
4
+ 输入 (input/actual_output, 可选 expected_output/context/retrieval_context) 与
5
+ 指标名列表, 输出逐条结果 + 汇总 (各指标 avg/min/max、pass/fail 计数、pass_rate)。
6
+
7
+ - 指标名解析前置: 任一未注册抛 UnknownMetricsError (携带无效名列表), 未发任何 LLM 调用
8
+ - 单条异常隔离: 单条用例内指标报错记入该条 result, 不中断整批
9
+ - 受限并发: asyncio.Semaphore (默认 4)
10
+ - LLM 函数沿用注入约定 (LLMFn); 未注入走 require_llm_fn 明确配置错误, 非静默 0 分
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ from agent_eval.metrics.base import Metric
20
+ from agent_eval.metrics.llm_judge import LLMFn, require_llm_fn
21
+
22
+ DEFAULT_CONCURRENCY = 4
23
+
24
+
25
+ class UnknownMetricsError(Exception):
26
+ """请求中的指标名未注册 (携带无效名列表; 解析前置, 零 LLM 调用)。"""
27
+
28
+ def __init__(self, unknown: list[str]):
29
+ self.unknown = list(unknown)
30
+ super().__init__(f"Unknown metrics: {', '.join(self.unknown)}")
31
+
32
+
33
+ class BatchTestCase(BaseModel):
34
+ """单条待评用例 — 已有输出 (非运行 Agent 产生)"""
35
+
36
+ input: str
37
+ actual_output: str
38
+ expected_output: str | None = None
39
+ context: list[str] | None = None
40
+ retrieval_context: list[str] | None = None
41
+
42
+
43
+ class BatchEvaluationRequest(BaseModel):
44
+ """批量评测请求 (与 REST POST /api/eval/metrics/batch 请求体同构)"""
45
+
46
+ test_cases: list[BatchTestCase] = Field(default_factory=list)
47
+ metrics: list[str] = Field(
48
+ ..., min_length=1, description="要计算的指标名 (须经注册表解析)"
49
+ )
50
+ thresholds: dict[str, float] = Field(
51
+ default_factory=dict,
52
+ description="逐指标覆盖 Metric 默认阈值 (显式给出的优先生效)",
53
+ )
54
+
55
+
56
+ class MetricScore(BaseModel):
57
+ """单条用例上单指标的结果 (异常隔离: error 非空时 score=0)"""
58
+
59
+ name: str
60
+ score: float
61
+ reason: str = ""
62
+ threshold: float = 0.5 # 实际使用的阈值 (thresholds 覆盖后)
63
+ success: bool = False
64
+ error: str | None = None
65
+
66
+
67
+ class BatchCaseResult(BaseModel):
68
+ """单条用例的逐指标结果"""
69
+
70
+ index: int
71
+ input: str
72
+ scores: dict[str, MetricScore] = Field(default_factory=dict)
73
+ overall_pass: bool = False
74
+
75
+
76
+ class BatchMetricSummary(BaseModel):
77
+ """单指标汇总 (threshold 记录实际使用的阈值)"""
78
+
79
+ avg: float = 0.0
80
+ min: float = 0.0
81
+ max: float = 0.0
82
+ pass_count: int = 0
83
+ fail_count: int = 0
84
+ pass_rate: float = 0.0
85
+ threshold: float = 0.5
86
+
87
+
88
+ class BatchEvaluationResult(BaseModel):
89
+ """批量评测结果"""
90
+
91
+ results: list[BatchCaseResult] = Field(default_factory=list)
92
+ summary: dict[str, BatchMetricSummary] = Field(default_factory=dict)
93
+ pass_count: int = 0
94
+ fail_count: int = 0
95
+ pass_rate: float = 0.0
96
+
97
+
98
+ class BatchEvaluator:
99
+ """
100
+ 批量评测器 — 对已有输出用例集计算指标 (受限并发 + 单条异常隔离)。
101
+
102
+ 用法:
103
+ evaluator = BatchEvaluator(metrics_registry, llm_fn=llm_fn, concurrency=4)
104
+ result = await evaluator.evaluate(request)
105
+
106
+ llm_fn 注入约定与 EvalRunner 一致: 注入注册表中未自行配置 llm_fn 的指标。
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ metrics_registry: dict[str, Metric] | None,
112
+ llm_fn: LLMFn | None = None,
113
+ concurrency: int = DEFAULT_CONCURRENCY,
114
+ ):
115
+ self.metrics_registry = dict(metrics_registry or {})
116
+ self.llm_fn = llm_fn
117
+ self.concurrency = max(1, concurrency)
118
+ if self.llm_fn is not None:
119
+ for metric in self.metrics_registry.values():
120
+ if getattr(metric, "llm_fn", None) is None:
121
+ metric.llm_fn = self.llm_fn
122
+
123
+ async def evaluate(
124
+ self,
125
+ request: BatchEvaluationRequest,
126
+ ) -> BatchEvaluationResult:
127
+ """
128
+ 执行批量评测。
129
+
130
+ 指标名解析前置 (未注册 → UnknownMetricsError, 零 LLM 调用);
131
+ 逐条逐指标计算 (Semaphore 受限并发, 单条异常记入该条 result)。
132
+ """
133
+ metrics = self._resolve_metrics(request.metrics)
134
+ self._ensure_llm_configured(metrics)
135
+
136
+ thresholds = {
137
+ m.name: request.thresholds.get(m.name, m.threshold) for m in metrics
138
+ }
139
+ semaphore = asyncio.Semaphore(self.concurrency)
140
+
141
+ async def _score(
142
+ case_index: int,
143
+ case: BatchTestCase,
144
+ metric: Metric,
145
+ ) -> tuple[int, str, MetricScore]:
146
+ async with semaphore:
147
+ score = await self._measure_isolated(
148
+ metric, thresholds[metric.name], case
149
+ )
150
+ return case_index, metric.name, score
151
+
152
+ pairs = await asyncio.gather(
153
+ *(
154
+ _score(i, case, m)
155
+ for i, case in enumerate(request.test_cases)
156
+ for m in metrics
157
+ )
158
+ )
159
+
160
+ by_case: list[dict[str, MetricScore]] = [{} for _ in request.test_cases]
161
+ for case_index, metric_name, score in pairs:
162
+ by_case[case_index][metric_name] = score
163
+
164
+ results = [
165
+ BatchCaseResult(
166
+ index=i,
167
+ input=case.input,
168
+ scores=by_case[i],
169
+ overall_pass=all(s.success for s in by_case[i].values()),
170
+ )
171
+ for i, case in enumerate(request.test_cases)
172
+ ]
173
+
174
+ pass_count = sum(1 for r in results if r.overall_pass)
175
+ total = len(results)
176
+ return BatchEvaluationResult(
177
+ results=results,
178
+ summary=self._compute_summary(results, thresholds),
179
+ pass_count=pass_count,
180
+ fail_count=total - pass_count,
181
+ pass_rate=pass_count / total if total else 0.0,
182
+ )
183
+
184
+ # ── 内部 ──────────────────────────────────────────────────────────────
185
+
186
+ def _resolve_metrics(self, names: list[str]) -> list[Metric]:
187
+ """解析指标名 → 实例; 任一未注册即抛 UnknownMetricsError (前置)。"""
188
+ unknown = sorted({n for n in names if n not in self.metrics_registry})
189
+ if unknown:
190
+ raise UnknownMetricsError(unknown)
191
+ # 保序去重 (同名指标重复请求只算一次)
192
+ seen: dict[str, Metric] = {}
193
+ for name in names:
194
+ seen.setdefault(name, self.metrics_registry[name])
195
+ return list(seen.values())
196
+
197
+ def _ensure_llm_configured(self, metrics: list[Metric]) -> None:
198
+ """请求到的指标必须拿到 LLM 函数 (自身持有或注入), 否则明确配置错误。"""
199
+ missing = [m.name for m in metrics if getattr(m, "llm_fn", None) is None]
200
+ if not missing:
201
+ return
202
+ # 走 require_llm_fn 的明确配置错误语义 (而非静默 0 分)
203
+ llm_fn = require_llm_fn(self.llm_fn)
204
+ for name in missing:
205
+ self.metrics_registry[name].llm_fn = llm_fn
206
+
207
+ @staticmethod
208
+ async def _measure_isolated(
209
+ metric: Metric,
210
+ threshold: float,
211
+ case: BatchTestCase,
212
+ ) -> MetricScore:
213
+ """单条单指标计算; 异常记入该条结果 (score=0 + error), 不中断整批。"""
214
+ try:
215
+ result = await metric.measure(
216
+ input=case.input,
217
+ actual_output=case.actual_output,
218
+ expected_output=case.expected_output,
219
+ context=case.context,
220
+ retrieval_context=case.retrieval_context,
221
+ )
222
+ except asyncio.CancelledError:
223
+ raise
224
+ except Exception as e:
225
+ return MetricScore(
226
+ name=metric.name,
227
+ score=0.0,
228
+ reason=f"指标计算失败: {e}",
229
+ threshold=threshold,
230
+ success=False,
231
+ error=str(e),
232
+ )
233
+ # success 按实际使用阈值重算 (MetricResult.success 用的是指标自带阈值)
234
+ return MetricScore(
235
+ name=metric.name,
236
+ score=result.score,
237
+ reason=result.reason,
238
+ threshold=threshold,
239
+ success=result.score >= threshold,
240
+ )
241
+
242
+ @staticmethod
243
+ def _compute_summary(
244
+ results: list[BatchCaseResult],
245
+ thresholds: dict[str, float],
246
+ ) -> dict[str, BatchMetricSummary]:
247
+ """按指标聚合 avg/min/max + pass/fail 计数 (记录实际使用阈值)。"""
248
+ by_metric: dict[str, list[MetricScore]] = {}
249
+ for r in results:
250
+ for name, s in r.scores.items():
251
+ by_metric.setdefault(name, []).append(s)
252
+
253
+ summary: dict[str, BatchMetricSummary] = {}
254
+ for name, scores in by_metric.items():
255
+ values = [s.score for s in scores]
256
+ pass_count = sum(1 for s in scores if s.success)
257
+ total = len(scores)
258
+ summary[name] = BatchMetricSummary(
259
+ avg=sum(values) / total,
260
+ min=min(values),
261
+ max=max(values),
262
+ pass_count=pass_count,
263
+ fail_count=total - pass_count,
264
+ pass_rate=pass_count / total if total else 0.0,
265
+ threshold=thresholds.get(name, 0.5),
266
+ )
267
+ return summary