264 lines
8.0 KiB
Python
264 lines
8.0 KiB
Python
"""Voice co-creation session APIs."""
|
|
|
|
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,
|
|
VoiceSessionCreateRequest,
|
|
VoiceSessionDetailResponse,
|
|
VoiceSessionFinalizeRequest,
|
|
VoiceSessionFinalizeResponse,
|
|
VoiceSessionSummaryResponse,
|
|
VoiceTurnAcceptedResponse,
|
|
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_detail_service,
|
|
get_voice_turn_audio_service,
|
|
get_voice_turn_service,
|
|
get_voice_turn_user_audio_service,
|
|
list_voice_sessions_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),
|
|
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,
|
|
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/{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.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)
|