"""Report aggregation logic & output format validation (报告聚合与格式校验). 补充 test_campaign_report.py(数值口径)与 test_metrics.py(单点聚合)之外: - 聚合边界:用户取消不计分母(ADR-0004)、窗口外/窗口前运行钳制、 SQLite naive 时间戳与 aware 等价; - 格式校验:报告 dict 的键集合、类型、取值范围、时间序列连续性、 能力面排序与 ISO-UTC 时间戳格式。generate_campaign_report 是纯函数, 直接构造模型对象,不落库。 """ from datetime import datetime, timedelta, timezone from agenteval.evaluation.metrics import aggregate_runs from agenteval.evaluation.report import generate_campaign_report from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus, EvalRun, RunStatus T0 = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) def _campaign(*, window_seconds: int = 12, time_scale: float = 1.0, started_at=T0, completed_at=None) -> Campaign: return Campaign( id="camp-fmt", name="格式校验活动", target_id="t-1", window_seconds=window_seconds, time_scale=time_scale, plan=[CampaignPlanEntry(scenario_id="s-a", offset_seconds=0, count=1)], status=CampaignStatus.RUNNING, started_at=started_at, completed_at=completed_at, ) def _run( scenario_id: str = "s-a", *, status: RunStatus = RunStatus.COMPLETED, offset_seconds: float = 0.0, pass_rate: float | None = None, latency: float | None = None, error: dict | None = None, started_at: datetime | None = None, ) -> EvalRun: summary = None if pass_rate is not None or latency is not None or error is not None: summary = {} if pass_rate is not None: summary["pass_rate"] = pass_rate if latency is not None: summary["avg_latency_ms"] = latency if error is not None: summary["error"] = error return EvalRun( target_id="t-1", scenario_id=scenario_id, campaign_id="camp-fmt", status=status, started_at=started_at if started_at is not None else T0 + timedelta(seconds=offset_seconds), summary=summary, ) def _report(runs, **kwargs): return generate_campaign_report(_campaign(), runs, **kwargs) # ── 格式校验:顶层结构 ──────────────────────────────────────────────── def test_top_level_schema_keys_and_types(): report = _report([_run(pass_rate=1.0, latency=100)]) assert set(report) == { "campaign_id", "name", "target_id", "status", "window_seconds", "time_scale", "started_at", "completed_at", "summary", "time_trend", "capability_summary", } assert report["campaign_id"] == "camp-fmt" assert isinstance(report["window_seconds"], int) and report["window_seconds"] > 0 assert isinstance(report["time_scale"], float) assert report["status"] == CampaignStatus.RUNNING.value assert isinstance(report["time_trend"], list) assert isinstance(report["capability_summary"], list) def test_summary_schema_types_and_bounds(): report = _report( [ _run(pass_rate=1.0, latency=100), _run(scenario_id="s-b", status=RunStatus.FAILED), ] ) summary = report["summary"] assert set(summary) == { "total_runs", "completed_runs", "overall_pass_rate", "overall_availability", "avg_latency_ms", } assert isinstance(summary["total_runs"], int) and summary["total_runs"] == 2 assert isinstance(summary["completed_runs"], int) and summary["completed_runs"] == 1 for key in ("overall_pass_rate", "overall_availability"): value = summary[key] assert value is None or (isinstance(value, float) and 0.0 <= value <= 1.0), key assert summary["avg_latency_ms"] is None or summary["avg_latency_ms"] >= 0 def test_summary_matches_single_aggregation_seam(): """summary 数值必须与单点聚合口径 aggregate_runs 完全一致(ADR-0004)。""" runs = [ _run(pass_rate=1.0, latency=100), _run(pass_rate=0.5, latency=300), _run(status=RunStatus.FAILED), ] report = _report(runs) agg = aggregate_runs(runs) summary = report["summary"] assert summary["overall_pass_rate"] == agg["pass_rate"] assert summary["overall_availability"] == agg["availability"] assert summary["avg_latency_ms"] == agg["avg_latency_ms"] # ── 格式校验:时间趋势序列 ──────────────────────────────────────────── def test_time_trend_contiguous_buckets_with_custom_count(): report = generate_campaign_report(_campaign(window_seconds=12), [_run(pass_rate=1.0)], bucket_count=4) trend = report["time_trend"] assert len(trend) == 4 for idx, bucket in enumerate(trend): assert bucket["bucket_index"] == idx assert bucket["start_seconds"] == idx * 3.0 # window / bucket_count assert bucket["end_seconds"] == (idx + 1) * 3.0 assert {"run_count", "pass_rate", "availability", "avg_latency_ms"} <= set(bucket) # 相邻桶首尾相接 for prev, cur in zip(trend, trend[1:]): assert prev["end_seconds"] == cur["start_seconds"] def test_time_trend_bucket_types_and_bounds(): report = _report([_run(pass_rate=0.8, latency=120)]) for bucket in report["time_trend"]: assert isinstance(bucket["run_count"], int) and bucket["run_count"] >= 0 if bucket["run_count"] == 0: assert bucket["pass_rate"] is None and bucket["availability"] is None else: assert 0.0 <= bucket["pass_rate"] <= 1.0 assert 0.0 <= bucket["availability"] <= 1.0 # ── 格式校验:能力面汇总 ────────────────────────────────────────────── def test_capability_summary_schema_and_sorted_by_run_count_desc(): runs = [ _run("s-a", pass_rate=1.0), _run("s-a", pass_rate=0.5), _run("s-a", pass_rate=0.0), _run("s-b", pass_rate=1.0), ] report = _report(runs, scenario_names={"s-a": "夜间问诊"}) caps = report["capability_summary"] assert [c["scenario_id"] for c in caps] == ["s-a", "s-b"] # run_count 降序 for cap in caps: assert {"scenario_id", "scenario_name", "run_count", "pass_rate", "availability", "avg_latency_ms"} <= set(cap) assert cap["run_count"] >= 1 assert caps[0]["scenario_name"] == "夜间问诊" assert caps[1]["scenario_name"] == "s-b"[:8] # 无映射时回落 id 前缀 # ── 格式校验:时间戳 ISO-UTC ────────────────────────────────────────── def test_timestamps_serialize_as_iso_utc_or_none(): report = generate_campaign_report(_campaign(completed_at=T0 + timedelta(hours=1)), [_run()]) assert report["started_at"].endswith("Z") assert report["completed_at"].endswith("Z") assert "T" in report["started_at"] unfinished = generate_campaign_report(_campaign(), [_run()]) assert unfinished["completed_at"] is None def test_naive_campaign_timestamps_still_serialize_utc(): naive_start = datetime(2026, 1, 1, 0, 0, 0) # SQLite round-trip drops tzinfo report = generate_campaign_report(_campaign(started_at=naive_start), [_run()]) assert report["started_at"].endswith("Z") # ── 聚合边界:取消 / 钳制 / naive 时间 ──────────────────────────────── def test_cancelled_run_excluded_from_summary_but_counted(): runs = [ _run(pass_rate=1.0, latency=100), _run(status=RunStatus.FAILED, error={"code": "cancelled_by_user", "message": "stop"}), ] summary = _report(runs)["summary"] assert summary["total_runs"] == 2 # 发生过的事仍然可见 assert summary["completed_runs"] == 1 assert summary["overall_pass_rate"] == 1.0 # 取消不进分母(ADR-0004) assert summary["overall_availability"] == 1.0 def test_run_started_before_window_clamped_to_first_bucket(): report = _report([_run(pass_rate=1.0, started_at=T0 - timedelta(seconds=10))]) assert report["time_trend"][0]["run_count"] == 1 def test_run_started_after_window_clamped_to_last_bucket(): report = _report([_run(pass_rate=1.0, started_at=T0 + timedelta(seconds=100))]) assert report["time_trend"][-1]["run_count"] == 1 assert sum(b["run_count"] for b in report["time_trend"]) == 1 def test_naive_run_timestamps_bucket_same_as_aware(): aware = _run(pass_rate=1.0, started_at=T0 + timedelta(seconds=6)) naive = _run(pass_rate=1.0, started_at=datetime(2026, 1, 1, 0, 0, 6)) # SQLite 读回的 naive UTC trend_aware = _report([aware])["time_trend"] trend_naive = _report([naive])["time_trend"] assert trend_aware == trend_naive assert trend_naive[6]["run_count"] == 1 # window 12s / 12 buckets → offset 6 落第 6 桶 def test_unstarted_run_sits_at_offset_zero(): report = _report([_run(pass_rate=1.0, started_at=None)]) assert report["time_trend"][0]["run_count"] == 1