diff --git a/backend/agenteval/evaluation/report.py b/backend/agenteval/evaluation/report.py index a71e6d0..c6a7f96 100644 --- a/backend/agenteval/evaluation/report.py +++ b/backend/agenteval/evaluation/report.py @@ -1,95 +1,21 @@ -"""Report generation for evaluation runs.""" +"""Report generation for evaluation runs. + +Generation only: read the DB / model objects and build the report dict. +Formatting lives in ``report_render`` (pure dict → HTML/Markdown/JSON). +""" -import json from collections import defaultdict from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional -from jinja2 import Template - from agenteval.evaluation.metrics import aggregate_runs +from agenteval.evaluation.report_render import render_html, render_json, render_markdown from agenteval.models import Campaign, EvalRun, RunStatus, RunSummary from agenteval.storage.db import DATA_DIR, iso_utc from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository from agenteval.utils.llm import extract_reply_text -HTML_TEMPLATE = """ - - - - 评测报告 - {{ report.run_id }} - - - -
-

评测报告

-

评测对象:{{ report.target_name }}({{ report.target_id }})

-

评测场景:{{ report.scenario_name }}({{ report.scenario_id }})

-

执行时间:{{ report.started_at }} 至 {{ report.completed_at or '进行中' }}

- -
-
-
{{ report.summary.total_cases }}
-
用例总数
-
-
-
{{ report.summary.passed_cases }}
-
通过用例
-
-
-
{{ report.summary.total_rules }}
-
规则总数
-
-
-
{{ "%.2f"|format(report.summary.pass_rate * 100) }}%
-
规则通过率
-
-
- - {% for case in report.cases %} -
-
用例 {{ case.case_id }}
- {% for turn in case.turns %} -
-
用户消息
-
{{ turn.sent_text }}
-
智能体回复({{ turn.latency_ms }}ms)
-
{{ turn.reply_text or '(无回复)' }}
-
- {% endfor %} -
- {% for result in case.results %} -
- {{ '通过' if result.passed else '失败' }} - {{ result.rule_type }}: {{ result.reason }} -
- {% endfor %} -
-
- {% endfor %} -
- - -""" - def _extract_text(data: Any) -> str: return extract_reply_text(data) @@ -292,24 +218,19 @@ def _to_utc(dt: Optional[datetime]) -> Optional[datetime]: return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt -def _aggregate_runs(runs: list[EvalRun]) -> dict[str, Any]: - """Delegates to the single cross-run aggregation seam (ADR-0004).""" - return aggregate_runs(runs) - - def summarize_campaign_progress(campaign: Campaign, runs: list[EvalRun]) -> dict[str, Any]: """Compact list-row progress: completed vs *planned* total, plus pass_rate. Unlike ``campaign_progress`` (live window position), this powers the list view. ``planned_total`` is the sum of plan-entry counts — a fixed target the campaign works toward, so the progress bar fills from 0 rather than tracking - a growing spawned count. ``overall_pass_rate`` reuses ``_aggregate_runs`` so + a growing spawned count. ``overall_pass_rate`` reuses ``aggregate_runs`` so the list figure matches the report exactly (ADR-0002: failures count as 0.0). """ return { "completed_runs": sum(1 for r in runs if r.status == RunStatus.COMPLETED), "planned_total": sum(entry.count for entry in campaign.plan), - "overall_pass_rate": _aggregate_runs(runs)["pass_rate"], + "overall_pass_rate": aggregate_runs(runs)["pass_rate"], } @@ -350,7 +271,7 @@ def generate_campaign_report( time_trend = [] for idx in range(bucket_count): - agg = _aggregate_runs(buckets.get(idx, [])) + agg = aggregate_runs(buckets.get(idx, [])) time_trend.append({ "bucket_index": idx, "start_seconds": round(idx * bucket_seconds, 3), @@ -364,7 +285,7 @@ def generate_campaign_report( by_scenario[run.scenario_id].append(run) capability_summary = [] for sid, sruns in by_scenario.items(): - agg = _aggregate_runs(sruns) + agg = aggregate_runs(sruns) capability_summary.append({ "scenario_id": sid, "scenario_name": scenario_names.get(sid, (sid or "")[:8]), @@ -372,7 +293,7 @@ def generate_campaign_report( }) capability_summary.sort(key=lambda s: s["run_count"], reverse=True) - overall = _aggregate_runs(runs) + overall = aggregate_runs(runs) return { "campaign_id": campaign.id, "name": campaign.name, @@ -394,163 +315,22 @@ def generate_campaign_report( } -def render_campaign_markdown_report( - campaign: Campaign, - runs: list[EvalRun], - *, - scenario_names: Optional[dict[str, str]] = None, -) -> str: - """Render the dual-axis campaign report as Markdown (reuses the export path).""" - report = generate_campaign_report(campaign, runs, scenario_names=scenario_names) - s = report["summary"] - - def _pct(v: Optional[float]) -> str: - return "—" if v is None else f"{v * 100:.1f}%" - - def _ms(v: Optional[float]) -> str: - return "—" if v is None else f"{v:.0f}ms" - - lines: list[str] = [ - f"# 活动周期报告 — {report['name']}", - "", - f"**状态**: {report['status']} ", - f"**窗口**: {report['window_seconds']}s(倍速 {report['time_scale']}) ", - f"**开始时间**: {report['started_at'] or '-'} ", - f"**完成时间**: {report['completed_at'] or '-'} ", - "", - "## 汇总", - "", - "| 指标 | 数值 |", - "|------|------|", - f"| 子运行总数 | {s['total_runs']} |", - f"| 已完成 | {s['completed_runs']} |", - f"| 整窗通过率 | {_pct(s['overall_pass_rate'])} |", - f"| 整窗可用性 | {_pct(s['overall_availability'])} |", - f"| 平均时延 | {_ms(s['avg_latency_ms'])} |", - "", - "## 时间趋势", - "", - "| 时段(秒) | 运行数 | 通过率 | 可用性 | 时延 |", - "|------|------|------|------|------|", - ] - for b in report["time_trend"]: - lines.append( - f"| {b['start_seconds']:.0f}–{b['end_seconds']:.0f} | {b['run_count']} | " - f"{_pct(b['pass_rate'])} | {_pct(b['availability'])} | {_ms(b['avg_latency_ms'])} |" - ) - lines += [ - "", - "## 能力汇总", - "", - "| 场景 | 运行数 | 通过率 | 可用性 | 时延 |", - "|------|------|------|------|------|", - ] - for c in report["capability_summary"]: - lines.append( - f"| {c['scenario_name']} | {c['run_count']} | {_pct(c['pass_rate'])} | " - f"{_pct(c['availability'])} | {_ms(c['avg_latency_ms'])} |" - ) - return "\n".join(lines) - - -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"] - judged_rate = s.get("judged_pass_rate") - judged_rate_text = "—" if judged_rate is None else f"{judged_rate * 100:.1f}%" - 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}% |", - f"| 连通用例 | {s.get('connectivity_cases', 0)} |", - f"| 判定型通过率 | {judged_rate_text} |", - "", - "## 用例明细", - "", - ] - - for case in report.get("cases", []): - if case.get("connectivity"): - badge = "🔗" - else: - badge = "✅" if case.get("passed") else "❌" - title = f"### {badge} 用例 `{case['case_id']}`" - if case.get("connectivity"): - title += "(连通用例,未配置判定标准)" - lines.append(title) - lines.append("") - - for turn in case.get("turns", []): - lines.append(f"**第 {turn['round']} 轮**") - lines.append("") - lines.append(f"> **用户**: {turn.get('sent_text', '—')}") - lines.append("") - reply = turn.get("reply_text") or "(无回复)" - lines.append(f"> **智能体**: {reply}") - if turn.get("latency_ms") is not None: - lines.append(f"> *延迟: {turn['latency_ms']}ms*") - lines.append("") - - if case.get("results"): - lines.append("**规则评估结果**") - lines.append("") - lines.append("| 规则 | 结果 | 评分 | 说明 |") - lines.append("|------|------|------|------|") - for r in case["results"]: - badge = "✅" if r["passed"] else "❌" - score = f"{r['score']:.2f}" if r.get("score") is not None else "-" - lines.append(f"| {r['rule_type']} | {badge} | {score} | {r.get('reason', '')} |") - lines.append("") - - return "\n".join(lines) - - -def render_json_report(run_id: str, session=None) -> str: - """Render a report as JSON string.""" - report = generate_report(run_id, session) - return json.dumps(report, ensure_ascii=False, indent=2) - - -def render_html_report(run_id: str, session=None) -> str: - """Render a report as HTML string.""" - report = generate_report(run_id, session) - template = Template(HTML_TEMPLATE) - return template.render(report=report) - - def save_report(run_id: str, fmt: str = "html", output_dir: Optional[Path] = None) -> Path: - """Generate and save a report to disk.""" + """Generate a run report and save it to disk in the requested format.""" output_dir = output_dir or DATA_DIR / "reports" output_dir.mkdir(parents=True, exist_ok=True) - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - if fmt == "html": - content = render_html_report(run_id) - path = output_dir / f"report_{run_id}_{timestamp}.html" - elif fmt == "json": - content = render_json_report(run_id) - path = output_dir / f"report_{run_id}_{timestamp}.json" - elif fmt == "markdown": - content = render_markdown_report(run_id) - path = output_dir / f"report_{run_id}_{timestamp}.md" - else: + report = generate_report(run_id) + renderers = { + "html": (render_html, "html"), + "json": (render_json, "json"), + "markdown": (render_markdown, "md"), + } + if fmt not in renderers: raise ValueError(f"unsupported report format: {fmt}") + render, ext = renderers[fmt] - path.write_text(content, encoding="utf-8") + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + path = output_dir / f"report_{run_id}_{timestamp}.{ext}" + path.write_text(render(report), encoding="utf-8") return path diff --git a/backend/agenteval/evaluation/report_render.py b/backend/agenteval/evaluation/report_render.py new file mode 100644 index 0000000..1dbd6f4 --- /dev/null +++ b/backend/agenteval/evaluation/report_render.py @@ -0,0 +1,219 @@ +"""Pure renderers: report dict in, HTML/Markdown/JSON string out. + +No I/O and no storage access — every function takes the dict produced by the +generation side (``evaluation.report``) so rendering can be unit-tested from a +hand-built dict. Report *content* decisions (which cases pass, which rates to +show) belong to generation; this module only formats. +""" + +import json +from typing import Any, Optional + +from jinja2 import Template + +HTML_TEMPLATE = """ + + + + 评测报告 - {{ report.run_id }} + + + +
+

评测报告

+

评测对象:{{ report.target_name }}({{ report.target_id }})

+

评测场景:{{ report.scenario_name }}({{ report.scenario_id }})

+

执行时间:{{ report.started_at }} 至 {{ report.completed_at or '进行中' }}

+ +
+
+
{{ report.summary.total_cases }}
+
用例总数
+
+
+
{{ report.summary.passed_cases }}
+
通过用例
+
+
+
{{ report.summary.total_rules }}
+
规则总数
+
+
+
{{ "%.2f"|format(report.summary.pass_rate * 100) }}%
+
规则通过率
+
+
+ + {% for case in report.cases %} +
+
用例 {{ case.case_id }}
+ {% for turn in case.turns %} +
+
用户消息
+
{{ turn.sent_text }}
+
智能体回复({{ turn.latency_ms }}ms)
+
{{ turn.reply_text or '(无回复)' }}
+
+ {% endfor %} +
+ {% for result in case.results %} +
+ {{ '通过' if result.passed else '失败' }} + {{ result.rule_type }}: {{ result.reason }} +
+ {% endfor %} +
+
+ {% endfor %} +
+ + +""" + + +def _pct(v: Optional[float]) -> str: + return "—" if v is None else f"{v * 100:.1f}%" + + +def _ms(v: Optional[float]) -> str: + return "—" if v is None else f"{v:.0f}ms" + + +def render_html(report: dict[str, Any]) -> str: + """Render a run report dict as an HTML string.""" + return Template(HTML_TEMPLATE).render(report=report) + + +def render_json(report: dict[str, Any]) -> str: + """Render a report dict as a JSON string.""" + return json.dumps(report, ensure_ascii=False, indent=2) + + +def render_markdown(report: dict[str, Any]) -> str: + """Render a run report dict as Markdown.""" + s = report["summary"] + judged_rate = s.get("judged_pass_rate") + judged_rate_text = "—" if judged_rate is None else f"{judged_rate * 100:.1f}%" + lines: list[str] = [ + f"# 评测报告 — {report.get('scenario_name', report.get('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}% |", + f"| 连通用例 | {s.get('connectivity_cases', 0)} |", + f"| 判定型通过率 | {judged_rate_text} |", + "", + "## 用例明细", + "", + ] + + for case in report.get("cases", []): + if case.get("connectivity"): + badge = "🔗" + else: + badge = "✅" if case.get("passed") else "❌" + title = f"### {badge} 用例 `{case['case_id']}`" + if case.get("connectivity"): + title += "(连通用例,未配置判定标准)" + lines.append(title) + 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_campaign_markdown(report: dict[str, Any]) -> str: + """Render a dual-axis campaign report dict as Markdown.""" + s = report["summary"] + lines: list[str] = [ + f"# 活动周期报告 — {report['name']}", + "", + f"**状态**: {report['status']} ", + f"**窗口**: {report['window_seconds']}s(倍速 {report['time_scale']}) ", + f"**开始时间**: {report['started_at'] or '-'} ", + f"**完成时间**: {report['completed_at'] or '-'} ", + "", + "## 汇总", + "", + "| 指标 | 数值 |", + "|------|------|", + f"| 子运行总数 | {s['total_runs']} |", + f"| 已完成 | {s['completed_runs']} |", + f"| 整窗通过率 | {_pct(s['overall_pass_rate'])} |", + f"| 整窗可用性 | {_pct(s['overall_availability'])} |", + f"| 平均时延 | {_ms(s['avg_latency_ms'])} |", + "", + "## 时间趋势", + "", + "| 时段(秒) | 运行数 | 通过率 | 可用性 | 时延 |", + "|------|------|------|------|------|", + ] + for b in report["time_trend"]: + lines.append( + f"| {b['start_seconds']:.0f}–{b['end_seconds']:.0f} | {b['run_count']} | " + f"{_pct(b['pass_rate'])} | {_pct(b['availability'])} | {_ms(b['avg_latency_ms'])} |" + ) + lines += [ + "", + "## 能力汇总", + "", + "| 场景 | 运行数 | 通过率 | 可用性 | 时延 |", + "|------|------|------|------|------|", + ] + for c in report["capability_summary"]: + lines.append( + f"| {c['scenario_name']} | {c['run_count']} | {_pct(c['pass_rate'])} | " + f"{_pct(c['availability'])} | {_ms(c['avg_latency_ms'])} |" + ) + return "\n".join(lines) diff --git a/backend/agenteval/web/routers/campaigns.py b/backend/agenteval/web/routers/campaigns.py index ac18a51..055c3a9 100644 --- a/backend/agenteval/web/routers/campaigns.py +++ b/backend/agenteval/web/routers/campaigns.py @@ -12,11 +12,8 @@ from pydantic import BaseModel, Field from sqlmodel import Session from agenteval.evaluation.campaign_runner import campaign_progress, request_cancel, start_campaign -from agenteval.evaluation.report import ( - generate_campaign_report, - render_campaign_markdown_report, - summarize_campaign_progress, -) +from agenteval.evaluation.report import generate_campaign_report, summarize_campaign_progress +from agenteval.evaluation.report_render import render_campaign_markdown from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus from agenteval.storage.db import utc_now from agenteval.storage.repository import ( @@ -114,7 +111,7 @@ async def get_campaign_report_markdown(campaign_id: str, session: Session = Depe raise HTTPException(status_code=404, detail="campaign not found") runs = RunRepository(session).list_by_campaign(campaign_id) scenario_names = {s.id: s.name for s in ScenarioRepository(session).list_all()} - md = render_campaign_markdown_report(campaign, runs, scenario_names=scenario_names) + md = render_campaign_markdown(generate_campaign_report(campaign, runs, scenario_names=scenario_names)) return Response( content=md, media_type="text/markdown; charset=utf-8", diff --git a/backend/agenteval/web/routers/reports.py b/backend/agenteval/web/routers/reports.py index 8e1ae16..ec0f55a 100644 --- a/backend/agenteval/web/routers/reports.py +++ b/backend/agenteval/web/routers/reports.py @@ -3,13 +3,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response from sqlmodel import Session -from agenteval.evaluation.report import ( - generate_compare_report, - generate_report, - render_html_report, - render_json_report, - render_markdown_report, -) +from agenteval.evaluation.report import generate_compare_report, generate_report +from agenteval.evaluation.report_render import render_html, render_json, render_markdown from agenteval.storage.repository import RunRepository from agenteval.web.deps import get_db @@ -53,7 +48,7 @@ def get_html_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") - html = render_html_report(run_id, session) + html = render_html(generate_report(run_id, session)) return Response(content=html, media_type="text/html") @@ -62,7 +57,7 @@ def get_json_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") - json_text = render_json_report(run_id, session) + json_text = render_json(generate_report(run_id, session)) return Response(content=json_text, media_type="application/json") @@ -71,7 +66,7 @@ def get_markdown_report(run_id: str, session: Session = Depends(get_db)) -> Resp 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) + md = render_markdown(generate_report(run_id, session)) return Response( content=md, media_type="text/markdown; charset=utf-8", diff --git a/backend/cli/report.py b/backend/cli/report.py index f4c2eb8..1f61a14 100644 --- a/backend/cli/report.py +++ b/backend/cli/report.py @@ -4,7 +4,9 @@ from pathlib import Path from typing import Optional import typer -from agenteval.evaluation.report import render_html_report, render_json_report, save_report +from agenteval.evaluation.report import generate_report as build_report +from agenteval.evaluation.report import save_report +from agenteval.evaluation.report_render import render_html, render_json from agenteval.storage.repository import RunRepository from rich.console import Console @@ -23,9 +25,9 @@ def show_report( raise typer.Exit(1) if fmt == "json": - console.print(render_json_report(run_id)) + console.print(render_json(build_report(run_id))) elif fmt == "html": - console.print(render_html_report(run_id)) + console.print(render_html(build_report(run_id))) else: console.print(f"不支持的格式: {fmt}", style="red") raise typer.Exit(1) @@ -51,16 +53,14 @@ def compare_reports( run_id_1: str, run_id_2: str, ) -> None: - from agenteval.evaluation.report import generate_report - r1 = RunRepository().get(run_id_1) r2 = RunRepository().get(run_id_2) if not r1 or not r2: console.print("评测记录不存在", style="red") raise typer.Exit(1) - report1 = generate_report(run_id_1) - report2 = generate_report(run_id_2) + report1 = build_report(run_id_1) + report2 = build_report(run_id_2) console.print(f"对比: {run_id_1} vs {run_id_2}") console.print(f" 用例数: {report1['summary']['total_cases']} -> {report2['summary']['total_cases']}") diff --git a/backend/cli/run.py b/backend/cli/run.py index be595f9..5f66353 100644 --- a/backend/cli/run.py +++ b/backend/cli/run.py @@ -57,9 +57,10 @@ def start_run( console.print_json(data=run.summary) if output_json: - from agenteval.evaluation.report import render_json_report + from agenteval.evaluation.report import generate_report + from agenteval.evaluation.report_render import render_json - console.print(render_json_report(run.id)) + console.print(render_json(generate_report(run.id))) @app.command("list", help="列出评测执行记录") diff --git a/tests/unit/test_report.py b/tests/unit/test_report.py index a52ba86..d30fd78 100644 --- a/tests/unit/test_report.py +++ b/tests/unit/test_report.py @@ -6,9 +6,8 @@ from sqlmodel import Session, SQLModel, create_engine from agenteval.evaluation.report import ( generate_compare_report, generate_report, - render_markdown_report, - render_json_report, ) +from agenteval.evaluation.report_render import render_json, render_markdown from agenteval.models import ( Case, CaseType, EvalResult, EvalRun, EvalTarget, RunStatus, Scenario, Turn, PlatformType, ChannelType, TargetStatus, @@ -318,7 +317,7 @@ def test_compare_report_marks_connectivity(report_session): def test_markdown_report_shows_connectivity(report_session): run_id = _seed_run(report_session, n_cases=1, connectivity_cases=1) - md = render_markdown_report(run_id, report_session) + md = render_markdown(generate_report(run_id, report_session)) assert "连通用例" in md assert "判定型通过率" in md @@ -327,13 +326,13 @@ def test_markdown_report_shows_connectivity(report_session): def test_render_markdown_contains_header(report_session): run_id = _seed_run(report_session) - md = render_markdown_report(run_id, report_session) + md = render_markdown(generate_report(run_id, report_session)) assert "# 评测报告" in md def test_render_markdown_contains_summary_table(report_session): run_id = _seed_run(report_session) - md = render_markdown_report(run_id, report_session) + md = render_markdown(generate_report(run_id, report_session)) assert "## 汇总" in md assert "| 指标 | 数值 |" in md assert "通过率" in md @@ -341,14 +340,14 @@ def test_render_markdown_contains_summary_table(report_session): def test_render_markdown_contains_case_section(report_session): run_id = _seed_run(report_session, n_cases=1) - md = render_markdown_report(run_id, report_session) + md = render_markdown(generate_report(run_id, report_session)) assert "## 用例明细" in md assert "### " in md # case header def test_render_markdown_contains_rule_table(report_session): run_id = _seed_run(report_session) - md = render_markdown_report(run_id, report_session) + md = render_markdown(generate_report(run_id, report_session)) assert "**规则评估结果**" in md assert "keyword_match" in md @@ -358,7 +357,7 @@ def test_render_markdown_contains_rule_table(report_session): def test_render_json_report_is_valid_json(report_session): import json run_id = _seed_run(report_session) - json_text = render_json_report(run_id, report_session) + json_text = render_json(generate_report(run_id, report_session)) parsed = json.loads(json_text) assert parsed["run_id"] == run_id @@ -380,7 +379,7 @@ def test_case_dict_contains_passed_fallback(report_session): def test_markdown_errored_case_shows_failed_badge(report_session): """故障用例(无结果且非连通)在 MD 中必须 ❌ —— 此前 all([]) 误判 ✅。""" run_id = _seed_run(report_session, n_cases=1, errored_cases=1) - md = render_markdown_report(run_id, report_session) + md = render_markdown(generate_report(run_id, report_session)) assert "❌ 用例 `err0`" in md assert "✅ 用例 `err0`" not in md @@ -396,7 +395,7 @@ def test_authoritative_case_outcomes_override_reconstruction(report_session): report = generate_report(run_id, report_session) assert report["cases"][0]["passed"] is False - md = render_markdown_report(run_id, report_session) + md = render_markdown(generate_report(run_id, report_session)) assert "❌ 用例 `c0`" in md diff --git a/tests/unit/test_report_render.py b/tests/unit/test_report_render.py new file mode 100644 index 0000000..817f0f1 --- /dev/null +++ b/tests/unit/test_report_render.py @@ -0,0 +1,187 @@ +"""Unit tests for the pure renderers: dict in, HTML/Markdown/JSON out (no DB).""" + +import json + +from agenteval.evaluation.report_render import ( + render_campaign_markdown, + render_html, + render_json, + render_markdown, +) + + +def _run_report(**overrides) -> dict: + """A hand-built report dict matching generate_report's shape.""" + report = { + "run_id": "run-1", + "target_id": "t-1", + "target_name": "客服机器人", + "scenario_id": "s-1", + "scenario_name": "售后场景", + "scenario_version": 2, + "triggered_by": "manual", + "status": "completed", + "started_at": "2026-07-30T10:00:00+00:00", + "completed_at": "2026-07-30T10:05:00+00:00", + "summary": { + "total_cases": 2, + "passed_cases": 1, + "failed_cases": 1, + "total_rules": 3, + "passed_rules": 2, + "pass_rate": 0.5, + "connectivity_cases": 1, + "judged_pass_rate": 0.0, + }, + "cases": [ + { + "case_id": "case-a", + "passed": True, + "connectivity": True, + "turns": [ + { + "round": 0, + "sent_text": "你好", + "reply_text": "您好,请问有什么可以帮您?", + "latency_ms": 120, + "question_msg_id": "m-1", + } + ], + "results": [], + }, + { + "case_id": "case-b", + "passed": False, + "connectivity": False, + "turns": [ + { + "round": 0, + "sent_text": "退货流程", + "reply_text": None, + "latency_ms": None, + "question_msg_id": "m-2", + } + ], + "results": [ + {"rule_type": "keyword_match", "passed": False, "score": 0.0, "reason": "缺少关键词"}, + ], + }, + ], + } + report.update(overrides) + return report + + +def _campaign_report() -> dict: + """A hand-built dict matching generate_campaign_report's shape.""" + return { + "campaign_id": "c-1", + "name": "夜间巡检", + "target_id": "t-1", + "status": "completed", + "window_seconds": 7200, + "time_scale": 1.0, + "started_at": "2026-07-30T00:00:00+00:00", + "completed_at": "2026-07-30T02:00:00+00:00", + "summary": { + "total_runs": 2, + "completed_runs": 2, + "overall_pass_rate": 0.75, + "overall_availability": 1.0, + "avg_latency_ms": 150.0, + }, + "time_trend": [ + { + "bucket_index": 0, + "start_seconds": 0.0, + "end_seconds": 3600.0, + "run_count": 2, + "pass_rate": 0.75, + "availability": 1.0, + "avg_latency_ms": 150.0, + }, + { + "bucket_index": 1, + "start_seconds": 3600.0, + "end_seconds": 7200.0, + "run_count": 0, + "pass_rate": None, + "availability": None, + "avg_latency_ms": None, + }, + ], + "capability_summary": [ + { + "scenario_id": "s-1", + "scenario_name": "售后场景", + "run_count": 2, + "pass_rate": 0.75, + "availability": 1.0, + "avg_latency_ms": 150.0, + }, + ], + } + + +# ── render_html ───────────────────────────────────────────────────────────── + +def test_render_html_contains_names_and_summary(): + html = render_html(_run_report()) + assert "客服机器人" in html + assert "售后场景" in html + assert "50.00%" in html # pass_rate 0.5 + + +def test_render_html_contains_turns_and_rule_badges(): + html = render_html(_run_report()) + assert "退货流程" in html + assert "keyword_match" in html + assert "失败" in html + + +# ── render_markdown ───────────────────────────────────────────────────────── + +def test_render_markdown_summary_table(): + md = render_markdown(_run_report()) + assert "| 总用例数 | 2 |" in md + assert "| 通过率 | 50.0% |" in md + assert "| 连通用例 | 1 |" in md + + +def test_render_markdown_connectivity_badge_and_no_reply(): + md = render_markdown(_run_report()) + assert "🔗" in md # connectivity case badge + assert "(连通用例,未配置判定标准)" in md + assert "(无回复)" in md + + +def test_render_markdown_judged_pass_rate_dash_when_none(): + report = _run_report() + report["summary"]["judged_pass_rate"] = None + md = render_markdown(report) + assert "| 判定型通过率 | — |" in md + + +# ── render_json ───────────────────────────────────────────────────────────── + +def test_render_json_roundtrips(): + report = _run_report() + parsed = json.loads(render_json(report)) + assert parsed == report + + +# ── render_campaign_markdown ──────────────────────────────────────────────── + +def test_render_campaign_markdown_summary_and_axes(): + md = render_campaign_markdown(_campaign_report()) + assert "# 活动周期报告 — 夜间巡检" in md + assert "| 整窗通过率 | 75.0% |" in md + assert "## 时间趋势" in md + assert "## 能力汇总" in md + assert "| 售后场景 | 2 | 75.0% | 100.0% | 150ms |" in md + + +def test_render_campaign_markdown_empty_bucket_dashes(): + md = render_campaign_markdown(_campaign_report()) + # bucket 1 has no runs: pass_rate/availability/latency all render as — + assert "| 3600–7200 | 0 | — | — | — |" in md