vignette/apps/api/app/routes/deliberate_practices.py

423 lines
14 KiB
Python

"""Typed HTTP boundary for G4 deliberate-practice ledgers."""
from __future__ import annotations
import secrets
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Annotated, Any, Literal
from uuid import UUID
import asyncpg
from fastapi import APIRouter, Depends, Header, HTTPException, status
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from ..config import Settings, get_settings
from ..contracts.deliberate_practice import (
CoachingCard,
CompetencyGraph,
CurriculumDecision,
PracticeEpisodeInput,
PracticePrescription,
)
from ..deps import AIView, Principal, Role, db_for_ai_view, require_role
from ..services import deliberate_practice_store, feedback_policy
router = APIRouter(tags=["deliberate-practice"])
INTERNAL_TOKEN_HEADER = "X-Vignette-Practice-Token"
MIN_INTERNAL_TOKEN_LENGTH = 32
_evaluator_db_provider = db_for_ai_view(AIView.EVALUATOR)
async def practice_internal_evaluator_db(
settings: Annotated[Settings, Depends(get_settings)],
presented_token: Annotated[
str | None,
Header(alias=INTERNAL_TOKEN_HEADER),
] = None,
) -> AsyncIterator[asyncpg.Connection]:
"""Authenticate the internal caller before acquiring evaluator-view DB state."""
configured_token = settings.practice_internal_token.get_secret_value()
if len(configured_token) < MIN_INTERNAL_TOKEN_LENGTH:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="internal practice ingestion is unavailable",
)
if presented_token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="internal authentication required",
)
if not secrets.compare_digest(presented_token, configured_token):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="internal authentication failed",
)
async for conn in _evaluator_db_provider():
yield conn
EvaluatorDB = Annotated[
asyncpg.Connection,
Depends(practice_internal_evaluator_db),
]
LearnerPrincipal = Annotated[Principal, Depends(require_role(Role.LEARNER))]
TeacherPrincipal = Annotated[
Principal,
Depends(require_role(Role.TEACHER, Role.ADMIN)),
]
class PracticePrescriptionSubmissionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
submission_id: UUID
coaching_cards: list[CoachingCard] = Field(min_length=1, max_length=12)
competency_graph: CompetencyGraph
evidence_turn_ids: list[UUID] = Field(min_length=1, max_length=36)
@field_validator("evidence_turn_ids")
@classmethod
def unique_evidence(cls, value: list[UUID]) -> list[UUID]:
if len(set(value)) != len(value):
raise ValueError("evidence_turn_ids must be unique")
return value
class PracticePrescriptionSubmissionResponse(BaseModel):
submission_id: UUID
prescription_ids: list[str] = Field(min_length=1)
snapshot_id: UUID
decision_id: UUID
next_prescription_id: str
idempotent_replay: bool
class PracticeAttemptSubmissionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
submission_id: UUID
episode: PracticeEpisodeInput
class PracticeAttemptSubmissionResponse(BaseModel):
submission_id: UUID
progress: Literal["practicing", "transfer_pending", "mastered"]
mastery_allowed: bool
snapshot_id: UUID
decision_id: UUID
next_prescription_id: str
idempotent_replay: bool
@model_validator(mode="after")
def keep_mastery_explicit(self) -> "PracticeAttemptSubmissionResponse":
if (self.progress == "mastered") != self.mastery_allowed:
raise ValueError("only mastered practice may allow mastery")
return self
class PracticeTeacherCorrectionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
submission_id: UUID
corrected_outcome: Literal["passed", "needs_retry", "insufficient_evidence"]
correction_reason: str = Field(min_length=1, max_length=1000)
evidence_turn_ids: list[UUID] = Field(min_length=1, max_length=24)
counterevidence: list[str] = Field(default_factory=list, max_length=24)
@field_validator("correction_reason")
@classmethod
def strip_reason(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("correction_reason must not be blank")
return stripped
@field_validator("evidence_turn_ids")
@classmethod
def unique_correction_evidence(cls, value: list[UUID]) -> list[UUID]:
if len(set(value)) != len(value):
raise ValueError("evidence_turn_ids must be unique")
return value
class PracticeTeacherCorrectionResponse(BaseModel):
submission_id: UUID
correction_id: UUID
correction_no: int = Field(ge=1)
idempotent_replay: bool
class PracticeTeacherCorrectionItem(BaseModel):
correction_id: UUID
submission_id: UUID
attempt_record_id: UUID
correction_no: int = Field(ge=1)
supersedes_correction_id: UUID | None = None
corrected_outcome: Literal["passed", "needs_retry", "insufficient_evidence"]
correction_reason: str
evidence_turn_ids: list[UUID]
counterevidence: list[str]
created_by_uid: UUID
created_by_role: Literal["instructor", "admin"]
created_at: datetime
class PracticeAttemptItem(BaseModel):
model_config = ConfigDict(protected_namespaces=())
attempt_record_id: UUID
attempt_key: str
episode_submission_id: UUID
sequence_no: int = Field(ge=1)
scenario_variant_id: str
scenario_novelty: Literal["familiar", "unseen_transfer"]
difficulty_level: int = Field(ge=1, le=5)
criterion_status: Literal["observed", "not_observed", "error"]
client_response: str | None = None
outcome: Literal["passed", "needs_retry", "insufficient_evidence"]
utterance_template_id: str | None = None
learner_claimed_success: bool
uncertainty: float = Field(ge=0.0, le=1.0)
evidence_turn_ids: list[UUID]
counterevidence: list[str]
attempt_payload: dict[str, Any]
created_at: datetime
corrections: list[PracticeTeacherCorrectionItem] = Field(default_factory=list)
class PracticeEpisodeItem(BaseModel):
model_config = ConfigDict(protected_namespaces=())
episode_submission_id: UUID
episode_key: str
session_id: UUID
progress: Literal["practicing", "transfer_pending", "mastered"]
mastery_allowed: bool
mastery_blockers: list[str]
uncertainty: float = Field(ge=0.0, le=1.0)
evidence_turn_ids: list[UUID]
counterevidence: list[str]
assessment_payload: dict[str, Any]
created_at: datetime
attempts: list[PracticeAttemptItem] = Field(default_factory=list)
class PracticePrescriptionItem(BaseModel):
model_config = ConfigDict(protected_namespaces=())
prescription_record_id: UUID
prescription_key: str
session_id: UUID
competency_id: str
criterion_id: str
observable_behavior: str
activity_mode: Literal[
"replay",
"branch",
"constrained_response",
"voice_retry",
"difficulty_ladder",
]
scenario_variant_id: str
scenario_novelty: Literal["familiar", "unseen_transfer"]
difficulty_level: int = Field(ge=1, le=5)
prescription_payload: PracticePrescription
created_at: datetime
card_key: str
coach_claim: str
evidence_turn_ids: list[UUID]
source_refs: list[str]
uncertainty: float = Field(ge=0.0, le=1.0)
counterevidence: list[str]
class DeliberatePracticeReadModelResponse(BaseModel):
learner_id: UUID
clinical_claim_allowed: Literal[False]
prescriptions: list[PracticePrescriptionItem]
episodes: list[PracticeEpisodeItem]
competency_graph: CompetencyGraph | None = None
snapshot_id: UUID | None = None
snapshot_no: int | None = Field(default=None, ge=1)
next_practice: CurriculumDecision | None = None
decision_id: UUID | None = None
def _http_error(exc: Exception) -> HTTPException:
if isinstance(
exc,
deliberate_practice_store.DeliberatePracticeFeedbackDisabledError,
):
return HTTPException(
status.HTTP_403_FORBIDDEN,
detail=feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
)
if isinstance(exc, deliberate_practice_store.DeliberatePracticeNotFoundError):
return HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc))
if isinstance(exc, deliberate_practice_store.DeliberatePracticeConflictError):
return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, deliberate_practice_store.DeliberatePracticeStateError):
return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
raise exc
@router.post(
"/internal/sessions/{session_id}/practice/prescriptions",
response_model=PracticePrescriptionSubmissionResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_practice_prescriptions(
session_id: UUID,
body: PracticePrescriptionSubmissionRequest,
conn: EvaluatorDB,
) -> PracticePrescriptionSubmissionResponse:
try:
payload = await deliberate_practice_store.append_prescription_submission(
conn=conn,
session_id=session_id,
submission_id=body.submission_id,
coaching_cards=body.coaching_cards,
graph=body.competency_graph,
evidence_turn_ids=body.evidence_turn_ids,
)
except (
deliberate_practice_store.DeliberatePracticeNotFoundError,
deliberate_practice_store.DeliberatePracticeConflictError,
deliberate_practice_store.DeliberatePracticeStateError,
) as exc:
raise _http_error(exc) from exc
return PracticePrescriptionSubmissionResponse.model_validate(payload)
@router.post(
"/practice/{prescription_id}/attempts",
response_model=PracticeAttemptSubmissionResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_practice_attempt(
prescription_id: str,
body: PracticeAttemptSubmissionRequest,
principal: LearnerPrincipal,
) -> PracticeAttemptSubmissionResponse:
feedback_policy.require_principal_learner_feedback(principal)
try:
payload = await deliberate_practice_store.append_learner_attempt_submission(
principal=principal,
submission_id=body.submission_id,
prescription_id=prescription_id,
episode=body.episode,
)
except (
deliberate_practice_store.DeliberatePracticeNotFoundError,
deliberate_practice_store.DeliberatePracticeConflictError,
deliberate_practice_store.DeliberatePracticeFeedbackDisabledError,
deliberate_practice_store.DeliberatePracticeStateError,
) as exc:
raise _http_error(exc) from exc
return PracticeAttemptSubmissionResponse.model_validate(payload)
@router.post(
"/practice/{prescription_id}/attempts/from-session/{practice_session_id}",
response_model=PracticeAttemptSubmissionResponse,
status_code=status.HTTP_201_CREATED,
)
async def observe_completed_practice_session(
prescription_id: str,
practice_session_id: UUID,
principal: LearnerPrincipal,
) -> PracticeAttemptSubmissionResponse:
await feedback_policy.require_session_learner_feedback(
practice_session_id,
principal,
)
try:
payload = await deliberate_practice_store.append_runtime_practice_session(
principal=principal,
prescription_id=prescription_id,
practice_session_id=practice_session_id,
)
except (
deliberate_practice_store.DeliberatePracticeNotFoundError,
deliberate_practice_store.DeliberatePracticeConflictError,
deliberate_practice_store.DeliberatePracticeFeedbackDisabledError,
deliberate_practice_store.DeliberatePracticeStateError,
) as exc:
raise _http_error(exc) from exc
return PracticeAttemptSubmissionResponse.model_validate(payload)
@router.patch(
"/practice/attempts/{attempt_record_id}/correction",
response_model=PracticeTeacherCorrectionResponse,
status_code=status.HTTP_201_CREATED,
)
async def correct_practice_attempt(
attempt_record_id: UUID,
body: PracticeTeacherCorrectionRequest,
principal: TeacherPrincipal,
) -> PracticeTeacherCorrectionResponse:
try:
payload = await deliberate_practice_store.append_teacher_correction(
principal=principal,
attempt_record_id=attempt_record_id,
**body.model_dump(),
)
except (
deliberate_practice_store.DeliberatePracticeNotFoundError,
deliberate_practice_store.DeliberatePracticeConflictError,
deliberate_practice_store.DeliberatePracticeStateError,
) as exc:
raise _http_error(exc) from exc
return PracticeTeacherCorrectionResponse.model_validate(payload)
@router.get(
"/practice/learners/me",
response_model=DeliberatePracticeReadModelResponse,
)
async def get_my_deliberate_practice(
principal: LearnerPrincipal,
) -> DeliberatePracticeReadModelResponse:
feedback_policy.require_principal_learner_feedback(principal)
try:
payload = await deliberate_practice_store.read_deliberate_practice(
principal=principal
)
except (
deliberate_practice_store.DeliberatePracticeNotFoundError,
deliberate_practice_store.DeliberatePracticeConflictError,
deliberate_practice_store.DeliberatePracticeFeedbackDisabledError,
deliberate_practice_store.DeliberatePracticeStateError,
) as exc:
raise _http_error(exc) from exc
return DeliberatePracticeReadModelResponse.model_validate(payload)
@router.get(
"/practice/learners/{learner_id}",
response_model=DeliberatePracticeReadModelResponse,
)
async def get_learner_deliberate_practice(
learner_id: UUID,
principal: TeacherPrincipal,
) -> DeliberatePracticeReadModelResponse:
try:
payload = await deliberate_practice_store.read_deliberate_practice(
principal=principal,
learner_id=learner_id,
)
except (
deliberate_practice_store.DeliberatePracticeNotFoundError,
deliberate_practice_store.DeliberatePracticeConflictError,
deliberate_practice_store.DeliberatePracticeStateError,
) as exc:
raise _http_error(exc) from exc
return DeliberatePracticeReadModelResponse.model_validate(payload)
__all__ = ["router"]