"""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, feedback_policy 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: expose_feedback = await feedback_policy.can_expose_session_learner_feedback( session_id, principal, ) 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 if not expose_feedback: items = [{**dict(item), "measurements": []} for item in items] 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"]