AgentEvalTool/backend/agenteval/evaluation/report.py
sinohqb 349200e51f v0.3-s3: Webhook + OpenClaw HTTP Skill + Markdown/对比报告
## Webhook 通知(S3-1)
- settings.py: 增加 AGENTEVAL_WEBHOOK_URL / AGENTEVAL_WEBHOOK_SECRET
- utils/webhook.py: send_run_webhook(),非阻断,任何异常仅 warning log
- runs.py: run 完成后自动触发 webhook(payload 含 run_id/status/summary/report_url)
- .env.example: 新增 webhook 配置示例

## OpenClaw Skill HTTP 改造(S3-2)
- plugins/openclaw/agenteval_skill.py: 完全重写
  - 改用 HTTP API(POST /api/runs + GET /api/runs/{id} 轮询 + GET /api/reports/{id})
  - 移除 subprocess + CLI 依赖
  - 轮询等待至 completed/failed,支持配置 poll_interval / timeout
  - 返回结构化中文摘要(summary_text),直接可用于 OpenClaw 对话展示

## Markdown 报告导出(S3-3)
- report.py: render_markdown_report() — 完整的 Markdown 表格 + 对话展示
- save_report: 支持 fmt="markdown",输出 .md 文件
- reports.py: GET /api/reports/{run_id}/markdown,Content-Disposition 附件下载
- api.ts: reportsApi.markdownUrl()
- Reports.tsx: 「导出 MD」按钮

## 对比报告(S3-4)
- report.py: generate_compare_report(run_id_1, run_id_2)
  - run_a / run_b 汇总 + delta(pass_rate / passed_cases / passed_rules)
  - case-level diff,标记 changed 用例
- reports.py: GET /api/reports/compare?run1=&run2=
- api.ts: reportsApi.compare()
- Reports.tsx: 完整对比视图
  - Segmented 切换「单次报告」/「对比报告」
  - 双 Select(报告 A vs B)+ 对比按钮
  - 汇总 delta card(pass_rate 变化 + 变化用例数徽章)
  - 用例对比表(通过/失败/改善↑/退步↓)+ 展开规则明细

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 11:44:54 +08:00

322 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Report generation for evaluation runs."""
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from jinja2 import Template
from agenteval.storage.db import DATA_DIR
from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository
from agenteval.utils.llm import extract_reply_text
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>评测报告 - {{ report.run_id }}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 40px; background: #f5f5f5; }
.container { max-width: 960px; margin: 0 auto; background: #fff; padding: 32px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
h1 { margin-top: 0; }
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; margin: 24px 0; }
.card { background: #fafafa; border-radius: 6px; padding: 16px; text-align: center; }
.card .value { font-size: 24px; font-weight: 700; }
.card .label { color: #666; font-size: 14px; margin-top: 4px; }
.case { border: 1px solid #e0e0e0; border-radius: 6px; margin: 16px 0; padding: 16px; }
.case-title { font-weight: 600; margin-bottom: 8px; }
.turn { background: #f9f9f9; border-radius: 4px; padding: 12px; margin: 8px 0; }
.message-label { color: #666; font-size: 12px; }
.rule { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
.badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; }
.pass { background: #e6f7e6; color: #2e7d32; }
.fail { background: #ffebee; color: #c62828; }
pre { white-space: pre-wrap; word-break: break-word; background: #f5f5f5; padding: 8px; border-radius: 4px; }
</style>
</head>
<body>
<div class="container">
<h1>评测报告</h1>
<p>评测对象:{{ report.target_name }}{{ report.target_id }}</p>
<p>评测场景:{{ report.scenario_name }}{{ report.scenario_id }}</p>
<p>执行时间:{{ report.started_at }} 至 {{ report.completed_at or '进行中' }}</p>
<div class="summary">
<div class="card">
<div class="value">{{ report.summary.total_cases }}</div>
<div class="label">用例总数</div>
</div>
<div class="card">
<div class="value">{{ report.summary.passed_cases }}</div>
<div class="label">通过用例</div>
</div>
<div class="card">
<div class="value">{{ report.summary.total_rules }}</div>
<div class="label">规则总数</div>
</div>
<div class="card">
<div class="value">{{ "%.2f"|format(report.summary.pass_rate * 100) }}%</div>
<div class="label">规则通过率</div>
</div>
</div>
{% for case in report.cases %}
<div class="case">
<div class="case-title">用例 {{ case.case_id }}</div>
{% for turn in case.turns %}
<div class="turn">
<div class="message-label">用户消息</div>
<pre>{{ turn.sent_text }}</pre>
<div class="message-label">智能体回复({{ turn.latency_ms }}ms</div>
<pre>{{ turn.reply_text or '(无回复)' }}</pre>
</div>
{% endfor %}
<div>
{% for result in case.results %}
<div class="rule">
<span class="badge {{ 'pass' if result.passed else 'fail' }}">{{ '通过' if result.passed else '失败' }}</span>
<span>{{ result.rule_type }}: {{ result.reason }}</span>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</body>
</html>
"""
def _extract_text(data: Any) -> str:
return extract_reply_text(data)
def generate_report(run_id: str, session=None) -> dict[str, Any]:
"""Build a structured report dict for a run."""
run_repo = RunRepository(session)
target_repo = TargetRepository(session)
scenario_repo = ScenarioRepository(session)
run = run_repo.get(run_id)
if not run:
raise ValueError(f"run not found: {run_id}")
target = target_repo.get(run.target_id)
scenario = scenario_repo.get(run.scenario_id)
turns = run_repo.get_turns(run_id)
results = run_repo.get_results(run_id)
# Group by case
case_map: dict[str, dict[str, Any]] = {}
for turn in turns:
case_map.setdefault(turn.case_id, {"turns": [], "results": []})
sent = turn.get_sent_message()
reply = turn.get_reply()
case_map[turn.case_id]["turns"].append(
{
"round": turn.round_index,
"sent_text": _extract_text(sent.get("msgBody")),
"reply_text": _extract_text(reply.get("msgBody") if reply else None),
"latency_ms": turn.latency_ms,
"question_msg_id": turn.question_msg_id,
}
)
for result in results:
case_map.setdefault(result.case_id, {"turns": [], "results": []})
case_map[result.case_id]["results"].append(
{
"rule_type": result.rule_type,
"passed": result.passed,
"score": result.score,
"reason": result.reason,
}
)
cases = []
for case_id in sorted(case_map.keys()):
item = case_map[case_id]
cases.append(
{
"case_id": case_id,
"turns": sorted(item["turns"], key=lambda x: x["round"]),
"results": item["results"],
}
)
summary = run.summary or {}
return {
"run_id": run.id,
"target_id": run.target_id,
"target_name": target.name if target else "未知",
"scenario_id": run.scenario_id,
"scenario_name": scenario.name if scenario else "未知",
"status": run.status.value,
"started_at": run.started_at.isoformat() if run.started_at else None,
"completed_at": run.completed_at.isoformat() if run.completed_at else None,
"summary": {
"total_cases": summary.get("total_cases", 0),
"passed_cases": summary.get("passed_cases", 0),
"failed_cases": summary.get("failed_cases", 0),
"total_rules": summary.get("total_rules", 0),
"passed_rules": summary.get("passed_rules", 0),
"pass_rate": summary.get("pass_rate", 0.0),
},
"cases": cases,
}
def generate_compare_report(run_id_1: str, run_id_2: str, session=None) -> dict[str, Any]:
"""Build a side-by-side comparison dict for two runs."""
report_a = generate_report(run_id_1, session)
report_b = generate_report(run_id_2, session)
def _summary_delta(key: str) -> float:
return report_b["summary"][key] - report_a["summary"][key]
# Case-level diff: match by case_id
cases_a = {c["case_id"]: c for c in report_a.get("cases", [])}
cases_b = {c["case_id"]: c for c in report_b.get("cases", [])}
all_case_ids = sorted(set(cases_a) | set(cases_b))
case_diffs = []
for cid in all_case_ids:
ca = cases_a.get(cid)
cb = cases_b.get(cid)
def _case_passed(c):
if not c:
return None
return all(r["passed"] for r in c.get("results", []))
case_diffs.append({
"case_id": cid,
"run_a_passed": _case_passed(ca),
"run_b_passed": _case_passed(cb),
"changed": _case_passed(ca) != _case_passed(cb),
"run_a_results": ca["results"] if ca else [],
"run_b_results": cb["results"] if cb else [],
})
return {
"run_a": {
"run_id": run_id_1,
"target_name": report_a.get("target_name"),
"scenario_name": report_a.get("scenario_name"),
"status": report_a.get("status"),
"started_at": report_a.get("started_at"),
"summary": report_a["summary"],
},
"run_b": {
"run_id": run_id_2,
"target_name": report_b.get("target_name"),
"scenario_name": report_b.get("scenario_name"),
"status": report_b.get("status"),
"started_at": report_b.get("started_at"),
"summary": report_b["summary"],
},
"delta": {
"pass_rate": round(_summary_delta("pass_rate"), 4),
"passed_cases": int(_summary_delta("passed_cases")),
"passed_rules": int(_summary_delta("passed_rules")),
},
"cases": case_diffs,
"changed_cases": sum(1 for c in case_diffs if c["changed"]),
}
def render_markdown_report(run_id: str, session=None) -> str:
"""Render a report as Markdown string."""
report = generate_report(run_id, session)
s = report["summary"]
lines: list[str] = [
f"# 评测报告 — {report.get('scenario_name', run_id)}",
"",
f"**评测对象**: {report.get('target_name', '-')} ",
f"**评测场景**: {report.get('scenario_name', '-')} ",
f"**状态**: {report.get('status', '-')} ",
f"**开始时间**: {report.get('started_at', '-')} ",
f"**完成时间**: {report.get('completed_at', '-')} ",
"",
"## 汇总",
"",
"| 指标 | 数值 |",
"|------|------|",
f"| 总用例数 | {s['total_cases']} |",
f"| 通过用例 | {s['passed_cases']} |",
f"| 失败用例 | {s['failed_cases']} |",
f"| 总规则数 | {s['total_rules']} |",
f"| 通过规则 | {s['passed_rules']} |",
f"| 通过率 | {s['pass_rate'] * 100:.1f}% |",
"",
"## 用例明细",
"",
]
for case in report.get("cases", []):
case_passed = all(r["passed"] for r in case.get("results", []))
badge = "" if case_passed else ""
lines.append(f"### {badge} 用例 `{case['case_id']}`")
lines.append("")
for turn in case.get("turns", []):
lines.append(f"**第 {turn['round']} 轮**")
lines.append("")
lines.append(f"> **用户**: {turn.get('sent_text', '')}")
lines.append("")
reply = turn.get("reply_text") or "(无回复)"
lines.append(f"> **智能体**: {reply}")
if turn.get("latency_ms") is not None:
lines.append(f"> *延迟: {turn['latency_ms']}ms*")
lines.append("")
if case.get("results"):
lines.append("**规则评估结果**")
lines.append("")
lines.append("| 规则 | 结果 | 评分 | 说明 |")
lines.append("|------|------|------|------|")
for r in case["results"]:
badge = "" if r["passed"] else ""
score = f"{r['score']:.2f}" if r.get("score") is not None else "-"
lines.append(f"| {r['rule_type']} | {badge} | {score} | {r.get('reason', '')} |")
lines.append("")
return "\n".join(lines)
def render_json_report(run_id: str, session=None) -> str:
"""Render a report as JSON string."""
report = generate_report(run_id, session)
return json.dumps(report, ensure_ascii=False, indent=2)
def render_html_report(run_id: str, session=None) -> str:
"""Render a report as HTML string."""
report = generate_report(run_id, session)
template = Template(HTML_TEMPLATE)
return template.render(report=report)
def save_report(run_id: str, fmt: str = "html", output_dir: Optional[Path] = None) -> Path:
"""Generate and save a report to disk."""
output_dir = output_dir or DATA_DIR / "reports"
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
if fmt == "html":
content = render_html_report(run_id)
path = output_dir / f"report_{run_id}_{timestamp}.html"
elif fmt == "json":
content = render_json_report(run_id)
path = output_dir / f"report_{run_id}_{timestamp}.json"
elif fmt == "markdown":
content = render_markdown_report(run_id)
path = output_dir / f"report_{run_id}_{timestamp}.md"
else:
raise ValueError(f"unsupported report format: {fmt}")
path.write_text(content, encoding="utf-8")
return path