v0.9 ticket 01. Independent exploration_sessions/exploration_messages entities (never merged into EvalRun, keeping ADR-0001/0002 semantics intact): create/message/close APIs forward virtual-user messages through the target's real channel, persist both parties' rows with latency, and close with a whitelist-normalized experience record. Budget enforcement is a platform ledger — sessions per window, turns per session, and session interval overruns return 409 with readable reasons; accelerated lines accept manual sessions only. Messages delivered but unanswered still consume a turn so timeouts cannot bypass the budget.
209 lines
7.6 KiB
Python
209 lines
7.6 KiB
Python
"""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")
|