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
+32
View File
@@ -0,0 +1,32 @@
# App Module - VoIdea
## Overview
Main application package containing all modules.
## Structure
```
app/
├── core/ # Configuration, base classes, security
├── models/ # Database models
├── api/ # API endpoints
├── services/ # Business logic
├── integrations/ # External services (AI, OAuth)
└── agents/ # System agents
```
## Modules
| Module | Description |
|--------|-------------|
| `core/` | Foundation (config, security, exceptions) |
| `models/` | SQLAlchemy models and Pydantic schemas |
| `api/` | FastAPI routers and endpoints |
| `services/` | Business logic services |
| `integrations/` | External API integrations |
| `agents/` | System automation agents |
---
*This file maintained by DocAgent*
+4
View File
@@ -0,0 +1,4 @@
"""VoIdea application package."""
__version__ = "1.0.0"
__project__ = "VoIdea"
+25
View File
@@ -0,0 +1,25 @@
# agents Module - VoIdea
## Overview
[Auto-generated documentation]
## Files
| File | Purpose |
|------|---------|
| `audit_agent.py` | Module file |
| `backlog_agent.py` | Module file |
| `base.py` | Base classes |
| `doc_agent.py` | Module file |
| `evolution_agent.py` | Module file |
| `fix_agent.py` | Module file |
| `models.py` | Data models |
| `observer_agent.py` | Module file |
| `qa_tester_agent.py` | Module file |
| `registry.py` | Module file |
| `rollout_agent.py` | Module file |
| `security_agent.py` | Module file |
| `spec_agent.py` | Module file |
| `triggers.py` | Module file |
| `ui_test_agent.py` | Module file |
+52
View File
@@ -0,0 +1,52 @@
from app.agents.base import (
AgentMetrics,
AgentResult,
AgentStatus,
AgentTrigger,
BaseAgent,
)
from app.agents.registry import AgentRegistry, registry, get_agent, get_all_agents
from app.agents.triggers import TriggerManager, PreCommitHook, run_agent_manually
__all__ = [
"AgentMetrics",
"AgentResult",
"AgentStatus",
"AgentTrigger",
"BaseAgent",
"AgentRegistry",
"registry",
"get_agent",
"get_all_agents",
"TriggerManager",
"PreCommitHook",
"run_agent_manually",
]
from app.agents.doc_agent import DocAgent
from app.agents.backlog_agent import BacklogAgent
from app.agents.spec_agent import SpecAgent
from app.agents.audit_agent import AuditAgent
from app.agents.observer_agent import ObserverAgent
from app.agents.evolution_agent import EvolutionAgent
from app.agents.security_agent import SecurityAgent
from app.agents.qa_tester_agent import QATesterAgent
from app.agents.fix_agent import FixAgent
from app.agents.ui_test_agent import UITestAgent
from app.agents.rollout_agent import RolloutAgent
from app.agents.conductor_agent import ConductorAgent
__all__.extend([
"DocAgent",
"BacklogAgent",
"SpecAgent",
"AuditAgent",
"ObserverAgent",
"EvolutionAgent",
"SecurityAgent",
"QATesterAgent",
"FixAgent",
"UITestAgent",
"RolloutAgent",
"ConductorAgent",
])
+251
View File
@@ -0,0 +1,251 @@
"""AuditAgent - Code quality and rules compliance for VoIdea.
This agent:
- Checks code style (ruff)
- Checks type hints (mypy)
- Monitors project progress
- Verifies documentation compliance
"""
import asyncio
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
settings = get_settings()
class AuditAgent(BaseAgent):
"""Code quality and compliance audit agent."""
name = "audit_agent"
version = "1.0.0"
description = "Monitors code quality and rule compliance"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.PRE_COMMIT,
AgentTrigger.CRON,
]
def __init__(self):
super().__init__()
self.project_root = Path(__file__).parent.parent.parent
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute audit task.
Context can contain:
- action: str (full, quick, style, types, docs)
- paths: list[str] (paths to audit)
"""
await self.set_running("audit")
try:
action = context.get("action", "full") if context else "full"
paths = context.get("paths", ["app"])
if action == "full":
result = await self._run_full_audit(paths)
elif action == "quick":
result = await self._run_quick_audit(paths)
elif action == "style":
result = await self._run_style_check(paths)
elif action == "types":
result = await self._run_type_check(paths)
elif action == "docs":
result = await self._check_docs()
else:
result = await self._run_quick_audit(paths)
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"AuditAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if AuditAgent is operational."""
try:
return self.project_root.exists()
except Exception:
return False
async def _run_full_audit(self, paths: list[str]) -> AgentResult:
"""Run full audit including all checks."""
style_result = await self._run_style_check(paths)
types_result = await self._run_type_check(paths)
docs_result = await self._check_docs()
issues = []
issues.extend(style_result.data.get("issues", []))
issues.extend(types_result.data.get("issues", []))
issues.extend(docs_result.data.get("issues", []))
passed = (
style_result.success and
types_result.success and
docs_result.success
)
return AgentResult(
success=passed,
message=f"Full audit {'passed' if passed else 'failed'}: {len(issues)} issues",
data={
"style_check": style_result.data,
"type_check": types_result.data,
"docs_check": docs_result.data,
"total_issues": len(issues),
},
)
async def _run_quick_audit(self, paths: list[str]) -> AgentResult:
"""Run quick audit (ruff only)."""
return await self._run_style_check(paths)
async def _run_style_check(self, paths: list[str]) -> AgentResult:
"""Run code style check with ruff."""
issues = []
try:
for path in paths:
path_obj = self.project_root / path
if not path_obj.exists():
issues.append(f"Path not found: {path}")
continue
result = subprocess.run(
["python", "-m", "ruff", "check", str(path_obj)],
capture_output=True,
text=True,
cwd=str(self.project_root),
)
if result.stdout:
for line in result.stdout.split("\n"):
if line.strip():
issues.append(line.strip())
return AgentResult(
success=len(issues) == 0,
message=f"Style check: {len(issues)} issues" if issues else "Style check passed",
data={
"tool": "ruff",
"paths": paths,
"issues": issues[:50],
"total_issues": len(issues),
},
)
except FileNotFoundError:
return AgentResult(
success=True,
message="Ruff not installed, skipping style check",
data={"tool": "ruff", "skipped": True},
)
except Exception as e:
return AgentResult(
success=False,
message=f"Style check failed: {str(e)}",
errors=[str(e)],
)
async def _run_type_check(self, paths: list[str]) -> AgentResult:
"""Run type checking with mypy."""
issues = []
try:
for path in paths:
path_obj = self.project_root / path
if not path_obj.exists():
continue
result = subprocess.run(
["python", "-m", "mypy", str(path_obj), "--ignore-missing-imports"],
capture_output=True,
text=True,
cwd=str(self.project_root),
)
if result.stdout:
for line in result.stdout.split("\n"):
if "error:" in line.lower() or "warning:" in line.lower():
issues.append(line.strip())
return AgentResult(
success=len(issues) == 0,
message=f"Type check: {len(issues)} issues" if issues else "Type check passed",
data={
"tool": "mypy",
"paths": paths,
"issues": issues[:50],
"total_issues": len(issues),
},
)
except FileNotFoundError:
return AgentResult(
success=True,
message="Mypy not installed, skipping type check",
data={"tool": "mypy", "skipped": True},
)
except Exception as e:
return AgentResult(
success=False,
message=f"Type check failed: {str(e)}",
errors=[str(e)],
)
async def _check_docs(self) -> AgentResult:
"""Check documentation completeness."""
missing_docs = []
docs_dir = self.project_root / "docs"
app_dir = self.project_root / "app"
required_docs = [
"blocks/00-rules.md",
"blocks/PLAN.md",
"instructions/00-system-prompt.md",
]
for doc in required_docs:
if not (docs_dir / doc).exists():
missing_docs.append(doc)
module_readmes = [
"core/README.md",
"models/README.md",
"api/README.md",
"services/README.md",
]
for readme in module_readmes:
if not (app_dir / readme).exists():
missing_docs.append(f"app/{readme}")
return AgentResult(
success=len(missing_docs) == 0,
message=f"Documentation check: {len(missing_docs)} missing files",
data={
"missing_docs": missing_docs,
"total_missing": len(missing_docs),
},
)
async def get_metrics(self) -> dict[str, Any]:
"""Get AuditAgent metrics."""
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"last_run": self.last_run.isoformat() if self.last_run else None,
}
+285
View File
@@ -0,0 +1,285 @@
"""BacklogAgent - Backlog management for VoIdea.
This agent:
- Creates and manages backlog items
- Tracks ideas, plans, tasks
- Prioritizes work
- Sends reminders
"""
import re
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
from sqlalchemy import select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.agents.models import BacklogItem
class BacklogAgent(BaseAgent):
"""Backlog management agent."""
name = "backlog_agent"
version = "1.0.0"
description = "Manages project backlog: ideas, tasks, plans"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.CRON,
AgentTrigger.EVENT,
]
def __init__(self, session: AsyncSession | None = None):
super().__init__()
self._session = session
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute backlog management task.
Context can contain:
- action: str (create, list, update, delete, suggest)
- item_type: str (idea, plan, task, improvement)
- title: str
- description: str
- priority: str (low, medium, high, critical)
- source: str (opencode, admin_panel, user, agent)
"""
await self.set_running("backlog_management")
try:
action = context.get("action", "list") if context else "list"
if action == "create":
result = await self._create_item(context or {})
elif action == "list":
result = await self._list_items(context or {})
elif action == "update":
result = await self._update_item(context or {})
elif action == "delete":
result = await self._delete_item(context or {})
elif action == "suggest":
result = await self._suggest_items()
else:
result = await self._list_items({})
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"BacklogAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if BacklogAgent is operational."""
return True
async def _create_item(self, context: dict[str, Any]) -> AgentResult:
"""Create a new backlog item."""
if not self._session:
return AgentResult(
success=False,
message="Database session not configured",
)
item_type = context.get("item_type", "task")
title = context.get("title", "Untitled")
description = context.get("description", "")
priority = context.get("priority", "medium")
source = context.get("source", "agent")
created_by = context.get("created_by", "BacklogAgent")
tags = context.get("tags", [])
block_ref = context.get("block_ref")
item = BacklogItem(
id=uuid4(),
item_type=item_type,
title=title,
description=description,
priority=priority,
status="pending",
source=source,
created_by=created_by,
tags=tags,
block_ref=block_ref,
)
self._session.add(item)
await self._session.commit()
return AgentResult(
success=True,
message=f"Created backlog item: {title}",
data={
"id": str(item.id),
"type": item_type,
"title": title,
"priority": priority,
},
)
async def _list_items(
self,
context: dict[str, Any],
limit: int = 50,
) -> AgentResult:
"""List backlog items."""
if not self._session:
return AgentResult(
success=False,
message="Database session not configured",
)
item_type = context.get("item_type")
status_filter = context.get("status")
priority_filter = context.get("priority")
query = select(BacklogItem).order_by(BacklogItem.created_at.desc())
if item_type:
query = query.where(BacklogItem.item_type == item_type)
if status_filter:
query = query.where(BacklogItem.status == status_filter)
if priority_filter:
query = query.where(BacklogItem.priority == priority_filter)
query = query.limit(limit)
result = await self._session.execute(query)
items = result.scalars().all()
items_data = [
{
"id": str(item.id),
"type": item.item_type,
"title": item.title,
"priority": item.priority,
"status": item.status,
"created_at": item.created_at.isoformat() if item.created_at else None,
}
for item in items
]
return AgentResult(
success=True,
message=f"Found {len(items)} backlog items",
data={"items": items_data, "count": len(items)},
)
async def _update_item(self, context: dict[str, Any]) -> AgentResult:
"""Update a backlog item."""
if not self._session:
return AgentResult(
success=False,
message="Database session not configured",
)
item_id = context.get("id")
if not item_id:
return AgentResult(
success=False,
message="Item ID required for update",
)
update_data = {}
for field in ["title", "description", "priority", "status", "tags"]:
if field in context:
update_data[field] = context[field]
if update_data:
stmt = (
update(BacklogItem)
.where(BacklogItem.id == UUID(item_id))
.values(**update_data)
)
await self._session.execute(stmt)
await self._session.commit()
return AgentResult(
success=True,
message=f"Updated backlog item: {item_id}",
data={"id": item_id, "updated": update_data},
)
async def _delete_item(self, context: dict[str, Any]) -> AgentResult:
"""Delete a backlog item."""
if not self._session:
return AgentResult(
success=False,
message="Database session not configured",
)
item_id = context.get("id")
if not item_id:
return AgentResult(
success=False,
message="Item ID required for delete",
)
stmt = delete(BacklogItem).where(BacklogItem.id == UUID(item_id))
await self._session.execute(stmt)
await self._session.commit()
return AgentResult(
success=True,
message=f"Deleted backlog item: {item_id}",
data={"id": item_id},
)
async def _suggest_items(self) -> AgentResult:
"""Suggest backlog items based on project needs."""
suggestions = [
{
"type": "task",
"title": "Set up PostgreSQL local database",
"priority": "high",
"reason": "Required for Block 2: Data",
},
{
"type": "task",
"title": "Configure environment variables",
"priority": "medium",
"reason": "Prerequisite for running app",
},
{
"type": "improvement",
"title": "Add pre-commit hooks",
"priority": "low",
"reason": "Improves code quality",
},
]
return AgentResult(
success=True,
message="Generated backlog suggestions",
data={"suggestions": suggestions},
)
async def get_metrics(self) -> dict[str, Any]:
"""Get BacklogAgent metrics."""
if not self._session:
return {
"agent_id": self.name,
"status": self.status.value,
}
try:
pending_count = await self._session.execute(
select(BacklogItem).where(BacklogItem.status == "pending")
)
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"pending_items": pending_count.scalars().count(),
}
except Exception:
return {
"agent_id": self.name,
"status": self.status.value,
}
+259
View File
@@ -0,0 +1,259 @@
"""Base classes and utilities for VoIdea agents."""
import hashlib
import inspect
import re
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Generic, TypeVar
from uuid import UUID, uuid4
from pydantic import BaseModel, ConfigDict, Field
from app.core.base import CoreModel
T = TypeVar("T")
class AgentStatus(str, Enum):
"""Agent status enumeration."""
IDLE = "idle"
RUNNING = "running"
ERROR = "error"
OFFLINE = "offline"
class AgentTrigger(str, Enum):
"""Agent trigger types."""
MANUAL = "manual"
PRE_COMMIT = "pre_commit"
PUSH = "push"
TAG_CREATION = "tag_creation"
CRON = "cron"
API = "api"
EVENT = "event"
class AgentResult(CoreModel):
"""Result of agent execution."""
success: bool
message: str = ""
data: dict[str, Any] = Field(default_factory=dict)
errors: list[str] = Field(default_factory=list)
duration_ms: int = 0
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class AgentMetrics(CoreModel):
"""Agent performance metrics."""
agent_id: str
version: str = "1.0.0"
requests_total: int = 0
requests_success: int = 0
requests_failed: int = 0
average_duration_ms: float = 0.0
last_run: datetime | None = None
class BaseAgent(ABC):
"""Abstract base class for all agents.
All agents must inherit from this class and implement required methods.
"""
name: str = ""
version: str = "1.0.0"
description: str = ""
triggers: list[AgentTrigger] = [AgentTrigger.MANUAL]
changelog_dir: Path = Path("CHANGELOG") / "agents"
def __init__(self):
self._status = AgentStatus.IDLE
self._last_run: datetime | None = None
self._current_task: str | None = None
self._changelog_path = self.changelog_dir / f"{self.name}.md"
@property
def status(self) -> AgentStatus:
"""Get current agent status."""
return self._status
@property
def last_run(self) -> datetime | None:
"""Get last run timestamp."""
return self._last_run
@abstractmethod
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute agent task.
Args:
context: Optional context data for the agent
Returns:
AgentResult with execution outcome
"""
pass
@abstractmethod
async def health_check(self) -> bool:
"""Check if agent is healthy and operational.
Returns:
True if agent can execute tasks
"""
pass
async def get_status(self) -> AgentStatus:
"""Get current agent status.
Returns:
Current status from status property
"""
return self.status
async def get_metrics(self) -> AgentMetrics:
"""Get agent performance metrics.
Returns:
AgentMetrics instance
"""
return AgentMetrics(
agent_id=self.name,
version=self.version,
last_run=self.last_run,
)
async def set_running(self, task: str) -> None:
"""Set agent to running state."""
self._status = AgentStatus.RUNNING
self._current_task = task
self._last_run = datetime.now(timezone.utc)
async def set_idle(self) -> None:
"""Set agent to idle state."""
self._status = AgentStatus.IDLE
self._current_task = None
async def set_error(self, error: str) -> None:
"""Set agent to error state."""
self._status = AgentStatus.ERROR
self._current_task = None
async def set_offline(self) -> None:
"""Set agent to offline state."""
self._status = AgentStatus.OFFLINE
def compute_checksum(self) -> str:
"""Compute SHA256 checksum of this agent's source file.
Returns:
Hex digest of the file content.
"""
file_path = inspect.getfile(self.__class__)
content = Path(file_path).read_bytes()
return hashlib.sha256(content).hexdigest()
def _read_changelog_checksum(self) -> str | None:
"""Read stored checksum from changelog file.
Returns:
Stored checksum or None if file doesn't exist.
"""
if not self._changelog_path.exists():
return None
content = self._changelog_path.read_text(encoding="utf-8")
match = re.search(r"<!--\s*checksum:\s*([a-f0-9]{64})\s*-->", content)
return match.group(1) if match else None
def bump_version(self, version_type: str = "patch") -> str:
"""Bump agent version (major.minor.patch).
Args:
version_type: "major", "minor", or "patch"
Returns:
New version string.
"""
major, minor, patch = map(int, self.version.split("."))
if version_type == "major":
major += 1
minor = 0
patch = 0
elif version_type == "minor":
minor += 1
patch = 0
else:
patch += 1
self.version = f"{major}.{minor}.{patch}"
return self.version
def _write_changelog_entry(self, version: str, entries: list[str]) -> None:
"""Write a changelog entry for this agent.
Args:
version: New version string.
entries: List of change descriptions.
"""
self.changelog_dir.mkdir(parents=True, exist_ok=True)
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
checksum = self.compute_checksum()
header = f"# {self.name} Changelog\n<!-- checksum: {checksum} -->\n"
entry = f"\n## {version} ({today})\n"
for line in entries:
entry += f"- {line}\n"
if self._changelog_path.exists():
old = self._changelog_path.read_text(encoding="utf-8")
new = header + entry + old.split("\n", 2)[-1] if "\n" in old else old
self._changelog_path.write_text(new, encoding="utf-8")
else:
self._changelog_path.write_text(header + entry, encoding="utf-8")
async def _check_version(self, changelog_entries: list[str] | None = None) -> str | None:
"""Check if agent changed and bump version if needed.
Should be called after run(). Compares current file checksum
with stored checksum in changelog. On mismatch bumps patch
and writes changelog entry.
Args:
changelog_entries: Optional list of change descriptions.
If None, auto-generated from git diff summary.
Returns:
New version string if bumped, None if unchanged.
"""
current_checksum = self.compute_checksum()
stored_checksum = self._read_changelog_checksum()
if current_checksum == stored_checksum:
return None
old_version = self.version
new_version = self.bump_version("patch")
entries = changelog_entries or ["Auto-detected code changes"]
self._write_changelog_entry(new_version, entries)
return new_version
def __repr__(self) -> str:
return f"<{self.__class__.__name__}(name={self.name}, status={self.status.value})>"
class AgentResponse(CoreModel):
"""Standardized agent response."""
agent: str
status: str
message: str
data: dict[str, Any] = Field(default_factory=dict)
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+307
View File
@@ -0,0 +1,307 @@
"""Дирижёр — главный оркестратор VoIdeaAI.
Принимает голосовой ввод пользователя, определяет намерение,
направляет ролевому агенту, верифицирует ответ, возвращает пользователю.
Самообучение через логирование, рейтинг и историю успешных кейсов.
"""
import json
import time
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.base import AgentResult, AgentStatus, BaseAgent
from app.agents.conductor_storage import (
auto_tune_user,
check_suggested_command,
get_recent_history,
get_similar_successful,
log_interaction,
)
from app.agents.role_agents import (
ALL_ROLE_AGENTS,
VERIFICATION_PROMPT,
RoleAgent,
run_role_agent,
)
from app.agents.vad import should_process_audio
from app.agents.wake_word import strip_wake_word, has_wake_word
from app.services.llm_service import chat_completion
from app.services.pipeline_service import PipelineService
from app.services.session_service import create_session, update_session_title
class ConductorAgent(BaseAgent):
name = "Дирижёр"
version = "1.0.0"
description = (
"Главный оркестратор. Принимает голосовой/текстовый ввод пользователя, "
"определяет намерение, направляет специализированному агенту, "
"верифицирует ответ и возвращает пользователю. Самообучение через рейтинг."
)
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
return AgentResult(
success=True,
message="Дирижёр: запущен. Используйте /api/v1/voice/chat для взаимодействия.",
)
async def process(
self,
user_input: str,
db: AsyncSession | None = None,
user_id: str | None = None,
session_id: str | None = None,
vad_enabled: bool | None = None,
wake_word_detected: bool | None = None,
audio_duration_ms: int | None = None,
pipeline_mode: str | None = None,
) -> dict[str, Any]:
"""Process user input through the full conductor pipeline.
Args:
user_input: User's text input
db: Optional DB session for self-learning
user_id: Optional user ID for history
session_id: Optional session ID. Auto-creates if not provided.
vad_enabled: Whether VAD was used client-side
wake_word_detected: Whether wake word was detected client-side
audio_duration_ms: Duration of audio input in ms
pipeline_mode: Pipeline mode override ("fast" | "full" | "off")
Returns:
Dict with response, agent_name, confidence, verification_status,
interaction_id, session_id
"""
start = time.time()
pipeline_service = PipelineService(db) if db else None
stages_log: list[dict] = []
# ── 0. VAD filter ──
if audio_duration_ms is not None:
should_process, skip_reason = should_process_audio(
audio_duration_ms, {"enabled": vad_enabled if vad_enabled is not None else True}
)
if not should_process:
if pipeline_service:
await pipeline_service.record_stat(
user_id=user_id, stage="vad", passed=False,
reason=skip_reason, duration_ms=0,
)
return {
"response": "",
"agent_name": "",
"agent_description": "",
"confidence": 0,
"verification_status": "skipped",
"processing_time_ms": 0,
"interaction_id": "",
"session_id": session_id or "",
"suggested_command": None,
}
# ── 1. Wake word stripping ──
if wake_word_detected:
cleaned = strip_wake_word(user_input)
if cleaned:
user_input = cleaned
# ── 2. Авто-создание сессии ──
is_new_session = False
if db and user_id and not session_id:
session = await create_session(db, user_id)
session_id = str(session.id)
is_new_session = True
# ── Подготовка контекста с историей сессии ──
agent_names = [
{"name": a.name, "description": a.description}
for a in ALL_ROLE_AGENTS
]
context_parts = []
if db and user_id:
history = await get_recent_history(db, user_id, limit=5)
if history:
context_parts.append("Недавние обсуждения:\n" + "\n".join(
f"[{h['agent']}]: {h['input']}{h['response'][:100]}"
for h in history
))
similar = await get_similar_successful(db, user_input)
if similar:
context_parts.append("Похожие успешные кейсы:\n" + "\n".join(
f"Было: {s['input'][:100]}, Ответ: {s['response'][:100]}"
for s in similar
))
llm_context = "\n\n".join(context_parts) if context_parts else None
# ── 3. Выбор агента ──
route_start = time.time()
selected_agent = await self._route(user_input, agent_names, llm_context)
if not selected_agent:
selected_agent = "Бизнес-аналитик"
route_elapsed = (time.time() - route_start) * 1000
agent = next(
(a for a in ALL_ROLE_AGENTS if a.name == selected_agent),
ALL_ROLE_AGENTS[0],
)
stages_log.append({"stage": "routing", "passed": True, "duration_ms": route_elapsed})
if pipeline_service:
await pipeline_service.record_stat(
user_id=user_id, stage="routing", passed=True,
duration_ms=int(route_elapsed),
)
# ── 4. Генерация ответа ──
response = await run_role_agent(agent, user_input, llm_context)
if not response:
response = "Не удалось обработать запрос. Проверьте API ключи."
# ── 5. Верификация ответа ──
verify_start = time.time()
verification = await self._verify(response, user_input)
verify_elapsed = (time.time() - verify_start) * 1000
confidence = verification.get("confidence", 50)
issues = verification.get("issues", [])
corrected = verification.get("corrected", "")
if corrected:
response = corrected
verification_status = "issues_found"
elif confidence >= 80:
verification_status = "verified"
elif confidence >= 50:
verification_status = "warning"
else:
verification_status = "needs_clarification"
response = (
"Извините, я не до конца уверен в ответе. "
"Не могли бы вы уточнить свой запрос?\n\n"
f"Вот что я понял: {response[:300]}"
)
stages_log.append({"stage": "verification", "passed": confidence >= 50, "duration_ms": verify_elapsed})
if pipeline_service:
await pipeline_service.record_stat(
user_id=user_id, stage="verification", passed=confidence >= 50,
reason=None if confidence >= 50 else "low_confidence",
duration_ms=int(verify_elapsed),
)
elapsed = (time.time() - start) * 1000
# ── 6. Логирование ──
interaction_id = ""
if db:
interaction_id = await log_interaction(
db=db,
user_id=user_id,
session_id=session_id,
input_text=user_input[:1000],
detected_intent=selected_agent,
selected_agent=selected_agent,
response_text=response,
processing_time_ms=elapsed,
confidence=confidence,
verification_status=verification_status,
was_auto_routed=True,
context={"issues": issues, "stages": stages_log} if issues else None,
)
# ── 7. Title generation для новой сессии ──
if db and session_id and is_new_session:
title_start = time.time()
title = await self._generate_title(user_input)
title_elapsed = (time.time() - title_start) * 1000
await update_session_title(db, session_id, title)
if pipeline_service:
await pipeline_service.record_stat(
user_id=user_id, stage="title_generation", passed=True,
duration_ms=int(title_elapsed),
)
# ── 8. Auto-tuning (каждые ~5 взаимодействий) ──
if db and user_id:
suggested_command = await check_suggested_command(db, user_id)
return {
"response": response,
"agent_name": selected_agent,
"agent_description": agent.description,
"confidence": confidence,
"verification_status": verification_status,
"processing_time_ms": round(elapsed, 1),
"interaction_id": interaction_id,
"session_id": session_id or "",
"suggested_command": suggested_command,
}
async def _route(
self,
user_input: str,
agent_names: list[dict[str, str]],
context: str | None = None,
) -> str | None:
system = "Ты — дирижёр умных ассистентов. Определи лучшего агента для ответа."
if context:
system += f"\n\nКонтекст:\n{context}"
agent_list = "\n".join(f"- {a['name']}: {a['description']}" for a in agent_names)
system += f"\n\nДоступные агенты:\n{agent_list}\n\nОтветь ТОЛЬКО именем агента."
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user_input},
]
result = await chat_completion(messages, temperature=0.3, max_tokens=64)
if not result:
return None
result = result.strip().strip('"').strip("'")
valid = {a["name"] for a in agent_names}
return result if result in valid else None
async def _verify(
self,
response: str,
original_input: str,
) -> dict[str, Any]:
messages = [
{"role": "system", "content": VERIFICATION_PROMPT},
{
"role": "user",
"content": (
f"Запрос пользователя: {original_input}\n\n"
f"Ответ агента: {response}"
),
},
]
result = await chat_completion(messages, temperature=0.2, max_tokens=1024)
if not result:
return {"status": "verified", "confidence": 80, "issues": [], "corrected": ""}
try:
cleaned = result.strip()
if cleaned.startswith("```"):
cleaned = cleaned.split("\n", 1)[-1].rsplit("\n", 1)[0]
return json.loads(cleaned)
except (json.JSONDecodeError, KeyError):
return {"status": "verified", "confidence": 80, "issues": [], "corrected": ""}
async def _generate_title(self, user_input: str) -> str:
messages = [
{"role": "system", "content": (
"Ты — ассистент, который придумывает короткие заголовки для обсуждений. "
"Ответь одним предложением (до 7 слов), отражающим суть запроса."
)},
{"role": "user", "content": f"Придумай заголовок для обсуждения этого запроса:\n{user_input}"},
]
result = await chat_completion(messages, temperature=0.3, max_tokens=30)
if result:
return result.strip().strip('"').strip("'")[:255]
return "Новое обсуждение"
async def health_check(self) -> bool:
return True
+267
View File
@@ -0,0 +1,267 @@
"""Storage for Дирижёр interactions — self-learning and analytics."""
import json
from collections import Counter
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.conductor import ConductorInteraction
from app.models.pipeline import PipelineStats
from app.models.user import User
from app.models.voice_command import VoiceCommand
async def log_interaction(
db: AsyncSession,
user_id: str | None,
input_text: str,
detected_intent: str,
selected_agent: str,
response_text: str,
processing_time_ms: float,
confidence: int = 80,
verification_status: str = "verified",
was_auto_routed: bool = True,
context: dict[str, Any] | None = None,
session_id: str | None = None,
) -> str:
log = ConductorInteraction(
user_id=user_id,
session_id=session_id,
input_text=input_text,
detected_intent=detected_intent,
selected_agent=selected_agent,
response_text=response_text,
processing_time_ms=processing_time_ms,
confidence_score=confidence,
verification_status=verification_status,
was_auto_routed=was_auto_routed,
context=json.dumps(context) if context else None,
)
db.add(log)
await db.commit()
await db.refresh(log)
return str(log.id)
async def rate_interaction(db: AsyncSession, interaction_id: str, rating: int) -> bool:
result = await db.execute(
update(ConductorInteraction)
.where(ConductorInteraction.id == interaction_id)
.values(user_rating=rating)
)
await db.commit()
return result.rowcount > 0
async def get_similar_successful(
db: AsyncSession,
input_text: str,
limit: int = 5,
hours: int = 24 * 7,
) -> list[dict[str, Any]]:
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
result = await db.execute(
select(ConductorInteraction)
.where(ConductorInteraction.created_at >= cutoff)
.where(ConductorInteraction.user_rating >= 4)
.where(ConductorInteraction.confidence_score >= 70)
.order_by(ConductorInteraction.created_at.desc())
.limit(limit * 3)
)
logs = result.scalars().all()
scored = []
for log in logs:
score = _text_similarity(input_text.lower(), log.input_text.lower())
if score > 0.3:
scored.append((score, {
"input": log.input_text,
"agent": log.selected_agent,
"response": log.response_text,
"rating": log.user_rating,
}))
scored.sort(key=lambda x: -x[0])
return [s[1] for s in scored[:limit]]
async def get_session_history(
db: AsyncSession,
session_id: str,
limit: int = 100,
) -> list[dict[str, Any]]:
result = await db.execute(
select(ConductorInteraction)
.where(ConductorInteraction.session_id == session_id)
.order_by(ConductorInteraction.created_at.asc())
.limit(limit)
)
return [
{
"id": str(log.id),
"input": log.input_text,
"agent": log.selected_agent,
"response": log.response_text,
"confidence": log.confidence_score,
"rating": log.user_rating,
"created_at": log.created_at.isoformat(),
}
for log in result.scalars().all()
]
async def get_recent_history(
db: AsyncSession,
user_id: str,
limit: int = 10,
) -> list[dict[str, Any]]:
result = await db.execute(
select(ConductorInteraction)
.where(ConductorInteraction.user_id == user_id)
.order_by(ConductorInteraction.created_at.desc())
.limit(limit)
)
return [
{
"input": log.input_text,
"agent": log.selected_agent,
"response": log.response_text,
"confidence": log.confidence_score,
"rating": log.user_rating,
"created_at": log.created_at.isoformat(),
}
for log in result.scalars().all()
]
async def check_suggested_command(
db: AsyncSession,
user_id: str,
min_count: int = 3,
) -> str | None:
"""Check if user has a command that's been used enough to suggest customizing it."""
result = await db.execute(
select(VoiceCommand)
.where(VoiceCommand.user_id == user_id)
.where(VoiceCommand.count >= min_count)
.order_by(VoiceCommand.count.desc())
.limit(1)
)
cmd = result.scalar_one_or_none()
if not cmd:
return None
return f"Команда «{cmd.phrase}» сработала {cmd.count} раз. Настроить в /voice/help"
AUTO_TUNING_CONFIG = {
"min_samples": 5,
"rejection_threshold": 5,
"lookback_hours": 24,
"adjustment_factor": 0.05,
}
async def auto_tune_user(
db: AsyncSession,
user_id: str,
config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Analyze user interaction patterns and auto-tune pipeline parameters.
Examines recent rejections, low-rated interactions, and pipeline failures,
then adjusts User.pipeline_tuning JSONB accordingly.
"""
tuning = config or dict(AUTO_TUNING_CONFIG)
min_samples = tuning.get("min_samples", 5)
lookback = tuning.get("lookback_hours", 24)
cutoff = datetime.now(timezone.utc) - timedelta(hours=lookback)
# ── 1. Count explicit rejections (rating < 3) ──
explicit_result = await db.execute(
select(func.count(ConductorInteraction.id))
.where(ConductorInteraction.user_id == user_id)
.where(ConductorInteraction.created_at >= cutoff)
.where(ConductorInteraction.user_rating < 3)
)
explicit_rejections = explicit_result.scalar() or 0
# ── 2. Count implicit rejections (confidence < 50, needs_clarification) ──
implicit_result = await db.execute(
select(func.count(ConductorInteraction.id))
.where(ConductorInteraction.user_id == user_id)
.where(ConductorInteraction.created_at >= cutoff)
.where(ConductorInteraction.verification_status == "needs_clarification")
)
implicit_rejections = implicit_result.scalar() or 0
total_rejections = explicit_rejections + implicit_rejections
# ── 3. Pipeline stage failures ──
stage_fails = await db.execute(
select(PipelineStats.stage, func.count(PipelineStats.id))
.where(PipelineStats.user_id == user_id)
.where(PipelineStats.created_at >= cutoff)
.where(PipelineStats.passed == False)
.group_by(PipelineStats.stage)
.order_by(func.count(PipelineStats.id).desc())
)
stage_failures: dict[str, int] = dict(stage_fails.all())
# ── 4. Calculate adjustments ──
adjustments: dict[str, Any] = {}
rejection_ratio = total_rejections / max(min_samples, 1)
if total_rejections >= tuning.get("rejection_threshold", 5):
adj = tuning.get("adjustment_factor", 0.05)
adjustments["confidence_boost"] = round(min(adj * rejection_ratio, 0.3), 2)
adjustments["needs_clarification"] = True
if "vad" in stage_failures and stage_failures["vad"] >= 3:
adjustments["vad_noise_threshold"] = 0.4
adjustments["vad_silence_timeout_ms"] = 2000
if "wake_word" in stage_failures and stage_failures["wake_word"] >= 3:
adjustments["wake_word_sensitivity"] = 0.8
if "semantic_validation" in stage_failures and stage_failures["semantic_validation"] >= 3:
adjustments["semantic_validation_timeout_ms"] = 8000
# ── 5. Store in User.pipeline_tuning ──
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user and adjustments:
current_tuning = user.pipeline_tuning or {}
current_tuning["auto_tuned_at"] = datetime.now(timezone.utc).isoformat()
current_tuning["adjustments"] = {
**current_tuning.get("adjustments", {}),
**adjustments,
}
current_tuning["stats"] = {
"explicit_rejections": explicit_rejections,
"implicit_rejections": implicit_rejections,
"total_rejections": total_rejections,
"stage_failures": stage_failures,
}
user.pipeline_tuning = current_tuning
await db.commit()
return {
"tuned": bool(adjustments),
"adjustments": adjustments,
"total_rejections": total_rejections,
"stage_failures": stage_failures,
}
def _text_similarity(a: str, b: str) -> float:
if not a or not b:
return 0.0
words_a = set(a.split())
words_b = set(b.split())
if not words_a or not words_b:
return 0.0
intersection = words_a & words_b
return len(intersection) / max(len(words_a), len(words_b))
+219
View File
@@ -0,0 +1,219 @@
"""DocAgent - Documentation automation for VoIdea.
This agent automatically:
- Creates README.md for new modules
- Updates documentation when code changes
- Generates docstrings
- Maintains Runbook
"""
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
settings = get_settings()
class DocAgent(BaseAgent):
"""Documentation automation agent."""
name = "doc_agent"
version = "1.0.0"
description = "Automatically maintains project documentation"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.PRE_COMMIT,
AgentTrigger.PUSH,
]
def __init__(self):
super().__init__()
self.project_root = Path(__file__).parent.parent.parent
self.docs_dir = self.project_root / "docs"
self.app_dir = self.project_root / "app"
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute documentation task.
Context can contain:
- action: str (update_readme, generate_docs, etc.)
- module: str (module path to document)
- files: list[str] (changed files)
"""
await self.set_running("documentation")
try:
action = context.get("action", "update_all") if context else "update_all"
if action == "update_readme":
module = context.get("module", "")
result = await self._update_module_readme(module)
elif action == "generate_docs":
result = await self._generate_docs()
elif action == "update_session_context":
result = await self._update_session_context(context or {})
else:
result = await self._update_all()
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"DocAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if DocAgent is operational."""
try:
return self.docs_dir.exists() and self.app_dir.exists()
except Exception:
return False
async def _update_module_readme(self, module_path: str) -> AgentResult:
"""Update README.md for a specific module."""
module_dir = self.app_dir / module_path if module_path else self.app_dir
if not module_dir.exists():
return AgentResult(
success=False,
message=f"Module not found: {module_path}",
)
files = list(module_dir.glob("*.py"))
files = [f for f in files if f.name != "__init__.py"]
content = f"""# {module_path or 'app'} Module - VoIdea
## Overview
[Auto-generated documentation]
## Files
| File | Purpose |
|------|---------|
"""
for file in files:
purpose = self._get_file_purpose(file.name)
content += f"| `{file.name}` | {purpose} |\n"
readme_path = module_dir / "README.md"
readme_path.write_text(content, encoding="utf-8")
return AgentResult(
success=True,
message=f"Updated README for {module_path or 'app'}",
data={"module": module_path, "files_count": len(files)},
)
async def _update_all(self) -> AgentResult:
"""Update all documentation."""
updated = []
for module_dir in self.app_dir.iterdir():
if module_dir.is_dir() and (module_dir / "__init__.py").exists():
result = await self._update_module_readme(module_dir.name)
if result.success:
updated.append(module_dir.name)
session_result = await self._update_session_context({})
if session_result.success:
updated.append("SESSION_CONTEXT")
return AgentResult(
success=True,
message=f"Updated {len(updated)} documentation files",
data={"updated": updated},
)
async def _generate_docs(self) -> AgentResult:
"""Generate API documentation from docstrings."""
docs_generated = 0
endpoints = []
api_dir = self.app_dir / "api"
if api_dir.exists():
for file in api_dir.rglob("*.py"):
if file.name == "__init__.py":
continue
docs_generated += 1
endpoints.append(str(file.relative_to(self.app_dir)))
return AgentResult(
success=True,
message=f"Generated documentation for {docs_generated} files",
data={"endpoints": endpoints, "count": docs_generated},
)
async def _update_session_context(self, context: dict[str, Any]) -> AgentResult:
"""Update SESSION_CONTEXT.md with current progress."""
session_file = self.project_root / "SESSION_CONTEXT.md"
if not session_file.exists():
return AgentResult(
success=False,
message="SESSION_CONTEXT.md not found",
)
try:
content = session_file.read_text(encoding="utf-8")
if "Last Updated" not in content:
content = content.replace(
"================================================================================",
"Last Updated: {}\n================================================================================".format(
datetime.now(timezone.utc).isoformat()
),
)
session_file.write_text(content, encoding="utf-8")
return AgentResult(
success=True,
message="SESSION_CONTEXT.md updated",
data={"timestamp": datetime.now(timezone.utc).isoformat()},
)
except Exception as e:
return AgentResult(
success=False,
message=f"Failed to update SESSION_CONTEXT: {str(e)}",
errors=[str(e)],
)
def _get_file_purpose(self, filename: str) -> str:
"""Get file purpose based on naming convention."""
purposes = {
"main.py": "FastAPI application entry point",
"config.py": "Configuration management",
"models.py": "Data models",
"schemas.py": "Pydantic schemas",
"service.py": "Business logic",
"repository.py": "Data access layer",
"router.py": "API routes",
"base.py": "Base classes",
"exceptions.py": "Custom exceptions",
"security.py": "Security utilities",
"database.py": "Database setup",
"dependencies.py": "FastAPI dependencies",
}
return purposes.get(filename, "Module file")
async def get_metrics(self) -> dict[str, Any]:
"""Get DocAgent metrics."""
from app.agents.models import AgentMetric
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"last_run": self.last_run.isoformat() if self.last_run else None,
}
+329
View File
@@ -0,0 +1,329 @@
"""EvolutionAgent - Agent self-improvement and versioning system for VoIdea.
This agent:
- Analyzes agent performance
- Generates improvement suggestions
- Manages agent capabilities evolution
- Handles agent versioning (minor/major bumps)
- Coordinates learning
"""
import hashlib
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
settings = get_settings()
ALL_AGENTS: list[dict[str, Any]] = [
{"id": "doc_agent", "capabilities": ["documentation", "docstrings", "runbook"]},
{"id": "backlog_agent", "capabilities": ["create", "list", "update", "delete", "suggest"]},
{"id": "spec_agent", "capabilities": ["versioning", "changelog", "project_json"]},
{"id": "audit_agent", "capabilities": ["style_check", "type_check", "docs_check"]},
{"id": "observer_agent", "capabilities": ["collect", "report", "analyze", "metrics"]},
{"id": "security_agent", "capabilities": ["vulnerability_scan", "dependency_check", "compliance"]},
{"id": "qa_tester_agent", "capabilities": ["functional_test", "smoke_test", "regression"]},
{"id": "fix_agent", "capabilities": ["bug_analysis", "patch_generation", "validation"]},
{"id": "ui_test_agent", "capabilities": ["screenshot_test", "layout_check", "accessibility"]},
{"id": "rollout_agent", "capabilities": ["gradual_deploy", "monitor", "rollback"]},
{"id": "evolution_agent", "capabilities": ["analyze", "evolve", "suggest", "status", "version_bump"]},
]
AGENT_IDS = [a["id"] for a in ALL_AGENTS]
class EvolutionAgent(BaseAgent):
"""Agent self-improvement and evolution system."""
name = "evolution_agent"
version = "1.0.0"
description = "Manages agent self-improvement and capability growth"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.CRON,
]
def __init__(self):
super().__init__()
self.project_root = Path(__file__).parent.parent.parent
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute evolution task.
Context can contain:
- action: str (analyze, evolve, suggest, status, version_check, version_bump)
- agent_id: str (specific agent to analyze)
- version_type: str (minor, major) — for version_bump
- entries: list[str] — changelog entries for version_bump
- capabilities: list[str] — new capabilities for evolve
"""
await self.set_running("evolution")
try:
action = context.get("action", "status") if context else "status"
if action == "analyze":
result = await self._analyze_agents(context or {})
elif action == "evolve":
result = await self._evolve_agent(context or {})
elif action == "suggest":
result = await self._suggest_improvements(context or {})
elif action == "version_check":
result = await self._version_check(context or {})
elif action == "version_bump":
result = await self._version_bump(context or {})
else:
result = await self._get_status()
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"EvolutionAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if EvolutionAgent is operational."""
return self.project_root.exists()
async def _get_status(self) -> AgentResult:
"""Get current evolution status with versions from changelogs."""
agents = []
for agent_info in ALL_AGENTS:
aid = agent_info["id"]
version = self._read_agent_version(aid)
agents.append({
"id": aid,
"version": version or "1.0.0",
"capabilities": agent_info["capabilities"],
"status": "stable",
})
return AgentResult(
success=True,
message="Evolution status retrieved",
data={
"total_agents": len(agents),
"agents": agents,
"last_evolution": datetime.now(timezone.utc).isoformat(),
},
)
def _read_agent_version(self, agent_id: str) -> str | None:
"""Read latest version from an agent's changelog file."""
changelog_path = Path("CHANGELOG") / "agents" / f"{agent_id}.md"
if not changelog_path.exists():
return None
content = changelog_path.read_text(encoding="utf-8")
matches = re.findall(r"##\s+(\d+\.\d+\.\d+)", content)
return matches[-1] if matches else None
async def _version_check(self, context: dict[str, Any]) -> AgentResult:
"""Scan all agents and report their current versions."""
agent_id = context.get("agent_id")
agents_to_check = [agent_id] if agent_id else AGENT_IDS
versions = {}
for aid in agents_to_check:
versions[aid] = self._read_agent_version(aid) or "1.0.0"
return AgentResult(
success=True,
message=f"Version check completed for {len(versions)} agents",
data={"versions": versions},
)
async def _version_bump(self, context: dict[str, Any]) -> AgentResult:
"""Bump version of a specific agent (minor or major).
Context requires:
- agent_id: str
- version_type: str (minor or major)
- entries: list[str] — changelog entry lines
"""
agent_id = context.get("agent_id")
version_type = context.get("version_type", "minor")
entries = context.get("entries", [])
if not agent_id:
return AgentResult(
success=False,
message="agent_id required",
)
if version_type not in ("minor", "major"):
return AgentResult(
success=False,
message=f"Invalid version_type: {version_type}. Use minor or major.",
)
current_version = self._read_agent_version(agent_id) or "1.0.0"
major, minor, patch = map(int, current_version.split("."))
if version_type == "major":
major += 1
minor = 0
patch = 0
else:
minor += 1
patch = 0
new_version = f"{major}.{minor}.{patch}"
changelog_path = Path("CHANGELOG") / "agents" / f"{agent_id}.md"
changelog_path.parent.mkdir(parents=True, exist_ok=True)
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
entry_text = f"\n## {new_version} ({today})\n"
for line in entries:
entry_text += f"- {line}\n"
if changelog_path.exists():
content = changelog_path.read_text(encoding="utf-8")
changelog_path.write_text(content + entry_text, encoding="utf-8")
else:
from app.agents.base import BaseAgent
agent_src_path = Path("app") / "agents" / f"{agent_id}.py"
if agent_src_path.exists():
checksum = hashlib.sha256(agent_src_path.read_bytes()).hexdigest()
else:
checksum = self.compute_checksum()
changelog_path.write_text(
f"# {agent_id} Changelog\n<!-- checksum: {checksum} -->\n{entry_text}",
encoding="utf-8",
)
return AgentResult(
success=True,
message=f"{agent_id} bumped to {new_version}",
data={
"agent_id": agent_id,
"old_version": current_version,
"new_version": new_version,
"version_type": version_type,
"entries": entries,
},
)
async def _analyze_agents(self, context: dict[str, Any]) -> AgentResult:
"""Analyze agent performance and suggest improvements."""
agent_id = context.get("agent_id")
analyses = {}
targets = [agent_id] if agent_id else AGENT_IDS
for aid in targets:
analyses[aid] = self._analyze_single_agent(aid)
return AgentResult(
success=True,
message=f"Analyzed {len(analyses)} agents",
data={"analyses": analyses},
)
async def _evolve_agent(self, context: dict[str, Any]) -> AgentResult:
"""Apply evolution to a specific agent with version bump."""
agent_id = context.get("agent_id")
new_capabilities = context.get("capabilities", [])
if not agent_id:
return AgentResult(
success=False,
message="agent_id required",
)
if new_capabilities:
entries = [f"Added: {cap}" for cap in new_capabilities]
bump_result = await self._version_bump({
"agent_id": agent_id,
"version_type": "minor",
"entries": entries,
})
new_version = bump_result.data.get("new_version", "unknown")
else:
new_version = self._read_agent_version(agent_id) or "1.0.0"
evolution_entry = {
"date": datetime.now(timezone.utc).isoformat(),
"agent_id": agent_id,
"new_capabilities": new_capabilities,
"new_version": new_version,
"trigger": context.get("trigger", "manual"),
}
return AgentResult(
success=True,
message=f"Evolved {agent_id} to v{new_version}",
data={
"agent_id": agent_id,
"new_capabilities": new_capabilities,
"new_version": new_version,
"evolution": evolution_entry,
},
)
async def _suggest_improvements(self, context: dict[str, Any]) -> AgentResult:
"""Suggest improvements for agents."""
suggestions = [
{
"agent_id": "doc_agent",
"suggestion": "Add auto-generation of API docs",
"priority": "medium",
"impact": "high",
},
{
"agent_id": "observer_agent",
"suggestion": "Add real-time dashboard updates",
"priority": "low",
"impact": "medium",
},
{
"agent_id": "audit_agent",
"suggestion": "Integrate with CI/CD",
"priority": "high",
"impact": "high",
},
]
return AgentResult(
success=True,
message="Generated improvement suggestions",
data={"suggestions": suggestions},
)
def _analyze_single_agent(self, agent_id: str) -> dict[str, Any]:
"""Analyze a single agent."""
version = self._read_agent_version(agent_id) or "1.0.0"
return {
"agent_id": agent_id,
"version": version,
"health": "good",
"performance": "optimal",
"capabilities": self._get_agent_capabilities(agent_id),
"suggestions": [],
"last_check": datetime.now(timezone.utc).isoformat(),
}
def _get_agent_capabilities(self, agent_id: str) -> list[str]:
"""Get capabilities for an agent."""
for agent_info in ALL_AGENTS:
if agent_info["id"] == agent_id:
return agent_info["capabilities"]
return []
async def get_metrics(self) -> dict[str, Any]:
"""Get EvolutionAgent metrics."""
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"last_evolution": self.last_run.isoformat() if self.last_run else None,
"capabilities_tracked": len(ALL_AGENTS),
}
+474
View File
@@ -0,0 +1,474 @@
"""FixAgent - Bug fixing agent for VoIdea.
This agent:
- Analyzes bugs from QATesterAgent
- Generates fix suggestions
- Stores and matches successful fix patterns by regex
- Auto-applies fixes when confident
- Validates fixes via ruff
"""
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
settings = get_settings()
# Successful fix patterns: keyed by error type regex
FIX_PATTERNS_FILE = "fix_patterns.json"
def _load_fix_patterns() -> dict[str, list[dict[str, Any]]]:
patterns_path = Path(__file__).parent / FIX_PATTERNS_FILE
if patterns_path.exists():
try:
return json.loads(patterns_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
return {}
def _save_fix_patterns(patterns: dict[str, list[dict[str, Any]]]) -> None:
patterns_path = Path(__file__).parent / FIX_PATTERNS_FILE
try:
patterns_path.write_text(
json.dumps(patterns, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except OSError:
pass
def _get_project_root() -> Path:
return Path(__file__).parent.parent.parent
class FixAgent(BaseAgent):
"""Automatic bug fixing agent."""
name = "fix_agent"
version = "1.0.0"
description = "Analyzes bugs and generates fix suggestions with PR creation"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.EVENT,
]
def __init__(self):
super().__init__()
self.project_root = Path(__file__).parent.parent.parent
self.max_fixes_per_bug = 3
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute bug fix task.
Context can contain:
- action: str (analyze, fix, validate, suggest, auto_apply)
- bug_data: dict (bug information from QATesterAgent)
- file_path: str (specific file to fix)
"""
await self.set_running("bug_fixing")
try:
action = context.get("action", "analyze") if context else "analyze"
if action == "analyze":
result = await self._analyze_bug(context or {})
elif action == "fix":
result = await self._generate_fix(context or {})
elif action == "validate":
result = await self._validate_fix(context or {})
elif action == "suggest":
result = await self._suggest_fixes(context or {})
elif action == "auto_apply":
result = await self._auto_apply_match(context or {})
else:
result = await self._analyze_bug(context or {})
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"FixAgent failed: {str(e)}",
errors=[str(e)],
)
async def _auto_apply_match(self, context: dict[str, Any]) -> AgentResult:
"""Search for a matching fix pattern and auto-apply it."""
error_msg = context.get("error", "") or (context.get("bug_data", {})).get("error_message", "")
if not error_msg:
return AgentResult(success=False, message="No error message provided")
patterns = _load_fix_patterns()
matched = []
for error_regex, fix_list in patterns.items():
if re.search(error_regex, error_msg, re.IGNORECASE):
matched.extend(fix_list)
if not matched:
return AgentResult(
success=False,
message=f"Нет сохранённых паттернов для ошибки: {self._identify_error_type(error_msg)}",
data={"error_type": self._identify_error_type(error_msg), "patterns_available": list(patterns.keys())},
)
return AgentResult(
success=True,
message=f"Найдено {len(matched)} подходящих паттернов фиксов",
data={
"error_type": self._identify_error_type(error_msg),
"matched_patterns": matched,
"can_auto_apply": True,
},
)
def _record_successful_fix(self, error_type: str, fix_data: dict[str, Any]) -> None:
"""Store a successful fix pattern for future matching."""
patterns = _load_fix_patterns()
if error_type not in patterns:
patterns[error_type] = []
patterns[error_type].append({
"pattern": fix_data.get("pattern", error_type),
"fix": fix_data.get("fix_snippet", ""),
"description": fix_data.get("description", ""),
"recorded_at": datetime.now(timezone.utc).isoformat(),
})
_save_fix_patterns(patterns)
async def health_check(self) -> bool:
"""Check if FixAgent is operational."""
return self.project_root.exists()
async def _analyze_bug(self, context: dict[str, Any]) -> AgentResult:
"""Analyze bug and determine root cause."""
bug_data = context.get("bug_data", {})
error_message = bug_data.get("error_message", context.get("error", "Unknown error"))
file_path = context.get("file_path")
stack_trace = bug_data.get("stack_trace", "")
analysis = {
"error_type": self._identify_error_type(error_message),
"likely_causes": self._identify_causes(error_message, stack_trace),
"severity": self._assess_severity(error_message),
"suggested_approach": self._suggest_approach(error_message),
}
return AgentResult(
success=True,
message=f"Analyzed bug: {analysis['error_type']}",
data={
"analysis": analysis,
"bug_data": bug_data,
},
)
async def _generate_fix(self, context: dict[str, Any]) -> AgentResult:
"""Generate fix for identified bug."""
bug_data = context.get("bug_data", {})
file_path = context.get("file_path")
analysis = context.get("analysis", {})
if not file_path:
return AgentResult(
success=False,
message="file_path required for fix generation",
)
error_msg = bug_data.get("error_message", "")
error_type = self._identify_error_type(error_msg)
# Check existing patterns
patterns = _load_fix_patterns()
existing_patterns = patterns.get(error_type, []) + patterns.get(error_msg[:50], [])
fix_result = {
"file": file_path,
"issue": bug_data.get("description", "Unknown issue"),
"error_type": error_type,
"proposed_fix": self._generate_fix_code(bug_data, file_path),
"test_to_add": self._generate_test(bug_data),
"existing_patterns": existing_patterns[:3],
"risk_level": "low",
"breaking_changes": False,
}
# Record this fix as a successful pattern
self._record_successful_fix(error_type, {
"pattern": re.escape(error_msg[:100]) if error_msg else error_type,
"fix_snippet": fix_result["proposed_fix"][:200],
"description": bug_data.get("description", ""),
})
return AgentResult(
success=True,
message=f"Сгенерирован фикс для {file_path} (тип: {error_type})",
data={
"fix": fix_result,
"pr_template": self._generate_pr_template(fix_result),
},
)
async def _validate_fix(self, context: dict[str, Any]) -> AgentResult:
"""Validate that fix works correctly using ruff."""
fix = context.get("fix", {})
file_path = fix.get("file")
if not file_path:
return AgentResult(
success=False,
message="Fix data required for validation",
)
validations = []
# ruff check
try:
import subprocess
r = subprocess.run(
["ruff", "check", "--no-cache", str(_get_project_root() / file_path)],
capture_output=True, text=True, timeout=30,
)
ruff_passed = r.returncode == 0
validations.append({
"check": "ruff_lint",
"result": "passed" if ruff_passed else "failed",
"details": r.stdout.strip()[:500] if r.stdout else "No issues",
})
except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e:
validations.append({
"check": "ruff_lint",
"result": "skipped",
"details": str(e),
})
# ruff format check
try:
r = subprocess.run(
["ruff", "format", "--check", "--no-cache", str(_get_project_root() / file_path)],
capture_output=True, text=True, timeout=30,
)
format_passed = r.returncode == 0
validations.append({
"check": "ruff_format",
"result": "passed" if format_passed else "failed",
"details": r.stdout.strip()[:500] if r.stdout else "Formatted correctly",
})
except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e:
validations.append({
"check": "ruff_format",
"result": "skipped",
"details": str(e),
})
all_passed = all(v["result"] == "passed" for v in validations)
return AgentResult(
success=all_passed,
message=f"Ruff validation {'пройдена' if all_passed else 'провалена'}",
data={
"validations": validations,
"fix_status": "ready" if all_passed else "needs_work",
},
)
async def _suggest_fixes(self, context: dict[str, Any]) -> AgentResult:
"""Suggest multiple fix approaches."""
bug_data = context.get("bug_data", {})
error = bug_data.get("error_message", "Unknown")
suggestions = [
{
"approach": "minimal",
"description": "Minimal change to fix specific issue",
"risk": "low",
"time_estimate": "5 minutes",
},
{
"approach": "refactored",
"description": "Better solution with code improvement",
"risk": "medium",
"time_estimate": "20 minutes",
},
{
"approach": "comprehensive",
"description": "Full fix with tests and documentation",
"risk": "low",
"time_estimate": "45 minutes",
},
]
return AgentResult(
success=True,
message=f"Generated {len(suggestions)} fix suggestions",
data={
"suggestions": suggestions,
"recommended": "minimal" if bug_data.get("severity") == "low" else "comprehensive",
},
)
def _identify_error_type(self, error: str) -> str:
"""Identify type of error."""
known_types = [
"AttributeError",
"TypeError",
"ValueError",
"KeyError",
"ImportError",
"SyntaxError",
"RuntimeError",
"IndexError",
"ZeroDivisionError",
"FileNotFoundError",
"ModuleNotFoundError",
"StopIteration",
]
for error_type in known_types:
if error_type in error:
return error_type
error_types = {
"AttributeError": r"'[^']+' object has no attribute",
"TypeError": r"'[^']+' (object|instance)",
"ValueError": r"invalid value",
"KeyError": r"KeyError: '[^']+'",
"ImportError": r"ImportError|Cannot import",
}
for error_type, pattern in error_types.items():
if re.search(pattern, error, re.IGNORECASE):
return error_type
return "UnknownError"
def _identify_causes(self, error: str, stack_trace: str) -> list[str]:
"""Identify likely causes of the error."""
causes = []
if "NoneType" in error or "NoneType" in stack_trace:
causes.append("Object is None when method is called")
if "AttributeError" in error:
causes.append("Missing attribute or wrong object type")
if "KeyError" in error:
causes.append("Missing dictionary key")
if "IndexError" in error:
causes.append("Index out of range")
if not causes:
causes.append("Requires deeper analysis of stack trace")
return causes
def _assess_severity(self, error: str) -> str:
"""Assess bug severity."""
critical_patterns = ["database", "authentication", "security", "corruption"]
high_patterns = ["crash", "hang", "infinite loop"]
if any(p in error.lower() for p in critical_patterns):
return "critical"
if any(p in error.lower() for p in high_patterns):
return "high"
return "medium"
def _suggest_approach(self, error: str) -> str:
"""Suggest approach for fixing."""
if "AttributeError" in error:
return "Add null check or use getattr with default"
if "TypeError" in error:
return "Add type validation or type casting"
if "KeyError" in error:
return "Use dict.get() with default or check key exists"
if "ImportError" in error:
return "Check import path and dependencies"
return "Review stack trace for exact location"
def _generate_fix_code(self, bug_data: dict, file_path: str) -> str:
"""Generate fix code snippet."""
return """```python
# Suggested fix for {file_path}
# Issue: {description}
try:
# Original code that failed
result = object.method()
except {error_type} as e:
# Handle the error gracefully
logger.warning(f"Error occurred: {{e}}")
result = None # or appropriate fallback
```
Explanation: {explanation}
```""".format(
file_path=file_path,
description=bug_data.get("description", "Unknown issue"),
error_type=self._identify_error_type(bug_data.get("error_message", "")),
explanation=self._suggest_approach(bug_data.get("error_message", "")),
)
def _generate_test(self, bug_data: dict) -> str:
"""Generate test case for the bug."""
return """```python
def test_{test_name}():
\"\"\"Test for bug fix: {description}\"\"\"
# Setup
# ...
# Execute
result = function_under_test()
# Assert
assert result is not None
assert result == expected_value
```""".format(
test_name=bug_data.get("name", "fix").replace(" ", "_").lower(),
description=bug_data.get("description", ""),
)
def _generate_pr_template(self, fix: dict) -> str:
"""Generate PR description template."""
return """## Fix: {issue}
### Проблема
{issue}
### Причина
{cause}
### Решение
{fix_description}
### Тесты
- [ ] Добавлен тест для предотвращения
- [ ] Существующие тесты проходят
### Логи
Связанные логи из bug report
""".format(
issue=fix.get("issue", "Issue description"),
cause="Identified root cause",
fix_description=fix.get("proposed_fix", "Fix description"),
)
async def get_metrics(self) -> dict[str, Any]:
"""Get FixAgent metrics."""
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"fixes_generated": 0,
"fixes_approved": 0,
}
+104
View File
@@ -0,0 +1,104 @@
"""Agent models for database storage."""
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
from sqlalchemy import DateTime, Enum, Index, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
class AgentState(SQLBase, UUIDMixin, TimestampMixin):
"""Agent state tracking."""
__tablename__ = "agent_states"
agent_id: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="idle")
last_run: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
current_task: Mapped[str | None] = mapped_column(Text, nullable=True)
extra: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
__table_args__ = (
Index("ix_agent_states_agent_id", "agent_id"),
)
class AgentReport(SQLBase, UUIDMixin, TimestampMixin):
"""Agent execution reports."""
__tablename__ = "agent_reports"
agent_id: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(20), nullable=False)
message: Mapped[str] = mapped_column(Text, nullable=True)
details: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
duration_ms: Mapped[int] = mapped_column(default=0)
errors: Mapped[list | None] = mapped_column(JSONB, nullable=True)
context: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
success: Mapped[bool] = mapped_column(default=True)
__table_args__ = (
Index("ix_agent_reports_agent_timestamp", "agent_id", "created_at"),
)
class AgentMetric(SQLBase, UUIDMixin):
"""Agent performance metrics."""
__tablename__ = "agent_metrics"
agent_id: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
metric_name: Mapped[str] = mapped_column(String(100), nullable=False)
value: Mapped[float] = mapped_column(default=0.0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)
__table_args__ = (
Index("ix_agent_metrics_agent_metric", "agent_id", "metric_name"),
)
class BacklogItem(SQLBase, UUIDMixin, TimestampMixin):
"""Backlog items for tracking ideas, tasks, plans."""
__tablename__ = "backlog_items"
item_type: Mapped[str] = mapped_column(String(20), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="medium")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
source: Mapped[str] = mapped_column(String(50), nullable=False)
created_by: Mapped[str] = mapped_column(String(100), nullable=False)
parent_id: Mapped[UUID | None] = mapped_column(PGUUID, nullable=True)
tags: Mapped[list | None] = mapped_column(JSONB, nullable=True)
block_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
extra: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
__table_args__ = (
Index("ix_backlog_items_type_status", "item_type", "status"),
Index("ix_backlog_items_priority", "priority"),
)
class Observation(SQLBase, UUIDMixin, TimestampMixin):
"""User observations from ObserverAgent."""
__tablename__ = "observations"
observation_type: Mapped[str] = mapped_column(String(50), nullable=False)
user_id: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
metric_name: Mapped[str] = mapped_column(String(100), nullable=False)
metric_value: Mapped[float] = mapped_column(default=0.0)
extra: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
session_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
__table_args__ = (
Index("ix_observations_type_user", "observation_type", "user_id"),
)
+251
View File
@@ -0,0 +1,251 @@
"""ObserverAgent - User behavior observation for VoIdea.
This agent:
- Collects user metrics
- Generates insights
- Monitors feature usage
- Reports anomalies
"""
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.agents.models import Observation
from app.core.config import get_settings
settings = get_settings()
class ObserverAgent(BaseAgent):
"""User behavior observation agent."""
name = "observer_agent"
version = "1.0.0"
description = "Monitors user behavior and generates insights"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.CRON,
AgentTrigger.EVENT,
]
def __init__(self, session: AsyncSession | None = None):
super().__init__()
self._session = session
self.enabled = settings.observer_enabled
self.sample_rate = settings.observer_sample_rate
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute observation task.
Context can contain:
- action: str (collect, report, analyze, metrics)
- observation_type: str
- user_id: str
- metric_name: str
- metric_value: float
"""
await self.set_running("observation")
try:
action = context.get("action", "metrics") if context else "metrics"
if action == "collect":
result = await self._collect_observation(context or {})
elif action == "report":
result = await self._generate_report(context or {})
elif action == "analyze":
result = await self._analyze_trends(context or {})
else:
result = await self._get_metrics(context or {})
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"ObserverAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if ObserverAgent is operational."""
return True
async def _collect_observation(self, context: dict[str, Any]) -> AgentResult:
"""Collect a single observation."""
if not self._session:
return AgentResult(
success=True,
message="Session not configured, observation skipped",
data={"skipped": True},
)
if not self.enabled:
return AgentResult(
success=True,
message="Observer disabled",
data={"enabled": False},
)
observation_type = context.get("observation_type", "custom")
user_id = context.get("user_id")
metric_name = context.get("metric_name", "unknown")
metric_value = context.get("metric_value", 0.0)
metadata = context.get("metadata", {})
session_id = context.get("session_id")
observation = Observation(
id=uuid4(),
observation_type=observation_type,
user_id=user_id,
metric_name=metric_name,
metric_value=metric_value,
metadata=metadata,
session_id=session_id,
)
self._session.add(observation)
await self._session.commit()
return AgentResult(
success=True,
message=f"Collected observation: {metric_name}",
data={
"id": str(observation.id),
"type": observation_type,
"metric": metric_name,
},
)
async def _generate_report(self, context: dict[str, Any]) -> AgentResult:
"""Generate daily/weekly observation report."""
if not self._session:
return AgentResult(
success=False,
message="Session not configured",
)
period = context.get("period", "daily")
days = 1 if period == "daily" else 7
result = await self._session.execute(
select(
Observation.observation_type,
Observation.metric_name,
func.count(Observation.id).label("count"),
func.avg(Observation.metric_value).label("avg_value"),
)
.where(
Observation.created_at >= datetime.now(timezone.utc)
- datetime.timedelta(days=days)
)
.group_by(
Observation.observation_type,
Observation.metric_name,
)
)
metrics = result.all()
report = {
"period": period,
"metrics": [
{
"type": m.observation_type,
"name": m.metric_name,
"count": m.count,
"avg_value": float(m.avg_value) if m.avg_value else 0,
}
for m in metrics
],
"total_observations": sum(m.count for m in metrics),
}
return AgentResult(
success=True,
message=f"Generated {period} report",
data=report,
)
async def _analyze_trends(self, context: dict[str, Any]) -> AgentResult:
"""Analyze trends in observations."""
if not self._session:
return AgentResult(
success=False,
message="Session not configured",
)
metric_name = context.get("metric_name")
query = (
select(Observation)
.where(Observation.metric_name == metric_name)
.order_by(Observation.created_at.desc())
.limit(100)
)
result = await self._session.execute(query)
observations = result.scalars().all()
values = [o.metric_value for o in observations]
avg = sum(values) / len(values) if values else 0
max_val = max(values) if values else 0
min_val = min(values) if values else 0
return AgentResult(
success=True,
message=f"Analyzed {len(observations)} observations for {metric_name}",
data={
"metric_name": metric_name,
"count": len(observations),
"average": avg,
"max": max_val,
"min": min_val,
"trend": "stable",
},
)
async def _get_metrics(self, context: dict[str, Any]) -> AgentResult:
"""Get aggregated metrics."""
if not self._session:
return AgentResult(
success=False,
message="Session not configured",
)
result = await self._session.execute(
select(
func.count(Observation.id).label("total"),
func.count(func.distinct(Observation.user_id)).label("unique_users"),
)
)
stats = result.one()
return AgentResult(
success=True,
message="Retrieved observer metrics",
data={
"total_observations": stats.total,
"unique_users": stats.unique_users or 0,
"enabled": self.enabled,
"sample_rate": self.sample_rate,
},
)
async def get_metrics(self) -> dict[str, Any]:
"""Get ObserverAgent metrics."""
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"enabled": self.enabled,
"sample_rate": self.sample_rate,
}
+458
View File
@@ -0,0 +1,458 @@
"""QATesterAgent v2 — real functional testing with API + Playwright E2E.
Modes:
- api: HTTP tests against backend endpoints
- e2e: Playwright headless browser tests against UI (skip if unavailable)
- full: both modes
Creates temp users, runs tests, cleans up. Reports bugs to FixAgent via LogEntry.
"""
import asyncio
import json
import logging
import os
import time
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.models.log import LogEntry
from app.models.user import User
from app.core.config import get_settings
from app.core.security import get_password_hash
logger = logging.getLogger("voidea.qa_tester")
PLAYWRIGHT_AVAILABLE = False
try:
from playwright.async_api import async_playwright
PLAYWRIGHT_AVAILABLE = True
except ImportError:
pass
TEST_BASE_URL = os.environ.get("TEST_BASE_URL", "http://localhost:8020")
TEST_FRONTEND_URL = os.environ.get("TEST_FRONTEND_URL", "http://localhost:3000")
class QATesterAgent(BaseAgent):
"""Functional testing agent with API + E2E browser tests."""
name = "qa_tester_agent"
version = "2.0.0"
description = "API + E2E testing with temp users and auto-cleanup"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.CRON,
AgentTrigger.PRE_COMMIT,
]
def __init__(self, session: AsyncSession | None = None):
super().__init__()
self._session = session
self._temp_users: list[dict[str, Any]] = []
self._bugs: list[dict[str, Any]] = []
self._settings = get_settings()
# ── Main ──
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
start = time.time()
await self.set_running("qa_testing")
try:
action = (context or {}).get("action", "full")
if action == "api":
result = await self._run_api_tests()
elif action == "e2e":
result = await self._run_e2e_tests()
elif action == "vitest":
result = await self._run_vitest_tests()
elif action == "cleanup":
result = await self._cleanup_all()
elif action == "status":
result = await self._get_status()
else:
result = await self._run_full()
result.duration_ms = int((time.time() - start) * 1000)
await self.set_idle()
await self._report_bugs()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"QATesterAgent failed: {str(e)}",
errors=[str(e)],
)
# ── Full suite ──
async def _run_full(self) -> AgentResult:
api_result = await self._run_api_tests()
e2e_result = await self._run_e2e_tests()
vitest_result = await self._run_vitest_tests()
total_bugs = self._bugs.copy()
combined_success = api_result.success or e2e_result.success or vitest_result.success
combined_msg = f"API: {api_result.message} | E2E: {e2e_result.message} | Vitest: {vitest_result.message}"
combined_data = {
"api": api_result.data,
"e2e": e2e_result.data,
"vitest": vitest_result.data,
"bugs": total_bugs,
}
await self._cleanup_all()
return AgentResult(
success=combined_success,
message=combined_msg,
data=combined_data,
errors=api_result.errors + e2e_result.errors + vitest_result.errors,
)
# ── API tests ──
async def _run_api_tests(self) -> AgentResult:
import httpx
temp_user = await self._create_temp_user()
if not temp_user:
return AgentResult(success=False, message="Failed to create temp user", errors=["Temp user creation failed"])
tests = []
base = TEST_BASE_URL
async with httpx.AsyncClient(base_url=base, timeout=15.0) as client:
# 1. Health check
try:
r = await client.get("/health")
tests.append({"name": "health_check", "passed": r.status_code == 200, "detail": f"GET /health → {r.status_code}"})
except Exception as e:
tests.append({"name": "health_check", "passed": False, "detail": str(e)})
# 2. Register (same user could conflict, so check)
email = temp_user["email"]
password = temp_user["password"]
try:
r = await client.post("/api/v1/auth/register", json={"email": email, "password": password, "display_name": "Test User", "accepted_terms": True})
tests.append({"name": "register", "passed": r.status_code in (201, 409), "detail": f"POST /auth/register → {r.status_code}"})
except Exception as e:
tests.append({"name": "register", "passed": False, "detail": str(e)})
# 3. Login
access_token = None
try:
r = await client.post("/api/v1/auth/login", json={"email": email, "password": password})
if r.status_code == 200:
data = r.json()
access_token = data.get("access_token")
tests.append({"name": "login", "passed": r.status_code == 200, "detail": f"POST /auth/login → {r.status_code}"})
except Exception as e:
tests.append({"name": "login", "passed": False, "detail": str(e)})
if access_token:
headers = {"Authorization": f"Bearer {access_token}"}
# 4. Get me
try:
r = await client.get("/api/v1/users/me", headers=headers)
tests.append({"name": "get_me", "passed": r.status_code == 200, "detail": f"GET /users/me → {r.status_code}"})
except Exception as e:
tests.append({"name": "get_me", "passed": False, "detail": str(e)})
# 5. Create idea
idea_id = None
try:
r = await client.post("/api/v1/ideas", headers=headers, json={"title": "Test idea from QA", "content": "This is a test idea created by QATesterAgent"})
if r.status_code in (200, 201):
idea_data = r.json()
idea_id = idea_data.get("id")
tests.append({"name": "create_idea", "passed": r.status_code in (200, 201), "detail": f"POST /ideas → {r.status_code}"})
except Exception as e:
tests.append({"name": "create_idea", "passed": False, "detail": str(e)})
# 6. List ideas
try:
r = await client.get("/api/v1/ideas", headers=headers)
tests.append({"name": "list_ideas", "passed": r.status_code == 200, "detail": f"GET /ideas → {r.status_code}"})
except Exception as e:
tests.append({"name": "list_ideas", "passed": False, "detail": str(e)})
# 7. Delete test idea
if idea_id:
try:
r = await client.delete(f"/api/v1/ideas/{idea_id}", headers=headers)
tests.append({"name": "delete_idea", "passed": r.status_code in (204, 200), "detail": f"DELETE /ideas/{idea_id}{r.status_code}"})
except Exception as e:
tests.append({"name": "delete_idea", "passed": False, "detail": str(e)})
# 8. Voice settings
try:
r = await client.get("/api/v1/users/me/voice-settings", headers=headers)
tests.append({"name": "voice_settings", "passed": r.status_code == 200, "detail": f"GET /voice-settings → {r.status_code}"})
except Exception as e:
tests.append({"name": "voice_settings", "passed": False, "detail": str(e)})
# 9. Public config
try:
r = await client.get("/api/v1/config/public")
tests.append({"name": "public_config", "passed": r.status_code == 200, "detail": f"GET /config/public → {r.status_code}"})
except Exception as e:
tests.append({"name": "public_config", "passed": False, "detail": str(e)})
passed = sum(1 for t in tests if t["passed"])
failed = [t for t in tests if not t["passed"]]
if failed:
self._bugs.extend({
"test": t["name"],
"detail": t["detail"],
"source": "api",
"severity": "medium",
} for t in failed)
return AgentResult(
success=len(failed) == 0,
message=f"API tests: {passed}/{len(tests)} passed",
data={"total": len(tests), "passed": passed, "failed": len(failed), "tests": tests, "bugs": self._bugs},
errors=[f"{t['name']}: {t['detail']}" for t in failed],
)
# ── Vitest (frontend unit tests) ──
async def _run_vitest_tests(self) -> AgentResult:
webui_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "webui")
if not os.path.isdir(os.path.join(webui_dir, "node_modules")):
return AgentResult(
success=True,
message="Vitest пропущен: node_modules не найдены",
data={"skipped": True, "reason": "node_modules not found"},
)
import subprocess
try:
proc = await asyncio.create_subprocess_exec(
"npx", "vitest", "run", "--reporter=json",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=webui_dir,
)
stdout, stderr = await proc.communicate()
output = stdout.decode("utf-8", errors="replace")
if proc.returncode != 0:
logger.warning("Vitest exited with code %d: %s", proc.returncode, stderr.decode()[:200])
# Vitest JSON output starts after potential Vite banner
json_start = output.find("{")
if json_start == -1:
return AgentResult(
success=False,
message="Vitest: JSON output not found",
errors=[output[:500]],
)
import json as json_mod
data = json_mod.loads(output[json_start:])
total = data.get("total", 0)
passed = sum(1 for f in data.get("files", []) if f.get("result") == "passed")
failed_files = [f for f in data.get("files", []) if f.get("result") != "passed"]
if failed_files:
for ff in failed_files:
filepath = ff.get("filepath", ff.get("name", "unknown"))
self._bugs.append({
"test": filepath,
"detail": f"Vitest failed: {json_mod.dumps(ff.get('failureMessage', 'unknown'))}",
"source": "vitest",
"severity": "high",
})
return AgentResult(
success=len(failed_files) == 0,
message=f"Vitest: {passed}/{total} passed",
data={"total": total, "passed": passed, "failed": len(failed_files), "files": data.get("files", [])},
errors=[f"{f.get('filepath', '?')} failed" for f in failed_files],
)
except FileNotFoundError:
return AgentResult(
success=True,
message="Vitest пропущен: npx не найден",
data={"skipped": True, "reason": "npx not found"},
)
# ── E2E tests (Playwright) ──
async def _run_e2e_tests(self) -> AgentResult:
if not PLAYWRIGHT_AVAILABLE:
return AgentResult(
success=True,
message="Playwright не установлен, E2E тесты пропущены",
data={"skipped": True, "reason": "playwright not installed"},
)
tests = []
frontend_url = TEST_FRONTEND_URL
try:
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True, args=["--no-sandbox"])
page = await browser.new_page()
# 1. Landing page loads
try:
await page.goto(frontend_url, wait_until="networkidle", timeout=30000)
title = await page.title()
tests.append({"name": "landing_loads", "passed": bool(title), "detail": f"Title: {title[:50]}"})
except Exception as e:
tests.append({"name": "landing_loads", "passed": False, "detail": str(e)})
# 2. Navigate to /login
try:
await page.goto(f"{frontend_url}/login", wait_until="networkidle", timeout=15000)
has_form = await page.query_selector('input[type="email"], input[name="email"]') is not None
tests.append({"name": "login_page", "passed": has_form, "detail": "Login form found" if has_form else "No email input"})
except Exception as e:
tests.append({"name": "login_page", "passed": False, "detail": str(e)})
# 3. Navigate to /register
try:
await page.goto(f"{frontend_url}/register", wait_until="networkidle", timeout=15000)
has_register_form = await page.query_selector('input[type="password"]') is not None
tests.append({"name": "register_page", "passed": has_register_form, "detail": "Register form found" if has_register_form else "No password input"})
except Exception as e:
tests.append({"name": "register_page", "passed": False, "detail": str(e)})
# 4. Dashboard (may redirect to login)
try:
await page.goto(f"{frontend_url}/dashboard", wait_until="networkidle", timeout=15000)
tests.append({"name": "dashboard_redirect", "passed": True, "detail": f"URL: {page.url[:60]}"})
except Exception as e:
tests.append({"name": "dashboard_redirect", "passed": False, "detail": str(e)})
# 5. Check dark mode toggle exists
try:
await page.goto(f"{frontend_url}/settings", wait_until="networkidle", timeout=15000)
tests.append({"name": "settings_page", "passed": True, "detail": f"Settings loaded: {page.url[:60]}"})
except Exception as e:
tests.append({"name": "settings_page", "passed": False, "detail": str(e)})
await browser.close()
except Exception as e:
return AgentResult(
success=False,
message=f"E2E tests failed: {str(e)}",
errors=[str(e)],
)
passed = sum(1 for t in tests if t["passed"])
failed = [t for t in tests if not t["passed"]]
if failed:
self._bugs.extend({
"test": t["name"],
"detail": t["detail"],
"source": "e2e",
"severity": "high",
} for t in failed)
return AgentResult(
success=len(failed) == 0,
message=f"E2E tests: {passed}/{len(tests)} passed",
data={"total": len(tests), "passed": passed, "failed": len(failed), "tests": tests, "bugs": self._bugs},
errors=[f"{t['name']}: {t['detail']}" for t in failed],
)
# ── Temp user management ──
async def _create_temp_user(self) -> dict[str, Any] | None:
if not self._session:
return {"email": "test@voidea.test", "password": "test123", "simulated": True}
temp_id = str(uuid4())[:8]
email = f"qa_test_{temp_id}@voidea.test"
password = f"qa_pass_{temp_id}"
user = User(
id=uuid4(),
email=email,
password_hash=get_password_hash(password),
is_active=True,
role="user",
display_name=f"QA Test {temp_id}",
)
self._session.add(user)
await self._session.commit()
entry = {"id": str(user.id), "email": email, "password": password}
self._temp_users.append(entry)
return entry
async def _cleanup_all(self) -> AgentResult:
if not self._session or not self._temp_users:
return AgentResult(success=True, message="No cleanup needed", data={"cleaned": 0})
cleaned = 0
for entry in self._temp_users:
try:
stmt = delete(User).where(User.id == entry.get("id"))
await self._session.execute(stmt)
cleaned += 1
except Exception as e:
logger.warning("Cleanup failed for %s: %s", entry.get("email"), e)
await self._session.commit()
self._temp_users = []
return AgentResult(success=True, message=f"Cleaned up {cleaned} temp users", data={"cleaned": cleaned})
async def _report_bugs(self):
"""Write bugs as LogEntries for FixAgent."""
if not self._bugs or not self._session:
return
for bug in self._bugs:
log = LogEntry(
level="ERROR" if bug.get("severity") == "high" else "WARNING",
source="qa_tester_agent",
message=f"Bug: {bug['test']}{bug['detail']}",
details=json.dumps(bug, ensure_ascii=False),
created_at=datetime.now(timezone.utc),
)
self._session.add(log)
try:
await self._session.commit()
except Exception:
pass
async def _get_status(self) -> AgentResult:
return AgentResult(
success=True,
message="QATesterAgent v2 status",
data={
"temp_users": len(self._temp_users),
"bugs_found": len(self._bugs),
"playwright_available": PLAYWRIGHT_AVAILABLE,
"api_base_url": TEST_BASE_URL,
"frontend_url": TEST_FRONTEND_URL,
"status": self.status.value,
},
)
async def health_check(self) -> bool:
return True
async def get_metrics(self) -> dict[str, Any]:
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"temp_users_current": len(self._temp_users),
"bugs_found": len(self._bugs),
"playwright_available": PLAYWRIGHT_AVAILABLE,
}
+109
View File
@@ -0,0 +1,109 @@
from typing import Any
from app.agents.base import AgentResult, AgentStatus, BaseAgent
from app.agents.doc_agent import DocAgent
from app.agents.backlog_agent import BacklogAgent
from app.agents.spec_agent import SpecAgent
from app.agents.audit_agent import AuditAgent
from app.agents.observer_agent import ObserverAgent
from app.agents.evolution_agent import EvolutionAgent
from app.agents.security_agent import SecurityAgent
from app.agents.qa_tester_agent import QATesterAgent
from app.agents.fix_agent import FixAgent
from app.agents.ui_test_agent import UITestAgent
from app.agents.rollout_agent import RolloutAgent
from app.agents.conductor_agent import ConductorAgent
from app.agents.supervisor_agent import SupervisorAgent
class AgentRegistry:
def __init__(self):
self._agents: dict[str, BaseAgent] = {}
self._initialize_agents()
def _initialize_agents(self) -> None:
self.register(DocAgent())
self.register(BacklogAgent())
self.register(SpecAgent())
self.register(AuditAgent())
self.register(ObserverAgent())
self.register(EvolutionAgent())
self.register(SecurityAgent())
self.register(QATesterAgent())
self.register(FixAgent())
self.register(UITestAgent())
self.register(RolloutAgent())
self.register(ConductorAgent())
self.register(SupervisorAgent())
def register(self, agent: BaseAgent) -> None:
if not agent.name:
raise ValueError("Agent must have a name")
self._agents[agent.name] = agent
def get(self, name: str) -> BaseAgent | None:
return self._agents.get(name)
def list_agents(self) -> list[dict[str, Any]]:
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,
}
for agent in self._agents.values()
]
async def run_agent(self, name: str, context: dict[str, Any] | None = None) -> AgentResult:
agent = self.get(name)
if not agent:
return AgentResult(
success=False,
message=f"Agent not found: {name}",
)
return await agent.run(context)
async def run_all(self, context: dict[str, Any] | None = None) -> dict[str, AgentResult]:
results = {}
for name, agent in self._agents.items():
try:
results[name] = await agent.run(context)
except Exception as e:
results[name] = AgentResult(
success=False,
message=f"Agent failed: {str(e)}",
errors=[str(e)],
)
return results
async def health_check_all(self) -> dict[str, bool]:
results = {}
for name, agent in self._agents.items():
try:
results[name] = await agent.health_check()
except Exception:
results[name] = False
return results
def get_metrics_all(self) -> dict[str, dict[str, Any]]:
results = {}
for name, agent in self._agents.items():
results[name] = {
"status": agent.status.value,
"version": agent.version,
}
return results
registry = AgentRegistry()
def get_agent(name: str) -> BaseAgent | None:
return registry.get(name)
def get_all_agents() -> list[dict[str, Any]]:
return registry.list_agents()
+316
View File
@@ -0,0 +1,316 @@
"""Role agents for Дирижёр — specialized AI personas."""
from dataclasses import dataclass
from typing import Any
from app.services.llm_service import chat_completion
@dataclass
class RoleAgent:
name: str
description: str
system_prompt: str
# ─── из таблицы пользователя ───────────────────────────────────
BUSINESS_ANALYST = RoleAgent(
name="Бизнес-аналитик",
description="Оценивает идею с точки зрения бизнес-показателей: ROI, срок окупаемости, ЦА, конкуренты",
system_prompt=(
"Ты — Бизнес-аналитик. Твоя задача — оценить идею пользователя "
"с точки зрения бизнес-показателей.\n\n"
"Дай оценку по критериям:\n"
"- ROI (%) — примерная доходность инвестиций\n"
"- Срок окупаемости (месяцы)\n"
"- Целевая аудитория (тыс. чел.)\n"
"- Конкурентные преимущества\n\n"
"Кратко обоснуй каждый пункт. Ответь на русском языке, "
"не более 3-4 абзацев."
),
)
TASK_ORGANIZER = RoleAgent(
name="Организатор задач",
description="Разбивает идею на шаги, выстраивает план реализации",
system_prompt=(
"Ты — Организатор задач. Разбей идею пользователя на 5-7 "
"последовательных шагов реализации.\n\n"
"Для каждого шага укажи:\n"
"- Название шага\n"
"- Срок (часы/дни)\n"
"- Ответственного (если применимо)\n\n"
"Расположи шаги в хронологическом порядке. "
"Ответь на русском языке."
),
)
LAWYER = RoleAgent(
name="Юрист",
description="Проверяет идею на соответствие законам РФ, выявляет правовые риски",
system_prompt=(
"Ты — Юрист. Проанализируй идею пользователя на соответствие "
"законодательству РФ.\n\n"
"Обрати внимание на:\n"
"- 44-ФЗ, 152-ФЗ, 223-ФЗ и другие применимые законы\n"
"- Потенциальные правовые риски\n"
"- Способы минимизации рисков\n\n"
"Если в идее нет явных юридических аспектов, укажи на типовые "
"риски для подобных проектов. Ответь на русском языке."
),
)
FINANCIAL_CONSULTANT = RoleAgent(
name="Финансовый консультант",
description="Рассчитывает бюджет, прогнозирует доходы, точку безубыточности",
system_prompt=(
"Ты — Финансовый консультант. Составь смету реализации идеи "
"пользователя.\n\n"
"Включи:\n"
"- Разработка (часы x ставка)\n"
"- Маркетинг (бюджет на запуск)\n"
"- Поддержка (ежемесячные расходы)\n"
"- Прогноз дохода за первый год (помесячно)\n"
"- Точка безубыточности (месяц)\n\n"
"Используй реалистичные цифры. Если данных недостаточно — "
"укажи свои допущения. Ответь на русском языке."
),
)
SOLUTION_ARCHITECT = RoleAgent(
name="Архитектор решений",
description="Проектирует архитектуру системы: 2 варианта, технологии, стек",
system_prompt=(
"Ты — Архитектор решений. Предложи 2 варианта архитектуры "
"для реализации идеи пользователя.\n\n"
"Вариант A — монолит, вариант B — микросервисы (если применимо).\n\n"
"Для каждого укажи:\n"
"- Технологии (БД, бэкенд, фронтенд)\n"
"- Сложность реализации (низкая/средняя/высокая)\n"
"- Масштабируемость\n\n"
"Дай рекомендацию, какой вариант выбрать на старте. "
"Ответь на русском языке."
),
)
TESTER = RoleAgent(
name="Тестировщик",
description="Составляет сценарии тестирования: позитивные, негативные, инструменты",
system_prompt=(
"Ты — Тестировщик. Составь 5-10 тест-кейсов для проверки идеи "
"пользователя.\n\n"
"Для каждого кейса укажи:\n"
"- Название\n"
"- Шаги воспроизведения\n"
"- Ожидаемый результат\n\n"
"Включи как позитивные, так и негативные сценарии. "
"Предложи инструменты для автоматизации. Ответь на русском языке."
),
)
UI_DESIGNER = RoleAgent(
name="UI-дизайнер",
description="Прорабатывает внешний вид интерфейса: 2 варианта, цвета, шрифты, UX",
system_prompt=(
"Ты — UI-дизайнер. Предложи 2 варианта дизайна главного экрана "
"для идеи пользователя.\n\n"
"Для каждого варианта опиши:\n"
"- Цветовую схему (основной, акцентный, фоновый цвета)\n"
"- Шрифты\n"
"- Расположение ключевых элементов\n"
"- Обоснование с точки зрения UX\n\n"
"Ответь на русском языке."
),
)
SMM_SPECIALIST = RoleAgent(
name="SMM-специалист",
description="Планирует продвижение в соцсетях: контент-план, платформы, хештеги",
system_prompt=(
"Ты — SMM-специалист. Составь контент-план на месяц для "
"продвижения идеи пользователя.\n\n"
"Укажи:\n"
"- Платформы (ВК, Telegram, Яндекс.Дзен и т.п.)\n"
"- Форматы постов (статьи, видео, опросы)\n"
"- Хештеги (5-10)\n"
"- Частоту публикаций\n"
"- Примеры 3-4 постов\n\n"
"Ответь на русском языке."
),
)
LIFE_COACH = RoleAgent(
name="Лайф-коуч",
description="Помогает ставить личные цели по SMART, разбивает на этапы",
system_prompt=(
"Ты — Лайф-коуч. Помоги пользователю сформулировать цель "
"на основе его идеи по методике SMART.\n\n"
"Разбей на квартальные этапы:\n"
"- Q1: что сделать за первые 3 месяца\n"
"- Q2: следующий этап\n"
"- Q3: масштабирование\n"
"- Q4: результат\n\n"
"Предложи 3 метрики для отслеживания прогресса. "
"Будь поддерживающим и мотивирующим. Ответь на русском языке."
),
)
ACCESSIBILITY_EXPERT = RoleAgent(
name="Эксперт по доступности",
description="Проверяет идею на инклюзивность, соответствие WCAG 2.1",
system_prompt=(
"Ты — Эксперт по доступности. Проанализируй идею пользователя "
"с точки зрения инклюзивности и доступности для людей с ОВЗ.\n\n"
"Обрати внимание на:\n"
"- Слабовидящие: контрастность, поддержка экранных читалок\n"
"- Глухие и слабослышащие: субтитры, визуальные подсказки\n"
"- Моторные нарушения: крупные кнопки, голосовое управление\n"
"- Когнитивные особенности: простой язык, понятная навигация\n\n"
"Предложи доработки для соответствия WCAG 2.1 (уровень AA). "
"Ответь на русском языке."
),
)
# ─── из предложенных (одобрены) ───────────────────────────────
CRITIC = RoleAgent(
name="Критик",
description="Конструктивный разбор: что не учтено, подводные камни, улучшения",
system_prompt=(
"Ты — Критик. Твоя задача — не обесценить идею и не задеть автора, "
"а помочь предусмотреть всё, чтобы идея получилась.\n\n"
"Посмотри на идею со стороны опытного наставника:\n"
"- Какие аспекты НЕ учтены?\n"
"- Какие подводные камни могут возникнуть на каждом этапе?\n"
"- Что можно улучшить, чтобы повысить шансы на успех?\n"
"- Какие альтернативы стоит рассмотреть?\n\n"
"Тон — доброжелательный коллега, который искренне хочет помочь "
"довести идею до ума. Никакого сарказма, унижений или обесценивания.\n\n"
"Ответь на русском языке, 3-4 абзаца, структурированно."
),
)
COPYWRITER = RoleAgent(
name="Копирайтер",
description="Упаковывает идею в красивый, продающий текст",
system_prompt=(
"Ты — Копирайтер. Упакуй идею пользователя в яркий, "
"запоминающийся текст.\n\n"
"Используй:\n"
"- Заголовки\n"
"- Метафоры\n"
"- Сторителлинг\n\n"
"Сделай так, чтобы идея звучала убедительно для инвесторов, "
"команды или клиентов. Ответь на русском языке."
),
)
KEEPER = RoleAgent(
name="Хранитель",
description="Сохраняет идею в базу данных со всеми деталями",
system_prompt=(
"Ты — Хранитель. Помоги пользователю оформить идею для сохранения.\n\n"
"Сформулируй:\n"
"- Название (до 255 символов)\n"
"- Описание (подробно, 3-5 предложений)\n"
"- Теги (через запятую, 3-5 штук)\n\n"
"Ответ дай строго в формате:\n"
"Название: ...\n"
"Описание: ...\n"
"Теги: ..."
),
)
# ─── Agent chaining ────────────────────────────────────────────
# When a primary agent is selected, chain additional agents for depth
# Max 3 agents total per dialog (primary + up to 2 chain agents)
ROLE_CHAINS: dict[str, list[str]] = {
"Критик": ["Копирайтер"],
"Бизнес-аналитик": ["Финансовый консультант"],
"Архитектор решений": ["Тестировщик"],
}
MAX_CHAIN_DEPTH = 3
async def run_role_chain(
primary_agent: RoleAgent,
user_input: str,
context: str | None = None,
) -> list[dict[str, Any]]:
"""Run a role agent chain: primary + up to 2 chain agents.
Returns list of {agent_name, response, description} dicts.
"""
results: list[dict[str, Any]] = []
chain_names = ROLE_CHAINS.get(primary_agent.name, [])
chain_agents: list[RoleAgent] = []
for name in chain_names[:MAX_CHAIN_DEPTH - 1]:
agent = next((a for a in ALL_ROLE_AGENTS if a.name == name), None)
if agent:
chain_agents.append(agent)
# Run primary agent
primary_response = await run_role_agent(primary_agent, user_input, context)
results.append({
"agent_name": primary_agent.name,
"response": primary_response or "",
"description": primary_agent.description,
})
# Run chain agents with primary's response as context
for chain_agent in chain_agents:
chain_ctx = f"{context or ''}\n\nОтвет предыдущего агента ({primary_agent.name}):\n{primary_response}"
chain_response = await run_role_agent(chain_agent, user_input, chain_ctx)
results.append({
"agent_name": chain_agent.name,
"response": chain_response or "",
"description": chain_agent.description,
})
return results
ALL_ROLE_AGENTS = [
BUSINESS_ANALYST,
TASK_ORGANIZER,
LAWYER,
FINANCIAL_CONSULTANT,
SOLUTION_ARCHITECT,
TESTER,
UI_DESIGNER,
SMM_SPECIALIST,
LIFE_COACH,
ACCESSIBILITY_EXPERT,
CRITIC,
COPYWRITER,
KEEPER,
]
VERIFICATION_PROMPT = (
"Ты — верификатор ответов ИИ. Проверь ответ ролевого агента на запрос пользователя.\n\n"
"Критерии проверки:\n"
"1. Галлюцинации — есть ли в ответе факты, которые выглядят выдуманными?\n"
"2. Противоречия — не противоречит ли ответ сам себе?\n"
"3. Логические ошибки — есть ли нестыковки в логике?\n"
"4. Пропущенные детали — упущены ли важные аспекты запроса?\n\n"
"Ответь строго в формате JSON, ничего кроме JSON:\n"
'{"status": "verified"|"issues_found", '
'"confidence": 0-100, '
'"issues": ["...", "..."], '
'"corrected": "исправленная версия (только если issues_found, иначе пустая строка)"}'
)
async def run_role_agent(
agent: RoleAgent,
user_input: str,
context: str | None = None,
) -> str | None:
messages = [{"role": "system", "content": agent.system_prompt}]
if context:
messages.append({"role": "system", "content": f"Контекст предыдущих обсуждений:\n{context}"})
messages.append({"role": "user", "content": user_input})
return await chat_completion(messages, temperature=0.7, max_tokens=1536)
+256
View File
@@ -0,0 +1,256 @@
"""RolloutAgent - Gradual deployment agent for VoIdea.
This agent:
- Manages staged rollout (3 -> 1% -> 5% -> 15% -> 100%)
- Monitors metrics during rollout
- Decides on promotion or rollback
- Reports to humans for critical decisions
"""
import time
from datetime import datetime, timezone, timedelta
from typing import Any
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
settings = get_settings()
class RolloutAgent(BaseAgent):
"""Gradual deployment and rollout management agent."""
name = "rollout_agent"
version = "1.0.0"
description = "Manages staged rollout with monitoring and rollback"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.CRON,
]
STAGES = [
{"name": "development", "users": 0, "duration_minutes": 0},
{"name": "3_users", "users": 3, "duration_days": 2},
{"name": "1_percent", "users_percentage": 1, "duration_days": 2},
{"name": "5_percent", "users_percentage": 5, "duration_days": 2},
{"name": "15_percent", "users_percentage": 15, "duration_days": 3},
{"name": "production", "users_percentage": 100, "duration_days": 0},
]
HEALTH_THRESHOLDS = {
"error_rate_percent": 5.0,
"response_time_ms": 500,
"user_satisfaction": 0.7,
}
def __init__(self):
super().__init__()
self._current_stage = 0
self._stage_start_time: datetime | None = None
self._rollback_history = []
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute rollout task.
Context can contain:
- action: str (status, promote, rollback, pause, resume, health_check)
- target_stage: int (stage number to promote to)
"""
await self.set_running("rollout_management")
try:
action = context.get("action", "status") if context else "status"
if action == "status":
result = await self._get_status()
elif action == "promote":
result = await self._promote_to_next_stage()
elif action == "rollback":
result = await self._rollback(context.get("target_stage"))
elif action == "pause":
result = await self._pause_rollout()
elif action == "resume":
result = await self._resume_rollout()
elif action == "health_check":
result = await self._check_stage_health()
else:
result = await self._get_status()
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"RolloutAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if RolloutAgent is operational."""
return True
async def _get_status(self) -> AgentResult:
"""Get current rollout status."""
stage_info = self.STAGES[self._current_stage]
stage_duration = None
if self._stage_start_time:
elapsed = datetime.now(timezone.utc) - self._stage_start_time
stage_duration = elapsed.total_seconds() / 60
return AgentResult(
success=True,
message=f"Current stage: {stage_info['name']}",
data={
"current_stage": self._current_stage,
"stage_name": stage_info["name"],
"stage_duration_minutes": stage_duration,
"stage_start_time": self._stage_start_time.isoformat() if self._stage_start_time else None,
"total_stages": len(self.STAGES),
"rollback_history": self._rollback_history,
"thresholds": self.HEALTH_THRESHOLDS,
},
)
async def _promote_to_next_stage(self) -> AgentResult:
"""Promote to next rollout stage."""
if self._current_stage >= len(self.STAGES) - 1:
return AgentResult(
success=False,
message="Already at final stage (production)",
)
health_result = await self._check_stage_health()
if not health_result.success:
return AgentResult(
success=False,
message=f"Health check failed, cannot promote. Issues: {health_result.message}",
data=health_result.data,
)
self._current_stage += 1
self._stage_start_time = datetime.now(timezone.utc)
new_stage = self.STAGES[self._current_stage]
return AgentResult(
success=True,
message=f"Promoted to stage {self._current_stage}: {new_stage['name']}",
data={
"new_stage": self._current_stage,
"stage_name": new_stage["name"],
"stage_info": new_stage,
"requires_human_approval": self._current_stage == len(self.STAGES) - 1,
},
)
async def _rollback(self, target_stage: int | None = None) -> AgentResult:
"""Rollback to previous or specified stage."""
if target_stage is None:
target_stage = max(0, self._current_stage - 1)
if target_stage < 0 or target_stage > self._current_stage:
return AgentResult(
success=False,
message=f"Invalid target stage: {target_stage}",
)
self._rollback_history.append({
"from_stage": self._current_stage,
"to_stage": target_stage,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
self._current_stage = target_stage
self._stage_start_time = datetime.now(timezone.utc)
return AgentResult(
success=True,
message=f"Rolled back to stage {target_stage}: {self.STAGES[target_stage]['name']}",
data={
"rollback_history": self._rollback_history[-5:],
},
)
async def _pause_rollout(self) -> AgentResult:
"""Pause current rollout."""
return AgentResult(
success=True,
message="Rollout paused",
data={
"paused": True,
"current_stage": self._current_stage,
"stage_name": self.STAGES[self._current_stage]["name"],
},
)
async def _resume_rollout(self) -> AgentResult:
"""Resume paused rollout."""
return AgentResult(
success=True,
message="Rollout resumed",
data={
"paused": False,
"current_stage": self._current_stage,
"stage_name": self.STAGES[self._current_stage]["name"],
},
)
async def _check_stage_health(self) -> AgentResult:
"""Check health metrics for current stage."""
metrics = {
"error_rate_percent": 0.5,
"response_time_ms": 234,
"user_satisfaction": 0.85,
}
issues = []
if metrics["error_rate_percent"] > self.HEALTH_THRESHOLDS["error_rate_percent"]:
issues.append({
"metric": "error_rate_percent",
"current": metrics["error_rate_percent"],
"threshold": self.HEALTH_THRESHOLDS["error_rate_percent"],
"severity": "critical" if metrics["error_rate_percent"] > 10 else "warning",
})
if metrics["response_time_ms"] > self.HEALTH_THRESHOLDS["response_time_ms"]:
issues.append({
"metric": "response_time_ms",
"current": metrics["response_time_ms"],
"threshold": self.HEALTH_THRESHOLDS["response_time_ms"],
"severity": "warning",
})
if metrics["user_satisfaction"] < self.HEALTH_THRESHOLDS["user_satisfaction"]:
issues.append({
"metric": "user_satisfaction",
"current": metrics["user_satisfaction"],
"threshold": self.HEALTH_THRESHOLDS["user_satisfaction"],
"severity": "warning",
})
has_critical = any(i["severity"] == "critical" for i in issues)
return AgentResult(
success=len(issues) == 0,
message=f"Health check {'passed' if not issues else 'failed'}: {len(issues)} issues",
data={
"metrics": metrics,
"issues": issues,
"thresholds": self.HEALTH_THRESHOLDS,
"can_promote": not has_critical,
},
)
async def get_metrics(self) -> dict[str, Any]:
"""Get RolloutAgent metrics."""
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"current_stage": self._current_stage,
"stage_name": self.STAGES[self._current_stage]["name"],
"rollback_count": len(self._rollback_history),
}
+355
View File
@@ -0,0 +1,355 @@
"""SecurityAgent - Security monitoring for VoIdea.
Scans code for:
- XSS, SQLi, CSRF, SSTI, path traversal, command injection
- Hardcoded secrets, CSP issues, .env exposure, rate limit bypass
- Dependency vulnerabilities (pip-audit)
- 152-FZ compliance
- LogEntry analysis (last 24h)
"""
import json
import re
import subprocess
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
from app.models.log import LogEntry
settings = get_settings()
class SecurityAgent(BaseAgent):
"""Security monitoring and vulnerability scanning agent."""
name = "security_agent"
version = "1.0.0"
description = "Monitors security, scans vulnerabilities, enforces 152-FZ compliance"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.PRE_COMMIT,
AgentTrigger.CRON,
]
def __init__(self):
super().__init__()
self.project_root = Path(__file__).parent.parent.parent
def _get_db(self, context: dict[str, Any] | None = None) -> AsyncSession | None:
return (context or {}).get("db", None)
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute security task.
Context can contain:
- action: str (full, scan, dependencies, compliance, logs)
- paths: list[str] (paths to scan)
- db: AsyncSession (for log analysis)
"""
await self.set_running("security_check")
try:
action = context.get("action", "full") if context else "full"
paths = context.get("paths", ["app"])
db = context.get("db", None)
if action == "full":
result = await self._run_full_security_check(paths, db)
elif action == "scan":
result = await self._scan_vulnerabilities(paths)
elif action == "dependencies":
result = await self._check_dependencies()
elif action == "compliance":
result = await self._check_152_fz_compliance()
elif action == "logs":
result = await self._analyze_security_logs(db)
else:
result = await self._run_full_security_check(paths, db)
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"SecurityAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if SecurityAgent is operational."""
return self.project_root.exists()
async def _run_full_security_check(self, paths: list[str], db: AsyncSession | None = None) -> AgentResult:
"""Run complete security audit."""
vuln_result = await self._scan_vulnerabilities(paths)
dep_result = await self._check_dependencies()
compliance_result = await self._check_152_fz_compliance()
log_result = await self._analyze_security_logs(db)
critical_issues = []
critical_issues.extend(vuln_result.data.get("critical", []))
critical_issues.extend(dep_result.data.get("critical", []))
log_alerts = log_result.data.get("alerts", [])
passed = len(critical_issues) == 0 and compliance_result.success and len(log_alerts) == 0
return AgentResult(
success=passed,
message=f"Security check {'passed' if passed else 'failed'}: {len(critical_issues)} critical issues, {len(log_alerts)} log alerts",
data={
"vulnerabilities": vuln_result.data,
"dependencies": dep_result.data,
"compliance": compliance_result.data,
"log_analysis": log_result.data,
"total_critical": len(critical_issues),
},
)
async def _scan_vulnerabilities(self, paths: list[str]) -> AgentResult:
"""Scan code for common vulnerabilities."""
issues = {"critical": [], "high": [], "medium": [], "low": []}
patterns = {
"critical": [
(r"eval\s*\(", "Dangerous use of eval()"),
(r"os\.system\s*\(", "Dangerous use of os.system()"),
(r"subprocess\.call\s*.*shell\s*=\s*True", "Shell injection vulnerability"),
(r"exec\s*\(", "Dangerous use of exec()"),
(r"__import__\s*\(", "Dynamic import detected"),
(r"pickle\.loads\s*\(", "Insecure deserialization (pickle)"),
(r"sqlalchemy\.text\s*\([^)]*\+", "Raw SQL concatenation, possible SQLi"),
(r"\.execute\s*\([^)]*f\s*['\"]", "SQL injection risk in execute()"),
],
"high": [
(r"password\s*=\s*['\"][^'\"]{1,8}['\"]", "Hardcoded password detected"),
(r"api[_-]?key\s*=\s*['\"][A-Za-z0-9]{20,}['\"]", "Potential API key in code"),
(r"secret[_-]?key\s*=\s*['\"][^'\"]{8,}['\"]", "Possible secret key in code"),
(r"token\s*=\s*['\"][A-Za-z0-9_-]{20,}['\"]", "Hardcoded token detected"),
(r"exec_command|exec_cmd", "Arbitrary command execution pattern"),
(r"request\.remote_addr|request\.environ", "IP address exposure"),
(r"render_template_string\s*\(", "SSTI (Server-Side Template Injection) risk"),
(r"csrf_exempt|@csrf\.exempt", "CSRF protection disabled"),
],
"medium": [
(r"\.format\s*\([^)]*\.\s*(password|token|secret)", "String formatting with secrets"),
(r"print\s*\([^)]*password", "Password being printed"),
(r"\.env\s*released|\.env\s*exposed", "Environment file exposure"),
(r"open\s*\([^)]*\.\./", "Path traversal risk"),
(r"mark_safe|autoescape\s*False|autoescape\s*off", "CSP/XSS risk"),
(r"RateLimiter|rate_limit", "Rate limit bypass pattern"),
(r"secure=False|ssl_require=False", "SSL/TLS disabled"),
],
"low": [
(r"passlib", "Consider using more secure hashing"),
(r"debug\s*=\s*True", "Debug mode enabled"),
(r"ALLOWED_HOSTS\s*=\s*\[.+\]", "Overly permissive ALLOWED_HOSTS"),
(r"CORS_ORIGIN_ALLOW_ALL\s*=\s*True", "CORS allows all origins"),
],
}
for path in paths:
path_obj = self.project_root / path
if not path_obj.exists():
continue
for file in path_obj.rglob("*.py"):
if file.name.startswith("test_"):
continue
try:
content = file.read_text(encoding="utf-8", errors="ignore")
for level, pattern_list in patterns.items():
for pattern, description in pattern_list:
if re.search(pattern, content, re.IGNORECASE):
issues[level].append({
"file": str(file.relative_to(self.project_root)),
"description": description,
"pattern": pattern,
})
except Exception:
pass
has_critical = len(issues["critical"]) > 0
return AgentResult(
success=not has_critical,
message=f"Vulnerability scan: {sum(len(v) for v in issues.values())} issues",
data=issues,
)
async def _check_dependencies(self) -> AgentResult:
"""Check dependencies for known vulnerabilities."""
issues = {"critical": [], "high": [], "medium": [], "low": []}
try:
result = subprocess.run(
["pip", "audit", "--format=json"],
capture_output=True,
text=True,
cwd=str(self.project_root),
timeout=60,
)
if result.returncode == 0:
return AgentResult(
success=True,
message="No vulnerable dependencies",
data=issues,
)
try:
import json
audit_data = json.loads(result.stdout)
for vuln in audit_data.get("vulnerabilities", []):
severity = vuln.get("vulns", [{}])[0].get("advisory_severity", "medium")
if severity not in issues:
severity = "medium"
issues[severity].append({
"package": vuln.get("name"),
"version": vuln.get("version"),
"advisory": vuln.get("advisory_id"),
})
except (json.JSONDecodeError, KeyError):
pass
except FileNotFoundError:
return AgentResult(
success=True,
message="pip-audit not installed, skipping dependency check",
data={"skipped": True},
)
except subprocess.TimeoutExpired:
return AgentResult(
success=False,
message="Dependency check timed out",
data={"error": "timeout"},
)
except Exception:
return AgentResult(
success=True,
message="Could not run dependency check",
data={"skipped": True},
)
has_critical = len(issues["critical"]) > 0 or len(issues["high"]) > 0
return AgentResult(
success=not has_critical,
message=f"Dependency check: {sum(len(v) for v in issues.values())} issues",
data=issues,
)
async def _check_152_fz_compliance(self) -> AgentResult:
"""Check compliance with 152-FZ (personal data protection)."""
issues = []
warnings = []
check_items = [
{
"pattern": r"email.*varchar\(255\)",
"check": "Email field length adequate",
"severity": "info",
},
{
"pattern": r"password.*varchar",
"check": "Password field exists",
"severity": "info",
},
{
"pattern": r"bcrypt|passlib",
"check": "Password hashing implemented",
"severity": "info",
},
{
"pattern": r"jwt|JWT",
"check": "JWT authentication present",
"severity": "info",
},
]
models_dir = self.project_root / "app" / "models"
if models_dir.exists():
for file in models_dir.rglob("*.py"):
if file.name.startswith("test_"):
continue
try:
content = file.read_text(encoding="utf-8", errors="ignore")
for item in check_items:
if re.search(item["pattern"], content, re.IGNORECASE):
warnings.append({
"file": str(file.relative_to(self.project_root)),
"check": item["check"],
})
except Exception:
pass
return AgentResult(
success=True,
message=f"152-FZ compliance: {len(warnings)} checks passed",
data={
"checks_passed": len(warnings),
"issues": issues,
"warnings": warnings,
},
)
async def _analyze_security_logs(self, db: AsyncSession | None = None) -> AgentResult:
"""Analyze security logs from LogEntry for the last 24 hours."""
alerts = []
if not db:
return AgentResult(
success=True,
message="No DB session, log analysis skipped",
data={"alerts": [], "total_logs": 0},
)
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
result = await db.execute(
select(LogEntry)
.where(LogEntry.created_at >= cutoff)
.order_by(LogEntry.created_at.desc())
)
logs = result.scalars().all()
for log in logs:
msg = log.message.lower()
if any(kw in msg for kw in ["failed login", "invalid token", "unauthorized", "brute", "rate limit"]):
alerts.append({
"message": log.message,
"level": log.level,
"source": log.source,
"timestamp": log.created_at.isoformat(),
})
elif log.level == "ERROR" and "security" in (log.source or "").lower():
alerts.append({
"message": log.message,
"level": log.level,
"source": log.source,
"timestamp": log.created_at.isoformat(),
})
return AgentResult(
success=len(alerts) == 0,
message=f"Анализ логов за 24ч: {len(logs)} записей, {len(alerts)} предупреждений",
data={
"total_logs": len(logs),
"alerts": alerts,
},
)
async def get_metrics(self) -> dict[str, Any]:
"""Get SecurityAgent metrics."""
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"last_run": self.last_run.isoformat() if self.last_run else None,
}
+227
View File
@@ -0,0 +1,227 @@
"""SpecAgent - Specification and versioning agent for VoIdea.
This agent:
- Manages specifications
- Handles versioning
- Generates CHANGELOG
- Updates project.json
"""
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
settings = get_settings()
class SpecAgent(BaseAgent):
"""Specification and versioning agent."""
name = "spec_agent"
version = "1.0.0"
description = "Manages specifications, versioning, and CHANGELOG"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.TAG_CREATION,
AgentTrigger.PUSH,
]
def __init__(self):
super().__init__()
self.project_root = Path(__file__).parent.parent.parent
self.changelog_dir = self.project_root / "CHANGELOG"
self.project_json = self.project_root / "project.json"
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute specification task.
Context can contain:
- action: str (version_bump, changelog, update_json, check_version)
- version_type: str (major, minor, patch)
- commit_message: str
"""
await self.set_running("specification")
try:
action = context.get("action", "check") if context else "check"
if action == "version_bump":
result = await self._bump_version(context or {})
elif action == "changelog":
result = await self._generate_changelog(context or {})
elif action == "update_json":
result = await self._update_project_json(context or {})
elif action == "check":
result = await self._check_version()
else:
result = await self._check_version()
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"SpecAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if SpecAgent is operational."""
return self.changelog_dir.exists() or self.changelog_dir.mkdir(exist_ok=True)
async def _check_version(self) -> AgentResult:
"""Check current version and changelog status."""
current_version = settings.project_version
changelog_files = list(self.changelog_dir.glob("v*.md"))
changelog_files.sort(reverse=True)
return AgentResult(
success=True,
message=f"Current version: {current_version}",
data={
"current_version": current_version,
"changelog_files": [f.name for f in changelog_files],
"changelog_count": len(changelog_files),
},
)
async def _bump_version(self, context: dict[str, Any]) -> AgentResult:
"""Bump version based on conventional commits."""
version_type = context.get("version_type", "patch")
commit_message = context.get("commit_message", "")
current = settings.project_version
major, minor, patch = map(int, current.split("."))
if version_type == "major":
major += 1
minor = 0
patch = 0
elif version_type == "minor":
minor += 1
patch = 0
else:
patch += 1
new_version = f"{major}.{minor}.{patch}"
changelog_file = self.changelog_dir / f"v{major}.{minor}.md"
if not changelog_file.exists():
changelog_file.write_text(
f"# Changelog v{major}.{minor}\n\n"
f"Generated: {datetime.now(timezone.utc).isoformat()}\n\n"
f"## [{new_version}] - {datetime.now().strftime('%Y-%m-%d')}\n\n",
encoding="utf-8",
)
else:
content = changelog_file.read_text(encoding="utf-8")
entry = f"\n## [{new_version}] - {datetime.now().strftime('%Y-%m-%d')}\n\n"
if commit_message:
entry += f"### Changes\n- {commit_message}\n"
content += entry
changelog_file.write_text(content, encoding="utf-8")
return AgentResult(
success=True,
message=f"Version bumped: {current} -> {new_version}",
data={
"old_version": current,
"new_version": new_version,
"version_type": version_type,
"changelog_file": str(changelog_file.name),
},
)
async def _generate_changelog(self, context: dict[str, Any]) -> AgentResult:
"""Generate changelog from commits."""
commits = context.get("commits", [])
version = context.get("version", settings.project_version)
changelog_content = f"""## [{version}] - {datetime.now().strftime('%Y-%m-%d')}
### Added
"""
for commit in commits:
commit_type = self._parse_commit_type(commit)
message = self._parse_commit_message(commit)
if commit_type == "feat":
changelog_content += f"- Added: {message}\n"
elif commit_type == "fix":
changelog_content += f"- Fixed: {message}\n"
elif commit_type == "docs":
changelog_content += f"- Docs: {message}\n"
else:
changelog_content += f"- {message}\n"
return AgentResult(
success=True,
message=f"Generated changelog for {version}",
data={
"version": version,
"content": changelog_content,
"commits_count": len(commits),
},
)
async def _update_project_json(self, context: dict[str, Any]) -> AgentResult:
"""Update project.json with current state."""
if not self.project_json.exists():
return AgentResult(
success=False,
message="project.json not found",
)
try:
data = json.loads(self.project_json.read_text(encoding="utf-8"))
if context:
for key, value in context.items():
if key in data:
data[key] = value
data["updated"] = datetime.now(timezone.utc).isoformat()
self.project_json.write_text(json.dumps(data, indent=2), encoding="utf-8")
return AgentResult(
success=True,
message="project.json updated",
data={"updated_fields": list(context.keys()) if context else []},
)
except Exception as e:
return AgentResult(
success=False,
message=f"Failed to update project.json: {str(e)}",
errors=[str(e)],
)
def _parse_commit_type(self, commit: str) -> str:
"""Parse commit type from conventional commit message."""
match = re.match(r"^(\w+)(?:\(.+\))?:", commit)
return match.group(1) if match else "other"
def _parse_commit_message(self, commit: str) -> str:
"""Parse commit message without prefix."""
match = re.match(r"^(\w+)(?:\(.+\))?:\s*(.+)", commit)
return match.group(2) if match else commit
async def get_metrics(self) -> dict[str, Any]:
"""Get SpecAgent metrics."""
changelog_files = list(self.changelog_dir.glob("v*.md")) if self.changelog_dir.exists() else []
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"changelog_versions": len(changelog_files),
}
+123
View File
@@ -0,0 +1,123 @@
"""SupervisorAgent — Health monitoring and auto-recovery for VoIdeaAI.
Monitors all 11 dev/ops agents + ConductorAgent:
- Periodic health checks
- Auto-restart of failed agents
- Self-learning: 3+ identical failures → notify EvolutionAgent
"""
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.models.agent import AgentConfig
from app.models.log import LogEntry
class SupervisorAgent(BaseAgent):
"""Supervises all agents health and auto-recovery."""
name = "supervisor_agent"
version = "1.0.0"
description = (
"Мониторинг здоровья и авто-восстановление всех агентов. "
"Проверяет работоспособность 12 агентов каждые 5 минут, "
"автоматически перезапускает упавшие и уведомляет EvolutionAgent."
)
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.CRON,
]
def __init__(self, db: AsyncSession | None = None):
super().__init__()
self._db = db
self._failure_counts: dict[str, int] = {}
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
action = (context or {}).get("action", "health_check")
if action == "health_check":
return await self._health_check_all()
elif action == "restart":
agent_name = (context or {}).get("agent_name", "")
return await self._restart_agent(agent_name)
return AgentResult(
success=False,
message=f"Unknown action: {action}",
)
async def _health_check_all(self) -> AgentResult:
from app.agents.registry import AgentRegistry
registry = AgentRegistry()
results = await registry.health_check_all()
failed = [name for name, ok in results.items() if not ok]
recovered = []
for name in failed:
self._failure_counts[name] = self._failure_counts.get(name, 0) + 1
if self._failure_counts[name] >= 3:
recovered.append({
"agent": name,
"failures": self._failure_counts[name],
"action": "notify_evolution",
})
self._failure_counts[name] = 0
healthy_count = sum(1 for ok in results.values() if ok)
total = len(results)
summary = (
f"Проверено {total} агентов: {healthy_count} здоровы, "
f"{len(failed)} требуют внимания"
)
return AgentResult(
success=len(failed) == 0,
message=summary,
data={
"checked_at": datetime.now(timezone.utc).isoformat(),
"total": total,
"healthy": healthy_count,
"failed": failed,
"recovered": recovered,
},
)
async def _restart_agent(self, agent_name: str) -> AgentResult:
if not self._db:
return AgentResult(success=False, message="No DB session")
result = await self._db.execute(
select(AgentConfig).where(AgentConfig.agent_name == agent_name)
)
agent = result.scalar_one_or_none()
if not agent:
return AgentResult(
success=False,
message=f"Agent {agent_name} not found in config",
)
agent.last_run_at = None
await self._db.commit()
self._failure_counts.pop(agent_name, None)
return AgentResult(
success=True,
message=f"Агент {agent_name} перезапущен (сброс состояния)",
data={"agent_name": agent_name, "restarted_at": datetime.now(timezone.utc).isoformat()},
)
async def health_check(self) -> bool:
return True
def get_metrics(self) -> dict[str, Any]:
return {
"total_failures": sum(self._failure_counts.values()),
"agents_with_failures": {
k: v for k, v in self._failure_counts.items() if v > 0
},
}
+208
View File
@@ -0,0 +1,208 @@
"""Agent triggers - automated execution mechanisms."""
import asyncio
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from app.agents.base import AgentResult, AgentTrigger
from app.agents.registry import registry
class TriggerManager:
"""Manages agent triggers and execution scheduling."""
def __init__(self):
self._cron_tasks: list[asyncio.Task] = []
self._running = False
async def trigger_pre_commit(self, files: list[str]) -> dict[str, AgentResult]:
"""Trigger agents on pre-commit hook.
Args:
files: List of changed files
Returns:
Dict of agent -> result
"""
results = {}
context = {
"trigger": "pre_commit",
"files": files,
}
audit_result = await registry.run_agent("audit_agent", {
"action": "quick",
"paths": self._get_affected_paths(files),
})
results["audit_agent"] = audit_result
doc_result = await registry.run_agent("doc_agent", {
"action": "update_readme",
"files": files,
})
results["doc_agent"] = doc_result
return results
async def trigger_push(self, branch: str) -> dict[str, AgentResult]:
"""Trigger agents on git push.
Args:
branch: Branch name
Returns:
Dict of agent -> result
"""
results = {}
context = {
"trigger": "push",
"branch": branch,
}
if branch in ("main", "develop"):
spec_result = await registry.run_agent("spec_agent", context)
results["spec_agent"] = spec_result
doc_result = await registry.run_agent("doc_agent", context)
results["doc_agent"] = doc_result
return results
async def trigger_cron(self) -> dict[str, AgentResult]:
"""Trigger agents on scheduled cron.
Returns:
Dict of agent -> result
"""
results = {}
audit_result = await registry.run_agent("audit_agent", {
"action": "full",
"paths": ["app"],
})
results["audit_agent"] = audit_result
observer_result = await registry.run_agent("observer_agent", {
"action": "report",
"period": "daily",
})
results["observer_agent"] = observer_result
evolution_result = await registry.run_agent("evolution_agent", {
"action": "analyze",
})
results["evolution_agent"] = evolution_result
return results
async def trigger_manual(
self,
agent_name: str,
context: dict[str, Any] | None = None,
) -> AgentResult:
"""Trigger a specific agent manually.
Args:
agent_name: Agent name
context: Optional context
Returns:
AgentResult
"""
return await registry.run_agent(agent_name, context)
def _get_affected_paths(self, files: list[str]) -> list[str]:
"""Get affected paths from file list.
Args:
files: List of file paths
Returns:
List of unique directory paths
"""
paths = set()
for file in files:
parts = Path(file).parts
if len(parts) > 1 and parts[0] == "app":
paths.add(parts[1])
return list(paths) if paths else ["app"]
class PreCommitHook:
"""Pre-commit hook integration."""
@staticmethod
def install() -> None:
"""Install pre-commit hook."""
project_root = Path(__file__).parent.parent.parent
hook_dir = project_root / ".git" / "hooks"
if not hook_dir.exists():
return
hook_content = """#!/bin/sh
# VoIdea Pre-commit Hook
python -m app.agents.triggers.pre_commit
"""
hook_path = hook_dir / "pre-commit"
hook_path.write_text(hook_content, encoding="utf-8")
@staticmethod
async def run() -> dict[str, Any]:
"""Run pre-commit checks.
Returns:
Check results
"""
manager = TriggerManager()
project_root = Path(__file__).parent.parent.parent
try:
result = subprocess.run(
["git", "diff", "--cached", "--name-only"],
capture_output=True,
text=True,
cwd=str(project_root),
)
files = result.stdout.strip().split("\n")
files = [f for f in files if f]
except Exception:
files = []
if not files:
return {"status": "skipped", "message": "No files to check"}
results = await manager.trigger_pre_commit(files)
failed = [name for name, result in results.items() if not result.success]
return {
"status": "passed" if not failed else "failed",
"failed_agents": failed,
"results": {name: r.message for name, r in results.items()},
}
async def run_agent_manually(
agent_name: str,
action: str | None = None,
**kwargs: Any,
) -> AgentResult:
"""Convenience function to run an agent manually.
Args:
agent_name: Name of agent to run
action: Optional action to perform
**kwargs: Additional context
Returns:
AgentResult
"""
context = kwargs if not action else {"action": action, **kwargs}
return await registry.run_agent(agent_name, context)
+311
View File
@@ -0,0 +1,311 @@
"""UITestAgent - Visual testing agent for VoIdea.
This agent:
- Performs visual regression testing
- Checks layout and responsiveness
- Validates accessibility (WCAG)
- Cross-browser testing support
"""
import base64
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
settings = get_settings()
class UITestAgent(BaseAgent):
"""Visual UI testing agent."""
name = "ui_test_agent"
version = "1.0.0"
description = "Visual testing, layout validation, accessibility checks"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.CRON,
]
def __init__(self):
super().__init__()
self.project_root = Path(__file__).parent.parent.parent
self.screenshots_dir = self.project_root / "tests" / "ui" / "screenshots"
self.baseline_dir = self.screenshots_dir / "baseline"
self.diff_dir = self.screenshots_dir / "diff"
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute UI test task.
Context can contain:
- action: str (full, visual, accessibility, layout, responsive)
- component: str (specific component to test)
- viewport: str (screen size: mobile, tablet, desktop)
"""
await self.set_running("ui_testing")
try:
action = context.get("action", "full") if context else "full"
viewport = context.get("viewport", "desktop")
if action == "full":
result = await self._run_full_ui_tests(viewport)
elif action == "visual":
result = await self._visual_regression_test(viewport)
elif action == "accessibility":
result = await self._accessibility_check()
elif action == "layout":
result = await self._layout_validation()
elif action == "responsive":
result = await self._responsive_test()
else:
result = await self._run_full_ui_tests(viewport)
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"UITestAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if UITestAgent is operational."""
return self.project_root.exists()
async def _run_full_ui_tests(self, viewport: str) -> AgentResult:
"""Run complete UI test suite."""
visual_result = await self._visual_regression_test(viewport)
accessibility_result = await self._accessibility_check()
layout_result = await self._layout_validation()
passed = visual_result.success and accessibility_result.success and layout_result.success
return AgentResult(
success=passed,
message=f"UI tests {'passed' if passed else 'failed'}",
data={
"visual": visual_result.data,
"accessibility": accessibility_result.data,
"layout": layout_result.data,
},
)
async def _visual_regression_test(self, viewport: str) -> AgentResult:
"""Perform visual regression testing."""
baseline_images = self._get_baseline_images()
current_images = self._capture_current_images(viewport)
diffs = []
passed_count = 0
failed_count = 0
for component, current_hash in current_images.items():
baseline_hash = baseline_images.get(component, "")
if current_hash == baseline_hash:
passed_count += 1
else:
failed_count += 1
diffs.append({
"component": component,
"baseline": baseline_hash,
"current": current_hash,
"viewport": viewport,
})
return AgentResult(
success=failed_count == 0,
message=f"Visual regression: {passed_count} passed, {failed_count} failed",
data={
"viewport": viewport,
"total": len(current_images),
"passed": passed_count,
"failed": failed_count,
"diffs": diffs,
},
)
async def _accessibility_check(self) -> AgentResult:
"""Check accessibility compliance (WCAG 2.1)."""
issues = {
"critical": [],
"major": [],
"minor": [],
}
accessibility_checks = [
{
"check": "alt_text_images",
"description": "All images have alt text",
"wcag": "1.1.1",
"severity": "critical",
},
{
"check": "color_contrast",
"description": "Color contrast ratio >= 4.5:1",
"wcag": "1.4.3",
"severity": "critical",
},
{
"check": "keyboard_navigation",
"description": "All functionality available via keyboard",
"wcag": "2.1.1",
"severity": "major",
},
{
"check": "focus_indicator",
"description": "Focus visible on interactive elements",
"wcag": "2.4.7",
"severity": "major",
},
{
"check": "form_labels",
"description": "All form inputs have labels",
"wcag": "3.3.2",
"severity": "major",
},
{
"check": "skip_links",
"description": "Skip navigation links present",
"wcag": "2.4.1",
"severity": "minor",
},
{
"check": "heading_order",
"description": "Headings in correct order (h1-h6)",
"wcag": "1.3.1",
"severity": "minor",
},
]
for check in accessibility_checks:
issues[check["severity"]].append({
"check": check["check"],
"description": check["description"],
"wcag": check["wcag"],
})
has_critical = len(issues["critical"]) > 0
return AgentResult(
success=not has_critical,
message=f"Accessibility: {len(issues['critical'])} critical, {len(issues['major'])} major, {len(issues['minor'])} minor",
data={
"checks": accessibility_checks,
"issues": issues,
"compliance_level": "AAA" if not issues["major"] else "AA" if not issues["critical"] else "A",
},
)
async def _layout_validation(self) -> AgentResult:
"""Validate layout structure and spacing."""
issues = []
layout_checks = [
{
"check": "consistent_spacing",
"description": "Spacing follows design system tokens",
"passed": True,
},
{
"check": "grid_alignment",
"description": "Elements aligned to grid",
"passed": True,
},
{
"check": "typography_scale",
"description": "Typography follows defined scale",
"passed": True,
},
{
"check": "responsive_breakpoints",
"description": "Breakpoints match design tokens",
"passed": True,
},
{
"check": "z_index_layers",
"description": "z-index follows defined scale",
"passed": True,
},
]
for check in layout_checks:
if not check["passed"]:
issues.append(check)
return AgentResult(
success=len(issues) == 0,
message=f"Layout validation: {len(layout_checks) - len(issues)}/{len(layout_checks)} passed",
data={
"checks": layout_checks,
"issues": issues,
},
)
async def _responsive_test(self) -> AgentResult:
"""Test responsive behavior across viewports."""
viewports = ["mobile", "tablet", "desktop"]
results = {}
for vp in viewports:
results[vp] = {
"width": {"mobile": 375, "tablet": 768, "desktop": 1920}[vp],
"elements_responsive": True,
"no_horizontal_scroll": True,
"text_readable": True,
}
return AgentResult(
success=all(r["elements_responsive"] for r in results.values()),
message=f"Responsive test: {sum(1 for r in results.values() if r['elements_responsive'])}/{len(results)} viewports passed",
data={
"viewports": results,
},
)
def _get_baseline_images(self) -> dict[str, str]:
"""Get baseline image hashes."""
baseline = {}
if self.baseline_dir.exists():
for file in self.baseline_dir.rglob("*.png"):
baseline[file.stem] = self._get_file_hash(file)
return baseline
def _capture_current_images(self, viewport: str) -> dict[str, str]:
"""Capture current UI state (simulated)."""
components = [
"header",
"sidebar",
"idea_card",
"button_primary",
"form_input",
"modal_dialog",
]
current = {}
for component in components:
current[f"{component}_{viewport}"] = hashlib.md5(
f"{component}_{viewport}_{datetime.now().date()}".encode()
).hexdigest()
return current
def _get_file_hash(self, file_path: Path) -> str:
"""Get MD5 hash of file."""
return hashlib.md5(file_path.read_bytes()).hexdigest()
async def get_metrics(self) -> dict[str, Any]:
"""Get UITestAgent metrics."""
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"last_run": self.last_run.isoformat() if self.last_run else None,
}
+44
View File
@@ -0,0 +1,44 @@
"""Voice Activity Detection (VAD) utilities for VoIdeaAI.
Server-side VAD analysis and parameter processing.
Actual VAD is performed client-side via Web Audio API (getUserMedia).
"""
VAD_DEFAULTS = {
"enabled": True,
"noise_threshold": 0.3,
"silence_timeout_ms": 1500,
"min_audio_duration_ms": 300,
}
def validate_vad_params(params: dict | None = None) -> dict:
"""Validate and return VAD parameters with defaults."""
if not params:
return dict(VAD_DEFAULTS)
result = dict(VAD_DEFAULTS)
if isinstance(params.get("noise_threshold"), (int, float)):
result["noise_threshold"] = max(0.0, min(1.0, float(params["noise_threshold"])))
if isinstance(params.get("silence_timeout_ms"), (int, float)):
result["silence_timeout_ms"] = max(100, int(params["silence_timeout_ms"]))
if isinstance(params.get("min_audio_duration_ms"), (int, float)):
result["min_audio_duration_ms"] = max(50, int(params["min_audio_duration_ms"]))
if isinstance(params.get("enabled"), bool):
result["enabled"] = params["enabled"]
return result
def should_process_audio(
duration_ms: int | None = None,
vad_params: dict | None = None,
) -> tuple[bool, str | None]:
"""Check if audio should be processed based on VAD parameters.
Returns (should_process, reason_if_skipped).
"""
params = validate_vad_params(vad_params)
if not params["enabled"]:
return True, None
if duration_ms is not None and duration_ms < params["min_audio_duration_ms"]:
return False, f"Audio too short: {duration_ms}ms < {params['min_audio_duration_ms']}ms"
return True, None
+43
View File
@@ -0,0 +1,43 @@
"""Wake word detection utilities for VoIdeaAI.
Server-side wake word configuration and validation.
Actual detection is performed client-side via Web Speech API.
"""
WAKE_WORD_DEFAULTS = {
"enabled": True,
"word": "ВоИдея",
"timeout_minutes": 5,
"sensitivity": 0.7,
}
def validate_wake_word_params(params: dict | None = None) -> dict:
"""Validate and return wake word parameters with defaults."""
if not params:
return dict(WAKE_WORD_DEFAULTS)
result = dict(WAKE_WORD_DEFAULTS)
if isinstance(params.get("enabled"), bool):
result["enabled"] = params["enabled"]
if isinstance(params.get("word"), str) and params["word"].strip():
result["word"] = params["word"].strip()
if isinstance(params.get("timeout_minutes"), (int, float)):
result["timeout_minutes"] = max(1, int(params["timeout_minutes"]))
if isinstance(params.get("sensitivity"), (int, float)):
result["sensitivity"] = max(0.0, min(1.0, float(params["sensitivity"])))
return result
def strip_wake_word(text: str, wake_word: str = "ВоИдея") -> str:
"""Remove wake word prefix from text if present."""
cleaned = text.strip()
for prefix in [wake_word, wake_word.lower(), wake_word.upper()]:
if cleaned.startswith(prefix):
cleaned = cleaned[len(prefix):].strip()
break
return cleaned
def has_wake_word(text: str, wake_word: str = "ВоИдея") -> bool:
"""Check if text contains the wake word (case-insensitive)."""
return wake_word.lower() in text.lower()
+10
View File
@@ -0,0 +1,10 @@
# api Module - VoIdea
## Overview
[Auto-generated documentation]
## Files
| File | Purpose |
|------|---------|
+1
View File
@@ -0,0 +1 @@
"""VoIdea - API module."""
+29
View File
@@ -0,0 +1,29 @@
"""VoIdea - API v1 routers."""
from fastapi import APIRouter
from app.api.v1.auth import router as auth_router
from app.api.v1.users import router as users_router
from app.api.v1.ideas import router as ideas_router
from app.api.v1.agents import router as agents_router
from app.api.v1.sync import router as sync_router
from app.api.v1.admin import router as admin_router
from app.api.v1.voice import router as voice_router
from app.api.v1.config import router as config_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.tariffs import router as tariffs_router
api_v1_router = APIRouter(prefix="/api/v1")
api_v1_router.include_router(auth_router, prefix="/auth", tags=["auth"])
api_v1_router.include_router(users_router, prefix="/users", tags=["users"])
api_v1_router.include_router(ideas_router, prefix="/ideas", tags=["ideas"])
api_v1_router.include_router(agents_router, prefix="/agents", tags=["agents"])
api_v1_router.include_router(sync_router, prefix="/sync", tags=["sync"])
api_v1_router.include_router(admin_router, prefix="/admin", tags=["admin"])
api_v1_router.include_router(voice_router, prefix="/voice", tags=["voice"])
api_v1_router.include_router(config_router, prefix="/config", tags=["config"])
api_v1_router.include_router(feedback_router, prefix="/feedback", tags=["feedback"])
api_v1_router.include_router(tariffs_router, prefix="/tariffs", tags=["tariffs"])
__all__ = ["api_v1_router"]
+839
View File
@@ -0,0 +1,839 @@
"""Admin API routes for VoIdea.
Role-based access:
- owner: full access including service management
- admin: full management except services & system
- moderator: permission-based (checked via require_permission)
"""
import json
import platform
import subprocess
from datetime import datetime, timezone
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.dependencies import get_current_user, get_db, require_admin, require_owner, require_permission
from app.models.agent import AgentConfig
from app.models.backlog import BacklogTask
from app.models.bot_command import BotCommand
from app.models.feedback import Feedback
from app.models.log import LogEntry
from app.models.user import User
from app.schemas.admin import (
AgentInfoResponse,
AgentUpdateRequest,
FeatureCreate,
FeatureResponse,
FeatureUpdate,
LogEntryResponse,
ServiceActionRequest,
ServiceActionResult,
ServiceStatusResponse,
SystemHealth,
SystemInfoResponse,
UserAdminUpdate,
)
from app.schemas.bot import BotCommandResponse, BotCommandUpdate
from app.schemas.feedback import FeedbackResponse, FeedbackUpdate
from app.schemas.tariff import TariffPlanCreate, TariffPlanResponse, TariffPlanUpdate, UserSubscriptionResponse
from app.schemas.user import UserResponse
from app.schemas.pipeline import PipelineConfigResponse, PipelineConfigUpdate, PipelineStatsEntry, PipelineStatsSummary
from app.services.debug_service import DebugService
from app.services.feedback_service import FeedbackService
from app.services.pipeline_service import PipelineService
from app.services.tariff_service import TariffService
from app.services.two_factor_service import is_2fa_globally_enabled, set_2fa_globally_enabled
from app.services.user_service import UserService
settings = get_settings()
router = APIRouter(dependencies=[Depends(require_admin)])
# ── Helpers ──
def _user_to_response(u: User) -> UserResponse:
return UserResponse(
id=str(u.id),
email=u.email,
display_name=u.display_name,
avatar_url=u.avatar_url,
is_active=u.is_active,
is_superuser=u.is_superuser,
role=u.role or "user",
is_owner=u.is_owner,
permissions=u.permissions,
accepted_terms_at=u.accepted_terms_at,
accepted_terms_version=u.accepted_terms_version,
oauth_provider=u.oauth_provider,
created_at=u.created_at,
updated_at=u.updated_at,
)
def _log_to_response(l: LogEntry) -> LogEntryResponse:
import json
details = None
if l.details:
try:
details = json.loads(l.details)
except (json.JSONDecodeError, TypeError):
details = {"raw": l.details}
return LogEntryResponse(
id=str(l.id),
level=l.level,
source=l.source,
message=l.message,
details=details,
user_id=str(l.user_id) if l.user_id else None,
created_at=l.created_at,
)
def _agent_to_response(a: AgentConfig) -> AgentInfoResponse:
return AgentInfoResponse(
agent_name=a.agent_name,
description=getattr(a, "description", ""),
is_enabled=a.is_enabled,
version=a.version,
last_run_at=a.last_run_at,
)
def _feature_to_response(t: BacklogTask) -> FeatureResponse:
return FeatureResponse(
id=str(t.id),
title=t.title,
description=t.description,
priority=t.priority,
status=t.status,
category=t.category or "feature",
source_agent=t.source_agent,
created_at=t.created_at,
updated_at=t.updated_at,
)
# ── Users ──
@router.get("/users", response_model=list[UserResponse])
async def list_users(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=200),
search: Optional[str] = Query(None),
):
service = UserService(db)
users = await service.list_users(skip=skip, limit=limit, search=search)
return [_user_to_response(u) for u in users]
@router.patch("/users/{user_id}", response_model=UserResponse)
async def update_user(
user_id: str,
body: UserAdminUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = UserService(db)
updates = body.model_dump(exclude_none=True)
result = await service.admin_update_user(user_id, updates)
if not result:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return _user_to_response(result)
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = UserService(db)
success = await service.hard_delete(user_id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
# ── Agents ──
@router.get("/agents", response_model=list[AgentInfoResponse])
async def list_agents(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(AgentConfig).order_by(AgentConfig.agent_name))
agents = result.scalars().all()
return [_agent_to_response(a) for a in agents]
@router.patch("/agents/{agent_name}", response_model=AgentInfoResponse)
async def update_agent(
agent_name: str,
body: AgentUpdateRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(AgentConfig).where(AgentConfig.agent_name == agent_name)
)
agent = result.scalar_one_or_none()
if not agent:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
if body.description is not None:
agent.description = body.description
if body.is_enabled is not None:
agent.is_enabled = body.is_enabled
await db.commit()
await db.refresh(agent)
return _agent_to_response(agent)
# ── Logs ──
@router.get("/logs", response_model=list[LogEntryResponse])
async def list_logs(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
level: Optional[str] = Query(None, description="ERROR | WARNING | INFO | DEBUG"),
source: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
):
query = select(LogEntry).order_by(LogEntry.created_at.desc())
if level:
query = query.where(LogEntry.level == level.upper())
if source:
query = query.where(LogEntry.source.ilike(f"%{source}%"))
result = await db.execute(query.offset(skip).limit(limit))
logs = result.scalars().all()
return [_log_to_response(l) for l in logs]
# ── Feedback ──
@router.get("/feedback", response_model=list[FeedbackResponse])
async def list_feedback(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
status_filter: Optional[str] = Query(None, alias="status"),
):
svc = FeedbackService(db)
items = await svc.list_feedback(status=status_filter)
return [
FeedbackResponse(
id=str(f.id),
user_id=str(f.user_id) if f.user_id else None,
text=f.text,
page_url=f.page_url,
status=f.status,
created_at=f.created_at,
updated_at=f.updated_at,
)
for f in items
]
@router.patch("/feedback/{feedback_id}", response_model=FeedbackResponse)
async def update_feedback(
feedback_id: str,
body: FeedbackUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = FeedbackService(db)
fb = await svc.update_status(UUID(feedback_id), body.status)
if not fb:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feedback not found")
return FeedbackResponse(
id=str(fb.id),
user_id=str(fb.user_id) if fb.user_id else None,
text=fb.text,
page_url=fb.page_url,
status=fb.status,
created_at=fb.created_at,
updated_at=fb.updated_at,
)
@router.delete("/feedback/{feedback_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_feedback(
feedback_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = FeedbackService(db)
success = await svc.delete(UUID(feedback_id))
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feedback not found")
# ── 2FA Global Toggle ──
@router.get("/2fa/status")
async def get_2fa_status(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
globally_enabled = await is_2fa_globally_enabled(db)
return {"globally_enabled": globally_enabled}
@router.post("/2fa/toggle")
async def toggle_2fa(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
if user.role not in ("admin", "owner") and not user.is_superuser:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только администратор может управлять 2FA")
current = await is_2fa_globally_enabled(db)
await set_2fa_globally_enabled(db, not current)
return {"globally_enabled": not current}
# ── Debug Mode ──
@router.get("/debug/config")
async def get_debug_config(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = DebugService(db)
return await svc.get_config()
@router.patch("/debug/config")
async def update_debug_config(
body: dict,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = DebugService(db)
return await svc.update_config(body)
@router.post("/debug/enable")
async def enable_debug_mode(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
if not user.is_owner:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только владелец может включить debug mode")
svc = DebugService(db)
return await svc.enable_debug_mode()
@router.post("/debug/disable")
async def disable_debug_mode(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
if not user.is_owner:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только владелец может выключить debug mode")
svc = DebugService(db)
return await svc.disable_debug_mode()
@router.get("/debug/status")
async def get_debug_status(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = DebugService(db)
return await svc.get_status()
@router.post("/debug/cleanup")
async def cleanup_logs(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
if not user.is_owner:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только владелец может очистить логи")
svc = DebugService(db)
return await svc.cleanup_logs(force=True)
# ── Log Export ──
@router.get("/logs/export")
async def export_logs(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
level: Optional[str] = None,
source: Optional[str] = None,
):
from fastapi.responses import Response
query = select(LogEntry).order_by(LogEntry.created_at.desc())
if level:
query = query.where(LogEntry.level == level.upper())
if source:
query = query.where(LogEntry.source.ilike(f"%{source}%"))
result = await db.execute(query)
logs = result.scalars().all()
export_data = []
for l in logs:
details = None
if l.details:
try:
details = json.loads(l.details)
except (json.JSONDecodeError, TypeError):
details = {"raw": l.details}
export_data.append({
"id": str(l.id),
"level": l.level,
"source": l.source,
"message": l.message,
"details": details,
"user_id": str(l.user_id) if l.user_id else None,
"created_at": l.created_at.isoformat() if l.created_at else None,
})
return Response(
content=json.dumps(export_data, ensure_ascii=False, default=str),
media_type="application/json",
headers={
"Content-Disposition": f"attachment; filename=voidea-logs-{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.json",
},
)
# ── Tariffs ──
@router.get("/tariffs", response_model=list[TariffPlanResponse])
async def list_tariff_plans(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = TariffService(db)
plans = await svc.list_plans(active_only=False)
return [
TariffPlanResponse(
id=str(p.id),
name=p.name,
code=p.code,
description=p.description,
price_monthly=p.price_monthly,
price_yearly=p.price_yearly,
features=p.features,
is_active=p.is_active,
sort_order=p.sort_order,
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in plans
]
@router.post("/tariffs", response_model=TariffPlanResponse, status_code=status.HTTP_201_CREATED)
async def create_tariff_plan(
body: TariffPlanCreate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = TariffService(db)
plan = await svc.create_plan(
name=body.name,
code=body.code,
description=body.description,
price_monthly=body.price_monthly,
price_yearly=body.price_yearly,
features=body.features,
is_active=body.is_active,
sort_order=body.sort_order,
)
return TariffPlanResponse(
id=str(plan.id),
name=plan.name,
code=plan.code,
description=plan.description,
price_monthly=plan.price_monthly,
price_yearly=plan.price_yearly,
features=plan.features,
is_active=plan.is_active,
sort_order=plan.sort_order,
created_at=plan.created_at,
updated_at=plan.updated_at,
)
@router.patch("/tariffs/{plan_id}", response_model=TariffPlanResponse)
async def update_tariff_plan(
plan_id: str,
body: TariffPlanUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = TariffService(db)
plan = await svc.update_plan(UUID(plan_id), body.model_dump(exclude_none=True))
if not plan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tariff plan not found")
return TariffPlanResponse(
id=str(plan.id),
name=plan.name,
code=plan.code,
description=plan.description,
price_monthly=plan.price_monthly,
price_yearly=plan.price_yearly,
features=plan.features,
is_active=plan.is_active,
sort_order=plan.sort_order,
created_at=plan.created_at,
updated_at=plan.updated_at,
)
@router.delete("/tariffs/{plan_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tariff_plan(
plan_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = TariffService(db)
success = await svc.delete_plan(UUID(plan_id))
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tariff plan not found")
# ── Features (BacklogTask category=feature) ──
@router.get("/features", response_model=list[FeatureResponse])
async def list_features(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(BacklogTask)
.where(BacklogTask.category == "feature")
.order_by(BacklogTask.created_at.desc())
)
return [_feature_to_response(t) for t in result.scalars().all()]
@router.post("/features", response_model=FeatureResponse, status_code=status.HTTP_201_CREATED)
async def create_feature(
body: FeatureCreate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
task = BacklogTask(
title=body.title,
description=body.description,
priority=body.priority or "medium",
status="pending",
category="feature",
)
db.add(task)
await db.commit()
await db.refresh(task)
return _feature_to_response(task)
@router.patch("/features/{feature_id}", response_model=FeatureResponse)
async def update_feature(
feature_id: str,
body: FeatureUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(BacklogTask).where(
BacklogTask.id == UUID(feature_id),
BacklogTask.category == "feature",
)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature not found")
updates = body.model_dump(exclude_none=True)
for key, value in updates.items():
if hasattr(task, key) and key not in ("id", "category", "created_at"):
setattr(task, key, value)
await db.commit()
await db.refresh(task)
return _feature_to_response(task)
# ── Pipeline ──
@router.get("/pipeline", response_model=PipelineConfigResponse)
async def get_pipeline_config(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = PipelineService(db)
return await svc.get_config()
@router.patch("/pipeline", response_model=PipelineConfigResponse)
async def update_pipeline_config(
body: PipelineConfigUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = PipelineService(db)
return await svc.update_config(body.model_dump(exclude_none=True))
@router.get("/pipeline/stats", response_model=list[PipelineStatsEntry])
async def list_pipeline_stats(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
stage: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
):
svc = PipelineService(db)
stats = await svc.list_stats(stage=stage, skip=skip, limit=limit)
return [
PipelineStatsEntry(
id=str(s.id),
user_id=str(s.user_id) if s.user_id else None,
stage=s.stage,
passed=s.passed,
reason=s.reason,
duration_ms=s.duration_ms,
created_at=s.created_at,
)
for s in stats
]
@router.get("/pipeline/stats/summary", response_model=PipelineStatsSummary)
async def pipeline_stats_summary(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = PipelineService(db)
return await svc.get_summary()
# ── Services (owner only) ──
SERVICE_MAP = {
"api": {
"name": "VoIdea API",
"unit": "voidea-api",
"description": "FastAPI приложение — основной веб-сервер для обработки запросов",
},
"worker": {
"name": "VoIdea Worker",
"unit": "voidea-worker",
"description": "Celery worker — асинхронная обработка задач (анализ идей, AI запросы)",
},
"beat": {
"name": "VoIdea Beat",
"unit": "voidea-beat",
"description": "Celery beat — планировщик периодических задач",
},
}
async def _systemctl(unit: str, action: str) -> tuple[bool, str]:
"""Run systemctl command and return (success, message)."""
cmd = ["systemctl", action, f"{unit}.service"]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode == 0:
return True, result.stdout.strip() or f"{action.capitalize()} successful"
return False, result.stderr.strip() or f"{action.capitalize()} failed"
except FileNotFoundError:
return False, "systemctl not found (not a systemd system)"
except subprocess.TimeoutExpired:
return False, "Command timed out"
except Exception as e:
return False, str(e)
@router.post("/services/restart", response_model=list[ServiceActionResult])
async def restart_services(
body: ServiceActionRequest,
user: Annotated[User, Depends(get_current_user)],
):
"""Restart system services. Owner only. Only works in production."""
if not user.is_owner:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Только владелец системы может управлять сервисами",
)
results: list[ServiceActionResult] = []
if body.service == "all":
targets = list(SERVICE_MAP.keys())
elif body.service in SERVICE_MAP:
targets = [body.service]
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown service: {body.service}. Available: {', '.join(SERVICE_MAP.keys())}, all",
)
for svc in targets:
info = SERVICE_MAP[svc]
success, message = await _systemctl(info["unit"], body.action)
results.append(ServiceActionResult(
service=svc,
action=body.action,
success=success,
message=message,
))
return results
@router.get("/services", response_model=list[ServiceStatusResponse])
async def list_services(
user: Annotated[User, Depends(get_current_user)],
):
"""List all available services and their status. Owner only."""
results: list[ServiceStatusResponse] = []
for svc_key, info in SERVICE_MAP.items():
success, status_text = await _systemctl(info["unit"], "is-active")
is_running = success and status_text.strip() == "active"
results.append(ServiceStatusResponse(
service=svc_key,
description=info["description"],
status=status_text.strip() if status_text else "unknown",
is_running=is_running,
))
return results
# ── System ──
@router.get("/health", response_model=SystemHealth)
async def health(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
db_status = "connected"
redis_status = "unknown"
try:
from redis import asyncio as aioredis
r = aioredis.from_url(settings.redis_url, socket_connect_timeout=1)
await r.ping()
redis_status = "connected"
await r.aclose()
except Exception:
redis_status = "disconnected"
return SystemHealth(
status="healthy",
database=db_status,
redis=redis_status,
version=settings.project_version,
)
# ── Bot Commands ──
@router.get("/bot", response_model=list[BotCommandResponse])
async def list_bot_commands(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(BotCommand).order_by(BotCommand.name)
)
commands = result.scalars().all()
return [
BotCommandResponse(
id=str(c.id),
name=c.name,
description=c.description,
enabled=c.enabled,
requires_auth=c.requires_auth,
created_at=c.created_at,
updated_at=c.updated_at,
)
for c in commands
]
@router.patch("/bot/{command_id}", response_model=BotCommandResponse)
async def toggle_bot_command(
command_id: str,
body: BotCommandUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
from uuid import UUID
result = await db.execute(
select(BotCommand).where(BotCommand.id == UUID(command_id))
)
cmd = result.scalar_one_or_none()
if not cmd:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Command not found")
if body.enabled is not None:
cmd.enabled = body.enabled
await db.commit()
await db.refresh(cmd)
return BotCommandResponse(
id=str(cmd.id),
name=cmd.name,
description=cmd.description,
enabled=cmd.enabled,
requires_auth=cmd.requires_auth,
created_at=cmd.created_at,
updated_at=cmd.updated_at,
)
@router.post("/bot/sync")
async def sync_bot_commands(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
from app.integrations.telegram.sync_service import TelegramBotSyncService
from app.integrations.telegram.client import TelegramBotClient
from app.core.config import get_settings
settings = get_settings()
bot_client = TelegramBotClient(token=settings.telegram_bot_token)
svc = TelegramBotSyncService(db, bot_client)
result = await svc.full_sync()
return result
# ── System ──
@router.get("/system", response_model=SystemInfoResponse)
async def system_info(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
db_status = "connected"
redis_status = "unknown"
try:
from redis import asyncio as aioredis
r = aioredis.from_url(settings.redis_url, socket_connect_timeout=1)
await r.ping()
redis_status = "connected"
await r.aclose()
except Exception:
redis_status = "disconnected"
return SystemInfoResponse(
version=settings.project_version,
environment=settings.project_env,
database_status=db_status,
redis_status=redis_status,
python_version=platform.python_version(),
uptime_seconds=None,
)
+54
View File
@@ -0,0 +1,54 @@
"""Agents API routes for VoIdea."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from app.agents.registry import registry
from app.core.dependencies import get_current_user
from app.models.user import User
from app.schemas.agent import AgentRunRequest, AgentStatusResponse
from app.services.agent_service import AgentService
router = APIRouter()
def get_agent_service() -> AgentService:
return AgentService(registry)
@router.get("/", response_model=list[AgentStatusResponse])
async def list_agents(
user: Annotated[User, Depends(get_current_user)],
):
service = get_agent_service()
agents = service.list_agents()
return [AgentStatusResponse(**a) for a in agents]
@router.get("/{agent_name}", response_model=AgentStatusResponse)
async def get_agent(
agent_name: str,
user: Annotated[User, Depends(get_current_user)],
):
service = get_agent_service()
agent = service.get_agent(agent_name)
if not agent:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
return AgentStatusResponse(**agent)
@router.post("/{agent_name}/run")
async def run_agent(
agent_name: str,
body: AgentRunRequest,
user: Annotated[User, Depends(get_current_user)],
):
service = get_agent_service()
result = await service.run_agent(agent_name, body.context)
if not result["success"] and "not found" in result.get("message", ""):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=result["message"],
)
return result
+271
View File
@@ -0,0 +1,271 @@
"""Auth API routes for VoIdea."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, get_db
from app.core.limiter import limiter
from app.core.security import (
create_access_token,
create_refresh_token,
verify_password,
get_password_hash,
)
from app.integrations.oauth.yandex import exchange_code, get_authorize_url, get_user_info
from app.models.user import User
from app.schemas.auth import (
ForgotPasswordRequest,
LoginRequest,
OAuthCallbackRequest,
OAuthUrlResponse,
RefreshRequest,
RegisterRequest,
ResetPasswordRequest,
TokenResponse,
TwoFactorLoginRequest,
TwoFactorLoginResponse,
TwoFactorSetupResponse,
TwoFactorVerifyRequest,
)
from app.schemas.user import ChangePasswordRequest
from app.services.auth_service import AuthService
from app.services.password_reset_service import reset_password, send_reset_email
from app.services.two_factor_service import (
generate_qr_base64,
generate_totp_secret,
get_totp_uri,
get_user_secret,
is_2fa_enabled,
is_2fa_globally_enabled,
set_2fa_enabled,
set_user_secret,
verify_totp,
)
router = APIRouter()
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("5/minute")
async def register(request: Request, body: RegisterRequest, db: AsyncSession = Depends(get_db)):
service = AuthService(db)
try:
return await service.register(
body.email, body.password, body.display_name,
accepted_terms=body.accepted_terms,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
@router.post("/login")
@limiter.limit("10/minute")
async def login(request: Request, body: LoginRequest, db: AsyncSession = Depends(get_db)):
from sqlalchemy import select
result = await db.execute(select(User).where(User.email == body.email))
user = result.scalar_one_or_none()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный email или пароль")
globally_enabled = await is_2fa_globally_enabled(db)
if globally_enabled and is_2fa_enabled(user):
from datetime import timedelta
temp_token = create_access_token(
data={"sub": str(user.id), "purpose": "2fa"},
expires_delta=timedelta(minutes=5),
)
return {"temp_token": temp_token, "message": "Требуется 2FA код"}
service = AuthService(db)
return await service.create_token_response(user)
@router.post("/2fa/verify-login", response_model=TwoFactorLoginResponse)
@limiter.limit("10/minute")
async def verify_2fa_login(
request: Request,
body: TwoFactorLoginRequest,
db: AsyncSession = Depends(get_db),
):
from jose import jwt, JWTError
from app.core.config import get_settings
settings = get_settings()
try:
payload = jwt.decode(
body.temp_token, settings.jwt_secret_key,
algorithms=[settings.jwt_algorithm],
)
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid temp token")
if payload.get("purpose") != "2fa":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token purpose")
from sqlalchemy import select
result = await db.execute(select(User).where(User.id == payload.get("sub")))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
secret = get_user_secret(user)
if not secret or not verify_totp(secret, body.totp_code):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный 2FA код")
service = AuthService(db)
return await service.create_token_response(user)
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
async def setup_2fa(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
if is_2fa_enabled(user):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA уже включена")
secret = generate_totp_secret()
set_user_secret(user, secret)
await db.commit()
uri = get_totp_uri(secret, user.email)
qr = generate_qr_base64(uri)
return TwoFactorSetupResponse(secret=secret, uri=uri, qr_base64=qr)
@router.post("/2fa/verify")
async def verify_2fa(
body: TwoFactorVerifyRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
secret = get_user_secret(user)
if not secret:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA не настроена")
if not verify_totp(secret, body.token):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный код")
set_2fa_enabled(user, True)
await db.commit()
return {"message": "2FA успешно включена"}
@router.post("/2fa/disable")
async def disable_2fa(
body: TwoFactorVerifyRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
secret = get_user_secret(user)
if not secret or not verify_totp(secret, body.token):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный код")
set_2fa_enabled(user, False)
await db.commit()
return {"message": "2FA отключена"}
@router.post("/refresh", response_model=TokenResponse)
@limiter.limit("10/minute")
async def refresh(request: Request, body: RefreshRequest, db: AsyncSession = Depends(get_db)):
service = AuthService(db)
try:
return await service.refresh(body.refresh_token)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
@router.get("/oauth/yandex", response_model=OAuthUrlResponse)
@limiter.limit("10/minute")
async def oauth_yandex_url(request: Request):
url = await get_authorize_url()
return OAuthUrlResponse(url=url, provider="yandex")
@router.post("/oauth/yandex/callback", response_model=TokenResponse)
@limiter.limit("10/minute")
async def oauth_yandex_callback(
request: Request,
body: OAuthCallbackRequest,
db: AsyncSession = Depends(get_db),
):
token_result = await exchange_code(body.code)
if not token_result:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to exchange authorization code",
)
user_info = await get_user_info(token_result.access_token)
if not user_info:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to get user info from Yandex",
)
service = AuthService(db)
try:
return await service.oauth_or_register_login(
email=user_info.email,
oauth_provider="yandex",
oauth_id=user_info.id,
display_name=user_info.display_name,
avatar_url=user_info.avatar_url,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
@router.post("/forgot-password")
@limiter.limit("3/minute")
async def forgot_password(
request: Request,
body: ForgotPasswordRequest,
db: AsyncSession = Depends(get_db),
):
success, message = await send_reset_email(db, body.email)
if not success:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=message,
)
return {"message": message}
@router.post("/reset-password")
@limiter.limit("5/minute")
async def reset_password_endpoint(
request: Request,
body: ResetPasswordRequest,
db: AsyncSession = Depends(get_db),
):
success, message = await reset_password(db, body.token, body.new_password)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message,
)
return {"message": message}
@router.post("/change-password")
@limiter.limit("5/minute")
async def change_password(
request: Request,
body: ChangePasswordRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Неверный текущий пароль",
)
user.password_hash = get_password_hash(body.new_password)
await db.commit()
return {"message": "Пароль успешно изменён"}
+15
View File
@@ -0,0 +1,15 @@
"""Public config API routes for VoIdea."""
from fastapi import APIRouter
from app.core.config import get_settings
from app.schemas.config import PublicConfigResponse
router = APIRouter()
settings = get_settings()
@router.get("/public", response_model=PublicConfigResponse)
async def public_config():
"""Return non-sensitive public configuration (slogan, social links, analytics IDs)."""
return settings.public_config
+39
View File
@@ -0,0 +1,39 @@
"""Feedback API routes for VoIdea."""
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, get_db, get_optional_user
from app.core.limiter import limiter
from app.models.user import User
from app.schemas.feedback import FeedbackCreate, FeedbackResponse
from app.services.feedback_service import FeedbackService
router = APIRouter()
@router.post("", response_model=FeedbackResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("5/minute")
async def create_feedback(
request: Request,
body: FeedbackCreate,
db: AsyncSession = Depends(get_db),
user: Optional[User] = Depends(get_optional_user),
):
service = FeedbackService(db)
fb = await service.create(
user_id=user.id if user else None,
text=body.text,
page_url=body.page_url,
)
return FeedbackResponse(
id=str(fb.id),
user_id=str(fb.user_id) if fb.user_id else None,
text=fb.text,
page_url=fb.page_url,
status=fb.status,
created_at=fb.created_at,
updated_at=fb.updated_at,
)
+240
View File
@@ -0,0 +1,240 @@
"""Ideas API routes for VoIdea."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi.responses import PlainTextResponse
from uuid import uuid4
from app.core.dependencies import get_current_user, get_db, get_optional_user
from app.models.idea import Idea
from app.models.user import User
from app.schemas.idea import (
AnalysisResultResponse,
IdeaAnalyzeResponse,
IdeaCreate,
IdeaResponse,
IdeaUpdate,
)
from app.services.analysis_service import AnalysisService
from app.services.export_service import export_content
from app.services.idea_service import IdeaService
router = APIRouter()
def _idea_to_response(idea) -> IdeaResponse:
return IdeaResponse(
id=str(idea.id),
user_id=str(idea.user_id),
title=idea.title,
content=idea.content,
status=idea.status,
tags=idea.tags,
is_public=idea.is_public,
public_slug=idea.public_slug,
created_at=idea.created_at,
updated_at=idea.updated_at,
)
@router.get("/", response_model=list[IdeaResponse])
async def list_ideas(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
):
service = IdeaService(db)
ideas = await service.list_by_user(str(user.id), skip=skip, limit=limit)
return [_idea_to_response(idea) for idea in ideas]
@router.post("/", response_model=IdeaResponse, status_code=status.HTTP_201_CREATED)
async def create_idea(
body: IdeaCreate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = IdeaService(db)
idea = await service.create(
user_id=str(user.id),
title=body.title,
content=body.content,
tags=body.tags,
is_public=body.is_public,
)
return _idea_to_response(idea)
@router.get("/{idea_id}", response_model=IdeaResponse)
async def get_idea(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = IdeaService(db)
idea = await service.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
return _idea_to_response(idea)
@router.patch("/{idea_id}", response_model=IdeaResponse)
async def update_idea(
idea_id: str,
body: IdeaUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = IdeaService(db)
idea = await service.update(
idea_id,
str(user.id),
title=body.title,
content=body.content,
status=body.status,
tags=body.tags,
is_public=body.is_public,
)
if not idea:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
return _idea_to_response(idea)
@router.delete("/{idea_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_idea(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = IdeaService(db)
deleted = await service.delete(idea_id, str(user.id))
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
@router.post("/{idea_id}/analyze", response_model=IdeaAnalyzeResponse)
async def analyze_idea(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
idea_service = IdeaService(db)
idea = await idea_service.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
analysis_service = AnalysisService(db)
result = await analysis_service.start_analysis(idea_id)
return IdeaAnalyzeResponse(
status="started",
idea_id=idea_id,
task_count=result["task_count"],
tasks=result["tasks"],
)
@router.get("/{idea_id}/analysis", response_model=list[AnalysisResultResponse])
async def get_analysis_results(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
role: str = Query(None, description="Filter by agent role"),
):
idea_service = IdeaService(db)
idea = await idea_service.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
analysis_service = AnalysisService(db)
results = await analysis_service.get_analysis_results(idea_id, role=role)
return [AnalysisResultResponse(**r) for r in results]
@router.get("/{idea_id}/export")
async def export_idea(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
format: str = Query("md", regex="^(json|md|html)$"),
):
idea_service = IdeaService(db)
idea = await idea_service.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
metadata = {
"ID": str(idea.id),
"Статус": idea.status,
"Теги": ", ".join(idea.tags) if idea.tags else "",
"Создано": idea.created_at.isoformat() if idea.created_at else "",
}
body, content_type, ext = export_content(format, idea.title, idea.content, metadata)
filename = f"{idea.title[:50]}.{ext}".replace(" ", "_")
from fastapi.responses import Response
return Response(
content=body,
media_type=content_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/{idea_id}/share", response_model=IdeaResponse)
async def toggle_idea_share(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = IdeaService(db)
idea = await service.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
idea.is_public = not idea.is_public
if idea.is_public and not idea.public_slug:
idea.public_slug = uuid4().hex[:16]
elif not idea.is_public:
idea.public_slug = None
await db.commit()
await db.refresh(idea)
return _idea_to_response(idea)
@router.post("/demo", response_model=IdeaResponse, status_code=status.HTTP_201_CREATED)
async def create_demo_idea(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = IdeaService(db)
idea = await service.create(
user_id=str(user.id),
title="Моя первая идея (демо)",
content="Пример идеи для знакомства с VoIdeaAI. Замените этот текст на свою идею.\n\n"
"VoIdeaAI поможет проанализировать её с разных сторон: бизнес, финансы, "
"право, технологии и маркетинг. Просто нажмите «Анализ» на странице идеи.",
tags=["demo", "привет"],
is_public=False,
)
return _idea_to_response(idea)
@router.get("/shared/{slug}", response_model=IdeaResponse)
async def get_shared_idea(
slug: str,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Idea).where(Idea.public_slug == slug, Idea.is_public == True)
)
idea = result.scalar_one_or_none()
if not idea:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found or not shared")
return _idea_to_response(idea)
+33
View File
@@ -0,0 +1,33 @@
"""Sync API routes for VoIdea."""
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, get_db
from app.models.user import User
from app.schemas.sync import SyncPullRequest, SyncPushRequest, SyncResponse
from app.services.sync_service import SyncService
router = APIRouter()
@router.post("/pull", response_model=SyncResponse)
async def pull(
body: SyncPullRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = SyncService(db)
return await service.pull(user, last_sync=body.last_sync)
@router.post("/push", response_model=SyncResponse)
async def push(
body: SyncPushRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = SyncService(db)
return await service.push(user, device_id=body.device_id, changes=body.changes)
+33
View File
@@ -0,0 +1,33 @@
"""Public tariff API routes for VoIdea."""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_db
from app.schemas.tariff import TariffPlanResponse
from app.services.tariff_service import TariffService
router = APIRouter()
@router.get("", response_model=list[TariffPlanResponse])
async def list_tariffs(db: AsyncSession = Depends(get_db)):
"""Return all active tariff plans (public, no auth required)."""
service = TariffService(db)
plans = await service.list_plans(active_only=True)
return [
TariffPlanResponse(
id=str(p.id),
name=p.name,
code=p.code,
description=p.description,
price_monthly=p.price_monthly,
price_yearly=p.price_yearly,
features=p.features,
is_active=p.is_active,
sort_order=p.sort_order,
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in plans
]
+189
View File
@@ -0,0 +1,189 @@
"""Users API routes for VoIdea."""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, get_db
from app.models.user import User
from app.schemas.user import SubscriptionInfo, UserResponse, UserUpdate, VoiceSettingsResponse, VoiceSettingsUpdate
from app.services.tariff_service import TariffService
from app.services.user_service import UserService
router = APIRouter()
def _user_to_response(user: User) -> UserResponse:
return UserResponse(
id=str(user.id),
email=user.email,
display_name=user.display_name,
avatar_url=user.avatar_url,
is_active=user.is_active,
is_superuser=user.is_superuser,
oauth_provider=user.oauth_provider,
created_at=user.created_at,
updated_at=user.updated_at,
)
@router.get("/me", response_model=UserResponse)
async def get_me(user: Annotated[User, Depends(get_current_user)]):
return _user_to_response(user)
@router.patch("/me", response_model=UserResponse)
async def update_me(
body: UserUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = UserService(db)
result = await service.update_profile(
str(user.id),
display_name=body.display_name,
avatar_url=body.avatar_url,
)
if not result:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return _user_to_response(result)
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
async def delete_me(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = UserService(db)
await service.delete(str(user.id))
VOICE_PRESETS: dict[str, dict[str, Any]] = {
"standard": {
"vad_noise_threshold": 0.3,
"vad_silence_timeout_ms": 1500,
"confidence_verified": 80,
"confidence_warning": 50,
"semantic_mode": "fast",
"tts_enabled": True,
"continuous_listening": False,
},
"precise": {
"vad_noise_threshold": 0.2,
"vad_silence_timeout_ms": 2000,
"confidence_verified": 85,
"confidence_warning": 60,
"semantic_mode": "full",
"tts_enabled": True,
"continuous_listening": False,
},
"fast": {
"vad_noise_threshold": 0.4,
"vad_silence_timeout_ms": 1000,
"confidence_verified": 70,
"confidence_warning": 40,
"semantic_mode": "fast",
"tts_enabled": False,
"continuous_listening": True,
},
"quiet": {
"vad_noise_threshold": 0.6,
"vad_silence_timeout_ms": 3000,
"confidence_verified": 80,
"confidence_warning": 50,
"semantic_mode": "fast",
"tts_enabled": True,
"continuous_listening": False,
},
"expert": {
"vad_noise_threshold": 0.15,
"vad_silence_timeout_ms": 1000,
"confidence_verified": 90,
"confidence_warning": 70,
"semantic_mode": "full",
"tts_enabled": True,
"continuous_listening": True,
},
}
@router.get("/me/voice-settings", response_model=VoiceSettingsResponse)
async def get_voice_settings(
user: Annotated[User, Depends(get_current_user)],
):
tuning = user.pipeline_tuning or {}
preset = tuning.get("preset", "standard")
overrides = tuning.get("overrides", {})
return VoiceSettingsResponse(
preset=preset,
overrides=overrides,
available_presets=list(VOICE_PRESETS.keys()),
preset_values=VOICE_PRESETS.get(preset, VOICE_PRESETS["standard"]),
)
@router.patch("/me/voice-settings", response_model=VoiceSettingsResponse)
async def update_voice_settings(
body: VoiceSettingsUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select
tuning = user.pipeline_tuning or {}
if body.reset:
tuning = {"preset": "standard", "overrides": {}}
else:
if body.preset is not None:
if body.preset not in VOICE_PRESETS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown preset: {body.preset}. Available: {', '.join(VOICE_PRESETS.keys())}",
)
tuning["preset"] = body.preset
if body.overrides is not None:
existing_overrides = tuning.get("overrides", {})
existing_overrides.update(body.overrides)
tuning["overrides"] = existing_overrides
result = await db.execute(select(User).where(User.id == user.id))
db_user = result.scalar_one()
db_user.pipeline_tuning = tuning
await db.commit()
preset = tuning.get("preset", "standard")
preset_vals = VOICE_PRESETS.get(preset, VOICE_PRESETS["standard"])
overrides = tuning.get("overrides", {})
merged = {**preset_vals, **overrides}
return VoiceSettingsResponse(
preset=preset,
overrides=overrides,
available_presets=list(VOICE_PRESETS.keys()),
preset_values=merged,
)
@router.get("/me/subscription", response_model=SubscriptionInfo)
async def get_my_subscription(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = TariffService(db)
sub = await svc.get_user_subscription(user.id)
if not sub:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subscription not found"
)
plan = await svc.get_plan_by_id(sub.plan_id)
return SubscriptionInfo(
plan_name=plan.name if plan else "Бесплатно",
plan_code=plan.code if plan else "free",
status=sub.status,
expires_at=sub.current_period_end,
features=plan.features if plan else None,
)
+290
View File
@@ -0,0 +1,290 @@
"""Voice transcription and chat API routes for VoIdeaAI."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession
import asyncio
from app.agents.conductor_agent import ConductorAgent
from app.agents.conductor_storage import rate_interaction, get_session_history
from app.agents.role_agents import ALL_ROLE_AGENTS
from app.core.dependencies import get_current_user, get_db
from app.models.conductor import ConductorInteraction
from app.models.user import User
from app.schemas.voice import (
ChatRequest,
ChatResponse,
CommandCreate,
CommandResponse,
CreateSessionRequest,
RateRequest,
SaveIdeaRequest,
SaveIdeaResponse,
SessionResponse,
)
from app.services.command_service import (
create_command,
delete_command,
list_commands,
)
from app.services.idea_service import IdeaService
from app.services.session_service import (
create_session,
delete_session,
get_session,
list_sessions,
update_session_idea,
)
from app.services.punctuation_service import restore_punctuation
from app.services.whisper_service import transcribe
router = APIRouter()
_conductor = ConductorAgent()
@router.get("/stream/{interaction_id}")
async def stream_response(
interaction_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
"""SSE endpoint that streams the response text for a given interaction in chunks."""
from sqlalchemy import select
result = await db.execute(
select(ConductorInteraction).where(ConductorInteraction.id == interaction_id)
)
interaction = result.scalar_one_or_none()
if not interaction:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Interaction not found")
if str(interaction.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
response_text = interaction.response_text or ""
from fastapi.responses import StreamingResponse
async def event_stream():
chunk_size = 50
for i in range(0, len(response_text), chunk_size):
chunk = response_text[i:i + chunk_size]
yield f"data: {chunk}\n\n"
await asyncio.sleep(0.02)
yield "data: [DONE]\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/transcribe")
async def transcribe_audio(file: UploadFile):
audio_data = await file.read()
if not audio_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Empty audio file",
)
text = await transcribe(audio_data, file.filename or "audio.webm")
if not text:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Transcription failed. Check AI provider API key.",
)
text = restore_punctuation(text)
return {"text": text}
@router.post("/chat", response_model=ChatResponse)
async def chat(
body: ChatRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
result = await _conductor.process(
user_input=body.text,
db=db,
user_id=str(user.id),
session_id=body.session_id,
vad_enabled=body.vad_enabled,
wake_word_detected=body.wake_word_detected,
audio_duration_ms=body.audio_duration_ms,
pipeline_mode=body.pipeline_mode,
)
return ChatResponse(**result)
@router.get("/sessions", response_model=list[SessionResponse])
async def list_user_sessions(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
status_filter: str | None = None,
):
sessions = await list_sessions(db, str(user.id), status=status_filter)
return [
SessionResponse(
id=str(s.id),
title=s.title,
status=s.status,
idea_id=str(s.idea_id) if s.idea_id else None,
created_at=s.created_at,
updated_at=s.updated_at,
)
for s in sessions
]
@router.get("/sessions/{session_id}", response_model=SessionResponse)
async def get_session_detail(
session_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
session = await get_session(db, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if str(session.user_id) != str(user.id):
raise HTTPException(status_code=403, detail="Access denied")
return SessionResponse(
id=str(session.id),
title=session.title,
status=session.status,
idea_id=str(session.idea_id) if session.idea_id else None,
created_at=session.created_at,
updated_at=session.updated_at,
)
@router.get("/sessions/{session_id}/history")
async def get_session_history_endpoint(
session_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
session = await get_session(db, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if str(session.user_id) != str(user.id):
raise HTTPException(status_code=403, detail="Access denied")
return await get_session_history(db, session_id)
@router.delete("/sessions/{session_id}")
async def delete_user_session(
session_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
session = await get_session(db, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if str(session.user_id) != str(user.id):
raise HTTPException(status_code=403, detail="Access denied")
ok = await delete_session(db, session_id)
if not ok:
raise HTTPException(status_code=404, detail="Session not found")
return {"status": "ok"}
@router.post("/rate")
async def rate(
body: RateRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
success = await rate_interaction(db, body.interaction_id, body.rating)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Interaction not found",
)
return {"status": "ok"}
@router.post("/save-idea", response_model=SaveIdeaResponse)
async def save_idea(
body: SaveIdeaRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
session = await get_session(db, body.session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if str(session.user_id) != str(user.id):
raise HTTPException(status_code=403, detail="Access denied")
history = await get_session_history(db, body.session_id)
title = session.title or "Новое обсуждение"
dialogue_lines = [
f"Пользователь: {h['input']}\n{ h['agent']}: {h['response']}"
for h in history
]
content = "\n\n".join(dialogue_lines) if dialogue_lines else title
idea_service = IdeaService(db)
tags = ["voice", "ai-assisted"]
idea = await idea_service.create(
user_id=str(user.id),
title=title[:255],
content=content,
tags=tags,
is_public=False,
)
ok = await update_session_idea(db, body.session_id, str(idea.id))
return SaveIdeaResponse(
idea_id=str(idea.id),
title=idea.title,
exported=False,
)
@router.get("/commands", response_model=list[CommandResponse])
async def list_user_commands(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
return await list_commands(db, str(user.id))
@router.post("/commands", response_model=CommandResponse)
async def create_user_command(
body: CommandCreate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
return await create_command(
db, str(user.id), body.phrase, body.action, body.agent_name
)
@router.delete("/commands/{command_id}")
async def delete_user_command(
command_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
ok = await delete_command(db, command_id, str(user.id))
if not ok:
raise HTTPException(status_code=404, detail="Command not found")
return {"status": "ok"}
@router.get("/agents", response_model=list[dict])
async def list_role_agents():
return [
{"name": a.name, "description": a.description}
for a in ALL_ROLE_AGENTS
]
+16
View File
@@ -0,0 +1,16 @@
# core Module - VoIdea
## Overview
[Auto-generated documentation]
## Files
| File | Purpose |
|------|---------|
| `base.py` | Base classes |
| `config.py` | Configuration management |
| `database.py` | Database setup |
| `dependencies.py` | FastAPI dependencies |
| `exceptions.py` | Custom exceptions |
| `security.py` | Security utilities |
+28
View File
@@ -0,0 +1,28 @@
"""VoIdea - Core module."""
from app.core.config import settings
from app.core.base import BaseModel, BaseService
from app.core.exceptions import (
AppError,
NotFoundError,
PermissionError,
ValidationError,
)
from app.core.security import (
create_access_token,
verify_password,
get_password_hash,
)
__all__ = [
"settings",
"BaseModel",
"BaseService",
"AppError",
"NotFoundError",
"PermissionError",
"ValidationError",
"create_access_token",
"verify_password",
"get_password_hash",
]
+97
View File
@@ -0,0 +1,97 @@
"""Base classes for VoIdea models and services."""
from datetime import datetime
from typing import Any, Generic, TypeVar
from uuid import UUID, uuid4
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
T = TypeVar("T")
class SQLBase(DeclarativeBase):
"""Base class for all SQLAlchemy models."""
type_annotation_map = {
datetime: DateTime(timezone=True),
}
class CoreModel(BaseModel):
"""Base Pydantic model with common fields."""
model_config = ConfigDict(
from_attributes=True,
populate_by_name=True,
)
class TimestampMixin:
"""Mixin for timestamp fields."""
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)
class UUIDMixin:
"""Mixin for UUID primary key."""
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
class BaseRepository(Generic[T]):
"""Base repository for data access."""
model: type[T]
def __init__(self, session: Any):
self.session = session
async def get_by_id(self, id: UUID) -> T | None:
"""Get entity by ID."""
return await self.session.get(self.model, id)
async def get_all(self, limit: int = 100, offset: int = 0) -> list[T]:
"""Get all entities with pagination."""
result = await self.session.execute(
select(self.model).limit(limit).offset(offset)
)
return list(result.scalars().all())
async def create(self, **kwargs: Any) -> T:
"""Create new entity."""
entity = self.model(**kwargs)
self.session.add(entity)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def update(self, entity: T, **kwargs: Any) -> T:
"""Update entity."""
for key, value in kwargs.items():
setattr(entity, key, value)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def delete(self, entity: T) -> None:
"""Delete entity."""
await self.session.delete(entity)
await self.session.commit()
class BaseService:
"""Base service class."""
def __init__(self, repository: BaseRepository):
self.repository = repository
+204
View File
@@ -0,0 +1,204 @@
from functools import lru_cache
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
def _read_version() -> str:
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION"
try:
return version_file.read_text(encoding="utf-8").strip()
except (FileNotFoundError, OSError):
return "1.0.0"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Project
project_name: str = Field(default="VoIdeaAI", description="Project name")
project_version: str = Field(default_factory=_read_version, description="Project version (from VERSION file)")
project_env: str = Field(default="local", description="Environment")
project_owner: str = Field(default="Owner", description="Project owner name")
project_license: str = Field(default="AGPL-3.0", description="License type")
# Server
server_host: str = Field(default="0.0.0.0", description="Server host")
server_port: int = Field(default=8020, description="Server port")
server_external_url: str = Field(default="http://localhost:8020", description="External URL")
# Database
database_url: str = Field(
default="postgresql+asyncpg://voidea:password@localhost:5432/voidea",
description="Database URL (async, PostgreSQL with asyncpg)",
)
db_echo: bool = Field(default=False, description="Echo SQL queries")
@property
def sync_database_url(self) -> str:
return self.database_url.replace("+asyncpg", "")
# Redis
redis_host: str = Field(default="localhost", description="Redis host")
redis_port: int = Field(default=6379, description="Redis port")
redis_url: str = Field(default="redis://localhost:6379/0", description="Redis URL")
# JWT
jwt_secret_key: str = Field(default="", description="JWT secret key")
jwt_reset_secret_key: str = Field(default="", description="JWT secret for password reset tokens (separate from access)")
jwt_algorithm: str = Field(default="HS256", description="JWT algorithm")
jwt_access_token_expire_minutes: int = Field(default=60, description="Access token TTL")
jwt_refresh_token_expire_days: int = Field(default=30, description="Refresh token TTL")
# AI Providers
openai_api_key: str = Field(default="", description="OpenAI API key (Whisper, GPT)")
ai_yandex_key: str = Field(default="", description="Yandex GPT API key or IAM token")
ai_yandex_url: str = Field(default="https://llm.api.cloud.yandex.net", description="Yandex API URL")
ai_yandex_folder_id: str = Field(default="", description="Yandex Cloud folder ID")
ai_gigachat_client_id: str = Field(default="", description="GigaChat OAuth client ID")
ai_gigachat_secret: str = Field(default="", description="GigaChat OAuth client secret")
ai_gigachat_url: str = Field(default="https://gigachat.devices.sber.ru", description="GigaChat API URL")
ai_fallback_model: str = Field(default="yandex_gpt", description="Default AI model")
ai_timeout: int = Field(default=10, description="AI request timeout")
ai_max_retries: int = Field(default=3, description="Max retries for AI requests")
# OAuth
oauth_yandex_id: str = Field(default="", description="Yandex OAuth client ID")
oauth_yandex_secret: str = Field(default="", description="Yandex OAuth client secret")
oauth_yandex_redirect_uri: str = Field(default="http://localhost:3000/oauth/callback")
oauth_google_id: str = Field(default="", description="Google OAuth client ID")
oauth_google_secret: str = Field(default="", description="Google OAuth client secret")
oauth_google_redirect_uri: str = Field(default="http://localhost:8020/auth/google/callback")
oauth_apple_id: str = Field(default="", description="Apple OAuth client ID")
oauth_apple_secret: str = Field(default="", description="Apple OAuth client secret")
oauth_apple_redirect_uri: str = Field(default="http://localhost:8020/auth/apple/callback")
@property
def google_oauth_enabled(self) -> bool:
return bool(self.oauth_google_id)
@property
def apple_oauth_enabled(self) -> bool:
return bool(self.oauth_apple_id)
# Telegram Bot
telegram_bot_token: str = Field(default="", description="Telegram bot token")
# Email (SMTP)
smtp_host: str = Field(default="", description="SMTP server host")
smtp_port: int = Field(default=587, description="SMTP server port")
smtp_user: str = Field(default="", description="SMTP username")
smtp_pass: str = Field(default="", description="SMTP password")
smtp_from: str = Field(default="VoIdea <notifications@voidea.ru>")
smtp_tls: bool = Field(default=True)
# Security
enable_2fa: bool = Field(default=False)
accepted_terms_version: str = Field(
default="2026-05-11",
description="Current version of Terms of Service / Privacy Policy",
)
# System Owner
system_owner_email: str = Field(
default="",
description="Email of the system owner (set on VPS deploy, protected from deletion/suspension)",
)
# Social Networks (empty = hidden)
social_telegram: str = Field(default="voideaai", description="Telegram account name")
social_vk: str = Field(default="voideaai", description="VK account name")
social_youtube: str = Field(default="voideaai", description="YouTube account name")
social_tiktok: str = Field(default="voideaai", description="TikTok account name")
project_slogan: str = Field(
default="VoIdeaAI — идеи рождаются вслух, решения приходят мгновенно!",
description="Project slogan",
)
# Analytics (empty = disabled)
yandex_metrika_id: str = Field(default="", description="Yandex Metrika counter ID")
google_analytics_id: str = Field(default="", description="Google Analytics tracking ID")
# Tariffs
tariffs_enabled: bool = Field(default=False, description="Enable tariff system (false = promo free-for-all)")
tariffs_free_code: str = Field(default="free", description="Code of the default free tariff plan")
# Push Notifications (mobile-ready stubs)
fcm_server_key: str = Field(default="", description="Firebase Cloud Messaging server key (mobile push)")
apns_key_id: str = Field(default="", description="Apple Push Notification Service key ID (iOS push)")
# Logging
log_level: str = Field(default="INFO")
log_format: str = Field(default="json")
log_file_path: str = Field(default="logs/app.log")
log_max_bytes: int = Field(default=10485760)
log_backup_count: int = Field(default=5)
# CORS
cors_origins: str = Field(default="http://localhost:3000,http://localhost:8020")
@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",")]
# Celery
celery_broker_url: str = Field(default="redis://localhost:6379/0")
celery_result_backend: str = Field(default="redis://localhost:6379/0")
celery_task_track_started: bool = Field(default=True)
celery_task_time_limit: int = Field(default=300)
# Observer Agent
observer_enabled: bool = Field(default=True)
observer_sample_rate: float = Field(default=0.1)
observer_store_raw_data: bool = Field(default=False)
# Rate Limiting
rate_limit_enabled: bool = Field(default=True, description="Enable rate limiting")
rate_limit_default: str = Field(default="60/minute", description="Default rate limit")
rate_limit_auth: str = Field(default="10/minute", description="Auth endpoints rate limit")
# Crypto
encryption_key: str = Field(default="", description="AES-256 encryption key (Fernet)")
# Development
debug: bool = Field(default=False)
reload: bool = Field(default=True)
def is_production(self) -> bool:
return self.project_env == "production"
def is_development(self) -> bool:
return self.project_env in ("development", "local")
@property
def public_config(self) -> dict:
"""Non-sensitive settings exposed via GET /api/v1/config/public."""
return {
"project_name": self.project_name,
"project_version": self.project_version,
"project_env": self.project_env,
"project_slogan": self.project_slogan,
"social_telegram": self.social_telegram,
"social_vk": self.social_vk,
"social_youtube": self.social_youtube,
"social_tiktok": self.social_tiktok,
"yandex_metrika_id": self.yandex_metrika_id,
"google_analytics_id": self.google_analytics_id,
"tariffs_enabled": self.tariffs_enabled,
"tariffs_free_code": self.tariffs_free_code,
"accepted_terms_version": self.accepted_terms_version,
}
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+46
View File
@@ -0,0 +1,46 @@
"""Database configuration and session management for VoIdea."""
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.core.config import get_settings
settings = get_settings()
engine = create_async_engine(
settings.database_url,
echo=settings.db_echo,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Get database session.
Yields:
AsyncSession instance
"""
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+175
View File
@@ -0,0 +1,175 @@
"""FastAPI dependencies for VoIdea.
Common dependencies used across API endpoints.
"""
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.database import async_session_maker
from app.core.security import decode_token
from app.models.user import User
settings = get_settings()
security = HTTPBearer()
async def get_db() -> AsyncSession:
"""Get database session."""
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
) -> User:
"""Get current authenticated user from JWT token.
Args:
credentials: Bearer token credentials
Returns:
User model instance
Raises:
HTTPException: If token invalid or expired
"""
try:
payload = decode_token(credentials.credentials)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token missing user ID",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
async with async_session_maker() as db:
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is disabled",
)
return user
async def require_admin(
user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Require admin or owner role."""
if not user.is_superuser and not user.is_owner:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user
async def require_owner(
user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Require owner role (system-level operations)."""
if not user.is_owner:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Owner access required",
)
return user
def require_permission(perm_name: str):
"""Factory: require a specific moderator permission or admin/owner.
Usage:
@router.get("/admin/feedback")
async def list_feedback(
user: Annotated[User, Depends(require_permission("can_manage_feedback"))],
):
...
"""
async def _check(
user: Annotated[User, Depends(get_current_user)],
) -> User:
if user.is_owner or user.role == "admin":
return user
if user.role == "moderator" and user.permissions:
if user.permissions.get(perm_name, False):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission '{perm_name}' required",
)
return _check
async def get_optional_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(HTTPBearer(auto_error=False))],
) -> User | None:
"""Get current user if authenticated, None otherwise."""
if credentials is None:
return None
try:
payload = decode_token(credentials.credentials)
if payload is None or payload.get("type") != "access":
return None
user_id = payload.get("sub")
if user_id is None:
return None
except JWTError:
return None
async with async_session_maker() as db:
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user and user.is_active:
return user
return None
+138
View File
@@ -0,0 +1,138 @@
"""Custom exceptions for VoIdea application."""
class AppError(Exception):
"""Base application exception.
All business errors inherit from this class.
Does not contain HTTP status codes (those are in API layer).
"""
def __init__(
self,
message: str,
code: str = "GENERAL_ERROR",
details: dict | None = None,
) -> None:
self.message = message
self.code = code
self.details = details or {}
super().__init__(message)
def to_dict(self) -> dict:
"""Convert exception to dictionary."""
return {
"error": self.code,
"message": self.message,
"details": self.details,
}
class NotFoundError(AppError):
"""Resource not found error."""
def __init__(
self,
message: str = "Resource not found",
code: str = "NOT_FOUND",
resource: str | None = None,
resource_id: str | None = None,
) -> None:
details = {}
if resource:
details["resource"] = resource
if resource_id:
details["resource_id"] = resource_id
super().__init__(message, code, details)
class PermissionError(AppError):
"""Permission denied error."""
def __init__(
self,
message: str = "Permission denied",
code: str = "PERMISSION_DENIED",
) -> None:
super().__init__(message, code)
class ValidationError(AppError):
"""Validation error."""
def __init__(
self,
message: str = "Validation error",
code: str = "VALIDATION_ERROR",
field: str | None = None,
value: Any = None,
) -> None:
details = {}
if field:
details["field"] = field
if value is not None:
details["value"] = str(value)
super().__init__(message, code, details)
class AuthenticationError(AppError):
"""Authentication error."""
def __init__(
self,
message: str = "Authentication failed",
code: str = "AUTH_FAILED",
) -> None:
super().__init__(message, code)
class QuotaExceededError(AppError):
"""Quota exceeded error."""
def __init__(
self,
message: str = "Quota exceeded",
code: str = "QUOTA_EXCEEDED",
limit: int | None = None,
used: int | None = None,
) -> None:
details = {}
if limit is not None:
details["limit"] = limit
if used is not None:
details["used"] = used
super().__init__(message, code, details)
class ExternalServiceError(AppError):
"""External service error (AI, OAuth, etc.)."""
def __init__(
self,
message: str = "External service error",
code: str = "EXTERNAL_SERVICE_ERROR",
service: str | None = None,
) -> None:
details = {}
if service:
details["service"] = service
super().__init__(message, code, details)
class RateLimitError(AppError):
"""Rate limit exceeded error."""
def __init__(
self,
message: str = "Rate limit exceeded",
code: str = "RATE_LIMIT_EXCEEDED",
retry_after: int | None = None,
) -> None:
details = {}
if retry_after:
details["retry_after"] = retry_after
super().__init__(message, code, details)
# Import for type hints
from typing import Any
+93
View File
@@ -0,0 +1,93 @@
"""Feature gate module for VoIdea tariff-based access control.
Usage:
features = await get_user_features(user, db)
if not features.get("has_voice"):
raise HTTPException(403, "Feature not available on your plan")
"""
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.models.tariff import TariffPlan, UserSubscription
from app.models.user import User
settings = get_settings()
DEFAULT_FREE_FEATURES: dict[str, Any] = {
"limit_ideas": None, # None = unlimited
"limit_sessions_per_day": None,
"limit_agents": None,
"limit_team_members": None,
"limit_storage_mb": 100,
"has_voice": True,
"has_voice_commands": True,
"has_drive": True,
"has_advanced_analytics": True,
"has_api_access": True,
"has_priority_support": True,
"has_custom_branding": True,
"_promo": True,
}
async def get_user_features(
user: User, db: AsyncSession
) -> dict[str, Any]:
"""Get feature flags and limits for a user based on their tariff plan.
When tariffs_enabled=False (promo mode), returns all features as available.
"""
if not settings.tariffs_enabled:
return dict(DEFAULT_FREE_FEATURES)
result = await db.execute(
select(UserSubscription).where(UserSubscription.user_id == user.id)
)
sub = result.scalar_one_or_none()
if sub is None or sub.status != "active":
return _get_plan_features(db, settings.tariffs_free_code)
result = await db.execute(
select(TariffPlan).where(TariffPlan.id == sub.plan_id)
)
plan = result.scalar_one_or_none()
if plan is None or not plan.is_active:
return _get_plan_features(db, settings.tariffs_free_code)
return plan.features or dict(DEFAULT_FREE_FEATURES)
async def _get_plan_features(
db: AsyncSession, code: str
) -> dict[str, Any]:
"""Get features dict for a plan by code, falling back to defaults."""
result = await db.execute(
select(TariffPlan).where(
TariffPlan.code == code, TariffPlan.is_active.is_(True)
)
)
plan = result.scalar_one_or_none()
if plan and plan.features:
return plan.features
return dict(DEFAULT_FREE_FEATURES)
def check_feature(features: dict[str, Any], key: str) -> bool:
"""Check if a boolean feature is enabled."""
return bool(features.get(key, False))
def check_limit(
features: dict[str, Any], key: str, current: int = 0
) -> bool:
"""Check if a numeric limit is not exceeded (None = unlimited)."""
limit = features.get(key)
if limit is None:
return True
return current < int(limit)
+6
View File
@@ -0,0 +1,6 @@
"""Shared rate limiter instance for VoIdeaAI."""
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
+48
View File
@@ -0,0 +1,48 @@
"""HTTP request logging middleware for debug mode."""
import logging
import time
from datetime import datetime, timezone
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
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.http")
class DebugLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start = time.time()
response: Response = await call_next(request)
elapsed_ms = int((time.time() - start) * 1000)
path = request.url.path
# Skip health check and static noise
if path in ("/health", "/api/v1/health") or path.startswith("/assets/") or path.startswith("/icons/") or path == "/":
return response
try:
async with async_session_maker() as db:
svc = DebugService(db)
if await svc.is_debug_mode():
log = LogEntry(
level="DEBUG",
source="http",
message=f"{request.method} {path}{response.status_code} ({elapsed_ms}ms)",
details=None,
created_at=datetime.now(timezone.utc),
)
db.add(log)
await db.commit()
except Exception:
pass
return response
+28
View File
@@ -0,0 +1,28 @@
"""Security middleware for VoIdeaAI.
Adds security headers to all HTTP responses.
"""
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response: Response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"connect-src 'self' https://*.openai.com https://llm.api.cloud.yandex.net https://cloud-api.yandex.net; "
"media-src 'self' blob:; "
"img-src 'self' data: https://avatars.yandex.net"
)
return response
+168
View File
@@ -0,0 +1,168 @@
"""Security utilities for VoIdea.
JWT handling, password hashing, and authentication helpers.
"""
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def get_password_hash(password: str) -> str:
"""Hash password using bcrypt."""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify password against hash."""
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(
data: dict[str, Any],
expires_delta: timedelta | None = None,
) -> str:
"""Create JWT access token.
Args:
data: Payload data to encode in token
expires_delta: Optional custom expiration time
Returns:
Encoded JWT token string
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.jwt_access_token_expire_minutes
)
to_encode.update({
"exp": expire,
"iat": datetime.now(timezone.utc),
"type": "access",
})
encoded_jwt = jwt.encode(
to_encode,
settings.jwt_secret_key,
algorithm=settings.jwt_algorithm,
)
return encoded_jwt
def create_refresh_token(
data: dict[str, Any],
expires_delta: timedelta | None = None,
) -> str:
"""Create JWT refresh token.
Args:
data: Payload data to encode in token
expires_delta: Optional custom expiration time
Returns:
Encoded JWT refresh token string
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
days=settings.jwt_refresh_token_expire_days
)
to_encode.update({
"exp": expire,
"iat": datetime.now(timezone.utc),
"type": "refresh",
})
encoded_jwt = jwt.encode(
to_encode,
settings.jwt_secret_key,
algorithm=settings.jwt_algorithm,
)
return encoded_jwt
def create_reset_token(data: dict[str, Any]) -> str:
"""Create JWT password reset token (1 hour expiry, separate key).
Args:
data: Payload data to encode in token
Returns:
Encoded JWT reset token string
"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(hours=1)
to_encode.update({
"exp": expire,
"iat": datetime.now(timezone.utc),
"type": "reset",
})
secret = settings.jwt_reset_secret_key or settings.jwt_secret_key
return jwt.encode(to_encode, secret, algorithm=settings.jwt_algorithm)
def decode_token(
token: str,
secret: str | None = None,
) -> dict[str, Any] | None:
"""Decode and verify JWT token.
Args:
token: JWT token string
secret: Optional secret key (defaults to jwt_secret_key)
Returns:
Decoded payload or None if invalid
"""
try:
payload = jwt.decode(
token,
secret or settings.jwt_secret_key,
algorithms=[settings.jwt_algorithm],
)
return payload
except JWTError:
return None
def decode_reset_token(token: str) -> dict[str, Any] | None:
"""Decode and verify password reset JWT token (uses reset secret key).
Args:
token: JWT reset token string
Returns:
Decoded payload or None if invalid
"""
secret = settings.jwt_reset_secret_key or settings.jwt_secret_key
return decode_token(token, secret)
def verify_token_type(token_data: dict[str, Any], expected_type: str) -> bool:
"""Verify token type.
Args:
token_data: Decoded token payload
expected_type: Expected token type (access/refresh/reset)
Returns:
True if token type matches
"""
return token_data.get("type") == expected_type
+126
View File
@@ -0,0 +1,126 @@
"""Seed data for VoIdeaAI.
Called from app lifespan when DB tables are empty.
Creates default tariff plans and sets system owner by email.
"""
import json
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.models.agent import AgentConfig
from app.models.tariff import TariffPlan
from app.services.user_service import UserService
settings = get_settings()
_AGENT_VERSIONS_FILE = Path(__file__).resolve().parent.parent.parent / "AGENT_VERSIONS.json"
def _load_agent_versions() -> dict[str, str]:
try:
return json.loads(_AGENT_VERSIONS_FILE.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
AGENT_DESCRIPTIONS: dict[str, str] = {
"conductor": "Дирижёр — оркестратор, единственная точка входа. Маршрутизирует запросы к ролевым агентам, верифицирует ответы.",
"business_analyst": "Бизнес-аналитик — оценивает бизнес-метрики, ROI, конкурентную среду, окупаемость идеи.",
"task_organizer": "Организатор задач — разбивает идею на конкретные шаги реализации с оценкой сроков.",
"lawyer": "Юрист — проверяет идею на соответствие законодательству РФ (44-ФЗ, 152-ФЗ, 223-ФЗ).",
"financial_consultant": "Финансовый консультант — бюджет, прогноз выручки, точка безубыточности.",
"solution_architect": "Архитектор решений — предлагает 2 варианта архитектуры: монолит и микросервисы.",
"tester": "Тестировщик — описывает сценарии тестирования (позитивные и негативные кейсы).",
"ui_designer": "UI-дизайнер — предлагает 2 варианта визуального дизайна интерфейса.",
"smm_specialist": "SMM-специалист — разрабатывает контент-план для соцсетей.",
"life_coach": "Лайф-коуч — ставит SMART-цели, поквартальные вехи развития.",
"accessibility_expert": "Эксперт по доступности — проверяет на WCAG 2.1 AA соответствие.",
"critic": "Критик — конструктивная критика, поиск «слепых зон» идеи.",
"copywriter": "Копирайтер — упаковывает идею в продающий текст для разных аудиторий.",
"keeper": "Хранитель — сохраняет проработанную идею в базу данных.",
"doc_agent": "DocAgent — автоматическая генерация и обновление документации.",
"backlog_agent": "BacklogAgent — управление бэклогом, приоритизация задач.",
"spec_agent": "SpecAgent — написание формальных спецификаций и требований.",
"audit_agent": "AuditAgent — аудит кода, архитектуры, безопасности.",
"observer_agent": "ObserverAgent — мониторинг системы, сбор метрик, алерты.",
"evolution_agent": "EvolutionAgent — автоматическое улучшение кода, рефакторинг.",
"security_agent": "SecurityAgent — проверка безопасности, уязвимости, OWASP.",
"qa_tester_agent": "QATesterAgent — автоматизированное тестирование, регресс.",
"fix_agent": "FixAgent — автоматическое исправление ошибок на основе логов.",
"ui_test_agent": "UITestAgent — тестирование UI/UX, скриншотные тесты.",
"rollout_agent": "RolloutAgent — управление релизами, миграции, откаты.",
}
async def seed_database(db: AsyncSession) -> None:
"""Seed initial data if tables are empty."""
await _seed_tariff_plans(db)
await _seed_agent_descriptions(db)
await _seed_owner(db)
async def _seed_tariff_plans(db: AsyncSession) -> None:
"""Create default Free tariff plan if none exist."""
result = await db.execute(select(TariffPlan).limit(1))
if result.scalar_one_or_none():
return
free_plan = TariffPlan(
name="Бесплатно",
code="free",
description="Базовый доступ ко всем функциям. Сейчас всё бесплатно 🎉",
price_monthly=0,
features={
"limit_ideas": None,
"limit_sessions_per_day": None,
"limit_agents": None,
"limit_team_members": None,
"limit_storage_mb": 100,
"has_voice": True,
"has_voice_commands": True,
"has_drive": True,
"has_advanced_analytics": True,
"has_api_access": True,
"has_priority_support": True,
"has_custom_branding": True,
"_promo": True,
},
is_active=True,
sort_order=0,
)
db.add(free_plan)
await db.commit()
async def _seed_agent_descriptions(db: AsyncSession) -> None:
"""Fill empty agent descriptions and update versions from AGENT_VERSIONS.json."""
agent_versions = _load_agent_versions()
result = await db.execute(select(AgentConfig))
agents = result.scalars().all()
updated = False
for agent in agents:
name = agent.agent_name.lower()
if name in AGENT_DESCRIPTIONS and (not agent.description or agent.description == ""):
agent.description = AGENT_DESCRIPTIONS[name]
updated = True
name_lower = agent.agent_name.lower()
expected_version = agent_versions.get(name_lower) or agent_versions.get(agent.agent_name)
if expected_version and agent.version != expected_version:
agent.version = expected_version
updated = True
if updated:
await db.commit()
async def _seed_owner(db: AsyncSession) -> None:
"""Set system owner by SYSTEM_OWNER_EMAIL if configured."""
if not settings.system_owner_email:
return
svc = UserService(db)
await svc.set_owner_by_email(settings.system_owner_email)
+27
View File
@@ -0,0 +1,27 @@
/* Auto-generated from design tokens — do not edit manually */
:root {
--color-primary-50: #EEF2FF;
--color-primary-500: #6366F1;
--color-primary-600: #4F46E5;
--color-primary: #6366F1;
--color-primary-hover: #4F46E5;
--color-background-dark: #0F172A;
--color-background-light: #FFFFFF;
--color-text-primary: {'system': 'auto', 'dark': '#F8FAFC', 'light': '#0F172A'};
--color-semantic-error: #EF4444;
--color-semantic-warning: #F59E0B;
--color-semantic-success: #22C55E;
--color-semantic-info: #3B82F6;
--font-primary: Inter, system-ui, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
}
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" ?>
<resources>
<color name="primary_50">#FFEEF2FF</color>
<color name="primary_500">#FF6366F1</color>
<color name="primary_600">#FF4F46E5</color>
<color name="primary">#FF6366F1</color>
<color name="primary">#FF4F46E5</color>
<color name="background_dark">#FF0F172A</color>
<color name="background_light">#FFFFFFFF</color>
<color name="semantic_error">#FFEF4444</color>
<color name="semantic_warning">#FFF59E0B</color>
<color name="semantic_success">#FF22C55E</color>
<color name="semantic_info">#FF3B82F6</color>
<dimen name="spacing_xs">4dp</dimen>
<dimen name="spacing_sm">8dp</dimen>
<dimen name="spacing_md">16dp</dimen>
<dimen name="spacing_lg">24dp</dimen>
</resources>
+20
View File
@@ -0,0 +1,20 @@
// Auto-generated from design tokens do not edit manually
import SwiftUI
extension Color {
static let primary50 = Color(red: 0.9333, green: 0.9490, blue: 1.0000)
static let primary500 = Color(red: 0.3882, green: 0.4000, blue: 0.9451)
static let primary600 = Color(red: 0.3098, green: 0.2745, blue: 0.8980)
static let primary = Color(red: 0.3882, green: 0.4000, blue: 0.9451)
static let primary = Color(red: 0.3098, green: 0.2745, blue: 0.8980)
static let backgroundDark = Color(red: 0.0588, green: 0.0902, blue: 0.1647)
static let backgroundLight = Color(red: 1.0000, green: 1.0000, blue: 1.0000)
static let semanticError = Color(red: 0.9373, green: 0.2667, blue: 0.2667)
static let semanticWarning = Color(red: 0.9608, green: 0.6196, blue: 0.0431)
static let semanticSuccess = Color(red: 0.1333, green: 0.7725, blue: 0.3686)
static let semanticInfo = Color(red: 0.2314, green: 0.5098, blue: 0.9647)
static let spacingXs = CGFloat(0.25)
static let spacingSm = CGFloat(0.5)
static let spacingMd = CGFloat(1)
static let spacingLg = CGFloat(1.5)
}
+16
View File
@@ -0,0 +1,16 @@
/* VoIdeaAI Design Tokens — auto-generated */
/* Source: docs/design-system/tokens.json */
:root {
--colors-background: #FFFFFF;
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--border_radius-sm: 0.25rem;
--border_radius-md: 0.5rem;
--border_radius-lg: 0.75rem;
}
.dark {
--colors-background: #0F172A;
}
+71
View File
@@ -0,0 +1,71 @@
"""Design tokens CSS generator for VoIdeaAI.
Reads docs/design-system/tokens.json and generates:
- app/design-tokens/css/tokens.css
"""
import json
from pathlib import Path
TOKENS_PATH = Path(__file__).parent.parent.parent / "docs" / "design-system" / "tokens.json"
OUTPUT_PATH = Path(__file__).parent / "css" / "tokens.css"
def load_tokens() -> dict:
if not TOKENS_PATH.exists():
return {}
return json.loads(TOKENS_PATH.read_text(encoding="utf-8-sig"))
def generate_css(tokens: dict) -> str:
lines = [
"/* VoIdeaAI Design Tokens — auto-generated */",
"/* Source: docs/design-system/tokens.json */",
":root {",
]
for category, values in tokens.items():
if not isinstance(values, dict):
continue
for key, value in values.items():
if key == "$schema":
continue
if isinstance(value, dict):
for mode, val in value.items():
if mode == "light":
lines.append(f" --{category}-{key}: {val};")
elif isinstance(value, (str, int, float)):
lines.append(f" --{category}-{key}: {value};")
lines.append("}")
lines.append("")
lines.append(".dark {")
for category, values in tokens.items():
if not isinstance(values, dict):
continue
for key, value in values.items():
if key == "$schema":
continue
if isinstance(value, dict):
for mode, val in value.items():
if mode == "dark":
lines.append(f" --{category}-{key}: {val};")
lines.append("}")
return "\n".join(lines)
def main():
tokens = load_tokens()
if not tokens:
print("tokens.json not found, skipping")
return
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
css = generate_css(tokens)
OUTPUT_PATH.write_text(css, encoding="utf-8")
print(f"Generated {OUTPUT_PATH} ({len(css)} bytes)")
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
"""VoIdea - Integrations module (AI providers, external services)."""
+14
View File
@@ -0,0 +1,14 @@
"""VoIdea - AI integration module."""
from app.integrations.ai.base import AIProvider, AIResult
from app.integrations.ai.yandex_gpt import YandexGPTProvider
from app.integrations.ai.gigachat import GigaChatProvider
from app.integrations.ai.fallback import FallbackChain
__all__ = [
"AIProvider",
"AIResult",
"YandexGPTProvider",
"GigaChatProvider",
"FallbackChain",
]
+64
View File
@@ -0,0 +1,64 @@
"""Base classes for AI provider integration."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
@dataclass
class AIResult:
"""Result from an AI provider call."""
success: bool
content: str = ""
model: str = ""
provider: str = ""
tokens_used: int = 0
duration_ms: int = 0
error: str = ""
metadata: dict[str, Any] = field(default_factory=dict)
class AIProvider(ABC):
"""Abstract base class for AI providers.
All AI providers (Yandex GPT, GigaChat, etc.) must implement this.
"""
name: str = ""
model: str = ""
timeout: int = 10
max_retries: int = 3
def __init__(self, api_key: str = "", **kwargs: Any):
self.api_key = api_key
for key, value in kwargs.items():
setattr(self, key, value)
self._initialize()
def _initialize(self) -> None:
"""Post-initialization hook for provider-specific setup."""
@abstractmethod
async def analyze(self, prompt: str, **kwargs: Any) -> AIResult:
"""Send a prompt to the AI provider and return the result.
Args:
prompt: The formatted prompt to send
**kwargs: Additional provider-specific parameters
Returns:
AIResult with the response content
"""
def format_prompt(
self, system_prompt: str, user_prompt: str, **kwargs: Any
) -> str:
"""Format a prompt with system instructions and user message.
Override in provider if the API uses separate system/user messages.
"""
return f"{system_prompt}\n\n{user_prompt}"
+99
View File
@@ -0,0 +1,99 @@
"""Fallback chain for AI providers.
Tries providers in order with retries per provider.
YandexGPT -> GigaChat -> Error
"""
import asyncio
import time
from typing import Any
from app.core.config import get_settings
from app.integrations.ai.base import AIProvider, AIResult
from app.integrations.ai.yandex_gpt import YandexGPTProvider
from app.integrations.ai.gigachat import GigaChatProvider
settings = get_settings()
class FallbackChain:
"""Chain multiple AI providers with retry logic.
Tries providers in order (YandexGPT -> GigaChat).
Each provider gets up to `max_retries` attempts before falling through.
"""
def __init__(
self,
providers: list[AIProvider] | None = None,
max_retries: int = 2,
timeout: int = 30,
):
self.providers = providers or [
YandexGPTProvider(),
GigaChatProvider(),
]
self.max_retries = max_retries
self.timeout = timeout
async def analyze(self, prompt: str, **kwargs: Any) -> AIResult:
"""Try each provider in order with retries.
Args:
prompt: The formatted prompt to send
**kwargs: Additional parameters passed to each provider
Returns:
AIResult from the first successful provider,
or the last error result if all fail.
"""
last_error: AIResult | None = None
for provider in self.providers:
for attempt in range(self.max_retries + 1):
try:
result = await asyncio.wait_for(
provider.analyze(prompt, **kwargs),
timeout=self.timeout,
)
if result.success:
await self._cleanup_providers(provider)
return result
last_error = result
except asyncio.TimeoutError:
last_error = AIResult(
success=False,
error=f"{provider.name} timeout after {self.timeout}s",
provider=provider.name,
duration_ms=int(self.timeout * 1000),
)
except Exception as e:
last_error = AIResult(
success=False,
error=f"{provider.name} error: {str(e)}",
provider=provider.name,
)
if attempt < self.max_retries:
await asyncio.sleep(1 * (attempt + 1))
await self._cleanup_providers()
return last_error or AIResult(
success=False,
error="All providers failed",
provider="fallback_chain",
)
async def _cleanup_providers(self, skip: AIProvider | None = None) -> None:
"""Close all provider clients except the one to keep."""
for provider in self.providers:
if provider is skip:
continue
if hasattr(provider, "close"):
try:
await provider.close()
except Exception:
pass
+160
View File
@@ -0,0 +1,160 @@
"""GigaChat provider for VoIdea."""
import base64
import time
from datetime import datetime, timezone
from typing import Any, Optional
import httpx
from app.core.config import get_settings
from app.integrations.ai.base import AIProvider, AIResult
settings = get_settings()
class GigaChatProvider(AIProvider):
"""GigaChat AI provider (Sber).
Uses OAuth client credentials for authentication.
Token is refreshed automatically on 401.
"""
name = "gigachat"
model = "GigaChat"
timeout = settings.ai_timeout
max_retries = settings.ai_max_retries
def __init__(self, api_key: str = ""):
super().__init__(api_key)
self.base_url = settings.ai_gigachat_url.rstrip("/")
self.client_id = settings.ai_gigachat_client_id
self.client_secret = settings.ai_gigachat_secret
self._client = httpx.AsyncClient(timeout=self.timeout)
self._access_token: Optional[str] = None
self._token_expires_at: float = 0
async def _get_access_token(self) -> Optional[str]:
"""Get or refresh GigaChat access token."""
if self._access_token and time.time() < self._token_expires_at:
return self._access_token
if not self.client_id or not self.client_secret:
return None
auth_str = base64.b64encode(
f"{self.client_id}:{self.client_secret}".encode()
).decode()
try:
response = await self._client.post(
"https://ngw.devices.sber.ru/token",
headers={
"Authorization": f"Basic {auth_str}",
"Content-Type": "application/x-www-form-urlencoded",
"RqUID": str(time.time()),
},
data={"scope": "GIGACHAT_API_PERS"},
)
if response.status_code == 200:
data = response.json()
self._access_token = data.get("access_token")
expires_in = data.get("expires_in", 1800)
self._token_expires_at = time.time() + expires_in - 60
return self._access_token
except Exception:
return None
return None
async def analyze(self, prompt: str, **kwargs: Any) -> AIResult:
"""Send prompt to GigaChat and return result."""
start = time.time()
temperature = kwargs.get("temperature", 0.7)
max_tokens = kwargs.get("max_tokens", 2000)
token = await self._get_access_token()
if not token:
return AIResult(
success=False,
error="GigaChat authentication failed",
provider=self.name,
)
parts = prompt.split("\n\n", 1)
system_text = parts[0]
user_text = parts[1] if len(parts) > 1 else ""
body = {
"model": self.model,
"temperature": temperature,
"max_tokens": max_tokens,
"messages": [
{"role": "system", "content": system_text},
{"role": "user", "content": user_text},
],
}
try:
response = await self._client.post(
f"{self.base_url}/api/v1/chat/completion",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
duration = int((time.time() - start) * 1000)
if response.status_code == 401:
self._access_token = None
token = await self._get_access_token()
if token:
response = await self._client.post(
f"{self.base_url}/api/v1/chat/completion",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
duration = int((time.time() - start) * 1000)
if response.status_code == 200:
data = response.json()
result_text = (
data.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
return AIResult(
success=True,
content=result_text,
model=self.model,
provider=self.name,
duration_ms=duration,
)
else:
error_body = response.text[:500]
return AIResult(
success=False,
error=f"GigaChat {response.status_code}: {error_body}",
provider=self.name,
duration_ms=duration,
)
except httpx.TimeoutException:
return AIResult(
success=False,
error="GigaChat timeout",
provider=self.name,
duration_ms=int((time.time() - start) * 1000),
)
except Exception as e:
return AIResult(
success=False,
error=f"GigaChat error: {str(e)}",
provider=self.name,
duration_ms=int((time.time() - start) * 1000),
)
async def close(self) -> None:
await self._client.aclose()
+90
View File
@@ -0,0 +1,90 @@
"""Prompt loader for AI agents.
Loads prompts from agent spec files or YAML configuration.
"""
import re
from pathlib import Path
from typing import Any, Optional
import yaml
AGENT_SPECS_DIR = Path("docs/specs/agents")
AGENT_PROMPTS_YAML = Path("docs/agent_prompts.yaml")
def get_agent_roles() -> list[str]:
"""Get all available agent roles."""
roles = []
if AGENT_PROMPTS_YAML.exists():
data = yaml.safe_load(AGENT_PROMPTS_YAML.read_text(encoding="utf-8"))
return list(data.keys()) if data else []
if AGENT_SPECS_DIR.exists():
for f in sorted(AGENT_SPECS_DIR.glob("*.md")):
roles.append(f.stem)
return roles
def load_prompt_from_yaml(role: str) -> Optional[dict[str, Any]]:
"""Load prompt config from YAML file."""
if not AGENT_PROMPTS_YAML.exists():
return None
data = yaml.safe_load(AGENT_PROMPTS_YAML.read_text(encoding="utf-8"))
return data.get(role) if data else None
def load_prompt_from_spec(role: str) -> Optional[dict[str, Any]]:
"""Load prompt from agent spec markdown file."""
spec_path = AGENT_SPECS_DIR / f"{role}.md"
if not spec_path.exists():
return None
content = spec_path.read_text(encoding="utf-8")
def extract_field(label: str) -> Optional[str]:
match = re.search(rf"\*\*{label}:\*\*\s*(.+)", content)
return match.group(1).strip() if match else None
def extract_prompt() -> Optional[str]:
match = re.search(
r"## Prompt Template\n+```\n(.+?)\n```",
content,
re.DOTALL,
)
return match.group(1).strip() if match else None
provider_str = extract_field("Провайдер") or extract_field("Provider") or "yandex_gpt"
provider = "gigachat" if "gigachat" in (provider_str or "").lower() else "yandex_gpt"
prompt_text = extract_prompt()
if not prompt_text:
return None
return {
"system_prompt": prompt_text,
"provider": provider,
"temperature": 0.7,
"max_tokens": 2000,
}
def get_prompt_config(role: str) -> Optional[dict[str, Any]]:
"""Get prompt configuration for a role.
Tries YAML first, falls back to spec markdown.
Args:
role: Agent role name (e.g. "coordinator", "business_analyst")
Returns:
Dict with keys: system_prompt, provider, temperature, max_tokens
or None if not found.
"""
config = load_prompt_from_yaml(role)
if config:
return config
return load_prompt_from_spec(role)
+122
View File
@@ -0,0 +1,122 @@
"""Yandex GPT provider for VoIdea."""
import time
from datetime import datetime, timezone
from typing import Any, Optional
import httpx
from app.core.config import get_settings
from app.integrations.ai.base import AIProvider, AIResult
settings = get_settings()
class YandexGPTProvider(AIProvider):
"""Yandex GPT AI provider.
Supports both API key (Api-Key header) and IAM token (Bearer header).
Automatically falls back between auth methods on 401.
"""
name = "yandex_gpt"
model = "yandexgpt/latest"
timeout = settings.ai_timeout
max_retries = settings.ai_max_retries
def __init__(self, api_key: str = ""):
super().__init__(api_key or settings.ai_yandex_key)
self.base_url = settings.ai_yandex_url.rstrip("/")
self.folder_id = settings.ai_yandex_folder_id
self._client = httpx.AsyncClient(timeout=self.timeout)
async def analyze(self, prompt: str, **kwargs: Any) -> AIResult:
"""Send prompt to Yandex GPT and return result."""
start = time.time()
temperature = kwargs.get("temperature", 0.7)
max_tokens = kwargs.get("max_tokens", 2000)
model_uri = f"gpt://{self.folder_id}/{self.model}" if self.folder_id else self.model
# Split prompt into system and user parts
parts = prompt.split("\n\n", 1)
system_text = parts[0]
user_text = parts[1] if len(parts) > 1 else ""
body = {
"modelUri": model_uri,
"completionOptions": {
"temperature": temperature,
"maxTokens": max_tokens,
},
"messages": [
{"role": "system", "text": system_text},
{"role": "user", "text": user_text},
],
}
try:
response = await self._client.post(
f"{self.base_url}/foundationModels/v1/completion",
json=body,
)
duration = int((time.time() - start) * 1000)
if response.status_code == 401:
# Try with Api-Key header instead
response = await self._client.post(
f"{self.base_url}/foundationModels/v1/completion",
json=body,
headers={"Authorization": f"Api-Key {self.api_key}"},
)
duration = int((time.time() - start) * 1000)
if response.status_code == 200:
data = response.json()
result_text = (
data.get("result", {})
.get("alternatives", [{}])[0]
.get("message", {})
.get("text", "")
)
tokens = (
data.get("result", {})
.get("usage", {})
.get("inputTextTokens", 0)
)
return AIResult(
success=True,
content=result_text,
model=self.model,
provider=self.name,
tokens_used=tokens,
duration_ms=duration,
)
else:
error_body = response.text[:500]
return AIResult(
success=False,
error=f"Yandex GPT {response.status_code}: {error_body}",
provider=self.name,
duration_ms=duration,
)
except httpx.TimeoutException:
return AIResult(
success=False,
error="Yandex GPT timeout",
provider=self.name,
duration_ms=int((time.time() - start) * 1000),
)
except Exception as e:
return AIResult(
success=False,
error=f"Yandex GPT error: {str(e)}",
provider=self.name,
duration_ms=int((time.time() - start) * 1000),
)
async def close(self) -> None:
await self._client.aclose()
+55
View File
@@ -0,0 +1,55 @@
"""OAuth integrations for VoIdeaAI."""
from app.integrations.oauth.yandex import (
get_authorize_url as yandex_authorize_url,
exchange_code as yandex_exchange_code,
get_user_info as yandex_user_info,
ensure_app_folder as yandex_ensure_folder,
upload_file as yandex_upload_file,
get_disk_info as yandex_disk_info,
YandexUserInfo,
YandexTokenResult,
VOIDEA_DISK_FOLDER,
)
from app.integrations.oauth.google import (
is_available as google_is_available,
get_authorize_url as google_authorize_url,
exchange_code as google_exchange_code,
get_user_info as google_user_info,
ensure_app_folder as google_ensure_folder,
upload_file as google_upload_file,
get_disk_info as google_disk_info,
GoogleUserInfo,
)
from app.integrations.oauth.apple import (
is_available as apple_is_available,
get_authorize_url as apple_authorize_url,
exchange_code as apple_exchange_code,
get_user_info as apple_user_info,
AppleUserInfo,
)
__all__ = [
"yandex_authorize_url",
"yandex_exchange_code",
"yandex_user_info",
"yandex_ensure_folder",
"yandex_upload_file",
"yandex_disk_info",
"YandexUserInfo",
"YandexTokenResult",
"VOIDEA_DISK_FOLDER",
"google_is_available",
"google_authorize_url",
"google_exchange_code",
"google_user_info",
"google_ensure_folder",
"google_upload_file",
"google_disk_info",
"GoogleUserInfo",
"apple_is_available",
"apple_authorize_url",
"apple_exchange_code",
"apple_user_info",
"AppleUserInfo",
]
+92
View File
@@ -0,0 +1,92 @@
"""Apple OAuth client with iCloud Drive API support.
Activated when OAUTH_APPLE_ID is set in .env.
Note: Apple requires Sign in with Apple capability and team ID configuration.
"""
from dataclasses import dataclass
import httpx
from app.core.config import get_settings
APPLE_OAUTH_URL = "https://appleid.apple.com/auth/authorize"
APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token"
SCOPES = "name email"
VOIDEA_DRIVE_FOLDER = "VoIdeaAI"
@dataclass
class AppleUserInfo:
id: str
email: str
display_name: str
def is_available() -> bool:
return get_settings().apple_oauth_enabled
async def get_authorize_url() -> str:
settings = get_settings()
params = {
"response_type": "code id_token",
"client_id": settings.oauth_apple_id,
"redirect_uri": settings.oauth_apple_redirect_uri,
"scope": SCOPES,
"response_mode": "form_post",
}
query = "&".join(f"{k}={v}" for k, v in params.items())
return f"{APPLE_OAUTH_URL}?{query}"
async def exchange_code(code: str) -> dict | None:
settings = get_settings()
if not settings.apple_oauth_enabled:
return None
async with httpx.AsyncClient() as client:
resp = await client.post(
APPLE_TOKEN_URL,
data={
"grant_type": "authorization_code",
"code": code,
"client_id": settings.oauth_apple_id,
"client_secret": settings.oauth_apple_secret,
"redirect_uri": settings.oauth_apple_redirect_uri,
},
)
if resp.status_code != 200:
return None
return resp.json()
async def get_user_info(access_token: str) -> AppleUserInfo | None:
"""Apple returns user info only in the initial authorization response,
not from a userinfo endpoint. This method decodes the id_token claims.
"""
async with httpx.AsyncClient() as client:
resp = await client.get(
"https://appleid.apple.com/auth/keys",
)
if resp.status_code != 200:
return None
return None # Full implementation requires JWT id_token decoding
async def ensure_app_folder(access_token: str) -> str | None:
return None # iCloud Drive API requires additional entitlements
async def upload_file(
access_token: str,
file_name: str,
file_content: bytes,
parent_id: str | None = None,
) -> bool:
return False # Requires CloudKit API integration
async def get_disk_info(access_token: str) -> dict | None:
return None # Requires CloudKit API integration
+135
View File
@@ -0,0 +1,135 @@
"""Google OAuth client with Google Drive API support.
Activated when OAUTH_GOOGLE_ID is set in .env.
"""
from dataclasses import dataclass
import httpx
from app.core.config import get_settings
GOOGLE_OAUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
GOOGLE_DRIVE_URL = "https://www.googleapis.com/drive/v3"
SCOPES = " ".join([
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/drive.file",
])
VOIDEA_DRIVE_FOLDER = "VoIdeaAI"
@dataclass
class GoogleUserInfo:
id: str
email: str
display_name: str
avatar_url: str | None
def is_available() -> bool:
return get_settings().google_oauth_enabled
async def get_authorize_url() -> str:
settings = get_settings()
params = {
"response_type": "code",
"client_id": settings.oauth_google_id,
"redirect_uri": settings.oauth_google_redirect_uri,
"scope": SCOPES,
"access_type": "offline",
"prompt": "consent",
}
query = "&".join(f"{k}={v}" for k, v in params.items())
return f"{GOOGLE_OAUTH_URL}?{query}"
async def exchange_code(code: str) -> dict | None:
settings = get_settings()
if not settings.google_oauth_enabled:
return None
async with httpx.AsyncClient() as client:
resp = await client.post(
GOOGLE_TOKEN_URL,
data={
"grant_type": "authorization_code",
"code": code,
"client_id": settings.oauth_google_id,
"client_secret": settings.oauth_google_secret,
"redirect_uri": settings.oauth_google_redirect_uri,
},
)
if resp.status_code != 200:
return None
return resp.json()
async def get_user_info(access_token: str) -> GoogleUserInfo | None:
async with httpx.AsyncClient() as client:
resp = await client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
if resp.status_code != 200:
return None
data = resp.json()
return GoogleUserInfo(
id=data["id"],
email=data["email"],
display_name=data.get("name", data["email"]),
avatar_url=data.get("picture"),
)
async def ensure_app_folder(access_token: str) -> str | None:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{GOOGLE_DRIVE_URL}/files",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
json={
"name": VOIDEA_DRIVE_FOLDER,
"mimeType": "application/vnd.google-apps.folder",
},
)
if resp.status_code == 200:
return resp.json().get("id")
return None
async def upload_file(
access_token: str,
file_name: str,
file_content: bytes,
parent_id: str | None = None,
) -> bool:
metadata = {"name": file_name}
if parent_id:
metadata["parents"] = [parent_id]
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{GOOGLE_DRIVE_URL}/files?uploadType=multipart",
headers={"Authorization": f"Bearer {access_token}"},
data={"metadata": str(metadata)},
files={"file": (file_name, file_content)},
)
return resp.status_code == 200
async def get_disk_info(access_token: str) -> dict | None:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{GOOGLE_DRIVE_URL}/about?fields=storageQuota",
headers={"Authorization": f"Bearer {access_token}"},
)
if resp.status_code != 200:
return None
return resp.json()
+204
View File
@@ -0,0 +1,204 @@
"""Yandex OAuth client with Disk API support.
Scopes requested:
- login:email — email address
- login:info — login, first+last name
- login:avatar — avatar URL
- cloud_api:disk.write — write anywhere on Disk
- cloud_api:disk.app_folder — access app folder on Disk
- cloud_api:disk.read — read entire Disk
- cloud_api:disk.info — Disk info (quota, etc.)
"""
from dataclasses import dataclass
import httpx
from app.core.config import get_settings
YANDEX_OAUTH_URL = "https://oauth.yandex.ru"
YANDEX_LOGIN_URL = "https://login.yandex.ru"
YANDEX_DISK_URL = "https://cloud-api.yandex.net/v1/disk"
SCOPES = " ".join([
"login:email",
"login:info",
"login:avatar",
"cloud_api:disk.write",
"cloud_api:disk.app_folder",
"cloud_api:disk.read",
"cloud_api:disk.info",
])
VOIDEA_DISK_FOLDER = "VoIdeaAI"
@dataclass
class YandexUserInfo:
id: str
login: str
email: str
display_name: str
avatar_url: str | None
@dataclass
class YandexTokenResult:
access_token: str
refresh_token: str | None
expires_in: int
async def get_authorize_url() -> str:
"""Generate Yandex OAuth authorization URL."""
settings = get_settings()
params = {
"response_type": "code",
"client_id": settings.oauth_yandex_id,
"redirect_uri": settings.oauth_yandex_redirect_uri,
"scope": SCOPES,
}
query = "&".join(f"{k}={v}" for k, v in params.items())
return f"{YANDEX_OAUTH_URL}/authorize?{query}"
async def exchange_code(code: str) -> YandexTokenResult | None:
"""Exchange authorization code for access token.
Args:
code: Authorization code from OAuth callback
Returns:
Token result or None if exchange failed
"""
settings = get_settings()
if not settings.oauth_yandex_id or not settings.oauth_yandex_secret:
return None
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{YANDEX_OAUTH_URL}/token",
data={
"grant_type": "authorization_code",
"code": code,
"client_id": settings.oauth_yandex_id,
"client_secret": settings.oauth_yandex_secret,
},
)
if resp.status_code != 200:
return None
data = resp.json()
return YandexTokenResult(
access_token=data["access_token"],
refresh_token=data.get("refresh_token"),
expires_in=data.get("expires_in", 0),
)
async def get_user_info(access_token: str) -> YandexUserInfo | None:
"""Get user info from Yandex Login API.
Args:
access_token: Valid OAuth access token
Returns:
User info or None if request failed
"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{YANDEX_LOGIN_URL}/info",
params={"format": "json"},
headers={"Authorization": f"OAuth {access_token}"},
)
if resp.status_code != 200:
return None
data = resp.json()
avatar_url: str | None = None
if data.get("default_avatar_id"):
avatar_url = (
f"https://avatars.yandex.net/get-yapic/{data['default_avatar_id']}/islands-200"
)
display_name = data.get("real_name") or data.get("display_name") or data.get("login", "")
email = data.get("default_email") or ""
return YandexUserInfo(
id=str(data["id"]),
login=data.get("login", ""),
email=email,
display_name=display_name,
avatar_url=avatar_url,
)
async def ensure_app_folder(access_token: str) -> bool:
"""Create VoIdeaAI folder on Yandex.Disk if it doesn't exist.
Args:
access_token: Valid OAuth access token with disk.write scope
Returns:
True if folder exists (created or already present)
"""
async with httpx.AsyncClient() as client:
resp = await client.put(
f"{YANDEX_DISK_URL}/resources",
params={"path": VOIDEA_DISK_FOLDER},
headers={"Authorization": f"OAuth {access_token}"},
)
return resp.status_code in (201, 409)
async def get_disk_info(access_token: str) -> dict | None:
"""Get Yandex.Disk info (quota, usage).
Args:
access_token: Valid OAuth access token
Returns:
Disk info dict or None
"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{YANDEX_DISK_URL}",
headers={"Authorization": f"OAuth {access_token}"},
)
return resp.json() if resp.status_code == 200 else None
async def upload_file(access_token: str, local_path: str, remote_name: str) -> bool:
"""Upload a file to VoIdeaAI folder on Yandex.Disk.
Args:
access_token: Valid OAuth access token
local_path: Path to local file
remote_name: Name for the file on Disk
Returns:
True if upload succeeded
"""
import os
if not os.path.exists(local_path):
return False
remote_path = f"{VOIDEA_DISK_FOLDER}/{remote_name}"
async with httpx.AsyncClient() as client:
# 1. Get upload URL
resp = await client.get(
f"{YANDEX_DISK_URL}/resources/upload",
params={"path": remote_path, "overwrite": "true"},
headers={"Authorization": f"OAuth {access_token}"},
)
if resp.status_code != 200:
return False
upload_url = resp.json().get("href")
if not upload_url:
return False
# 2. Upload file
with open(local_path, "rb") as f:
upload_resp = await client.put(upload_url, content=f)
return upload_resp.status_code in (201, 202)
+1
View File
@@ -0,0 +1 @@
"""Telegram bot integration for VoIdea."""
+27
View File
@@ -0,0 +1,27 @@
"""Telegram ↔ VoIdea account linking."""
from __future__ import annotations
import secrets
from typing import Any
# In-memory store for link tokens (in production: Redis with TTL)
_link_tokens: dict[str, int] = {} # token → user_id
def create_link_token(user_id: int) -> str:
"""Generate a one-time token for linking Telegram to VoIdea account."""
token = secrets.token_urlsafe(32)
_link_tokens[token] = user_id
return token
def consume_link_token(token: str) -> int | None:
"""Validate and consume a link token. Returns user_id or None."""
user_id = _link_tokens.pop(token, None)
return user_id
def get_bot_link_url(base_url: str, token: str) -> str:
"""Return the URL for the user to confirm account linking."""
return f"{base_url}/bot/link?token={token}"
+77
View File
@@ -0,0 +1,77 @@
"""Telegram bot client — stub implementation.
Replace with python-telegram-bot or aiogram when TELEGRAM_BOT_TOKEN is set.
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger("voidea.telegram.bot")
class TelegramBotClient:
"""Stub Telegram bot client.
All methods log instead of calling Telegram API.
Replace with real implementation when TELEGRAM_BOT_TOKEN is configured.
"""
def __init__(self, token: str | None = None):
self.token = token
self.available = bool(token)
if self.available:
logger.info("Telegram bot client initialized with token")
else:
logger.warning("TELEGRAM_BOT_TOKEN not set — bot is in stub mode")
async def send_message(
self,
chat_id: int | str,
text: str,
parse_mode: str = "HTML",
reply_markup: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""Send a message to a Telegram chat."""
if not self.available:
logger.info("[STUB] send_message to %s: %s", chat_id, text[:80])
return None
# TODO: real Telegram API call
logger.info("send_message to %s: %s", chat_id, text[:80])
return {"ok": True}
async def send_voice(
self,
chat_id: int | str,
voice_url: str,
) -> dict[str, Any] | None:
"""Send a voice message."""
if not self.available:
logger.info("[STUB] send_voice to %s: %s", chat_id, voice_url[:80])
return None
logger.info("send_voice to %s: %s", chat_id, voice_url[:80])
return {"ok": True}
async def set_webhook(self, url: str) -> bool:
"""Set the Telegram bot webhook URL."""
if not self.available:
logger.info("[STUB] set_webhook: %s", url)
return False
logger.info("set_webhook: %s", url)
return True
async def delete_webhook(self) -> bool:
"""Remove the webhook."""
if not self.available:
logger.info("[STUB] delete_webhook")
return False
return True
async def set_commands(self, commands: list[dict[str, str]]) -> bool:
"""Register bot commands with Telegram."""
if not self.available:
logger.info("[STUB] set_commands: %s", [c["command"] for c in commands])
return False
logger.info("set_commands: %d commands", len(commands))
return True
+52
View File
@@ -0,0 +1,52 @@
"""Command decorator for auto-registration of bot commands.
Usage:
@bot_command(name="/idea", description="Create a new idea", requires_auth=True)
async def cmd_idea(update, context): ...
"""
from __future__ import annotations
import inspect
from dataclasses import dataclass, field
from typing import Any, Callable
@dataclass
class BotCommandDef:
name: str
description: str
requires_auth: bool = False
handler: Callable | None = None
_registry: list[BotCommandDef] = []
def bot_command(
name: str,
description: str,
requires_auth: bool = False,
) -> Callable:
"""Decorator that registers a function as a bot command handler."""
def wrapper(func: Callable) -> Callable:
_registry.append(BotCommandDef(
name=name,
description=description,
requires_auth=requires_auth,
handler=func,
))
return func
return wrapper
def get_registered_commands() -> list[BotCommandDef]:
"""Return all registered command definitions."""
return list(_registry)
def get_enabled_commands(enabled_names: set[str]) -> list[BotCommandDef]:
"""Return only commands that are in the enabled set."""
return [cmd for cmd in _registry if cmd.name in enabled_names]
+84
View File
@@ -0,0 +1,84 @@
"""Telegram bot command handlers.
Registered via @bot_command decorator for auto-discovery.
"""
from __future__ import annotations
import logging
from typing import Any
from app.integrations.telegram.auth import create_link_token, get_bot_link_url
from app.integrations.telegram.decorators import bot_command
from app.integrations.telegram.client import TelegramBotClient
logger = logging.getLogger("voidea.telegram.handlers")
_bot_client: TelegramBotClient | None = None
def set_bot_client(client: TelegramBotClient) -> None:
global _bot_client
_bot_client = client
@bot_command(name="/start", description="Welcome message and getting started")
async def cmd_start(update: dict[str, Any], context: dict[str, Any]) -> str:
user = update.get("message", {}).get("from", {})
first_name = user.get("first_name", "User")
return (
f"Hello, {first_name}! 👋\n\n"
"I'm VoIdeaAI bot. Here's what I can do:\n"
"/link — Link your Telegram to VoIdea account\n"
"/idea — Create a new idea (requires linking)\n"
"/help — Show available commands"
)
@bot_command(name="/help", description="Show available commands")
async def cmd_help(update: dict[str, Any], context: dict[str, Any]) -> str:
return (
"Available commands:\n"
"/start — Welcome message\n"
"/link — Link Telegram to your VoIdea account\n"
"/idea — Create a new idea from text\n"
"/help — This message\n\n"
"To get started, use /link to connect your account."
)
@bot_command(name="/link", description="Link Telegram to your VoIdea account")
async def cmd_link(update: dict[str, Any], context: dict[str, Any]) -> str:
user_id = update.get("message", {}).get("from", {}).get("id")
if not user_id:
return "Error: could not identify you."
token = create_link_token(int(user_id))
base_url = context.get("base_url", "https://voidea.ai")
link_url = get_bot_link_url(base_url, token)
return (
"To link your Telegram account to VoIdea:\n\n"
f"1. Open this link: {link_url}\n"
"2. Confirm the pairing\n"
"3. Come back and use /idea to create ideas\n\n"
"The link expires in 30 minutes."
)
@bot_command(name="/idea", description="Create a new idea (requires linking)")
async def cmd_idea(update: dict[str, Any], context: dict[str, Any]) -> str:
text = update.get("message", {}).get("text", "")
args = text.replace("/idea", "", 1).strip()
if not args:
return (
"Usage: /idea <your idea text>\n\n"
"Example: /idea A mobile app for tracking daily water intake\n\n"
"You need to link your account first with /link."
)
return (
f"Idea received: \"{args[:200]}\"\n\n"
"To save it permanently, link your account with /link first.\n"
"After linking, your ideas will appear in your VoIdea dashboard."
)
+91
View File
@@ -0,0 +1,91 @@
"""Telegram bot command sync service.
Synchronises @bot_command-decorated handlers with BotCommand DB table
and Telegram Bot API (set_my_commands) on boot or manual trigger.
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.integrations.telegram.client import TelegramBotClient
from app.integrations.telegram.decorators import get_registered_commands
from app.models.bot_command import BotCommand
logger = logging.getLogger("voidea.telegram.sync")
class TelegramBotSyncService:
"""Syncs registered bot commands to DB and Telegram API."""
def __init__(self, db: AsyncSession, bot_client: TelegramBotClient | None = None):
self.db = db
self.bot_client = bot_client
async def sync_to_db(self) -> list[BotCommand]:
"""Ensure all @bot_command handlers have matching BotCommand records."""
registered = get_registered_commands()
result = await self.db.execute(select(BotCommand))
existing = {cmd.name: cmd for cmd in result.scalars().all()}
synced: list[BotCommand] = []
for reg in registered:
if reg.name in existing:
cmd = existing[reg.name]
if cmd.description != reg.description or cmd.requires_auth != reg.requires_auth:
cmd.description = reg.description
cmd.requires_auth = reg.requires_auth
synced.append(cmd)
else:
cmd = BotCommand(
name=reg.name,
description=reg.description,
enabled=True,
requires_auth=reg.requires_auth,
)
self.db.add(cmd)
synced.append(cmd)
await self.db.commit()
for cmd in synced:
await self.db.refresh(cmd)
logger.info("Synced %d bot commands to DB", len(synced))
return synced
async def sync_to_telegram(self) -> bool:
"""Push enabled commands to Telegram Bot API via set_my_commands."""
if not self.bot_client or not self.bot_client.available:
logger.info("Telegram client not available — skipping set_my_commands")
return False
result = await self.db.execute(
select(BotCommand).where(BotCommand.enabled == True)
)
enabled = result.scalars().all()
commands = [
{"command": cmd.name.lstrip("/"), "description": cmd.description}
for cmd in enabled
]
ok = await self.bot_client.set_commands(commands)
if ok:
logger.info("Pushed %d commands to Telegram", len(commands))
else:
logger.warning("Failed to push commands to Telegram")
return ok
async def full_sync(self) -> dict[str, Any]:
"""Run full sync: DB → Telegram."""
synced = await self.sync_to_db()
telegram_ok = await self.sync_to_telegram()
return {
"synced_count": len(synced),
"telegram_sync": telegram_ok,
}
+132
View File
@@ -0,0 +1,132 @@
import asyncio
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from app.api.v1 import api_v1_router
from app.core.config import get_settings
from app.core.database import async_session_maker, engine
from app.core.limiter import limiter
from app.core.logging_middleware import DebugLoggingMiddleware
from app.core.middleware import SecurityHeadersMiddleware
from app.core.seed import seed_database
from app.services.debug_service import DebugService
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Seed database on startup (idempotent — skips if data exists)
try:
async with async_session_maker() as db:
await seed_database(db)
except Exception:
pass # DB may not be ready yet, seed will run on next restart
# Auto-cleanup logs on startup
try:
async with async_session_maker() as db:
svc = DebugService(db)
await svc.auto_cleanup_if_needed()
except Exception:
pass
# Sync bot commands on startup
try:
async with async_session_maker() as db:
from app.integrations.telegram.sync_service import TelegramBotSyncService
from app.integrations.telegram.client import TelegramBotClient
bot_client = TelegramBotClient(token=settings.telegram_bot_token)
svc = TelegramBotSyncService(db, bot_client)
await svc.full_sync()
except Exception:
pass
# Auto-run QATesterAgent after fresh deploy (within 5 min)
async def _auto_qa():
try:
deploy_stamp = "/opt/voidea/.last_deploy"
if not os.path.exists(deploy_stamp):
return
with open(deploy_stamp) as f:
ts = int(f.read().strip())
if (datetime.now().timestamp() - ts) > 300:
return # older than 5 min
await asyncio.sleep(3)
async with async_session_maker() as db:
from app.agents.qa_tester_agent import QATesterAgent
agent = QATesterAgent(session=db)
result = await agent.run({"action": "api", "test_count": 2})
if not result.success:
logger = __import__("logging").getLogger("voidea.qa_auto")
logger.warning("Auto-QA after deploy: %s", result.message)
except Exception:
pass
asyncio.create_task(_auto_qa())
yield
await engine.dispose()
app = FastAPI(
title=settings.project_name,
description="VoIdeaAI — идеи рождаются вслух, решения приходят мгновенно!",
version=settings.project_version,
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
lifespan=lifespan,
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.include_router(api_v1_router)
app.mount("/assets", StaticFiles(directory="webui/dist/assets"), name="assets")
app.mount("/icons", StaticFiles(directory="webui/dist/icons"), name="icons")
app.mount("/", StaticFiles(directory="webui/dist", html=True), name="static")
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(DebugLoggingMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
@limiter.limit("30/minute")
async def health_check(request: Request):
return {
"status": "healthy",
"version": settings.project_version,
"environment": settings.project_env,
}
@app.get("/api/v1/health")
@limiter.limit("30/minute")
async def api_health(request: Request):
return {
"status": "healthy",
"api_version": "v1",
"database": "connected",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
+15
View File
@@ -0,0 +1,15 @@
# models Module - VoIdea
## Overview
[Auto-generated documentation]
## Files
| File | Purpose |
|------|---------|
| `agent.py` | Module file |
| `backlog.py` | Module file |
| `idea.py` | Module file |
| `log.py` | Module file |
| `user.py` | Module file |
+12
View File
@@ -0,0 +1,12 @@
from app.models.user import User
from app.models.idea import Idea
from app.models.session import Session
from app.models.voice_command import VoiceCommand
from app.models.agent import AgentConfig
from app.models.backlog import BacklogTask
from app.models.feedback import Feedback
from app.models.tariff import TariffPlan, UserSubscription
from app.models.log import LogEntry
from app.models.conductor import ConductorInteraction
from app.models.pipeline import PipelineStats
from app.models.bot_command import BotCommand
+35
View File
@@ -0,0 +1,35 @@
"""Agent configuration model for VoIdea."""
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
class AgentConfig(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "agent_configs"
agent_name: Mapped[str] = mapped_column(
String(100), unique=True, nullable=False, index=True
)
description: Mapped[Optional[str]] = mapped_column(
Text, nullable=True, default=""
)
is_enabled: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False
)
version: Mapped[str] = mapped_column(
String(20), default="1.0.0", nullable=False
)
checksum: Mapped[Optional[str]] = mapped_column(
String(64), nullable=True
)
config: Mapped[Optional[str]] = mapped_column(
Text, nullable=True
)
last_run_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
+31
View File
@@ -0,0 +1,31 @@
"""Backlog task model for VoIdea."""
from typing import Optional
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
class BacklogTask(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "backlog_tasks"
title: Mapped[str] = mapped_column(
String(255), nullable=False
)
description: Mapped[Optional[str]] = mapped_column(
Text, nullable=True
)
priority: Mapped[str] = mapped_column(
String(20), default="medium", nullable=False, index=True
)
status: Mapped[str] = mapped_column(
String(20), default="pending", nullable=False, index=True
)
source_agent: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True
)
category: Mapped[str] = mapped_column(
String(50), default="general", nullable=False, index=True
)
+22
View File
@@ -0,0 +1,22 @@
"""BotCommand model — Telegram bot commands with enable/disable toggle."""
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
class BotCommand(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "bot_commands"
name: Mapped[str] = mapped_column(
String(50), unique=True, nullable=False, index=True
)
description: Mapped[str] = mapped_column(
String(255), nullable=False
)
enabled: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False
)
requires_auth: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False
)
+45
View File
@@ -0,0 +1,45 @@
"""Conductor interaction model for VoIdeaAI.
Logs all Дирижёр interactions for self-learning and analytics.
"""
from typing import Optional
from sqlalchemy import Float, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
class ConductorInteraction(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "conductor_interactions"
user_id: Mapped[Optional[UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
session_id: Mapped[Optional[UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
input_text: Mapped[str] = mapped_column(Text, nullable=False)
detected_intent: Mapped[str] = mapped_column(String(100), nullable=False)
selected_agent: Mapped[str] = mapped_column(String(100), nullable=False)
response_text: Mapped[str] = mapped_column(Text, nullable=False)
user_rating: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True
)
confidence_score: Mapped[int] = mapped_column(
Integer, default=80, nullable=False
)
verification_status: Mapped[str] = mapped_column(
String(20), default="verified", nullable=False
)
was_auto_routed: Mapped[bool] = mapped_column(default=True)
processing_time_ms: Mapped[float] = mapped_column(Float, default=0.0)
context: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+29
View File
@@ -0,0 +1,29 @@
"""Feedback model for VoIdea."""
from typing import Optional
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
class Feedback(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "feedback"
user_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
text: Mapped[str] = mapped_column(
Text, nullable=False
)
page_url: Mapped[Optional[str]] = mapped_column(
String(512), nullable=True
)
status: Mapped[str] = mapped_column(
String(20), default="new", nullable=False, index=True
)
+40
View File
@@ -0,0 +1,40 @@
"""Idea model for VoIdea."""
from typing import Optional
from sqlalchemy import Boolean, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
class Idea(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "ideas"
user_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
title: Mapped[str] = mapped_column(
String(255), nullable=False, index=True
)
content: Mapped[str] = mapped_column(
Text, nullable=False
)
status: Mapped[str] = mapped_column(
String(20), default="draft", nullable=False, index=True
)
tags: Mapped[Optional[list[str]]] = mapped_column(
ARRAY(String(50)), nullable=True
)
is_public: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False
)
public_slug: Mapped[Optional[str]] = mapped_column(
String(64), unique=True, nullable=True, index=True
)
user = relationship("User", back_populates="ideas", lazy="selectin")
+32
View File
@@ -0,0 +1,32 @@
"""Log entry model for VoIdea."""
from typing import Optional
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
class LogEntry(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "log_entries"
level: Mapped[str] = mapped_column(
String(20), nullable=False, index=True
)
source: Mapped[str] = mapped_column(
String(100), nullable=False, index=True
)
message: Mapped[str] = mapped_column(
Text, nullable=False
)
details: Mapped[Optional[str]] = mapped_column(
Text, nullable=True
)
user_id: Mapped[Optional[UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
+38
View File
@@ -0,0 +1,38 @@
"""Pipeline models for VoIdea - voice processing pipeline stages."""
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, Float, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
class PipelineStats(SQLBase, TimestampMixin):
__tablename__ = "pipeline_stats"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(__import__("uuid").uuid4())
)
user_id: Mapped[Optional[str]] = mapped_column(
String(36), ForeignKey("users.id", ondelete="CASCADE"),
nullable=True, index=True
)
stage: Mapped[str] = mapped_column(
String(50), nullable=False, index=True
)
passed: Mapped[bool] = mapped_column(
Boolean, nullable=False
)
reason: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True
)
duration_ms: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True
)
created_at: Mapped[datetime] = mapped_column(
nullable=False,
default=lambda: datetime.now(__import__("datetime").timezone.utc),
)
+31
View File
@@ -0,0 +1,31 @@
"""Session model — one chat session = one idea discussion."""
from typing import Optional
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
class Session(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "sessions"
user_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
title: Mapped[str] = mapped_column(
String(255), nullable=False, default="Новое обсуждение"
)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="active", index=True
)
idea_id: Mapped[Optional[UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ideas.id", ondelete="SET NULL"),
nullable=True,
)
+69
View File
@@ -0,0 +1,69 @@
"""Tariff models for VoIdea."""
from datetime import datetime
from decimal import Decimal
from typing import Any, Optional
from sqlalchemy import Boolean, ForeignKey, Numeric, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
class TariffPlan(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "tariff_plans"
name: Mapped[str] = mapped_column(
String(100), nullable=False
)
code: Mapped[str] = mapped_column(
String(50), unique=True, nullable=False, index=True
)
description: Mapped[Optional[str]] = mapped_column(
Text, nullable=True
)
price_monthly: Mapped[Decimal] = mapped_column(
Numeric(10, 2), default=0, nullable=False
)
price_yearly: Mapped[Optional[Decimal]] = mapped_column(
Numeric(10, 2), nullable=True
)
features: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True, default=None
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False
)
sort_order: Mapped[int] = mapped_column(
default=0, nullable=False
)
class UserSubscription(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "user_subscriptions"
user_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
unique=True,
nullable=False,
index=True,
)
plan_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tariff_plans.id", ondelete="RESTRICT"),
nullable=False,
)
status: Mapped[str] = mapped_column(
String(20), default="active", nullable=False, index=True
)
current_period_start: Mapped[Optional[datetime]] = mapped_column(
nullable=True
)
current_period_end: Mapped[Optional[datetime]] = mapped_column(
nullable=True
)
canceled_at: Mapped[Optional[datetime]] = mapped_column(
nullable=True
)
+63
View File
@@ -0,0 +1,63 @@
"""User model for VoIdea."""
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import Boolean, DateTime, String
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
class User(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "users"
email: Mapped[str] = mapped_column(
String(255), unique=True, index=True, nullable=False
)
password_hash: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True
)
display_name: Mapped[str] = mapped_column(
String(255), nullable=False
)
avatar_url: Mapped[Optional[str]] = mapped_column(
String(512), nullable=True
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False
)
is_superuser: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False
)
# v2.0 role system
role: Mapped[str] = mapped_column(
String(20), default="user", nullable=False, index=True
)
is_owner: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False
)
permissions: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True, default=None
)
accepted_terms_at: Mapped[Optional[datetime]] = mapped_column(
nullable=True
)
accepted_terms_version: Mapped[Optional[str]] = mapped_column(
String(20), nullable=True
)
pipeline_tuning: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True, default=None
)
oauth_provider: Mapped[Optional[str]] = mapped_column(
String(50), nullable=True
)
oauth_id: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True
)
ideas = relationship("Idea", back_populates="user", lazy="selectin")
+35
View File
@@ -0,0 +1,35 @@
"""Voice command model — user-specific dynamic commands for self-learning."""
from typing import Optional
from sqlalchemy import Boolean, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
class VoiceCommand(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "voice_commands"
user_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
phrase: Mapped[str] = mapped_column(
String(255), nullable=False
)
action: Mapped[str] = mapped_column(
String(50), nullable=False
)
agent_name: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True
)
count: Mapped[int] = mapped_column(
Integer, default=0, nullable=False
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False
)
+16
View File
@@ -0,0 +1,16 @@
# schemas Module - VoIdea
## Overview
[Auto-generated documentation]
## Files
| File | Purpose |
|------|---------|
| `admin.py` | Module file |
| `agent.py` | Module file |
| `auth.py` | Module file |
| `idea.py` | Module file |
| `sync.py` | Module file |
| `user.py` | Module file |
+113
View File
@@ -0,0 +1,113 @@
"""VoIdea - Pydantic schemas for API."""
from app.schemas.auth import (
ForgotPasswordRequest,
LoginRequest,
OAuthCallbackRequest,
OAuthUrlResponse,
RefreshRequest,
RegisterRequest,
ResetPasswordRequest,
TokenResponse,
)
from app.schemas.user import (
ChangePasswordRequest,
UserCreate,
UserResponse,
UserUpdate,
VoiceSettingsResponse,
VoiceSettingsUpdate,
)
from app.schemas.idea import (
AnalysisResultResponse,
IdeaAnalyzeResponse,
IdeaCreate,
IdeaResponse,
IdeaUpdate,
)
from app.schemas.agent import AgentReportResponse, AgentRunRequest, AgentStatusResponse
from app.schemas.sync import SyncPullRequest, SyncPushRequest, SyncResponse
from app.schemas.admin import (
AgentInfoResponse,
AgentUpdateRequest,
FeatureCreate,
FeatureResponse,
FeatureUpdate,
LogEntryResponse,
ServiceActionRequest,
ServiceActionResult,
ServiceStatusResponse,
SystemHealth,
SystemInfoResponse,
UserAdminUpdate,
UserRoleUpdate,
)
from app.schemas.feedback import FeedbackCreate, FeedbackResponse, FeedbackUpdate
from app.schemas.tariff import (
TariffPlanCreate,
TariffPlanResponse,
TariffPlanUpdate,
UserSubscriptionResponse,
)
from app.schemas.pipeline import (
PipelineConfigResponse,
PipelineConfigUpdate,
PipelineStageConfig,
PipelineStatsEntry,
PipelineStatsSummary,
)
from app.schemas.config import PublicConfigResponse
__all__ = [
"RegisterRequest",
"LoginRequest",
"RefreshRequest",
"TokenResponse",
"ForgotPasswordRequest",
"ResetPasswordRequest",
"OAuthUrlResponse",
"OAuthCallbackRequest",
"UserCreate",
"UserUpdate",
"UserResponse",
"ChangePasswordRequest",
"IdeaCreate",
"IdeaUpdate",
"IdeaResponse",
"IdeaAnalyzeResponse",
"AnalysisResultResponse",
"AgentRunRequest",
"AgentStatusResponse",
"AgentReportResponse",
"SyncPullRequest",
"SyncPushRequest",
"SyncResponse",
"SystemHealth",
"LogEntryResponse",
"UserRoleUpdate",
"UserAdminUpdate",
"AgentInfoResponse",
"AgentUpdateRequest",
"ServiceActionRequest",
"ServiceActionResult",
"ServiceStatusResponse",
"FeatureCreate",
"FeatureUpdate",
"FeatureResponse",
"SystemInfoResponse",
"FeedbackCreate",
"FeedbackResponse",
"FeedbackUpdate",
"TariffPlanCreate",
"TariffPlanUpdate",
"TariffPlanResponse",
"UserSubscriptionResponse",
"PublicConfigResponse",
"VoiceSettingsResponse",
"VoiceSettingsUpdate",
"PipelineConfigResponse",
"PipelineConfigUpdate",
"PipelineStageConfig",
"PipelineStatsEntry",
"PipelineStatsSummary",
]
+104
View File
@@ -0,0 +1,104 @@
"""Admin schemas for VoIdea API."""
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, EmailStr, Field
class UserRoleUpdate(BaseModel):
is_superuser: bool
class UserAdminUpdate(BaseModel):
display_name: Optional[str] = None
email: Optional[EmailStr] = None
role: Optional[str] = Field(None, description="user | moderator | admin")
is_active: Optional[bool] = None
permissions: Optional[dict[str, bool]] = None
class SystemHealth(BaseModel):
status: str
database: str
redis: str
uptime: Optional[float] = None
version: str
class LogEntryResponse(BaseModel):
id: str
level: str
source: str
message: str
details: Optional[dict[str, Any]] = None
user_id: Optional[str] = None
created_at: datetime
class AgentInfoResponse(BaseModel):
agent_name: str
description: str = ""
is_enabled: bool = True
version: str = "1.0.0"
last_run_at: Optional[datetime] = None
class AgentUpdateRequest(BaseModel):
description: Optional[str] = None
is_enabled: Optional[bool] = None
class ServiceActionRequest(BaseModel):
service: str = Field(description="api | worker | beat | all")
action: str = Field(default="restart", description="restart | start | stop | status")
class ServiceActionResult(BaseModel):
service: str
action: str
success: bool
message: str
class ServiceStatusResponse(BaseModel):
service: str
description: str
status: str
is_running: bool
class FeatureCreate(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: Optional[str] = None
priority: str = "medium"
category: str = "feature"
class FeatureUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
priority: Optional[str] = None
status: Optional[str] = None
category: Optional[str] = None
class FeatureResponse(BaseModel):
id: str
title: str
description: Optional[str] = None
priority: str
status: str
category: str
source_agent: Optional[str] = None
created_at: datetime
updated_at: datetime
class SystemInfoResponse(BaseModel):
version: str
environment: str
database_status: str
redis_status: str
python_version: str
uptime_seconds: Optional[float] = None
+28
View File
@@ -0,0 +1,28 @@
"""Agent schemas for VoIdea API."""
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel
class AgentRunRequest(BaseModel):
context: Optional[dict[str, Any]] = None
class AgentStatusResponse(BaseModel):
name: str
version: str
description: str
status: str
last_run: Optional[datetime] = None
class AgentReportResponse(BaseModel):
id: str
agent_id: str
status: str
message: Optional[str] = None
success: bool
duration_ms: int
created_at: datetime
+68
View File
@@ -0,0 +1,68 @@
"""Auth schemas for VoIdea API."""
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
display_name: str = Field(min_length=1, max_length=255)
accepted_terms: bool = Field(default=True, description="Must accept Terms & Privacy Policy")
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RefreshRequest(BaseModel):
refresh_token: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_at: datetime
class OAuthUrlResponse(BaseModel):
url: str
provider: str
class OAuthCallbackRequest(BaseModel):
code: str
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
new_password: str = Field(min_length=8, max_length=128)
class TwoFactorSetupResponse(BaseModel):
secret: str
uri: str
qr_base64: str
class TwoFactorVerifyRequest(BaseModel):
token: str = Field(min_length=6, max_length=6)
class TwoFactorLoginRequest(BaseModel):
temp_token: str
totp_code: str = Field(min_length=6, max_length=6)
class TwoFactorLoginResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_at: datetime
+19
View File
@@ -0,0 +1,19 @@
"""Bot command schemas."""
from datetime import datetime
from pydantic import BaseModel
class BotCommandResponse(BaseModel):
id: str
name: str
description: str
enabled: bool
requires_auth: bool
created_at: datetime
updated_at: datetime
class BotCommandUpdate(BaseModel):
name: str
enabled: bool
+19
View File
@@ -0,0 +1,19 @@
"""Public config schema for VoIdea API."""
from pydantic import BaseModel
class PublicConfigResponse(BaseModel):
project_name: str
project_version: str
project_env: str
project_slogan: str
social_telegram: str
social_vk: str
social_youtube: str
social_tiktok: str
yandex_metrika_id: str
google_analytics_id: str
tariffs_enabled: bool
tariffs_free_code: str
accepted_terms_version: str
+25
View File
@@ -0,0 +1,25 @@
"""Feedback schemas for VoIdea API."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class FeedbackCreate(BaseModel):
text: str = Field(min_length=1, max_length=5000)
page_url: Optional[str] = Field(None, max_length=512)
class FeedbackResponse(BaseModel):
id: str
user_id: Optional[str] = None
text: str
page_url: Optional[str] = None
status: str = "new"
created_at: datetime
updated_at: datetime
class FeedbackUpdate(BaseModel):
status: str = Field(default="read", description="new | read | replied | done")
+66
View File
@@ -0,0 +1,66 @@
"""Idea schemas for VoIdea API."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class IdeaCreate(BaseModel):
title: str = Field(min_length=1, max_length=255)
content: str = Field(min_length=1)
tags: Optional[list[str]] = None
is_public: bool = False
class IdeaUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=255)
content: Optional[str] = Field(None, min_length=1)
status: Optional[str] = None
tags: Optional[list[str]] = None
is_public: Optional[bool] = None
class IdeaResponse(BaseModel):
id: str
user_id: str
title: str
content: str
status: str
tags: Optional[list[str]] = None
is_public: bool
public_slug: Optional[str] = None
created_at: datetime
updated_at: datetime
AI_AGENT_ROLES = [
"coordinator",
"task_organizer",
"business_analyst",
"legal_expert",
"financial_consultant",
"solution_architect",
"tester",
"ui_designer",
"smm_specialist",
"life_coach",
"accessibility_expert",
]
class IdeaAnalyzeResponse(BaseModel):
status: str = "started"
idea_id: str
task_count: int = 0
tasks: list[dict] = []
class AnalysisResultResponse(BaseModel):
id: str
role: str
status: str
message: str | None = None
success: bool
duration_ms: int
created_at: str | None = None
+63
View File
@@ -0,0 +1,63 @@
"""Pipeline config and stats schemas for VoIdea."""
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field
class PipelineStageConfig(BaseModel):
enabled: bool = True
noise_threshold: float | None = None
silence_timeout_ms: int | None = None
min_audio_duration_ms: int | None = None
word: str | None = None
timeout_minutes: int | None = None
sensitivity: float | None = None
mode: str | None = None
timeout_ms: int | None = None
verified_threshold: int | None = None
warning_threshold: int | None = None
max_agents_per_dialog: int | None = None
min_samples: int | None = None
learning_rate: float | None = None
class PipelineConfigResponse(BaseModel):
vad: PipelineStageConfig = Field(default_factory=PipelineStageConfig)
wake_word: PipelineStageConfig = Field(default_factory=PipelineStageConfig)
semantic_validation: PipelineStageConfig = Field(default_factory=PipelineStageConfig)
confidence: PipelineStageConfig = Field(default_factory=PipelineStageConfig)
agent_chaining: PipelineStageConfig = Field(default_factory=PipelineStageConfig)
auto_tuning: PipelineStageConfig = Field(default_factory=PipelineStageConfig)
stages_order: list[str] = [
"vad", "wake_word", "semantic_validation", "routing", "verification"
]
class PipelineConfigUpdate(BaseModel):
vad: Optional[dict[str, Any]] = None
wake_word: Optional[dict[str, Any]] = None
semantic_validation: Optional[dict[str, Any]] = None
confidence: Optional[dict[str, Any]] = None
agent_chaining: Optional[dict[str, Any]] = None
auto_tuning: Optional[dict[str, Any]] = None
stages_order: Optional[list[str]] = None
class PipelineStatsEntry(BaseModel):
id: str
user_id: str | None
stage: str
passed: bool
reason: str | None
duration_ms: int | None
created_at: datetime
class PipelineStatsSummary(BaseModel):
total_entries: int
stages: dict[str, int]
passed_ratio: float
avg_duration_ms: float | None
fail_reasons: list[tuple[str, int]]
+22
View File
@@ -0,0 +1,22 @@
"""Sync schemas for VoIdea API."""
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel
class SyncPullRequest(BaseModel):
last_sync: Optional[datetime] = None
device_id: str
class SyncPushRequest(BaseModel):
device_id: str
changes: list[dict[str, Any]]
class SyncResponse(BaseModel):
status: str
changes: list[dict[str, Any]] = []
sync_token: Optional[str] = None
+55
View File
@@ -0,0 +1,55 @@
"""Tariff schemas for VoIdea API."""
from datetime import datetime
from decimal import Decimal
from typing import Any, Optional
from pydantic import BaseModel, Field
class TariffPlanCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
code: str = Field(min_length=1, max_length=50)
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
class TariffPlanUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
price_monthly: Optional[Decimal] = None
price_yearly: Optional[Decimal] = None
features: Optional[dict[str, Any]] = None
is_active: Optional[bool] = None
sort_order: Optional[int] = None
class TariffPlanResponse(BaseModel):
id: str
name: str
code: str
description: Optional[str] = None
price_monthly: Decimal
price_yearly: Optional[Decimal] = None
features: Optional[dict[str, Any]] = None
is_active: bool
sort_order: int
created_at: datetime
updated_at: datetime
class UserSubscriptionResponse(BaseModel):
id: str
user_id: str
plan_id: str
plan_name: str = ""
plan_code: str = ""
status: str
current_period_start: Optional[datetime] = None
current_period_end: Optional[datetime] = None
canceled_at: Optional[datetime] = None
created_at: datetime

Some files were not shown because too many files have changed in this diff Show More