"""API routes for evaluation campaigns (评估活动). Creating a campaign starts its durable ``CampaignRuntime`` loop, which spawns child Runs across the (optionally compressed) service-cycle window until it finishes. Progress is authoritative in the DB, so detail queries report the live window position and spawned/completed Run counts, and a campaign can be cancelled mid-flight. """ from fastapi import APIRouter, Body, Depends, HTTPException, Response from pydantic import BaseModel, Field from sqlmodel import Session from agenteval.evaluation.analysis import AnalysisError, validate_analysis_request from agenteval.evaluation.campaign_lifecycle import CampaignCreateError, CampaignLifecycleError from agenteval.evaluation.campaign_lifecycle import cancel_campaign as cancel_campaign_lifecycle from agenteval.evaluation.campaign_lifecycle import create_campaign as create_campaign_lifecycle from agenteval.evaluation.campaign_read_model import CampaignReadModel from agenteval.evaluation.campaign_runner import campaign_runtime from agenteval.evaluation.comparison import ComparisonError, validate_comparison_request from agenteval.evaluation.intelligence_jobs import enqueue_campaign_analysis, enqueue_campaign_comparison from agenteval.evaluation.report_render import render_campaign_markdown from agenteval.models import CampaignPlanEntry, ExplorationBudgetConfig, ExplorationSeeds from agenteval.storage.repository import CampaignRepository from agenteval.web.deps import get_db router = APIRouter() class CreateCampaignRequest(BaseModel): name: str target_id: str window_seconds: int = Field(gt=0) time_scale: float = Field(default=1.0, gt=0) plan: list[CampaignPlanEntry] = Field(min_length=1) analysis_model_config_id: str | None = None exploration_seeds: ExplorationSeeds | None = None exploration_budget: ExplorationBudgetConfig | None = None @router.get("") async def list_campaigns(session: Session = Depends(get_db)) -> list[dict]: return CampaignReadModel(session).list_items() @router.post("") async def create_campaign( request: CreateCampaignRequest, session: Session = Depends(get_db), ) -> dict: try: campaign = create_campaign_lifecycle( session, name=request.name, target_id=request.target_id, window_seconds=request.window_seconds, time_scale=request.time_scale, plan=request.plan, analysis_model_config_id=request.analysis_model_config_id, exploration_seeds=request.exploration_seeds, exploration_budget=request.exploration_budget, launch=campaign_runtime.start, ) except CampaignCreateError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc return campaign.model_dump() @router.post("/{campaign_id}/cancel") async def cancel_campaign(campaign_id: str, session: Session = Depends(get_db)) -> dict: try: campaign = cancel_campaign_lifecycle(session, campaign_id, stop=campaign_runtime.cancel) except CampaignLifecycleError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc return campaign.model_dump() @router.get("/{campaign_id}/report") async def get_campaign_report(campaign_id: str, session: Session = Depends(get_db)) -> dict: report = CampaignReadModel(session).report(campaign_id) if report is None: raise HTTPException(status_code=404, detail="campaign not found") return report @router.get("/{campaign_id}/report/markdown") async def get_campaign_report_markdown(campaign_id: str, session: Session = Depends(get_db)) -> Response: projection = CampaignReadModel(session).markdown_projection(campaign_id) if projection is None: raise HTTPException(status_code=404, detail="campaign not found") md = render_campaign_markdown( projection["report"], analysis=projection["analysis"], comparison=projection["comparison"], exploration=projection["exploration"], target_name=projection["target_name"], scenario_names=projection["scenario_names"], ) return Response( content=md, media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="campaign-report-{campaign_id}.md"'}, ) @router.get("/{campaign_id}/timeline") async def get_campaign_timeline(campaign_id: str, session: Session = Depends(get_db)) -> dict: timeline = CampaignReadModel(session).timeline(campaign_id) if timeline is None: raise HTTPException(status_code=404, detail="campaign not found") return timeline @router.get("/{campaign_id}/analysis") async def get_campaign_analysis(campaign_id: str, session: Session = Depends(get_db)) -> dict: analysis = CampaignReadModel(session).analysis(campaign_id) if analysis is None: raise HTTPException(status_code=404, detail="campaign not found") return analysis @router.post("/{campaign_id}/analysis") async def trigger_campaign_analysis(campaign_id: str, session: Session = Depends(get_db)) -> dict: campaign = CampaignRepository(session).get(campaign_id) if not campaign: raise HTTPException(status_code=404, detail="campaign not found") try: validate_analysis_request(session, campaign) except AnalysisError as exc: raise HTTPException(status_code=400, detail=str(exc)) enqueue_campaign_analysis(campaign_id, triggered_by="manual") return {"status": "generating"} @router.get("/{campaign_id}/comparison") async def get_campaign_comparison(campaign_id: str, session: Session = Depends(get_db)) -> dict: """周期对比(v0.8):自动基线配对 + 叙述行状态 + 机械 diff(现算,不存储)。 ``metric_diff`` 对应「生效基线」:已有对比行时为该行记录的基线, 否则为自动基线;两者都没有时为 null。 """ comparison = CampaignReadModel(session).comparison(campaign_id) if comparison is None: raise HTTPException(status_code=404, detail="campaign not found") return comparison class GenerateComparisonRequest(BaseModel): baseline_campaign_id: str | None = None @router.post("/{campaign_id}/comparison") async def trigger_campaign_comparison( campaign_id: str, request: GenerateComparisonRequest = Body(default=None), session: Session = Depends(get_db), ) -> dict: campaign = CampaignRepository(session).get(campaign_id) if not campaign: raise HTTPException(status_code=404, detail="campaign not found") explicit_baseline_id = request.baseline_campaign_id if request else None try: baseline = validate_comparison_request(session, campaign, explicit_baseline_id=explicit_baseline_id) except ComparisonError as exc: raise HTTPException(status_code=400, detail=str(exc)) enqueue_campaign_comparison( campaign_id, triggered_by="manual", baseline_campaign_id=baseline.id, session=session, ) return {"status": "generating"} @router.get("/{campaign_id}") async def get_campaign(campaign_id: str, session: Session = Depends(get_db)) -> dict: campaign = CampaignReadModel(session).detail(campaign_id) if campaign is None: raise HTTPException(status_code=404, detail="campaign not found") return campaign