AgentEvalTool/backend/agenteval/exploration/models.py
sinohqb a351f65550 feat(exploration): session lifecycle endpoints with platform hard guardrails
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.
2026-08-03 17:35:17 +08:00

100 lines
3.3 KiB
Python

"""Domain models and budget defaults for exploratory evaluation (v0.9).
Exploration sessions are an independent entity — never merged into EvalRun —
so pass-rate semantics (ADR-0002) and scenario comparability (ADR-0001) stay
untouched. Budget enforcement is a platform ledger: the defaults here are the
hard floor/ceiling, overridable per-campaign (ticket 02 wires the override).
"""
from datetime import datetime
from enum import Enum
from typing import Any, Optional
from pydantic import BaseModel, Field
from agenteval.models import Campaign
DEFAULT_MAX_SESSIONS_PER_WINDOW = 8
DEFAULT_MAX_TURNS_PER_SESSION = 12
DEFAULT_MIN_SESSION_INTERVAL_SECONDS = 30 * 60
VALID_EMOTIONS = ("positive", "neutral", "confused", "frustrated")
class ExplorationSessionStatus(str, Enum):
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
EXPIRED = "expired"
class ExplorationTrigger(str, Enum):
AUTO = "auto"
MANUAL = "manual"
class ExplorationMessage(BaseModel):
"""One chat message (role/content) inside an exploration session."""
id: Optional[str] = None
session_id: str
round_index: int = 0
role: str = "user"
content: str = ""
latency_ms: Optional[int] = None
created_at: Optional[datetime] = None
class ExplorationSession(BaseModel):
"""A virtual-user exploration session belonging to one campaign."""
id: Optional[str] = None
campaign_id: str
target_id: str
persona: dict[str, Any] = Field(default_factory=dict)
goal: str = ""
seed_ref: Optional[dict[str, Any]] = None
status: ExplorationSessionStatus = ExplorationSessionStatus.RUNNING
triggered_by: ExplorationTrigger = ExplorationTrigger.AUTO
experience: Optional[dict[str, Any]] = None
judge_review: Optional[dict[str, Any]] = None
turn_count: int = 0
error: Optional[str] = None
created_at: Optional[datetime] = None
closed_at: Optional[datetime] = None
class ExplorationBudget(BaseModel):
max_sessions: int = DEFAULT_MAX_SESSIONS_PER_WINDOW
max_turns: int = DEFAULT_MAX_TURNS_PER_SESSION
min_interval_seconds: int = DEFAULT_MIN_SESSION_INTERVAL_SECONDS
def resolve_budget(campaign: Campaign) -> ExplorationBudget:
"""Effective exploration budget for a campaign.
Currently platform defaults only; the campaign-level override field lands
in ticket 02 and plugs in here.
"""
return ExplorationBudget()
def normalize_experience(raw: dict[str, Any]) -> dict[str, Any]:
"""Whitelist-normalize a self-reported experience record (v0.7 precedent).
Invalid values are coerced to safe defaults rather than rejected, so a
sloppy virtual user still leaves a usable evidence row.
"""
raw_blockers = raw.get("blockers") if isinstance(raw.get("blockers"), list) else []
raw_misled = raw.get("misled") if isinstance(raw.get("misled"), list) else []
blockers = [str(item) for item in raw_blockers if isinstance(item, (str, int, float))]
misled = [str(item) for item in raw_misled if isinstance(item, (str, int, float))]
emotion = raw.get("emotion")
return {
"goal_achieved": bool(raw.get("goal_achieved")),
"blockers": blockers,
"misled": misled,
"emotion": emotion if emotion in VALID_EMOTIONS else "neutral",
"notes": str(raw.get("notes") or ""),
}