"""Typed standalone HTTP boundary for G5 Calibration Mirror & Transfer.""" from __future__ import annotations import logging 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 ..contracts.calibration_transfer import ( ActualTransferAssessment, ActualTransferExecution, CompetencyCalibrationAssessment, MetacognitivePrescription, SubgroupDriftReport, TransferAssessment, TransferSuiteInput, ) from ..config import Settings, get_settings from ..deps import AIView, Principal, Role, db_for_ai_view, require_role from ..services import calibration_transfer_store, session_learning_producer router = APIRouter(tags=["calibration-transfer"]) logger = logging.getLogger(__name__) INTERNAL_TOKEN_HEADER = "X-Vignette-Calibration-Transfer-Token" MIN_INTERNAL_TOKEN_LENGTH = 32 _evaluator_db_provider = db_for_ai_view(AIView.EVALUATOR) async def calibration_transfer_internal_evaluator_db( settings: Annotated[Settings, Depends(get_settings)], presented_token: Annotated[ str | None, Header(alias=INTERNAL_TOKEN_HEADER) ] = None, ) -> AsyncIterator[asyncpg.Connection]: """Fail closed before acquiring evaluator-view DB state.""" configured_token = settings.calibration_transfer_internal_token.get_secret_value() if len(configured_token) < MIN_INTERNAL_TOKEN_LENGTH: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="internal calibration transfer 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(calibration_transfer_internal_evaluator_db), ] LearnerPrincipal = Annotated[Principal, Depends(require_role(Role.LEARNER))] TeacherPrincipal = Annotated[ Principal, Depends(require_role(Role.TEACHER, Role.ADMIN)) ] def _unique(values: list[UUID], field_name: str) -> list[UUID]: if len(set(values)) != len(values): raise ValueError(f"{field_name} must be unique") return values def _forbid_raw_or_total(payload: Any) -> None: serialized = str(payload).lower() forbidden = ( "raw_transcript", "transcript", "text_masked", "utterance_text", "total_score", "overall_score", ) if any(item in serialized for item in forbidden): raise ValueError( "payload cannot contain transcript text or aggregate score fields" ) class PredictionRevisionRequest(BaseModel): model_config = ConfigDict(extra="forbid") submission_id: UUID prediction_revision_id: UUID history_id: UUID session_id: UUID competency_id: str = Field(pattern=r"^competency\.[a-z0-9_.-]+$") practice_block_id: str = Field(pattern=r"^oas-g5-block-[a-z0-9-]+$") scenario_variant_id: str = Field(min_length=1, max_length=180) phrase_family_id: str = Field(min_length=1, max_length=180) revision_no: int = Field(ge=1) supersedes_prediction_revision_id: UUID | None = None predicted_success_probability: float = Field(ge=0.0, le=1.0) confidence: float = Field(ge=0.0, le=1.0) recorded_sequence: int = Field(ge=1) revision_reason: str = Field(min_length=1, max_length=300) instrument_id: str = Field(min_length=1, max_length=120) instrument_version: str = Field(min_length=1, max_length=40) evidence_turn_ids: list[UUID] = Field(default_factory=list, max_length=24) @field_validator("revision_reason") @classmethod def strip_reason(cls, value: str) -> str: stripped = value.strip() if not stripped: raise ValueError("revision_reason must not be blank") return stripped @field_validator("evidence_turn_ids") @classmethod def unique_evidence(cls, value: list[UUID]) -> list[UUID]: return _unique(value, "evidence_turn_ids") @model_validator(mode="after") def preserve_revision_chain(self) -> "PredictionRevisionRequest": if self.revision_no == 1 and self.supersedes_prediction_revision_id: raise ValueError("first revision cannot supersede another revision") if self.revision_no > 1 and not self.supersedes_prediction_revision_id: raise ValueError("later revision must supersede its predecessor") return self class PredictionRevisionResponse(BaseModel): submission_id: UUID history_id: UUID prediction_revision_id: UUID revision_no: int = Field(ge=1) idempotent_replay: bool class PredictionLockRequest(BaseModel): model_config = ConfigDict(extra="forbid") submission_id: UUID lock_id: UUID prediction_revision_id: UUID locked_sequence: int = Field(ge=1) class PredictionLockResponse(BaseModel): submission_id: UUID history_id: UUID lock_id: UUID idempotent_replay: bool class PerformanceObservationRequest(BaseModel): model_config = ConfigDict(extra="forbid", protected_namespaces=()) submission_id: UUID observation_id: UUID history_id: UUID status: Literal["passed", "failed", "insufficient_evidence"] source_kind: Literal["model_inferred", "observed_runtime"] perspective: Literal["independent_observer", "runtime_observation"] model_run_id: UUID | None = None instrument_id: str = Field(min_length=1, max_length=120) instrument_version: str = Field(min_length=1, max_length=40) uncertainty: float = Field(ge=0.0, le=1.0) evidence_turn_ids: list[UUID] = Field(default_factory=list, max_length=36) counterevidence: list[str] = Field(default_factory=list, max_length=36) revealed_sequence: int = Field(ge=1) @field_validator("evidence_turn_ids") @classmethod def unique_observation_evidence(cls, value: list[UUID]) -> list[UUID]: return _unique(value, "evidence_turn_ids") @model_validator(mode="after") def preserve_independent_provenance(self) -> "PerformanceObservationRequest": pairs = { ("model_inferred", "independent_observer"), ("observed_runtime", "runtime_observation"), } if (self.source_kind, self.perspective) not in pairs: raise ValueError("source_kind and perspective are incompatible") if self.source_kind == "model_inferred" and self.model_run_id is None: raise ValueError("model-inferred observation requires model_run_id") if self.status == "insufficient_evidence": if self.evidence_turn_ids or self.uncertainty != 1.0: raise ValueError( "insufficient observation must remain evidence-free" ) elif not self.evidence_turn_ids: raise ValueError("ready observation requires turn UUID evidence") if self.status == "failed" and not self.counterevidence: raise ValueError("failed observation requires counterevidence") return self class PerformanceObservationResponse(BaseModel): submission_id: UUID history_id: UUID observation_id: UUID idempotent_replay: bool class CalibrationAssessmentSubmissionRequest(BaseModel): model_config = ConfigDict(extra="forbid", protected_namespaces=()) submission_id: UUID assessment_snapshot_id: UUID prescription_id: UUID assessment: CompetencyCalibrationAssessment prescription: MetacognitivePrescription source_observation_ids: list[UUID] = Field(min_length=1, max_length=100) model_run_id: UUID instrument_id: str = Field(min_length=1, max_length=120) instrument_version: str = Field(min_length=1, max_length=40) evidence_turn_ids: list[UUID] = Field(default_factory=list, max_length=100) @field_validator("source_observation_ids", "evidence_turn_ids") @classmethod def unique_assessment_sources( cls, value: list[UUID], info: Any ) -> list[UUID]: return _unique(value, info.field_name) @model_validator(mode="after") def preserve_competency_and_evidence( self, ) -> "CalibrationAssessmentSubmissionRequest": if self.assessment.competency_id != self.prescription.competency_id: raise ValueError("assessment and prescription competency must match") if self.assessment.pair_count > 0 and not self.evidence_turn_ids: raise ValueError("observed calibration assessment requires turn evidence") _forbid_raw_or_total(self.assessment.model_dump(mode="json")) _forbid_raw_or_total(self.prescription.model_dump(mode="json")) return self class CalibrationAssessmentSubmissionResponse(BaseModel): submission_id: UUID assessment_snapshot_id: UUID prescription_id: UUID idempotent_replay: bool class TransferSuiteSubmissionRequest(BaseModel): model_config = ConfigDict(extra="forbid", protected_namespaces=()) submission_id: UUID transfer_suite_record_id: UUID suite: TransferSuiteInput model_run_id: UUID instrument_id: str = Field(min_length=1, max_length=120) instrument_version: str = Field(min_length=1, max_length=40) @model_validator(mode="after") def evidence_refs_are_uuid_only(self) -> "TransferSuiteSubmissionRequest": for trial in self.suite.trials: for ref in trial.evidence_refs: try: UUID(ref) except ValueError as exc: raise ValueError( "transfer evidence refs must be transcript turn UUIDs" ) from exc _forbid_raw_or_total(self.suite.model_dump(mode="json")) return self class TransferSuiteSubmissionResponse(BaseModel): submission_id: UUID transfer_suite_record_id: UUID trial_count: int = Field(ge=1) assessment_count: int = Field(ge=1) drift_report_count: int = Field(ge=1) idempotent_replay: bool class TeacherReviewRequest(BaseModel): model_config = ConfigDict(extra="forbid") submission_id: UUID review_id: UUID target_kind: Literal[ "calibration_assessment", "transfer_assessment", "drift_report" ] target_id: UUID disposition: Literal["confirmed", "corrected", "needs_more_evidence"] correction_payload: dict[str, Any] = Field(default_factory=dict) review_reason: str = Field(min_length=1, max_length=1000) evidence_turn_ids: list[UUID] = Field(default_factory=list, max_length=36) counterevidence: list[str] = Field(default_factory=list, max_length=36) @field_validator("review_reason") @classmethod def strip_review_reason(cls, value: str) -> str: stripped = value.strip() if not stripped: raise ValueError("review_reason must not be blank") return stripped @field_validator("evidence_turn_ids") @classmethod def unique_review_evidence(cls, value: list[UUID]) -> list[UUID]: return _unique(value, "evidence_turn_ids") @model_validator(mode="after") def correction_only_for_corrected(self) -> "TeacherReviewRequest": if self.disposition != "corrected" and self.correction_payload: raise ValueError("only corrected review may carry correction_payload") _forbid_raw_or_total(self.correction_payload) return self class TeacherReviewResponse(BaseModel): submission_id: UUID review_id: UUID review_no: int = Field(ge=1) idempotent_replay: bool class ActualTransferExecutionRequest(BaseModel): model_config = ConfigDict(extra="forbid") original_transfer_trial_record_id: UUID practice_session_id: UUID class ActualTransferExecutionResponse(BaseModel): execution: ActualTransferExecution assessment: ActualTransferAssessment idempotent_replay: bool class PredictionRevisionItem(BaseModel): prediction_revision_id: UUID submission_id: UUID history_id: UUID revision_no: int = Field(ge=1) supersedes_prediction_revision_id: UUID | None = None predicted_success_probability: float = Field(ge=0.0, le=1.0) confidence: float = Field(ge=0.0, le=1.0) recorded_sequence: int = Field(ge=1) revision_reason: str source_kind: Literal["learner_reported"] perspective: Literal["learner_self_report"] instrument_id: str instrument_version: str evidence_turn_ids: list[UUID] created_at: datetime class PredictionLockItem(BaseModel): lock_id: UUID submission_id: UUID history_id: UUID prediction_revision_id: UUID locked_sequence: int = Field(ge=1) created_at: datetime class PerformanceObservationItem(BaseModel): model_config = ConfigDict(protected_namespaces=()) observation_id: UUID submission_id: UUID history_id: UUID status: Literal["passed", "failed", "insufficient_evidence"] source_kind: Literal["model_inferred", "observed_runtime"] perspective: Literal["independent_observer", "runtime_observation"] model_run_id: UUID | None = None instrument_id: str instrument_version: str uncertainty: float = Field(ge=0.0, le=1.0) evidence_turn_ids: list[UUID] counterevidence: list[str] revealed_sequence: int = Field(ge=1) created_at: datetime class PredictionHistoryItem(BaseModel): history_id: UUID session_id: UUID competency_id: str practice_block_id: str scenario_variant_id: str phrase_family_id: str created_at: datetime revisions: list[PredictionRevisionItem] lock: PredictionLockItem | None = None external_observation: PerformanceObservationItem | None = None class CalibrationAssessmentItem(BaseModel): model_config = ConfigDict(protected_namespaces=()) assessment_snapshot_id: UUID submission_id: UUID session_id: UUID competency_id: str snapshot_no: int = Field(ge=1) supersedes_assessment_snapshot_id: UUID | None = None source_observation_ids: list[UUID] assessment_payload: CompetencyCalibrationAssessment model_run_id: UUID instrument_id: str instrument_version: str evidence_turn_ids: list[UUID] created_at: datetime prescription_id: UUID prescription_payload: MetacognitivePrescription class TransferTrialItem(BaseModel): model_config = ConfigDict(protected_namespaces=()) transfer_trial_record_id: UUID transfer_suite_record_id: UUID trial_key: str competency_id: str scenario_variant_id: str scenario_novelty: Literal["unseen_transfer"] context_variant: str relationship_style: Literal[ "collaborative", "withdrawn", "confrontational", "ambivalent" ] difficulty_level: int = Field(ge=1, le=5) expression_variant: str synthetic_subgroup: str scenario_family_id: str phrase_family_id: str status: Literal["passed", "failed", "insufficient_evidence"] uncertainty: float = Field(ge=0.0, le=1.0) evidence_turn_ids: list[UUID] counterevidence: list[str] model_run_id: UUID instrument_id: str instrument_version: str created_at: datetime class TransferAssessmentItem(BaseModel): model_config = ConfigDict(protected_namespaces=()) transfer_assessment_id: UUID transfer_suite_record_id: UUID competency_id: str source_trial_ids: list[UUID] assessment_payload: TransferAssessment evidence_turn_ids: list[UUID] model_run_id: UUID instrument_id: str instrument_version: str created_at: datetime class DriftReportItem(BaseModel): model_config = ConfigDict(protected_namespaces=()) drift_report_id: UUID transfer_suite_record_id: UUID competency_id: str source_trial_ids: list[UUID] report_payload: SubgroupDriftReport model_run_id: UUID instrument_id: str instrument_version: str data_classification: Literal["synthetic_educational"] clinical_claim_allowed: Literal[False] created_at: datetime class TransferSuiteItem(BaseModel): model_config = ConfigDict(protected_namespaces=()) transfer_suite_record_id: UUID submission_id: UUID suite_key: str session_id: UUID training_phrase_family_ids: list[str] model_run_id: UUID instrument_id: str instrument_version: str data_classification: Literal["synthetic_educational"] clinical_claim_allowed: Literal[False] created_at: datetime trials: list[TransferTrialItem] assessments: list[TransferAssessmentItem] drift_reports: list[DriftReportItem] class TeacherReviewItem(BaseModel): review_id: UUID submission_id: UUID target_kind: Literal[ "calibration_assessment", "transfer_assessment", "drift_report" ] target_id: UUID review_no: int = Field(ge=1) supersedes_review_id: UUID | None = None disposition: Literal["confirmed", "corrected", "needs_more_evidence"] correction_payload: dict[str, Any] review_reason: str evidence_turn_ids: list[UUID] counterevidence: list[str] created_by_uid: UUID created_by_role: Literal["instructor", "admin"] created_at: datetime class CalibrationTransferReadModelResponse(BaseModel): learner_id: UUID requested_view: Literal["learner", "supervisor"] clinical_claim_allowed: Literal[False] prediction_histories: list[PredictionHistoryItem] calibration_assessments: list[CalibrationAssessmentItem] transfer_suites: list[TransferSuiteItem] teacher_reviews: list[TeacherReviewItem] actual_executions: list[ActualTransferExecution] = Field(default_factory=list) actual_transfer_assessments: list[ActualTransferAssessment] = Field( default_factory=list ) def _http_error(exc: Exception) -> HTTPException: if isinstance( exc, calibration_transfer_store.CalibrationTransferNotFoundError ): return HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc)) if isinstance(exc, calibration_transfer_store.CalibrationTransferConflictError): return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) if isinstance(exc, calibration_transfer_store.CalibrationTransferStateError): return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) raise exc _STORE_ERRORS = ( calibration_transfer_store.CalibrationTransferNotFoundError, calibration_transfer_store.CalibrationTransferConflictError, calibration_transfer_store.CalibrationTransferStateError, ) @router.post( "/calibration/predictions/revisions", response_model=PredictionRevisionResponse, status_code=status.HTTP_201_CREATED, ) async def create_prediction_revision( body: PredictionRevisionRequest, principal: LearnerPrincipal, ) -> PredictionRevisionResponse: try: payload = await calibration_transfer_store.append_prediction_revision( principal=principal, **body.model_dump() ) except _STORE_ERRORS as exc: raise _http_error(exc) from exc return PredictionRevisionResponse.model_validate(payload) @router.post( "/calibration/predictions/{history_id}/lock", response_model=PredictionLockResponse, status_code=status.HTTP_201_CREATED, ) async def lock_prediction_history( history_id: UUID, body: PredictionLockRequest, principal: LearnerPrincipal, ) -> PredictionLockResponse: try: payload = await calibration_transfer_store.append_prediction_lock( principal=principal, history_id=history_id, **body.model_dump(), ) except _STORE_ERRORS as exc: raise _http_error(exc) from exc try: await session_learning_producer.produce_locked_prediction_history(history_id) except Exception: # lock 원장은 이미 별도 트랜잭션으로 커밋됐다. 외부 관찰 파생 실패는 # 잠금 응답을 실패시키거나 자기예측을 되돌리지 않는다. logger.exception( "calibration observation production failed after lock: history_id=%s", history_id, ) return PredictionLockResponse.model_validate(payload) @router.post( "/internal/calibration/performance-observations", response_model=PerformanceObservationResponse, status_code=status.HTTP_201_CREATED, ) async def create_performance_observation( body: PerformanceObservationRequest, conn: EvaluatorDB, ) -> PerformanceObservationResponse: try: payload = await calibration_transfer_store.append_performance_observation( conn=conn, **body.model_dump() ) except _STORE_ERRORS as exc: raise _http_error(exc) from exc return PerformanceObservationResponse.model_validate(payload) @router.post( "/internal/sessions/{session_id}/calibration/assessments", response_model=CalibrationAssessmentSubmissionResponse, status_code=status.HTTP_201_CREATED, ) async def create_calibration_assessment( session_id: UUID, body: CalibrationAssessmentSubmissionRequest, conn: EvaluatorDB, ) -> CalibrationAssessmentSubmissionResponse: try: payload = await calibration_transfer_store.append_calibration_assessment( conn=conn, session_id=session_id, **body.model_dump(), ) except _STORE_ERRORS as exc: raise _http_error(exc) from exc return CalibrationAssessmentSubmissionResponse.model_validate(payload) @router.post( "/internal/sessions/{session_id}/calibration/transfer-suites", response_model=TransferSuiteSubmissionResponse, status_code=status.HTTP_201_CREATED, ) async def create_transfer_suite( session_id: UUID, body: TransferSuiteSubmissionRequest, conn: EvaluatorDB, ) -> TransferSuiteSubmissionResponse: try: payload = await calibration_transfer_store.append_transfer_suite( conn=conn, session_id=session_id, submission_id=body.submission_id, transfer_suite_record_id=body.transfer_suite_record_id, suite=body.suite, model_run_id=body.model_run_id, instrument_id=body.instrument_id, instrument_version=body.instrument_version, ) except _STORE_ERRORS as exc: raise _http_error(exc) from exc return TransferSuiteSubmissionResponse.model_validate(payload) @router.post( "/calibration/transfer-executions", response_model=ActualTransferExecutionResponse, status_code=status.HTTP_201_CREATED, ) async def create_actual_transfer_execution( body: ActualTransferExecutionRequest, principal: LearnerPrincipal, ) -> ActualTransferExecutionResponse: try: payload = await calibration_transfer_store.append_actual_transfer_execution( principal=principal, **body.model_dump() ) except _STORE_ERRORS as exc: raise _http_error(exc) from exc return ActualTransferExecutionResponse.model_validate(payload) @router.post( "/calibration/reviews", response_model=TeacherReviewResponse, status_code=status.HTTP_201_CREATED, ) async def create_teacher_review( body: TeacherReviewRequest, principal: TeacherPrincipal, ) -> TeacherReviewResponse: try: payload = await calibration_transfer_store.append_teacher_review( principal=principal, **body.model_dump() ) except _STORE_ERRORS as exc: raise _http_error(exc) from exc return TeacherReviewResponse.model_validate(payload) @router.get( "/calibration/learners/me", response_model=CalibrationTransferReadModelResponse, ) async def get_my_calibration_transfer( principal: LearnerPrincipal, ) -> CalibrationTransferReadModelResponse: try: payload = await calibration_transfer_store.read_calibration_transfer( principal=principal ) except _STORE_ERRORS as exc: raise _http_error(exc) from exc return CalibrationTransferReadModelResponse.model_validate(payload) @router.get( "/calibration/learners/{learner_id}", response_model=CalibrationTransferReadModelResponse, ) async def get_learner_calibration_transfer( learner_id: UUID, principal: TeacherPrincipal, ) -> CalibrationTransferReadModelResponse: try: payload = await calibration_transfer_store.read_calibration_transfer( principal=principal, learner_id=learner_id ) except _STORE_ERRORS as exc: raise _http_error(exc) from exc return CalibrationTransferReadModelResponse.model_validate(payload) __all__ = ["router"]