"""API routes for exploratory evaluation sessions (探索式评测, v0.9). The virtual user (OpenClaw) drives these sessions through plain HTTP: create a running session against a campaign, converse with the target through its real channel, then close with a structured self-reported experience record. Guardrails are a platform ledger — enforced here on every call, never trusted to client self-discipline. Budget overruns and state violations are rejected with 409 plus a readable reason, so the rejection itself is feedback to the resident agent. """ from datetime import timezone from typing import Any from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlmodel import Session from agenteval.channels.factory import ChannelFactory from agenteval.config import get_settings from agenteval.exploration.models import ( ExplorationBudget, ExplorationMessage, ExplorationSession, ExplorationSessionStatus, ExplorationTrigger, normalize_experience, resolve_budget, ) from agenteval.models import Campaign, CampaignStatus from agenteval.storage.db import utc_now from agenteval.storage.repository import ( CampaignRepository, ExplorationMessageRepository, ExplorationSessionRepository, TargetRepository, ) 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 _check_creation_guardrails( campaign: Campaign, triggered_by: ExplorationTrigger, budget: ExplorationBudget, repo: ExplorationSessionRepository, ) -> None: if campaign.status != CampaignStatus.RUNNING: raise HTTPException(status_code=409, detail="活动不在进行中,无法创建探索会话") if campaign.time_scale != 1 and triggered_by != ExplorationTrigger.MANUAL: raise HTTPException( status_code=409, detail="加速调试线仅允许手动创建探索会话(时间压缩与拟真相冲突)", ) sessions = repo.list_by_campaign(campaign.id) if len(sessions) >= budget.max_sessions: raise HTTPException( status_code=409, detail=f"探索会话数超出预算:本活动窗口最多 {budget.max_sessions} 个会话", ) if sessions: latest = max(s.created_at for s in sessions if s.created_at) if latest.tzinfo is None: # SQLite round-trip drops tzinfo; stored times are UTC latest = latest.replace(tzinfo=timezone.utc) elapsed = (utc_now() - latest).total_seconds() if elapsed < budget.min_interval_seconds: wait_minutes = budget.min_interval_seconds // 60 raise HTTPException( status_code=409, detail=f"相邻探索会话间隔不足:最小间隔 {wait_minutes} 分钟,请稍后再试", ) @router.post("/sessions") async def create_session( request: CreateSessionRequest, session: Session = Depends(get_db), ) -> dict: campaign = CampaignRepository(session).get(request.campaign_id) if not campaign: raise HTTPException(status_code=404, detail="campaign not found") if not TargetRepository(session).get(campaign.target_id): raise HTTPException(status_code=404, detail="campaign target not found") budget = resolve_budget(campaign) repo = ExplorationSessionRepository(session) _check_creation_guardrails(campaign, request.triggered_by, budget, repo) session_obj = ExplorationSession( campaign_id=campaign.id, target_id=campaign.target_id, persona=request.persona, goal=request.goal, seed_ref=request.seed_ref, triggered_by=request.triggered_by, ) return repo.create(session_obj).model_dump(mode="json") @router.post("/sessions/{session_id}/messages") async def send_session_message( session_id: str, request: SendMessageRequest, session: Session = Depends(get_db), ) -> dict: repo = ExplorationSessionRepository(session) session_obj = repo.get(session_id) if not session_obj: raise HTTPException(status_code=404, detail="exploration session not found") if session_obj.status != ExplorationSessionStatus.RUNNING: raise HTTPException(status_code=409, detail="探索会话不在进行中,拒收消息") campaign = CampaignRepository(session).get(session_obj.campaign_id) budget = resolve_budget(campaign) if campaign else ExplorationBudget() if session_obj.turn_count >= budget.max_turns: raise HTTPException( status_code=409, detail=f"会话轮数超出预算:单会话最多 {budget.max_turns} 轮", ) target = TargetRepository(session).get(session_obj.target_id) if not target: raise HTTPException(status_code=404, detail="session target not found") channel = ChannelFactory.create(target) sent_at = utc_now() try: send_result = await channel.send(request.content) except Exception as exc: # channel adapters raise transport-specific errors raise HTTPException(status_code=502, detail=f"评测对象通道发送失败: {exc}") if not send_result.ok: raise HTTPException( status_code=502, detail=f"评测对象通道发送失败: {send_result.error}", ) # 消息已送达即消耗一轮预算(平台账本):先落用户消息,再等回复。 message_repo = ExplorationMessageRepository(session) round_index = session_obj.turn_count + 1 message_repo.save_message( ExplorationMessage( session_id=session_obj.id, round_index=round_index, role="user", content=request.content, created_at=sent_at ) ) session_obj.turn_count = round_index repo.update(session_obj) try: reply = await channel.poll_reply( send_result.question_msg_id, timeout=get_settings().poll_reply_timeout, ) except Exception as exc: raise HTTPException(status_code=502, detail=f"等待评测对象回复失败: {exc}") if reply is None: raise HTTPException(status_code=502, detail="等待评测对象回复超时") received_at = utc_now() latency_ms = int((received_at - sent_at).total_seconds() * 1000) reply_text = str(reply.content) message_repo.save_message( ExplorationMessage( session_id=session_obj.id, round_index=round_index, role="assistant", content=reply_text, latency_ms=latency_ms, created_at=received_at, ) ) return {"reply": reply_text, "latency_ms": latency_ms, "turn_count": round_index} @router.post("/sessions/{session_id}/close") async def close_session( session_id: str, request: CloseSessionRequest, session: Session = Depends(get_db), ) -> dict: repo = ExplorationSessionRepository(session) session_obj = repo.get(session_id) if not session_obj: raise HTTPException(status_code=404, detail="exploration session not found") if session_obj.status != ExplorationSessionStatus.RUNNING: raise HTTPException(status_code=409, detail="探索会话不在进行中,无法关闭") session_obj.experience = normalize_experience(request.experience) session_obj.status = ExplorationSessionStatus.COMPLETED session_obj.closed_at = utc_now() return repo.update(session_obj).model_dump(mode="json")