"""HTTP boundary for G2 educational longitudinal outcome trajectories.""" 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, ConfigDict, Field, field_validator, model_validator from ..contracts.measurement import MeasurementPerspective, SourceKind from ..contracts.outcome_trajectory import ( LongitudinalOutcomeAssessment, OutcomeAxis, RelationshipEventType, RelationshipMemoryProjection, SafetySignalReference, SyntheticExpectedDistribution, ) from ..deps import CurrentPrincipal, Principal, Role, require_role from ..services import feedback_policy, outcome_trajectory_store router = APIRouter(prefix="/sessions", tags=["outcome-trajectories"]) LearnerPrincipal = Annotated[Principal, Depends(require_role(Role.LEARNER))] TeacherPrincipal = Annotated[ Principal, Depends(require_role(Role.TEACHER, Role.ADMIN)), ] class ExpectedArcLabelResponse(BaseModel): schema_version: Literal["vignette.synthetic-outcome-arc.v1"] arc_id: str title_ko: str data_classification: Literal["synthetic_educational"] clinical_claim_allowed: Literal[False] provenance_note: str session_count: Literal[5] = 5 distributions: list[SyntheticExpectedDistribution] = Field( min_length=15, max_length=15 ) class OutcomeObservationResponse(BaseModel): model_config = ConfigDict(protected_namespaces=()) measurement_id: UUID | None = None session_id: UUID session_no: int = Field(ge=1, le=5) axis: OutcomeAxis status: Literal["observed", "missing", "error"] value: float | None = Field(default=None, ge=0.0, le=1.0) raw_value: float | None = None scale_min: float | None = None scale_max: float | None = None confidence: float | None = Field(default=None, ge=0.0, le=1.0) source_kind: SourceKind perspective: MeasurementPerspective instrument_id: str instrument_version: str model_run_id: UUID | None = None evidence_refs: list[str] = Field(default_factory=list) missing_reason: str | None = None occurred_at: datetime | None = None class OutcomeTrajectoryResponse(BaseModel): session_id: UUID revision_id: UUID revision_no: int = Field(ge=1) supersedes_revision_id: UUID | None = None source_fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$") recompute_reason: str computed_at: datetime notice_ko: str expected_arc: ExpectedArcLabelResponse assessment: LongitudinalOutcomeAssessment next_questions: list[str] = Field(default_factory=list) observations: list[OutcomeObservationResponse] safety_signals: list[SafetySignalReference] = Field(default_factory=list) relationship_memory: list[RelationshipMemoryProjection] = Field( default_factory=list ) class OutcomeTrajectoryRecomputeRequest(BaseModel): reason: str = Field(default="manual_recompute", min_length=1, max_length=300) @field_validator("reason") @classmethod def strip_reason(cls, value: str) -> str: stripped = value.strip() if not stripped: raise ValueError("reason must not be blank") return stripped class OutcomeAxisValues(BaseModel): model_config = ConfigDict(extra="forbid") distress_load: float = Field(ge=0.0, le=1.0) daily_functioning: float = Field(ge=0.0, le=1.0) learning_engagement: float = Field(ge=0.0, le=1.0) class OutcomeObservationSubmissionRequest(BaseModel): model_config = ConfigDict(extra="forbid") submission_id: UUID scores: OutcomeAxisValues confidences: OutcomeAxisValues 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 OutcomeObservationSubmissionResponse(OutcomeTrajectoryResponse): submission_id: UUID submitted_measurement_ids: list[UUID] = Field(min_length=3, max_length=3) RelationshipView = Literal[ "client", "counselor", "evaluator", "supervisor", "research" ] class RelationshipMemoryCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") event_type: RelationshipEventType summaries: dict[RelationshipView, str] = Field(min_length=1, max_length=5) evidence_turn_ids: tuple[UUID, ...] = Field(min_length=1, max_length=12) resolves_event_id: UUID | None = None @field_validator("summaries") @classmethod def normalize_summaries( cls, value: dict[RelationshipView, str] ) -> dict[RelationshipView, str]: normalized = {view: summary.strip() for view, summary in value.items()} if any(not summary for summary in normalized.values()): raise ValueError("relationship summaries must not be blank") return normalized @field_validator("evidence_turn_ids") @classmethod def unique_relationship_evidence( cls, value: tuple[UUID, ...] ) -> tuple[UUID, ...]: if len(set(value)) != len(value): raise ValueError("evidence_turn_ids must be unique") return value @model_validator(mode="after") def require_explicit_repair_target(self) -> "RelationshipMemoryCreateRequest": if self.event_type == "repair_confirmed" and self.resolves_event_id is None: raise ValueError("repair_confirmed requires resolves_event_id") if self.event_type != "repair_confirmed" and self.resolves_event_id is not None: raise ValueError("only repair_confirmed can resolve a relationship event") return self class RelationshipMemoryCreateResponse(BaseModel): memory_event_id: UUID status: Literal["recorded"] = "recorded" def _http_error(exc: Exception) -> HTTPException: if isinstance(exc, outcome_trajectory_store.OutcomeTrajectoryNotFoundError): return HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc)) if isinstance(exc, outcome_trajectory_store.OutcomeTrajectoryConflictError): return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) if isinstance(exc, outcome_trajectory_store.OutcomeTrajectoryStateError): return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) raise exc @router.get( "/{session_id}/outcome-trajectory", response_model=OutcomeTrajectoryResponse, ) async def get_outcome_trajectory( session_id: UUID, principal: CurrentPrincipal, ) -> OutcomeTrajectoryResponse: await feedback_policy.require_session_learner_feedback(session_id, principal) try: payload = await outcome_trajectory_store.read_outcome_trajectory( principal=principal, session_id=session_id, ) except ( outcome_trajectory_store.OutcomeTrajectoryNotFoundError, outcome_trajectory_store.OutcomeTrajectoryConflictError, outcome_trajectory_store.OutcomeTrajectoryStateError, ) as exc: raise _http_error(exc) from exc return OutcomeTrajectoryResponse.model_validate(payload) @router.post( "/{session_id}/outcome-trajectory/recompute", response_model=OutcomeTrajectoryResponse, status_code=status.HTTP_201_CREATED, ) async def recompute_outcome_trajectory( session_id: UUID, body: OutcomeTrajectoryRecomputeRequest, principal: CurrentPrincipal, ) -> OutcomeTrajectoryResponse: await feedback_policy.require_session_learner_feedback(session_id, principal) try: payload = await outcome_trajectory_store.read_outcome_trajectory( principal=principal, session_id=session_id, force_recompute=True, recompute_reason=body.reason, ) except ( outcome_trajectory_store.OutcomeTrajectoryNotFoundError, outcome_trajectory_store.OutcomeTrajectoryConflictError, outcome_trajectory_store.OutcomeTrajectoryStateError, ) as exc: raise _http_error(exc) from exc return OutcomeTrajectoryResponse.model_validate(payload) @router.post( "/{session_id}/outcome-observations", response_model=OutcomeObservationSubmissionResponse, status_code=status.HTTP_201_CREATED, ) async def create_outcome_observations( session_id: UUID, body: OutcomeObservationSubmissionRequest, principal: LearnerPrincipal, ) -> OutcomeObservationSubmissionResponse: try: payload = await outcome_trajectory_store.submit_outcome_observations( principal=principal, session_id=session_id, submission_id=body.submission_id, scores=body.scores.model_dump(), confidences=body.confidences.model_dump(), evidence_turn_ids=body.evidence_turn_ids, ) except ( outcome_trajectory_store.OutcomeTrajectoryNotFoundError, outcome_trajectory_store.OutcomeTrajectoryConflictError, outcome_trajectory_store.OutcomeTrajectoryStateError, ) as exc: raise _http_error(exc) from exc return OutcomeObservationSubmissionResponse.model_validate(payload) @router.post( "/{session_id}/relationship-memory-events", response_model=RelationshipMemoryCreateResponse, status_code=status.HTTP_201_CREATED, ) async def create_relationship_memory_event( session_id: UUID, body: RelationshipMemoryCreateRequest, principal: TeacherPrincipal, ) -> RelationshipMemoryCreateResponse: try: memory_event_id = ( await outcome_trajectory_store.append_relationship_memory_event( principal=principal, session_id=session_id, event_type=body.event_type, summaries=body.summaries, evidence_turn_ids=body.evidence_turn_ids, resolves_event_id=body.resolves_event_id, ) ) except ( outcome_trajectory_store.OutcomeTrajectoryNotFoundError, outcome_trajectory_store.OutcomeTrajectoryConflictError, outcome_trajectory_store.OutcomeTrajectoryStateError, ) as exc: raise _http_error(exc) from exc return RelationshipMemoryCreateResponse(memory_event_id=memory_event_id) __all__ = ["router"]