AgentEvalTool/backend/agenteval/web/routers/campaigns.py
sinohqb 7eae6de52d refactor(evaluation/storage): 结算统一与 repository 拆分(Phase 2 + 3)
合并两个不可分割的深化:

Phase 2 — 智能作业结算统一(ADR-0012)
- intelligence_jobs.execute(job_kind, campaign_id, ...) 作为结算的
  唯一实现:建行 → 认领 → 校验 → generating → 落账,一处编排、
  一处截断(500 字符)。两个 executor 退化为 ensure_queued /
  validate / work_fn 三个小 adapter。
- analysis.validate_analysis_request() 共享校验入口(活动终态 →
  模型),路由捕获映射 400、executor 捕获落 failed 行,与
  validate_comparison_request 先例同构。
- campaign_runner._auto_start_analysis 的跳过守卫收敛至
  auto_intelligence_eligible 单一判断点。
- comparison.py 删除零调用的 build_comparison_payload;
  load_comparison_view 投影归位至 campaign_read_model。
- 新增 characterization 测试(认领竞争、重复触发、截断、恢复上限)。

Phase 3 — storage/repository.py 拆分
- AsyncJobRepository 及两个子类迁至
  storage/async_job_repository.py(Phase 2 的 intelligence_jobs
  与 comparison 必须 import 自该路径,故与 Phase 2 同 commit)。
- ExplorationSession / ExplorationMessage 迁至
  storage/exploration_repository.py;repository.py 由 1180 行降至
  约 814 行,grep 确认无残留符号。
- exploration 子模块与路由 import 全部更新;测试 import 跟随。

刻意不做:CAS 共享原语、app.py 五 registry 关停顺序归一
(ADR-0006 精神,等真实需求出现再议)。
2026-08-24 05:50:27 +08:00

184 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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