216 lines
6.9 KiB
Python
216 lines
6.9 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, 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"]
|