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.
- agenteval/__init__.py +5 -0
- agenteval/_starter/certification/policy.yaml +15 -0
- agenteval/_starter/certification/systems/northwind-cs-staging.yaml +16 -0
- agenteval/_starter/certification/systems/northwind-cs.yaml +17 -0
- agenteval/_starter/examples/agents/demo-good.yaml +41 -0
- agenteval/_starter/examples/agents/demo-weak.yaml +34 -0
- agenteval/_starter/examples/agents/openai-compatible.example.yaml +15 -0
- agenteval/_starter/examples/agents/webhook.example.yaml +21 -0
- agenteval/_starter/examples/attestations-certification.yaml +16 -0
- agenteval/_starter/examples/attestations.yaml +19 -0
- agenteval/_starter/examples/snapshots/sample-cluster.json +323 -0
- agenteval/_starter/exams/customer-service/cs-ecommerce-v1.yaml +222 -0
- agenteval/_starter/rules/industry-pci-dss/i18n/zh.yaml +27 -0
- agenteval/_starter/rules/industry-pci-dss/pack.yaml +9 -0
- agenteval/_starter/rules/industry-pci-dss/rules.yaml +83 -0
- agenteval/_starter/rules/us-ca/i18n/zh.yaml +21 -0
- agenteval/_starter/rules/us-ca/pack.yaml +9 -0
- agenteval/_starter/rules/us-ca/rules.yaml +72 -0
- agenteval/_starter/rules/us-federal/governance.yaml +216 -0
- agenteval/_starter/rules/us-federal/i18n/zh.yaml +107 -0
- agenteval/_starter/rules/us-federal/pack.yaml +8 -0
- agenteval/_starter/rules/us-federal/security.yaml +164 -0
- agenteval/certification/__init__.py +16 -0
- agenteval/certification/assess.py +137 -0
- agenteval/certification/issuer.py +165 -0
- agenteval/certification/models.py +113 -0
- agenteval/certification/render.py +100 -0
- agenteval/certification/signing.py +86 -0
- agenteval/cli.py +533 -0
- agenteval/collectors/__init__.py +30 -0
- agenteval/collectors/kubernetes.py +70 -0
- agenteval/collectors/normalize.py +247 -0
- agenteval/config.py +49 -0
- agenteval/engine/__init__.py +12 -0
- agenteval/engine/conditions.py +109 -0
- agenteval/engine/evaluator.py +133 -0
- agenteval/engine/loader.py +131 -0
- agenteval/evaluation/__init__.py +17 -0
- agenteval/evaluation/adapters.py +276 -0
- agenteval/evaluation/assertions.py +73 -0
- agenteval/evaluation/judges.py +214 -0
- agenteval/evaluation/messages.py +56 -0
- agenteval/evaluation/models.py +174 -0
- agenteval/evaluation/papers.py +103 -0
- agenteval/evaluation/report.py +80 -0
- agenteval/evaluation/runner.py +154 -0
- agenteval/i18n.py +118 -0
- agenteval/models.py +136 -0
- agenteval/reports/__init__.py +86 -0
- agenteval/reports/templates/certificate.html.j2 +178 -0
- agenteval/reports/templates/eval_report.html.j2 +191 -0
- agenteval/reports/templates/report.html.j2 +75 -0
- agenteval/schema.py +66 -0
- agenteval/schemas/attestations.schema.json +76 -0
- agenteval/schemas/exam.schema.json +85 -0
- agenteval/schemas/pack.schema.json +48 -0
- agenteval/schemas/policy.schema.json +67 -0
- agenteval/schemas/rule.schema.json +298 -0
- agenteval/schemas/system.schema.json +76 -0
- agenteval/schemas/translations.schema.json +32 -0
- agenteval_toolkit-0.1.0.dist-info/METADATA +307 -0
- agenteval_toolkit-0.1.0.dist-info/RECORD +67 -0
- agenteval_toolkit-0.1.0.dist-info/WHEEL +5 -0
- agenteval_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- agenteval_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
- agenteval_toolkit-0.1.0.dist-info/licenses/NOTICE +19 -0
- 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
|