G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
585
apps/api/app/routes/multimodal_alliance.py
Normal file
585
apps/api/app/routes/multimodal_alliance.py
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
"""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"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue