Files
voidea/app/api/v1/voice.py
T

291 lines
8.8 KiB
Python

"""Voice transcription and chat API routes for VoIdeaAI."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession
import asyncio
from app.agents.conductor_agent import ConductorAgent
from app.agents.conductor_storage import rate_interaction, get_session_history
from app.agents.role_agents import ALL_ROLE_AGENTS
from app.core.dependencies import get_current_user, get_db
from app.models.conductor import ConductorInteraction
from app.models.user import User
from app.schemas.voice import (
ChatRequest,
ChatResponse,
CommandCreate,
CommandResponse,
CreateSessionRequest,
RateRequest,
SaveIdeaRequest,
SaveIdeaResponse,
SessionResponse,
)
from app.services.command_service import (
create_command,
delete_command,
list_commands,
)
from app.services.idea_service import IdeaService
from app.services.session_service import (
create_session,
delete_session,
get_session,
list_sessions,
update_session_idea,
)
from app.services.punctuation_service import restore_punctuation
from app.services.whisper_service import transcribe
router = APIRouter()
_conductor = ConductorAgent()
@router.get("/stream/{interaction_id}")
async def stream_response(
interaction_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
"""SSE endpoint that streams the response text for a given interaction in chunks."""
from sqlalchemy import select
result = await db.execute(
select(ConductorInteraction).where(ConductorInteraction.id == interaction_id)
)
interaction = result.scalar_one_or_none()
if not interaction:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Interaction not found")
if str(interaction.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
response_text = interaction.response_text or ""
from fastapi.responses import StreamingResponse
async def event_stream():
chunk_size = 50
for i in range(0, len(response_text), chunk_size):
chunk = response_text[i:i + chunk_size]
yield f"data: {chunk}\n\n"
await asyncio.sleep(0.02)
yield "data: [DONE]\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/transcribe")
async def transcribe_audio(file: UploadFile):
audio_data = await file.read()
if not audio_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Empty audio file",
)
text = await transcribe(audio_data, file.filename or "audio.webm")
if not text:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Transcription failed. Check AI provider API key.",
)
text = restore_punctuation(text)
return {"text": text}
@router.post("/chat", response_model=ChatResponse)
async def chat(
body: ChatRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
result = await _conductor.process(
user_input=body.text,
db=db,
user_id=str(user.id),
session_id=body.session_id,
vad_enabled=body.vad_enabled,
wake_word_detected=body.wake_word_detected,
audio_duration_ms=body.audio_duration_ms,
pipeline_mode=body.pipeline_mode,
)
return ChatResponse(**result)
@router.get("/sessions", response_model=list[SessionResponse])
async def list_user_sessions(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
status_filter: str | None = None,
):
sessions = await list_sessions(db, str(user.id), status=status_filter)
return [
SessionResponse(
id=str(s.id),
title=s.title,
status=s.status,
idea_id=str(s.idea_id) if s.idea_id else None,
created_at=s.created_at,
updated_at=s.updated_at,
)
for s in sessions
]
@router.get("/sessions/{session_id}", response_model=SessionResponse)
async def get_session_detail(
session_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
session = await get_session(db, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if str(session.user_id) != str(user.id):
raise HTTPException(status_code=403, detail="Access denied")
return SessionResponse(
id=str(session.id),
title=session.title,
status=session.status,
idea_id=str(session.idea_id) if session.idea_id else None,
created_at=session.created_at,
updated_at=session.updated_at,
)
@router.get("/sessions/{session_id}/history")
async def get_session_history_endpoint(
session_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
session = await get_session(db, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if str(session.user_id) != str(user.id):
raise HTTPException(status_code=403, detail="Access denied")
return await get_session_history(db, session_id)
@router.delete("/sessions/{session_id}")
async def delete_user_session(
session_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
session = await get_session(db, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if str(session.user_id) != str(user.id):
raise HTTPException(status_code=403, detail="Access denied")
ok = await delete_session(db, session_id)
if not ok:
raise HTTPException(status_code=404, detail="Session not found")
return {"status": "ok"}
@router.post("/rate")
async def rate(
body: RateRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
success = await rate_interaction(db, body.interaction_id, body.rating)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Interaction not found",
)
return {"status": "ok"}
@router.post("/save-idea", response_model=SaveIdeaResponse)
async def save_idea(
body: SaveIdeaRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
session = await get_session(db, body.session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if str(session.user_id) != str(user.id):
raise HTTPException(status_code=403, detail="Access denied")
history = await get_session_history(db, body.session_id)
title = session.title or "Новое обсуждение"
dialogue_lines = [
f"Пользователь: {h['input']}\n{ h['agent']}: {h['response']}"
for h in history
]
content = "\n\n".join(dialogue_lines) if dialogue_lines else title
idea_service = IdeaService(db)
tags = ["voice", "ai-assisted"]
idea = await idea_service.create(
user_id=str(user.id),
title=title[:255],
content=content,
tags=tags,
is_public=False,
)
ok = await update_session_idea(db, body.session_id, str(idea.id))
return SaveIdeaResponse(
idea_id=str(idea.id),
title=idea.title,
exported=False,
)
@router.get("/commands", response_model=list[CommandResponse])
async def list_user_commands(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
return await list_commands(db, str(user.id))
@router.post("/commands", response_model=CommandResponse)
async def create_user_command(
body: CommandCreate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
return await create_command(
db, str(user.id), body.phrase, body.action, body.agent_name
)
@router.delete("/commands/{command_id}")
async def delete_user_command(
command_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
ok = await delete_command(db, command_id, str(user.id))
if not ok:
raise HTTPException(status_code=404, detail="Command not found")
return {"status": "ok"}
@router.get("/agents", response_model=list[dict])
async def list_role_agents():
return [
{"name": a.name, "description": a.description}
for a in ALL_ROLE_AGENTS
]