vignette/apps/api/app/routes/measurements.py
Yun Chan 16e791e044 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 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

210 lines
6.7 KiB
Python

"""Outcome & Alliance OS measurement routes.
The route surface keeps the reveal order explicit: a learner must lock a
self-assessment before the client-agent and independent-observer jobs are
scheduled. Scores are returned as separate goal/task/bond dimensions and are
never collapsed into a synthetic total.
"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator
from ..contracts.measurement import (
AllianceCheckpoint,
AllianceDimension,
AllianceScores,
MeasurementPerspective,
MeasurementStatus,
SourceKind,
)
from ..deps import CurrentPrincipal, Principal, Role, require_role
from ..services import alliance_measurement
router = APIRouter(prefix="/sessions", tags=["measurements"])
LearnerPrincipal = Annotated[Principal, Depends(require_role(Role.LEARNER))]
TeacherPrincipal = Annotated[
Principal,
Depends(require_role(Role.TEACHER, Role.ADMIN)),
]
class AlliancePulseCreateRequest(BaseModel):
checkpoint: AllianceCheckpoint
scores: AllianceScores
evidence_turn_ids: tuple[UUID, ...] = Field(default=(), max_length=12)
@field_validator("evidence_turn_ids")
@classmethod
def unique_evidence_turns(cls, value: tuple[UUID, ...]) -> tuple[UUID, ...]:
if len(set(value)) != len(value):
raise ValueError("evidence_turn_ids must be unique")
return value
class AlliancePulseAcceptedResponse(BaseModel):
pulse_id: UUID
status: Literal["awaiting_agents"] = "awaiting_agents"
idempotent_replay: bool = False
class AllianceEvidenceTurnResponse(BaseModel):
turn_id: UUID
seq: int
speaker: str
text: str
class AllianceMeasurementResponse(BaseModel):
measurement_id: UUID
dimension: AllianceDimension
perspective: MeasurementPerspective
source_kind: SourceKind
value: float | None = None
confidence: float | None = None
status: MeasurementStatus
error_code: str | None = None
rationale: str | None = None
evidence: list[AllianceEvidenceTurnResponse] = Field(default_factory=list)
created_at: datetime
class AlliancePulseResponse(BaseModel):
pulse_id: UUID
checkpoint: AllianceCheckpoint
status: Literal["awaiting_agents", "ready", "degraded", "error"]
learner_locked_at: datetime
revealed_at: datetime | None = None
error_code: str | None = None
self_scores: AllianceScores
measurements: list[AllianceMeasurementResponse] = Field(default_factory=list)
class AlliancePulseListResponse(BaseModel):
items: list[AlliancePulseResponse] = Field(default_factory=list)
class SupervisorAllianceRatingRequest(BaseModel):
scores: AllianceScores
evidence_turn_ids: tuple[UUID, ...] = Field(min_length=1, max_length=12)
note: str = Field(min_length=1, max_length=2000)
@field_validator("evidence_turn_ids")
@classmethod
def unique_evidence_turns(cls, value: tuple[UUID, ...]) -> tuple[UUID, ...]:
if len(set(value)) != len(value):
raise ValueError("evidence_turn_ids must be unique")
return value
@field_validator("note")
@classmethod
def strip_note(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("note must not be blank")
return stripped
class SupervisorAllianceRatingResponse(BaseModel):
status: Literal["recorded"] = "recorded"
def _measurement_http_error(exc: Exception) -> HTTPException:
if isinstance(exc, alliance_measurement.AlliancePulseNotFoundError):
return HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc))
if isinstance(exc, alliance_measurement.AlliancePulseConflictError):
return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, alliance_measurement.AlliancePulseStateError):
return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
raise exc
@router.post(
"/{session_id}/alliance-pulses",
response_model=AlliancePulseAcceptedResponse,
status_code=status.HTTP_202_ACCEPTED,
)
async def create_alliance_pulse(
session_id: UUID,
body: AlliancePulseCreateRequest,
principal: LearnerPrincipal,
) -> AlliancePulseAcceptedResponse:
try:
result = await alliance_measurement.create_locked_pulse(
principal=principal,
session_id=session_id,
checkpoint=body.checkpoint,
scores=body.scores,
evidence_turn_ids=body.evidence_turn_ids,
)
except (
alliance_measurement.AlliancePulseNotFoundError,
alliance_measurement.AlliancePulseConflictError,
alliance_measurement.AlliancePulseStateError,
) as exc:
raise _measurement_http_error(exc) from exc
# The transaction above is committed before either independent agent can
# run, so no model perspective can be revealed before learner lock-in.
if not result.idempotent_replay:
alliance_measurement.schedule_alliance_agents(result.pulse_id)
return AlliancePulseAcceptedResponse(
pulse_id=result.pulse_id,
idempotent_replay=result.idempotent_replay,
)
@router.get(
"/{session_id}/alliance-pulses",
response_model=AlliancePulseListResponse,
)
async def get_alliance_pulses(
session_id: UUID,
principal: CurrentPrincipal,
) -> AlliancePulseListResponse:
try:
items = await alliance_measurement.list_alliance_pulses(
principal=principal,
session_id=session_id,
)
except alliance_measurement.AlliancePulseNotFoundError as exc:
raise _measurement_http_error(exc) from exc
return AlliancePulseListResponse.model_validate({"items": items})
@router.post(
"/{session_id}/alliance-pulses/{pulse_id}/supervisor-rating",
response_model=SupervisorAllianceRatingResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_supervisor_alliance_rating(
session_id: UUID,
pulse_id: UUID,
body: SupervisorAllianceRatingRequest,
principal: TeacherPrincipal,
) -> SupervisorAllianceRatingResponse:
try:
await alliance_measurement.add_supervisor_rating(
principal=principal,
session_id=session_id,
pulse_id=pulse_id,
scores=body.scores,
evidence_turn_ids=body.evidence_turn_ids,
note=body.note,
)
except (
alliance_measurement.AlliancePulseNotFoundError,
alliance_measurement.AlliancePulseConflictError,
alliance_measurement.AlliancePulseStateError,
) as exc:
raise _measurement_http_error(exc) from exc
return SupervisorAllianceRatingResponse()
__all__ = ["router"]