"""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 ]