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 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
406
apps/api/app/routes/deliberate_practices.py
Normal file
406
apps/api/app/routes/deliberate_practices.py
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
"""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"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue