"""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 from typing import Literal from pydantic import BaseModel, Field, ValidationError 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 Scenario(BaseModel): """A Praxis role-play scenario (D-018 — YAML → Pydantic → Pipecat Flows).""" 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 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 __all__ = [ "Scenario", "ScenarioPersona", "ScenarioSetup", "Branch", "BranchTrigger", "ScenarioDebrief", "ValidationError", ]