"""Auto-baseline pairing + mechanical metric diff + narrative orchestration (v0.8).""" import json from datetime import timedelta import pytest from agenteval.evaluation.comparison import ( ComparisonError, campaign_plan_fingerprint, compute_metric_diff, narrate_period_comparison, resolve_auto_baseline, ) from agenteval.evaluation.intelligence_jobs import execute_campaign_comparison_job from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus, EvalRun, RunStatus, RunSummary from agenteval.storage.db import ( CampaignAnalysisDB, CampaignPeriodComparisonDB, ModelConfigDB, utc_now, ) from agenteval.storage.model_config_repository import ModelConfigRepository from agenteval.storage.repository import CampaignRepository, RunRepository from sqlmodel import select T0 = utc_now().replace(tzinfo=None) - timedelta(hours=1) def _campaign( campaign_id: str = "camp-1", *, target_id: str = "t-1", window_seconds: int = 86400, time_scale: float = 1.0, plan: list[CampaignPlanEntry] | None = None, status: CampaignStatus = CampaignStatus.COMPLETED, completed_at=T0 + timedelta(hours=1), ) -> Campaign: return Campaign( id=campaign_id, name=f"campaign-{campaign_id}", target_id=target_id, window_seconds=window_seconds, time_scale=time_scale, plan=plan or [ CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=2), CampaignPlanEntry(scenario_id="s-2", offset_seconds=3600, count=1), ], status=status, completed_at=completed_at, ) # ── 计划指纹 ────────────────────────────────────────────────────────────── def test_fingerprint_ignores_plan_entry_order(): a = _campaign() b = _campaign(plan=list(reversed(a.plan))) assert campaign_plan_fingerprint(a) == campaign_plan_fingerprint(b) def test_fingerprint_changes_with_any_plan_dimension(): base = campaign_plan_fingerprint(_campaign()) assert campaign_plan_fingerprint(_campaign(target_id="t-2")) != base assert campaign_plan_fingerprint(_campaign(window_seconds=3600)) != base assert campaign_plan_fingerprint( _campaign(plan=[CampaignPlanEntry(scenario_id="s-9", offset_seconds=0, count=2), CampaignPlanEntry(scenario_id="s-2", offset_seconds=3600, count=1)]) ) != base assert campaign_plan_fingerprint( _campaign(plan=[CampaignPlanEntry(scenario_id="s-1", offset_seconds=60, count=2), CampaignPlanEntry(scenario_id="s-2", offset_seconds=3600, count=1)]) ) != base assert campaign_plan_fingerprint( _campaign(plan=[CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=3), CampaignPlanEntry(scenario_id="s-2", offset_seconds=3600, count=1)]) ) != base def test_fingerprint_ignores_time_scale(): assert campaign_plan_fingerprint(_campaign()) == campaign_plan_fingerprint(_campaign(time_scale=24.0)) # ── 自动基线解析 ────────────────────────────────────────────────────────── def _seed_baseline(session, campaign: Campaign, *, analysis_status: str | None = "completed") -> None: CampaignRepository(session).create(campaign) if analysis_status is not None: session.add(CampaignAnalysisDB(campaign_id=campaign.id, status=analysis_status)) session.commit() def test_baseline_picks_most_recent_with_completed_analysis(db_session): old = _campaign("camp-old", completed_at=T0) recent = _campaign("camp-recent", completed_at=T0 + timedelta(minutes=30)) current = _campaign("camp-cur", completed_at=T0 + timedelta(hours=2)) _seed_baseline(db_session, old) _seed_baseline(db_session, recent) resolved = resolve_auto_baseline(current, db_session) assert resolved is not None and resolved.id == "camp-recent" def test_baseline_skips_candidates_without_completed_analysis(db_session): no_analysis = _campaign("camp-no", completed_at=T0) failed_analysis = _campaign("camp-failed", completed_at=T0 + timedelta(minutes=30)) current = _campaign("camp-cur", completed_at=T0 + timedelta(hours=2)) _seed_baseline(db_session, no_analysis, analysis_status=None) _seed_baseline(db_session, failed_analysis, analysis_status="failed") assert resolve_auto_baseline(current, db_session) is None def test_baseline_skips_accelerated_candidates_and_accelerated_current(db_session): accelerated = _campaign("camp-fast", time_scale=24.0, completed_at=T0) current = _campaign("camp-cur", completed_at=T0 + timedelta(hours=2)) _seed_baseline(db_session, accelerated) assert resolve_auto_baseline(current, db_session) is None # 本期自身为加速线 → 无自动基线 production = _campaign("camp-prod", completed_at=T0) _seed_baseline(db_session, production) assert resolve_auto_baseline(_campaign("camp-cur", time_scale=12.0), db_session) is None def test_baseline_skips_different_fingerprint(db_session): other_plan = _campaign("camp-other", window_seconds=3600, completed_at=T0) current = _campaign("camp-cur", completed_at=T0 + timedelta(hours=2)) _seed_baseline(db_session, other_plan) assert resolve_auto_baseline(current, db_session) is None def test_baseline_skips_later_or_same_moment_completion(db_session): later = _campaign("camp-later", completed_at=T0 + timedelta(hours=3)) current = _campaign("camp-cur", completed_at=T0 + timedelta(hours=2)) _seed_baseline(db_session, later) assert resolve_auto_baseline(current, db_session) is None same_moment = _campaign("camp-same", completed_at=current.completed_at) _seed_baseline(db_session, same_moment) assert resolve_auto_baseline(current, db_session) is None def test_baseline_skips_uncompleted_candidates(db_session): uncompleted = _campaign("camp-run", status=CampaignStatus.RUNNING, completed_at=None) current = _campaign("camp-cur", completed_at=T0 + timedelta(hours=2)) _seed_baseline(db_session, uncompleted) assert resolve_auto_baseline(current, db_session) is None # ── 机械指标 diff ───────────────────────────────────────────────────────── def _report(summary: dict, capability: list[dict]) -> dict: return {"summary": summary, "capability_summary": capability} def test_metric_diff_computes_overall_and_scenario_deltas(): baseline = _report( {"overall_pass_rate": 0.5, "overall_availability": 1.0, "avg_latency_ms": 800.0}, [ {"scenario_id": "s-1", "scenario_name": "售前", "pass_rate": 0.4, "availability": 1.0, "avg_latency_ms": 700.0}, {"scenario_id": "s-2", "scenario_name": "售后", "pass_rate": 0.6, "availability": 1.0, "avg_latency_ms": 900.0}, ], ) current = _report( {"overall_pass_rate": 0.8, "overall_availability": 0.75, "avg_latency_ms": 500.0}, [ {"scenario_id": "s-1", "scenario_name": "售前", "pass_rate": 0.9, "availability": 0.5, "avg_latency_ms": 400.0}, {"scenario_id": "s-2", "scenario_name": "售后", "pass_rate": 0.6, "availability": 1.0, "avg_latency_ms": 900.0}, ], ) diff = compute_metric_diff(baseline, current) assert diff["overall"]["pass_rate"] == {"baseline": 0.5, "current": 0.8, "delta": 0.3} assert diff["overall"]["availability"] == {"baseline": 1.0, "current": 0.75, "delta": -0.25} assert diff["overall"]["avg_latency_ms"] == {"baseline": 800.0, "current": 500.0, "delta": -300.0} scenarios = {s["scenario_id"]: s for s in diff["scenarios"]} assert scenarios["s-1"]["pass_rate"] == {"baseline": 0.4, "current": 0.9, "delta": 0.5} assert scenarios["s-1"]["availability"] == {"baseline": 1.0, "current": 0.5, "delta": -0.5} assert scenarios["s-1"]["avg_latency_ms"] == {"baseline": 700.0, "current": 400.0, "delta": -300.0} assert scenarios["s-2"]["pass_rate"]["delta"] == 0.0 assert [s["scenario_name"] for s in diff["scenarios"]] == ["售前", "售后"] def test_metric_diff_union_of_scenarios_missing_side_yields_none(): baseline = _report( {"overall_pass_rate": 0.5, "overall_availability": 1.0, "avg_latency_ms": 800.0}, [{"scenario_id": "s-1", "scenario_name": "售前", "pass_rate": 0.4, "availability": 1.0, "avg_latency_ms": 700.0}], ) current = _report( {"overall_pass_rate": 0.8, "overall_availability": 1.0, "avg_latency_ms": 500.0}, [{"scenario_id": "s-2", "scenario_name": "售后", "pass_rate": 0.9, "availability": 1.0, "avg_latency_ms": 400.0}], ) diff = compute_metric_diff(baseline, current) scenarios = {s["scenario_id"]: s for s in diff["scenarios"]} assert set(scenarios) == {"s-1", "s-2"} # s-1 仅基线有:current/delta 为 None assert scenarios["s-1"]["pass_rate"] == {"baseline": 0.4, "current": None, "delta": None} assert scenarios["s-1"]["avg_latency_ms"]["delta"] is None # s-2 仅本期有:baseline/delta 为 None assert scenarios["s-2"]["pass_rate"] == {"baseline": None, "current": 0.9, "delta": None} assert scenarios["s-2"]["availability"]["baseline"] is None def test_metric_diff_none_metrics_propagate(): baseline = _report( {"overall_pass_rate": None, "overall_availability": None, "avg_latency_ms": None}, [{"scenario_id": "s-1", "scenario_name": "售前", "pass_rate": None, "availability": None, "avg_latency_ms": None}], ) current = _report( {"overall_pass_rate": 0.8, "overall_availability": 1.0, "avg_latency_ms": 500.0}, [{"scenario_id": "s-1", "scenario_name": "售前", "pass_rate": 0.9, "availability": 1.0, "avg_latency_ms": 400.0}], ) diff = compute_metric_diff(baseline, current) assert diff["overall"]["pass_rate"]["delta"] is None assert diff["scenarios"][0]["pass_rate"]["delta"] is None assert diff["scenarios"][0]["pass_rate"]["current"] == 0.9 def test_metric_diff_empty_capability_summaries(): diff = compute_metric_diff( _report({"overall_pass_rate": None, "overall_availability": None, "avg_latency_ms": None}, []), _report({"overall_pass_rate": None, "overall_availability": None, "avg_latency_ms": None}, []), ) assert diff["scenarios"] == [] assert diff["overall"]["pass_rate"]["delta"] is None # ── 叙述编排(单次 LLM 调用) ───────────────────────────────────────────── class FakeChatClient: """Queued-response fake for the comparison LLM seam.""" def __init__(self, *responses): self._responses = list(responses) self.calls: list[list[dict]] = [] async def __call__(self, messages: list[dict]) -> str: self.calls.append(messages) if not self._responses: raise AssertionError("unexpected extra LLM call") item = self._responses.pop(0) if isinstance(item, Exception): raise item return item def _analysis(overall: str) -> dict: return { "overall": overall, "problems": [{"severity": "high", "title": "答非所问", "scenario_ids": ["s-1"]}], "suggestions": [{"priority": 1, "text": "补充意图语料"}], } def _diff() -> dict: return compute_metric_diff( {"summary": {"overall_pass_rate": 0.5, "overall_availability": 1.0, "avg_latency_ms": 800.0}, "capability_summary": [{"scenario_id": "s-1", "pass_rate": 0.5, "availability": 1.0, "avg_latency_ms": 800.0}]}, {"summary": {"overall_pass_rate": 0.9, "overall_availability": 1.0, "avg_latency_ms": 500.0}, "capability_summary": [{"scenario_id": "s-1", "pass_rate": 0.9, "availability": 1.0, "avg_latency_ms": 500.0}]}, ) NARRATION = json.dumps({ "trend": "improving", "summary": "整体通过率显著提升,售前答非所问问题缓解", "problem_evolution": [ {"status": "resolved", "title": "答非所问", "detail": "语料补充后恢复", "scenario_ids": ["s-1", "ghost-scenario"]}, {"status": "nonsense", "title": "新问题", "detail": "...", "scenario_ids": []}, ], "suggestion_tracking": [ {"text": "补充意图语料", "status": "addressed", "note": "已落实"}, {"text": "排查上游", "status": "nonsense", "note": "状态未知"}, {"text": "本期新增建议", "status": "new", "note": ""}, ], }) async def test_narration_input_carries_both_analyses_and_diff(): client = FakeChatClient(NARRATION) await narrate_period_comparison( baseline_analysis=_analysis("上期整体不达标"), current_analysis=_analysis("本期整体改善"), metric_diff=_diff(), valid_scenario_ids={"s-1"}, chat_client=client, ) assert len(client.calls) == 1 prompt = str(client.calls[0]) assert "上期整体不达标" in prompt assert "本期整体改善" in prompt assert "pass_rate" in prompt # 机械 diff 进入输入 async def test_narration_normalizes_and_whitelists(): client = FakeChatClient(NARRATION) result = await narrate_period_comparison( baseline_analysis=_analysis("a"), current_analysis=_analysis("b"), metric_diff=_diff(), valid_scenario_ids={"s-1"}, chat_client=client, ) assert result["trend"] == "improving" assert result["summary"].startswith("整体通过率显著提升") resolved = result["problem_evolution"][0] assert resolved["status"] == "resolved" assert resolved["scenario_ids"] == ["s-1"] # ghost-scenario 剔除 assert result["problem_evolution"][1]["status"] == "persisting" # 非法枚举归一 statuses = [s["status"] for s in result["suggestion_tracking"]] assert statuses == ["addressed", "unaddressed", "new"] # nonsense → unaddressed async def test_narration_normalizes_invalid_trend_to_stable(): client = FakeChatClient(json.dumps({"trend": "wild", "summary": "结论"})) result = await narrate_period_comparison( baseline_analysis={}, current_analysis={}, metric_diff={}, valid_scenario_ids=set(), chat_client=client, ) assert result["trend"] == "stable" assert result["problem_evolution"] == [] assert result["suggestion_tracking"] == [] async def test_unparseable_narration_raises_comparison_error(): client = FakeChatClient("这不是 JSON") with pytest.raises(ComparisonError): await narrate_period_comparison( baseline_analysis={}, current_analysis={}, metric_diff={}, valid_scenario_ids=set(), chat_client=client, ) async def test_narration_missing_summary_raises(): client = FakeChatClient(json.dumps({"trend": "stable"})) with pytest.raises(ComparisonError): await narrate_period_comparison( baseline_analysis={}, current_analysis={}, metric_diff={}, valid_scenario_ids=set(), chat_client=client, ) # ── 后台执行状态机 ─────────────────────────────────────────────────────── def _seed_config(session, config_id: str) -> None: ModelConfigRepository(session).create(ModelConfigDB( id=config_id, name=f"cfg-{config_id}", provider="openai_compatible", capability="chat", endpoint_url="https://models.example.com/v1/chat/completions", model_name="m", is_analysis_default=True, )) def _seed_campaign_with_analysis(session, campaign: Campaign, analysis_result: dict) -> None: CampaignRepository(session).create(campaign) row = CampaignAnalysisDB(campaign_id=campaign.id, status="completed") row.set_result(analysis_result) session.add(row) session.commit() RunRepository(session).create(EvalRun( id=f"run-{campaign.id}", target_id="t-1", scenario_id="s-1", campaign_id=campaign.id, status=RunStatus.COMPLETED, started_at=utc_now(), summary=RunSummary(total_cases=2, pass_rate=0.5, avg_latency_ms=700), )) async def test_execute_writes_completed_row_with_baseline_snapshot(db_session): _seed_config(db_session, "mc-default") baseline = _campaign("camp-base", completed_at=T0) current = _campaign("camp-cur", completed_at=T0 + timedelta(hours=2)) _seed_campaign_with_analysis(db_session, baseline, _analysis("上期")) _seed_campaign_with_analysis(db_session, current, _analysis("本期")) await execute_campaign_comparison_job( "camp-cur", triggered_by="manual", chat_client=FakeChatClient(NARRATION), session_factory=lambda: db_session, ) row = db_session.exec( select(CampaignPeriodComparisonDB).where(CampaignPeriodComparisonDB.campaign_id == "camp-cur") ).one() assert row.status == "completed" assert row.baseline_campaign_id == "camp-base" assert row.model_config_id == "mc-default" assert row.get_result()["trend"] == "improving" async def test_execute_records_failure_on_unparseable_output(db_session): _seed_config(db_session, "mc-default") baseline = _campaign("camp-base", completed_at=T0) current = _campaign("camp-cur", completed_at=T0 + timedelta(hours=2)) _seed_campaign_with_analysis(db_session, baseline, _analysis("上期")) _seed_campaign_with_analysis(db_session, current, _analysis("本期")) await execute_campaign_comparison_job( "camp-cur", triggered_by="manual", chat_client=FakeChatClient("garbage"), session_factory=lambda: db_session, ) row = db_session.exec( select(CampaignPeriodComparisonDB).where(CampaignPeriodComparisonDB.campaign_id == "camp-cur") ).one() assert row.status == "failed" assert row.error assert row.baseline_campaign_id == "camp-base" async def test_execute_fails_without_baseline_analysis(db_session): _seed_config(db_session, "mc-default") baseline = _campaign("camp-base", completed_at=T0) current = _campaign("camp-cur", completed_at=T0 + timedelta(hours=2)) CampaignRepository(db_session).create(baseline) # 基线无分析行 _seed_campaign_with_analysis(db_session, current, _analysis("本期")) await execute_campaign_comparison_job( "camp-cur", triggered_by="manual", chat_client=FakeChatClient(NARRATION), session_factory=lambda: db_session, ) row = db_session.exec( select(CampaignPeriodComparisonDB).where(CampaignPeriodComparisonDB.campaign_id == "camp-cur") ).one() assert row.status == "failed" assert "基线" in row.error