"""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.repository import ( CampaignRepository, ExplorationMessageRepository, ExplorationSessionRepository, ) 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")