"""Typed HTTP boundary for G7 multimodal alliance ledgers.""" from __future__ import annotations import secrets from collections.abc import AsyncIterator from datetime import UTC, datetime from pathlib import Path from typing import Annotated, Any, Literal from urllib.parse import unquote, urlsplit from uuid import UUID import asyncpg from fastapi import APIRouter, Depends, Header, HTTPException, status from fastapi.responses import FileResponse from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from ..config import Settings, get_settings from ..contracts.multimodal_alliance import ( AlignedVoiceTimeline, CalibratedAxisReadModel, FusionCalibration, ModalityAxisMeasurement, ) from ..deps import AIView, Principal, Role, db_for_ai_view, require_role from ..services import multimodal_alliance_store router = APIRouter(tags=["multimodal-alliance"]) INTERNAL_TOKEN_HEADER = "X-Vignette-Multimodal-Token" MIN_INTERNAL_TOKEN_LENGTH = 32 _evaluator_db_provider = db_for_ai_view(AIView.EVALUATOR) def _authenticate_internal(settings: Settings, presented_token: str | None) -> None: configured_token = settings.multimodal_alliance_internal_token.get_secret_value() if len(configured_token) < MIN_INTERNAL_TOKEN_LENGTH: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail="internal multimodal ingestion is unavailable", ) if presented_token is None: raise HTTPException( status.HTTP_401_UNAUTHORIZED, detail="internal authentication required", ) if not secrets.compare_digest(presented_token, configured_token): raise HTTPException( status.HTTP_403_FORBIDDEN, detail="internal authentication failed", ) async def multimodal_internal_evaluator_db( settings: Annotated[Settings, Depends(get_settings)], presented_token: Annotated[ str | None, Header(alias=INTERNAL_TOKEN_HEADER), ] = None, ) -> AsyncIterator[asyncpg.Connection]: """Authenticate before acquiring a connection or applying evaluator RLS.""" _authenticate_internal(settings, presented_token) async for conn in _evaluator_db_provider(): yield conn InternalDB = Annotated[ asyncpg.Connection, Depends(multimodal_internal_evaluator_db), ] LearnerPrincipal = Annotated[Principal, Depends(require_role(Role.LEARNER))] HumanPrincipal = Annotated[ Principal, Depends(require_role(Role.LEARNER, Role.TEACHER, Role.ADMIN)), ] RawAudioPrincipal = Annotated[ Principal, Depends(require_role(Role.LEARNER, Role.ADMIN)), ] class MultimodalConsentRequest(BaseModel): model_config = ConfigDict(extra="forbid") submission_id: UUID consent_status: Literal["granted", "withdrawn", "not_granted"] retain_audio: bool = False retain_derived_features: bool = False transcript_retained: Literal[True] = True retention_days: int | None = Field(default=None, ge=1, le=3650) policy_version: str = Field(min_length=1, max_length=80) reason_code: str | None = Field(default=None, min_length=1, max_length=120) @model_validator(mode="after") def preserve_consent_truth(self) -> "MultimodalConsentRequest": if self.consent_status == "granted": if not self.retain_derived_features or self.retention_days is None: raise ValueError( "granted consent requires derived retention and expiry" ) elif ( self.retain_audio or self.retain_derived_features or self.retention_days is not None ): raise ValueError("ungranted consent cannot retain voice material") return self class MultimodalConsentResponse(BaseModel): submission_id: UUID consent_snapshot_id: UUID consent_status: Literal["granted", "withdrawn", "not_granted"] deletion_request_id: UUID | None = None idempotent_replay: bool class MultimodalWithdrawalRequest(BaseModel): model_config = ConfigDict(extra="forbid") submission_id: UUID policy_version: str = Field(min_length=1, max_length=80) reason_code: str = Field(default="learner_withdrawal", min_length=1, max_length=120) transcript_retained: Literal[True] = True class AudioAssetMetadata(BaseModel): model_config = ConfigDict(extra="forbid") audio_ref: str = Field(min_length=1, max_length=300) audio_sha256: str = Field(pattern=r"^[a-f0-9]{64}$") media_type: Literal[ "audio/wav", "audio/webm", "audio/ogg", "audio/mpeg", "audio/mp4" ] byte_size: int = Field(gt=0, le=524_288_000) class MultimodalTimelineRequest(BaseModel): model_config = ConfigDict(extra="forbid") submission_id: UUID timeline: AlignedVoiceTimeline audio_asset: AudioAssetMetadata | None = None class MultimodalTimelineResponse(BaseModel): submission_id: UUID timeline_id: UUID audio_asset_id: UUID | None = None idempotent_replay: bool class MeasurementProvenance(BaseModel): model_config = ConfigDict(extra="forbid", protected_namespaces=()) instrument_id: str = Field(min_length=1, max_length=120) instrument_version: str = Field(min_length=1, max_length=80) model_name: str = Field(min_length=1, max_length=160) prompt_version: str = Field(min_length=1, max_length=80) class MultimodalMeasurementFusionRequest(BaseModel): model_config = ConfigDict(extra="forbid") submission_id: UUID text_measurement: ModalityAxisMeasurement text_provenance: MeasurementProvenance voice_measurement: ModalityAxisMeasurement voice_provenance: MeasurementProvenance calibration: FusionCalibration @model_validator(mode="after") def keep_modalities_independent(self) -> "MultimodalMeasurementFusionRequest": if self.text_measurement.modality != "text": raise ValueError("text_measurement must use text modality") if self.voice_measurement.modality != "voice": raise ValueError("voice_measurement must use voice modality") return self class MultimodalMeasurementFusionResponse(BaseModel): submission_id: UUID fusion_record_id: UUID result: CalibratedAxisReadModel idempotent_replay: bool class MultimodalDeletionRequest(BaseModel): model_config = ConfigDict(extra="forbid") submission_id: UUID scopes: list[Literal["audio", "derived_features"]] = Field( min_length=1, max_length=2 ) @field_validator("scopes") @classmethod def unique_scopes(cls, value: list[str]) -> list[str]: if len(set(value)) != len(value): raise ValueError("deletion scopes must be unique") return value class MultimodalDeletionRequestResponse(BaseModel): submission_id: UUID deletion_request_id: UUID idempotent_replay: bool class DeletionTombstoneInput(BaseModel): model_config = ConfigDict(extra="forbid") scope: Literal["audio", "derived_features"] target_ref_hash: str = Field(pattern=r"^[a-f0-9]{64}$") deletion_proof: str = Field(min_length=1, max_length=300) deleted_at: datetime class MultimodalDeletionCompletionRequest(BaseModel): model_config = ConfigDict(extra="forbid") submission_id: UUID actor_uid: UUID | None = None actor_kind: Literal["retention_worker", "admin"] tombstones: list[DeletionTombstoneInput] = Field(min_length=1, max_length=2) @field_validator("tombstones") @classmethod def unique_tombstone_scopes( cls, value: list[DeletionTombstoneInput] ) -> list[DeletionTombstoneInput]: if len({item.scope for item in value}) != len(value): raise ValueError("tombstone scopes must be unique") return value class MultimodalDeletionCompletionResponse(BaseModel): submission_id: UUID deletion_request_id: UUID tombstone_ids: list[UUID] = Field(min_length=1) idempotent_replay: bool class MultimodalRetentionSweepRequest(BaseModel): model_config = ConfigDict(extra="forbid") limit: int = Field(default=100, ge=1, le=100) class MultimodalRetentionSweepItem(BaseModel): audio_asset_id: UUID submission_id: UUID deletion_request_id: UUID idempotent_replay: bool class MultimodalRetentionSweepResponse(BaseModel): items: list[MultimodalRetentionSweepItem] class MultimodalSessionMetadataResponse(BaseModel): model_config = ConfigDict(protected_namespaces=()) session_id: UUID learner_id: UUID clinical_claim_allowed: Literal[False] consent_snapshots: list[dict[str, Any]] timelines: list[dict[str, Any]] word_timestamps: list[dict[str, Any]] voice_events: list[dict[str, Any]] measurements: list[dict[str, Any]] fusion_decisions: list[dict[str, Any]] deletion_requests: list[dict[str, Any]] class RawAudioAssetResponse(BaseModel): """Browser-safe raw-audio metadata; the private storage handle never crosses HTTP.""" model_config = ConfigDict(extra="ignore") audio_asset_id: UUID session_id: UUID media_type: str = Field(min_length=1, max_length=120) byte_size: int = Field(ge=0) duration_ms: int = Field(ge=0) retained_until: datetime created_at: datetime class RawAudioAccessResponse(BaseModel): items: list[RawAudioAssetResponse] def _raw_audio_storage_root(settings: Settings) -> Path: root = Path(settings.user_upload_dir) if not root.is_absolute(): root = Path.cwd() / root return (root / "multimodal-audio").resolve() def _resolve_private_audio_ref(settings: Settings, audio_ref: str) -> Path: """Resolve a private storage handle without exposing or escaping its root.""" parsed = urlsplit(audio_ref) if parsed.scheme != "private" or parsed.query or parsed.fragment: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail="raw audio storage adapter unavailable", ) relative = unquote(f"{parsed.netloc}{parsed.path}").replace("\\", "/").lstrip("/") if not relative: raise HTTPException( status.HTTP_404_NOT_FOUND, detail="raw audio object not found", ) root = _raw_audio_storage_root(settings) candidate = (root / relative).resolve() try: candidate.relative_to(root) except ValueError as exc: raise HTTPException( status.HTTP_404_NOT_FOUND, detail="raw audio object not found", ) from exc if not candidate.is_file(): raise HTTPException( status.HTTP_404_NOT_FOUND, detail="raw audio object not found", ) return candidate def _http_error(exc: Exception) -> HTTPException: if isinstance(exc, multimodal_alliance_store.MultimodalAllianceNotFoundError): return HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc)) if isinstance(exc, multimodal_alliance_store.MultimodalAllianceConflictError): return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) if isinstance( exc, ( multimodal_alliance_store.MultimodalConsentRequiredError, multimodal_alliance_store.MultimodalConsentWithdrawnError, ), ): return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) if isinstance(exc, multimodal_alliance_store.MultimodalAllianceStateError): return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) raise exc @router.post( "/sessions/{session_id}/multimodal-alliance/consent", response_model=MultimodalConsentResponse, status_code=status.HTTP_201_CREATED, ) async def create_multimodal_consent( session_id: UUID, body: MultimodalConsentRequest, principal: LearnerPrincipal, ) -> MultimodalConsentResponse: try: payload = await multimodal_alliance_store.append_consent_snapshot( principal=principal, session_id=session_id, **body.model_dump(), ) except multimodal_alliance_store.MultimodalAllianceError as exc: raise _http_error(exc) from exc return MultimodalConsentResponse.model_validate(payload) @router.post( "/sessions/{session_id}/multimodal-alliance/withdraw", response_model=MultimodalConsentResponse, status_code=status.HTTP_201_CREATED, ) async def withdraw_multimodal_consent( session_id: UUID, body: MultimodalWithdrawalRequest, principal: LearnerPrincipal, ) -> MultimodalConsentResponse: try: payload = await multimodal_alliance_store.append_consent_snapshot( principal=principal, session_id=session_id, submission_id=body.submission_id, consent_status="withdrawn", retain_audio=False, retain_derived_features=False, transcript_retained=body.transcript_retained, retention_days=None, policy_version=body.policy_version, reason_code=body.reason_code, ) except multimodal_alliance_store.MultimodalAllianceError as exc: raise _http_error(exc) from exc return MultimodalConsentResponse.model_validate(payload) @router.post( "/internal/sessions/{session_id}/multimodal-alliance/timelines", response_model=MultimodalTimelineResponse, status_code=status.HTTP_201_CREATED, ) async def create_multimodal_timeline( session_id: UUID, body: MultimodalTimelineRequest, conn: InternalDB, ) -> MultimodalTimelineResponse: try: payload = await multimodal_alliance_store.append_timeline( conn=conn, session_id=session_id, submission_id=body.submission_id, timeline=body.timeline, audio_asset=( body.audio_asset.model_dump() if body.audio_asset is not None else None ), ) except multimodal_alliance_store.MultimodalAllianceError as exc: raise _http_error(exc) from exc return MultimodalTimelineResponse.model_validate(payload) @router.post( "/internal/sessions/{session_id}/multimodal-alliance/measurements", response_model=MultimodalMeasurementFusionResponse, status_code=status.HTTP_201_CREATED, ) async def create_multimodal_measurement_fusion( session_id: UUID, body: MultimodalMeasurementFusionRequest, conn: InternalDB, ) -> MultimodalMeasurementFusionResponse: try: payload = await multimodal_alliance_store.append_measurement_fusion( conn=conn, session_id=session_id, submission_id=body.submission_id, text=body.text_measurement, voice=body.voice_measurement, calibration=body.calibration, text_provenance=body.text_provenance.model_dump(), voice_provenance=body.voice_provenance.model_dump(), ) except multimodal_alliance_store.MultimodalAllianceError as exc: raise _http_error(exc) from exc return MultimodalMeasurementFusionResponse.model_validate(payload) @router.post( "/sessions/{session_id}/multimodal-alliance/deletion-requests", response_model=MultimodalDeletionRequestResponse, status_code=status.HTTP_201_CREATED, ) async def request_multimodal_deletion( session_id: UUID, body: MultimodalDeletionRequest, principal: RawAudioPrincipal, ) -> MultimodalDeletionRequestResponse: try: payload = await multimodal_alliance_store.append_deletion_request( principal=principal, session_id=session_id, submission_id=body.submission_id, scopes=body.scopes, ) except multimodal_alliance_store.MultimodalAllianceError as exc: raise _http_error(exc) from exc return MultimodalDeletionRequestResponse.model_validate(payload) @router.post( "/internal/multimodal-alliance/deletion-requests/{deletion_request_id}/complete", response_model=MultimodalDeletionCompletionResponse, status_code=status.HTTP_201_CREATED, ) async def complete_multimodal_deletion( deletion_request_id: UUID, body: MultimodalDeletionCompletionRequest, conn: InternalDB, ) -> MultimodalDeletionCompletionResponse: try: payload = await multimodal_alliance_store.complete_deletion( conn=conn, deletion_request_id=deletion_request_id, submission_id=body.submission_id, tombstones=[item.model_dump() for item in body.tombstones], actor_uid=body.actor_uid, actor_kind=body.actor_kind, ) except multimodal_alliance_store.MultimodalAllianceError as exc: raise _http_error(exc) from exc return MultimodalDeletionCompletionResponse.model_validate(payload) @router.post( "/internal/multimodal-alliance/retention/sweep", response_model=MultimodalRetentionSweepResponse, ) async def sweep_multimodal_retention( body: MultimodalRetentionSweepRequest, conn: InternalDB, ) -> MultimodalRetentionSweepResponse: try: items = await multimodal_alliance_store.request_expired_retention_deletions( conn=conn, as_of=datetime.now(UTC), limit=body.limit, ) except multimodal_alliance_store.MultimodalAllianceError as exc: raise _http_error(exc) from exc return MultimodalRetentionSweepResponse.model_validate({"items": items}) @router.get( "/sessions/{session_id}/multimodal-alliance", response_model=MultimodalSessionMetadataResponse, ) async def get_multimodal_session_metadata( session_id: UUID, principal: HumanPrincipal, ) -> MultimodalSessionMetadataResponse: try: payload = await multimodal_alliance_store.read_session_metadata( principal=principal, session_id=session_id, ) except multimodal_alliance_store.MultimodalAllianceError as exc: raise _http_error(exc) from exc return MultimodalSessionMetadataResponse.model_validate(payload) @router.get( "/sessions/{session_id}/multimodal-alliance/raw-audio", response_model=RawAudioAccessResponse, ) async def get_multimodal_raw_audio_access( session_id: UUID, principal: RawAudioPrincipal, ) -> RawAudioAccessResponse: try: items = await multimodal_alliance_store.read_raw_audio_access( principal=principal, session_id=session_id, ) except multimodal_alliance_store.MultimodalAllianceError as exc: raise _http_error(exc) from exc return RawAudioAccessResponse(items=items) @router.get( "/sessions/{session_id}/multimodal-alliance/raw-audio/{audio_asset_id}", response_class=FileResponse, ) async def play_multimodal_raw_audio( session_id: UUID, audio_asset_id: UUID, settings: Annotated[Settings, Depends(get_settings)], principal: RawAudioPrincipal, ) -> FileResponse: """Stream a retained object through the authenticated API; never reveal its handle.""" try: asset = await multimodal_alliance_store.read_raw_audio_asset( principal=principal, session_id=session_id, audio_asset_id=audio_asset_id, ) except multimodal_alliance_store.MultimodalAllianceError as exc: raise _http_error(exc) from exc audio_path = _resolve_private_audio_ref(settings, str(asset["audio_ref"])) return FileResponse( path=audio_path, media_type=str(asset["media_type"]), headers={ "Cache-Control": "private, no-store", "Content-Disposition": "inline", "X-Content-Type-Options": "nosniff", }, ) __all__ = ["router"]