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>
This commit is contained in:
sinohqb 2026-07-17 11:44:54 +08:00
parent c7f1dca49d
commit 349200e51f
9 changed files with 638 additions and 222 deletions

View File

@ -27,3 +27,10 @@ AGENTEVAL_OPENCLAW_AUTH_TOKEN=change-me-in-production
# ── Frontend ─────────────────────────────────────────────────── # ── Frontend ───────────────────────────────────────────────────
# Optional override of the built frontend dist path (default: frontend/web/dist). # Optional override of the built frontend dist path (default: frontend/web/dist).
# AGENTEVAL_FRONTEND_DIST_PATH=/app/frontend/web/dist # AGENTEVAL_FRONTEND_DIST_PATH=/app/frontend/web/dist
# ── Webhook ────────────────────────────────────────────────────
# If set, AgentEvalTool will POST run completion summaries to this URL.
# Example: AGENTEVAL_WEBHOOK_URL=https://your-service.example.com/webhooks/agenteval
AGENTEVAL_WEBHOOK_URL=
# Optional shared secret included as X-Webhook-Secret header for verification.
AGENTEVAL_WEBHOOK_SECRET=

View File

@ -65,6 +65,16 @@ class Settings(BaseSettings):
description="Comma-separated list of allowed file extensions for upload.", description="Comma-separated list of allowed file extensions for upload.",
) )
# ── Webhook ────────────────────────────────────────────────────
webhook_url: Optional[str] = Field(
default=None,
description="If set, POST run completion summaries to this URL.",
)
webhook_secret: Optional[str] = Field(
default=None,
description="If set, included as X-Webhook-Secret header for verification.",
)
@model_validator(mode="after") @model_validator(mode="after")
def _derive_openclaw_ws_origin(self) -> "Settings": def _derive_openclaw_ws_origin(self) -> "Settings":
"""Auto-derive openclaw_ws_origin from allowed_origins. """Auto-derive openclaw_ws_origin from allowed_origins.

View File

@ -168,6 +168,124 @@ def generate_report(run_id: str, session=None) -> dict[str, Any]:
} }
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: def render_json_report(run_id: str, session=None) -> str:
"""Render a report as JSON string.""" """Render a report as JSON string."""
report = generate_report(run_id, session) report = generate_report(run_id, session)
@ -193,6 +311,9 @@ def save_report(run_id: str, fmt: str = "html", output_dir: Optional[Path] = Non
elif fmt == "json": elif fmt == "json":
content = render_json_report(run_id) content = render_json_report(run_id)
path = output_dir / f"report_{run_id}_{timestamp}.json" 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: else:
raise ValueError(f"unsupported report format: {fmt}") raise ValueError(f"unsupported report format: {fmt}")

View File

@ -0,0 +1,40 @@
"""Webhook notification for run completion events."""
import logging
from typing import Any
import httpx
from agenteval.config import get_settings
logger = logging.getLogger(__name__)
async def send_run_webhook(run_id: str, status: str, summary: dict[str, Any]) -> None:
"""POST run completion payload to the configured webhook URL.
Silently logs and returns on any error webhook failure must never
affect the run itself.
"""
settings = get_settings()
if not settings.webhook_url:
return
payload = {
"event": "run_completed",
"run_id": run_id,
"status": status,
"summary": summary,
"report_url": f"/api/reports/{run_id}",
}
headers = {"Content-Type": "application/json"}
if settings.webhook_secret:
headers["X-Webhook-Secret"] = settings.webhook_secret
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(settings.webhook_url, json=payload, headers=headers)
logger.info("webhook sent: run=%s status=%s http=%d", run_id, status, resp.status_code)
except Exception as exc:
logger.warning("webhook failed (non-fatal): run=%s error=%s", run_id, exc)

View File

@ -1,15 +1,35 @@
"""API routes for evaluation reports.""" """API routes for evaluation reports."""
from fastapi import APIRouter, Depends, HTTPException, Response from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlmodel import Session from sqlmodel import Session
from agenteval.evaluation.report import generate_report, render_html_report, render_json_report from agenteval.evaluation.report import (
generate_compare_report,
generate_report,
render_html_report,
render_json_report,
render_markdown_report,
)
from agenteval.storage.repository import RunRepository from agenteval.storage.repository import RunRepository
from agenteval.web.deps import get_db from agenteval.web.deps import get_db
router = APIRouter() router = APIRouter()
@router.get("/compare")
def get_compare_report(
run1: str = Query(..., description="First run ID"),
run2: str = Query(..., description="Second run ID"),
session: Session = Depends(get_db),
) -> dict:
repo = RunRepository(session)
if not repo.get(run1):
raise HTTPException(status_code=404, detail=f"run not found: {run1}")
if not repo.get(run2):
raise HTTPException(status_code=404, detail=f"run not found: {run2}")
return generate_compare_report(run1, run2, session)
@router.get("/{run_id}") @router.get("/{run_id}")
def get_report(run_id: str, session: Session = Depends(get_db)) -> dict: def get_report(run_id: str, session: Session = Depends(get_db)) -> dict:
run = RunRepository(session).get(run_id) run = RunRepository(session).get(run_id)
@ -34,3 +54,16 @@ def get_json_report(run_id: str, session: Session = Depends(get_db)) -> Response
raise HTTPException(status_code=404, detail="run not found") raise HTTPException(status_code=404, detail="run not found")
json_text = render_json_report(run_id, session) json_text = render_json_report(run_id, session)
return Response(content=json_text, media_type="application/json") return Response(content=json_text, media_type="application/json")
@router.get("/{run_id}/markdown")
def get_markdown_report(run_id: str, session: Session = Depends(get_db)) -> Response:
run = RunRepository(session).get(run_id)
if not run:
raise HTTPException(status_code=404, detail="run not found")
md = render_markdown_report(run_id, session)
return Response(
content=md,
media_type="text/markdown; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="report-{run_id}.md"'},
)

View File

@ -12,6 +12,7 @@ from agenteval.models import EvalRun, RunStatus
from agenteval.storage.db import get_session from agenteval.storage.db import get_session
from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository
from agenteval.utils.llm import extract_reply_text from agenteval.utils.llm import extract_reply_text
from agenteval.utils.webhook import send_run_webhook
from agenteval.web.deps import get_db from agenteval.web.deps import get_db
from agenteval.web.websocket import ws_manager from agenteval.web.websocket import ws_manager
@ -52,6 +53,14 @@ async def _run_evaluation(run_id: str, target_id: str, scenario_id: str) -> None
progress_callback=lambda event, data: ws_manager.emit(run_id, event, data), progress_callback=lambda event, data: ws_manager.emit(run_id, event, data),
existing_run=existing_run, existing_run=existing_run,
) )
# Fire webhook after run completes (non-blocking, best-effort)
completed_run = RunRepository(session).get(run_id)
if completed_run:
await send_run_webhook(
run_id=run_id,
status=completed_run.status.value,
summary=completed_run.summary or {},
)
finally: finally:
session.close() session.close()
_cancel_tokens.pop(run_id, None) _cancel_tokens.pop(run_id, None)

View File

@ -1,96 +1,116 @@
""" """OpenClaw Skill for AgentEvalTool — HTTP API mode.
OpenClaw Skill example for AgentEvalTool.
This skill demonstrates how OpenClaw can call the AgentEvalTool CLI Triggers an evaluation run via the AgentEvalTool REST API, polls until
to execute an evaluation run and fetch the report. completion, and returns a structured Chinese-language summary suitable for
display in an OpenClaw conversation.
In OpenClaw, register this as a skill and configure the following parameters: Configuration (passed via OpenClaw skill params):
- target_id: ID of the registered evaluation target api_base_url Base URL of AgentEvalTool (e.g. http://192.168.8.145:8001)
- scenario_id: ID of the evaluation scenario api_key X-API-Key header value (if auth is enabled, else omit)
- report_format: "json" or "html" (default "json") target_id ID of the registered evaluation target
scenario_id ID of the evaluation scenario
The skill assumes that the `agenteval` CLI is available on the system PATH. poll_interval Seconds between status polls (default: 3)
timeout Max seconds to wait for completion (default: 300)
""" """
import json import time
import subprocess
from typing import Any from typing import Any
import httpx
class AgentEvalSkill: class AgentEvalSkill:
"""OpenClaw skill wrapper for AgentEvalTool.""" """OpenClaw skill: trigger evaluation and return a structured summary."""
def __init__(self, config: dict[str, Any]): def __init__(self, config: dict[str, Any]):
self.config = config self.api_base = config["api_base_url"].rstrip("/")
self.api_key = config.get("api_key")
self.target_id = config["target_id"]
self.scenario_id = config["scenario_id"]
self.poll_interval = float(config.get("poll_interval", 3))
self.timeout = float(config.get("timeout", 300))
def _headers(self) -> dict[str, str]:
h = {"Content-Type": "application/json"}
if self.api_key:
h["X-API-Key"] = self.api_key
return h
def run(self) -> dict[str, Any]: def run(self) -> dict[str, Any]:
target_id = self.config["target_id"] with httpx.Client(base_url=self.api_base, headers=self._headers(), timeout=30) as client:
scenario_id = self.config["scenario_id"] # 1. Start the run
report_format = self.config.get("report_format", "json") resp = client.post("/api/runs", json={
"target_id": self.target_id,
"scenario_id": self.scenario_id,
})
if resp.status_code != 200:
return {"ok": False, "error": f"启动评测失败: HTTP {resp.status_code} {resp.text}"}
# 1. Trigger evaluation run run = resp.json()
run_cmd = [ run_id: str = run["id"]
"agenteval",
"run",
"start",
"--target-id",
target_id,
"--scenario-id",
scenario_id,
]
run_result = subprocess.run(run_cmd, capture_output=True, text=True, check=False)
if run_result.returncode != 0:
return {
"ok": False,
"error": f"evaluation run failed: {run_result.stderr}",
"stdout": run_result.stdout,
}
# Extract run_id from CLI output (last line contains "run_id=xxx") # 2. Poll until completion or timeout
run_id = None deadline = time.time() + self.timeout
for line in reversed(run_result.stdout.strip().splitlines()): while time.time() < deadline:
if "run_id=" in line: time.sleep(self.poll_interval)
run_id = line.split("run_id=")[-1].strip().split()[0] status_resp = client.get(f"/api/runs/{run_id}")
if status_resp.status_code != 200:
continue
run = status_resp.json()
if run.get("status") in ("completed", "failed"):
break break
else:
if not run_id:
return { return {
"ok": False, "ok": False,
"error": "could not extract run_id from CLI output",
"stdout": run_result.stdout,
}
# 2. Fetch report
report_cmd = [
"agenteval",
"report",
"show",
run_id,
"--format",
report_format,
]
report_result = subprocess.run(report_cmd, capture_output=True, text=True, check=False)
if report_result.returncode != 0:
return {
"ok": False,
"error": f"report fetch failed: {report_result.stderr}",
"run_id": run_id, "run_id": run_id,
"error": f"评测超时(>{self.timeout}s 未完成)",
} }
report_data = report_result.stdout # 3. Fetch structured report
if report_format == "json": report_resp = client.get(f"/api/reports/{run_id}")
try: report = report_resp.json() if report_resp.status_code == 200 else {}
report_data = json.loads(report_data)
except json.JSONDecodeError: return self._format_result(run, report)
pass
def _format_result(self, run: dict[str, Any], report: dict[str, Any]) -> dict[str, Any]:
status = run.get("status", "unknown")
summary = run.get("summary") or {}
pass_rate = summary.get("pass_rate", 0.0)
passed_cases = summary.get("passed_cases", 0)
total_cases = summary.get("total_cases", 0)
passed_rules = summary.get("passed_rules", 0)
total_rules = summary.get("total_rules", 0)
# Build Chinese natural-language summary for display in chat
if status == "completed":
verdict = "✅ 评测完成" if pass_rate >= 0.8 else "⚠️ 评测完成(有失败项)"
summary_text = (
f"{verdict}\n"
f"• 评测对象:{report.get('target_name', self.target_id)}\n"
f"• 评测场景:{report.get('scenario_name', self.scenario_id)}\n"
f"• 通过率:{pass_rate * 100:.1f}%\n"
f"• 用例:{passed_cases}/{total_cases} 通过\n"
f"• 规则:{passed_rules}/{total_rules} 通过\n"
f"• 报告链接:{self.api_base}/api/reports/{run['id']}"
)
else:
error_info = summary.get("error", {})
if isinstance(error_info, dict):
error_msg = error_info.get("message", str(error_info))
else:
error_msg = str(error_info)
summary_text = f"❌ 评测失败:{error_msg}"
return { return {
"ok": True, "ok": status == "completed",
"run_id": run_id, "run_id": run.get("id"),
"report": report_data, "status": status,
"pass_rate": pass_rate,
"summary_text": summary_text,
"report": report,
} }
# Example entrypoint for OpenClaw runtime.
def execute(config: dict[str, Any]) -> dict[str, Any]: def execute(config: dict[str, Any]) -> dict[str, Any]:
"""OpenClaw skill entrypoint."""
return AgentEvalSkill(config).run() return AgentEvalSkill(config).run()

View File

@ -138,6 +138,9 @@ export const reportsApi = {
get: (runId: string) => api.get(`/reports/${runId}`), get: (runId: string) => api.get(`/reports/${runId}`),
html: (runId: string) => api.get(`/reports/${runId}/html`), html: (runId: string) => api.get(`/reports/${runId}/html`),
json: (runId: string) => api.get(`/reports/${runId}/json`), json: (runId: string) => api.get(`/reports/${runId}/json`),
markdownUrl: (runId: string) => `/api/reports/${runId}/markdown`,
compare: (run1: string, run2: string) =>
api.get(`/reports/compare`, { params: { run1, run2 } }),
} }
export const statsApi = { export const statsApi = {

View File

@ -1,12 +1,13 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { import {
Button, Card, Col, Collapse, Descriptions, Empty, Row, Button, Card, Col, Collapse, Descriptions, Empty, Row, Segmented,
Select, Space, Spin, Statistic, Table, Tag, Tooltip, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Badge,
} from 'antd' } from 'antd'
import { import {
CheckCircleOutlined, CloseCircleOutlined, CheckCircleOutlined, CloseCircleOutlined,
DownloadOutlined, UserOutlined, RobotOutlined, ReloadOutlined, DownloadOutlined, UserOutlined, RobotOutlined, ReloadOutlined,
DiffOutlined, FileMarkdownOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { reportsApi, runsApi, type Run } from '../api' import { reportsApi, runsApi, type Run } from '../api'
import { colors } from '../tokens' import { colors } from '../tokens'
@ -32,6 +33,7 @@ interface CaseReport {
} }
interface Report { interface Report {
run_id: string
target_name: string target_name: string
scenario_name: string scenario_name: string
status: string status: string
@ -48,17 +50,38 @@ interface Report {
cases: CaseReport[] cases: CaseReport[]
} }
interface CompareResult {
run_a: { run_id: string; target_name: string; scenario_name: string; status: string; started_at: string; summary: Report['summary'] }
run_b: { run_id: string; target_name: string; scenario_name: string; status: string; started_at: string; summary: Report['summary'] }
delta: { pass_rate: number; passed_cases: number; passed_rules: number }
cases: Array<{
case_id: string
run_a_passed: boolean | null
run_b_passed: boolean | null
changed: boolean
run_a_results: RuleResultData[]
run_b_results: RuleResultData[]
}>
changed_cases: number
}
type ViewMode = 'single' | 'compare'
export default function ReportsPage() { export default function ReportsPage() {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const runQuery = searchParams.get('run') ?? '' const runQuery = searchParams.get('run') ?? ''
const [runs, setRuns] = useState<Run[]>([]) const [runs, setRuns] = useState<Run[]>([])
const [selectedRunId, setSelectedRunId] = useState<string>('') const [selectedRunId, setSelectedRunId] = useState<string>('')
const [compareRunId, setCompareRunId] = useState<string>('')
const [report, setReport] = useState<Report | null>(null) const [report, setReport] = useState<Report | null>(null)
const [compareResult, setCompareResult] = useState<CompareResult | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('single')
const loadReport = async (runId: string) => { const loadReport = async (runId: string) => {
setSelectedRunId(runId) setSelectedRunId(runId)
setCompareResult(null)
setLoading(true) setLoading(true)
try { try {
const res = await reportsApi.get(runId) const res = await reportsApi.get(runId)
@ -93,10 +116,21 @@ export default function ReportsPage() {
loadReport(runId) loadReport(runId)
} }
const handleExportHtml = () => { const handleCompare = async () => {
if (!selectedRunId) return if (!selectedRunId || !compareRunId) return
window.open(`/api/reports/${selectedRunId}/html`, '_blank') setLoading(true)
try {
const res = await reportsApi.compare(selectedRunId, compareRunId)
setCompareResult(res.data as CompareResult)
} finally {
setLoading(false)
} }
}
const runSelectOptions = runs.map((r) => ({
value: r.id,
label: `${r.id.slice(0, 8)}… | ${r.started_at ? formatDateTime(r.started_at) : ''}`,
}))
return ( return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
@ -115,26 +149,63 @@ export default function ReportsPage() {
padding: '8px 16px', flexShrink: 0, padding: '8px 16px', flexShrink: 0,
borderBottom: `1px solid ${colors.border}`, borderBottom: `1px solid ${colors.border}`,
background: colors.bgSubtle, background: colors.bgSubtle,
display: 'flex', alignItems: 'center', gap: 12, display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
}}> }}>
<Segmented
value={viewMode}
onChange={(v) => { setViewMode(v as ViewMode); setCompareResult(null) }}
options={[
{ label: '单次报告', value: 'single' },
{ label: '对比报告', value: 'compare', icon: <DiffOutlined /> },
]}
/>
<Select <Select
style={{ width: 400 }} style={{ width: 340 }}
placeholder="选择已完成的评测记录" placeholder={viewMode === 'compare' ? '选择报告 A' : '选择已完成的评测记录'}
value={selectedRunId || undefined} value={selectedRunId || undefined}
onChange={handleView} onChange={handleView}
showSearch showSearch
filterOption={(input, opt) => filterOption={(input, opt) =>
(opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())} (opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())}
options={runs.map((r) => ({ options={runSelectOptions}
value: r.id,
label: `${r.id.slice(0, 8)}... | ${r.started_at ? formatDateTime(r.started_at) : ''}`,
}))}
/> />
{report && (
<Button icon={<DownloadOutlined />} onClick={handleExportHtml}> {viewMode === 'compare' && (
<>
<span style={{ color: colors.textMuted, fontSize: 12 }}>vs</span>
<Select
style={{ width: 340 }}
placeholder="选择报告 B"
value={compareRunId || undefined}
onChange={setCompareRunId}
showSearch
filterOption={(input, opt) =>
(opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())}
options={runSelectOptions.filter((o) => o.value !== selectedRunId)}
/>
<Button
type="primary"
icon={<DiffOutlined />}
disabled={!selectedRunId || !compareRunId}
onClick={handleCompare}
>
</Button>
</>
)}
{viewMode === 'single' && report && (
<Space>
<Button icon={<DownloadOutlined />} onClick={() => window.open(`/api/reports/${selectedRunId}/html`, '_blank')}>
HTML HTML
</Button> </Button>
<Button icon={<FileMarkdownOutlined />} onClick={() => window.open(reportsApi.markdownUrl(selectedRunId), '_blank')}>
MD
</Button>
</Space>
)} )}
<div style={{ marginLeft: 'auto' }}> <div style={{ marginLeft: 'auto' }}>
<Tooltip title="刷新列表"> <Tooltip title="刷新列表">
<Button size="middle" icon={<ReloadOutlined />} onClick={loadRuns} /> <Button size="middle" icon={<ReloadOutlined />} onClick={loadRuns} />
@ -142,10 +213,31 @@ export default function ReportsPage() {
</div> </div>
</div> </div>
{/* 报告内容 — 可滚动 */} {/* 报告内容 */}
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '16px' }}> <div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '16px' }}>
<Spin spinning={loading}> <Spin spinning={loading}>
{report ? ( {viewMode === 'compare'
? <CompareView result={compareResult} />
: <SingleReportView report={report} />
}
</Spin>
</div>
</div>
)
}
// ── Single Report View ───────────────────────────────────────────────────
function SingleReportView({ report }: { report: Report | null }) {
if (!report) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}>
<Empty description="请选择一个评测记录查看报告" />
</div>
)
}
return (
<> <>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}> <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
@ -153,33 +245,20 @@ export default function ReportsPage() {
</Col> </Col>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
<Card> <Card>
<Statistic <Statistic title="通过用例" value={report.summary.passed_cases}
title="通过用例" valueStyle={{ color: '#3f8600' }} prefix={<CheckCircleOutlined />} />
value={report.summary.passed_cases}
valueStyle={{ color: '#3f8600' }}
prefix={<CheckCircleOutlined />}
/>
</Card> </Card>
</Col> </Col>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
<Card> <Card>
<Statistic <Statistic title="失败用例" value={report.summary.failed_cases}
title="失败用例" valueStyle={{ color: '#cf1322' }} prefix={<CloseCircleOutlined />} />
value={report.summary.failed_cases}
valueStyle={{ color: '#cf1322' }}
prefix={<CloseCircleOutlined />}
/>
</Card> </Card>
</Col> </Col>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
<Card> <Card>
<Statistic <Statistic title="通过率" value={report.summary.pass_rate * 100} precision={1} suffix="%"
title="通过率" valueStyle={{ color: report.summary.pass_rate >= 0.8 ? '#3f8600' : '#cf1322' }} />
value={report.summary.pass_rate * 100}
precision={1}
suffix="%"
valueStyle={{ color: report.summary.pass_rate >= 0.8 ? '#3f8600' : '#cf1322' }}
/>
</Card> </Card>
</Col> </Col>
</Row> </Row>
@ -188,31 +267,32 @@ export default function ReportsPage() {
<Descriptions size="small" column={2}> <Descriptions size="small" column={2}>
<Descriptions.Item label="评测对象">{report.target_name}</Descriptions.Item> <Descriptions.Item label="评测对象">{report.target_name}</Descriptions.Item>
<Descriptions.Item label="评测场景">{report.scenario_name}</Descriptions.Item> <Descriptions.Item label="评测场景">{report.scenario_name}</Descriptions.Item>
<Descriptions.Item label="开始时间"> <Descriptions.Item label="开始时间">{formatDateTime(report.started_at)}</Descriptions.Item>
{report.started_at ? formatDateTime(report.started_at) : '-'} <Descriptions.Item label="完成时间">{report.completed_at ? formatDateTime(report.completed_at) : '-'}</Descriptions.Item>
</Descriptions.Item>
<Descriptions.Item label="完成时间">
{report.completed_at ? formatDateTime(report.completed_at) : '-'}
</Descriptions.Item>
</Descriptions> </Descriptions>
</Card> </Card>
<Card title="用例明细"> <Card title="用例明细">
<Collapse <Collapse items={report.cases.map((c) => ({
items={report.cases.map((c) => ({
key: c.case_id, key: c.case_id,
label: ( label: (
<Space> <Space>
<span style={{ fontWeight: 500 }}>{c.case_id}</span> <span style={{ fontWeight: 500 }}>{c.case_id}</span>
{c.results.every((r) => r.passed) ? ( {c.results.every((r) => r.passed)
<Tag color="success"></Tag> ? <Tag color="success"></Tag>
) : ( : <Tag color="error"></Tag>}
<Tag color="error"></Tag>
)}
<Tag>{c.turns.length} </Tag> <Tag>{c.turns.length} </Tag>
</Space> </Space>
), ),
children: ( children: <CaseDetail c={c} />,
}))} />
</Card>
</>
)
}
function CaseDetail({ c }: { c: CaseReport }) {
return (
<div> <div>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
{c.turns.map((turn, idx) => ( {c.turns.map((turn, idx) => (
@ -236,46 +316,139 @@ export default function ReportsPage() {
<RobotOutlined style={{ marginRight: 6, color: '#52c41a' }} /> <RobotOutlined style={{ marginRight: 6, color: '#52c41a' }} />
{turn.reply_text || '(无回复)'} {turn.reply_text || '(无回复)'}
{turn.latency_ms != null && ( {turn.latency_ms != null && (
<span style={{ color: '#999', fontSize: 11, marginLeft: 8 }}> <span style={{ color: '#999', fontSize: 11, marginLeft: 8 }}>{turn.latency_ms}ms</span>
{turn.latency_ms}ms
</span>
)} )}
</div> </div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
<Table <Table size="small" pagination={false} dataSource={c.results} rowKey={(_, idx) => String(idx)}
size="small"
pagination={false}
dataSource={c.results}
rowKey={(_, idx) => String(idx)}
columns={[ columns={[
{ title: '规则', dataIndex: 'rule_type', width: 150 }, { title: '规则', dataIndex: 'rule_type', width: 160 },
{ title: '结果', dataIndex: 'passed', width: 80, { title: '结果', dataIndex: 'passed', width: 80,
render: (p: boolean) => p render: (p: boolean) => p ? <Tag color="success"></Tag> : <Tag color="error"></Tag> },
? <Tag color="success"></Tag>
: <Tag color="error"></Tag>,
},
{ title: '评分', dataIndex: 'score', width: 80, { title: '评分', dataIndex: 'score', width: 80,
render: (s: number | null) => s != null ? s.toFixed(2) : '-', render: (s: number | null) => s != null ? s.toFixed(2) : '-' },
},
{ title: '说明', dataIndex: 'reason' }, { title: '说明', dataIndex: 'reason' },
]} ]}
/> />
</div> </div>
)
}
// ── Compare View ─────────────────────────────────────────────────────────
function CompareView({ result }: { result: CompareResult | null }) {
if (!result) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}>
<Empty description="选择两次评测记录后点击「对比」" />
</div>
)
}
const { run_a, run_b, delta, cases, changed_cases } = result
const deltaColor = (v: number) => v > 0 ? '#3f8600' : v < 0 ? '#cf1322' : colors.textMuted
const deltaSign = (v: number) => v > 0 ? `+${v}` : String(v)
return (
<>
{/* 汇总对比 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={11}>
<Card title={<span style={{ color: '#1677ff' }}> A {run_a.run_id.slice(0, 8)}</span>} size="small">
<Descriptions size="small" column={1}>
<Descriptions.Item label="场景">{run_a.scenario_name}</Descriptions.Item>
<Descriptions.Item label="时间">{formatDateTime(run_a.started_at)}</Descriptions.Item>
<Descriptions.Item label="通过率">{(run_a.summary.pass_rate * 100).toFixed(1)}%</Descriptions.Item>
<Descriptions.Item label="用例">{run_a.summary.passed_cases}/{run_a.summary.total_cases}</Descriptions.Item>
</Descriptions>
</Card>
</Col>
<Col span={2} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 11, color: colors.textMuted }}></div>
<div style={{ fontWeight: 700, color: deltaColor(delta.pass_rate), fontSize: 16 }}>
{deltaSign(Math.round(delta.pass_rate * 1000) / 10)}%
</div>
<Badge count={changed_cases} color={changed_cases > 0 ? 'orange' : 'green'}
title={`${changed_cases} 个用例结果变化`} />
</Col>
<Col span={11}>
<Card title={<span style={{ color: '#52c41a' }}> B {run_b.run_id.slice(0, 8)}</span>} size="small">
<Descriptions size="small" column={1}>
<Descriptions.Item label="场景">{run_b.scenario_name}</Descriptions.Item>
<Descriptions.Item label="时间">{formatDateTime(run_b.started_at)}</Descriptions.Item>
<Descriptions.Item label="通过率">{(run_b.summary.pass_rate * 100).toFixed(1)}%</Descriptions.Item>
<Descriptions.Item label="用例">{run_b.summary.passed_cases}/{run_b.summary.total_cases}</Descriptions.Item>
</Descriptions>
</Card>
</Col>
</Row>
{/* 用例对比表 */}
<Card title={`用例对比(${changed_cases} 个结果变化)`}>
<Table
size="small"
pagination={false}
dataSource={cases}
rowKey="case_id"
rowClassName={(r) => r.changed ? 'run-row' : ''}
columns={[
{ title: '用例', dataIndex: 'case_id', width: 180,
render: (id: string, r) => (
<Space>
{r.changed && <Badge dot color="orange" />}
<span style={{ fontWeight: r.changed ? 600 : 400 }}>{id}</span>
</Space>
), ),
}))} },
{ title: '报告 A', dataIndex: 'run_a_passed', width: 100,
render: (p: boolean | null) =>
p === null ? <Tag></Tag>
: p ? <Tag color="success"></Tag>
: <Tag color="error"></Tag>,
},
{ title: '报告 B', dataIndex: 'run_b_passed', width: 100,
render: (p: boolean | null) =>
p === null ? <Tag></Tag>
: p ? <Tag color="success"></Tag>
: <Tag color="error"></Tag>,
},
{ title: '变化', width: 80,
render: (_: any, r) => {
if (!r.changed) return <span style={{ color: colors.textMuted }}></span>
if (r.run_b_passed && !r.run_a_passed) return <Tag color="success"> </Tag>
if (!r.run_b_passed && r.run_a_passed) return <Tag color="error">退 </Tag>
return <Tag color="orange"></Tag>
},
},
]}
expandable={{
expandedRowRender: (r) => (
<Row gutter={16}>
<Col span={12}>
<div style={{ fontSize: 12, color: colors.textMuted, marginBottom: 4 }}> A </div>
{r.run_a_results.map((res, i) => (
<div key={i} style={{ fontSize: 12, marginBottom: 2 }}>
{res.passed ? '✅' : '❌'} {res.rule_type}: {res.reason}
</div>
))}
</Col>
<Col span={12}>
<div style={{ fontSize: 12, color: colors.textMuted, marginBottom: 4 }}> B </div>
{r.run_b_results.map((res, i) => (
<div key={i} style={{ fontSize: 12, marginBottom: 2 }}>
{res.passed ? '✅' : '❌'} {res.rule_type}: {res.reason}
</div>
))}
</Col>
</Row>
),
rowExpandable: (r) => r.run_a_results.length > 0 || r.run_b_results.length > 0,
}}
/> />
</Card> </Card>
</> </>
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}>
<Empty description="请选择一个评测记录查看报告" />
</div>
)}
</Spin>
</div>
</div>
) )
} }