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,308 @@
1
+ """pytest integration — metric fixtures + suite gate (framework layer).
2
+
3
+ 模块级只依赖 pytest + agent_eval 自身 (AChat 装配 eval_integration 在
4
+ runner 装配路径内延迟导入 — 纯框架使用场景不触碰 app 代码, AST import
5
+ 检查仅约束 app.*)。
6
+
7
+ 注册方式 (dev 期 import 注册, entry-point 打包延后至独立 repo — design D3):
8
+ 根 conftest.py 中 ``pytest_plugins = ["agent_eval.metrics.pytest_plugin"]``
9
+
10
+ Fixtures:
11
+ eval_metrics — P0 指标注册表 (name → SyncMetric)
12
+ answer_relevancy / faithfulness / context_recall / context_precision — 单指标
13
+ eval_runner — EvalRunner (默认经 eval_integration 装配, 未配置时
14
+ skip 并提示; 可用 set_eval_runner_factory 覆盖装配)
15
+
16
+ 每个指标 fixture 值为 SyncMetric 同步包装: 同步测试直接调用
17
+ ``measure(...)`` (内部 asyncio.run, 零事件循环管理); 异步测试取
18
+ ``.async_metric`` 直接 await measure (不与 pytest-asyncio 抢 loop)。
19
+ 指标默认未注入 llm_fn — 缺配置时报错信息可读 (LLMNotConfiguredError),
20
+ 测试可经 ``fixture.async_metric.llm_fn = stub`` 注入。
21
+
22
+ Suite 门禁:
23
+ pytest --eval-suite=suite.yaml --eval-threshold=0.7
24
+
25
+ 常规测试循环结束后执行 suite 评测, terminal summary 打印 pass@k 与阈值
26
+ 判定; pass@1 低于阈值时置 session.testsfailed (退出码非 0)。
27
+ 评测 runner 装配与 eval_runner fixture 同源 (set_eval_runner_factory 可覆盖)。
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import inspect
34
+ from collections.abc import Callable
35
+ from typing import Any
36
+
37
+ import pytest
38
+
39
+ from agent_eval.core.types import RunResult
40
+ from agent_eval.metrics import (
41
+ AnswerRelevancyMetric,
42
+ ContextPrecisionMetric,
43
+ ContextRecallMetric,
44
+ FaithfulnessMetric,
45
+ build_default_metrics_registry,
46
+ )
47
+ from agent_eval.metrics.base import Metric, MetricResult
48
+
49
+ # ─── 同步包装 ────────────────────────────────────────────────────────────────
50
+
51
+
52
+ class SyncMetric:
53
+ """Metric 同步包装 — measure 内部 asyncio.run, 供纯同步 pytest 测试调用。
54
+
55
+ 用法边界: 仅限无 running event loop 的同步测试 (notebook 等已有 loop
56
+ 的环境不适用); 异步测试取 .async_metric 直接 await measure。
57
+ """
58
+
59
+ def __init__(self, metric: Metric):
60
+ self._metric = metric
61
+
62
+ @property
63
+ def async_metric(self) -> Metric:
64
+ """原始异步 Metric (异步测试直接 await 其 measure)"""
65
+ return self._metric
66
+
67
+ @property
68
+ def name(self) -> str:
69
+ return self._metric.name
70
+
71
+ @property
72
+ def threshold(self) -> float:
73
+ return self._metric.threshold
74
+
75
+ def measure(
76
+ self,
77
+ input: str,
78
+ actual_output: str,
79
+ expected_output: str | None = None,
80
+ context: list[str] | None = None,
81
+ retrieval_context: list[str] | None = None,
82
+ ) -> MetricResult:
83
+ """同步 measure (内部 asyncio.run 桥接异步 Metric.measure)。"""
84
+ return asyncio.run(
85
+ self._metric.measure(
86
+ input=input,
87
+ actual_output=actual_output,
88
+ expected_output=expected_output,
89
+ context=context,
90
+ retrieval_context=retrieval_context,
91
+ )
92
+ )
93
+
94
+
95
+ def _sync(metric: Metric) -> SyncMetric:
96
+ return SyncMetric(metric)
97
+
98
+
99
+ # ─── Fixtures ────────────────────────────────────────────────────────────────
100
+
101
+
102
+ @pytest.fixture
103
+ def eval_metrics() -> dict[str, SyncMetric]:
104
+ """P0 指标注册表 (name → SyncMetric; 原始异步对象经 .async_metric)。"""
105
+ return {name: _sync(m) for name, m in build_default_metrics_registry().items()}
106
+
107
+
108
+ @pytest.fixture
109
+ def answer_relevancy() -> SyncMetric:
110
+ """AnswerRelevancyMetric 同步包装 (llm_fn 未注入 — 按需注入 stub/凭证)。"""
111
+ return _sync(AnswerRelevancyMetric())
112
+
113
+
114
+ @pytest.fixture
115
+ def faithfulness() -> SyncMetric:
116
+ """FaithfulnessMetric 同步包装。"""
117
+ return _sync(FaithfulnessMetric())
118
+
119
+
120
+ @pytest.fixture
121
+ def context_recall() -> SyncMetric:
122
+ """ContextRecallMetric 同步包装。"""
123
+ return _sync(ContextRecallMetric())
124
+
125
+
126
+ @pytest.fixture
127
+ def context_precision() -> SyncMetric:
128
+ """ContextPrecisionMetric 同步包装。"""
129
+ return _sync(ContextPrecisionMetric())
130
+
131
+
132
+ # ─── Runner 装配 (eval_runner fixture 与 suite 门禁共用) ─────────────────────
133
+
134
+
135
+ class EvalRunnerUnavailableError(Exception):
136
+ """Aeval runner 无法装配 (eval_integration 不可用或配置缺失)。"""
137
+
138
+
139
+ # 同步返回 runner, 或返回 awaitable (async 工厂)
140
+ EvalRunnerFactory = Callable[[], Any]
141
+
142
+ _eval_runner_factory: EvalRunnerFactory | None = None
143
+
144
+
145
+ def set_eval_runner_factory(factory: EvalRunnerFactory | None) -> None:
146
+ """注入 runner 装配工厂 (测试/宿主覆盖装配点, 如 MockRunner 演示)。"""
147
+ global _eval_runner_factory
148
+ _eval_runner_factory = factory
149
+
150
+
151
+ async def build_eval_runner():
152
+ """装配 EvalRunner: 注入工厂优先; 默认经 eval_integration (AChat 接入层)。
153
+
154
+ Raises:
155
+ EvalRunnerUnavailableError: 装配点不可用 (错误信息可读;
156
+ eval_runner fixture 据此 skip, suite 门禁据此判门禁失败)
157
+ """
158
+ if _eval_runner_factory is not None:
159
+ result = _eval_runner_factory()
160
+ if inspect.isawaitable(result):
161
+ result = await result
162
+ return result
163
+
164
+ # 延迟导入: 框架独立使用场景不依赖 AChat 接入层
165
+ try:
166
+ from eval_integration.config import create_aeval_runner
167
+ except Exception as e:
168
+ raise EvalRunnerUnavailableError(
169
+ "eval_integration unavailable — cannot assemble an Aeval runner "
170
+ "(standalone usage: register one via set_eval_runner_factory): "
171
+ f"{e}"
172
+ ) from e
173
+ try:
174
+ return await create_aeval_runner()
175
+ except Exception as e:
176
+ raise EvalRunnerUnavailableError(
177
+ "Aeval runner assembly failed (check EVAL_AGENT_ID / EVAL_API_BASE / "
178
+ f"token settings): {e}"
179
+ ) from e
180
+
181
+
182
+ @pytest.fixture
183
+ def eval_runner():
184
+ """EvalRunner 实例 (默认经 eval_integration 装配; 未配置时 skip 并提示)。"""
185
+ try:
186
+ return asyncio.run(build_eval_runner())
187
+ except EvalRunnerUnavailableError as e:
188
+ pytest.skip(f"eval_runner unavailable — skipping: {e}")
189
+
190
+
191
+ # ─── Suite 门禁 (--eval-suite / --eval-threshold) ────────────────────────────
192
+
193
+
194
+ class SuiteGatePlugin:
195
+ """--eval-suite 门禁: 测试循环结束后跑 suite, 打印 pass@k 并按阈值置失败。
196
+
197
+ 阈值判定在 pytest_runtestloop wrapper 的 finally 中递增
198
+ session.testsfailed — 早于 _main 的退出码计算 (terminal summary 阶段
199
+ 已无法影响退出码); 打印则放在 pytest_terminal_summary。
200
+ """
201
+
202
+ def __init__(self, suite_path: str, threshold: float):
203
+ self.suite_path = suite_path
204
+ self.threshold = threshold
205
+ self.suite_name: str | None = None
206
+ self.result: RunResult | None = None
207
+ self.error: str | None = None
208
+ self.failed = False
209
+
210
+ @pytest.hookimpl(wrapper=True)
211
+ def pytest_runtestloop(self, session):
212
+ try:
213
+ return (yield)
214
+ finally:
215
+ self._apply_gate(session)
216
+
217
+ def _apply_gate(self, session) -> None:
218
+ if session.config.option.collectonly:
219
+ return
220
+ try:
221
+ self.result = asyncio.run(self._run_suite())
222
+ except Exception as e:
223
+ self.error = str(e)
224
+ # 阈值判定: 分数低于阈值或评测本身出错都判门禁失败 (CI 语义 —
225
+ # 装配失败静默放行比失败更危险)
226
+ if self.error is not None or self._pass_at_1() < self.threshold:
227
+ self.failed = True
228
+ session.testsfailed += 1
229
+
230
+ async def _run_suite(self):
231
+ from agent_eval.core.suite import load_suite
232
+
233
+ suite = load_suite(self.suite_path)
234
+ self.suite_name = suite.name
235
+ runner = await build_eval_runner()
236
+ return await runner.run_suite(suite)
237
+
238
+ def _pass_at_1(self) -> float:
239
+ if self.result is None or self.result.summary is None:
240
+ return 0.0
241
+ return float(self.result.summary.pass_at_k.get(1, 0.0))
242
+
243
+ def pytest_terminal_summary(self, terminalreporter, exitstatus) -> None:
244
+ terminalreporter.write_sep("=", "AEVAL EVALUATION GATE")
245
+ suite_label = f"{self.suite_name} ({self.suite_path})" if self.suite_name else self.suite_path
246
+ terminalreporter.write_line(
247
+ f"Suite: {suite_label} (threshold: {self.threshold:.4f})"
248
+ )
249
+
250
+ if self.error is not None:
251
+ terminalreporter.write_line(f"GATE ERROR: {self.error}")
252
+ return
253
+
254
+ result = self.result
255
+ if result is None:
256
+ terminalreporter.write_line("GATE ERROR: evaluation produced no result")
257
+ return
258
+
259
+ summary = result.summary
260
+ terminalreporter.write_line(
261
+ f"Run {result.run_id} status={result.status} "
262
+ f"tasks={summary.total_tasks if summary else 0} "
263
+ f"trials={summary.total_trials if summary else 0}"
264
+ )
265
+ pass_at_k = summary.pass_at_k if summary else {}
266
+ terminalreporter.write_line(
267
+ "Pass@k: "
268
+ + ", ".join(f"@{k}={v:.4f}" for k, v in sorted(pass_at_k.items()))
269
+ )
270
+ pass1 = self._pass_at_1()
271
+ if self.failed:
272
+ terminalreporter.write_line(
273
+ f"GATE FAILED: pass@1 {pass1:.4f} < threshold {self.threshold:.4f}"
274
+ )
275
+ else:
276
+ terminalreporter.write_line(
277
+ f"GATE PASSED: pass@1 {pass1:.4f} >= threshold {self.threshold:.4f}"
278
+ )
279
+
280
+
281
+ _gate_plugin: SuiteGatePlugin | None = None
282
+
283
+
284
+ def pytest_addoption(parser) -> None:
285
+ group = parser.getgroup("aeval", "Aeval evaluation framework")
286
+ group.addoption(
287
+ "--eval-suite",
288
+ action="store",
289
+ default=None,
290
+ help="Aeval 评测 Suite YAML 路径 (常规测试跑完后执行 suite 门禁)",
291
+ )
292
+ group.addoption(
293
+ "--eval-threshold",
294
+ action="store",
295
+ type=float,
296
+ default=0.7,
297
+ help="门禁通过阈值 (pass@1, 默认 0.7)",
298
+ )
299
+
300
+
301
+ def pytest_configure(config) -> None:
302
+ global _gate_plugin
303
+ suite_path = config.getoption("eval_suite")
304
+ if suite_path:
305
+ _gate_plugin = SuiteGatePlugin(
306
+ suite_path, float(config.getoption("eval_threshold"))
307
+ )
308
+ config.pluginmanager.register(_gate_plugin)
@@ -0,0 +1,149 @@
1
+ """Metric report rendering — RunResult / BatchEvaluationResult → Markdown or JSON text.
2
+
3
+ 纯渲染函数 (无 I/O、无模板依赖): 批量报告含逐条分数与汇总统计; run 报告含
4
+ pass@k / pass^k / 任务分解。可直接写入 stdout 或文件, 供 CI 脚本与 Phase 2
5
+ CLI 消费 (交互视图由 Dashboard 覆盖, 不做 HTML/图表)。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Literal
12
+
13
+ from agent_eval.core.types import RunResult
14
+ from agent_eval.metrics.batch_evaluation import BatchEvaluationResult
15
+
16
+ ReportFormat = Literal["markdown", "json"]
17
+
18
+
19
+ def render_batch_report(
20
+ result: BatchEvaluationResult,
21
+ fmt: ReportFormat = "markdown",
22
+ ) -> str:
23
+ """渲染批量评测结果 (markdown: 分数表 + 汇总段; json: 纯 dump)。"""
24
+ if fmt == "json":
25
+ return json.dumps(result.model_dump(), ensure_ascii=False, indent=2)
26
+ if fmt != "markdown":
27
+ raise ValueError(f"Unsupported report format: {fmt!r} (use 'markdown' or 'json')")
28
+ return _render_batch_markdown(result)
29
+
30
+
31
+ def render_run_report(run: RunResult, fmt: ReportFormat = "markdown") -> str:
32
+ """渲染 run 结果 (markdown: 汇总 + 任务分解; json: 纯 dump)。"""
33
+ if fmt == "json":
34
+ return json.dumps(run.model_dump(), ensure_ascii=False, indent=2)
35
+ if fmt != "markdown":
36
+ raise ValueError(f"Unsupported report format: {fmt!r} (use 'markdown' or 'json')")
37
+ return _render_run_markdown(run)
38
+
39
+
40
+ # ─── Markdown 渲染 ───────────────────────────────────────────────────────────
41
+
42
+
43
+ def _score(value: float) -> str:
44
+ return f"{value:.4f}"
45
+
46
+
47
+ def _pct(value: float) -> str:
48
+ return f"{value:.1%}"
49
+
50
+
51
+ def _render_batch_markdown(result: BatchEvaluationResult) -> str:
52
+ lines: list[str] = ["# Aeval 批量评测报告", "", "## 概览", ""]
53
+
54
+ total = result.pass_count + result.fail_count
55
+ lines.append(f"- 用例数: {total}")
56
+ lines.append(f"- 通过 / 失败: {result.pass_count} / {result.fail_count}")
57
+ lines.append(f"- pass_rate: {_pct(result.pass_rate)}")
58
+ lines.append("")
59
+
60
+ lines += ["## 指标汇总", ""]
61
+ if result.summary:
62
+ lines.append("| 指标 | 平均分 | 最小 | 最大 | 通过 | 失败 | pass_rate | 阈值 |")
63
+ lines.append("|------|--------|------|------|------|------|-----------|------|")
64
+ for name, s in result.summary.items():
65
+ lines.append(
66
+ f"| {name} | {_score(s.avg)} | {_score(s.min)} | {_score(s.max)} "
67
+ f"| {s.pass_count} | {s.fail_count} | {_pct(s.pass_rate)} | {_score(s.threshold)} |"
68
+ )
69
+ else:
70
+ lines.append("(无指标数据)")
71
+ lines.append("")
72
+
73
+ lines += ["## 逐用例结果", ""]
74
+ if result.results:
75
+ lines.append("| # | 输入 | 各指标分数 | 结果 |")
76
+ lines.append("|---|------|------------|------|")
77
+ for case in result.results:
78
+ parts = []
79
+ for name, s in case.scores.items():
80
+ mark = "PASS" if s.success else "FAIL"
81
+ cell = f"{name}={_score(s.score)} ({mark})"
82
+ if s.error:
83
+ cell += f" [error: {s.error}]"
84
+ parts.append(cell)
85
+ verdict = "PASS" if case.overall_pass else "FAIL"
86
+ lines.append(f"| {case.index} | {case.input} | {' / '.join(parts)} | {verdict} |")
87
+ else:
88
+ lines.append("(无用例)")
89
+ lines.append("")
90
+ return "\n".join(lines)
91
+
92
+
93
+ def _render_run_markdown(run: RunResult) -> str:
94
+ lines: list[str] = ["# Aeval 评测 Run 报告", "", "## 概览", ""]
95
+
96
+ lines.append(f"- Run ID: {run.run_id}")
97
+ lines.append(f"- Suite: {run.suite_name}")
98
+ lines.append(f"- 状态: {run.status}")
99
+ if run.duration_ms is not None:
100
+ lines.append(f"- 耗时: {run.duration_ms:.0f} ms")
101
+ lines.append("")
102
+
103
+ summary = run.summary
104
+ lines += ["## 汇总", ""]
105
+ if summary is None:
106
+ lines.append("(无汇总 — run 未完成或汇总未计算)")
107
+ lines.append("")
108
+ return "\n".join(lines)
109
+
110
+ lines.append(f"- 总任务 / 总 trial: {summary.total_tasks} / {summary.total_trials}")
111
+ lines.append(
112
+ "- Pass@k: "
113
+ + ", ".join(
114
+ f"@{k}={_pct(v)}" for k, v in sorted(summary.pass_at_k.items())
115
+ )
116
+ )
117
+ lines.append(
118
+ "- Pass^k: "
119
+ + ", ".join(
120
+ f"@{k}={_pct(v)}" for k, v in sorted(summary.pass_power_k.items())
121
+ )
122
+ )
123
+ lines.append(f"- 平均分: {_score(summary.avg_score)}")
124
+ lines.append("")
125
+
126
+ lines += ["## 任务分解", ""]
127
+ if summary.task_summaries:
128
+ lines.append("| 任务 | 描述 | Trials | Pass@1 | Pass^1 | 平均分 | 失败 Trials |")
129
+ lines.append("|------|------|--------|--------|--------|--------|-------------|")
130
+ for ts in summary.task_summaries:
131
+ pass1 = _pct(ts.pass_at_k.get(1, 0.0))
132
+ pow1 = _pct(ts.pass_power_k.get(1, 0.0))
133
+ failures = ", ".join(str(i) for i in ts.failures) if ts.failures else "—"
134
+ lines.append(
135
+ f"| {ts.task_id} | {ts.task_description} | {ts.total_trials} "
136
+ f"| {pass1} | {pow1} | {_score(ts.avg_score)} | {failures} |"
137
+ )
138
+ else:
139
+ lines.append("(无任务汇总)")
140
+
141
+ saturation = summary.saturation
142
+ if saturation:
143
+ lines.append("")
144
+ lines.append(
145
+ f"饱和度: is_saturated={saturation.get('is_saturated', False)}, "
146
+ f"ratio={saturation.get('saturation_ratio', 0.0)}"
147
+ )
148
+ lines.append("")
149
+ return "\n".join(lines)
@@ -0,0 +1,203 @@
1
+ """Synthetic data generation — documents → Goldens → dataset items (D6).
2
+
3
+ Golden = a synthetic eval case (input / expected_output / source context).
4
+ Long text is chunked by character length (no tokenizer dependency). Goldens
5
+ convert to dataset items with llm_generated provenance and default
6
+ answer_relevancy + faithfulness metric graders (threshold 0.7).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ from agent_eval.dataset.models import EvalDatasetItem, SourceType, now_ms
14
+ from agent_eval.dataset.sources.llm_generator import LLMFn, _extract_json_array
15
+ from agent_eval.dataset.sources.manual import DatasetImportError
16
+ from agent_eval.metrics.llm_judge import require_llm_fn
17
+
18
+ # 分块大小 (字符; 按长度估算, 不做 tokenizer 依赖) 与重叠
19
+ _CHUNK_SIZE_CHARS = 2000
20
+ _CHUNK_OVERLAP_CHARS = 200
21
+
22
+ _SYNTH_SYSTEM_PROMPT = "You are an evaluation dataset designer. You output only valid JSON."
23
+
24
+ _SYNTH_PROMPT = """根据以下文档, 生成 {count} 个高质量评测问题。
25
+
26
+ 文档内容:
27
+ {context}
28
+
29
+ 对每个问题, 提供:
30
+ - "input": 用户问题 (应能从文档中回答)
31
+ - "expected_output": 标准答案 (基于文档)
32
+
33
+ 要求:
34
+ - 问题类型多样 (事实性/推理性/比较性)
35
+ - 答案必须能从文档中直接推导
36
+ - 避免模糊或主观的问题
37
+
38
+ 以 JSON 数组格式返回,不要输出数组以外的任何内容。"""
39
+
40
+
41
+ @dataclass
42
+ class Golden:
43
+ """评测黄金标准 — 单个合成测试用例"""
44
+
45
+ input: str # 问题
46
+ expected_output: str # 期望回答
47
+ context: list[str] = field(default_factory=list) # 来源文档
48
+ source: str = "" # 来源引用
49
+
50
+
51
+ class SyntheticDataGenerator:
52
+ """合成数据生成器 — 从文档自动生成评测用例"""
53
+
54
+ def __init__(self, llm_fn: LLMFn | None = None):
55
+ self.llm_fn = llm_fn
56
+
57
+ async def generate_from_docs(
58
+ self,
59
+ documents: list[str],
60
+ count_per_doc: int = 3,
61
+ llm_fn: LLMFn | None = None,
62
+ ) -> list[Golden]:
63
+ """从文档列表生成评测用例 (每文档 count_per_doc 个)"""
64
+ fn = require_llm_fn(llm_fn or self.llm_fn)
65
+ goldens: list[Golden] = []
66
+ for idx, doc in enumerate(documents):
67
+ if not str(doc).strip():
68
+ continue
69
+ raw = await fn(
70
+ _SYNTH_SYSTEM_PROMPT,
71
+ _SYNTH_PROMPT.format(context=doc, count=count_per_doc),
72
+ )
73
+ parsed = _extract_json_array(raw)
74
+ if parsed is None:
75
+ raise DatasetImportError(
76
+ f"Synthetic generation: document #{idx} judge response is not "
77
+ f"a parsable JSON array (got: {raw[:120]!r}...)"
78
+ )
79
+ for entry in parsed:
80
+ if not isinstance(entry, dict):
81
+ continue
82
+ question = str(entry.get("input", "")).strip()
83
+ answer = str(entry.get("expected_output", "")).strip()
84
+ if not question or not answer:
85
+ continue
86
+ goldens.append(Golden(
87
+ input=question,
88
+ expected_output=answer,
89
+ context=[str(doc)],
90
+ source=f"doc[{idx}]",
91
+ ))
92
+ return goldens
93
+
94
+ async def generate_from_text(
95
+ self,
96
+ text: str,
97
+ count: int = 5,
98
+ llm_fn: LLMFn | None = None,
99
+ ) -> list[Golden]:
100
+ """从单段长文本生成评测用例 (先分块再逐块生成)"""
101
+ fn = llm_fn or self.llm_fn
102
+ chunks = self.chunk_text(text)
103
+ if not chunks:
104
+ return []
105
+ per_chunk = max(1, count // len(chunks))
106
+ remainder = count - per_chunk * len(chunks)
107
+
108
+ goldens: list[Golden] = []
109
+ for i, chunk in enumerate(chunks):
110
+ take = per_chunk + (1 if i < remainder else 0)
111
+ if take <= 0:
112
+ continue
113
+ goldens.extend(await self.generate_from_docs([chunk], take, llm_fn=fn))
114
+ return goldens
115
+
116
+ @staticmethod
117
+ def chunk_text(
118
+ text: str,
119
+ max_chars: int = _CHUNK_SIZE_CHARS,
120
+ overlap: int = _CHUNK_OVERLAP_CHARS,
121
+ ) -> list[str]:
122
+ """按字符长度分块 (段落边界优先, 带重叠)"""
123
+ text = (text or "").strip()
124
+ if not text:
125
+ return []
126
+ if len(text) <= max_chars:
127
+ return [text]
128
+
129
+ paragraphs = text.split("\n\n")
130
+ chunks: list[str] = []
131
+ current = ""
132
+ for para in paragraphs:
133
+ if current and len(current) + len(para) + 2 > max_chars:
134
+ chunks.append(current)
135
+ # 保留尾部重叠, 维持跨块上下文
136
+ current = current[-overlap:] + "\n\n" + para if overlap else para
137
+ else:
138
+ current = f"{current}\n\n{para}" if current else para
139
+ # 单段超长: 硬切
140
+ while len(current) > max_chars:
141
+ chunks.append(current[:max_chars])
142
+ current = current[max_chars - overlap:] if overlap else ""
143
+ if current.strip():
144
+ chunks.append(current)
145
+ return chunks
146
+
147
+ def to_dataset_items(
148
+ self,
149
+ goldens: list[Golden],
150
+ threshold: float = 0.7,
151
+ ) -> list[EvalDatasetItem]:
152
+ """
153
+ Golden → 数据集条目 (D6)。
154
+
155
+ 默认 graders: answer_relevancy + faithfulness 指标 (threshold 0.7),
156
+ source_type=llm_generated, source_ref=来源文档引用。
157
+ """
158
+ from agent_eval.dataset.models import make_grader_config
159
+
160
+ # answer_relevancy 对所有条目相同; faithfulness 需要按条目注入
161
+ # Golden context (grader config.context) 才能在流水线中评估忠实度
162
+ relevancy_grader = make_grader_config(
163
+ "metric",
164
+ name="answer_relevancy",
165
+ metric_name="answer_relevancy",
166
+ threshold=threshold,
167
+ )
168
+ items = []
169
+ for i, g in enumerate(goldens):
170
+ faithfulness_grader = make_grader_config(
171
+ "metric",
172
+ name="faithfulness",
173
+ metric_name="faithfulness",
174
+ threshold=threshold,
175
+ context=g.context,
176
+ )
177
+ items.append(EvalDatasetItem(
178
+ id=f"synthetic_{i:04d}",
179
+ prompt=g.input,
180
+ description=f"Synthetic: {g.input[:60]}",
181
+ graders=[relevancy_grader, faithfulness_grader],
182
+ env={},
183
+ metadata={
184
+ "capabilities": ["rag"],
185
+ "context": g.context,
186
+ "expected_output": g.expected_output,
187
+ },
188
+ source_type=SourceType.LLM_GENERATED,
189
+ source_ref=g.source,
190
+ created_at=now_ms(),
191
+ ))
192
+ return items
193
+
194
+ async def generate_dataset_items(
195
+ self,
196
+ documents: list[str],
197
+ count_per_doc: int = 3,
198
+ threshold: float = 0.7,
199
+ llm_fn: LLMFn | None = None,
200
+ ) -> list[EvalDatasetItem]:
201
+ """文档 → Golden → 数据集条目 (便捷链路)"""
202
+ goldens = await self.generate_from_docs(documents, count_per_doc, llm_fn)
203
+ return self.to_dataset_items(goldens, threshold=threshold)
@@ -0,0 +1,17 @@
1
+ """
2
+ Storage implementations for the Aeval evaluation framework.
3
+
4
+ Provides SQLite (default) and in-memory storage.
5
+ PostgreSQL can be added for production use.
6
+
7
+ Usage:
8
+ from agent_eval.storage import SqliteStorage, MemoryStorage
9
+
10
+ storage = SqliteStorage(db_path="./aeval.db")
11
+ await storage.save_run(run_result)
12
+ """
13
+
14
+ from agent_eval.storage.memory import MemoryStorage
15
+ from agent_eval.storage.sqlite import SqliteStorage
16
+
17
+ __all__ = ["MemoryStorage", "SqliteStorage"]