合并两个不可分割的深化: 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 精神,等真实需求出现再议)。
184 lines
7.3 KiB
Python
184 lines
7.3 KiB
Python
"""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
|