feat(campaigns): 周期对比纳入 Markdown 导出,活动导出排版重优化

- 导出端点解析评测对象名与 completed 周期对比行(基线元信息、分析模型、现算机械 diff),渲染 `## 周期对比` 附录(趋势 + 指标变化表 + 问题演变 + 建议落实),紧跟智能分析之后;非 completed 则完全无痕
- 头部排版重优化:状态中文化、窗口与时段人类可读(24h、0h–1h)、友好时间戳、头部补评测对象名、「正式线」/「加速调试线 ×N」措辞(加速线附注压缩后实际耗时);Run 级导出不动
- 测试:渲染器黄金断言更新 + 附录/头部/缺省用例,集成测试新增导出含对比、无对比行、failed 行三例
This commit is contained in:
sinohqb 2026-08-03 15:13:11 +08:00
parent dd3b9a5e91
commit 14b09e1ac6
4 changed files with 338 additions and 9 deletions

View File

@ -7,6 +7,7 @@ show) belong to generation; this module only formats.
"""
import json
from datetime import datetime
from typing import Any, Optional
from jinja2 import Template
@ -213,25 +214,164 @@ def _render_analysis_lines(analysis: dict[str, Any], scenario_names: dict[str, s
return lines
_CAMPAIGN_STATUS_LABELS = {
"planned": "计划中", "running": "进行中", "completed": "已完成",
"cancelled": "已取消", "failed": "失败",
}
_TREND_LABELS = {"improving": "改善", "stable": "平稳", "regressing": "退化"}
_EVOLUTION_LABELS = {"new": "新增", "persisting": "持续", "resolved": "消解"}
_TRACKING_LABELS = {"addressed": "已落实", "partial": "部分落实", "unaddressed": "未落实", "new": "新增"}
def _offset(seconds: float) -> str:
"""Human-readable window offset: 86400 → 24h, 1800 → 30m, 45 → 45s."""
s = int(round(seconds))
if s % 3600 == 0:
return f"{s // 3600}h"
if s % 60 == 0:
return f"{s // 60}m"
return f"{s}s"
def _dt(iso: Optional[str]) -> str:
"""ISO timestamp → `2026-08-01 08:00`; unparseable input passes through."""
if not iso:
return "-"
try:
return datetime.fromisoformat(iso.replace("Z", "+00:00")).strftime("%Y-%m-%d %H:%M")
except ValueError:
return iso
def _diff_cell(metric: str, pair: dict[str, Any]) -> str:
"""One metric cell of the diff table: `基线 → 本期±delta`."""
def _val(v: Optional[float]) -> str:
if v is None:
return ""
return _ms(v) if metric == "avg_latency_ms" else _pct(v)
delta = pair.get("delta")
if delta is None:
delta_text = ""
elif metric == "avg_latency_ms":
delta_text = f"{delta:+.1f}ms"
else:
delta_text = f"{delta * 100:+.1f}pp"
return f"{_val(pair.get('baseline'))}{_val(pair.get('current'))}{delta_text}"
def _render_comparison_lines(comparison: dict[str, Any], scenario_names: dict[str, str]) -> list[str]:
"""Appendix for the campaign export: narrative + mechanical metric diff.
``comparison`` carries the completed narrative result plus context resolved
on the read path (baseline name/times, model name, generated-at, diff).
"""
def _name(sid: str) -> str:
return scenario_names.get(sid, sid[:8])
result = comparison.get("result") or {}
lines = ["", "## 周期对比", ""]
meta: list[str] = []
if comparison.get("baseline_name"):
base = f"基线:「{comparison['baseline_name']}"
if comparison.get("baseline_completed_at"):
base += f"(完成于 {_dt(comparison['baseline_completed_at'])}"
meta.append(base)
if comparison.get("model_name"):
meta.append(f"分析模型:{comparison['model_name']}")
if comparison.get("updated_at"):
meta.append(f"生成于:{_dt(comparison['updated_at'])}")
if meta:
lines += [" · ".join(meta), ""]
trend = _TREND_LABELS.get(result.get("trend"), "平稳")
lines += [f"**趋势**{trend}{result.get('summary') or ''}", ""]
diff = comparison.get("metric_diff")
if diff:
lines += [
"### 指标变化",
"",
"| 维度 | 通过率 | 可用性 | 平均时延 |",
"|------|------|------|------|",
]
def _row(label: str, block: dict[str, Any]) -> str:
return (
f"| {label} | {_diff_cell('pass_rate', block['pass_rate'])} | "
f"{_diff_cell('availability', block['availability'])} | "
f"{_diff_cell('avg_latency_ms', block['avg_latency_ms'])} |"
)
lines.append(_row("整窗(总体)", diff["overall"]))
for s in diff.get("scenarios") or []:
label = s.get("scenario_name") or _name(s.get("scenario_id", ""))
lines.append(_row(label, s))
lines.append("")
evolution = result.get("problem_evolution") or []
if evolution:
lines += ["### 问题演变", ""]
for p in evolution:
status = _EVOLUTION_LABELS.get(p.get("status"), "持续")
names = "".join(_name(sid) for sid in p.get("scenario_ids") or [])
head = f"- **[{status}] {p.get('title', '')}**"
if names:
head += f"(场景:{names}"
lines.append(head)
if p.get("detail"):
lines.append(f" {p['detail']}")
lines.append("")
tracking = result.get("suggestion_tracking") or []
if tracking:
lines += ["### 建议落实情况", ""]
for t in tracking:
status = _TRACKING_LABELS.get(t.get("status"), "未落实")
lines.append(f"- **[{status}] {t.get('text', '')}**")
if t.get("note"):
lines.append(f" {t['note']}")
lines.append("")
return lines
def _window_line(report: dict[str, Any]) -> str:
"""Human-readable window line with the 正式线 / 加速调试线 wording."""
window = report.get("window_seconds") or 0
scale = float(report.get("time_scale") or 1)
if scale == 1:
return f"**窗口**: {_offset(window)}(正式线)"
wall = f",压缩后实际耗时约 {_offset(window / scale)}"
return f"**窗口**: {_offset(window)}(加速调试线 ×{scale:g}{wall}"
def render_campaign_markdown(
report: dict[str, Any],
*,
analysis: Optional[dict[str, Any]] = None,
comparison: Optional[dict[str, Any]] = None,
target_name: Optional[str] = None,
scenario_names: Optional[dict[str, str]] = None,
) -> str:
"""Render a dual-axis campaign report dict as Markdown.
``analysis`` is the stored 智能分析 result dict (completed only); when
absent the export is identical to the pre-analysis format.
``analysis`` and ``comparison`` are the stored 智能分析 / 周期对比 results
(completed only); when absent the corresponding appendix is omitted
entirely.
"""
s = report["summary"]
status = _CAMPAIGN_STATUS_LABELS.get(report.get("status"), report.get("status") or "-")
target = target_name or report.get("target_id") or "-"
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"**评测对象**: {target} ",
f"**状态**: {status} ",
_window_line(report) + " ",
f"**开始时间**: {_dt(report.get('started_at'))} ",
f"**完成时间**: {_dt(report.get('completed_at'))} ",
"",
"## 汇总",
"",
@ -245,12 +385,12 @@ def render_campaign_markdown(
"",
"## 时间趋势",
"",
"| 时段(秒) | 运行数 | 通过率 | 可用性 | 时延 |",
"| 时段 | 运行数 | 通过率 | 可用性 | 时延 |",
"|------|------|------|------|------|",
]
for b in report["time_trend"]:
lines.append(
f"| {b['start_seconds']:.0f}{b['end_seconds']:.0f} | {b['run_count']} | "
f"| {_offset(b['start_seconds'])}{_offset(b['end_seconds'])} | {b['run_count']} | "
f"{_pct(b['pass_rate'])} | {_pct(b['availability'])} | {_ms(b['avg_latency_ms'])} |"
)
lines += [
@ -267,4 +407,6 @@ def render_campaign_markdown(
)
if analysis:
lines += _render_analysis_lines(analysis, scenario_names or {})
if comparison:
lines += _render_comparison_lines(comparison, scenario_names or {})
return "\n".join(lines)

View File

@ -134,9 +134,39 @@ async def get_campaign_report_markdown(campaign_id: str, session: Session = Depe
scenario_names = {s.id: s.name for s in ScenarioRepository(session).list_all()}
analysis_row = CampaignAnalysisRepository(session).get_by_campaign(campaign_id)
analysis = analysis_row.get_result() if analysis_row and analysis_row.status == "completed" else None
target = TargetRepository(session).get(campaign.target_id)
target_name = target.name if target else None
comparison = None
cmp_row = CampaignPeriodComparisonRepository(session).get_by_campaign(campaign_id)
if cmp_row is not None and cmp_row.status == "completed" and cmp_row.get_result():
baseline = CampaignRepository(session).get(cmp_row.baseline_campaign_id)
metric_diff = (
compute_metric_diff(
build_campaign_report_dict(baseline, session),
build_campaign_report_dict(campaign, session),
)
if baseline is not None
else None
)
model_cfg = ModelConfigRepository(session).get(cmp_row.model_config_id) if cmp_row.model_config_id else None
model_label = f"{model_cfg.name}{model_cfg.model_name}" if model_cfg and model_cfg.model_name else (
model_cfg.name if model_cfg else None
)
comparison = {
"result": cmp_row.get_result(),
"baseline_name": baseline.name if baseline else None,
"baseline_completed_at": iso_utc(baseline.completed_at) if baseline else None,
"model_name": model_label,
"updated_at": iso_utc(cmp_row.updated_at),
"metric_diff": metric_diff,
}
md = render_campaign_markdown(
generate_campaign_report(campaign, runs, scenario_names=scenario_names),
analysis=analysis,
comparison=comparison,
target_name=target_name,
scenario_names=scenario_names,
)
return Response(

View File

@ -407,3 +407,60 @@ async def test_post_comparison_rerun_upserts_without_new_row(client, seeded_db,
select(CampaignPeriodComparisonDB).where(CampaignPeriodComparisonDB.campaign_id == current_id)
).all()
assert len(rows) == 1
# ── Markdown 导出纳入周期对比 ──────────────────────────────────────────────
async def test_markdown_export_includes_completed_comparison(client, seeded_db):
_seed_analysis_default(seeded_db)
baseline_id, current_id = await _two_period_setup(client, seeded_db)
_seed_run(seeded_db, "run-base", baseline_id, pass_rate=0.5, latency=800.0)
_seed_run(seeded_db, "run-cur", current_id, pass_rate=0.9, latency=500.0)
_complete_comparison_row(seeded_db, current_id, baseline_id)
resp = await client.get(f"/api/campaigns/{current_id}/report/markdown")
assert resp.status_code == 200
md = resp.text
# 排版重优化:头部评测对象名、中文化状态、正式线窗口
assert "**评测对象**: mock-target" in md
assert "**状态**: 已完成" in md
assert "**窗口**: 24h正式线" in md
# 周期对比附录:元信息 + 趋势 + 机械 diff 表
assert "## 周期对比" in md
assert "基线:「上期」" in md
assert "分析模型analysis-cfgm" in md
assert "**趋势**:改善 — 通过率提升" in md
assert "| 整窗(总体) | 50.0% → 90.0%+40.0pp | 100.0% → 100.0%+0.0pp | 800ms → 500ms-300.0ms |" in md
assert "**[消解] 答非所问**场景mock-scenario" in md
assert "**[已落实] 保持**" in md
# 智能分析附录在前,周期对比紧随其后
assert md.index("## 智能分析") < md.index("## 周期对比")
async def test_markdown_export_without_comparison_row_has_no_section(client, seeded_db):
_seed_analysis_default(seeded_db)
_, current_id = await _two_period_setup(client, seeded_db)
_seed_run(seeded_db, "run-cur", current_id, pass_rate=0.9, latency=500.0)
resp = await client.get(f"/api/campaigns/{current_id}/report/markdown")
assert resp.status_code == 200
assert "周期对比" not in resp.text
async def test_markdown_export_skips_non_completed_comparison(client, seeded_db):
_seed_analysis_default(seeded_db)
baseline_id, current_id = await _two_period_setup(client, seeded_db)
seeded_db.add(CampaignPeriodComparisonDB(
campaign_id=current_id, baseline_campaign_id=baseline_id,
status="failed", model_config_id="mc-1", triggered_by="manual",
error="boom",
))
seeded_db.commit()
resp = await client.get(f"/api/campaigns/{current_id}/report/markdown")
assert resp.status_code == 200
assert "周期对比" not in resp.text

View File

@ -184,7 +184,7 @@ def test_render_campaign_markdown_summary_and_axes():
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 "| 36007200 | 0 | — | — | — |" in md
assert "| 1h2h | 0 | — | — | — |" in md
def _analysis() -> dict:
@ -246,3 +246,103 @@ def test_render_campaign_markdown_analysis_falls_back_to_id_prefix():
def test_render_campaign_markdown_without_analysis_unchanged():
md = render_campaign_markdown(_campaign_report())
assert "智能分析" not in md
def test_render_campaign_markdown_header_readability():
md = render_campaign_markdown(_campaign_report(), target_name="客服机器人")
assert "**评测对象**: 客服机器人" in md
assert "**状态**: 已完成" in md
assert "**窗口**: 2h正式线" in md
assert "**开始时间**: 2026-07-30 00:00" in md
assert "**完成时间**: 2026-07-30 02:00" in md
assert "| 0h1h | 2 | 75.0% | 100.0% | 150ms |" in md
def test_render_campaign_markdown_header_falls_back_to_target_id():
md = render_campaign_markdown(_campaign_report())
assert "**评测对象**: t-1" in md
def test_render_campaign_markdown_accelerated_line():
report = _campaign_report()
report["window_seconds"] = 86400
report["time_scale"] = 4.0
md = render_campaign_markdown(report)
assert "**窗口**: 24h加速调试线 ×4压缩后实际耗时约 6h" in md
def _comparison() -> dict:
"""A hand-built dict matching the comparison context the export endpoint resolves."""
return {
"result": {
"trend": "regressing",
"summary": "整体质量下滑,售后场景恶化",
"problem_evolution": [
{
"status": "persisting",
"title": "售后答非所问",
"scenario_ids": ["s-1"],
"detail": "问题仍未收敛",
},
{"status": "resolved", "title": "响应偏慢", "scenario_ids": [], "detail": ""},
],
"suggestion_tracking": [
{"status": "partial", "text": "补充售后知识库", "note": "仅覆盖部分问题"},
{"status": "new", "text": "新增建议:监控时延", "note": ""},
],
},
"baseline_name": "上一期巡检",
"baseline_completed_at": "2026-07-29T02:00:00+00:00",
"model_name": "qwen-max",
"updated_at": "2026-07-30T03:00:00+00:00",
"metric_diff": {
"overall": {
"pass_rate": {"baseline": 0.8, "current": 0.75, "delta": -0.05},
"availability": {"baseline": 1.0, "current": 0.5, "delta": -0.5},
"avg_latency_ms": {"baseline": 120.0, "current": 150.0, "delta": 30.0},
},
"scenarios": [
{
"scenario_id": "s-1",
"scenario_name": "售后场景",
"pass_rate": {"baseline": 0.7, "current": 0.6, "delta": -0.1},
"availability": {"baseline": None, "current": None, "delta": None},
"avg_latency_ms": {"baseline": 100.0, "current": 140.0, "delta": 40.0},
},
],
},
}
def test_render_campaign_markdown_appends_comparison_section():
md = render_campaign_markdown(
_campaign_report(),
comparison=_comparison(),
scenario_names={"s-1": "售后场景"},
)
assert "## 周期对比" in md
assert "基线:「上一期巡检」(完成于 2026-07-29 02:00" in md
assert "分析模型qwen-max" in md
assert "生成于2026-07-30 03:00" in md
assert "**趋势**:退化 — 整体质量下滑,售后场景恶化" in md
assert "### 指标变化" in md
assert "| 整窗(总体) | 80.0% → 75.0%-5.0pp | 100.0% → 50.0%-50.0pp | 120ms → 150ms+30.0ms |" in md
assert "| 售后场景 | 70.0% → 60.0%-10.0pp | — → —(—) | 100ms → 140ms+40.0ms |" in md
assert "### 问题演变" in md
assert "**[持续] 售后答非所问**(场景:售后场景)" in md
assert " 问题仍未收敛" in md
assert "**[消解] 响应偏慢**" in md
assert "### 建议落实情况" in md
assert "**[部分落实] 补充售后知识库**" in md
assert " 仅覆盖部分问题" in md
assert "**[新增] 新增建议:监控时延**" in md
def test_render_campaign_markdown_comparison_comes_after_analysis():
md = render_campaign_markdown(_campaign_report(), analysis=_analysis(), comparison=_comparison())
assert md.index("## 智能分析") < md.index("## 周期对比")
def test_render_campaign_markdown_without_comparison_unchanged():
md = render_campaign_markdown(_campaign_report())
assert "周期对比" not in md