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
+10
View File
@@ -0,0 +1,10 @@
# api Module - VoIdea
## Overview
[Auto-generated documentation]
## Files
| File | Purpose |
|------|---------|
+1
View File
@@ -0,0 +1 @@
"""VoIdea - API module."""
+29
View File
@@ -0,0 +1,29 @@
"""VoIdea - API v1 routers."""
from fastapi import APIRouter
from app.api.v1.auth import router as auth_router
from app.api.v1.users import router as users_router
from app.api.v1.ideas import router as ideas_router
from app.api.v1.agents import router as agents_router
from app.api.v1.sync import router as sync_router
from app.api.v1.admin import router as admin_router
from app.api.v1.voice import router as voice_router
from app.api.v1.config import router as config_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.tariffs import router as tariffs_router
api_v1_router = APIRouter(prefix="/api/v1")
api_v1_router.include_router(auth_router, prefix="/auth", tags=["auth"])
api_v1_router.include_router(users_router, prefix="/users", tags=["users"])
api_v1_router.include_router(ideas_router, prefix="/ideas", tags=["ideas"])
api_v1_router.include_router(agents_router, prefix="/agents", tags=["agents"])
api_v1_router.include_router(sync_router, prefix="/sync", tags=["sync"])
api_v1_router.include_router(admin_router, prefix="/admin", tags=["admin"])
api_v1_router.include_router(voice_router, prefix="/voice", tags=["voice"])
api_v1_router.include_router(config_router, prefix="/config", tags=["config"])
api_v1_router.include_router(feedback_router, prefix="/feedback", tags=["feedback"])
api_v1_router.include_router(tariffs_router, prefix="/tariffs", tags=["tariffs"])
__all__ = ["api_v1_router"]
+839
View File
@@ -0,0 +1,839 @@
"""Admin API routes for VoIdea.
Role-based access:
- owner: full access including service management
- admin: full management except services & system
- moderator: permission-based (checked via require_permission)
"""
import json
import platform
import subprocess
from datetime import datetime, timezone
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.dependencies import get_current_user, get_db, require_admin, require_owner, require_permission
from app.models.agent import AgentConfig
from app.models.backlog import BacklogTask
from app.models.bot_command import BotCommand
from app.models.feedback import Feedback
from app.models.log import LogEntry
from app.models.user import User
from app.schemas.admin import (
AgentInfoResponse,
AgentUpdateRequest,
FeatureCreate,
FeatureResponse,
FeatureUpdate,
LogEntryResponse,
ServiceActionRequest,
ServiceActionResult,
ServiceStatusResponse,
SystemHealth,
SystemInfoResponse,
UserAdminUpdate,
)
from app.schemas.bot import BotCommandResponse, BotCommandUpdate
from app.schemas.feedback import FeedbackResponse, FeedbackUpdate
from app.schemas.tariff import TariffPlanCreate, TariffPlanResponse, TariffPlanUpdate, UserSubscriptionResponse
from app.schemas.user import UserResponse
from app.schemas.pipeline import PipelineConfigResponse, PipelineConfigUpdate, PipelineStatsEntry, PipelineStatsSummary
from app.services.debug_service import DebugService
from app.services.feedback_service import FeedbackService
from app.services.pipeline_service import PipelineService
from app.services.tariff_service import TariffService
from app.services.two_factor_service import is_2fa_globally_enabled, set_2fa_globally_enabled
from app.services.user_service import UserService
settings = get_settings()
router = APIRouter(dependencies=[Depends(require_admin)])
# ── Helpers ──
def _user_to_response(u: User) -> UserResponse:
return UserResponse(
id=str(u.id),
email=u.email,
display_name=u.display_name,
avatar_url=u.avatar_url,
is_active=u.is_active,
is_superuser=u.is_superuser,
role=u.role or "user",
is_owner=u.is_owner,
permissions=u.permissions,
accepted_terms_at=u.accepted_terms_at,
accepted_terms_version=u.accepted_terms_version,
oauth_provider=u.oauth_provider,
created_at=u.created_at,
updated_at=u.updated_at,
)
def _log_to_response(l: LogEntry) -> LogEntryResponse:
import json
details = None
if l.details:
try:
details = json.loads(l.details)
except (json.JSONDecodeError, TypeError):
details = {"raw": l.details}
return LogEntryResponse(
id=str(l.id),
level=l.level,
source=l.source,
message=l.message,
details=details,
user_id=str(l.user_id) if l.user_id else None,
created_at=l.created_at,
)
def _agent_to_response(a: AgentConfig) -> AgentInfoResponse:
return AgentInfoResponse(
agent_name=a.agent_name,
description=getattr(a, "description", ""),
is_enabled=a.is_enabled,
version=a.version,
last_run_at=a.last_run_at,
)
def _feature_to_response(t: BacklogTask) -> FeatureResponse:
return FeatureResponse(
id=str(t.id),
title=t.title,
description=t.description,
priority=t.priority,
status=t.status,
category=t.category or "feature",
source_agent=t.source_agent,
created_at=t.created_at,
updated_at=t.updated_at,
)
# ── Users ──
@router.get("/users", response_model=list[UserResponse])
async def list_users(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=200),
search: Optional[str] = Query(None),
):
service = UserService(db)
users = await service.list_users(skip=skip, limit=limit, search=search)
return [_user_to_response(u) for u in users]
@router.patch("/users/{user_id}", response_model=UserResponse)
async def update_user(
user_id: str,
body: UserAdminUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = UserService(db)
updates = body.model_dump(exclude_none=True)
result = await service.admin_update_user(user_id, updates)
if not result:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return _user_to_response(result)
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = UserService(db)
success = await service.hard_delete(user_id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
# ── Agents ──
@router.get("/agents", response_model=list[AgentInfoResponse])
async def list_agents(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(AgentConfig).order_by(AgentConfig.agent_name))
agents = result.scalars().all()
return [_agent_to_response(a) for a in agents]
@router.patch("/agents/{agent_name}", response_model=AgentInfoResponse)
async def update_agent(
agent_name: str,
body: AgentUpdateRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(AgentConfig).where(AgentConfig.agent_name == agent_name)
)
agent = result.scalar_one_or_none()
if not agent:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
if body.description is not None:
agent.description = body.description
if body.is_enabled is not None:
agent.is_enabled = body.is_enabled
await db.commit()
await db.refresh(agent)
return _agent_to_response(agent)
# ── Logs ──
@router.get("/logs", response_model=list[LogEntryResponse])
async def list_logs(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
level: Optional[str] = Query(None, description="ERROR | WARNING | INFO | DEBUG"),
source: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
):
query = select(LogEntry).order_by(LogEntry.created_at.desc())
if level:
query = query.where(LogEntry.level == level.upper())
if source:
query = query.where(LogEntry.source.ilike(f"%{source}%"))
result = await db.execute(query.offset(skip).limit(limit))
logs = result.scalars().all()
return [_log_to_response(l) for l in logs]
# ── Feedback ──
@router.get("/feedback", response_model=list[FeedbackResponse])
async def list_feedback(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
status_filter: Optional[str] = Query(None, alias="status"),
):
svc = FeedbackService(db)
items = await svc.list_feedback(status=status_filter)
return [
FeedbackResponse(
id=str(f.id),
user_id=str(f.user_id) if f.user_id else None,
text=f.text,
page_url=f.page_url,
status=f.status,
created_at=f.created_at,
updated_at=f.updated_at,
)
for f in items
]
@router.patch("/feedback/{feedback_id}", response_model=FeedbackResponse)
async def update_feedback(
feedback_id: str,
body: FeedbackUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = FeedbackService(db)
fb = await svc.update_status(UUID(feedback_id), body.status)
if not fb:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feedback not found")
return FeedbackResponse(
id=str(fb.id),
user_id=str(fb.user_id) if fb.user_id else None,
text=fb.text,
page_url=fb.page_url,
status=fb.status,
created_at=fb.created_at,
updated_at=fb.updated_at,
)
@router.delete("/feedback/{feedback_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_feedback(
feedback_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = FeedbackService(db)
success = await svc.delete(UUID(feedback_id))
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feedback not found")
# ── 2FA Global Toggle ──
@router.get("/2fa/status")
async def get_2fa_status(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
globally_enabled = await is_2fa_globally_enabled(db)
return {"globally_enabled": globally_enabled}
@router.post("/2fa/toggle")
async def toggle_2fa(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
if user.role not in ("admin", "owner") and not user.is_superuser:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только администратор может управлять 2FA")
current = await is_2fa_globally_enabled(db)
await set_2fa_globally_enabled(db, not current)
return {"globally_enabled": not current}
# ── Debug Mode ──
@router.get("/debug/config")
async def get_debug_config(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = DebugService(db)
return await svc.get_config()
@router.patch("/debug/config")
async def update_debug_config(
body: dict,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = DebugService(db)
return await svc.update_config(body)
@router.post("/debug/enable")
async def enable_debug_mode(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
if not user.is_owner:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только владелец может включить debug mode")
svc = DebugService(db)
return await svc.enable_debug_mode()
@router.post("/debug/disable")
async def disable_debug_mode(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
if not user.is_owner:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только владелец может выключить debug mode")
svc = DebugService(db)
return await svc.disable_debug_mode()
@router.get("/debug/status")
async def get_debug_status(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = DebugService(db)
return await svc.get_status()
@router.post("/debug/cleanup")
async def cleanup_logs(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
if not user.is_owner:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только владелец может очистить логи")
svc = DebugService(db)
return await svc.cleanup_logs(force=True)
# ── Log Export ──
@router.get("/logs/export")
async def export_logs(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
level: Optional[str] = None,
source: Optional[str] = None,
):
from fastapi.responses import Response
query = select(LogEntry).order_by(LogEntry.created_at.desc())
if level:
query = query.where(LogEntry.level == level.upper())
if source:
query = query.where(LogEntry.source.ilike(f"%{source}%"))
result = await db.execute(query)
logs = result.scalars().all()
export_data = []
for l in logs:
details = None
if l.details:
try:
details = json.loads(l.details)
except (json.JSONDecodeError, TypeError):
details = {"raw": l.details}
export_data.append({
"id": str(l.id),
"level": l.level,
"source": l.source,
"message": l.message,
"details": details,
"user_id": str(l.user_id) if l.user_id else None,
"created_at": l.created_at.isoformat() if l.created_at else None,
})
return Response(
content=json.dumps(export_data, ensure_ascii=False, default=str),
media_type="application/json",
headers={
"Content-Disposition": f"attachment; filename=voidea-logs-{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.json",
},
)
# ── Tariffs ──
@router.get("/tariffs", response_model=list[TariffPlanResponse])
async def list_tariff_plans(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = TariffService(db)
plans = await svc.list_plans(active_only=False)
return [
TariffPlanResponse(
id=str(p.id),
name=p.name,
code=p.code,
description=p.description,
price_monthly=p.price_monthly,
price_yearly=p.price_yearly,
features=p.features,
is_active=p.is_active,
sort_order=p.sort_order,
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in plans
]
@router.post("/tariffs", response_model=TariffPlanResponse, status_code=status.HTTP_201_CREATED)
async def create_tariff_plan(
body: TariffPlanCreate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = TariffService(db)
plan = await svc.create_plan(
name=body.name,
code=body.code,
description=body.description,
price_monthly=body.price_monthly,
price_yearly=body.price_yearly,
features=body.features,
is_active=body.is_active,
sort_order=body.sort_order,
)
return TariffPlanResponse(
id=str(plan.id),
name=plan.name,
code=plan.code,
description=plan.description,
price_monthly=plan.price_monthly,
price_yearly=plan.price_yearly,
features=plan.features,
is_active=plan.is_active,
sort_order=plan.sort_order,
created_at=plan.created_at,
updated_at=plan.updated_at,
)
@router.patch("/tariffs/{plan_id}", response_model=TariffPlanResponse)
async def update_tariff_plan(
plan_id: str,
body: TariffPlanUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = TariffService(db)
plan = await svc.update_plan(UUID(plan_id), body.model_dump(exclude_none=True))
if not plan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tariff plan not found")
return TariffPlanResponse(
id=str(plan.id),
name=plan.name,
code=plan.code,
description=plan.description,
price_monthly=plan.price_monthly,
price_yearly=plan.price_yearly,
features=plan.features,
is_active=plan.is_active,
sort_order=plan.sort_order,
created_at=plan.created_at,
updated_at=plan.updated_at,
)
@router.delete("/tariffs/{plan_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tariff_plan(
plan_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = TariffService(db)
success = await svc.delete_plan(UUID(plan_id))
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tariff plan not found")
# ── Features (BacklogTask category=feature) ──
@router.get("/features", response_model=list[FeatureResponse])
async def list_features(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(BacklogTask)
.where(BacklogTask.category == "feature")
.order_by(BacklogTask.created_at.desc())
)
return [_feature_to_response(t) for t in result.scalars().all()]
@router.post("/features", response_model=FeatureResponse, status_code=status.HTTP_201_CREATED)
async def create_feature(
body: FeatureCreate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
task = BacklogTask(
title=body.title,
description=body.description,
priority=body.priority or "medium",
status="pending",
category="feature",
)
db.add(task)
await db.commit()
await db.refresh(task)
return _feature_to_response(task)
@router.patch("/features/{feature_id}", response_model=FeatureResponse)
async def update_feature(
feature_id: str,
body: FeatureUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(BacklogTask).where(
BacklogTask.id == UUID(feature_id),
BacklogTask.category == "feature",
)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature not found")
updates = body.model_dump(exclude_none=True)
for key, value in updates.items():
if hasattr(task, key) and key not in ("id", "category", "created_at"):
setattr(task, key, value)
await db.commit()
await db.refresh(task)
return _feature_to_response(task)
# ── Pipeline ──
@router.get("/pipeline", response_model=PipelineConfigResponse)
async def get_pipeline_config(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = PipelineService(db)
return await svc.get_config()
@router.patch("/pipeline", response_model=PipelineConfigResponse)
async def update_pipeline_config(
body: PipelineConfigUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = PipelineService(db)
return await svc.update_config(body.model_dump(exclude_none=True))
@router.get("/pipeline/stats", response_model=list[PipelineStatsEntry])
async def list_pipeline_stats(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
stage: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
):
svc = PipelineService(db)
stats = await svc.list_stats(stage=stage, skip=skip, limit=limit)
return [
PipelineStatsEntry(
id=str(s.id),
user_id=str(s.user_id) if s.user_id else None,
stage=s.stage,
passed=s.passed,
reason=s.reason,
duration_ms=s.duration_ms,
created_at=s.created_at,
)
for s in stats
]
@router.get("/pipeline/stats/summary", response_model=PipelineStatsSummary)
async def pipeline_stats_summary(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = PipelineService(db)
return await svc.get_summary()
# ── Services (owner only) ──
SERVICE_MAP = {
"api": {
"name": "VoIdea API",
"unit": "voidea-api",
"description": "FastAPI приложение — основной веб-сервер для обработки запросов",
},
"worker": {
"name": "VoIdea Worker",
"unit": "voidea-worker",
"description": "Celery worker — асинхронная обработка задач (анализ идей, AI запросы)",
},
"beat": {
"name": "VoIdea Beat",
"unit": "voidea-beat",
"description": "Celery beat — планировщик периодических задач",
},
}
async def _systemctl(unit: str, action: str) -> tuple[bool, str]:
"""Run systemctl command and return (success, message)."""
cmd = ["systemctl", action, f"{unit}.service"]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode == 0:
return True, result.stdout.strip() or f"{action.capitalize()} successful"
return False, result.stderr.strip() or f"{action.capitalize()} failed"
except FileNotFoundError:
return False, "systemctl not found (not a systemd system)"
except subprocess.TimeoutExpired:
return False, "Command timed out"
except Exception as e:
return False, str(e)
@router.post("/services/restart", response_model=list[ServiceActionResult])
async def restart_services(
body: ServiceActionRequest,
user: Annotated[User, Depends(get_current_user)],
):
"""Restart system services. Owner only. Only works in production."""
if not user.is_owner:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Только владелец системы может управлять сервисами",
)
results: list[ServiceActionResult] = []
if body.service == "all":
targets = list(SERVICE_MAP.keys())
elif body.service in SERVICE_MAP:
targets = [body.service]
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown service: {body.service}. Available: {', '.join(SERVICE_MAP.keys())}, all",
)
for svc in targets:
info = SERVICE_MAP[svc]
success, message = await _systemctl(info["unit"], body.action)
results.append(ServiceActionResult(
service=svc,
action=body.action,
success=success,
message=message,
))
return results
@router.get("/services", response_model=list[ServiceStatusResponse])
async def list_services(
user: Annotated[User, Depends(get_current_user)],
):
"""List all available services and their status. Owner only."""
results: list[ServiceStatusResponse] = []
for svc_key, info in SERVICE_MAP.items():
success, status_text = await _systemctl(info["unit"], "is-active")
is_running = success and status_text.strip() == "active"
results.append(ServiceStatusResponse(
service=svc_key,
description=info["description"],
status=status_text.strip() if status_text else "unknown",
is_running=is_running,
))
return results
# ── System ──
@router.get("/health", response_model=SystemHealth)
async def health(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
db_status = "connected"
redis_status = "unknown"
try:
from redis import asyncio as aioredis
r = aioredis.from_url(settings.redis_url, socket_connect_timeout=1)
await r.ping()
redis_status = "connected"
await r.aclose()
except Exception:
redis_status = "disconnected"
return SystemHealth(
status="healthy",
database=db_status,
redis=redis_status,
version=settings.project_version,
)
# ── Bot Commands ──
@router.get("/bot", response_model=list[BotCommandResponse])
async def list_bot_commands(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(BotCommand).order_by(BotCommand.name)
)
commands = result.scalars().all()
return [
BotCommandResponse(
id=str(c.id),
name=c.name,
description=c.description,
enabled=c.enabled,
requires_auth=c.requires_auth,
created_at=c.created_at,
updated_at=c.updated_at,
)
for c in commands
]
@router.patch("/bot/{command_id}", response_model=BotCommandResponse)
async def toggle_bot_command(
command_id: str,
body: BotCommandUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
from uuid import UUID
result = await db.execute(
select(BotCommand).where(BotCommand.id == UUID(command_id))
)
cmd = result.scalar_one_or_none()
if not cmd:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Command not found")
if body.enabled is not None:
cmd.enabled = body.enabled
await db.commit()
await db.refresh(cmd)
return BotCommandResponse(
id=str(cmd.id),
name=cmd.name,
description=cmd.description,
enabled=cmd.enabled,
requires_auth=cmd.requires_auth,
created_at=cmd.created_at,
updated_at=cmd.updated_at,
)
@router.post("/bot/sync")
async def sync_bot_commands(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
from app.integrations.telegram.sync_service import TelegramBotSyncService
from app.integrations.telegram.client import TelegramBotClient
from app.core.config import get_settings
settings = get_settings()
bot_client = TelegramBotClient(token=settings.telegram_bot_token)
svc = TelegramBotSyncService(db, bot_client)
result = await svc.full_sync()
return result
# ── System ──
@router.get("/system", response_model=SystemInfoResponse)
async def system_info(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
db_status = "connected"
redis_status = "unknown"
try:
from redis import asyncio as aioredis
r = aioredis.from_url(settings.redis_url, socket_connect_timeout=1)
await r.ping()
redis_status = "connected"
await r.aclose()
except Exception:
redis_status = "disconnected"
return SystemInfoResponse(
version=settings.project_version,
environment=settings.project_env,
database_status=db_status,
redis_status=redis_status,
python_version=platform.python_version(),
uptime_seconds=None,
)
+54
View File
@@ -0,0 +1,54 @@
"""Agents API routes for VoIdea."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from app.agents.registry import registry
from app.core.dependencies import get_current_user
from app.models.user import User
from app.schemas.agent import AgentRunRequest, AgentStatusResponse
from app.services.agent_service import AgentService
router = APIRouter()
def get_agent_service() -> AgentService:
return AgentService(registry)
@router.get("/", response_model=list[AgentStatusResponse])
async def list_agents(
user: Annotated[User, Depends(get_current_user)],
):
service = get_agent_service()
agents = service.list_agents()
return [AgentStatusResponse(**a) for a in agents]
@router.get("/{agent_name}", response_model=AgentStatusResponse)
async def get_agent(
agent_name: str,
user: Annotated[User, Depends(get_current_user)],
):
service = get_agent_service()
agent = service.get_agent(agent_name)
if not agent:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
return AgentStatusResponse(**agent)
@router.post("/{agent_name}/run")
async def run_agent(
agent_name: str,
body: AgentRunRequest,
user: Annotated[User, Depends(get_current_user)],
):
service = get_agent_service()
result = await service.run_agent(agent_name, body.context)
if not result["success"] and "not found" in result.get("message", ""):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=result["message"],
)
return result
+271
View File
@@ -0,0 +1,271 @@
"""Auth API routes for VoIdea."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, get_db
from app.core.limiter import limiter
from app.core.security import (
create_access_token,
create_refresh_token,
verify_password,
get_password_hash,
)
from app.integrations.oauth.yandex import exchange_code, get_authorize_url, get_user_info
from app.models.user import User
from app.schemas.auth import (
ForgotPasswordRequest,
LoginRequest,
OAuthCallbackRequest,
OAuthUrlResponse,
RefreshRequest,
RegisterRequest,
ResetPasswordRequest,
TokenResponse,
TwoFactorLoginRequest,
TwoFactorLoginResponse,
TwoFactorSetupResponse,
TwoFactorVerifyRequest,
)
from app.schemas.user import ChangePasswordRequest
from app.services.auth_service import AuthService
from app.services.password_reset_service import reset_password, send_reset_email
from app.services.two_factor_service import (
generate_qr_base64,
generate_totp_secret,
get_totp_uri,
get_user_secret,
is_2fa_enabled,
is_2fa_globally_enabled,
set_2fa_enabled,
set_user_secret,
verify_totp,
)
router = APIRouter()
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("5/minute")
async def register(request: Request, body: RegisterRequest, db: AsyncSession = Depends(get_db)):
service = AuthService(db)
try:
return await service.register(
body.email, body.password, body.display_name,
accepted_terms=body.accepted_terms,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
@router.post("/login")
@limiter.limit("10/minute")
async def login(request: Request, body: LoginRequest, db: AsyncSession = Depends(get_db)):
from sqlalchemy import select
result = await db.execute(select(User).where(User.email == body.email))
user = result.scalar_one_or_none()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный email или пароль")
globally_enabled = await is_2fa_globally_enabled(db)
if globally_enabled and is_2fa_enabled(user):
from datetime import timedelta
temp_token = create_access_token(
data={"sub": str(user.id), "purpose": "2fa"},
expires_delta=timedelta(minutes=5),
)
return {"temp_token": temp_token, "message": "Требуется 2FA код"}
service = AuthService(db)
return await service.create_token_response(user)
@router.post("/2fa/verify-login", response_model=TwoFactorLoginResponse)
@limiter.limit("10/minute")
async def verify_2fa_login(
request: Request,
body: TwoFactorLoginRequest,
db: AsyncSession = Depends(get_db),
):
from jose import jwt, JWTError
from app.core.config import get_settings
settings = get_settings()
try:
payload = jwt.decode(
body.temp_token, settings.jwt_secret_key,
algorithms=[settings.jwt_algorithm],
)
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid temp token")
if payload.get("purpose") != "2fa":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token purpose")
from sqlalchemy import select
result = await db.execute(select(User).where(User.id == payload.get("sub")))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
secret = get_user_secret(user)
if not secret or not verify_totp(secret, body.totp_code):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный 2FA код")
service = AuthService(db)
return await service.create_token_response(user)
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
async def setup_2fa(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
if is_2fa_enabled(user):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA уже включена")
secret = generate_totp_secret()
set_user_secret(user, secret)
await db.commit()
uri = get_totp_uri(secret, user.email)
qr = generate_qr_base64(uri)
return TwoFactorSetupResponse(secret=secret, uri=uri, qr_base64=qr)
@router.post("/2fa/verify")
async def verify_2fa(
body: TwoFactorVerifyRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
secret = get_user_secret(user)
if not secret:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA не настроена")
if not verify_totp(secret, body.token):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный код")
set_2fa_enabled(user, True)
await db.commit()
return {"message": "2FA успешно включена"}
@router.post("/2fa/disable")
async def disable_2fa(
body: TwoFactorVerifyRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
secret = get_user_secret(user)
if not secret or not verify_totp(secret, body.token):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный код")
set_2fa_enabled(user, False)
await db.commit()
return {"message": "2FA отключена"}
@router.post("/refresh", response_model=TokenResponse)
@limiter.limit("10/minute")
async def refresh(request: Request, body: RefreshRequest, db: AsyncSession = Depends(get_db)):
service = AuthService(db)
try:
return await service.refresh(body.refresh_token)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
@router.get("/oauth/yandex", response_model=OAuthUrlResponse)
@limiter.limit("10/minute")
async def oauth_yandex_url(request: Request):
url = await get_authorize_url()
return OAuthUrlResponse(url=url, provider="yandex")
@router.post("/oauth/yandex/callback", response_model=TokenResponse)
@limiter.limit("10/minute")
async def oauth_yandex_callback(
request: Request,
body: OAuthCallbackRequest,
db: AsyncSession = Depends(get_db),
):
token_result = await exchange_code(body.code)
if not token_result:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to exchange authorization code",
)
user_info = await get_user_info(token_result.access_token)
if not user_info:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to get user info from Yandex",
)
service = AuthService(db)
try:
return await service.oauth_or_register_login(
email=user_info.email,
oauth_provider="yandex",
oauth_id=user_info.id,
display_name=user_info.display_name,
avatar_url=user_info.avatar_url,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
@router.post("/forgot-password")
@limiter.limit("3/minute")
async def forgot_password(
request: Request,
body: ForgotPasswordRequest,
db: AsyncSession = Depends(get_db),
):
success, message = await send_reset_email(db, body.email)
if not success:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=message,
)
return {"message": message}
@router.post("/reset-password")
@limiter.limit("5/minute")
async def reset_password_endpoint(
request: Request,
body: ResetPasswordRequest,
db: AsyncSession = Depends(get_db),
):
success, message = await reset_password(db, body.token, body.new_password)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message,
)
return {"message": message}
@router.post("/change-password")
@limiter.limit("5/minute")
async def change_password(
request: Request,
body: ChangePasswordRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Неверный текущий пароль",
)
user.password_hash = get_password_hash(body.new_password)
await db.commit()
return {"message": "Пароль успешно изменён"}
+15
View File
@@ -0,0 +1,15 @@
"""Public config API routes for VoIdea."""
from fastapi import APIRouter
from app.core.config import get_settings
from app.schemas.config import PublicConfigResponse
router = APIRouter()
settings = get_settings()
@router.get("/public", response_model=PublicConfigResponse)
async def public_config():
"""Return non-sensitive public configuration (slogan, social links, analytics IDs)."""
return settings.public_config
+39
View File
@@ -0,0 +1,39 @@
"""Feedback API routes for VoIdea."""
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, get_db, get_optional_user
from app.core.limiter import limiter
from app.models.user import User
from app.schemas.feedback import FeedbackCreate, FeedbackResponse
from app.services.feedback_service import FeedbackService
router = APIRouter()
@router.post("", response_model=FeedbackResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("5/minute")
async def create_feedback(
request: Request,
body: FeedbackCreate,
db: AsyncSession = Depends(get_db),
user: Optional[User] = Depends(get_optional_user),
):
service = FeedbackService(db)
fb = await service.create(
user_id=user.id if user else None,
text=body.text,
page_url=body.page_url,
)
return FeedbackResponse(
id=str(fb.id),
user_id=str(fb.user_id) if fb.user_id else None,
text=fb.text,
page_url=fb.page_url,
status=fb.status,
created_at=fb.created_at,
updated_at=fb.updated_at,
)
+240
View File
@@ -0,0 +1,240 @@
"""Ideas API routes for VoIdea."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi.responses import PlainTextResponse
from uuid import uuid4
from app.core.dependencies import get_current_user, get_db, get_optional_user
from app.models.idea import Idea
from app.models.user import User
from app.schemas.idea import (
AnalysisResultResponse,
IdeaAnalyzeResponse,
IdeaCreate,
IdeaResponse,
IdeaUpdate,
)
from app.services.analysis_service import AnalysisService
from app.services.export_service import export_content
from app.services.idea_service import IdeaService
router = APIRouter()
def _idea_to_response(idea) -> IdeaResponse:
return IdeaResponse(
id=str(idea.id),
user_id=str(idea.user_id),
title=idea.title,
content=idea.content,
status=idea.status,
tags=idea.tags,
is_public=idea.is_public,
public_slug=idea.public_slug,
created_at=idea.created_at,
updated_at=idea.updated_at,
)
@router.get("/", response_model=list[IdeaResponse])
async def list_ideas(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
):
service = IdeaService(db)
ideas = await service.list_by_user(str(user.id), skip=skip, limit=limit)
return [_idea_to_response(idea) for idea in ideas]
@router.post("/", response_model=IdeaResponse, status_code=status.HTTP_201_CREATED)
async def create_idea(
body: IdeaCreate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = IdeaService(db)
idea = await service.create(
user_id=str(user.id),
title=body.title,
content=body.content,
tags=body.tags,
is_public=body.is_public,
)
return _idea_to_response(idea)
@router.get("/{idea_id}", response_model=IdeaResponse)
async def get_idea(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = IdeaService(db)
idea = await service.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
return _idea_to_response(idea)
@router.patch("/{idea_id}", response_model=IdeaResponse)
async def update_idea(
idea_id: str,
body: IdeaUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = IdeaService(db)
idea = await service.update(
idea_id,
str(user.id),
title=body.title,
content=body.content,
status=body.status,
tags=body.tags,
is_public=body.is_public,
)
if not idea:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
return _idea_to_response(idea)
@router.delete("/{idea_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_idea(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = IdeaService(db)
deleted = await service.delete(idea_id, str(user.id))
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
@router.post("/{idea_id}/analyze", response_model=IdeaAnalyzeResponse)
async def analyze_idea(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
idea_service = IdeaService(db)
idea = await idea_service.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
analysis_service = AnalysisService(db)
result = await analysis_service.start_analysis(idea_id)
return IdeaAnalyzeResponse(
status="started",
idea_id=idea_id,
task_count=result["task_count"],
tasks=result["tasks"],
)
@router.get("/{idea_id}/analysis", response_model=list[AnalysisResultResponse])
async def get_analysis_results(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
role: str = Query(None, description="Filter by agent role"),
):
idea_service = IdeaService(db)
idea = await idea_service.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
analysis_service = AnalysisService(db)
results = await analysis_service.get_analysis_results(idea_id, role=role)
return [AnalysisResultResponse(**r) for r in results]
@router.get("/{idea_id}/export")
async def export_idea(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
format: str = Query("md", regex="^(json|md|html)$"),
):
idea_service = IdeaService(db)
idea = await idea_service.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
metadata = {
"ID": str(idea.id),
"Статус": idea.status,
"Теги": ", ".join(idea.tags) if idea.tags else "",
"Создано": idea.created_at.isoformat() if idea.created_at else "",
}
body, content_type, ext = export_content(format, idea.title, idea.content, metadata)
filename = f"{idea.title[:50]}.{ext}".replace(" ", "_")
from fastapi.responses import Response
return Response(
content=body,
media_type=content_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/{idea_id}/share", response_model=IdeaResponse)
async def toggle_idea_share(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = IdeaService(db)
idea = await service.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
idea.is_public = not idea.is_public
if idea.is_public and not idea.public_slug:
idea.public_slug = uuid4().hex[:16]
elif not idea.is_public:
idea.public_slug = None
await db.commit()
await db.refresh(idea)
return _idea_to_response(idea)
@router.post("/demo", response_model=IdeaResponse, status_code=status.HTTP_201_CREATED)
async def create_demo_idea(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = IdeaService(db)
idea = await service.create(
user_id=str(user.id),
title="Моя первая идея (демо)",
content="Пример идеи для знакомства с VoIdeaAI. Замените этот текст на свою идею.\n\n"
"VoIdeaAI поможет проанализировать её с разных сторон: бизнес, финансы, "
"право, технологии и маркетинг. Просто нажмите «Анализ» на странице идеи.",
tags=["demo", "привет"],
is_public=False,
)
return _idea_to_response(idea)
@router.get("/shared/{slug}", response_model=IdeaResponse)
async def get_shared_idea(
slug: str,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Idea).where(Idea.public_slug == slug, Idea.is_public == True)
)
idea = result.scalar_one_or_none()
if not idea:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found or not shared")
return _idea_to_response(idea)
+33
View File
@@ -0,0 +1,33 @@
"""Sync API routes for VoIdea."""
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, get_db
from app.models.user import User
from app.schemas.sync import SyncPullRequest, SyncPushRequest, SyncResponse
from app.services.sync_service import SyncService
router = APIRouter()
@router.post("/pull", response_model=SyncResponse)
async def pull(
body: SyncPullRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = SyncService(db)
return await service.pull(user, last_sync=body.last_sync)
@router.post("/push", response_model=SyncResponse)
async def push(
body: SyncPushRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = SyncService(db)
return await service.push(user, device_id=body.device_id, changes=body.changes)
+33
View File
@@ -0,0 +1,33 @@
"""Public tariff API routes for VoIdea."""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_db
from app.schemas.tariff import TariffPlanResponse
from app.services.tariff_service import TariffService
router = APIRouter()
@router.get("", response_model=list[TariffPlanResponse])
async def list_tariffs(db: AsyncSession = Depends(get_db)):
"""Return all active tariff plans (public, no auth required)."""
service = TariffService(db)
plans = await service.list_plans(active_only=True)
return [
TariffPlanResponse(
id=str(p.id),
name=p.name,
code=p.code,
description=p.description,
price_monthly=p.price_monthly,
price_yearly=p.price_yearly,
features=p.features,
is_active=p.is_active,
sort_order=p.sort_order,
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in plans
]
+189
View File
@@ -0,0 +1,189 @@
"""Users API routes for VoIdea."""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, get_db
from app.models.user import User
from app.schemas.user import SubscriptionInfo, UserResponse, UserUpdate, VoiceSettingsResponse, VoiceSettingsUpdate
from app.services.tariff_service import TariffService
from app.services.user_service import UserService
router = APIRouter()
def _user_to_response(user: User) -> UserResponse:
return UserResponse(
id=str(user.id),
email=user.email,
display_name=user.display_name,
avatar_url=user.avatar_url,
is_active=user.is_active,
is_superuser=user.is_superuser,
oauth_provider=user.oauth_provider,
created_at=user.created_at,
updated_at=user.updated_at,
)
@router.get("/me", response_model=UserResponse)
async def get_me(user: Annotated[User, Depends(get_current_user)]):
return _user_to_response(user)
@router.patch("/me", response_model=UserResponse)
async def update_me(
body: UserUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = UserService(db)
result = await service.update_profile(
str(user.id),
display_name=body.display_name,
avatar_url=body.avatar_url,
)
if not result:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return _user_to_response(result)
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
async def delete_me(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = UserService(db)
await service.delete(str(user.id))
VOICE_PRESETS: dict[str, dict[str, Any]] = {
"standard": {
"vad_noise_threshold": 0.3,
"vad_silence_timeout_ms": 1500,
"confidence_verified": 80,
"confidence_warning": 50,
"semantic_mode": "fast",
"tts_enabled": True,
"continuous_listening": False,
},
"precise": {
"vad_noise_threshold": 0.2,
"vad_silence_timeout_ms": 2000,
"confidence_verified": 85,
"confidence_warning": 60,
"semantic_mode": "full",
"tts_enabled": True,
"continuous_listening": False,
},
"fast": {
"vad_noise_threshold": 0.4,
"vad_silence_timeout_ms": 1000,
"confidence_verified": 70,
"confidence_warning": 40,
"semantic_mode": "fast",
"tts_enabled": False,
"continuous_listening": True,
},
"quiet": {
"vad_noise_threshold": 0.6,
"vad_silence_timeout_ms": 3000,
"confidence_verified": 80,
"confidence_warning": 50,
"semantic_mode": "fast",
"tts_enabled": True,
"continuous_listening": False,
},
"expert": {
"vad_noise_threshold": 0.15,
"vad_silence_timeout_ms": 1000,
"confidence_verified": 90,
"confidence_warning": 70,
"semantic_mode": "full",
"tts_enabled": True,
"continuous_listening": True,
},
}
@router.get("/me/voice-settings", response_model=VoiceSettingsResponse)
async def get_voice_settings(
user: Annotated[User, Depends(get_current_user)],
):
tuning = user.pipeline_tuning or {}
preset = tuning.get("preset", "standard")
overrides = tuning.get("overrides", {})
return VoiceSettingsResponse(
preset=preset,
overrides=overrides,
available_presets=list(VOICE_PRESETS.keys()),
preset_values=VOICE_PRESETS.get(preset, VOICE_PRESETS["standard"]),
)
@router.patch("/me/voice-settings", response_model=VoiceSettingsResponse)
async def update_voice_settings(
body: VoiceSettingsUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select
tuning = user.pipeline_tuning or {}
if body.reset:
tuning = {"preset": "standard", "overrides": {}}
else:
if body.preset is not None:
if body.preset not in VOICE_PRESETS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown preset: {body.preset}. Available: {', '.join(VOICE_PRESETS.keys())}",
)
tuning["preset"] = body.preset
if body.overrides is not None:
existing_overrides = tuning.get("overrides", {})
existing_overrides.update(body.overrides)
tuning["overrides"] = existing_overrides
result = await db.execute(select(User).where(User.id == user.id))
db_user = result.scalar_one()
db_user.pipeline_tuning = tuning
await db.commit()
preset = tuning.get("preset", "standard")
preset_vals = VOICE_PRESETS.get(preset, VOICE_PRESETS["standard"])
overrides = tuning.get("overrides", {})
merged = {**preset_vals, **overrides}
return VoiceSettingsResponse(
preset=preset,
overrides=overrides,
available_presets=list(VOICE_PRESETS.keys()),
preset_values=merged,
)
@router.get("/me/subscription", response_model=SubscriptionInfo)
async def get_my_subscription(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = TariffService(db)
sub = await svc.get_user_subscription(user.id)
if not sub:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subscription not found"
)
plan = await svc.get_plan_by_id(sub.plan_id)
return SubscriptionInfo(
plan_name=plan.name if plan else "Бесплатно",
plan_code=plan.code if plan else "free",
status=sub.status,
expires_at=sub.current_period_end,
features=plan.features if plan else None,
)
+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
]