Initial commit: VoIdeaAI - voice-first AI idea assistant

This commit is contained in:
2026-05-13 12:51:42 +03:00
commit 688d043dad
421 changed files with 47915 additions and 0 deletions
+290
View File
@@ -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
]