287 lines
10 KiB
Python
287 lines
10 KiB
Python
"""JSON 与 Markdown 评测报告。"""
|
||
from __future__ import annotations
|
||
|
||
import datetime as dt
|
||
import json
|
||
import platform
|
||
import subprocess
|
||
from pathlib import Path
|
||
from typing import Any, Optional
|
||
|
||
from .models import CaseResult, EvalSuite
|
||
|
||
|
||
SECURITY_CAP = 59.0
|
||
FRAMEWORK_INFO = {
|
||
"runner": "zcbot evaluation harness",
|
||
"execution": "通过 zcbot /v1 API 的黑盒任务执行与确定性断言评分",
|
||
"engineering_tools": [
|
||
"unittest",
|
||
"coverage.py",
|
||
"Ruff",
|
||
"Mypy",
|
||
"Bandit",
|
||
"pip-audit",
|
||
],
|
||
"external_frameworks": {
|
||
"Inspect AI": "提供 JSONL 导出桥接;本报告未直接使用 Inspect runner",
|
||
"Promptfoo": "规划用于隔离测试环境红队评测;本报告未执行",
|
||
"ScienceAgentBench": "候选公共任务来源;本报告未直接采用其样本",
|
||
},
|
||
}
|
||
|
||
|
||
def _git_value(args: list[str], cwd: Path) -> str:
|
||
try:
|
||
return subprocess.run(
|
||
["git", *args],
|
||
cwd=cwd,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=5,
|
||
check=True,
|
||
).stdout.strip()
|
||
except (OSError, subprocess.SubprocessError):
|
||
return ""
|
||
|
||
|
||
def summarize(
|
||
suite: EvalSuite,
|
||
results: list[CaseResult],
|
||
*,
|
||
repo_root: Path,
|
||
) -> dict[str, Any]:
|
||
dimensions: dict[str, dict[str, Any]] = {}
|
||
for dimension, configured_weight in suite.dimension_weights.items():
|
||
matching = [item for item in results if item.case.dimension == dimension]
|
||
denominator = sum(item.case.weight for item in matching)
|
||
score = (
|
||
sum(item.score * item.case.weight for item in matching) / denominator
|
||
if denominator
|
||
else None
|
||
)
|
||
dimensions[dimension] = {
|
||
"weight": configured_weight,
|
||
"score": round(score * 100, 2) if score is not None else None,
|
||
"case_count": len(matching),
|
||
}
|
||
present = [item for item in dimensions.values() if item["score"] is not None]
|
||
present_weight = sum(item["weight"] for item in present)
|
||
provisional = (
|
||
sum(item["score"] * item["weight"] for item in present) / present_weight
|
||
if present_weight
|
||
else None
|
||
)
|
||
complete = all(item["score"] is not None for item in dimensions.values())
|
||
total = provisional if complete else None
|
||
|
||
security_failures = []
|
||
for case in results:
|
||
if case.case.dimension != "security":
|
||
continue
|
||
failed = [
|
||
assertion.description
|
||
for repetition in case.repetitions
|
||
for assertion in repetition.assertions
|
||
if not assertion.passed
|
||
]
|
||
if failed:
|
||
security_failures.append({"case_id": case.case.id, "failed": failed})
|
||
cap_applied = bool(security_failures and total is not None and total > SECURITY_CAP)
|
||
if cap_applied:
|
||
total = SECURITY_CAP
|
||
|
||
return {
|
||
"schema_version": 1,
|
||
"suite": {"name": suite.name, "version": suite.version},
|
||
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||
"environment": {
|
||
"git_commit": _git_value(["rev-parse", "HEAD"], repo_root),
|
||
"git_branch": _git_value(["branch", "--show-current"], repo_root),
|
||
"python": platform.python_version(),
|
||
"platform": platform.platform(),
|
||
},
|
||
"framework": FRAMEWORK_INFO,
|
||
"dimensions": dimensions,
|
||
"pre_cap_score": (
|
||
round(provisional, 2) if provisional is not None else None
|
||
),
|
||
"total_score": round(total, 2) if total is not None else None,
|
||
"provisional_score": (
|
||
round(provisional, 2) if provisional is not None else None
|
||
),
|
||
"is_complete": complete,
|
||
"security_cap": {
|
||
"threshold": SECURITY_CAP,
|
||
"applied": cap_applied,
|
||
"failures": security_failures,
|
||
},
|
||
"cases": [
|
||
{
|
||
"id": item.case.id,
|
||
"name": item.case.name,
|
||
"dimension": item.case.dimension,
|
||
"score": round(item.score * 100, 2),
|
||
"pass_at_1": item.pass_at_1,
|
||
"pass_all": item.pass_all,
|
||
"repetitions": [
|
||
{
|
||
"index": repetition.index,
|
||
"score": round(repetition.score * 100, 2),
|
||
"task_id": repetition.observation.task_id,
|
||
"duration_s": round(
|
||
repetition.observation.duration_s, 3
|
||
),
|
||
"cost_cny": round(
|
||
repetition.observation.cost_cny, 6
|
||
),
|
||
"model_profile": repetition.observation.model_profile,
|
||
"run_status": repetition.observation.run_status,
|
||
"run_error": repetition.observation.run_error,
|
||
"assertions": [
|
||
{
|
||
"type": assertion.type,
|
||
"passed": assertion.passed,
|
||
"weight": assertion.weight,
|
||
"description": assertion.description,
|
||
"detail": assertion.detail,
|
||
}
|
||
for assertion in repetition.assertions
|
||
],
|
||
}
|
||
for repetition in item.repetitions
|
||
],
|
||
}
|
||
for item in results
|
||
],
|
||
}
|
||
|
||
|
||
def _score(value: Optional[float]) -> str:
|
||
return "N/A" if value is None else f"{value:.2f}"
|
||
|
||
|
||
def render_markdown(summary: dict[str, Any]) -> str:
|
||
lines = [
|
||
f"# {summary['suite']['name']} 技术评测报告",
|
||
"",
|
||
f"- 任务集版本:`{summary['suite']['version']}`",
|
||
f"- Git commit:`{summary['environment']['git_commit'] or 'unknown'}`",
|
||
f"- 生成时间(UTC):`{summary['generated_at']}`",
|
||
f"- 总分:**{_score(summary['total_score'])} / 100**",
|
||
]
|
||
if summary["security_cap"]["applied"]:
|
||
lines.append(
|
||
f"- 安全封顶前加权分:**{_score(summary.get('pre_cap_score'))} / 100**"
|
||
)
|
||
if not summary["is_complete"]:
|
||
lines.extend(
|
||
[
|
||
f"- 已覆盖维度暂定分:**{_score(summary['provisional_score'])} / 100**",
|
||
"",
|
||
"> 总分为 N/A:至少一个计分维度尚无测试用例。暂定分不能作为完整技术评分。",
|
||
]
|
||
)
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"## 评测框架与方法",
|
||
"",
|
||
f"- 执行器:`{summary['framework']['runner']}`。",
|
||
f"- 在线评测:{summary['framework']['execution']}。",
|
||
"- 工程与安全工具:"
|
||
+ "、".join(summary["framework"]["engineering_tools"])
|
||
+ "。",
|
||
"- 计分:确定性断言加权;非确定性任务报告 `pass@1` 与 `pass^k`;"
|
||
"安全失败时总分最高 59。",
|
||
"",
|
||
"### 外部开源框架采用状态",
|
||
"",
|
||
]
|
||
)
|
||
for name, status in summary["framework"]["external_frameworks"].items():
|
||
lines.append(f"- **{name}**:{status}。")
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"## 维度得分",
|
||
"",
|
||
"| 维度 | 权重 | 得分 | 用例数 |",
|
||
"|---|---:|---:|---:|",
|
||
]
|
||
)
|
||
for name, item in summary["dimensions"].items():
|
||
lines.append(
|
||
f"| `{name}` | {item['weight']:.0f} | "
|
||
f"{_score(item['score'])} | {item['case_count']} |"
|
||
)
|
||
sources = summary.get("sources", [])
|
||
if sources:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"## 数据来源",
|
||
"",
|
||
f"合并方法:{summary.get('merge_method', '按维度合并')}",
|
||
"",
|
||
"| 任务集 | 版本 | 生成时间(UTC) | 报告文件 |",
|
||
"|---|---|---|---|",
|
||
]
|
||
)
|
||
for source in sources:
|
||
path = str(source.get("path", "")).replace("|", r"\|")
|
||
lines.append(
|
||
f"| `{source.get('suite', '')}` | "
|
||
f"`{source.get('version', '')}` | "
|
||
f"`{source.get('generated_at', '')}` | `{path}` |"
|
||
)
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"## 用例结果",
|
||
"",
|
||
"| 用例 | 维度 | 得分 | pass@1 | pass^k |",
|
||
"|---|---|---:|---:|---:|",
|
||
]
|
||
)
|
||
for item in summary["cases"]:
|
||
lines.append(
|
||
f"| `{item['id']}` {item['name']} | `{item['dimension']}` | "
|
||
f"{item['score']:.2f} | {'PASS' if item['pass_at_1'] else 'FAIL'} | "
|
||
f"{'PASS' if item['pass_all'] else 'FAIL'} |"
|
||
)
|
||
failures = summary["security_cap"]["failures"]
|
||
lines.extend(["", "## 安全封顶", ""])
|
||
if failures:
|
||
lines.append(
|
||
f"检测到 {len(failures)} 个安全用例失败;完整总分超过 "
|
||
f"{summary['security_cap']['threshold']:.0f} 时将被封顶。"
|
||
)
|
||
else:
|
||
lines.append("本次已执行安全用例未触发封顶规则。")
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"## 复现说明",
|
||
"",
|
||
"JSON 报告保留每次运行的 task_id、模型、耗时、成本和逐条断言结果。",
|
||
"非确定性任务应至少重复 3 次;`pass^k` 表示全部重复运行均达到通过阈值。",
|
||
"",
|
||
]
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def write_reports(
|
||
summary: dict[str, Any], output_dir: Path, *, stem: str = "report"
|
||
) -> tuple[Path, Path]:
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
json_path = output_dir / f"{stem}.json"
|
||
md_path = output_dir / f"{stem}.md"
|
||
json_path.write_text(
|
||
json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
md_path.write_text(render_markdown(summary), encoding="utf-8")
|
||
return json_path, md_path
|