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:
parent
c7f1dca49d
commit
349200e51f
@ -27,3 +27,10 @@ AGENTEVAL_OPENCLAW_AUTH_TOKEN=change-me-in-production
|
||||
# ── Frontend ───────────────────────────────────────────────────
|
||||
# Optional override of the built frontend dist path (default: 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=
|
||||
|
||||
@ -65,6 +65,16 @@ class Settings(BaseSettings):
|
||||
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")
|
||||
def _derive_openclaw_ws_origin(self) -> "Settings":
|
||||
"""Auto-derive openclaw_ws_origin from allowed_origins.
|
||||
|
||||
@ -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:
|
||||
"""Render a report as JSON string."""
|
||||
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":
|
||||
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}")
|
||||
|
||||
|
||||
40
backend/agenteval/utils/webhook.py
Normal file
40
backend/agenteval/utils/webhook.py
Normal 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)
|
||||
@ -1,15 +1,35 @@
|
||||
"""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 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.web.deps import get_db
|
||||
|
||||
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}")
|
||||
def get_report(run_id: str, session: Session = Depends(get_db)) -> dict:
|
||||
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")
|
||||
json_text = render_json_report(run_id, session)
|
||||
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"'},
|
||||
)
|
||||
|
||||
@ -12,6 +12,7 @@ from agenteval.models import EvalRun, RunStatus
|
||||
from agenteval.storage.db import get_session
|
||||
from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository
|
||||
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.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),
|
||||
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:
|
||||
session.close()
|
||||
_cancel_tokens.pop(run_id, None)
|
||||
|
||||
@ -1,96 +1,116 @@
|
||||
"""
|
||||
OpenClaw Skill example for AgentEvalTool.
|
||||
"""OpenClaw Skill for AgentEvalTool — HTTP API mode.
|
||||
|
||||
This skill demonstrates how OpenClaw can call the AgentEvalTool CLI
|
||||
to execute an evaluation run and fetch the report.
|
||||
Triggers an evaluation run via the AgentEvalTool REST API, polls until
|
||||
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:
|
||||
- target_id: ID of the registered evaluation target
|
||||
- scenario_id: ID of the evaluation scenario
|
||||
- report_format: "json" or "html" (default "json")
|
||||
|
||||
The skill assumes that the `agenteval` CLI is available on the system PATH.
|
||||
Configuration (passed via OpenClaw skill params):
|
||||
api_base_url Base URL of AgentEvalTool (e.g. http://192.168.8.145:8001)
|
||||
api_key X-API-Key header value (if auth is enabled, else omit)
|
||||
target_id ID of the registered evaluation target
|
||||
scenario_id ID of the evaluation scenario
|
||||
poll_interval Seconds between status polls (default: 3)
|
||||
timeout Max seconds to wait for completion (default: 300)
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class AgentEvalSkill:
|
||||
"""OpenClaw skill wrapper for AgentEvalTool."""
|
||||
"""OpenClaw skill: trigger evaluation and return a structured summary."""
|
||||
|
||||
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]:
|
||||
target_id = self.config["target_id"]
|
||||
scenario_id = self.config["scenario_id"]
|
||||
report_format = self.config.get("report_format", "json")
|
||||
with httpx.Client(base_url=self.api_base, headers=self._headers(), timeout=30) as client:
|
||||
# 1. Start the run
|
||||
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_cmd = [
|
||||
"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,
|
||||
}
|
||||
run = resp.json()
|
||||
run_id: str = run["id"]
|
||||
|
||||
# Extract run_id from CLI output (last line contains "run_id=xxx")
|
||||
run_id = None
|
||||
for line in reversed(run_result.stdout.strip().splitlines()):
|
||||
if "run_id=" in line:
|
||||
run_id = line.split("run_id=")[-1].strip().split()[0]
|
||||
# 2. Poll until completion or timeout
|
||||
deadline = time.time() + self.timeout
|
||||
while time.time() < deadline:
|
||||
time.sleep(self.poll_interval)
|
||||
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
|
||||
|
||||
if not run_id:
|
||||
else:
|
||||
return {
|
||||
"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,
|
||||
"error": f"评测超时(>{self.timeout}s 未完成)",
|
||||
}
|
||||
|
||||
report_data = report_result.stdout
|
||||
if report_format == "json":
|
||||
try:
|
||||
report_data = json.loads(report_data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# 3. Fetch structured report
|
||||
report_resp = client.get(f"/api/reports/{run_id}")
|
||||
report = report_resp.json() if report_resp.status_code == 200 else {}
|
||||
|
||||
return self._format_result(run, report)
|
||||
|
||||
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 {
|
||||
"ok": True,
|
||||
"run_id": run_id,
|
||||
"report": report_data,
|
||||
"ok": status == "completed",
|
||||
"run_id": run.get("id"),
|
||||
"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]:
|
||||
"""OpenClaw skill entrypoint."""
|
||||
return AgentEvalSkill(config).run()
|
||||
|
||||
@ -138,6 +138,9 @@ export const reportsApi = {
|
||||
get: (runId: string) => api.get(`/reports/${runId}`),
|
||||
html: (runId: string) => api.get(`/reports/${runId}/html`),
|
||||
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 = {
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
Button, Card, Col, Collapse, Descriptions, Empty, Row,
|
||||
Select, Space, Spin, Statistic, Table, Tag, Tooltip,
|
||||
Button, Card, Col, Collapse, Descriptions, Empty, Row, Segmented,
|
||||
Select, Space, Spin, Statistic, Table, Tag, Tooltip, Badge,
|
||||
} from 'antd'
|
||||
import {
|
||||
CheckCircleOutlined, CloseCircleOutlined,
|
||||
DownloadOutlined, UserOutlined, RobotOutlined, ReloadOutlined,
|
||||
DiffOutlined, FileMarkdownOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { reportsApi, runsApi, type Run } from '../api'
|
||||
import { colors } from '../tokens'
|
||||
@ -32,6 +33,7 @@ interface CaseReport {
|
||||
}
|
||||
|
||||
interface Report {
|
||||
run_id: string
|
||||
target_name: string
|
||||
scenario_name: string
|
||||
status: string
|
||||
@ -48,17 +50,38 @@ interface Report {
|
||||
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() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const runQuery = searchParams.get('run') ?? ''
|
||||
|
||||
const [runs, setRuns] = useState<Run[]>([])
|
||||
const [selectedRunId, setSelectedRunId] = useState<string>('')
|
||||
const [compareRunId, setCompareRunId] = useState<string>('')
|
||||
const [report, setReport] = useState<Report | null>(null)
|
||||
const [compareResult, setCompareResult] = useState<CompareResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('single')
|
||||
|
||||
const loadReport = async (runId: string) => {
|
||||
setSelectedRunId(runId)
|
||||
setCompareResult(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await reportsApi.get(runId)
|
||||
@ -93,10 +116,21 @@ export default function ReportsPage() {
|
||||
loadReport(runId)
|
||||
}
|
||||
|
||||
const handleExportHtml = () => {
|
||||
if (!selectedRunId) return
|
||||
window.open(`/api/reports/${selectedRunId}/html`, '_blank')
|
||||
const handleCompare = async () => {
|
||||
if (!selectedRunId || !compareRunId) return
|
||||
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 (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
@ -115,26 +149,63 @@ export default function ReportsPage() {
|
||||
padding: '8px 16px', flexShrink: 0,
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
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
|
||||
style={{ width: 400 }}
|
||||
placeholder="选择已完成的评测记录"
|
||||
style={{ width: 340 }}
|
||||
placeholder={viewMode === 'compare' ? '选择报告 A' : '选择已完成的评测记录'}
|
||||
value={selectedRunId || undefined}
|
||||
onChange={handleView}
|
||||
showSearch
|
||||
filterOption={(input, opt) =>
|
||||
(opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())}
|
||||
options={runs.map((r) => ({
|
||||
value: r.id,
|
||||
label: `${r.id.slice(0, 8)}... | ${r.started_at ? formatDateTime(r.started_at) : ''}`,
|
||||
}))}
|
||||
options={runSelectOptions}
|
||||
/>
|
||||
{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
|
||||
</Button>
|
||||
<Button icon={<FileMarkdownOutlined />} onClick={() => window.open(reportsApi.markdownUrl(selectedRunId), '_blank')}>
|
||||
导出 MD
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<Tooltip title="刷新列表">
|
||||
<Button size="middle" icon={<ReloadOutlined />} onClick={loadRuns} />
|
||||
@ -142,10 +213,31 @@ export default function ReportsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 报告内容 — 可滚动 */}
|
||||
{/* 报告内容区 */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '16px' }}>
|
||||
<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 }}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
@ -153,33 +245,20 @@ export default function ReportsPage() {
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="通过用例"
|
||||
value={report.summary.passed_cases}
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
/>
|
||||
<Statistic title="通过用例" value={report.summary.passed_cases}
|
||||
valueStyle={{ color: '#3f8600' }} prefix={<CheckCircleOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="失败用例"
|
||||
value={report.summary.failed_cases}
|
||||
valueStyle={{ color: '#cf1322' }}
|
||||
prefix={<CloseCircleOutlined />}
|
||||
/>
|
||||
<Statistic title="失败用例" value={report.summary.failed_cases}
|
||||
valueStyle={{ color: '#cf1322' }} prefix={<CloseCircleOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="通过率"
|
||||
value={report.summary.pass_rate * 100}
|
||||
precision={1}
|
||||
suffix="%"
|
||||
valueStyle={{ color: report.summary.pass_rate >= 0.8 ? '#3f8600' : '#cf1322' }}
|
||||
/>
|
||||
<Statistic title="通过率" value={report.summary.pass_rate * 100} precision={1} suffix="%"
|
||||
valueStyle={{ color: report.summary.pass_rate >= 0.8 ? '#3f8600' : '#cf1322' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
@ -188,31 +267,32 @@ export default function ReportsPage() {
|
||||
<Descriptions size="small" column={2}>
|
||||
<Descriptions.Item label="评测对象">{report.target_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="评测场景">{report.scenario_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="开始时间">
|
||||
{report.started_at ? formatDateTime(report.started_at) : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="完成时间">
|
||||
{report.completed_at ? formatDateTime(report.completed_at) : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开始时间">{formatDateTime(report.started_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="完成时间">{report.completed_at ? formatDateTime(report.completed_at) : '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="用例明细">
|
||||
<Collapse
|
||||
items={report.cases.map((c) => ({
|
||||
<Collapse items={report.cases.map((c) => ({
|
||||
key: c.case_id,
|
||||
label: (
|
||||
<Space>
|
||||
<span style={{ fontWeight: 500 }}>{c.case_id}</span>
|
||||
{c.results.every((r) => r.passed) ? (
|
||||
<Tag color="success">全部通过</Tag>
|
||||
) : (
|
||||
<Tag color="error">存在失败</Tag>
|
||||
)}
|
||||
{c.results.every((r) => r.passed)
|
||||
? <Tag color="success">全部通过</Tag>
|
||||
: <Tag color="error">存在失败</Tag>}
|
||||
<Tag>{c.turns.length} 轮对话</Tag>
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
children: <CaseDetail c={c} />,
|
||||
}))} />
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CaseDetail({ c }: { c: CaseReport }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{c.turns.map((turn, idx) => (
|
||||
@ -236,46 +316,139 @@ export default function ReportsPage() {
|
||||
<RobotOutlined style={{ marginRight: 6, color: '#52c41a' }} />
|
||||
{turn.reply_text || '(无回复)'}
|
||||
{turn.latency_ms != null && (
|
||||
<span style={{ color: '#999', fontSize: 11, marginLeft: 8 }}>
|
||||
{turn.latency_ms}ms
|
||||
</span>
|
||||
<span style={{ color: '#999', fontSize: 11, marginLeft: 8 }}>{turn.latency_ms}ms</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={c.results}
|
||||
rowKey={(_, idx) => String(idx)}
|
||||
<Table size="small" pagination={false} dataSource={c.results} rowKey={(_, idx) => String(idx)}
|
||||
columns={[
|
||||
{ title: '规则', dataIndex: 'rule_type', width: 150 },
|
||||
{ title: '规则', dataIndex: 'rule_type', width: 160 },
|
||||
{ title: '结果', dataIndex: 'passed', width: 80,
|
||||
render: (p: boolean) => p
|
||||
? <Tag color="success">通过</Tag>
|
||||
: <Tag color="error">失败</Tag>,
|
||||
},
|
||||
render: (p: boolean) => p ? <Tag color="success">通过</Tag> : <Tag color="error">失败</Tag> },
|
||||
{ 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' },
|
||||
]}
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}>
|
||||
<Empty description="请选择一个评测记录查看报告" />
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user