From 42be31dd1f75b974ad1a414b91b70ee6eed4d8b5 Mon Sep 17 00:00:00 2001 From: sinohqb Date: Tue, 4 Aug 2026 11:33:45 +0800 Subject: [PATCH] feat(report): add load_campaign_view as unified campaign read model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 活动级读模型单一出口:一次取齐报告 / 探索 / 分析 / 对比四大数据源。 markdown handler 从 30 行拼装逻辑缩到 3 行;分析执行器同步迁移。 comparison.py 内部的 8 次 load_campaign_report 调用暂不动(跨请求冗余, 缓存收益有限,改动风险高)。 - 新增 load_campaign_view(session, campaign) -> dict[str, Any] - 返回 {report, exploration, analysis, comparison} 四键 - 迁移 markdown handler 和分析执行器两个调用点 - 4 个新测试覆盖 view 的组装逻辑 --- backend/agenteval/evaluation/analysis.py | 9 +- backend/agenteval/evaluation/report.py | 27 ++++++ backend/agenteval/web/routers/campaigns.py | 21 +++-- tests/unit/test_campaign_view.py | 100 +++++++++++++++++++++ 4 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 tests/unit/test_campaign_view.py diff --git a/backend/agenteval/evaluation/analysis.py b/backend/agenteval/evaluation/analysis.py index 4086f3a..761c9f8 100644 --- a/backend/agenteval/evaluation/analysis.py +++ b/backend/agenteval/evaluation/analysis.py @@ -13,8 +13,7 @@ from typing import Any, Awaitable, Callable, Optional from sqlmodel import Session -from agenteval.evaluation.report import load_campaign_report -from agenteval.exploration.summary import summarize_campaign_exploration +from agenteval.evaluation.report import load_campaign_view from agenteval.model_gateway import ModelGateway from agenteval.models import Campaign, ModelCapability, RunStatus from agenteval.services.model_configs import ( @@ -306,14 +305,14 @@ async def execute_campaign_analysis( try: client = chat_client or gateway_chat_client(runtime) runs = RunRepository(session).list_by_campaign(campaign_id) - report = load_campaign_report(session, campaign) + view = load_campaign_view(session, campaign) result = await analyze_campaign( campaign=campaign, - report=report, + report=view["report"], failure_samples=collect_failure_samples(campaign_id, session), valid_run_ids={r.id for r in runs if r.id}, chat_client=client, - exploration_summary=summarize_campaign_exploration(session, campaign_id), + exploration_summary=view["exploration"], ) except Exception as exc: _logger.warning("活动 %s 智能分析失败: %s", campaign_id, exc) diff --git a/backend/agenteval/evaluation/report.py b/backend/agenteval/evaluation/report.py index 54c7804..8bea91b 100644 --- a/backend/agenteval/evaluation/report.py +++ b/backend/agenteval/evaluation/report.py @@ -360,6 +360,33 @@ def load_campaign_report(session: Session, campaign: Campaign) -> dict[str, Any] return generate_campaign_report(campaign, runs, scenario_names=ScenarioRepository(session).name_map()) +def load_campaign_view(session: Session, campaign: Campaign) -> dict[str, Any]: + """活动级读模型单一出口:一次取齐报告 / 探索 / 分析 / 对比。 + + 四个数据源总是被一起取(markdown 导出、分析执行器、前端报告抽屉), + 统一入口消除调用方的拼装逻辑。各子模块的取法保持不变,此处只做组合。 + """ + from agenteval.evaluation.comparison import load_comparison_view + from agenteval.exploration.summary import summarize_campaign_exploration + from agenteval.storage.repository import CampaignAnalysisRepository + + report = load_campaign_report(session, campaign) + exploration = summarize_campaign_exploration(session, 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 + + comparison_view = load_comparison_view(session, campaign) + comparison = comparison_view if comparison_view.get("status") != "none" else None + + return { + "report": report, + "exploration": exploration, + "analysis": analysis, + "comparison": comparison, + } + + def save_report(run_id: str, fmt: str = "html", output_dir: Optional[Path] = None) -> Path: """Generate a run report and save it to disk in the requested format.""" output_dir = output_dir or DATA_DIR / "reports" diff --git a/backend/agenteval/web/routers/campaigns.py b/backend/agenteval/web/routers/campaigns.py index 32074a0..d1acfc2 100644 --- a/backend/agenteval/web/routers/campaigns.py +++ b/backend/agenteval/web/routers/campaigns.py @@ -22,6 +22,7 @@ from agenteval.evaluation.comparison import ( from agenteval.evaluation.report import ( build_campaign_timeline, load_campaign_report, + load_campaign_view, summarize_campaign_progress, ) from agenteval.evaluation.report_render import render_campaign_markdown @@ -139,32 +140,30 @@ async def get_campaign_report_markdown(campaign_id: str, session: Session = Depe campaign = CampaignRepository(session).get(campaign_id) if not campaign: raise HTTPException(status_code=404, detail="campaign not found") - report = load_campaign_report(session, campaign) + view = load_campaign_view(session, campaign) scenario_names = ScenarioRepository(session).name_map() - 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 - view = load_comparison_view(session, campaign) - if view["status"] == "completed" and view["comparison"] and view["comparison"].get("result"): - cmp = view["comparison"] + if view["comparison"] and view["comparison"].get("status") == "completed": + cmp_view = view["comparison"] + cmp = cmp_view.get("comparison") or {} baseline = cmp.get("baseline") or {} comparison = { - "result": cmp["result"], + "result": cmp.get("result"), "baseline_name": baseline.get("name"), "baseline_completed_at": baseline.get("completed_at"), "model_name": cmp.get("model_name"), "updated_at": cmp.get("updated_at"), - "metric_diff": view["metric_diff"], + "metric_diff": cmp_view.get("metric_diff"), } md = render_campaign_markdown( - report, - analysis=analysis, + view["report"], + analysis=view["analysis"], comparison=comparison, - exploration=summarize_campaign_exploration(session, campaign_id), + exploration=view["exploration"], target_name=target_name, scenario_names=scenario_names, ) diff --git a/tests/unit/test_campaign_view.py b/tests/unit/test_campaign_view.py new file mode 100644 index 0000000..c9187b6 --- /dev/null +++ b/tests/unit/test_campaign_view.py @@ -0,0 +1,100 @@ +"""Tests for load_campaign_view — the unified campaign read model.""" + +from uuid import uuid4 + +from agenteval.evaluation.report import load_campaign_view +from agenteval.models import ( + Campaign, + CampaignPlanEntry, + CampaignStatus, + Case, + CaseType, + EvalTarget, + Scenario, +) +from agenteval.storage.repository import ( + CampaignAnalysisRepository, + CampaignRepository, + ScenarioRepository, + TargetRepository, +) + + +def _seed_campaign(db_session, *, status=CampaignStatus.COMPLETED): + target = TargetRepository(db_session).create( + EvalTarget(id=f"t-{uuid4().hex[:8]}", name="数字员工") + ) + scenario = ScenarioRepository(db_session).create( + Scenario( + id=f"s-{uuid4().hex[:8]}", + name="查账单", + target_id=target.id, + cases=[Case(id="c-1", type=CaseType.SINGLE, messages=["你好"])], + ) + ) + campaign = CampaignRepository(db_session).create( + Campaign( + name="cycle", + target_id=target.id, + window_seconds=3600, + time_scale=1.0, + plan=[CampaignPlanEntry(scenario_id=scenario.id, offset_seconds=0, count=1)], + status=status, + started_at=None, + ) + ) + return campaign + + +def test_view_returns_all_four_keys(db_session): + """View always returns report/exploration/analysis/comparison keys.""" + campaign = _seed_campaign(db_session) + view = load_campaign_view(db_session, campaign) + assert "report" in view + assert "exploration" in view + assert "analysis" in view + assert "comparison" in view + + +def test_view_with_no_analysis_or_comparison(db_session): + """No analysis, no comparison, no exploration → those keys are None.""" + campaign = _seed_campaign(db_session) + view = load_campaign_view(db_session, campaign) + assert view["report"] is not None + assert view["analysis"] is None + assert view["comparison"] is None + assert view["exploration"] is None + + +def test_view_with_completed_analysis(db_session): + """Completed analysis row → analysis key is non-None.""" + campaign = _seed_campaign(db_session) + analyses = CampaignAnalysisRepository(db_session) + analyses.upsert(campaign.id, status="completed", result={"summary": "ok"}) + view = load_campaign_view(db_session, campaign) + assert view["analysis"] is not None + assert view["analysis"]["summary"] == "ok" + + +def test_view_with_comparison(db_session): + """Completed comparison row → comparison key is non-None and contains metric_diff.""" + campaign = _seed_campaign(db_session) + # Create a baseline campaign for comparison + baseline = _seed_campaign(db_session) + comparisons = CampaignAnalysisRepository(db_session) + # Mark both as having completed analysis (required for comparison) + comparisons.upsert(baseline.id, status="completed", result={"summary": "baseline"}) + comparisons.upsert(campaign.id, status="completed", result={"summary": "current"}) + # Create a comparison row + from agenteval.storage.repository import CampaignPeriodComparisonRepository + + cmp_repo = CampaignPeriodComparisonRepository(db_session) + cmp_repo.upsert( + campaign.id, + status="completed", + baseline_campaign_id=baseline.id, + result={"evolution": []}, + ) + view = load_campaign_view(db_session, campaign) + assert view["comparison"] is not None + assert view["comparison"]["status"] == "completed"