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:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

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