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 import json
from datetime import datetime
from typing import Any, Optional from typing import Any, Optional
from jinja2 import Template from jinja2 import Template
@ -213,25 +214,164 @@ def _render_analysis_lines(analysis: dict[str, Any], scenario_names: dict[str, s
return lines 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( def render_campaign_markdown(
report: dict[str, Any], report: dict[str, Any],
*, *,
analysis: Optional[dict[str, Any]] = None, analysis: Optional[dict[str, Any]] = None,
comparison: Optional[dict[str, Any]] = None,
target_name: Optional[str] = None,
scenario_names: Optional[dict[str, str]] = None, scenario_names: Optional[dict[str, str]] = None,
) -> str: ) -> str:
"""Render a dual-axis campaign report dict as Markdown. """Render a dual-axis campaign report dict as Markdown.
``analysis`` is the stored 智能分析 result dict (completed only); when ``analysis`` and ``comparison`` are the stored 智能分析 / 周期对比 results
absent the export is identical to the pre-analysis format. (completed only); when absent the corresponding appendix is omitted
entirely.
""" """
s = report["summary"] 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] = [ lines: list[str] = [
f"# 活动周期报告 — {report['name']}", f"# 活动周期报告 — {report['name']}",
"", "",
f"**状态**: {report['status']} ", f"**评测对象**: {target} ",
f"**窗口**: {report['window_seconds']}s倍速 {report['time_scale']} ", f"**状态**: {status} ",
f"**开始时间**: {report['started_at'] or '-'} ", _window_line(report) + " ",
f"**完成时间**: {report['completed_at'] or '-'} ", 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"]: for b in report["time_trend"]:
lines.append( 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'])} |" f"{_pct(b['pass_rate'])} | {_pct(b['availability'])} | {_ms(b['avg_latency_ms'])} |"
) )
lines += [ lines += [
@ -267,4 +407,6 @@ def render_campaign_markdown(
) )
if analysis: if analysis:
lines += _render_analysis_lines(analysis, scenario_names or {}) lines += _render_analysis_lines(analysis, scenario_names or {})
if comparison:
lines += _render_comparison_lines(comparison, scenario_names or {})
return "\n".join(lines) 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()} scenario_names = {s.id: s.name for s in ScenarioRepository(session).list_all()}
analysis_row = CampaignAnalysisRepository(session).get_by_campaign(campaign_id) analysis_row = CampaignAnalysisRepository(session).get_by_campaign(campaign_id)
analysis = analysis_row.get_result() if analysis_row and analysis_row.status == "completed" else None 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( md = render_campaign_markdown(
generate_campaign_report(campaign, runs, scenario_names=scenario_names), generate_campaign_report(campaign, runs, scenario_names=scenario_names),
analysis=analysis, analysis=analysis,
comparison=comparison,
target_name=target_name,
scenario_names=scenario_names, scenario_names=scenario_names,
) )
return Response( 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) select(CampaignPeriodComparisonDB).where(CampaignPeriodComparisonDB.campaign_id == current_id)
).all() ).all()
assert len(rows) == 1 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(): def test_render_campaign_markdown_empty_bucket_dashes():
md = render_campaign_markdown(_campaign_report()) md = render_campaign_markdown(_campaign_report())
# bucket 1 has no runs: pass_rate/availability/latency all render as — # 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: 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(): def test_render_campaign_markdown_without_analysis_unchanged():
md = render_campaign_markdown(_campaign_report()) md = render_campaign_markdown(_campaign_report())
assert "智能分析" not in md 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