feat(P01-03-01,P01-03-02): Pydantic scenario schema + refund YAML
server/scenarios/schema.py defines the typed model: Scenario (id, path, market, language, title, difficulty, failure_mode, persona, setup, success_criteria, common_mistakes, branches[], debrief) + Branch (id, trigger.learner_signals, outcome, failure_mode, debrief_focus) + ScenarioDebrief (model=deepseek-v4-flash:cloud, mode=no_think, D-020). failure_mode field present per D-009. server/scenarios/loader.py loads YAML → Pydantic, validates at load time, raises typed ValidationError on bad input. scenarios/customer_service_refund_ca_v01.yaml — the v0.1 Canada Customer Service scenario (D-010): 'Angry customer requesting refund on a damaged product', one branch point (accept_resolution vs escalate), failure_mode=escalates_unresolved, success criteria, common mistakes, debrief config. Matches the RESEARCH.md example. 5 unit tests pass (valid parse, invalid raises typed error, branch outcome Literal, branch_by_id, real YAML load). load() returns a valid Scenario with both branches. ---ci--- phase: 1 milestone: v0.1 plan: 03 task: 03-01,03-02 status: execute persona: data-engineer requirements: covered: [REQ-SCEN-01, REQ-SCEN-FMT-01] ---/ci---
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
"""Scenario runtime package — YAML → Pydantic → Pipecat Flows (D-018)."""
|
||||
|
||||
from server.scenarios.schema import (
|
||||
Branch,
|
||||
BranchTrigger,
|
||||
Scenario,
|
||||
ScenarioDebrief,
|
||||
ScenarioPersona,
|
||||
ScenarioSetup,
|
||||
ValidationError,
|
||||
)
|
||||
from server.scenarios.loader import load, load_all
|
||||
|
||||
__all__ = [
|
||||
"Scenario",
|
||||
"ScenarioPersona",
|
||||
"ScenarioSetup",
|
||||
"Branch",
|
||||
"BranchTrigger",
|
||||
"ScenarioDebrief",
|
||||
"ValidationError",
|
||||
"load",
|
||||
"load_all",
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Scenario loader — YAML → Pydantic Scenario (D-018).
|
||||
|
||||
Loads a scenario by id from the scenarios/ directory, validates it against the
|
||||
Pydantic schema, and returns a typed Scenario object. Used by the pipeline
|
||||
(TASK-03-07) and the e2e smoke test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from server.scenarios.schema import Scenario, ValidationError
|
||||
|
||||
_DEFAULT_SCENARIOS_DIR = Path(__file__).resolve().parent.parent.parent / "scenarios"
|
||||
|
||||
|
||||
def load(scenario_id: str, scenarios_dir: Path | None = None) -> Scenario:
|
||||
"""Load and validate a scenario by id.
|
||||
|
||||
Args:
|
||||
scenario_id: e.g. 'customer_service_refund_ca_v01' (the YAML filename stem).
|
||||
scenarios_dir: override the scenarios directory (default: repo /scenarios).
|
||||
|
||||
Returns:
|
||||
A validated Scenario object.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: if the YAML file doesn't exist.
|
||||
ValidationError: if the YAML fails schema validation (typed Pydantic error).
|
||||
"""
|
||||
base = scenarios_dir or _DEFAULT_SCENARIOS_DIR
|
||||
path = base / f"{scenario_id}.yaml"
|
||||
if not path.exists():
|
||||
# Try the id-with-cs-prefix alias (RESEARCH example used 'cs_refund_ca_v01').
|
||||
path = base / f"{scenario_id.replace('cs_', 'customer_service_')}.yaml"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Scenario YAML not found: {scenario_id} in {base}")
|
||||
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f)
|
||||
|
||||
return Scenario.model_validate(raw)
|
||||
|
||||
|
||||
def load_all(scenarios_dir: Path | None = None) -> list[Scenario]:
|
||||
"""Load all scenarios in the directory (for the future scenario library)."""
|
||||
base = scenarios_dir or _DEFAULT_SCENARIOS_DIR
|
||||
out: list[Scenario] = []
|
||||
for p in sorted(base.glob("*.yaml")):
|
||||
with p.open("r", encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f)
|
||||
out.append(Scenario.model_validate(raw))
|
||||
return out
|
||||
|
||||
|
||||
__all__ = ["load", "load_all", "ValidationError"]
|
||||
@@ -0,0 +1,100 @@
|
||||
"""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",
|
||||
]
|
||||
Reference in New Issue
Block a user