Initial commit: VoIdeaAI - voice-first AI idea assistant
This commit is contained in:
@@ -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*
|
||||
@@ -0,0 +1,4 @@
|
||||
"""VoIdea application package."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__project__ = "VoIdea"
|
||||
@@ -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 |
|
||||
@@ -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",
|
||||
])
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,10 @@
|
||||
# api Module - VoIdea
|
||||
|
||||
## Overview
|
||||
|
||||
[Auto-generated documentation]
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
@@ -0,0 +1 @@
|
||||
"""VoIdea - API module."""
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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": "Пароль успешно изменён"}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
]
|
||||
@@ -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 |
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""VoIdea - Integrations module (AI providers, external services)."""
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Telegram bot integration for VoIdea."""
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
@@ -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(),
|
||||
}
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 |
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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]]
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user