vignette/apps/api/app/routes/deliberate_practices.py
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

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