合并两个不可分割的深化: 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 精神,等真实需求出现再议)。
123 lines
4.3 KiB
Python
123 lines
4.3 KiB
Python
"""API routes for exploratory evaluation sessions (探索式评测, v0.9).
|
||
|
||
领域逻辑(账本规则、状态机、巡检)在 exploration/lifecycle.py 与
|
||
patrol.py;本层只做 HTTP 翻译:解析请求、调用领域操作、把领域异常
|
||
映射为状态码(NotFound→404、Guardrail→409、Channel→502)。预算违规
|
||
与状态违规的拒绝文案由领域层给出,拒绝本身就是对常驻智能体的反馈。
|
||
"""
|
||
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel, Field
|
||
from sqlmodel import Session
|
||
|
||
from agenteval.exploration import lifecycle, patrol
|
||
from agenteval.exploration.errors import (
|
||
ExplorationChannelError,
|
||
ExplorationGuardrailError,
|
||
ExplorationNotFoundError,
|
||
)
|
||
from agenteval.exploration.models import ExplorationTrigger
|
||
from agenteval.storage.exploration_repository import ExplorationMessageRepository, ExplorationSessionRepository
|
||
from agenteval.storage.repository import CampaignRepository
|
||
from agenteval.web.deps import get_db
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
class CreateSessionRequest(BaseModel):
|
||
campaign_id: str
|
||
persona: dict[str, Any]
|
||
goal: str = Field(min_length=1)
|
||
seed_ref: dict[str, Any] | None = None
|
||
triggered_by: ExplorationTrigger = ExplorationTrigger.AUTO
|
||
|
||
|
||
class SendMessageRequest(BaseModel):
|
||
content: str = Field(min_length=1)
|
||
|
||
|
||
class CloseSessionRequest(BaseModel):
|
||
experience: dict[str, Any]
|
||
|
||
|
||
def _translate(exc: Exception) -> HTTPException:
|
||
if isinstance(exc, ExplorationNotFoundError):
|
||
return HTTPException(status_code=404, detail=str(exc))
|
||
if isinstance(exc, ExplorationGuardrailError):
|
||
return HTTPException(status_code=409, detail=exc.reason)
|
||
return HTTPException(status_code=502, detail=exc.reason)
|
||
|
||
|
||
@router.get("/patrol")
|
||
async def patrol_endpoint(session: Session = Depends(get_db)) -> dict:
|
||
"""Stateless patrol for the resident agent — see exploration/patrol.py."""
|
||
return patrol.patrol_report(session)
|
||
|
||
|
||
@router.post("/sessions")
|
||
async def create_session(
|
||
request: CreateSessionRequest,
|
||
session: Session = Depends(get_db),
|
||
) -> dict:
|
||
try:
|
||
session_obj = lifecycle.open_session(
|
||
session,
|
||
campaign_id=request.campaign_id,
|
||
persona=request.persona,
|
||
goal=request.goal,
|
||
seed_ref=request.seed_ref,
|
||
triggered_by=request.triggered_by,
|
||
)
|
||
except (ExplorationNotFoundError, ExplorationGuardrailError) as exc:
|
||
raise _translate(exc) from exc
|
||
return session_obj.model_dump(mode="json")
|
||
|
||
|
||
@router.get("/campaigns/{campaign_id}/sessions")
|
||
async def list_campaign_sessions(
|
||
campaign_id: str,
|
||
session: Session = Depends(get_db),
|
||
) -> dict:
|
||
if not CampaignRepository(session).get(campaign_id):
|
||
raise HTTPException(status_code=404, detail="campaign not found")
|
||
sessions = ExplorationSessionRepository(session).list_by_campaign(campaign_id)
|
||
return {"sessions": [s.model_dump(mode="json") for s in sessions]}
|
||
|
||
|
||
@router.get("/sessions/{session_id}/messages")
|
||
async def list_session_messages(
|
||
session_id: str,
|
||
session: Session = Depends(get_db),
|
||
) -> dict:
|
||
if not ExplorationSessionRepository(session).get(session_id):
|
||
raise HTTPException(status_code=404, detail="exploration session not found")
|
||
messages = ExplorationMessageRepository(session).list_by_session(session_id)
|
||
return {"messages": [m.model_dump(mode="json") for m in messages]}
|
||
|
||
|
||
@router.post("/sessions/{session_id}/messages")
|
||
async def send_session_message(
|
||
session_id: str,
|
||
request: SendMessageRequest,
|
||
session: Session = Depends(get_db),
|
||
) -> dict:
|
||
try:
|
||
return await lifecycle.conduct_turn(session, session_id=session_id, content=request.content)
|
||
except (ExplorationNotFoundError, ExplorationGuardrailError, ExplorationChannelError) as exc:
|
||
raise _translate(exc) from exc
|
||
|
||
|
||
@router.post("/sessions/{session_id}/close")
|
||
async def close_session(
|
||
session_id: str,
|
||
request: CloseSessionRequest,
|
||
session: Session = Depends(get_db),
|
||
) -> dict:
|
||
try:
|
||
updated = lifecycle.close_session(session, session_id=session_id, experience=request.experience)
|
||
except (ExplorationNotFoundError, ExplorationGuardrailError) as exc:
|
||
raise _translate(exc) from exc
|
||
return updated.model_dump(mode="json")
|