"""Praxis scenario schema — YAML DSL → Pydantic (D-018, D-009, D-010). Defines the typed model for a branching role-play scenario. Loaded from YAML by server/scenarios/loader.py. Drives Pipecat Flows (TASK-03-03). Per RESEARCH.md example + PROJECT.md D-010: one branch point (escalate vs accept), failure_mode field present (D-009 — not provoked in v0.1). """ from __future__ import annotations import re from typing import Literal from pydantic import BaseModel, Field, ValidationError, field_validator _SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$") class ScenarioPersona(BaseModel): """The AI character's voice + identity (D-006 — one voice for role-play + mentor).""" voice_id: str = Field(..., description="TTS voice id (Cartesia/Piper) — same as mentor per D-006") character: str = Field(..., description="Character name + role, e.g. 'Customer (Jordan)'") class ScenarioSetup(BaseModel): """The system prompt + opening line that start the role-play.""" system_prompt: str = Field(..., description="LLM system prompt (stays in character)") opening_line: str = Field(..., description="First TTS utterance the AI speaks") class BranchTrigger(BaseModel): """Learner signals that trigger a branch transition (R7 classification).""" learner_signals: list[str] = Field( ..., description="Signals the branch classifier looks for (e.g. 'empathy', 'defensive')" ) class Branch(BaseModel): """One branch outcome (D-010 — v0.1 has two: accept_resolution + escalate).""" id: str = Field(..., description="Branch id, e.g. 'accept_resolution' / 'escalate'") trigger: BranchTrigger outcome: Literal["success", "failure"] = Field(..., description="Branch outcome label") failure_mode: str | None = Field( None, description="D-009 failure_mode (present, not provoked in v0.1)" ) debrief_focus: str = Field(..., description="What the debrief emphasizes for this branch") class ScenarioDebrief(BaseModel): """Debrief generation config (D-020 — deepseek-v4-flash:cloud, no-think).""" model: str = Field("deepseek-v4-flash:cloud", description="Ollama model for the debrief") mode: Literal["no_think", "think", "max_think"] = Field( "no_think", description="Reasoning mode (no_think for latency, D-020)" ) prompt_template: str = Field( "debrief/default", description="Prompt template id (resolved by server/debrief.py)" ) class RubricMapping(BaseModel): """Maps a scenario to one rubric criterion (SLICE-02 — D-039). A scenario lists the rubric criteria it exercises; the scoring engine (SLICE-03) extracts evidence for each and scores against the rubric YAML. """ criterion_id: str = Field(..., description="Rubric criterion id, e.g. 'empathy'") weight: float | None = Field( None, description="Optional per-scenario weight override (defaults to rubric weight)" ) evidence_required: bool = Field( True, description="If True, the scorer must find evidence to score this criterion" ) class Scenario(BaseModel): """A Praxis role-play scenario (D-018 — YAML → Pydantic → Pipecat Flows). Extended in v0.3 (SLICE-02) with rubric mapping + IRT + provenance fields. All new fields have defaults so v0.1 scenario YAMLs still load unchanged. """ id: str = Field(..., description="Scenario id, e.g. 'cs_refund_ca_v01'") path: str = Field(..., description="Skill path, e.g. 'customer_service'") market: str = Field(..., description="Market code, e.g. 'CA'") language: str = Field("en-CA", description="Language code") title: str = Field(..., description="Human-readable scenario title") difficulty: int = Field(1, ge=1, le=5, description="Difficulty 1-5") failure_mode: str = Field( ..., description="D-009 failure_mode — present (not provoked in v0.1)" ) persona: ScenarioPersona setup: ScenarioSetup success_criteria: list[str] = Field(..., min_length=1) common_mistakes: list[str] = Field(..., min_length=1) branches: list[Branch] = Field(..., min_length=1, description="Branch points (v0.1: 2)") debrief: ScenarioDebrief rubric_criteria: list[RubricMapping] = Field( default_factory=list, description="Rubric criteria this scenario exercises (SLICE-02). Empty for v0.1 scenarios.", ) irt_target_p: float = Field( 0.7, ge=0.0, le=1.0, description="Target P for IRT scenario selection (D-035 default 0.7)" ) version: str = Field("1.0.0", description="Scenario semver (D-036)") generated_from: str | None = Field( None, description="AI-variation backref: parent scenario id if this was generated (D-036)" ) intent_hash: str | None = Field( None, description="Structural drift detection hash (D-036)" ) @field_validator("version") @classmethod def _validate_semver(cls, v: str) -> str: if not _SEMVER_RE.match(v): raise ValueError(f"invalid semver: {v!r}") return v def branch_ids(self) -> list[str]: return [b.id for b in self.branches] def branch_by_id(self, branch_id: str) -> Branch | None: for b in self.branches: if b.id == branch_id: return b return None def rubric_criterion_ids(self) -> list[str]: return [m.criterion_id for m in self.rubric_criteria] __all__ = [ "Scenario", "ScenarioPersona", "ScenarioSetup", "Branch", "BranchTrigger", "ScenarioDebrief", "RubricMapping", "ValidationError", ]