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
736
apps/api/app/routes/calibration_transfer.py
Normal file
736
apps/api/app/routes/calibration_transfer.py
Normal file
|
|
@ -0,0 +1,736 @@
|
|||
"""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"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue