Files
praxis/server/scenarios/schema.py
T
Praxis CI 813bd586d6 docs(milestone): merge v0.3-mastery-scoring → main
v0.3 milestone merged to main. Mastery scoring + competency rubrics +
verifiable credentials (formative-tier) shipped. 13/13 REQ-IDs covered.
Next milestone: v0.4 (operator tier — cohort dashboard + auth + Postgres).

---ci---
project: praxis
phase: 2
milestone: v0.3
status: complete
milestone_complete: true
milestone_merged_to_main: true
---/ci---
2026-08-04 00:14:59 +00:00

149 lines
5.5 KiB
Python

"""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",
]