Initial commit: VoIdeaAI - voice-first AI idea assistant
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
"""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
|
||||
]
|
||||
Reference in New Issue
Block a user