"""API routes for evaluation campaigns (评估活动). Creating a campaign starts its durable scheduler loop (``campaign_runner``), 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, Depends, HTTPException from pydantic import BaseModel, Field from sqlmodel import Session from agenteval.evaluation.campaign_runner import campaign_progress, request_cancel, start_campaign from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus from agenteval.storage.db import utc_now from agenteval.storage.repository import ( CampaignRepository, RunRepository, ScenarioRepository, TargetRepository, ) 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) @router.get("") async def list_campaigns(session: Session = Depends(get_db)) -> list[dict]: return [c.model_dump() for c in CampaignRepository(session).list_all()] @router.post("") async def create_campaign( request: CreateCampaignRequest, session: Session = Depends(get_db), ) -> dict: if not TargetRepository(session).get(request.target_id): raise HTTPException(status_code=404, detail="target not found") scenario_repo = ScenarioRepository(session) for entry in request.plan: if not scenario_repo.get(entry.scenario_id): raise HTTPException( status_code=404, detail=f"scenario not found: {entry.scenario_id}", ) campaign = Campaign( name=request.name, target_id=request.target_id, window_seconds=request.window_seconds, time_scale=request.time_scale, plan=request.plan, ) repo = CampaignRepository(session) campaign = repo.create(campaign) # Kick off the durable loop; it moves the campaign into RUNNING. start_campaign(campaign.id, session) return (repo.get(campaign.id) or campaign).model_dump() @router.post("/{campaign_id}/cancel") async def cancel_campaign(campaign_id: str, session: Session = Depends(get_db)) -> dict: repo = CampaignRepository(session) campaign = repo.get(campaign_id) if not campaign: raise HTTPException(status_code=404, detail="campaign not found") if campaign.status not in (CampaignStatus.PLANNED, CampaignStatus.RUNNING): raise HTTPException(status_code=400, detail="campaign is not in a cancellable state") campaign.status = CampaignStatus.CANCELLED campaign.completed_at = utc_now() repo.update(campaign) request_cancel(campaign_id) return campaign.model_dump() @router.get("/{campaign_id}") async def get_campaign(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") runs = RunRepository(session).list_by_campaign(campaign_id) data = campaign.model_dump() data["progress"] = campaign_progress(campaign, runs) return data