Files
dreamweaver/backend/app/api/voice_sessions.py

317 lines
9.7 KiB
Python

"""Voice co-creation session APIs."""
from typing import Literal
from fastapi import (
APIRouter,
Depends,
File,
Form,
Query,
Response,
UploadFile,
status,
)
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.deps import require_user
from app.core.rate_limiter import check_rate_limit
from app.db.database import get_db
from app.db.models import User
from app.schemas.voice_session_schemas import (
VoiceSessionAbandonRequest,
VoiceSessionAnalyticsResponse,
VoiceSessionCreateRequest,
VoiceSessionDetailResponse,
VoiceSessionFinalizeRequest,
VoiceSessionFinalizeResponse,
VoiceSessionSummaryResponse,
VoiceTurnAcceptedResponse,
VoiceTurnConfirmRequest,
VoiceTurnCreateFallbackRequest,
VoiceTurnSummaryResponse,
VoiceTurnUploadAcceptedResponse,
)
from app.services.voice_session_service import (
abandon_voice_session_service,
create_voice_session_service,
create_voice_turn_from_text_service,
create_voice_turn_from_upload_service,
finalize_voice_session_service,
get_latest_active_voice_session_service,
get_voice_session_analytics_service,
get_voice_session_detail_service,
get_voice_turn_audio_service,
get_voice_turn_service,
get_voice_turn_user_audio_service,
list_voice_sessions_service,
resolve_voice_turn_confirmation_service,
retry_voice_turn_audio_service,
retry_voice_turn_service,
)
router = APIRouter()
VOICE_SESSION_RATE_LIMIT_WINDOW = 60
VOICE_SESSION_RATE_LIMIT_REQUESTS = 20
@router.post(
"/voice-sessions",
response_model=VoiceSessionSummaryResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_voice_session(
request: VoiceSessionCreateRequest,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Create one draft voice co-creation session."""
await check_rate_limit(
f"voice-session:{user.id}",
VOICE_SESSION_RATE_LIMIT_REQUESTS,
VOICE_SESSION_RATE_LIMIT_WINDOW,
)
return await create_voice_session_service(request, user.id, db)
@router.get("/voice-sessions", response_model=list[VoiceSessionSummaryResponse])
async def list_voice_sessions(
limit: int = Query(
default=settings.voice_session_default_list_limit,
ge=1,
le=settings.voice_session_max_list_limit,
),
active_only: bool = Query(default=False),
needs_attention: bool = Query(default=False),
attention_reason: (
Literal["pending_confirmation", "safety_intervention", "failed_turn"] | None
) = Query(default=None),
active_first: bool = Query(default=True),
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""List recent voice co-creation sessions for restore/resume behavior."""
return await list_voice_sessions_service(
user.id,
db,
limit=limit,
active_only=active_only,
needs_attention=needs_attention,
attention_reason=attention_reason,
active_first=active_first,
)
@router.get("/voice-sessions/active", response_model=VoiceSessionSummaryResponse | None)
async def get_latest_active_voice_session(
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Get the latest active voice session for quick resume behavior."""
return await get_latest_active_voice_session_service(user.id, db)
@router.get("/voice-sessions/analytics", response_model=VoiceSessionAnalyticsResponse)
async def get_voice_session_analytics(
days: int | None = Query(default=30, ge=1, le=365),
provider: str | None = Query(default=None, min_length=1, max_length=64),
session_status: (
Literal["draft", "active", "waiting_user", "completed", "abandoned"] | None
) = Query(default=None),
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Get aggregate voice co-creation analytics for the current user."""
return await get_voice_session_analytics_service(
user.id,
db,
days=days,
provider=provider,
session_status=session_status,
)
@router.get("/voice-sessions/{session_id}", response_model=VoiceSessionDetailResponse)
async def get_voice_session(
session_id: str,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Get one voice co-creation session with recent turns and events."""
return await get_voice_session_detail_service(session_id, user.id, db)
@router.post(
"/voice-sessions/{session_id}/turns/fallback",
response_model=VoiceTurnAcceptedResponse,
status_code=status.HTTP_202_ACCEPTED,
)
async def create_voice_turn_from_text(
session_id: str,
request: VoiceTurnCreateFallbackRequest,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Create one turn using text fallback before real audio upload is added."""
await check_rate_limit(
f"voice-turn:{user.id}",
VOICE_SESSION_RATE_LIMIT_REQUESTS,
VOICE_SESSION_RATE_LIMIT_WINDOW,
)
return await create_voice_turn_from_text_service(session_id, request, user.id, db)
@router.post(
"/voice-sessions/{session_id}/turns",
response_model=VoiceTurnUploadAcceptedResponse,
status_code=status.HTTP_202_ACCEPTED,
)
async def create_voice_turn_from_upload(
session_id: str,
audio_file: UploadFile = File(...),
duration_ms: int | None = Form(default=None),
transcript_hint: str | None = Form(default=None),
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Create one turn from uploaded audio and configured ASR behavior."""
await check_rate_limit(
f"voice-turn:{user.id}",
VOICE_SESSION_RATE_LIMIT_REQUESTS,
VOICE_SESSION_RATE_LIMIT_WINDOW,
)
audio_bytes = await audio_file.read()
return await create_voice_turn_from_upload_service(
session_id=session_id,
user_id=user.id,
audio_bytes=audio_bytes,
file_name=audio_file.filename or "voice-turn.webm",
mime_type=audio_file.content_type,
duration_ms=duration_ms,
transcript_hint=transcript_hint,
db=db,
)
@router.get(
"/voice-sessions/{session_id}/turns/{turn_id}",
response_model=VoiceTurnSummaryResponse,
)
async def get_voice_turn(
session_id: str,
turn_id: str,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Get one processed turn within a voice session."""
return await get_voice_turn_service(session_id, turn_id, user.id, db)
@router.post(
"/voice-sessions/{session_id}/turns/{turn_id}/retry",
response_model=VoiceTurnAcceptedResponse,
status_code=status.HTTP_202_ACCEPTED,
)
async def retry_voice_turn(
session_id: str,
turn_id: str,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Retry one failed voice turn using its saved transcript."""
return await retry_voice_turn_service(session_id, turn_id, user.id, db)
@router.post(
"/voice-sessions/{session_id}/turns/{turn_id}/confirm",
response_model=VoiceTurnSummaryResponse,
)
async def resolve_voice_turn_confirmation(
session_id: str,
turn_id: str,
request: VoiceTurnConfirmRequest,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Resolve one pending confirmation before continuing the session."""
return await resolve_voice_turn_confirmation_service(
session_id,
turn_id,
request,
user.id,
db,
)
@router.get("/voice-sessions/{session_id}/turns/{turn_id}/audio")
async def get_voice_turn_audio(
session_id: str,
turn_id: str,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Get synthesized assistant audio for one completed voice turn."""
audio_bytes = await get_voice_turn_audio_service(session_id, turn_id, user.id, db)
return Response(content=audio_bytes, media_type="audio/mpeg")
@router.post(
"/voice-sessions/{session_id}/turns/{turn_id}/retry-audio",
response_model=VoiceTurnSummaryResponse,
)
async def retry_voice_turn_audio(
session_id: str,
turn_id: str,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Retry assistant audio synthesis when one turn only has text output."""
return await retry_voice_turn_audio_service(session_id, turn_id, user.id, db)
@router.get("/voice-sessions/{session_id}/turns/{turn_id}/user-audio")
async def get_voice_turn_user_audio(
session_id: str,
turn_id: str,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Get uploaded user audio for one voice turn."""
audio_bytes, mime_type = await get_voice_turn_user_audio_service(
session_id,
turn_id,
user.id,
db,
)
return Response(content=audio_bytes, media_type=mime_type)
@router.post(
"/voice-sessions/{session_id}/finalize",
response_model=VoiceSessionFinalizeResponse,
)
async def finalize_voice_session(
session_id: str,
request: VoiceSessionFinalizeRequest,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Finalize one voice session into a persisted story."""
return await finalize_voice_session_service(session_id, request, user.id, db)
@router.post(
"/voice-sessions/{session_id}/abandon",
response_model=VoiceSessionSummaryResponse,
)
async def abandon_voice_session(
session_id: str,
request: VoiceSessionAbandonRequest,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Abandon one in-progress voice session without saving it as a story."""
return await abandon_voice_session_service(session_id, request, user.id, db)