agenteval-toolkit 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 (67) hide show
  1. agenteval/__init__.py +5 -0
  2. agenteval/_starter/certification/policy.yaml +15 -0
  3. agenteval/_starter/certification/systems/northwind-cs-staging.yaml +16 -0
  4. agenteval/_starter/certification/systems/northwind-cs.yaml +17 -0
  5. agenteval/_starter/examples/agents/demo-good.yaml +41 -0
  6. agenteval/_starter/examples/agents/demo-weak.yaml +34 -0
  7. agenteval/_starter/examples/agents/openai-compatible.example.yaml +15 -0
  8. agenteval/_starter/examples/agents/webhook.example.yaml +21 -0
  9. agenteval/_starter/examples/attestations-certification.yaml +16 -0
  10. agenteval/_starter/examples/attestations.yaml +19 -0
  11. agenteval/_starter/examples/snapshots/sample-cluster.json +323 -0
  12. agenteval/_starter/exams/customer-service/cs-ecommerce-v1.yaml +222 -0
  13. agenteval/_starter/rules/industry-pci-dss/i18n/zh.yaml +27 -0
  14. agenteval/_starter/rules/industry-pci-dss/pack.yaml +9 -0
  15. agenteval/_starter/rules/industry-pci-dss/rules.yaml +83 -0
  16. agenteval/_starter/rules/us-ca/i18n/zh.yaml +21 -0
  17. agenteval/_starter/rules/us-ca/pack.yaml +9 -0
  18. agenteval/_starter/rules/us-ca/rules.yaml +72 -0
  19. agenteval/_starter/rules/us-federal/governance.yaml +216 -0
  20. agenteval/_starter/rules/us-federal/i18n/zh.yaml +107 -0
  21. agenteval/_starter/rules/us-federal/pack.yaml +8 -0
  22. agenteval/_starter/rules/us-federal/security.yaml +164 -0
  23. agenteval/certification/__init__.py +16 -0
  24. agenteval/certification/assess.py +137 -0
  25. agenteval/certification/issuer.py +165 -0
  26. agenteval/certification/models.py +113 -0
  27. agenteval/certification/render.py +100 -0
  28. agenteval/certification/signing.py +86 -0
  29. agenteval/cli.py +533 -0
  30. agenteval/collectors/__init__.py +30 -0
  31. agenteval/collectors/kubernetes.py +70 -0
  32. agenteval/collectors/normalize.py +247 -0
  33. agenteval/config.py +49 -0
  34. agenteval/engine/__init__.py +12 -0
  35. agenteval/engine/conditions.py +109 -0
  36. agenteval/engine/evaluator.py +133 -0
  37. agenteval/engine/loader.py +131 -0
  38. agenteval/evaluation/__init__.py +17 -0
  39. agenteval/evaluation/adapters.py +276 -0
  40. agenteval/evaluation/assertions.py +73 -0
  41. agenteval/evaluation/judges.py +214 -0
  42. agenteval/evaluation/messages.py +56 -0
  43. agenteval/evaluation/models.py +174 -0
  44. agenteval/evaluation/papers.py +103 -0
  45. agenteval/evaluation/report.py +80 -0
  46. agenteval/evaluation/runner.py +154 -0
  47. agenteval/i18n.py +118 -0
  48. agenteval/models.py +136 -0
  49. agenteval/reports/__init__.py +86 -0
  50. agenteval/reports/templates/certificate.html.j2 +178 -0
  51. agenteval/reports/templates/eval_report.html.j2 +191 -0
  52. agenteval/reports/templates/report.html.j2 +75 -0
  53. agenteval/schema.py +66 -0
  54. agenteval/schemas/attestations.schema.json +76 -0
  55. agenteval/schemas/exam.schema.json +85 -0
  56. agenteval/schemas/pack.schema.json +48 -0
  57. agenteval/schemas/policy.schema.json +67 -0
  58. agenteval/schemas/rule.schema.json +298 -0
  59. agenteval/schemas/system.schema.json +76 -0
  60. agenteval/schemas/translations.schema.json +32 -0
  61. agenteval_toolkit-0.1.0.dist-info/METADATA +307 -0
  62. agenteval_toolkit-0.1.0.dist-info/RECORD +67 -0
  63. agenteval_toolkit-0.1.0.dist-info/WHEEL +5 -0
  64. agenteval_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  65. agenteval_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
  66. agenteval_toolkit-0.1.0.dist-info/licenses/NOTICE +19 -0
  67. agenteval_toolkit-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,174 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 The AgentEval Authors
3
+ """AI agent evaluation: models for papers, cases and results."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Literal
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ AssertionType = Literal[
13
+ "contains", "contains_any", "contains_all", "not_contains",
14
+ "regex", "not_regex",
15
+ "tool_called", "tool_not_called",
16
+ "max_latency_ms", "min_chars", "max_chars",
17
+ ]
18
+
19
+
20
+ # ---------------- Exam papers ----------------
21
+
22
+ class Dimension(BaseModel):
23
+ id: str
24
+ name: str
25
+ weight: float
26
+ description: str = ""
27
+
28
+
29
+ class Assertion(BaseModel):
30
+ type: AssertionType
31
+ value: Any = None
32
+ dimension: str
33
+ weight: float = 1.0
34
+ critical: bool = False # critical: failing it scores the whole case 0 (veto)
35
+ args_contains: dict[str, Any] | None = None # optional argument check for tool_called
36
+ description: str = ""
37
+
38
+
39
+ class Turn(BaseModel):
40
+ user: str
41
+ expect: list[Assertion] = Field(default_factory=list)
42
+
43
+
44
+ class RubricItem(BaseModel):
45
+ id: str
46
+ dimension: str
47
+ criterion: str
48
+ weight: float = 1.0
49
+ # Reference signal words for the offline heuristic judge (LLM judges use them as reference points)
50
+ signals: list[str] = Field(default_factory=list)
51
+
52
+
53
+ class Case(BaseModel):
54
+ id: str
55
+ title: str
56
+ category: str
57
+ difficulty: Literal["easy", "medium", "hard"] = "medium"
58
+ weight: float = 1.0
59
+ tags: list[str] = Field(default_factory=list)
60
+ tool_mocks: dict[str, Any] = Field(default_factory=dict)
61
+ turns: list[Turn]
62
+ rubric: list[RubricItem] = Field(default_factory=list)
63
+
64
+
65
+ class ExamPaper(BaseModel):
66
+ id: str
67
+ title: str
68
+ domain: str
69
+ version: str
70
+ language: str = "en"
71
+ description: str = ""
72
+ passing_score: float = 75.0
73
+ case_passing_score: float = 70.0
74
+ dimensions: list[Dimension]
75
+ context: str = "" # Business context (e.g. policies, FAQ); optionally injected into the agent's system prompt
76
+ tools: list[dict[str, Any]] = Field(default_factory=list) # OpenAI function format
77
+ cases: list[Case]
78
+
79
+
80
+ # ---------------- Run results ----------------
81
+
82
+ class ToolCall(BaseModel):
83
+ name: str
84
+ arguments: dict[str, Any] = Field(default_factory=dict)
85
+
86
+
87
+ class AgentReply(BaseModel):
88
+ text: str
89
+ tool_calls: list[ToolCall] = Field(default_factory=list)
90
+ latency_ms: float = 0.0
91
+
92
+
93
+ class TranscriptEntry(BaseModel):
94
+ role: Literal["user", "agent"]
95
+ content: str
96
+ tool_calls: list[ToolCall] = Field(default_factory=list)
97
+ latency_ms: float | None = None
98
+
99
+
100
+ class AssertionResult(BaseModel):
101
+ turn: int
102
+ type: str
103
+ value: Any = None
104
+ dimension: str
105
+ weight: float
106
+ critical: bool
107
+ passed: bool
108
+ detail: str = ""
109
+ detail_code: str = ""
110
+ detail_params: dict[str, Any] = Field(default_factory=dict)
111
+
112
+
113
+ class RubricResult(BaseModel):
114
+ id: str
115
+ dimension: str
116
+ criterion: str
117
+ weight: float
118
+ score: int | None = None # 1–5; None means the judge failed
119
+ rationale: str = "" # LLM judge's verbatim rationale, or the default Chinese text of a system message
120
+ rationale_code: str = "" # system-generated rationales (heuristic/errors) use a code for multilingual rendering
121
+ rationale_params: dict[str, Any] = Field(default_factory=dict)
122
+
123
+
124
+ class CaseResult(BaseModel):
125
+ id: str
126
+ title: str
127
+ category: str
128
+ difficulty: str
129
+ weight: float
130
+ score: float = 0.0
131
+ passed: bool = False
132
+ critical_failures: list[str] = Field(default_factory=list)
133
+ error: str | None = None
134
+ error_code: str = ""
135
+ error_params: dict[str, Any] = Field(default_factory=dict)
136
+ transcript: list[TranscriptEntry] = Field(default_factory=list)
137
+ assertions: list[AssertionResult] = Field(default_factory=list)
138
+ rubric: list[RubricResult] = Field(default_factory=list)
139
+
140
+
141
+ class DimensionScore(BaseModel):
142
+ id: str
143
+ name: str
144
+ weight: float
145
+ score: float | None = None # None means the dimension has no scorable items
146
+ items: int = 0
147
+
148
+
149
+ class EvalStats(BaseModel):
150
+ cases: int = 0
151
+ cases_passed: int = 0
152
+ pass_rate: float = 0.0
153
+ critical_failures: int = 0
154
+ errors: int = 0
155
+ latency_p50_ms: float | None = None
156
+ latency_p95_ms: float | None = None
157
+
158
+
159
+ class EvalRun(BaseModel):
160
+ run_id: str
161
+ paper_id: str
162
+ paper_title: str
163
+ paper_version: str
164
+ agent_name: str
165
+ judge: str
166
+ started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
167
+ finished_at: datetime | None = None
168
+ overall_score: float = 0.0
169
+ grade: str = "F"
170
+ passed: bool = False
171
+ passing_score: float = 75.0
172
+ dimensions: list[DimensionScore] = Field(default_factory=list)
173
+ cases: list[CaseResult] = Field(default_factory=list)
174
+ stats: EvalStats = Field(default_factory=EvalStats)
@@ -0,0 +1,103 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 The AgentEval Authors
3
+ """Exam paper library: loading, schema validation and consistency checks."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from importlib import resources
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+ from jsonschema import Draft202012Validator
13
+
14
+ from agenteval.config import content_dir
15
+
16
+ from .models import ExamPaper
17
+
18
+
19
+ class PaperError(Exception):
20
+ def __init__(self, errors: list[str]):
21
+ self.errors = errors
22
+ super().__init__("Exam paper validation failed:\n " + "\n ".join(errors))
23
+
24
+
25
+ def default_exams_dir() -> Path:
26
+ return content_dir("exams", "AGENTEVAL_EXAMS_DIR")
27
+
28
+
29
+ def _validator() -> Draft202012Validator:
30
+ text = resources.files("agenteval").joinpath("schemas/exam.schema.json").read_text(encoding="utf-8")
31
+ return Draft202012Validator(json.loads(text))
32
+
33
+
34
+ def _consistency(p: ExamPaper) -> list[str]:
35
+ errs: list[str] = []
36
+ dims = {d.id for d in p.dimensions}
37
+ tools = {t.get("name") for t in p.tools}
38
+ seen: set[str] = set()
39
+ for c in p.cases:
40
+ if c.id in seen:
41
+ errs.append(f"{p.id}: duplicate case ID {c.id}")
42
+ seen.add(c.id)
43
+ rub_ids = [r.id for r in c.rubric]
44
+ if len(rub_ids) != len(set(rub_ids)):
45
+ errs.append(f"{p.id}/{c.id}: rubric id 重复")
46
+ for i, t in enumerate(c.turns):
47
+ for a in t.expect:
48
+ if a.dimension not in dims:
49
+ errs.append(f"{p.id}/{c.id} turn {i + 1}: unknown dimension {a.dimension}")
50
+ if a.type in ("tool_called", "tool_not_called") and tools and a.value not in tools:
51
+ errs.append(f"{p.id}/{c.id} turn {i + 1}: tool not declared by the paper {a.value}")
52
+ if a.type in ("contains_any", "contains_all") and not isinstance(a.value, list):
53
+ errs.append(f"{p.id}/{c.id} turn {i + 1}: value of {a.type} must be a list")
54
+ for r in c.rubric:
55
+ if r.dimension not in dims:
56
+ errs.append(f"{p.id}/{c.id}: rubric {r.id} has unknown dimension {r.dimension}")
57
+ if not c.rubric and not any(t.expect for t in c.turns):
58
+ errs.append(f"{p.id}/{c.id}: no assertions or rubric items")
59
+ return errs
60
+
61
+
62
+ def load_paper(path: str | Path) -> ExamPaper:
63
+ path = Path(path)
64
+ with path.open(encoding="utf-8") as fh:
65
+ doc = yaml.safe_load(fh)
66
+ errs = [f"{path.name} {'/'.join(map(str, e.path)) or '<root>'}: {e.message}"
67
+ for e in sorted(_validator().iter_errors(doc), key=lambda e: list(e.path))]
68
+ if errs:
69
+ raise PaperError(errs)
70
+ paper = ExamPaper.model_validate(doc)
71
+ errs = _consistency(paper)
72
+ if errs:
73
+ raise PaperError(errs)
74
+ return paper
75
+
76
+
77
+ def load_papers(directory: str | Path | None = None) -> dict[str, ExamPaper]:
78
+ directory = Path(directory or default_exams_dir())
79
+ papers: dict[str, ExamPaper] = {}
80
+ errors: list[str] = []
81
+ for f in sorted(directory.rglob("*.y*ml")):
82
+ try:
83
+ p = load_paper(f)
84
+ except PaperError as e:
85
+ errors.extend(e.errors)
86
+ continue
87
+ if p.id in papers:
88
+ errors.append(f"{f.name}: 试卷 ID 重复 {p.id}")
89
+ papers[p.id] = p
90
+ if errors:
91
+ raise PaperError(errors)
92
+ return papers
93
+
94
+
95
+ def resolve_paper(ref: str, directory: str | Path | None = None) -> ExamPaper:
96
+ """ref may be a paper file path or a paper ID from the library."""
97
+ p = Path(ref)
98
+ if p.suffix in (".yaml", ".yml") and p.exists():
99
+ return load_paper(p)
100
+ papers = load_papers(directory)
101
+ if ref not in papers:
102
+ raise PaperError([f"Exam paper not found: {ref} (available: {', '.join(sorted(papers))})"])
103
+ return papers[ref]
@@ -0,0 +1,80 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 The AgentEval Authors
3
+ """Evaluation report: JSON / HTML (zh/en UI). Paper content is shown as-is in the language it was written in."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from importlib import resources
9
+
10
+ from jinja2 import Environment, select_autoescape
11
+
12
+ from .messages import fmt
13
+ from .models import EvalRun
14
+
15
+ UI = {
16
+ "zh": {
17
+ "html_lang": "zh-CN", "title": "AI Agent 评测报告", "paper": "试卷", "agent": "被测 Agent",
18
+ "judge": "裁判", "run": "评测 ID", "overall": "综合得分", "grade": "等级", "passed": "通过",
19
+ "not_passed": "未通过", "passing": "及格线", "cases": "样本", "case_pass_rate": "样本通过率",
20
+ "critical": "关键项失败", "latency": "延迟 p50 / p95", "dimensions": "维度得分", "dimension": "维度",
21
+ "weight": "权重", "score": "得分", "items_label": "评分项", "case_results": "样本明细",
22
+ "category": "类别", "difficulty": "难度", "status": "状态", "transcript": "对话记录",
23
+ "assertions": "断言", "rubric": "评分细则", "user": "用户", "agent_role": "Agent", "tools": "工具调用",
24
+ "error": "错误", "na": "无评分项", "case_pass": "通过", "case_fail": "未通过",
25
+ "heuristic_note": "本次使用离线启发式裁判(按参考信号词打分),主观维度仅供冒烟参考;正式评测请使用 LLM 裁判。",
26
+ "disclaimer": "评测结果反映被测 Agent 在本试卷样本上的表现,不代表其在所有真实场景中的表现。",
27
+ },
28
+ "en": {
29
+ "html_lang": "en", "title": "AI Agent Evaluation Report", "paper": "Exam", "agent": "Agent under test",
30
+ "judge": "Judge", "run": "Run ID", "overall": "Overall score", "grade": "Grade", "passed": "Passed",
31
+ "not_passed": "Not passed", "passing": "Passing score", "cases": "Cases", "case_pass_rate": "Case pass rate",
32
+ "critical": "Critical failures", "latency": "Latency p50 / p95", "dimensions": "Dimension scores",
33
+ "dimension": "Dimension", "weight": "Weight", "score": "Score", "items_label": "items",
34
+ "case_results": "Case results", "category": "Category", "difficulty": "Difficulty", "status": "Status",
35
+ "transcript": "Transcript", "assertions": "Assertions", "rubric": "Rubric", "user": "User",
36
+ "agent_role": "Agent", "tools": "Tool calls", "error": "Error", "na": "no scored items",
37
+ "case_pass": "Pass", "case_fail": "Fail",
38
+ "heuristic_note": ("This run used the offline heuristic judge (reference-signal matching). Subjective "
39
+ "dimensions are a smoke-test signal only; use an LLM judge for formal evaluation."),
40
+ "disclaimer": ("Results reflect the agent's performance on this exam's samples and do not guarantee "
41
+ "performance in all real-world scenarios."),
42
+ },
43
+ }
44
+
45
+
46
+ def to_json(run: EvalRun) -> str:
47
+ return run.model_dump_json(indent=2)
48
+
49
+
50
+ def localize(run: EvalRun, lang: str) -> EvalRun:
51
+ """Re-format system-generated messages (assertion details, errors, heuristic rationales) for a language; returns a copy."""
52
+ if lang not in UI:
53
+ raise ValueError(f"Unsupported language: {lang}")
54
+ out = run.model_copy(deep=True)
55
+ for c in out.cases:
56
+ if c.error_code:
57
+ c.error = fmt(c.error_code, c.error_params, lang)
58
+ for a in c.assertions:
59
+ if a.detail_code:
60
+ a.detail = fmt(a.detail_code, a.detail_params, lang)
61
+ for g in c.rubric:
62
+ if g.rationale_code:
63
+ g.rationale = fmt(g.rationale_code, g.rationale_params, lang)
64
+ return out
65
+
66
+
67
+ def to_html(run: EvalRun, lang: str = "zh") -> str:
68
+ run = localize(run, lang)
69
+ env = Environment(autoescape=select_autoescape(default=True, default_for_string=True))
70
+ env.filters["tojson"] = lambda v: json.dumps(v, ensure_ascii=False, default=str)
71
+ tpl = resources.files("agenteval").joinpath("reports/templates/eval_report.html.j2").read_text(encoding="utf-8")
72
+ return env.from_string(tpl).render(r=run, t=UI[lang])
73
+
74
+
75
+ def render(run: EvalRun, fmt: str = "html", lang: str = "zh") -> str:
76
+ if fmt == "json":
77
+ return to_json(localize(run, lang))
78
+ if fmt == "html":
79
+ return to_html(run, lang)
80
+ raise ValueError(f"Unsupported format: {fmt}")
@@ -0,0 +1,154 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 The AgentEval Authors
3
+ """Evaluation execution and overall scoring.
4
+
5
+ Scoring rules:
6
+ - each item is normalized to [0, 1]: assertion pass = 1, fail = 0; rubric scores 1–5 map to (score-1)/4;
7
+ - case score = 100 × Σ(item weight × item score) / Σ item weight; a failed critical assertion sets the case score to 0;
8
+ - dimension score = 100 × Σ(case weight × item weight × item score) / Σ(case weight × item weight), over that dimension's items only;
9
+ - overall score = weighted mean of dimension scores by the paper's dimension weights (dimensions without items are excluded and weights re-normalized);
10
+ - pass condition: overall score ≥ passing_score and no critical failures.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import statistics
16
+ import uuid
17
+ from concurrent.futures import ThreadPoolExecutor
18
+ from datetime import datetime, timezone
19
+
20
+ from .adapters import Agent, AdapterError
21
+ from .assertions import check
22
+ from .judges import Judge
23
+ from .messages import fmt
24
+ from .models import (
25
+ AssertionResult, CaseResult, DimensionScore, EvalRun, EvalStats, ExamPaper, RubricResult, TranscriptEntry,
26
+ )
27
+
28
+
29
+ def grade_for(score: float) -> str:
30
+ for g, cut in (("A", 90), ("B", 80), ("C", 70), ("D", 60)):
31
+ if score >= cut:
32
+ return g
33
+ return "F"
34
+
35
+
36
+ def _items(cr: CaseResult) -> list[tuple[str, float, float]]:
37
+ """List of (dimension, weight, normalized score); rubric items whose judge failed (score=None) are excluded."""
38
+ out = [(a.dimension, a.weight, 1.0 if a.passed else 0.0) for a in cr.assertions]
39
+ out += [(r.dimension, r.weight, (r.score - 1) / 4) for r in cr.rubric if r.score is not None]
40
+ return out
41
+
42
+
43
+ def run_case(paper: ExamPaper, case, agent: Agent, judge: Judge) -> CaseResult:
44
+ cr = CaseResult(id=case.id, title=case.title, category=case.category,
45
+ difficulty=case.difficulty, weight=case.weight)
46
+ session = None
47
+ try:
48
+ session = agent.new_session(paper, case)
49
+ for i, turn in enumerate(case.turns, start=1):
50
+ cr.transcript.append(TranscriptEntry(role="user", content=turn.user))
51
+ reply = session.send(turn.user)
52
+ cr.transcript.append(TranscriptEntry(role="agent", content=reply.text,
53
+ tool_calls=reply.tool_calls, latency_ms=reply.latency_ms))
54
+ cr.assertions += [check(a, reply, i) for a in turn.expect]
55
+ except AdapterError as e:
56
+ cr.error, cr.error_code, cr.error_params = str(e), e.code, e.params
57
+ # Assertions that never ran count as failures, so an erroring agent is not penalized less
58
+ done = {(a.turn, a.type, str(a.value)) for a in cr.assertions}
59
+ for i, turn in enumerate(case.turns, start=1):
60
+ for a in turn.expect:
61
+ if (i, a.type, str(a.value)) not in done:
62
+ cr.assertions.append(AssertionResult(
63
+ turn=i, type=a.type, value=a.value, dimension=a.dimension, weight=a.weight,
64
+ critical=a.critical, passed=False, detail=fmt("not_executed", {}),
65
+ detail_code="not_executed"))
66
+ finally:
67
+ if session is not None:
68
+ session.close()
69
+
70
+ if case.rubric:
71
+ if cr.error:
72
+ cr.rubric = [RubricResult(id=r.id, dimension=r.dimension, criterion=r.criterion, weight=r.weight,
73
+ score=1, rationale=fmt("agent_error_min", {}), rationale_code="agent_error_min")
74
+ for r in case.rubric]
75
+ else:
76
+ cr.rubric = judge.score(paper, case, cr.transcript, case.rubric)
77
+
78
+ cr.critical_failures = [f"T{a.turn}:{a.type}:{a.value}" for a in cr.assertions if a.critical and not a.passed]
79
+ items = _items(cr)
80
+ total_w = sum(w for _, w, _ in items)
81
+ cr.score = round(100 * sum(w * s for _, w, s in items) / total_w, 1) if total_w else 0.0
82
+ if cr.critical_failures or cr.error:
83
+ cr.score = 0.0
84
+ cr.passed = cr.score >= paper.case_passing_score
85
+ return cr
86
+
87
+
88
+ def score_run(paper: ExamPaper, cases: list[CaseResult]) -> tuple[list[DimensionScore], float]:
89
+ dims = []
90
+ for d in paper.dimensions:
91
+ num = den = 0.0
92
+ n = 0
93
+ for cr in cases:
94
+ for dim, w, s in _items(cr):
95
+ if dim == d.id:
96
+ num += cr.weight * w * s
97
+ den += cr.weight * w
98
+ n += 1
99
+ dims.append(DimensionScore(id=d.id, name=d.name, weight=d.weight,
100
+ score=round(100 * num / den, 1) if den else None, items=n))
101
+ scored = [d for d in dims if d.score is not None]
102
+ wsum = sum(d.weight for d in scored)
103
+ overall = round(sum(d.weight * d.score for d in scored) / wsum, 1) if wsum else 0.0
104
+ return dims, overall
105
+
106
+
107
+ def _pct(values: list[float], q: float) -> float | None:
108
+ if not values:
109
+ return None
110
+ if len(values) == 1:
111
+ return round(values[0], 1)
112
+ return round(statistics.quantiles(values, n=100, method="inclusive")[int(q) - 1], 1)
113
+
114
+
115
+ def run_exam(paper: ExamPaper, agent: Agent, judge: Judge, concurrency: int = 1,
116
+ case_ids: list[str] | None = None) -> EvalRun:
117
+ started = datetime.now(timezone.utc)
118
+ cases = [c for c in paper.cases if not case_ids or c.id in case_ids]
119
+ with ThreadPoolExecutor(max_workers=max(1, concurrency)) as pool:
120
+ results = list(pool.map(lambda c: run_case(paper, c, agent, judge), cases))
121
+
122
+ dims, overall = score_run(paper, results)
123
+ latencies = [t.latency_ms for r in results for t in r.transcript if t.role == "agent" and t.latency_ms is not None]
124
+ crit = sum(len(r.critical_failures) for r in results)
125
+ passed_cases = sum(1 for r in results if r.passed)
126
+ stats = EvalStats(
127
+ cases=len(results), cases_passed=passed_cases,
128
+ pass_rate=round(100 * passed_cases / len(results), 1) if results else 0.0,
129
+ critical_failures=crit, errors=sum(1 for r in results if r.error),
130
+ latency_p50_ms=_pct(latencies, 50), latency_p95_ms=_pct(latencies, 95),
131
+ )
132
+ return EvalRun(
133
+ run_id=uuid.uuid4().hex[:12], paper_id=paper.id, paper_title=paper.title, paper_version=paper.version,
134
+ agent_name=agent.name, judge=judge.name, started_at=started, finished_at=datetime.now(timezone.utc),
135
+ overall_score=overall, grade=grade_for(overall), passed=overall >= paper.passing_score and crit == 0,
136
+ passing_score=paper.passing_score, dimensions=dims, cases=results, stats=stats,
137
+ )
138
+
139
+
140
+ def to_attestation(run: EvalRun, asset_id: str, rule_id: str = "FED-GOV-A02",
141
+ attested_by: str = "agenteval-eval") -> dict:
142
+ """Turn a passing evaluation into an attestation entry usable by the compliance scan (as pre_deployment_testing evidence)."""
143
+ if not run.passed:
144
+ raise ValueError("Evaluation did not pass and cannot serve as pre-deployment testing attestation")
145
+ return {
146
+ "rule_id": rule_id,
147
+ "asset_id": asset_id,
148
+ "attested_by": attested_by,
149
+ "attested_at": run.finished_at.date().isoformat() if run.finished_at else None,
150
+ "evidence": {
151
+ "pre_deployment_testing": (f"agenteval-eval run {run.run_id}: {run.paper_id}@{run.paper_version} "
152
+ f"score={run.overall_score} grade={run.grade} judge={run.judge}"),
153
+ },
154
+ }
agenteval/i18n.py ADDED
@@ -0,0 +1,118 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 The AgentEval Authors
3
+ """Multilingual report support (zh / en).
4
+
5
+ - Engine-generated messages are stored as message_code + message_params and formatted per language at render time;
6
+ - rule titles, descriptions, remediation and source references come from rules/**/i18n/<lang>.yaml;
7
+ - missing translations fall back to the source (English) text.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from agenteval.models import ScanResult
15
+
16
+ LANGS = ("zh", "en")
17
+ DEFAULT_LANG = "en" # source language of rules and engine messages
18
+
19
+ MESSAGES: dict[str, dict[str, str]] = {
20
+ "ok": {"zh": "符合要求", "en": "Compliant"},
21
+ "check_failed": {"zh": "不符合: {title}", "en": "Non-compliant: {title}"},
22
+ "not_applicable": {"zh": "前置条件不满足,规则不适用", "en": "Precondition not met; rule not applicable"},
23
+ "att_missing": {"zh": "等待人工证明,需提供: {required}",
24
+ "en": "Awaiting manual attestation; required evidence: {required}"},
25
+ "att_incomplete": {"zh": "证明材料不完整,缺少: {missing}",
26
+ "en": "Attestation incomplete; missing: {missing}"},
27
+ "att_expired": {"zh": "证明已过期({age} 天 > {max_age} 天),需重新评估",
28
+ "en": "Attestation expired ({age} days > {max_age} days); re-assessment required"},
29
+ "att_no_date": {"zh": "证明缺少 attested_at 日期,无法判断有效期",
30
+ "en": "Attestation has no attested_at date; validity cannot be determined"},
31
+ "att_ok": {"zh": "已提供有效的人工证明", "en": "Valid manual attestation provided"},
32
+ "eval_error": {"zh": "规则求值错误: {error}", "en": "Rule evaluation error: {error}"},
33
+ }
34
+
35
+ DISCLAIMER = {
36
+ "zh": "本报告为基于证据的技术控制项评估,不构成法律意见或合规认定。规则与法规的映射需经法务/合规团队审核。",
37
+ "en": ("This report is an evidence-based assessment of technical controls and does not constitute legal "
38
+ "advice or a determination of regulatory compliance. Rule-to-regulation mappings must be reviewed "
39
+ "by your legal/compliance team."),
40
+ }
41
+
42
+ UI: dict[str, dict[str, str]] = {
43
+ "zh": {
44
+ "html_lang": "zh-CN", "title": "AI 合规扫描报告", "scan_id": "扫描 ID", "source": "来源",
45
+ "rules_version": "规则集", "score": "自动项通过率", "assets": "资产", "failed": "不通过",
46
+ "manual": "待人工证明", "passed": "通过", "col_status": "状态", "col_severity": "严重度",
47
+ "col_rule": "规则", "col_asset": "资产", "col_detail": "说明与整改",
48
+ "remediation": "整改建议与依据", "evidence": "证据", "unverified": "(映射待审核)",
49
+ "status_fail": "不通过", "status_pass": "通过", "status_manual": "待证明",
50
+ "status_not_applicable": "不适用", "status_error": "错误",
51
+ "sev_critical": "严重", "sev_high": "高", "sev_medium": "中", "sev_low": "低", "sev_info": "信息",
52
+ "list_sep": "、",
53
+ },
54
+ "en": {
55
+ "html_lang": "en", "title": "AI Compliance Scan Report", "scan_id": "Scan ID", "source": "Source",
56
+ "rules_version": "Rule set", "score": "Automated pass rate", "assets": "Assets", "failed": "Failed",
57
+ "manual": "Awaiting attestation", "passed": "Passed", "col_status": "Status", "col_severity": "Severity",
58
+ "col_rule": "Rule", "col_asset": "Asset", "col_detail": "Details & remediation",
59
+ "remediation": "Remediation & basis", "evidence": "Evidence", "unverified": " (mapping pending review)",
60
+ "status_fail": "Fail", "status_pass": "Pass", "status_manual": "Pending",
61
+ "status_not_applicable": "N/A", "status_error": "Error",
62
+ "sev_critical": "Critical", "sev_high": "High", "sev_medium": "Medium", "sev_low": "Low", "sev_info": "Info",
63
+ "list_sep": ", ",
64
+ },
65
+ }
66
+
67
+
68
+ def check_lang(lang: str) -> str:
69
+ if lang not in LANGS:
70
+ raise ValueError(f"Unsupported language: {lang} (choose from: {', '.join(LANGS)})")
71
+ return lang
72
+
73
+
74
+ def format_message(code: str, params: dict[str, Any], lang: str, title: str = "") -> str:
75
+ tpl = MESSAGES.get(code, {}).get(lang) or MESSAGES.get(code, {}).get(DEFAULT_LANG)
76
+ if not tpl:
77
+ return ""
78
+ sep = UI[lang]["list_sep"]
79
+ p = {k: (sep.join(map(str, v)) if isinstance(v, list) else v) for k, v in params.items()}
80
+ return tpl.format(title=title, **p)
81
+
82
+
83
+ def localize_rule(rule_dict: dict, translations: dict, lang: str) -> dict:
84
+ """Replace a rule's (model_dump dict) title, description, remediation and sources with the target language."""
85
+ check_lang(lang)
86
+ if lang == DEFAULT_LANG:
87
+ return rule_dict
88
+ tr = translations.get(lang) or {}
89
+ out = dict(rule_dict)
90
+ for k in ("title", "description", "remediation"):
91
+ if tr.get(k):
92
+ out[k] = tr[k]
93
+ refs = tr.get("sources") or []
94
+ out["sources"] = [{**s, "reference": refs[i]} if i < len(refs) and refs[i] else s
95
+ for i, s in enumerate(rule_dict.get("sources", []))]
96
+ return out
97
+
98
+
99
+ def localize_result(result: ScanResult, lang: str) -> ScanResult:
100
+ """Return a copy with fields replaced by the target language (the original is not modified)."""
101
+ check_lang(lang)
102
+ if lang == DEFAULT_LANG:
103
+ return result
104
+ out = result.model_copy(deep=True)
105
+ out.disclaimer = DISCLAIMER[lang]
106
+ for f in out.findings:
107
+ tr = f.translations.get(lang) or {}
108
+ if tr.get("title"):
109
+ f.rule_title = tr["title"]
110
+ if tr.get("remediation"):
111
+ f.remediation = tr["remediation"]
112
+ refs = tr.get("sources") or []
113
+ for i, s in enumerate(f.sources):
114
+ if i < len(refs) and refs[i]:
115
+ s.reference = refs[i]
116
+ if f.message_code:
117
+ f.message = format_message(f.message_code, f.message_params, lang, f.rule_title)
118
+ return out