"""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 ""), }