vignette/apps/api/app/routes/multimodal_alliance.py

601 lines
20 KiB
Python

"""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 feedback_policy, 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,
include_derived: bool = True,
) -> MultimodalSessionMetadataResponse:
expose_feedback = await feedback_policy.can_expose_session_learner_feedback(
session_id,
principal,
)
if include_derived and not expose_feedback:
await feedback_policy.require_session_learner_feedback(session_id, principal)
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
if not include_derived:
payload = {
**payload,
"timelines": [],
"word_timestamps": [],
"voice_events": [],
"measurements": [],
"fusion_decisions": [],
}
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"]