diff --git a/.env.example b/.env.example index 3adda73..3a511b1 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/backend/agenteval/config/settings.py b/backend/agenteval/config/settings.py index 34103fa..b4e017d 100644 --- a/backend/agenteval/config/settings.py +++ b/backend/agenteval/config/settings.py @@ -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. diff --git a/backend/agenteval/evaluation/report.py b/backend/agenteval/evaluation/report.py index 7952116..c9b37e8 100644 --- a/backend/agenteval/evaluation/report.py +++ b/backend/agenteval/evaluation/report.py @@ -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}") diff --git a/backend/agenteval/utils/webhook.py b/backend/agenteval/utils/webhook.py new file mode 100644 index 0000000..55cbcd0 --- /dev/null +++ b/backend/agenteval/utils/webhook.py @@ -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) diff --git a/backend/agenteval/web/routers/reports.py b/backend/agenteval/web/routers/reports.py index 308b795..1c314f4 100644 --- a/backend/agenteval/web/routers/reports.py +++ b/backend/agenteval/web/routers/reports.py @@ -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"'}, + ) diff --git a/backend/agenteval/web/routers/runs.py b/backend/agenteval/web/routers/runs.py index f84da67..4a65123 100644 --- a/backend/agenteval/web/routers/runs.py +++ b/backend/agenteval/web/routers/runs.py @@ -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) diff --git a/backend/plugins/openclaw/agenteval_skill.py b/backend/plugins/openclaw/agenteval_skill.py index 609bc8e..ab89548 100644 --- a/backend/plugins/openclaw/agenteval_skill.py +++ b/backend/plugins/openclaw/agenteval_skill.py @@ -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] - break + # 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 + else: + return { + "ok": False, + "run_id": run_id, + "error": f"评测超时(>{self.timeout}s 未完成)", + } - if not run_id: - return { - "ok": False, - "error": "could not extract run_id from CLI output", - "stdout": run_result.stdout, - } + # 3. Fetch structured report + report_resp = client.get(f"/api/reports/{run_id}") + report = report_resp.json() if report_resp.status_code == 200 else {} - # 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, - } + return self._format_result(run, report) - report_data = report_result.stdout - if report_format == "json": - try: - report_data = json.loads(report_data) - except json.JSONDecodeError: - 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 { - "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() diff --git a/frontend/web/src/api.ts b/frontend/web/src/api.ts index 0825751..8a80d5b 100644 --- a/frontend/web/src/api.ts +++ b/frontend/web/src/api.ts @@ -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 = { diff --git a/frontend/web/src/pages/Reports.tsx b/frontend/web/src/pages/Reports.tsx index 74fd9e9..da733b7 100644 --- a/frontend/web/src/pages/Reports.tsx +++ b/frontend/web/src/pages/Reports.tsx @@ -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([]) const [selectedRunId, setSelectedRunId] = useState('') + const [compareRunId, setCompareRunId] = useState('') const [report, setReport] = useState(null) + const [compareResult, setCompareResult] = useState(null) const [loading, setLoading] = useState(false) + const [viewMode, setViewMode] = useState('single') const loadReport = async (runId: string) => { setSelectedRunId(runId) + setCompareResult(null) setLoading(true) try { const res = await reportsApi.get(runId) @@ -78,14 +101,14 @@ export default function ReportsPage() { loadRuns().then(() => { if (runQuery) loadReport(runQuery) }) - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useEffect(() => { if (runQuery && runQuery !== selectedRunId) { loadReport(runQuery) } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [runQuery]) const handleView = (runId: string) => { @@ -93,11 +116,22 @@ 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 (
{/* 页头 */} @@ -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', }}> + { setViewMode(v as ViewMode); setCompareResult(null) }} + options={[ + { label: '单次报告', value: 'single' }, + { label: '对比报告', value: 'compare', icon: }, + ]} + /> + + (opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())} + options={runSelectOptions.filter((o) => o.value !== selectedRunId)} + /> + + )} + + {viewMode === 'single' && report && ( + + + + + )} +
- {/* 报告内容 — 可滚动 */} + {/* 报告内容区 */}
- {report ? ( - <> - - - - - - - } - /> - - - - - } - /> - - - - - = 0.8 ? '#3f8600' : '#cf1322' }} - /> - - - - - - - {report.target_name} - {report.scenario_name} - - {report.started_at ? formatDateTime(report.started_at) : '-'} - - - {report.completed_at ? formatDateTime(report.completed_at) : '-'} - - - - - - ({ - key: c.case_id, - label: ( - - {c.case_id} - {c.results.every((r) => r.passed) ? ( - 全部通过 - ) : ( - 存在失败 - )} - {c.turns.length} 轮对话 - - ), - children: ( -
-
- {c.turns.map((turn, idx) => ( -
-
-
- - {turn.sent_text} -
-
-
-
- - {turn.reply_text || '(无回复)'} - {turn.latency_ms != null && ( - - {turn.latency_ms}ms - - )} -
-
-
- ))} -
- String(idx)} - columns={[ - { title: '规则', dataIndex: 'rule_type', width: 150 }, - { title: '结果', dataIndex: 'passed', width: 80, - render: (p: boolean) => p - ? 通过 - : 失败, - }, - { title: '评分', dataIndex: 'score', width: 80, - render: (s: number | null) => s != null ? s.toFixed(2) : '-', - }, - { title: '说明', dataIndex: 'reason' }, - ]} - /> - - ), - }))} - /> - - - ) : ( -
- -
- )} + {viewMode === 'compare' + ? + : + } ) } + +// ── Single Report View ─────────────────────────────────────────────────── + +function SingleReportView({ report }: { report: Report | null }) { + if (!report) { + return ( +
+ +
+ ) + } + + return ( + <> + +
+ + + + + } /> + + + + + } /> + + + + + = 0.8 ? '#3f8600' : '#cf1322' }} /> + + + + + + + {report.target_name} + {report.scenario_name} + {formatDateTime(report.started_at)} + {report.completed_at ? formatDateTime(report.completed_at) : '-'} + + + + + ({ + key: c.case_id, + label: ( + + {c.case_id} + {c.results.every((r) => r.passed) + ? 全部通过 + : 存在失败} + {c.turns.length} 轮对话 + + ), + children: , + }))} /> + + + ) +} + +function CaseDetail({ c }: { c: CaseReport }) { + return ( +
+
+ {c.turns.map((turn, idx) => ( +
+
+
+ + {turn.sent_text} +
+
+
+
+ + {turn.reply_text || '(无回复)'} + {turn.latency_ms != null && ( + {turn.latency_ms}ms + )} +
+
+
+ ))} +
+
String(idx)} + columns={[ + { title: '规则', dataIndex: 'rule_type', width: 160 }, + { title: '结果', dataIndex: 'passed', width: 80, + render: (p: boolean) => p ? 通过 : 失败 }, + { title: '评分', dataIndex: 'score', width: 80, + render: (s: number | null) => s != null ? s.toFixed(2) : '-' }, + { title: '说明', dataIndex: 'reason' }, + ]} + /> + + ) +} + +// ── Compare View ───────────────────────────────────────────────────────── + +function CompareView({ result }: { result: CompareResult | null }) { + if (!result) { + return ( +
+ +
+ ) + } + + 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 ( + <> + {/* 汇总对比 */} + + + 报告 A — {run_a.run_id.slice(0, 8)}…} size="small"> + + {run_a.scenario_name} + {formatDateTime(run_a.started_at)} + {(run_a.summary.pass_rate * 100).toFixed(1)}% + {run_a.summary.passed_cases}/{run_a.summary.total_cases} + + + + +
变化
+
+ {deltaSign(Math.round(delta.pass_rate * 1000) / 10)}% +
+ 0 ? 'orange' : 'green'} + title={`${changed_cases} 个用例结果变化`} /> + + + 报告 B — {run_b.run_id.slice(0, 8)}…} size="small"> + + {run_b.scenario_name} + {formatDateTime(run_b.started_at)} + {(run_b.summary.pass_rate * 100).toFixed(1)}% + {run_b.summary.passed_cases}/{run_b.summary.total_cases} + + + + + + {/* 用例对比表 */} + +
r.changed ? 'run-row' : ''} + columns={[ + { title: '用例', dataIndex: 'case_id', width: 180, + render: (id: string, r) => ( + + {r.changed && } + {id} + + ), + }, + { title: '报告 A', dataIndex: 'run_a_passed', width: 100, + render: (p: boolean | null) => + p === null ? + : p ? 通过 + : 失败, + }, + { title: '报告 B', dataIndex: 'run_b_passed', width: 100, + render: (p: boolean | null) => + p === null ? + : p ? 通过 + : 失败, + }, + { title: '变化', width: 80, + render: (_: any, r) => { + if (!r.changed) return + if (r.run_b_passed && !r.run_a_passed) return 改善 ↑ + if (!r.run_b_passed && r.run_a_passed) return 退步 ↓ + return 变化 + }, + }, + ]} + expandable={{ + expandedRowRender: (r) => ( + + +
报告 A 规则
+ {r.run_a_results.map((res, i) => ( +
+ {res.passed ? '✅' : '❌'} {res.rule_type}: {res.reason} +
+ ))} + + +
报告 B 规则
+ {r.run_b_results.map((res, i) => ( +
+ {res.passed ? '✅' : '❌'} {res.rule_type}: {res.reason} +
+ ))} + + + ), + rowExpandable: (r) => r.run_a_results.length > 0 || r.run_b_results.length > 0, + }} + /> + + + ) +}