Embed compact progress (completed/planned total + overall pass_rate, reusing the report's aggregation) into GET /campaigns so the list drops its N+1 report fetch. Poll list and open report drawer every 5s while the tab is active and a campaign is still running. Show scenario version and trigger source tags in the child-run drill-down.
135 lines
5.0 KiB
Python
135 lines
5.0 KiB
Python
"""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, Response
|
|
from pydantic import BaseModel, Field
|
|
from sqlmodel import Session
|
|
|
|
from agenteval.evaluation.campaign_runner import campaign_progress, request_cancel, start_campaign
|
|
from agenteval.evaluation.report import (
|
|
generate_campaign_report,
|
|
render_campaign_markdown_report,
|
|
summarize_campaign_progress,
|
|
)
|
|
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]:
|
|
repo = CampaignRepository(session)
|
|
run_repo = RunRepository(session)
|
|
result = []
|
|
for campaign in repo.list_all():
|
|
data = campaign.model_dump()
|
|
runs = run_repo.list_by_campaign(campaign.id)
|
|
data["progress"] = summarize_campaign_progress(campaign, runs)
|
|
result.append(data)
|
|
return result
|
|
|
|
|
|
@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}/report")
|
|
async def get_campaign_report(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)
|
|
scenario_names = {s.id: s.name for s in ScenarioRepository(session).list_all()}
|
|
return generate_campaign_report(campaign, runs, scenario_names=scenario_names)
|
|
|
|
|
|
@router.get("/{campaign_id}/report/markdown")
|
|
async def get_campaign_report_markdown(campaign_id: str, session: Session = Depends(get_db)) -> Response:
|
|
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)
|
|
scenario_names = {s.id: s.name for s in ScenarioRepository(session).list_all()}
|
|
md = render_campaign_markdown_report(campaign, runs, scenario_names=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}")
|
|
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
|