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
+15
View File
@@ -0,0 +1,15 @@
# services Module - VoIdea
## Overview
[Auto-generated documentation]
## Files
| File | Purpose |
|------|---------|
| `agent_service.py` | Module file |
| `auth_service.py` | Module file |
| `idea_service.py` | Module file |
| `sync_service.py` | Module file |
| `user_service.py` | Module file |
+48
View File
@@ -0,0 +1,48 @@
"""VoIdeaAI - Services module."""
from app.services.agent_service import AgentService, run_agent
from app.services.analysis_service import AnalysisService
from app.services.auth_service import AuthService
from app.services.crypto_service import encrypt, decrypt, get_fernet
from app.services.email_service import send_email
from app.services.feedback_service import FeedbackService
from app.services.idea_service import IdeaService, create_idea, get_idea, list_ideas, update_idea, delete_idea
from app.services.llm_service import chat_completion, classify_intent
from app.services.pipeline_service import PipelineService
from app.services.password_reset_service import send_reset_email, reset_password
from app.services.session_service import create_session, list_sessions, get_session, delete_session
from app.services.sync_service import SyncService
from app.services.tariff_service import TariffService
from app.services.user_service import UserService
from app.services.whisper_service import transcribe
__all__ = [
"AgentService",
"run_agent",
"AnalysisService",
"AuthService",
"encrypt",
"decrypt",
"get_fernet",
"send_email",
"FeedbackService",
"IdeaService",
"create_idea",
"get_idea",
"list_ideas",
"update_idea",
"delete_idea",
"chat_completion",
"classify_intent",
"send_reset_email",
"reset_password",
"create_session",
"list_sessions",
"get_session",
"delete_session",
"SyncService",
"TariffService",
"UserService",
"transcribe",
"PipelineService",
]
+37
View File
@@ -0,0 +1,37 @@
"""Agent service for VoIdea."""
from typing import Any, Optional
from app.agents.registry import AgentRegistry
class AgentService:
def __init__(self, registry: AgentRegistry):
self.registry = registry
def list_agents(self) -> list[dict[str, Any]]:
return self.registry.list_agents()
def get_agent(self, name: str) -> Optional[dict[str, Any]]:
agent = self.registry.get(name)
if not agent:
return None
return {
"name": agent.name,
"version": agent.version,
"description": agent.description,
"status": agent.status.value,
"last_run": agent.last_run.isoformat() if agent.last_run else None,
}
async def run_agent(
self, name: str, context: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
result = await self.registry.run_agent(name, context)
return {
"success": result.success,
"message": result.message,
"data": result.data,
"errors": result.errors,
"duration_ms": result.duration_ms,
}
+74
View File
@@ -0,0 +1,74 @@
"""Analysis service for VoIdea."""
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.models import AgentReport
from app.schemas.idea import AI_AGENT_ROLES
from app.tasks.analysis import analyze_idea
class AnalysisService:
def __init__(self, db: AsyncSession):
self.db = db
async def start_analysis(self, idea_id: str) -> dict:
"""Start full analysis of an idea across all AI agent roles.
Args:
idea_id: UUID of the idea to analyze
Returns:
Dict with idea_id, task_count, and list of celery task_ids
"""
tasks = []
for role in AI_AGENT_ROLES:
task = analyze_idea.delay(idea_id, role)
tasks.append({
"role": role,
"task_id": task.id,
})
return {
"idea_id": idea_id,
"task_count": len(tasks),
"tasks": tasks,
}
async def get_analysis_results(
self, idea_id: str, role: str | None = None,
) -> list[dict]:
"""Get analysis results for an idea.
Args:
idea_id: Filter by idea
role: Optional filter by agent role
Returns:
List of analysis reports
"""
query = select(AgentReport).where(
AgentReport.details["idea_id"].as_string() == idea_id
)
if role:
query = query.where(AgentReport.agent_id == f"ai_{role}")
query = query.order_by(AgentReport.created_at.desc())
result = await self.db.execute(query)
reports = result.scalars().all()
return [
{
"id": str(r.id),
"role": r.agent_id.replace("ai_", ""),
"status": r.status,
"message": r.message,
"success": r.success,
"duration_ms": r.duration_ms,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in reports
]
+188
View File
@@ -0,0 +1,188 @@
"""Auth service for VoIdea."""
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
get_password_hash,
verify_password,
)
from app.models.user import User
settings = get_settings()
# ── Brute force protection (in-memory, TODO: move to Redis in production) ──
_login_attempts: dict[str, list[datetime]] = defaultdict(list)
MAX_LOGIN_ATTEMPTS = 5
LOGIN_WINDOW = timedelta(minutes=15)
def _check_login_attempts(email: str) -> bool:
now = datetime.now(timezone.utc)
attempts = [t for t in _login_attempts[email] if now - t < LOGIN_WINDOW]
_login_attempts[email] = attempts
return len(attempts) < MAX_LOGIN_ATTEMPTS
def _record_login_attempt(email: str):
_login_attempts[email].append(datetime.now(timezone.utc))
def _clear_login_attempts(email: str):
_login_attempts.pop(email, None)
class AuthService:
def __init__(self, db: AsyncSession):
self.db = db
async def register(
self, email: str, password: str, display_name: str,
accepted_terms: bool = True,
) -> dict:
result = await self.db.execute(
select(User).where(User.email == email)
)
if result.scalar_one_or_none():
raise ValueError("Email already registered")
if not accepted_terms:
raise ValueError("Необходимо принять Пользовательское соглашение и Политику конфиденциальности")
user = User(
id=uuid4(),
email=email,
password_hash=get_password_hash(password),
display_name=display_name,
is_active=True,
is_superuser=False,
role="user",
accepted_terms_at=datetime.now(timezone.utc),
accepted_terms_version=settings.accepted_terms_version,
)
self.db.add(user)
await self.db.commit()
await self.db.refresh(user)
# Set owner by SYSTEM_OWNER_EMAIL if matches
if settings.system_owner_email and email == settings.system_owner_email:
from app.services.user_service import UserService
svc = UserService(self.db)
await svc.set_owner_by_email(email)
return self._generate_tokens(str(user.id))
async def login(self, email: str, password: str) -> dict:
if not _check_login_attempts(email):
raise ValueError("Too many login attempts. Try again in 15 minutes.")
result = await self.db.execute(
select(User).where(User.email == email)
)
user = result.scalar_one_or_none()
if not user or not user.password_hash:
_record_login_attempt(email)
raise ValueError("Invalid credentials")
if not verify_password(password, user.password_hash):
_record_login_attempt(email)
raise ValueError("Invalid credentials")
if not user.is_active:
raise ValueError("Account is disabled")
_clear_login_attempts(email)
return self._generate_tokens(str(user.id))
async def refresh(self, refresh_token: str) -> dict:
payload = decode_token(refresh_token)
if not payload or payload.get("type") != "refresh":
raise ValueError("Invalid refresh token")
user_id = payload.get("sub")
if not user_id:
raise ValueError("Invalid token payload")
result = await self.db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise ValueError("User not found or disabled")
# Refresh token rotation: issue new pair, old token becomes invalid
return self._generate_tokens(user_id)
async def oauth_or_register_login(
self,
email: str,
oauth_provider: str,
oauth_id: str,
display_name: str,
avatar_url: str | None = None,
) -> dict:
result = await self.db.execute(
select(User).where(
User.oauth_provider == oauth_provider,
User.oauth_id == oauth_id,
)
)
user = result.scalar_one_or_none()
if user:
if not user.is_active:
raise ValueError("Account is disabled")
if avatar_url:
user.avatar_url = avatar_url
await self.db.commit()
return self._generate_tokens(str(user.id))
result = await self.db.execute(
select(User).where(User.email == email)
)
user = result.scalar_one_or_none()
if user:
user.oauth_provider = oauth_provider
user.oauth_id = oauth_id
if avatar_url:
user.avatar_url = avatar_url
await self.db.commit()
return self._generate_tokens(str(user.id))
user = User(
id=uuid4(),
email=email,
password_hash=None,
display_name=display_name,
avatar_url=avatar_url,
is_active=True,
is_superuser=False,
oauth_provider=oauth_provider,
oauth_id=oauth_id,
)
self.db.add(user)
await self.db.commit()
await self.db.refresh(user)
return self._generate_tokens(str(user.id))
async def create_token_response(self, user: User) -> dict:
return self._generate_tokens(str(user.id))
def _generate_tokens(self, user_id: str) -> dict:
now = datetime.now(timezone.utc)
return {
"access_token": create_access_token({"sub": user_id}),
"refresh_token": create_refresh_token({"sub": user_id}),
"token_type": "bearer",
"expires_at": now + settings.jwt_access_token_expire_minutes * 60,
}
+112
View File
@@ -0,0 +1,112 @@
"""Voice command service — CRUD for user-specific dynamic commands."""
from uuid import uuid4
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.voice_command import VoiceCommand
async def list_commands(
db: AsyncSession,
user_id: str,
active_only: bool = True,
) -> list[dict]:
stmt = select(VoiceCommand).where(VoiceCommand.user_id == user_id)
if active_only:
stmt = stmt.where(VoiceCommand.is_active == True)
stmt = stmt.order_by(VoiceCommand.count.desc())
result = await db.execute(stmt)
return [
{
"id": str(c.id),
"phrase": c.phrase,
"action": c.action,
"agent_name": c.agent_name,
"count": c.count,
"is_active": c.is_active,
}
for c in result.scalars().all()
]
async def create_command(
db: AsyncSession,
user_id: str,
phrase: str,
action: str,
agent_name: str | None = None,
) -> dict:
cmd = VoiceCommand(
id=uuid4(),
user_id=user_id,
phrase=phrase,
action=action,
agent_name=agent_name,
count=0,
is_active=True,
)
db.add(cmd)
await db.commit()
await db.refresh(cmd)
return {
"id": str(cmd.id),
"phrase": cmd.phrase,
"action": cmd.action,
"agent_name": cmd.agent_name,
"count": cmd.count,
"is_active": cmd.is_active,
}
async def increment_command(
db: AsyncSession,
command_id: str,
) -> bool:
result = await db.execute(
update(VoiceCommand)
.where(VoiceCommand.id == command_id)
.values(count=VoiceCommand.count + 1)
)
await db.commit()
return result.rowcount > 0
async def delete_command(db: AsyncSession, command_id: str, user_id: str) -> bool:
result = await db.execute(
select(VoiceCommand).where(
VoiceCommand.id == command_id,
VoiceCommand.user_id == user_id,
)
)
cmd = result.scalar_one_or_none()
if not cmd:
return False
await db.delete(cmd)
await db.commit()
return True
async def get_suggested_command(
db: AsyncSession,
user_id: str,
min_count: int = 3,
) -> dict | None:
"""Find a frequently used command that could be suggested."""
result = await db.execute(
select(VoiceCommand)
.where(VoiceCommand.user_id == user_id)
.where(VoiceCommand.count >= min_count)
.where(VoiceCommand.is_active == True)
.order_by(VoiceCommand.count.desc())
.limit(1)
)
cmd = result.scalar_one_or_none()
if not cmd:
return None
return {
"phrase": cmd.phrase,
"action": cmd.action,
"agent_name": cmd.agent_name,
}
+77
View File
@@ -0,0 +1,77 @@
"""Encryption service for VoIdea.
Uses Fernet (symmetric AES-128-CBC with HMAC-SHA256).
Key loaded from ENCRYPTION_KEY env var (must be 32 url-safe base64 bytes).
"""
import base64
import os
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from app.core.config import get_settings
def _derive_key(secret: str, salt: bytes | None = None) -> tuple[bytes, bytes]:
"""Derive a Fernet-compatible key from a secret string.
Args:
secret: Raw secret string (any length)
salt: Optional salt bytes (generated if None)
Returns:
Tuple of (key, salt) where key is 32 url-safe base64 bytes
"""
if salt is None:
salt = os.urandom(16)
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=600_000)
key = base64.urlsafe_b64encode(kdf.derive(secret.encode()))
return key, salt
def get_fernet() -> Fernet | None:
"""Get Fernet instance from ENCRYPTION_KEY or generate ephemeral.
Returns:
Fernet instance or None if no key configured
"""
settings = get_settings()
if not settings.encryption_key:
return None
key, _ = _derive_key(settings.encryption_key)
return Fernet(key)
def encrypt(text: str) -> str | None:
"""Encrypt a string.
Args:
text: Plain text to encrypt
Returns:
Encrypted text (base64 string) or None if encryption not configured
"""
f = get_fernet()
if f is None:
return None
return f.encrypt(text.encode()).decode()
def decrypt(token: str) -> str | None:
"""Decrypt a string.
Args:
token: Encrypted text (base64 string)
Returns:
Decrypted plain text or None if decryption fails
"""
f = get_fernet()
if f is None:
return None
try:
return f.decrypt(token.encode()).decode()
except Exception:
return None
+180
View File
@@ -0,0 +1,180 @@
"""Debug mode and log cleanup service for VoIdea."""
import json
import logging
import os
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.agent import AgentConfig
from app.models.log import LogEntry
logger = logging.getLogger("voidea.debug")
_DEBUG_KEY = "debug_config"
DEFAULT_DEBUG_CONFIG: dict[str, Any] = {
"debug_mode": False,
"debug_mode_expires_at": None,
"cleanup_interval_hours": 24,
"log_retention_days": 14,
"log_max_bytes": 524288000,
"notify_at_percent": 80,
}
class DebugService:
def __init__(self, db: AsyncSession):
self.db = db
async def _get_config_raw(self) -> dict[str, Any]:
result = await self.db.execute(
select(AgentConfig).where(AgentConfig.agent_name == "system")
)
cfg = result.scalar_one_or_none()
if not cfg or not cfg.config:
return dict(DEFAULT_DEBUG_CONFIG)
try:
data = json.loads(cfg.config)
return data.get(_DEBUG_KEY, dict(DEFAULT_DEBUG_CONFIG))
except (json.JSONDecodeError, TypeError):
return dict(DEFAULT_DEBUG_CONFIG)
async def _save_config_raw(self, data: dict[str, Any]) -> None:
result = await self.db.execute(
select(AgentConfig).where(AgentConfig.agent_name == "system")
)
cfg = result.scalar_one_or_none()
full = {}
if cfg and cfg.config:
try:
full = json.loads(cfg.config)
except (json.JSONDecodeError, TypeError):
full = {}
full[_DEBUG_KEY] = data
if not cfg:
cfg = AgentConfig(
agent_name="system",
description="System-wide configuration",
is_enabled=True,
version="1.0.0",
config=json.dumps(full, ensure_ascii=False),
)
self.db.add(cfg)
else:
cfg.config = json.dumps(full, ensure_ascii=False)
await self.db.commit()
async def get_config(self) -> dict[str, Any]:
return await self._get_config_raw()
async def update_config(self, updates: dict[str, Any]) -> dict[str, Any]:
current = await self._get_config_raw()
current.update(updates)
await self._save_config_raw(current)
return current
async def is_debug_mode(self) -> bool:
cfg = await self._get_config_raw()
if not cfg.get("debug_mode"):
return False
expires_at = cfg.get("debug_mode_expires_at")
if expires_at:
try:
exp = datetime.fromisoformat(expires_at)
if exp < datetime.now(timezone.utc):
return False
except (ValueError, TypeError):
pass
return True
async def enable_debug_mode(self, ttl_hours: int = 48) -> dict[str, Any]:
expires_at = (datetime.now(timezone.utc) + timedelta(hours=ttl_hours)).isoformat()
return await self.update_config({
"debug_mode": True,
"debug_mode_expires_at": expires_at,
})
async def disable_debug_mode(self) -> dict[str, Any]:
return await self.update_config({
"debug_mode": False,
"debug_mode_expires_at": None,
})
async def get_logs_size(self) -> int:
result = await self.db.execute(
select(func.sum(func.length(LogEntry.message)).cast(int))
)
size = result.scalar()
return size or 0
async def get_logs_count(self) -> int:
result = await self.db.execute(select(func.count(LogEntry.id)))
return result.scalar() or 0
async def get_status(self) -> dict[str, Any]:
cfg = await self._get_config_raw()
size = await self.get_logs_size()
count = await self.get_logs_count()
max_bytes = cfg.get("log_max_bytes", DEFAULT_DEBUG_CONFIG["log_max_bytes"])
percent = round((size / max_bytes) * 100, 1) if max_bytes > 0 else 0
debug_active = await self.is_debug_mode()
return {
"debug_mode": debug_active,
"debug_config": cfg,
"logs_size_bytes": size,
"logs_count": count,
"logs_max_bytes": max_bytes,
"usage_percent": min(percent, 100),
"needs_cleanup": percent >= cfg.get("notify_at_percent", 80),
}
async def cleanup_logs(self, force: bool = False) -> dict[str, Any]:
cfg = await self._get_config_raw()
retention_days = cfg.get("log_retention_days", 14)
min_retention = max(retention_days, 7)
cutoff = datetime.now(timezone.utc) - timedelta(days=min_retention)
if force:
cutoff = datetime.now(timezone.utc) - timedelta(days=min_retention)
stmt = select(func.count(LogEntry.id)).where(LogEntry.created_at < cutoff)
count_result = await self.db.execute(stmt)
to_delete = count_result.scalar() or 0
del_stmt = type(LogEntry).__table__.delete().where(LogEntry.created_at < cutoff)
await self.db.execute(del_stmt)
await self.db.commit()
return {
"deleted": to_delete,
"retention_days": min_retention,
"cutoff": cutoff.isoformat(),
}
async def auto_cleanup_if_needed(self) -> dict[str, Any] | None:
status = await self.get_status()
if status["needs_cleanup"]:
result = await self.cleanup_logs()
logger.info("Auto-cleanup performed: %d logs deleted", result["deleted"])
return result
return None
@staticmethod
async def check_last_deploy():
"""Enable debug mode for 48h if this is a fresh deploy."""
deploy_stamp_file = "/opt/voidea/.last_deploy"
if not os.path.exists(deploy_stamp_file):
return False
try:
with open(deploy_stamp_file) as f:
timestamp = int(f.read().strip())
elapsed = datetime.now().timestamp() - timestamp
if elapsed < 48 * 3600:
return True
except (ValueError, OSError):
pass
return False
+102
View File
@@ -0,0 +1,102 @@
"""Email notification service for VoIdea.
Uses aiosmtplib for async SMTP with Jinja2 templates.
Falls back to logging when SMTP is not configured.
"""
import logging
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
from app.core.config import get_settings
logger = logging.getLogger(__name__)
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "email"
_env = Environment(loader=FileSystemLoader(str(TEMPLATES_DIR)), autoescape=select_autoescape())
def _render(template_name: str, context: dict) -> str:
"""Render an email template.
Args:
template_name: Name of the template file (e.g. "welcome.html")
context: Template variables
Returns:
Rendered HTML string, or plain text fallback
"""
try:
template = _env.get_template(template_name)
return template.render(**context)
except Exception:
lines = [f"{k}: {v}" for k, v in context.items()]
return "\n".join(lines)
async def send_email(to: str, subject: str, html: str) -> bool:
"""Send an email via SMTP, or log to console if SMTP not configured.
Args:
to: Recipient email
subject: Email subject
html: HTML body
Returns:
True if sent (or logged) successfully, False on SMTP error
"""
settings = get_settings()
if not settings.smtp_host:
logger.info(
"SMTP not configured — email logged instead:\n"
" To: %s\n Subject: %s\n Body:\n%s",
to, subject, html,
)
return True
try:
import aiosmtplib
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = settings.smtp_from
msg["To"] = to
msg.attach(MIMEText(html, "html"))
await aiosmtplib.send(
msg,
hostname=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_user or None,
password=settings.smtp_pass or None,
use_tls=settings.smtp_tls,
)
return True
except Exception:
return False
async def send_welcome_email(to: str, username: str) -> bool:
"""Send welcome email after registration.
Args:
to: Recipient email
username: User display name
"""
html = _render("welcome.html", {"username": username, "project_name": "VoIdea"})
return await send_email(to, f"Добро пожаловать в VoIdea!", html)
async def send_notification_email(to: str, subject: str, message: str) -> bool:
"""Send a notification email.
Args:
to: Recipient email
subject: Email subject
message: Plain text or HTML message
"""
html = _render("notification.html", {"subject": subject, "message": message})
return await send_email(to, subject, html)
+105
View File
@@ -0,0 +1,105 @@
"""Export service for VoIdea — JSON, Markdown, HTML (printable as PDF)."""
import json
from datetime import datetime
from typing import Any
def export_json(
title: str,
content: str,
metadata: dict[str, Any] | None = None,
) -> str:
data = {
"title": title,
"exported_at": datetime.utcnow().isoformat(),
"content": content,
"metadata": metadata or {},
}
return json.dumps(data, ensure_ascii=False, indent=2)
def export_markdown(
title: str,
content: str,
metadata: dict[str, Any] | None = None,
) -> str:
lines = [f"# {title}", ""]
if metadata:
for k, v in metadata.items():
lines.append(f"- **{k}**: {v}")
lines.append("")
lines.append("---")
lines.append("")
lines.append(content)
lines.append("")
lines.append(f"*Экспортировано {datetime.utcnow().isoformat()}*")
return "\n".join(lines)
def export_html(
title: str,
content: str,
metadata: dict[str, Any] | None = None,
) -> str:
meta_rows = ""
if metadata:
for k, v in metadata.items():
meta_rows += f"<tr><td><strong>{k}</strong></td><td>{v}</td></tr>\n"
content_html = content.replace("\n", "<br>\n")
return f"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>{title}</title>
<style>
body {{ font-family: 'Segoe UI', sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; color: #333; }}
h1 {{ color: #1a1a2e; border-bottom: 2px solid #e0e0e0; padding-bottom: 10px; }}
table {{ width: 100%; border-collapse: collapse; margin: 20px 0; }}
td {{ padding: 6px 12px; border: 1px solid #e0e0e0; }}
.meta {{ background: #f5f5f5; border-radius: 8px; padding: 16px; margin: 20px 0; }}
.footer {{ margin-top: 40px; font-size: 12px; color: #999; text-align: center; }}
@media print {{ body {{ margin: 0; }} }}
</style>
</head>
<body>
<h1>{title}</h1>
<div class="meta"><table>{meta_rows}</table></div>
<hr>
<div>{content_html}</div>
<div class="footer">Экспортировано из VoIdeaAI • {datetime.utcnow().isoformat()}</div>
</body>
</html>"""
CONTENT_TYPE_MAP = {
"json": "application/json",
"md": "text/markdown; charset=utf-8",
"html": "text/html; charset=utf-8",
}
FILE_EXT_MAP = {
"json": "json",
"md": "md",
"html": "html",
}
def export_content(
fmt: str,
title: str,
content: str,
metadata: dict[str, Any] | None = None,
) -> tuple[str, str, str]:
"""Export content in requested format.
Returns (content_str, content_type, file_extension).
"""
fmt = fmt.lower()
if fmt == "json":
return export_json(title, content, metadata), CONTENT_TYPE_MAP["json"], FILE_EXT_MAP["json"]
elif fmt == "md":
return export_markdown(title, content, metadata), CONTENT_TYPE_MAP["md"], FILE_EXT_MAP["md"]
else:
return export_html(title, content, metadata), CONTENT_TYPE_MAP["html"], FILE_EXT_MAP["html"]
+53
View File
@@ -0,0 +1,53 @@
"""Feedback service for VoIdea."""
from typing import Optional
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.feedback import Feedback
class FeedbackService:
def __init__(self, db: AsyncSession):
self.db = db
async def create(
self, user_id: Optional[UUID], text: str, page_url: Optional[str] = None
) -> Feedback:
fb = Feedback(user_id=user_id, text=text, page_url=page_url, status="new")
self.db.add(fb)
await self.db.commit()
await self.db.refresh(fb)
return fb
async def list_feedback(
self, skip: int = 0, limit: int = 50, status: Optional[str] = None
) -> list[Feedback]:
query = select(Feedback)
if status:
query = query.where(Feedback.status == status)
query = query.order_by(Feedback.created_at.desc()).offset(skip).limit(limit)
result = await self.db.execute(query)
return list(result.scalars().all())
async def get_by_id(self, feedback_id: UUID) -> Optional[Feedback]:
return await self.db.get(Feedback, feedback_id)
async def update_status(self, feedback_id: UUID, status: str) -> Optional[Feedback]:
fb = await self.get_by_id(feedback_id)
if not fb:
return None
fb.status = status
await self.db.commit()
await self.db.refresh(fb)
return fb
async def delete(self, feedback_id: UUID) -> bool:
fb = await self.get_by_id(feedback_id)
if not fb:
return False
await self.db.delete(fb)
await self.db.commit()
return True
+74
View File
@@ -0,0 +1,74 @@
"""Idea service for VoIdea."""
from typing import Optional
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.idea import Idea
class IdeaService:
def __init__(self, db: AsyncSession):
self.db = db
async def create(
self, user_id: str, title: str, content: str,
tags: Optional[list[str]] = None, is_public: bool = False,
) -> Idea:
idea = Idea(
id=uuid4(),
user_id=user_id,
title=title,
content=content,
tags=tags,
is_public=is_public,
status="draft",
)
self.db.add(idea)
await self.db.commit()
await self.db.refresh(idea)
return idea
async def get_by_id(self, idea_id: str) -> Optional[Idea]:
result = await self.db.execute(
select(Idea).where(Idea.id == idea_id)
)
return result.scalar_one_or_none()
async def list_by_user(
self, user_id: str, skip: int = 0, limit: int = 50,
) -> list[Idea]:
result = await self.db.execute(
select(Idea)
.where(Idea.user_id == user_id)
.offset(skip)
.limit(limit)
.order_by(Idea.created_at.desc())
)
return list(result.scalars().all())
async def update(
self, idea_id: str, user_id: str, **kwargs,
) -> Optional[Idea]:
idea = await self.get_by_id(idea_id)
if not idea or str(idea.user_id) != user_id:
return None
for key, value in kwargs.items():
if value is not None and hasattr(idea, key):
setattr(idea, key, value)
await self.db.commit()
await self.db.refresh(idea)
return idea
async def delete(self, idea_id: str, user_id: str) -> bool:
idea = await self.get_by_id(idea_id)
if not idea or str(idea.user_id) != user_id:
return False
await self.db.delete(idea)
await self.db.commit()
return True
+130
View File
@@ -0,0 +1,130 @@
"""Unified LLM service for VoIdeaAI.
Supports OpenAI-compatible APIs (OpenAI, YandexGPT via API).
"""
import json
import logging
from datetime import datetime, timezone
from typing import Any
import httpx
from app.core.config import get_settings
from app.core.database import async_session_maker
from app.models.log import LogEntry
from app.services.debug_service import DebugService
logger = logging.getLogger("voidea.llm")
OPENAI_URL = "https://api.openai.com/v1/chat/completions"
async def _log_llm_call(messages: list[dict], response: str | None, model: str, duration_ms: int):
"""Log LLM call details if debug mode is active."""
try:
async with async_session_maker() as db:
svc = DebugService(db)
if await svc.is_debug_mode():
log = LogEntry(
level="DEBUG",
source="llm",
message=f"LLM call: model={model} duration={duration_ms}ms success={response is not None}",
details=json.dumps({
"messages": messages,
"response": response,
"model": model,
}, ensure_ascii=False),
created_at=datetime.now(timezone.utc),
)
db.add(log)
await db.commit()
except Exception:
pass
async def chat_completion(
messages: list[dict[str, str]],
model: str = "gpt-4o-mini",
temperature: float = 0.7,
max_tokens: int = 1024,
) -> str | None:
"""Call an LLM with OpenAI-compatible chat completions format.
Args:
messages: List of {"role": "system"|"user"|"assistant", "content": "..."}
model: Model name
temperature: Response creativity
max_tokens: Max tokens in response
Returns:
Response text or None on failure
"""
import time
start = time.time()
settings = get_settings()
api_key = settings.openai_api_key or settings.ai_yandex_key or ""
if not api_key:
await _log_llm_call(messages, None, model, 0)
return None
url = settings.ai_yandex_url.rstrip("/") + "/chat/completions" if settings.ai_yandex_key else OPENAI_URL
async with httpx.AsyncClient(timeout=30.0) as client:
try:
resp = await client.post(
url,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
},
)
elapsed = int((time.time() - start) * 1000)
if resp.status_code != 200:
await _log_llm_call(messages, None, model, elapsed)
return None
data = resp.json()
content = data["choices"][0]["message"]["content"]
await _log_llm_call(messages, content, model, elapsed)
return content
except Exception as e:
elapsed = int((time.time() - start) * 1000)
await _log_llm_call(messages, None, model, elapsed)
return None
async def classify_intent(text: str, agents: list[dict[str, Any]]) -> str | None:
"""Use LLM to classify user intent and select the best agent.
Args:
text: User input text
agents: List of agent dicts with name and description
Returns:
Selected agent name or None
"""
agent_list = "\n".join(f"- {a['name']}: {a['description']}" for a in agents)
prompt = f"""Ты — дирижёр умных ассистентов. Определи, какой агент лучше всего подходит для ответа пользователю.
Доступные агенты:
{agent_list}
Ответь ТОЛЬКО именем агента, без пояснений."""
messages = [
{"role": "system", "content": prompt},
{"role": "user", "content": text},
]
result = await chat_completion(messages, temperature=0.3, max_tokens=64)
if not result:
return None
result = result.strip().strip('"').strip("'")
agent_names = {a["name"] for a in agents}
return result if result in agent_names else None
+93
View File
@@ -0,0 +1,93 @@
"""Password reset service for VoIdeaAI.
Generates and validates reset tokens (JWT, type=reset, exp=1h).
Sends reset link via email_service when SMTP is configured.
"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.security import create_reset_token, decode_reset_token, get_password_hash
from app.models.user import User
from app.services.email_service import send_email
RESET_TOKEN_EXPIRE_HOURS = 1
async def send_reset_email(db: AsyncSession, email: str) -> tuple[bool, str]:
"""Send password reset email.
Args:
db: Database session
email: User's email address
Returns:
Tuple of (success, message)
"""
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if not user:
return True, "If the email exists, a reset link has been sent."
settings = get_settings()
if not settings.smtp_host:
return False, "SMTP not configured. Password reset is unavailable."
token = create_reset_token({"sub": str(user.id)})
reset_link = f"{settings.server_external_url}/reset-password?token={token}"
html = _render_reset_email(user.display_name, reset_link)
sent = await send_email(user.email, "Сброс пароля — VoIdeaAI", html)
return sent, "If the email exists, a reset link has been sent."
async def reset_password(db: AsyncSession, token: str, new_password: str) -> tuple[bool, str]:
"""Reset password using a valid reset token.
Args:
db: Database session
token: JWT reset token
new_password: New password
Returns:
Tuple of (success, message)
"""
payload = decode_reset_token(token)
if not payload or payload.get("type") != "reset":
return False, "Invalid or expired reset token."
user_id = payload.get("sub")
if not user_id:
return False, "Invalid token payload."
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
return False, "User not found."
if not user.password_hash:
return False, "Cannot reset password for OAuth-only account."
user.password_hash = get_password_hash(new_password)
await db.commit()
return True, "Password has been reset successfully."
def _render_reset_email(username: str, reset_link: str) -> str:
"""Render password reset email HTML."""
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="font-family:sans-serif;max-width:600px;margin:0 auto;">
<h2>Сброс пароля — VoIdeaAI</h2>
<p>Привет, {username}!</p>
<p>Вы запросили сброс пароля. Нажмите на ссылку ниже:</p>
<p><a href="{reset_link}" style="display:inline-block;padding:12px 24px;background:#4f46e5;color:#fff;text-decoration:none;border-radius:8px;">Сбросить пароль</a></p>
<p>Ссылка действует 1 час.</p>
<p>Если вы не запрашивали сброс — проигнорируйте это письмо.</p>
<hr><p style="color:#666;font-size:12px;">VoIdeaAI — идеи рождаются вслух, решения приходят мгновенно!</p>
</body></html>"""
+212
View File
@@ -0,0 +1,212 @@
"""Pipeline configuration and statistics service for VoIdea."""
import json
from collections import Counter
from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.agent import AgentConfig
from app.models.pipeline import PipelineStats
from app.schemas.pipeline import PipelineConfigResponse, PipelineStageConfig
DEFAULT_PIPELINE_CONFIG: dict[str, Any] = {
"vad": {
"enabled": True,
"noise_threshold": 0.3,
"silence_timeout_ms": 1500,
"min_audio_duration_ms": 300,
},
"wake_word": {
"enabled": True,
"word": "ВоИдея",
"timeout_minutes": 5,
"sensitivity": 0.7,
},
"semantic_validation": {
"mode": "fast",
"timeout_ms": 5000,
},
"confidence": {
"verified_threshold": 80,
"warning_threshold": 50,
},
"agent_chaining": {
"max_agents_per_dialog": 3,
"enabled": True,
},
"auto_tuning": {
"enabled": True,
"min_samples": 5,
"learning_rate": 0.1,
},
"stages_order": [
"vad",
"wake_word",
"semantic_validation",
"routing",
"verification",
],
}
def _dict_to_response(data: dict[str, Any]) -> PipelineConfigResponse:
stages = data.get("stages_order", DEFAULT_PIPELINE_CONFIG["stages_order"])
config_data = {k: v for k, v in data.items() if k != "stages_order"}
def stage_or_default(name: str) -> PipelineStageConfig:
default = DEFAULT_PIPELINE_CONFIG.get(name, {})
override = config_data.get(name, {})
merged = {**default, **override}
return PipelineStageConfig(**merged)
return PipelineConfigResponse(
vad=stage_or_default("vad"),
wake_word=stage_or_default("wake_word"),
semantic_validation=stage_or_default("semantic_validation"),
confidence=stage_or_default("confidence"),
agent_chaining=stage_or_default("agent_chaining"),
auto_tuning=stage_or_default("auto_tuning"),
stages_order=stages,
)
class PipelineService:
def __init__(self, db: AsyncSession):
self.db = db
async def get_config(self) -> PipelineConfigResponse:
result = await self.db.execute(
select(AgentConfig).where(AgentConfig.agent_name == "conductor")
)
conductor = result.scalar_one_or_none()
if not conductor or not conductor.config:
return _dict_to_response(dict(DEFAULT_PIPELINE_CONFIG))
try:
data = json.loads(conductor.config)
if not isinstance(data, dict):
return _dict_to_response(dict(DEFAULT_PIPELINE_CONFIG))
return _dict_to_response(data)
except (json.JSONDecodeError, TypeError):
return _dict_to_response(dict(DEFAULT_PIPELINE_CONFIG))
async def update_config(self, updates: dict[str, Any]) -> PipelineConfigResponse:
result = await self.db.execute(
select(AgentConfig).where(AgentConfig.agent_name == "conductor")
)
conductor = result.scalar_one_or_none()
if not conductor:
conductor = AgentConfig(
agent_name="conductor",
description="Дирижёр — главный оркестратор",
is_enabled=True,
version="1.0.0",
config=json.dumps(DEFAULT_PIPELINE_CONFIG, ensure_ascii=False),
)
self.db.add(conductor)
current = {}
if conductor.config:
try:
current = json.loads(conductor.config)
except (json.JSONDecodeError, TypeError):
current = {}
merged = {**DEFAULT_PIPELINE_CONFIG, **current}
stages_order = updates.pop("stages_order", None)
if stages_order is not None:
merged["stages_order"] = stages_order
for key, value in updates.items():
if value is not None and isinstance(value, dict):
existing = merged.get(key, {})
if isinstance(existing, dict):
merged[key] = {**existing, **value}
else:
merged[key] = value
conductor.config = json.dumps(merged, ensure_ascii=False)
await self.db.commit()
return _dict_to_response(merged)
async def record_stat(
self,
user_id: str | None,
stage: str,
passed: bool,
reason: str | None = None,
duration_ms: int | None = None,
) -> PipelineStats:
stat = PipelineStats(
id=str(__import__("uuid").uuid4()),
user_id=user_id,
stage=stage,
passed=passed,
reason=reason,
duration_ms=duration_ms,
created_at=datetime.now(timezone.utc),
)
self.db.add(stat)
await self.db.commit()
return stat
async def list_stats(
self,
user_id: str | None = None,
stage: str | None = None,
skip: int = 0,
limit: int = 50,
) -> list[PipelineStats]:
query = select(PipelineStats).order_by(PipelineStats.created_at.desc())
if user_id:
query = query.where(PipelineStats.user_id == user_id)
if stage:
query = query.where(PipelineStats.stage == stage)
result = await self.db.execute(query.offset(skip).limit(limit))
return list(result.scalars().all())
async def get_summary(self) -> dict[str, Any]:
total = await self.db.execute(select(func.count(PipelineStats.id)))
total_count = total.scalar() or 0
passed_count = await self.db.execute(
select(func.count(PipelineStats.id)).where(PipelineStats.passed == True)
)
passed_total = passed_count.scalar() or 0
stage_result = await self.db.execute(
select(PipelineStats.stage, func.count(PipelineStats.id))
.group_by(PipelineStats.stage)
.order_by(func.count(PipelineStats.id).desc())
)
stages = {row[0]: row[1] for row in stage_result.all()}
avg_dur = await self.db.execute(
select(func.avg(PipelineStats.duration_ms)).where(PipelineStats.duration_ms.isnot(None))
)
avg_val = avg_dur.scalar()
fail_reason_result = await self.db.execute(
select(PipelineStats.reason, func.count(PipelineStats.id))
.where(
PipelineStats.passed == False,
PipelineStats.reason.isnot(None),
)
.group_by(PipelineStats.reason)
.order_by(func.count(PipelineStats.id).desc())
.limit(10)
)
fail_reasons = [(row[0], row[1]) for row in fail_reason_result.all()]
return {
"total_entries": total_count,
"stages": stages,
"passed_ratio": round(passed_total / total_count, 4) if total_count > 0 else 0.0,
"avg_duration_ms": round(float(avg_val), 1) if avg_val else None,
"fail_reasons": fail_reasons,
}
+89
View File
@@ -0,0 +1,89 @@
"""Server-side punctuation restoration for voice transcripts.
Uses simple rule-based approach (no external model dependency).
Can be replaced with ML-based model (e.g., silero, yandex punctuator) later.
"""
from __future__ import annotations
import re
# Words that typically start a new sentence
_SENTENCE_STARTERS: set[str] = {
"а", "но", "и", "да", "вот", "так", "это", "тот", "кто", "что",
"как", "когда", "где", "почему", "зачем", "сколько", "чей",
"во-первых", "во-вторых", "наконец", "однако", "причем", "притом",
"кстати", "например", "между", "тем", "впрочем", "значит",
"итак", "следовательно", "кроме", "того", "помимо",
"я", "ты", "он", "она", "оно", "мы", "вы", "они",
"этот", "эта", "это", "эти", "мой", "твой", "наш", "ваш",
"сегодня", "завтра", "вчера", "сейчас", "потом", "после",
"сначала", "затем", "далее", "теперь", "тут", "там", "здесь",
}
# Interrogative words
_QUESTION_WORDS: set[str] = {
"кто", "что", "какой", "какая", "какое", "какие", "чей", "чья", "чьё", "чьи",
"сколько", "когда", "где", "куда", "откуда", "почему", "зачем", "как",
"неужели", "разве", "ли",
}
# Exclamation words
_EXCLAMATION_WORDS: set[str] = {
"ах", "ох", "ух", "ой", "эй", "ура", "браво", "караул",
"здорово", "отлично", "прекрасно", "замечательно", "классно",
"ужасно", "кошмар", "боже",
}
def restore_punctuation(text: str) -> str:
"""Add basic punctuation and capitalization to raw transcribed text.
Works on Russian text from Whisper/STT output which typically
comes without punctuation.
"""
if not text or not text.strip():
return text
text = text.strip()
# Split into clauses by common separators
clauses = re.split(r"(?<=[^.!?])[;,]\s+", text)
formatted: list[str] = []
for clause in clauses:
clause = clause.strip()
if not clause:
continue
# Ensure first word is capitalized
words = clause.split()
if words:
words[0] = words[0].capitalize()
# Detect sentence type and add punctuation
first_word = words[0].lower().rstrip(",.!?")
# Remove any trailing punctuation
last_word = words[-1].rstrip(",.!?") if words else ""
if first_word in _EXCLAMATION_WORDS:
punctuation = "!"
elif first_word in _QUESTION_WORDS:
punctuation = "?"
elif last_word in _QUESTION_WORDS:
punctuation = "?"
else:
punctuation = "."
formatted.append(" ".join(words) + punctuation)
result = " ".join(formatted)
# Clean up common issues
result = re.sub(r"\s+", " ", result)
result = re.sub(r"\s*([.!?,;:])\s*", r"\1 ", result)
result = re.sub(r"\s+\.", ".", result)
result = result.strip()
return result
+102
View File
@@ -0,0 +1,102 @@
"""Session service — chat sessions (each = one idea discussion)."""
from uuid import uuid4
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.session import Session
async def create_session(
db: AsyncSession,
user_id: str,
title: str = "Новое обсуждение",
) -> Session:
session = Session(
id=uuid4(),
user_id=user_id,
title=title,
status="active",
)
db.add(session)
await db.commit()
await db.refresh(session)
return session
async def list_sessions(
db: AsyncSession,
user_id: str,
status: str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[Session]:
stmt = (
select(Session)
.where(Session.user_id == user_id)
.order_by(Session.updated_at.desc())
.limit(limit)
.offset(offset)
)
if status:
stmt = stmt.where(Session.status == status)
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_session(db: AsyncSession, session_id: str) -> Session | None:
result = await db.execute(
select(Session).where(Session.id == session_id)
)
return result.scalar_one_or_none()
async def update_session_title(
db: AsyncSession,
session_id: str,
title: str,
) -> bool:
result = await db.execute(
update(Session)
.where(Session.id == session_id)
.values(title=title)
)
await db.commit()
return result.rowcount > 0
async def update_session_idea(
db: AsyncSession,
session_id: str,
idea_id: str,
) -> bool:
result = await db.execute(
update(Session)
.where(Session.id == session_id)
.values(idea_id=idea_id)
)
await db.commit()
return result.rowcount > 0
async def archive_session(db: AsyncSession, session_id: str) -> bool:
result = await db.execute(
update(Session)
.where(Session.id == session_id)
.values(status="archived")
)
await db.commit()
return result.rowcount > 0
async def delete_session(db: AsyncSession, session_id: str) -> bool:
result = await db.execute(
select(Session).where(Session.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
return False
await db.delete(session)
await db.commit()
return True
+146
View File
@@ -0,0 +1,146 @@
"""Sync service for VoIdea.
Handles pull/push sync of user data across devices.
Change tracking via updated_at timestamps.
"""
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.idea import Idea
from app.models.user import User
class SyncService:
def __init__(self, db: AsyncSession):
self.db = db
async def pull(self, user: User, last_sync: datetime | None = None) -> dict:
"""Pull changes since last_sync.
Args:
user: Authenticated user
last_sync: Last sync timestamp (None = full sync)
Returns:
Dict with changes list and sync_token
"""
query = select(Idea).where(Idea.user_id == user.id)
if last_sync:
query = query.where(Idea.updated_at > last_sync)
result = await self.db.execute(query)
ideas = result.scalars().all()
changes = [
{
"entity": "idea",
"action": "update",
"id": str(idea.id),
"data": {
"title": idea.title,
"content": idea.content,
"status": idea.status,
"updated_at": idea.updated_at.isoformat() if idea.updated_at else None,
},
}
for idea in ideas
]
return {
"status": "ok",
"changes": changes,
"sync_token": datetime.now(timezone.utc).isoformat(),
}
async def push(self, user: User, device_id: str, changes: list[dict[str, Any]]) -> dict:
"""Push changes from device.
Args:
user: Authenticated user
device_id: Source device identifier
changes: List of change dicts
Returns:
Dict with applied changes and conflicts
"""
applied = []
conflicts = []
for change in changes:
entity = change.get("entity", "idea")
action = change.get("action", "update")
change_id = change.get("id")
if entity != "idea":
applied.append({"id": change_id, "status": "skipped", "reason": "unsupported_entity"})
continue
data = change.get("data", {})
if action == "delete" and change_id:
query = select(Idea).where(Idea.id == UUID(change_id), Idea.user_id == user.id)
result = await self.db.execute(query)
idea = result.scalar_one_or_none()
if idea:
await self.db.delete(idea)
applied.append({"id": change_id, "status": "deleted"})
else:
applied.append({"id": change_id, "status": "not_found"})
continue
if change_id:
query = select(Idea).where(Idea.id == UUID(change_id), Idea.user_id == user.id)
result = await self.db.execute(query)
existing = result.scalar_one_or_none()
if existing:
incoming_ts = data.get("updated_at")
if incoming_ts and existing.updated_at:
incoming_dt = datetime.fromisoformat(incoming_ts)
if existing.updated_at.replace(tzinfo=timezone.utc) > incoming_dt.replace(tzinfo=timezone.utc):
conflicts.append({
"id": change_id,
"server_version": existing.updated_at.isoformat(),
"client_version": incoming_ts,
"message": "Server has newer version",
})
continue
for key, value in data.items():
if key != "updated_at" and hasattr(existing, key):
setattr(existing, key, value)
applied.append({"id": change_id, "status": "updated"})
else:
new_idea = Idea(
id=UUID(change_id) if change_id else uuid4(),
user_id=user.id,
title=data.get("title", ""),
content=data.get("content", ""),
status=data.get("status", "draft"),
)
self.db.add(new_idea)
applied.append({"id": change_id or str(new_idea.id), "status": "created"})
else:
new_idea = Idea(
user_id=user.id,
title=data.get("title", ""),
content=data.get("content", ""),
status=data.get("status", "draft"),
)
self.db.add(new_idea)
applied.append({"id": str(new_idea.id), "status": "created"})
await self.db.commit()
return {
"status": "ok",
"changes": applied,
"conflicts": conflicts,
"sync_token": datetime.now(timezone.utc).isoformat(),
}
+110
View File
@@ -0,0 +1,110 @@
"""Tariff service for VoIdea."""
from decimal import Decimal
from typing import Any, Optional
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tariff import TariffPlan, UserSubscription
class TariffService:
def __init__(self, db: AsyncSession):
self.db = db
# ── Plans ──
async def list_plans(self, active_only: bool = True) -> list[TariffPlan]:
query = select(TariffPlan)
if active_only:
query = query.where(TariffPlan.is_active.is_(True))
query = query.order_by(TariffPlan.sort_order)
result = await self.db.execute(query)
return list(result.scalars().all())
async def get_plan_by_id(self, plan_id: UUID) -> Optional[TariffPlan]:
return await self.db.get(TariffPlan, plan_id)
async def get_plan_by_code(self, code: str) -> Optional[TariffPlan]:
result = await self.db.execute(
select(TariffPlan).where(TariffPlan.code == code)
)
return result.scalar_one_or_none()
async def create_plan(
self,
name: str,
code: str,
description: Optional[str] = None,
price_monthly: Decimal = Decimal("0"),
price_yearly: Optional[Decimal] = None,
features: Optional[dict[str, Any]] = None,
is_active: bool = True,
sort_order: int = 0,
) -> TariffPlan:
plan = TariffPlan(
name=name,
code=code,
description=description,
price_monthly=price_monthly,
price_yearly=price_yearly,
features=features,
is_active=is_active,
sort_order=sort_order,
)
self.db.add(plan)
await self.db.commit()
await self.db.refresh(plan)
return plan
async def update_plan(
self, plan_id: UUID, updates: dict[str, Any]
) -> Optional[TariffPlan]:
plan = await self.get_plan_by_id(plan_id)
if not plan:
return None
for key, value in updates.items():
if hasattr(plan, key) and key not in ("id", "code", "created_at"):
setattr(plan, key, value)
await self.db.commit()
await self.db.refresh(plan)
return plan
async def delete_plan(self, plan_id: UUID) -> bool:
plan = await self.get_plan_by_id(plan_id)
if not plan:
return False
await self.db.delete(plan)
await self.db.commit()
return True
# ── Subscriptions ──
async def get_user_subscription(self, user_id: UUID) -> Optional[UserSubscription]:
result = await self.db.execute(
select(UserSubscription).where(UserSubscription.user_id == user_id)
)
return result.scalar_one_or_none()
async def assign_plan(self, user_id: UUID, plan_code: str) -> Optional[UserSubscription]:
plan = await self.get_plan_by_code(plan_code)
if not plan:
return None
sub = await self.get_user_subscription(user_id)
if sub:
sub.plan_id = plan.id
sub.status = "active"
else:
sub = UserSubscription(
user_id=user_id,
plan_id=plan.id,
status="active",
)
self.db.add(sub)
await self.db.commit()
await self.db.refresh(sub)
return sub
+101
View File
@@ -0,0 +1,101 @@
"""2FA TOTP service for VoIdea."""
import base64
import io
import json
from typing import Any
import pyotp
import qrcode
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.agent import AgentConfig
from app.models.user import User
def generate_totp_secret() -> str:
return pyotp.random_base32()
def get_totp_uri(secret: str, email: str, issuer: str = "VoIdeaAI") -> str:
return pyotp.totp.TOTP(secret).provisioning_uri(name=email, issuer_name=issuer)
def generate_qr_base64(uri: str) -> str:
qr = qrcode.make(uri)
buf = io.BytesIO()
qr.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode()
def verify_totp(secret: str, token: str) -> bool:
totp = pyotp.TOTP(secret)
return totp.verify(token)
def get_user_secret(user: User) -> str | None:
tuning = user.pipeline_tuning or {}
return tuning.get("totp_secret")
def set_user_secret(user: User, secret: str) -> None:
tuning = user.pipeline_tuning or {}
tuning["totp_secret"] = secret
user.pipeline_tuning = tuning
def is_2fa_enabled(user: User) -> bool:
tuning = user.pipeline_tuning or {}
return bool(tuning.get("totp_enabled", False))
def set_2fa_enabled(user: User, enabled: bool) -> None:
tuning = user.pipeline_tuning or {}
tuning["totp_enabled"] = enabled
user.pipeline_tuning = tuning
# ── Global 2FA toggle (stored in AgentConfig for "system") ──
_GLOBAL_2FA_KEY = "2fa_globally_enabled"
async def is_2fa_globally_enabled(db: AsyncSession) -> bool:
result = await db.execute(
select(AgentConfig).where(AgentConfig.agent_name == "system")
)
cfg = result.scalar_one_or_none()
if not cfg or not cfg.config:
return True
try:
data = json.loads(cfg.config)
return bool(data.get(_GLOBAL_2FA_KEY, True))
except (json.JSONDecodeError, TypeError):
return True
async def set_2fa_globally_enabled(db: AsyncSession, enabled: bool) -> None:
result = await db.execute(
select(AgentConfig).where(AgentConfig.agent_name == "system")
)
cfg = result.scalar_one_or_none()
if not cfg:
cfg = AgentConfig(
agent_name="system",
description="System-wide configuration",
is_enabled=True,
version="1.0.0",
config=json.dumps({_GLOBAL_2FA_KEY: enabled}, ensure_ascii=False),
)
db.add(cfg)
else:
current = {}
if cfg.config:
try:
current = json.loads(cfg.config)
except (json.JSONDecodeError, TypeError):
current = {}
current[_GLOBAL_2FA_KEY] = enabled
cfg.config = json.dumps(current, ensure_ascii=False)
await db.commit()
+147
View File
@@ -0,0 +1,147 @@
"""User service for VoIdea."""
from typing import Any, Optional
from fastapi import HTTPException, status
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.models.user import User
settings = get_settings()
def _check_owner(target: User) -> None:
"""Protect owner from deletion, suspension, or role change."""
if target.is_owner:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Владелец системы защищён от изменений",
)
class UserService:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, user_id: str) -> Optional[User]:
result = await self.db.execute(
select(User).where(User.id == user_id)
)
return result.scalar_one_or_none()
async def get_by_email(self, email: str) -> Optional[User]:
result = await self.db.execute(
select(User).where(User.email == email)
)
return result.scalar_one_or_none()
async def update_profile(
self, user_id: str, display_name: Optional[str] = None,
avatar_url: Optional[str] = None,
) -> Optional[User]:
user = await self.get_by_id(user_id)
if not user:
return None
if display_name is not None:
user.display_name = display_name
if avatar_url is not None:
user.avatar_url = avatar_url
await self.db.commit()
await self.db.refresh(user)
return user
async def delete(self, user_id: str) -> bool:
user = await self.get_by_id(user_id)
if not user:
return False
_check_owner(user)
user.is_active = False
await self.db.commit()
return True
async def hard_delete(self, user_id: str) -> bool:
"""Permanently delete user from DB (admin only, never for owner)."""
user = await self.get_by_id(user_id)
if not user:
return False
_check_owner(user)
await self.db.delete(user)
await self.db.commit()
return True
async def list_users(
self, skip: int = 0, limit: int = 100, search: Optional[str] = None
) -> list[User]:
query = select(User)
if search:
query = query.where(
or_(
User.display_name.ilike(f"%{search}%"),
User.email.ilike(f"%{search}%"),
)
)
result = await self.db.execute(
query.order_by(User.created_at.desc()).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def update_role(self, user_id: str, is_superuser: bool) -> Optional[User]:
user = await self.get_by_id(user_id)
if not user:
return None
_check_owner(user)
user.is_superuser = is_superuser
await self.db.commit()
await self.db.refresh(user)
return user
# ── v2.0 admin methods ──
async def admin_update_user(
self, user_id: str, updates: dict[str, Any]
) -> Optional[User]:
"""Admin: update user fields with owner protection."""
user = await self.get_by_id(user_id)
if not user:
return None
is_owner_change = any(k in updates for k in ("is_owner", "role"))
if is_owner_change or "is_active" in updates:
_check_owner(user)
for key, value in updates.items():
if hasattr(user, key) and key not in ("id", "password_hash", "created_at"):
if key == "permissions" and value is not None:
value = {k: bool(v) for k, v in value.items()}
setattr(user, key, value)
await self.db.commit()
await self.db.refresh(user)
return user
async def set_owner_by_email(self, email: str) -> Optional[User]:
"""Mark a user as system owner by email (called at startup / registration)."""
if not settings.system_owner_email:
return None
if email != settings.system_owner_email:
return None
user = await self.get_by_email(email)
if not user:
return None
if user.is_owner:
return user
user.is_owner = True
user.role = "owner"
user.is_superuser = True
user.is_active = True
await self.db.commit()
await self.db.refresh(user)
return user
+54
View File
@@ -0,0 +1,54 @@
"""Whisper transcription service for VoIdeaAI.
Used as fallback when browser Web Speech API fails.
Supports OpenAI Whisper API and compatible endpoints.
"""
import tempfile
from pathlib import Path
import httpx
from app.core.config import get_settings
WHISPER_URL = "https://api.openai.com/v1/audio/transcriptions"
async def transcribe(audio_data: bytes, filename: str = "audio.webm") -> str | None:
"""Transcribe audio using Whisper API.
Args:
audio_data: Raw audio bytes
filename: Original filename (determines format)
Returns:
Transcribed text or None if failed
"""
settings = get_settings()
api_key = settings.openai_api_key or settings.ai_yandex_key or ""
if not api_key:
return None
url = WHISPER_URL
with tempfile.NamedTemporaryFile(delete=False, suffix=Path(filename).suffix) as tmp:
tmp.write(audio_data)
tmp_path = tmp.name
try:
async with httpx.AsyncClient(timeout=30.0) as client:
with open(tmp_path, "rb") as f:
resp = await client.post(
url,
headers={"Authorization": f"Bearer {api_key}"},
files={"file": (filename, f, "audio/webm")},
data={"model": "whisper-1", "language": "ru"},
)
if resp.status_code == 200:
return resp.json().get("text")
return None
except Exception:
return None
finally:
Path(tmp_path).unlink(missing_ok=True)