"""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 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.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: 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.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: 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.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: try: payload = await deliberate_practice_store.read_deliberate_practice( principal=principal ) 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) @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"]