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,194 @@
1
+ """Dataset quality checks and capability coverage analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from agent_eval.dataset.models import EvalDataset
9
+ from agent_eval.dataset.sources.regression import normalize_prompt
10
+
11
+ # 单条 prompt 长度告警阈值 (字符)
12
+ PROMPT_LENGTH_WARN = 10_000
13
+ # 每个能力维度视为充分覆盖所需的最少条目数
14
+ COVERAGE_FULL_ITEMS = 5.0
15
+
16
+
17
+ @dataclass
18
+ class QualityIssue:
19
+ """单条质量问题"""
20
+
21
+ code: str # empty_prompt / missing_graders / duplicate_prompt / long_prompt / duplicate_item_id
22
+ severity: str # error / warning
23
+ item_id: str = ""
24
+ message: str = ""
25
+
26
+ def to_dict(self) -> dict[str, Any]:
27
+ return {
28
+ "code": self.code,
29
+ "severity": self.severity,
30
+ "item_id": self.item_id,
31
+ "message": self.message,
32
+ }
33
+
34
+
35
+ @dataclass
36
+ class QualityReport:
37
+ """数据集质量报告"""
38
+
39
+ total_items: int = 0
40
+ errors: list[QualityIssue] = field(default_factory=list)
41
+ warnings: list[QualityIssue] = field(default_factory=list)
42
+
43
+ @property
44
+ def ok(self) -> bool:
45
+ return not self.errors
46
+
47
+ def to_dict(self) -> dict[str, Any]:
48
+ return {
49
+ "total_items": self.total_items,
50
+ "ok": self.ok,
51
+ "error_count": len(self.errors),
52
+ "warning_count": len(self.warnings),
53
+ "errors": [e.to_dict() for e in self.errors],
54
+ "warnings": [w.to_dict() for w in self.warnings],
55
+ }
56
+
57
+
58
+ class DatasetQualityChecker:
59
+ """数据集质量检查 — errors 阻塞转换, warnings 仅提示"""
60
+
61
+ def check(self, dataset: EvalDataset) -> QualityReport:
62
+ from agent_eval.dataset.models import EvalDatasetItem
63
+
64
+ items = [
65
+ i if isinstance(i, EvalDatasetItem) else EvalDatasetItem(**i)
66
+ for i in dataset.items
67
+ ]
68
+ report = QualityReport(total_items=len(items))
69
+
70
+ seen_prompts: dict[str, str] = {}
71
+ seen_ids: dict[str, str] = {}
72
+
73
+ for item in items:
74
+ # 1. 空 prompt (error)
75
+ if not item.prompt.strip():
76
+ report.errors.append(QualityIssue(
77
+ code="empty_prompt", severity="error", item_id=item.id,
78
+ message=f"Item '{item.id}' has an empty prompt",
79
+ ))
80
+
81
+ # 2. 缺 graders (error — to_suite 无法执行)
82
+ if not item.graders:
83
+ report.errors.append(QualityIssue(
84
+ code="missing_graders", severity="error", item_id=item.id,
85
+ message=f"Item '{item.id}' has no graders configured",
86
+ ))
87
+
88
+ # 3. 重复 prompt (warning, 归一化比较)
89
+ if item.prompt.strip():
90
+ normalized = normalize_prompt(item.prompt)
91
+ if normalized in seen_prompts:
92
+ report.warnings.append(QualityIssue(
93
+ code="duplicate_prompt", severity="warning", item_id=item.id,
94
+ message=(
95
+ f"Item '{item.id}' duplicates prompt of "
96
+ f"'{seen_prompts[normalized]}'"
97
+ ),
98
+ ))
99
+ else:
100
+ seen_prompts[normalized] = item.id
101
+
102
+ # 4. 超长 prompt (warning)
103
+ if len(item.prompt) > PROMPT_LENGTH_WARN:
104
+ report.warnings.append(QualityIssue(
105
+ code="long_prompt", severity="warning", item_id=item.id,
106
+ message=(
107
+ f"Item '{item.id}' prompt is very long "
108
+ f"({len(item.prompt)} chars, warn threshold {PROMPT_LENGTH_WARN})"
109
+ ),
110
+ ))
111
+
112
+ # 5. 重复条目 ID (error — to_suite 要求任务 ID 唯一)
113
+ if item.id in seen_ids:
114
+ report.errors.append(QualityIssue(
115
+ code="duplicate_item_id", severity="error", item_id=item.id,
116
+ message=f"Item id '{item.id}' is used more than once",
117
+ ))
118
+ else:
119
+ seen_ids[item.id] = item.id
120
+
121
+ return report
122
+
123
+
124
+ @dataclass
125
+ class CoverageReport:
126
+ """能力维度覆盖度报告"""
127
+
128
+ total_items: int = 0
129
+ untagged_items: int = 0
130
+ coverage: dict[str, float] = field(default_factory=dict) # 维度 → 0-1
131
+ insufficient: list[dict[str, Any]] = field(default_factory=list)
132
+
133
+ def to_dict(self) -> dict[str, Any]:
134
+ return {
135
+ "total_items": self.total_items,
136
+ "untagged_items": self.untagged_items,
137
+ "coverage": self.coverage,
138
+ "insufficient": self.insufficient,
139
+ }
140
+
141
+
142
+ class CoverageAnalyzer:
143
+ """
144
+ 分析数据集对能力维度的覆盖度。
145
+
146
+ 条目的能力维度标签取 metadata.capabilities (list[str])。
147
+ 覆盖度 = min(1, 该维度条目数 / COVERAGE_FULL_ITEMS) — 前几个条目
148
+ 贡献最大, 达到 COVERAGE_FULL_ITEMS 个即视为充分覆盖。
149
+ """
150
+
151
+ def __init__(self, full_items_per_dim: float = COVERAGE_FULL_ITEMS,
152
+ insufficient_below: float = 0.6):
153
+ """
154
+ Args:
155
+ full_items_per_dim: 视为充分覆盖的条目数
156
+ insufficient_below: 覆盖度低于该值列入覆盖不足清单
157
+ """
158
+ self.full_items_per_dim = max(1.0, full_items_per_dim)
159
+ self.insufficient_below = insufficient_below
160
+
161
+ def analyze(
162
+ self,
163
+ dataset: EvalDataset,
164
+ expected_capabilities: list[str] | None = None,
165
+ ) -> CoverageReport:
166
+ """
167
+ Args:
168
+ dataset: 数据集
169
+ expected_capabilities: 额外要求覆盖的维度 (即使条目未标注也计入报告)
170
+ """
171
+ report = CoverageReport(total_items=len(dataset.items))
172
+ dim_counts: dict[str, int] = {}
173
+
174
+ for item in dataset.items:
175
+ caps = item.metadata.get("capabilities", [])
176
+ if not isinstance(caps, list) or not caps:
177
+ report.untagged_items += 1
178
+ continue
179
+ for cap in caps:
180
+ dim_counts[str(cap)] = dim_counts.get(str(cap), 0) + 1
181
+
182
+ dims = set(dim_counts) | set(expected_capabilities or [])
183
+ for dim in sorted(dims):
184
+ count = dim_counts.get(dim, 0)
185
+ coverage = min(1.0, count / self.full_items_per_dim)
186
+ report.coverage[dim] = round(coverage, 3)
187
+ if coverage < self.insufficient_below:
188
+ report.insufficient.append({
189
+ "capability": dim,
190
+ "item_count": count,
191
+ "coverage": report.coverage[dim],
192
+ })
193
+
194
+ return report
@@ -0,0 +1,45 @@
1
+ """Dataset sources — manual import, trace mining, LLM generation, regression."""
2
+
3
+ from agent_eval.dataset.sources.llm_generator import (
4
+ DatasetGenerationError,
5
+ GenerationReport,
6
+ LLMDatasetGenerator,
7
+ LLMFn,
8
+ )
9
+ from agent_eval.dataset.sources.manual import (
10
+ DatasetImportError,
11
+ import_from_content,
12
+ import_from_json,
13
+ import_from_yaml,
14
+ parse_dataset_payload,
15
+ )
16
+ from agent_eval.dataset.sources.regression import (
17
+ DEFAULT_MAX_ITEMS,
18
+ RegressionExtractor,
19
+ RegressionReport,
20
+ normalize_prompt,
21
+ )
22
+ from agent_eval.dataset.sources.trace_mining import (
23
+ MiningReport,
24
+ MiningStrategy,
25
+ TraceMiner,
26
+ )
27
+
28
+ __all__ = [
29
+ "DatasetImportError",
30
+ "import_from_content",
31
+ "import_from_json",
32
+ "import_from_yaml",
33
+ "parse_dataset_payload",
34
+ "DatasetGenerationError",
35
+ "GenerationReport",
36
+ "LLMDatasetGenerator",
37
+ "LLMFn",
38
+ "DEFAULT_MAX_ITEMS",
39
+ "RegressionExtractor",
40
+ "RegressionReport",
41
+ "normalize_prompt",
42
+ "MiningReport",
43
+ "MiningStrategy",
44
+ "TraceMiner",
45
+ ]
@@ -0,0 +1,219 @@
1
+ """LLM-assisted dataset generation — scenario description → batch items.
2
+
3
+ The LLM function is injected as a protocol (`async (system, user) -> str`);
4
+ the framework core binds to no LLM SDK. Generated items go through the SAME
5
+ validation as manual import: every item must carry a prompt and grader
6
+ config; invalid ones are rejected with explicit reasons.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from collections.abc import Awaitable, Callable
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from agent_eval.core.types import GraderConfig
18
+ from agent_eval.dataset.models import (
19
+ DatasetError,
20
+ EvalDatasetItem,
21
+ SourceType,
22
+ now_ms,
23
+ )
24
+
25
+ # LLM 函数协议: (system_prompt, user_message) → raw text (D2)
26
+ LLMFn = Callable[[str, str], Awaitable[str]]
27
+
28
+ _ALLOWED_GRADER_TYPES = ("code", "model", "metric", "tool_calls", "transcript", "artifact")
29
+
30
+ _GEN_SYSTEM_PROMPT = "You are an evaluation dataset designer. You output only valid JSON."
31
+
32
+ _GEN_PROMPT = """根据以下场景描述,生成 {count} 个评测任务。
33
+
34
+ 场景: {scenario}
35
+ 能力维度: {capabilities}
36
+
37
+ 每个任务是一个 JSON 对象,包含:
38
+ - "id": 唯一标识 (kebab-case, 如 "summarize-quarterly-report")
39
+ - "description": 一句话描述
40
+ - "prompt": 给 Agent 的完整指令
41
+ - "capabilities": 该任务考察的能力维度标签列表 (从上面能力维度中选)
42
+ - "graders": 评分器配置列表,每项形如 {{"type": "<type>", "name": "<name>", "config": {{...}}}}
43
+ - type 只能是: {grader_types}
44
+ - name 必须用框架内置名: model→"model_based", code→"code_based", metric→"metric",
45
+ tool_calls→"tool_calls", transcript→"transcript", artifact→"artifact_check"
46
+ - metric 类型请在 config 中加 "metric_name" (如 faithfulness) 与 "threshold"
47
+
48
+ 以 JSON 数组格式返回,不要输出数组以外的任何内容。"""
49
+
50
+
51
+ class DatasetGenerationError(DatasetError):
52
+ """LLM 生成失败 (LLM 未配置 / 输出不可解析 / 无合法条目)。"""
53
+
54
+
55
+ @dataclass
56
+ class GenerationReport:
57
+ """生成结果 — 合法条目 + 被拒绝条目的明细"""
58
+
59
+ scenario: str = ""
60
+ requested: int = 0
61
+ items: list[EvalDatasetItem] = field(default_factory=list)
62
+ invalid: list[dict[str, Any]] = field(default_factory=list)
63
+
64
+ def to_dict(self) -> dict[str, Any]:
65
+ return {
66
+ "scenario": self.scenario,
67
+ "requested": self.requested,
68
+ "generated": len(self.items),
69
+ "invalid_count": len(self.invalid),
70
+ "invalid": self.invalid,
71
+ "item_ids": [i.id for i in self.items],
72
+ }
73
+
74
+
75
+ class LLMDatasetGenerator:
76
+ """LLM 辅助生成评测数据集条目"""
77
+
78
+ def __init__(self, llm_fn: LLMFn | None = None):
79
+ self.llm_fn = llm_fn
80
+
81
+ async def generate(
82
+ self,
83
+ scenario: str,
84
+ capabilities: list[str] | None = None,
85
+ count: int = 5,
86
+ llm_fn: LLMFn | None = None,
87
+ ) -> GenerationReport:
88
+ """
89
+ 按场景批量生成评测条目。
90
+
91
+ Args:
92
+ scenario: 场景描述
93
+ capabilities: 能力维度标签 (写入条目 metadata.capabilities)
94
+ count: 请求生成的条目数
95
+ llm_fn: 覆盖实例级 llm_fn
96
+
97
+ Raises:
98
+ DatasetGenerationError: 未注入 llm_fn / 输出不可解析 / 无一条合法
99
+ """
100
+ fn = llm_fn or self.llm_fn
101
+ if fn is None:
102
+ raise DatasetGenerationError(
103
+ "LLM function not configured — pass llm_fn to "
104
+ "LLMDatasetGenerator() or generate(). Metric-style judge "
105
+ "assembly lives in eval_integration.config."
106
+ )
107
+
108
+ capabilities = capabilities or []
109
+ report = GenerationReport(scenario=scenario, requested=count)
110
+ caps = ", ".join(capabilities) if capabilities else "(无)"
111
+
112
+ raw = await fn(
113
+ _GEN_SYSTEM_PROMPT,
114
+ _GEN_PROMPT.format(
115
+ count=count,
116
+ scenario=scenario,
117
+ capabilities=caps,
118
+ grader_types=", ".join(_ALLOWED_GRADER_TYPES),
119
+ ),
120
+ )
121
+
122
+ parsed = _extract_json_array(raw)
123
+ if parsed is None:
124
+ raise DatasetGenerationError(
125
+ f"LLM response is not a parsable JSON array (got: {raw[:200]!r}...)"
126
+ )
127
+
128
+ for idx, entry in enumerate(parsed):
129
+ try:
130
+ report.items.append(self._entry_to_item(entry, idx, capabilities, scenario))
131
+ except DatasetError as e:
132
+ report.invalid.append({"index": idx, "error": str(e)})
133
+
134
+ if not report.items:
135
+ detail = "; ".join(i["error"] for i in report.invalid) or "empty response"
136
+ raise DatasetGenerationError(
137
+ f"LLM generation produced no valid items — {detail}"
138
+ )
139
+ return report
140
+
141
+ def _entry_to_item(
142
+ self,
143
+ entry: Any,
144
+ idx: int,
145
+ capabilities: list[str],
146
+ scenario: str,
147
+ ) -> EvalDatasetItem:
148
+ if not isinstance(entry, dict):
149
+ raise DatasetError(f"item #{idx}: must be a JSON object")
150
+
151
+ prompt = entry.get("prompt")
152
+ if not isinstance(prompt, str) or not prompt.strip():
153
+ raise DatasetError(f"item #{idx}: missing required field 'prompt'")
154
+ raw_graders = entry.get("graders")
155
+ if not raw_graders:
156
+ raise DatasetError(f"item #{idx}: missing required field 'graders'")
157
+
158
+ graders = [self._parse_grader(g, idx) for g in raw_graders]
159
+
160
+ item_id = entry.get("id")
161
+ if not isinstance(item_id, str) or not item_id.strip():
162
+ item_id = f"gen-{idx}"
163
+ item_caps = entry.get("capabilities")
164
+ if not isinstance(item_caps, list) or not item_caps:
165
+ item_caps = list(capabilities)
166
+
167
+ return EvalDatasetItem(
168
+ id=item_id.strip(),
169
+ prompt=prompt.strip(),
170
+ description=str(entry.get("description", "")),
171
+ graders=graders,
172
+ metadata={
173
+ "capabilities": [str(c) for c in item_caps],
174
+ "scenario": scenario[:128],
175
+ },
176
+ source_type=SourceType.LLM_GENERATED,
177
+ source_ref=scenario[:128],
178
+ created_at=now_ms(),
179
+ )
180
+
181
+ @staticmethod
182
+ def _parse_grader(raw: Any, idx: int) -> GraderConfig:
183
+ from agent_eval.dataset.models import make_grader_config
184
+
185
+ if isinstance(raw, str):
186
+ return make_grader_config(raw)
187
+
188
+ if isinstance(raw, dict):
189
+ grader_type = raw.get("type")
190
+ if not grader_type:
191
+ raise DatasetError(f"item #{idx}: grader missing 'type': {raw!r}")
192
+ config = raw.get("config") or {}
193
+ if not isinstance(config, dict):
194
+ raise DatasetError(f"item #{idx}: grader 'config' must be a mapping")
195
+ name = raw.get("name")
196
+ return make_grader_config(
197
+ str(grader_type), name=str(name) if name else None, **config
198
+ )
199
+
200
+ raise DatasetError(f"item #{idx}: grader must be a string or object, got {raw!r}")
201
+
202
+
203
+ def _extract_json_array(raw: str) -> list[Any] | None:
204
+ """从容错提取 LLM 输出中的 JSON 数组 (容忍代码围栏/前后缀文本)"""
205
+ text = raw.strip()
206
+ # 去掉 ```json ... ``` 围栏
207
+ fence = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL)
208
+ if fence:
209
+ text = fence.group(1).strip()
210
+
211
+ start = text.find("[")
212
+ end = text.rfind("]")
213
+ if start < 0 or end <= start:
214
+ return None
215
+ try:
216
+ data = json.loads(text[start : end + 1])
217
+ except json.JSONDecodeError:
218
+ return None
219
+ return data if isinstance(data, list) else None
@@ -0,0 +1,172 @@
1
+ """Manual dataset import — YAML / JSON files with strict validation.
2
+
3
+ Validation failure messages follow the change-① Suite loader convention
4
+ (pydantic ValidationError wrapped with the source file path). Items missing
5
+ a prompt or grader config are rejected with the specific item and field.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import yaml
15
+ from pydantic import ValidationError
16
+
17
+ from agent_eval.dataset.models import (
18
+ DatasetError,
19
+ EvalDataset,
20
+ SourceType,
21
+ now_ms,
22
+ )
23
+
24
+
25
+ class DatasetImportError(DatasetError):
26
+ """数据集导入失败 (解析/校验), 错误信息含来源文件上下文。"""
27
+
28
+
29
+ def _validate_items_or_raise(items: list[dict[str, Any]], source: str) -> None:
30
+ """条目级必填校验: prompt 与 graders 必填, 缺失时报具体条目与字段。"""
31
+ problems: list[str] = []
32
+ for idx, item in enumerate(items):
33
+ if not isinstance(item, dict):
34
+ problems.append(f"items[{idx}]: must be a mapping (got {type(item).__name__})")
35
+ continue
36
+ item_label = f"items[{idx}]" + (f" (id={item.get('id')!r})" if item.get("id") else "")
37
+ prompt = item.get("prompt")
38
+ if not isinstance(prompt, str) or not prompt.strip():
39
+ problems.append(f"{item_label}: missing required field 'prompt'")
40
+ if not item.get("graders"):
41
+ problems.append(f"{item_label}: missing required field 'graders'")
42
+ if problems:
43
+ raise DatasetImportError(
44
+ f"Dataset import failed for '{source}' — {len(problems)} item problem(s):\n"
45
+ + "\n".join(f"- {p}" for p in problems)
46
+ )
47
+
48
+
49
+ def _fill_item_defaults(items: list[dict[str, Any]], source_type: SourceType) -> list[dict[str, Any]]:
50
+ """补齐条目溯源默认值 (source_type / created_at), 保留显式指定值。"""
51
+ filled = []
52
+ for item in items:
53
+ item = dict(item)
54
+ item.setdefault("source_type", source_type)
55
+ item.setdefault("created_at", now_ms())
56
+ filled.append(item)
57
+ return filled
58
+
59
+
60
+ def parse_dataset_payload(
61
+ data: dict[str, Any],
62
+ source: str = "<payload>",
63
+ source_type: SourceType = SourceType.MANUAL,
64
+ source_ref: str = "",
65
+ ) -> EvalDataset:
66
+ """
67
+ 将 dict 解析为 EvalDataset (与文件导入同一校验路径, API 复用)。
68
+
69
+ Args:
70
+ data: 数据集定义 (name/items[...]/tags/...)
71
+ source: 报错时展示的来源描述 (文件路径或 API)
72
+ source_type: 条目默认来源类型
73
+ source_ref: 条目默认来源引用
74
+
75
+ Raises:
76
+ DatasetImportError: 顶层不是 mapping / 条目缺 prompt 或 graders / 模型校验失败
77
+ """
78
+ if not isinstance(data, dict):
79
+ raise DatasetImportError(
80
+ f"Dataset payload '{source}' must contain a mapping "
81
+ f"(got {type(data).__name__})"
82
+ )
83
+
84
+ raw_items = data.get("items", [])
85
+ if not isinstance(raw_items, list):
86
+ raise DatasetImportError(f"Dataset payload '{source}': 'items' must be a list")
87
+
88
+ _validate_items_or_raise(raw_items, source)
89
+
90
+ payload = dict(data)
91
+ payload["items"] = _fill_item_defaults(raw_items, source_type)
92
+ if source_ref:
93
+ for item in payload["items"]:
94
+ item.setdefault("source_ref", source_ref)
95
+
96
+ try:
97
+ return EvalDataset(**payload)
98
+ except ValidationError as e:
99
+ raise DatasetImportError(
100
+ f"Dataset validation failed for '{source}':\n{e}"
101
+ ) from e
102
+
103
+
104
+ def import_from_yaml(path: str | Path, source_type: SourceType = SourceType.MANUAL) -> EvalDataset:
105
+ """从 YAML 文件导入数据集 (对抗样本等手工构造场景传 adversarial)。"""
106
+ dataset_path = Path(path)
107
+
108
+ if not dataset_path.exists():
109
+ raise DatasetImportError(f"Dataset file not found: {dataset_path}")
110
+
111
+ try:
112
+ with open(dataset_path, encoding="utf-8") as f:
113
+ data = yaml.safe_load(f)
114
+ except OSError as e:
115
+ raise DatasetImportError(f"Cannot read dataset file '{dataset_path}': {e}") from e
116
+ except yaml.YAMLError as e:
117
+ raise DatasetImportError(f"Invalid YAML in dataset file '{dataset_path}': {e}") from e
118
+
119
+ return parse_dataset_payload(data, source=str(dataset_path), source_type=source_type)
120
+
121
+
122
+ def import_from_json(path: str | Path, source_type: SourceType = SourceType.MANUAL) -> EvalDataset:
123
+ """从 JSON 文件导入数据集。"""
124
+ dataset_path = Path(path)
125
+
126
+ if not dataset_path.exists():
127
+ raise DatasetImportError(f"Dataset file not found: {dataset_path}")
128
+
129
+ try:
130
+ with open(dataset_path, encoding="utf-8") as f:
131
+ data = json.load(f)
132
+ except OSError as e:
133
+ raise DatasetImportError(f"Cannot read dataset file '{dataset_path}': {e}") from e
134
+ except json.JSONDecodeError as e:
135
+ raise DatasetImportError(f"Invalid JSON in dataset file '{dataset_path}': {e}") from e
136
+
137
+ return parse_dataset_payload(data, source=str(dataset_path), source_type=source_type)
138
+
139
+
140
+ def import_from_content(
141
+ content: str,
142
+ format: str = "yaml",
143
+ source: str = "<inline>",
144
+ source_type: SourceType = SourceType.MANUAL,
145
+ source_ref: str = "",
146
+ ) -> EvalDataset:
147
+ """
148
+ 从文本内容导入 (API 用): format 为 "yaml" 或 "json"。
149
+
150
+ Raises:
151
+ DatasetImportError: 格式不支持 / 语法错误 / 校验失败
152
+ """
153
+ fmt = (format or "yaml").lower()
154
+ if fmt not in ("yaml", "json"):
155
+ raise DatasetImportError(
156
+ f"Unsupported import format '{format}' (valid: yaml, json)"
157
+ )
158
+
159
+ if fmt == "json":
160
+ try:
161
+ data = json.loads(content)
162
+ except json.JSONDecodeError as e:
163
+ raise DatasetImportError(f"Invalid JSON in dataset content '{source}': {e}") from e
164
+ else:
165
+ try:
166
+ data = yaml.safe_load(content)
167
+ except yaml.YAMLError as e:
168
+ raise DatasetImportError(f"Invalid YAML in dataset content '{source}': {e}") from e
169
+
170
+ return parse_dataset_payload(
171
+ data, source=source, source_type=source_type, source_ref=source_ref
172
+ )