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