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 ───────────────────────────────────────────────────
|
# ── 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=
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|
||||||
|
|||||||
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."""
|
"""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"'},
|
||||||
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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>
|
||||||
),
|
)
|
||||||
}))}
|
}
|
||||||
/>
|
|
||||||
</Card>
|
// ── Compare View ─────────────────────────────────────────────────────────
|
||||||
</>
|
|
||||||
) : (
|
function CompareView({ result }: { result: CompareResult | null }) {
|
||||||
|
if (!result) {
|
||||||
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}>
|
||||||
<Empty description="请选择一个评测记录查看报告" />
|
<Empty description="选择两次评测记录后点击「对比」" />
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Spin>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user