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