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

782 lines
26 KiB
Python

"""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,
feedback_policy,
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 | None = None
assessment: ActualTransferAssessment | None = None
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 _learner_input_only_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""자기예측 원문만 남기고 내부·교수자·AI 파생 판정을 제거한다."""
redacted = dict(payload)
redacted["prediction_histories"] = [
{**dict(history), "external_observation": None}
for history in payload.get("prediction_histories", [])
]
redacted["calibration_assessments"] = []
redacted["transfer_suites"] = []
redacted["teacher_reviews"] = []
redacted["actual_executions"] = []
redacted["actual_transfer_assessments"] = []
return redacted
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:
expose_feedback = await feedback_policy.can_expose_session_learner_feedback(
body.practice_session_id,
principal,
)
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
if not expose_feedback:
return ActualTransferExecutionResponse(
idempotent_replay=bool(payload.get("idempotent_replay", False))
)
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,
session_id: UUID | None = None,
) -> CalibrationTransferReadModelResponse:
try:
payload = await calibration_transfer_store.read_calibration_transfer(
principal=principal
)
except _STORE_ERRORS as exc:
raise _http_error(exc) from exc
payload = dict(payload)
snapshot_enabled = bool(
payload.pop("_learner_feedback_snapshot_enabled", True)
)
expose_feedback = bool(
principal.learner_feedback_enabled and snapshot_enabled
)
if session_id is not None:
expose_feedback = bool(
expose_feedback
and await feedback_policy.can_expose_session_learner_feedback(
session_id,
principal,
)
)
if not expose_feedback:
payload = _learner_input_only_payload(payload)
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"]