813bd586d6
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---
105 lines
3.6 KiB
Python
105 lines
3.6 KiB
Python
"""Praxis path schema — YAML DSL -> Pydantic (SLICE-05, D-037, REQ-PATH-02).
|
|
|
|
Defines the typed model for a 6-week competency path. Each week lists the
|
|
scenarios it exercises and a mastery gate (>= required_scenarios distinct
|
|
scenarios passed, >= required_score mean score per D-032). Loaded from
|
|
`paths/<slug>.yaml` by server/paths/engine.py.
|
|
|
|
Per PRD section 6.4 (D-037): exactly 6 weeks, numbered 1..6 sequentially.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
|
|
|
|
_REQUIRED_WEEKS = 6
|
|
_MIN_WEEK = 1
|
|
_MAX_WEEK = 6
|
|
_DEFAULT_REQUIRED_SCENARIOS = 3
|
|
_DEFAULT_REQUIRED_SCORE = 3.5
|
|
|
|
|
|
class WeekGate(BaseModel):
|
|
"""Mastery gate config for one week (D-032).
|
|
|
|
A week's gate opens when the learner has passed >= required_scenarios
|
|
distinct scenarios with a mean score >= required_score across those
|
|
passing scenarios.
|
|
"""
|
|
|
|
required_scenarios: int = Field(
|
|
_DEFAULT_REQUIRED_SCENARIOS,
|
|
ge=1,
|
|
description="Min distinct passed scenarios to open the gate (D-032 default 3)",
|
|
)
|
|
required_score: float = Field(
|
|
_DEFAULT_REQUIRED_SCORE,
|
|
ge=0.0,
|
|
description="Min mean score across passing scenarios to open the gate (D-032 default 3.5)",
|
|
)
|
|
|
|
|
|
class PathWeek(BaseModel):
|
|
"""One week in a 6-week competency path."""
|
|
|
|
week: int = Field(..., ge=_MIN_WEEK, le=_MAX_WEEK, description="Week number 1..6")
|
|
title: str = Field(..., min_length=1, description="Human-readable week title")
|
|
scenario_ids: list[str] = Field(
|
|
..., min_length=1, description="Scenario ids exercised this week (authored in SLICE-06)"
|
|
)
|
|
gate: WeekGate = Field(default_factory=WeekGate, description="Mastery gate for this week")
|
|
|
|
@field_validator("scenario_ids")
|
|
@classmethod
|
|
def _scenario_ids_unique(cls, v: list[str]) -> list[str]:
|
|
if len(v) != len(set(v)):
|
|
dupes = sorted({s for s in v if v.count(s) > 1})
|
|
raise ValueError(f"duplicate scenario_ids in week: {dupes}")
|
|
return v
|
|
|
|
|
|
class Path(BaseModel):
|
|
"""A 6-week competency path (D-037, PRD section 6.4)."""
|
|
|
|
slug: str = Field(..., min_length=1, description="Path slug, e.g. 'customer_service'")
|
|
name: str = Field(..., min_length=1, description="Human-readable path name")
|
|
skill: str = Field(..., min_length=1, description="Skill this path develops (matches a rubric id)")
|
|
weeks: list[PathWeek] = Field(..., description="Exactly 6 weeks, numbered 1..6 sequentially")
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_weeks(self) -> Path:
|
|
if len(self.weeks) != _REQUIRED_WEEKS:
|
|
raise ValueError(
|
|
f"path must have exactly {_REQUIRED_WEEKS} weeks (D-037 / PRD section 6.4), "
|
|
f"got {len(self.weeks)}"
|
|
)
|
|
seen = sorted(w.week for w in self.weeks)
|
|
expected = list(range(_MIN_WEEK, _MAX_WEEK + 1))
|
|
if seen != expected:
|
|
raise ValueError(
|
|
f"week numbers must be exactly 1..{_REQUIRED_WEEKS} sequential, got {seen}"
|
|
)
|
|
dupes = [w.week for w in self.weeks if [x.week for x in self.weeks].count(w.week) > 1]
|
|
if dupes:
|
|
raise ValueError(f"duplicate week numbers: {sorted(set(dupes))}")
|
|
return self
|
|
|
|
def week_by_number(self, week: int) -> PathWeek | None:
|
|
for w in self.weeks:
|
|
if w.week == week:
|
|
return w
|
|
return None
|
|
|
|
def all_scenario_ids(self) -> list[str]:
|
|
ids: list[str] = []
|
|
for w in self.weeks:
|
|
ids.extend(w.scenario_ids)
|
|
return ids
|
|
|
|
|
|
__all__ = [
|
|
"Path",
|
|
"PathWeek",
|
|
"WeekGate",
|
|
"ValidationError",
|
|
] |