Initial commit: VoIdeaAI - voice-first AI idea assistant

This commit is contained in:
2026-05-13 12:51:42 +03:00
commit 688d043dad
421 changed files with 47915 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# agents Module - VoIdea
## Overview
[Auto-generated documentation]
## Files
| File | Purpose |
|------|---------|
| `audit_agent.py` | Module file |
| `backlog_agent.py` | Module file |
| `base.py` | Base classes |
| `doc_agent.py` | Module file |
| `evolution_agent.py` | Module file |
| `fix_agent.py` | Module file |
| `models.py` | Data models |
| `observer_agent.py` | Module file |
| `qa_tester_agent.py` | Module file |
| `registry.py` | Module file |
| `rollout_agent.py` | Module file |
| `security_agent.py` | Module file |
| `spec_agent.py` | Module file |
| `triggers.py` | Module file |
| `ui_test_agent.py` | Module file |
+52
View File
@@ -0,0 +1,52 @@
from app.agents.base import (
AgentMetrics,
AgentResult,
AgentStatus,
AgentTrigger,
BaseAgent,
)
from app.agents.registry import AgentRegistry, registry, get_agent, get_all_agents
from app.agents.triggers import TriggerManager, PreCommitHook, run_agent_manually
__all__ = [
"AgentMetrics",
"AgentResult",
"AgentStatus",
"AgentTrigger",
"BaseAgent",
"AgentRegistry",
"registry",
"get_agent",
"get_all_agents",
"TriggerManager",
"PreCommitHook",
"run_agent_manually",
]
from app.agents.doc_agent import DocAgent
from app.agents.backlog_agent import BacklogAgent
from app.agents.spec_agent import SpecAgent
from app.agents.audit_agent import AuditAgent
from app.agents.observer_agent import ObserverAgent
from app.agents.evolution_agent import EvolutionAgent
from app.agents.security_agent import SecurityAgent
from app.agents.qa_tester_agent import QATesterAgent
from app.agents.fix_agent import FixAgent
from app.agents.ui_test_agent import UITestAgent
from app.agents.rollout_agent import RolloutAgent
from app.agents.conductor_agent import ConductorAgent
__all__.extend([
"DocAgent",
"BacklogAgent",
"SpecAgent",
"AuditAgent",
"ObserverAgent",
"EvolutionAgent",
"SecurityAgent",
"QATesterAgent",
"FixAgent",
"UITestAgent",
"RolloutAgent",
"ConductorAgent",
])
+251
View File
@@ -0,0 +1,251 @@
"""AuditAgent - Code quality and rules compliance for VoIdea.
This agent:
- Checks code style (ruff)
- Checks type hints (mypy)
- Monitors project progress
- Verifies documentation compliance
"""
import asyncio
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
settings = get_settings()
class AuditAgent(BaseAgent):
"""Code quality and compliance audit agent."""
name = "audit_agent"
version = "1.0.0"
description = "Monitors code quality and rule compliance"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.PRE_COMMIT,
AgentTrigger.CRON,
]
def __init__(self):
super().__init__()
self.project_root = Path(__file__).parent.parent.parent
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute audit task.
Context can contain:
- action: str (full, quick, style, types, docs)
- paths: list[str] (paths to audit)
"""
await self.set_running("audit")
try:
action = context.get("action", "full") if context else "full"
paths = context.get("paths", ["app"])
if action == "full":
result = await self._run_full_audit(paths)
elif action == "quick":
result = await self._run_quick_audit(paths)
elif action == "style":
result = await self._run_style_check(paths)
elif action == "types":
result = await self._run_type_check(paths)
elif action == "docs":
result = await self._check_docs()
else:
result = await self._run_quick_audit(paths)
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"AuditAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if AuditAgent is operational."""
try:
return self.project_root.exists()
except Exception:
return False
async def _run_full_audit(self, paths: list[str]) -> AgentResult:
"""Run full audit including all checks."""
style_result = await self._run_style_check(paths)
types_result = await self._run_type_check(paths)
docs_result = await self._check_docs()
issues = []
issues.extend(style_result.data.get("issues", []))
issues.extend(types_result.data.get("issues", []))
issues.extend(docs_result.data.get("issues", []))
passed = (
style_result.success and
types_result.success and
docs_result.success
)
return AgentResult(
success=passed,
message=f"Full audit {'passed' if passed else 'failed'}: {len(issues)} issues",
data={
"style_check": style_result.data,
"type_check": types_result.data,
"docs_check": docs_result.data,
"total_issues": len(issues),
},
)
async def _run_quick_audit(self, paths: list[str]) -> AgentResult:
"""Run quick audit (ruff only)."""
return await self._run_style_check(paths)
async def _run_style_check(self, paths: list[str]) -> AgentResult:
"""Run code style check with ruff."""
issues = []
try:
for path in paths:
path_obj = self.project_root / path
if not path_obj.exists():
issues.append(f"Path not found: {path}")
continue
result = subprocess.run(
["python", "-m", "ruff", "check", str(path_obj)],
capture_output=True,
text=True,
cwd=str(self.project_root),
)
if result.stdout:
for line in result.stdout.split("\n"):
if line.strip():
issues.append(line.strip())
return AgentResult(
success=len(issues) == 0,
message=f"Style check: {len(issues)} issues" if issues else "Style check passed",
data={
"tool": "ruff",
"paths": paths,
"issues": issues[:50],
"total_issues": len(issues),
},
)
except FileNotFoundError:
return AgentResult(
success=True,
message="Ruff not installed, skipping style check",
data={"tool": "ruff", "skipped": True},
)
except Exception as e:
return AgentResult(
success=False,
message=f"Style check failed: {str(e)}",
errors=[str(e)],
)
async def _run_type_check(self, paths: list[str]) -> AgentResult:
"""Run type checking with mypy."""
issues = []
try:
for path in paths:
path_obj = self.project_root / path
if not path_obj.exists():
continue
result = subprocess.run(
["python", "-m", "mypy", str(path_obj), "--ignore-missing-imports"],
capture_output=True,
text=True,
cwd=str(self.project_root),
)
if result.stdout:
for line in result.stdout.split("\n"):
if "error:" in line.lower() or "warning:" in line.lower():
issues.append(line.strip())
return AgentResult(
success=len(issues) == 0,
message=f"Type check: {len(issues)} issues" if issues else "Type check passed",
data={
"tool": "mypy",
"paths": paths,
"issues": issues[:50],
"total_issues": len(issues),
},
)
except FileNotFoundError:
return AgentResult(
success=True,
message="Mypy not installed, skipping type check",
data={"tool": "mypy", "skipped": True},
)
except Exception as e:
return AgentResult(
success=False,
message=f"Type check failed: {str(e)}",
errors=[str(e)],
)
async def _check_docs(self) -> AgentResult:
"""Check documentation completeness."""
missing_docs = []
docs_dir = self.project_root / "docs"
app_dir = self.project_root / "app"
required_docs = [
"blocks/00-rules.md",
"blocks/PLAN.md",
"instructions/00-system-prompt.md",
]
for doc in required_docs:
if not (docs_dir / doc).exists():
missing_docs.append(doc)
module_readmes = [
"core/README.md",
"models/README.md",
"api/README.md",
"services/README.md",
]
for readme in module_readmes:
if not (app_dir / readme).exists():
missing_docs.append(f"app/{readme}")
return AgentResult(
success=len(missing_docs) == 0,
message=f"Documentation check: {len(missing_docs)} missing files",
data={
"missing_docs": missing_docs,
"total_missing": len(missing_docs),
},
)
async def get_metrics(self) -> dict[str, Any]:
"""Get AuditAgent metrics."""
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"last_run": self.last_run.isoformat() if self.last_run else None,
}
+285
View File
@@ -0,0 +1,285 @@
"""BacklogAgent - Backlog management for VoIdea.
This agent:
- Creates and manages backlog items
- Tracks ideas, plans, tasks
- Prioritizes work
- Sends reminders
"""
import re
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
from sqlalchemy import select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.agents.models import BacklogItem
class BacklogAgent(BaseAgent):
"""Backlog management agent."""
name = "backlog_agent"
version = "1.0.0"
description = "Manages project backlog: ideas, tasks, plans"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.CRON,
AgentTrigger.EVENT,
]
def __init__(self, session: AsyncSession | None = None):
super().__init__()
self._session = session
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute backlog management task.
Context can contain:
- action: str (create, list, update, delete, suggest)
- item_type: str (idea, plan, task, improvement)
- title: str
- description: str
- priority: str (low, medium, high, critical)
- source: str (opencode, admin_panel, user, agent)
"""
await self.set_running("backlog_management")
try:
action = context.get("action", "list") if context else "list"
if action == "create":
result = await self._create_item(context or {})
elif action == "list":
result = await self._list_items(context or {})
elif action == "update":
result = await self._update_item(context or {})
elif action == "delete":
result = await self._delete_item(context or {})
elif action == "suggest":
result = await self._suggest_items()
else:
result = await self._list_items({})
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"BacklogAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if BacklogAgent is operational."""
return True
async def _create_item(self, context: dict[str, Any]) -> AgentResult:
"""Create a new backlog item."""
if not self._session:
return AgentResult(
success=False,
message="Database session not configured",
)
item_type = context.get("item_type", "task")
title = context.get("title", "Untitled")
description = context.get("description", "")
priority = context.get("priority", "medium")
source = context.get("source", "agent")
created_by = context.get("created_by", "BacklogAgent")
tags = context.get("tags", [])
block_ref = context.get("block_ref")
item = BacklogItem(
id=uuid4(),
item_type=item_type,
title=title,
description=description,
priority=priority,
status="pending",
source=source,
created_by=created_by,
tags=tags,
block_ref=block_ref,
)
self._session.add(item)
await self._session.commit()
return AgentResult(
success=True,
message=f"Created backlog item: {title}",
data={
"id": str(item.id),
"type": item_type,
"title": title,
"priority": priority,
},
)
async def _list_items(
self,
context: dict[str, Any],
limit: int = 50,
) -> AgentResult:
"""List backlog items."""
if not self._session:
return AgentResult(
success=False,
message="Database session not configured",
)
item_type = context.get("item_type")
status_filter = context.get("status")
priority_filter = context.get("priority")
query = select(BacklogItem).order_by(BacklogItem.created_at.desc())
if item_type:
query = query.where(BacklogItem.item_type == item_type)
if status_filter:
query = query.where(BacklogItem.status == status_filter)
if priority_filter:
query = query.where(BacklogItem.priority == priority_filter)
query = query.limit(limit)
result = await self._session.execute(query)
items = result.scalars().all()
items_data = [
{
"id": str(item.id),
"type": item.item_type,
"title": item.title,
"priority": item.priority,
"status": item.status,
"created_at": item.created_at.isoformat() if item.created_at else None,
}
for item in items
]
return AgentResult(
success=True,
message=f"Found {len(items)} backlog items",
data={"items": items_data, "count": len(items)},
)
async def _update_item(self, context: dict[str, Any]) -> AgentResult:
"""Update a backlog item."""
if not self._session:
return AgentResult(
success=False,
message="Database session not configured",
)
item_id = context.get("id")
if not item_id:
return AgentResult(
success=False,
message="Item ID required for update",
)
update_data = {}
for field in ["title", "description", "priority", "status", "tags"]:
if field in context:
update_data[field] = context[field]
if update_data:
stmt = (
update(BacklogItem)
.where(BacklogItem.id == UUID(item_id))
.values(**update_data)
)
await self._session.execute(stmt)
await self._session.commit()
return AgentResult(
success=True,
message=f"Updated backlog item: {item_id}",
data={"id": item_id, "updated": update_data},
)
async def _delete_item(self, context: dict[str, Any]) -> AgentResult:
"""Delete a backlog item."""
if not self._session:
return AgentResult(
success=False,
message="Database session not configured",
)
item_id = context.get("id")
if not item_id:
return AgentResult(
success=False,
message="Item ID required for delete",
)
stmt = delete(BacklogItem).where(BacklogItem.id == UUID(item_id))
await self._session.execute(stmt)
await self._session.commit()
return AgentResult(
success=True,
message=f"Deleted backlog item: {item_id}",
data={"id": item_id},
)
async def _suggest_items(self) -> AgentResult:
"""Suggest backlog items based on project needs."""
suggestions = [
{
"type": "task",
"title": "Set up PostgreSQL local database",
"priority": "high",
"reason": "Required for Block 2: Data",
},
{
"type": "task",
"title": "Configure environment variables",
"priority": "medium",
"reason": "Prerequisite for running app",
},
{
"type": "improvement",
"title": "Add pre-commit hooks",
"priority": "low",
"reason": "Improves code quality",
},
]
return AgentResult(
success=True,
message="Generated backlog suggestions",
data={"suggestions": suggestions},
)
async def get_metrics(self) -> dict[str, Any]:
"""Get BacklogAgent metrics."""
if not self._session:
return {
"agent_id": self.name,
"status": self.status.value,
}
try:
pending_count = await self._session.execute(
select(BacklogItem).where(BacklogItem.status == "pending")
)
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"pending_items": pending_count.scalars().count(),
}
except Exception:
return {
"agent_id": self.name,
"status": self.status.value,
}
+259
View File
@@ -0,0 +1,259 @@
"""Base classes and utilities for VoIdea agents."""
import hashlib
import inspect
import re
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Generic, TypeVar
from uuid import UUID, uuid4
from pydantic import BaseModel, ConfigDict, Field
from app.core.base import CoreModel
T = TypeVar("T")
class AgentStatus(str, Enum):
"""Agent status enumeration."""
IDLE = "idle"
RUNNING = "running"
ERROR = "error"
OFFLINE = "offline"
class AgentTrigger(str, Enum):
"""Agent trigger types."""
MANUAL = "manual"
PRE_COMMIT = "pre_commit"
PUSH = "push"
TAG_CREATION = "tag_creation"
CRON = "cron"
API = "api"
EVENT = "event"
class AgentResult(CoreModel):
"""Result of agent execution."""
success: bool
message: str = ""
data: dict[str, Any] = Field(default_factory=dict)
errors: list[str] = Field(default_factory=list)
duration_ms: int = 0
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class AgentMetrics(CoreModel):
"""Agent performance metrics."""
agent_id: str
version: str = "1.0.0"
requests_total: int = 0
requests_success: int = 0
requests_failed: int = 0
average_duration_ms: float = 0.0
last_run: datetime | None = None
class BaseAgent(ABC):
"""Abstract base class for all agents.
All agents must inherit from this class and implement required methods.
"""
name: str = ""
version: str = "1.0.0"
description: str = ""
triggers: list[AgentTrigger] = [AgentTrigger.MANUAL]
changelog_dir: Path = Path("CHANGELOG") / "agents"
def __init__(self):
self._status = AgentStatus.IDLE
self._last_run: datetime | None = None
self._current_task: str | None = None
self._changelog_path = self.changelog_dir / f"{self.name}.md"
@property
def status(self) -> AgentStatus:
"""Get current agent status."""
return self._status
@property
def last_run(self) -> datetime | None:
"""Get last run timestamp."""
return self._last_run
@abstractmethod
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute agent task.
Args:
context: Optional context data for the agent
Returns:
AgentResult with execution outcome
"""
pass
@abstractmethod
async def health_check(self) -> bool:
"""Check if agent is healthy and operational.
Returns:
True if agent can execute tasks
"""
pass
async def get_status(self) -> AgentStatus:
"""Get current agent status.
Returns:
Current status from status property
"""
return self.status
async def get_metrics(self) -> AgentMetrics:
"""Get agent performance metrics.
Returns:
AgentMetrics instance
"""
return AgentMetrics(
agent_id=self.name,
version=self.version,
last_run=self.last_run,
)
async def set_running(self, task: str) -> None:
"""Set agent to running state."""
self._status = AgentStatus.RUNNING
self._current_task = task
self._last_run = datetime.now(timezone.utc)
async def set_idle(self) -> None:
"""Set agent to idle state."""
self._status = AgentStatus.IDLE
self._current_task = None
async def set_error(self, error: str) -> None:
"""Set agent to error state."""
self._status = AgentStatus.ERROR
self._current_task = None
async def set_offline(self) -> None:
"""Set agent to offline state."""
self._status = AgentStatus.OFFLINE
def compute_checksum(self) -> str:
"""Compute SHA256 checksum of this agent's source file.
Returns:
Hex digest of the file content.
"""
file_path = inspect.getfile(self.__class__)
content = Path(file_path).read_bytes()
return hashlib.sha256(content).hexdigest()
def _read_changelog_checksum(self) -> str | None:
"""Read stored checksum from changelog file.
Returns:
Stored checksum or None if file doesn't exist.
"""
if not self._changelog_path.exists():
return None
content = self._changelog_path.read_text(encoding="utf-8")
match = re.search(r"<!--\s*checksum:\s*([a-f0-9]{64})\s*-->", content)
return match.group(1) if match else None
def bump_version(self, version_type: str = "patch") -> str:
"""Bump agent version (major.minor.patch).
Args:
version_type: "major", "minor", or "patch"
Returns:
New version string.
"""
major, minor, patch = map(int, self.version.split("."))
if version_type == "major":
major += 1
minor = 0
patch = 0
elif version_type == "minor":
minor += 1
patch = 0
else:
patch += 1
self.version = f"{major}.{minor}.{patch}"
return self.version
def _write_changelog_entry(self, version: str, entries: list[str]) -> None:
"""Write a changelog entry for this agent.
Args:
version: New version string.
entries: List of change descriptions.
"""
self.changelog_dir.mkdir(parents=True, exist_ok=True)
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
checksum = self.compute_checksum()
header = f"# {self.name} Changelog\n<!-- checksum: {checksum} -->\n"
entry = f"\n## {version} ({today})\n"
for line in entries:
entry += f"- {line}\n"
if self._changelog_path.exists():
old = self._changelog_path.read_text(encoding="utf-8")
new = header + entry + old.split("\n", 2)[-1] if "\n" in old else old
self._changelog_path.write_text(new, encoding="utf-8")
else:
self._changelog_path.write_text(header + entry, encoding="utf-8")
async def _check_version(self, changelog_entries: list[str] | None = None) -> str | None:
"""Check if agent changed and bump version if needed.
Should be called after run(). Compares current file checksum
with stored checksum in changelog. On mismatch bumps patch
and writes changelog entry.
Args:
changelog_entries: Optional list of change descriptions.
If None, auto-generated from git diff summary.
Returns:
New version string if bumped, None if unchanged.
"""
current_checksum = self.compute_checksum()
stored_checksum = self._read_changelog_checksum()
if current_checksum == stored_checksum:
return None
old_version = self.version
new_version = self.bump_version("patch")
entries = changelog_entries or ["Auto-detected code changes"]
self._write_changelog_entry(new_version, entries)
return new_version
def __repr__(self) -> str:
return f"<{self.__class__.__name__}(name={self.name}, status={self.status.value})>"
class AgentResponse(CoreModel):
"""Standardized agent response."""
agent: str
status: str
message: str
data: dict[str, Any] = Field(default_factory=dict)
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+307
View File
@@ -0,0 +1,307 @@
"""Дирижёр — главный оркестратор VoIdeaAI.
Принимает голосовой ввод пользователя, определяет намерение,
направляет ролевому агенту, верифицирует ответ, возвращает пользователю.
Самообучение через логирование, рейтинг и историю успешных кейсов.
"""
import json
import time
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.base import AgentResult, AgentStatus, BaseAgent
from app.agents.conductor_storage import (
auto_tune_user,
check_suggested_command,
get_recent_history,
get_similar_successful,
log_interaction,
)
from app.agents.role_agents import (
ALL_ROLE_AGENTS,
VERIFICATION_PROMPT,
RoleAgent,
run_role_agent,
)
from app.agents.vad import should_process_audio
from app.agents.wake_word import strip_wake_word, has_wake_word
from app.services.llm_service import chat_completion
from app.services.pipeline_service import PipelineService
from app.services.session_service import create_session, update_session_title
class ConductorAgent(BaseAgent):
name = "Дирижёр"
version = "1.0.0"
description = (
"Главный оркестратор. Принимает голосовой/текстовый ввод пользователя, "
"определяет намерение, направляет специализированному агенту, "
"верифицирует ответ и возвращает пользователю. Самообучение через рейтинг."
)
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
return AgentResult(
success=True,
message="Дирижёр: запущен. Используйте /api/v1/voice/chat для взаимодействия.",
)
async def process(
self,
user_input: str,
db: AsyncSession | None = None,
user_id: str | None = None,
session_id: str | None = None,
vad_enabled: bool | None = None,
wake_word_detected: bool | None = None,
audio_duration_ms: int | None = None,
pipeline_mode: str | None = None,
) -> dict[str, Any]:
"""Process user input through the full conductor pipeline.
Args:
user_input: User's text input
db: Optional DB session for self-learning
user_id: Optional user ID for history
session_id: Optional session ID. Auto-creates if not provided.
vad_enabled: Whether VAD was used client-side
wake_word_detected: Whether wake word was detected client-side
audio_duration_ms: Duration of audio input in ms
pipeline_mode: Pipeline mode override ("fast" | "full" | "off")
Returns:
Dict with response, agent_name, confidence, verification_status,
interaction_id, session_id
"""
start = time.time()
pipeline_service = PipelineService(db) if db else None
stages_log: list[dict] = []
# ── 0. VAD filter ──
if audio_duration_ms is not None:
should_process, skip_reason = should_process_audio(
audio_duration_ms, {"enabled": vad_enabled if vad_enabled is not None else True}
)
if not should_process:
if pipeline_service:
await pipeline_service.record_stat(
user_id=user_id, stage="vad", passed=False,
reason=skip_reason, duration_ms=0,
)
return {
"response": "",
"agent_name": "",
"agent_description": "",
"confidence": 0,
"verification_status": "skipped",
"processing_time_ms": 0,
"interaction_id": "",
"session_id": session_id or "",
"suggested_command": None,
}
# ── 1. Wake word stripping ──
if wake_word_detected:
cleaned = strip_wake_word(user_input)
if cleaned:
user_input = cleaned
# ── 2. Авто-создание сессии ──
is_new_session = False
if db and user_id and not session_id:
session = await create_session(db, user_id)
session_id = str(session.id)
is_new_session = True
# ── Подготовка контекста с историей сессии ──
agent_names = [
{"name": a.name, "description": a.description}
for a in ALL_ROLE_AGENTS
]
context_parts = []
if db and user_id:
history = await get_recent_history(db, user_id, limit=5)
if history:
context_parts.append("Недавние обсуждения:\n" + "\n".join(
f"[{h['agent']}]: {h['input']}{h['response'][:100]}"
for h in history
))
similar = await get_similar_successful(db, user_input)
if similar:
context_parts.append("Похожие успешные кейсы:\n" + "\n".join(
f"Было: {s['input'][:100]}, Ответ: {s['response'][:100]}"
for s in similar
))
llm_context = "\n\n".join(context_parts) if context_parts else None
# ── 3. Выбор агента ──
route_start = time.time()
selected_agent = await self._route(user_input, agent_names, llm_context)
if not selected_agent:
selected_agent = "Бизнес-аналитик"
route_elapsed = (time.time() - route_start) * 1000
agent = next(
(a for a in ALL_ROLE_AGENTS if a.name == selected_agent),
ALL_ROLE_AGENTS[0],
)
stages_log.append({"stage": "routing", "passed": True, "duration_ms": route_elapsed})
if pipeline_service:
await pipeline_service.record_stat(
user_id=user_id, stage="routing", passed=True,
duration_ms=int(route_elapsed),
)
# ── 4. Генерация ответа ──
response = await run_role_agent(agent, user_input, llm_context)
if not response:
response = "Не удалось обработать запрос. Проверьте API ключи."
# ── 5. Верификация ответа ──
verify_start = time.time()
verification = await self._verify(response, user_input)
verify_elapsed = (time.time() - verify_start) * 1000
confidence = verification.get("confidence", 50)
issues = verification.get("issues", [])
corrected = verification.get("corrected", "")
if corrected:
response = corrected
verification_status = "issues_found"
elif confidence >= 80:
verification_status = "verified"
elif confidence >= 50:
verification_status = "warning"
else:
verification_status = "needs_clarification"
response = (
"Извините, я не до конца уверен в ответе. "
"Не могли бы вы уточнить свой запрос?\n\n"
f"Вот что я понял: {response[:300]}"
)
stages_log.append({"stage": "verification", "passed": confidence >= 50, "duration_ms": verify_elapsed})
if pipeline_service:
await pipeline_service.record_stat(
user_id=user_id, stage="verification", passed=confidence >= 50,
reason=None if confidence >= 50 else "low_confidence",
duration_ms=int(verify_elapsed),
)
elapsed = (time.time() - start) * 1000
# ── 6. Логирование ──
interaction_id = ""
if db:
interaction_id = await log_interaction(
db=db,
user_id=user_id,
session_id=session_id,
input_text=user_input[:1000],
detected_intent=selected_agent,
selected_agent=selected_agent,
response_text=response,
processing_time_ms=elapsed,
confidence=confidence,
verification_status=verification_status,
was_auto_routed=True,
context={"issues": issues, "stages": stages_log} if issues else None,
)
# ── 7. Title generation для новой сессии ──
if db and session_id and is_new_session:
title_start = time.time()
title = await self._generate_title(user_input)
title_elapsed = (time.time() - title_start) * 1000
await update_session_title(db, session_id, title)
if pipeline_service:
await pipeline_service.record_stat(
user_id=user_id, stage="title_generation", passed=True,
duration_ms=int(title_elapsed),
)
# ── 8. Auto-tuning (каждые ~5 взаимодействий) ──
if db and user_id:
suggested_command = await check_suggested_command(db, user_id)
return {
"response": response,
"agent_name": selected_agent,
"agent_description": agent.description,
"confidence": confidence,
"verification_status": verification_status,
"processing_time_ms": round(elapsed, 1),
"interaction_id": interaction_id,
"session_id": session_id or "",
"suggested_command": suggested_command,
}
async def _route(
self,
user_input: str,
agent_names: list[dict[str, str]],
context: str | None = None,
) -> str | None:
system = "Ты — дирижёр умных ассистентов. Определи лучшего агента для ответа."
if context:
system += f"\n\nКонтекст:\n{context}"
agent_list = "\n".join(f"- {a['name']}: {a['description']}" for a in agent_names)
system += f"\n\nДоступные агенты:\n{agent_list}\n\nОтветь ТОЛЬКО именем агента."
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user_input},
]
result = await chat_completion(messages, temperature=0.3, max_tokens=64)
if not result:
return None
result = result.strip().strip('"').strip("'")
valid = {a["name"] for a in agent_names}
return result if result in valid else None
async def _verify(
self,
response: str,
original_input: str,
) -> dict[str, Any]:
messages = [
{"role": "system", "content": VERIFICATION_PROMPT},
{
"role": "user",
"content": (
f"Запрос пользователя: {original_input}\n\n"
f"Ответ агента: {response}"
),
},
]
result = await chat_completion(messages, temperature=0.2, max_tokens=1024)
if not result:
return {"status": "verified", "confidence": 80, "issues": [], "corrected": ""}
try:
cleaned = result.strip()
if cleaned.startswith("```"):
cleaned = cleaned.split("\n", 1)[-1].rsplit("\n", 1)[0]
return json.loads(cleaned)
except (json.JSONDecodeError, KeyError):
return {"status": "verified", "confidence": 80, "issues": [], "corrected": ""}
async def _generate_title(self, user_input: str) -> str:
messages = [
{"role": "system", "content": (
"Ты — ассистент, который придумывает короткие заголовки для обсуждений. "
"Ответь одним предложением (до 7 слов), отражающим суть запроса."
)},
{"role": "user", "content": f"Придумай заголовок для обсуждения этого запроса:\n{user_input}"},
]
result = await chat_completion(messages, temperature=0.3, max_tokens=30)
if result:
return result.strip().strip('"').strip("'")[:255]
return "Новое обсуждение"
async def health_check(self) -> bool:
return True
+267
View File
@@ -0,0 +1,267 @@
"""Storage for Дирижёр interactions — self-learning and analytics."""
import json
from collections import Counter
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.conductor import ConductorInteraction
from app.models.pipeline import PipelineStats
from app.models.user import User
from app.models.voice_command import VoiceCommand
async def log_interaction(
db: AsyncSession,
user_id: str | None,
input_text: str,
detected_intent: str,
selected_agent: str,
response_text: str,
processing_time_ms: float,
confidence: int = 80,
verification_status: str = "verified",
was_auto_routed: bool = True,
context: dict[str, Any] | None = None,
session_id: str | None = None,
) -> str:
log = ConductorInteraction(
user_id=user_id,
session_id=session_id,
input_text=input_text,
detected_intent=detected_intent,
selected_agent=selected_agent,
response_text=response_text,
processing_time_ms=processing_time_ms,
confidence_score=confidence,
verification_status=verification_status,
was_auto_routed=was_auto_routed,
context=json.dumps(context) if context else None,
)
db.add(log)
await db.commit()
await db.refresh(log)
return str(log.id)
async def rate_interaction(db: AsyncSession, interaction_id: str, rating: int) -> bool:
result = await db.execute(
update(ConductorInteraction)
.where(ConductorInteraction.id == interaction_id)
.values(user_rating=rating)
)
await db.commit()
return result.rowcount > 0
async def get_similar_successful(
db: AsyncSession,
input_text: str,
limit: int = 5,
hours: int = 24 * 7,
) -> list[dict[str, Any]]:
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
result = await db.execute(
select(ConductorInteraction)
.where(ConductorInteraction.created_at >= cutoff)
.where(ConductorInteraction.user_rating >= 4)
.where(ConductorInteraction.confidence_score >= 70)
.order_by(ConductorInteraction.created_at.desc())
.limit(limit * 3)
)
logs = result.scalars().all()
scored = []
for log in logs:
score = _text_similarity(input_text.lower(), log.input_text.lower())
if score > 0.3:
scored.append((score, {
"input": log.input_text,
"agent": log.selected_agent,
"response": log.response_text,
"rating": log.user_rating,
}))
scored.sort(key=lambda x: -x[0])
return [s[1] for s in scored[:limit]]
async def get_session_history(
db: AsyncSession,
session_id: str,
limit: int = 100,
) -> list[dict[str, Any]]:
result = await db.execute(
select(ConductorInteraction)
.where(ConductorInteraction.session_id == session_id)
.order_by(ConductorInteraction.created_at.asc())
.limit(limit)
)
return [
{
"id": str(log.id),
"input": log.input_text,
"agent": log.selected_agent,
"response": log.response_text,
"confidence": log.confidence_score,
"rating": log.user_rating,
"created_at": log.created_at.isoformat(),
}
for log in result.scalars().all()
]
async def get_recent_history(
db: AsyncSession,
user_id: str,
limit: int = 10,
) -> list[dict[str, Any]]:
result = await db.execute(
select(ConductorInteraction)
.where(ConductorInteraction.user_id == user_id)
.order_by(ConductorInteraction.created_at.desc())
.limit(limit)
)
return [
{
"input": log.input_text,
"agent": log.selected_agent,
"response": log.response_text,
"confidence": log.confidence_score,
"rating": log.user_rating,
"created_at": log.created_at.isoformat(),
}
for log in result.scalars().all()
]
async def check_suggested_command(
db: AsyncSession,
user_id: str,
min_count: int = 3,
) -> str | None:
"""Check if user has a command that's been used enough to suggest customizing it."""
result = await db.execute(
select(VoiceCommand)
.where(VoiceCommand.user_id == user_id)
.where(VoiceCommand.count >= min_count)
.order_by(VoiceCommand.count.desc())
.limit(1)
)
cmd = result.scalar_one_or_none()
if not cmd:
return None
return f"Команда «{cmd.phrase}» сработала {cmd.count} раз. Настроить в /voice/help"
AUTO_TUNING_CONFIG = {
"min_samples": 5,
"rejection_threshold": 5,
"lookback_hours": 24,
"adjustment_factor": 0.05,
}
async def auto_tune_user(
db: AsyncSession,
user_id: str,
config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Analyze user interaction patterns and auto-tune pipeline parameters.
Examines recent rejections, low-rated interactions, and pipeline failures,
then adjusts User.pipeline_tuning JSONB accordingly.
"""
tuning = config or dict(AUTO_TUNING_CONFIG)
min_samples = tuning.get("min_samples", 5)
lookback = tuning.get("lookback_hours", 24)
cutoff = datetime.now(timezone.utc) - timedelta(hours=lookback)
# ── 1. Count explicit rejections (rating < 3) ──
explicit_result = await db.execute(
select(func.count(ConductorInteraction.id))
.where(ConductorInteraction.user_id == user_id)
.where(ConductorInteraction.created_at >= cutoff)
.where(ConductorInteraction.user_rating < 3)
)
explicit_rejections = explicit_result.scalar() or 0
# ── 2. Count implicit rejections (confidence < 50, needs_clarification) ──
implicit_result = await db.execute(
select(func.count(ConductorInteraction.id))
.where(ConductorInteraction.user_id == user_id)
.where(ConductorInteraction.created_at >= cutoff)
.where(ConductorInteraction.verification_status == "needs_clarification")
)
implicit_rejections = implicit_result.scalar() or 0
total_rejections = explicit_rejections + implicit_rejections
# ── 3. Pipeline stage failures ──
stage_fails = await db.execute(
select(PipelineStats.stage, func.count(PipelineStats.id))
.where(PipelineStats.user_id == user_id)
.where(PipelineStats.created_at >= cutoff)
.where(PipelineStats.passed == False)
.group_by(PipelineStats.stage)
.order_by(func.count(PipelineStats.id).desc())
)
stage_failures: dict[str, int] = dict(stage_fails.all())
# ── 4. Calculate adjustments ──
adjustments: dict[str, Any] = {}
rejection_ratio = total_rejections / max(min_samples, 1)
if total_rejections >= tuning.get("rejection_threshold", 5):
adj = tuning.get("adjustment_factor", 0.05)
adjustments["confidence_boost"] = round(min(adj * rejection_ratio, 0.3), 2)
adjustments["needs_clarification"] = True
if "vad" in stage_failures and stage_failures["vad"] >= 3:
adjustments["vad_noise_threshold"] = 0.4
adjustments["vad_silence_timeout_ms"] = 2000
if "wake_word" in stage_failures and stage_failures["wake_word"] >= 3:
adjustments["wake_word_sensitivity"] = 0.8
if "semantic_validation" in stage_failures and stage_failures["semantic_validation"] >= 3:
adjustments["semantic_validation_timeout_ms"] = 8000
# ── 5. Store in User.pipeline_tuning ──
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user and adjustments:
current_tuning = user.pipeline_tuning or {}
current_tuning["auto_tuned_at"] = datetime.now(timezone.utc).isoformat()
current_tuning["adjustments"] = {
**current_tuning.get("adjustments", {}),
**adjustments,
}
current_tuning["stats"] = {
"explicit_rejections": explicit_rejections,
"implicit_rejections": implicit_rejections,
"total_rejections": total_rejections,
"stage_failures": stage_failures,
}
user.pipeline_tuning = current_tuning
await db.commit()
return {
"tuned": bool(adjustments),
"adjustments": adjustments,
"total_rejections": total_rejections,
"stage_failures": stage_failures,
}
def _text_similarity(a: str, b: str) -> float:
if not a or not b:
return 0.0
words_a = set(a.split())
words_b = set(b.split())
if not words_a or not words_b:
return 0.0
intersection = words_a & words_b
return len(intersection) / max(len(words_a), len(words_b))
+219
View File
@@ -0,0 +1,219 @@
"""DocAgent - Documentation automation for VoIdea.
This agent automatically:
- Creates README.md for new modules
- Updates documentation when code changes
- Generates docstrings
- Maintains Runbook
"""
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
settings = get_settings()
class DocAgent(BaseAgent):
"""Documentation automation agent."""
name = "doc_agent"
version = "1.0.0"
description = "Automatically maintains project documentation"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.PRE_COMMIT,
AgentTrigger.PUSH,
]
def __init__(self):
super().__init__()
self.project_root = Path(__file__).parent.parent.parent
self.docs_dir = self.project_root / "docs"
self.app_dir = self.project_root / "app"
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute documentation task.
Context can contain:
- action: str (update_readme, generate_docs, etc.)
- module: str (module path to document)
- files: list[str] (changed files)
"""
await self.set_running("documentation")
try:
action = context.get("action", "update_all") if context else "update_all"
if action == "update_readme":
module = context.get("module", "")
result = await self._update_module_readme(module)
elif action == "generate_docs":
result = await self._generate_docs()
elif action == "update_session_context":
result = await self._update_session_context(context or {})
else:
result = await self._update_all()
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"DocAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if DocAgent is operational."""
try:
return self.docs_dir.exists() and self.app_dir.exists()
except Exception:
return False
async def _update_module_readme(self, module_path: str) -> AgentResult:
"""Update README.md for a specific module."""
module_dir = self.app_dir / module_path if module_path else self.app_dir
if not module_dir.exists():
return AgentResult(
success=False,
message=f"Module not found: {module_path}",
)
files = list(module_dir.glob("*.py"))
files = [f for f in files if f.name != "__init__.py"]
content = f"""# {module_path or 'app'} Module - VoIdea
## Overview
[Auto-generated documentation]
## Files
| File | Purpose |
|------|---------|
"""
for file in files:
purpose = self._get_file_purpose(file.name)
content += f"| `{file.name}` | {purpose} |\n"
readme_path = module_dir / "README.md"
readme_path.write_text(content, encoding="utf-8")
return AgentResult(
success=True,
message=f"Updated README for {module_path or 'app'}",
data={"module": module_path, "files_count": len(files)},
)
async def _update_all(self) -> AgentResult:
"""Update all documentation."""
updated = []
for module_dir in self.app_dir.iterdir():
if module_dir.is_dir() and (module_dir / "__init__.py").exists():
result = await self._update_module_readme(module_dir.name)
if result.success:
updated.append(module_dir.name)
session_result = await self._update_session_context({})
if session_result.success:
updated.append("SESSION_CONTEXT")
return AgentResult(
success=True,
message=f"Updated {len(updated)} documentation files",
data={"updated": updated},
)
async def _generate_docs(self) -> AgentResult:
"""Generate API documentation from docstrings."""
docs_generated = 0
endpoints = []
api_dir = self.app_dir / "api"
if api_dir.exists():
for file in api_dir.rglob("*.py"):
if file.name == "__init__.py":
continue
docs_generated += 1
endpoints.append(str(file.relative_to(self.app_dir)))
return AgentResult(
success=True,
message=f"Generated documentation for {docs_generated} files",
data={"endpoints": endpoints, "count": docs_generated},
)
async def _update_session_context(self, context: dict[str, Any]) -> AgentResult:
"""Update SESSION_CONTEXT.md with current progress."""
session_file = self.project_root / "SESSION_CONTEXT.md"
if not session_file.exists():
return AgentResult(
success=False,
message="SESSION_CONTEXT.md not found",
)
try:
content = session_file.read_text(encoding="utf-8")
if "Last Updated" not in content:
content = content.replace(
"================================================================================",
"Last Updated: {}\n================================================================================".format(
datetime.now(timezone.utc).isoformat()
),
)
session_file.write_text(content, encoding="utf-8")
return AgentResult(
success=True,
message="SESSION_CONTEXT.md updated",
data={"timestamp": datetime.now(timezone.utc).isoformat()},
)
except Exception as e:
return AgentResult(
success=False,
message=f"Failed to update SESSION_CONTEXT: {str(e)}",
errors=[str(e)],
)
def _get_file_purpose(self, filename: str) -> str:
"""Get file purpose based on naming convention."""
purposes = {
"main.py": "FastAPI application entry point",
"config.py": "Configuration management",
"models.py": "Data models",
"schemas.py": "Pydantic schemas",
"service.py": "Business logic",
"repository.py": "Data access layer",
"router.py": "API routes",
"base.py": "Base classes",
"exceptions.py": "Custom exceptions",
"security.py": "Security utilities",
"database.py": "Database setup",
"dependencies.py": "FastAPI dependencies",
}
return purposes.get(filename, "Module file")
async def get_metrics(self) -> dict[str, Any]:
"""Get DocAgent metrics."""
from app.agents.models import AgentMetric
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"last_run": self.last_run.isoformat() if self.last_run else None,
}
+329
View File
@@ -0,0 +1,329 @@
"""EvolutionAgent - Agent self-improvement and versioning system for VoIdea.
This agent:
- Analyzes agent performance
- Generates improvement suggestions
- Manages agent capabilities evolution
- Handles agent versioning (minor/major bumps)
- Coordinates learning
"""
import hashlib
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
settings = get_settings()
ALL_AGENTS: list[dict[str, Any]] = [
{"id": "doc_agent", "capabilities": ["documentation", "docstrings", "runbook"]},
{"id": "backlog_agent", "capabilities": ["create", "list", "update", "delete", "suggest"]},
{"id": "spec_agent", "capabilities": ["versioning", "changelog", "project_json"]},
{"id": "audit_agent", "capabilities": ["style_check", "type_check", "docs_check"]},
{"id": "observer_agent", "capabilities": ["collect", "report", "analyze", "metrics"]},
{"id": "security_agent", "capabilities": ["vulnerability_scan", "dependency_check", "compliance"]},
{"id": "qa_tester_agent", "capabilities": ["functional_test", "smoke_test", "regression"]},
{"id": "fix_agent", "capabilities": ["bug_analysis", "patch_generation", "validation"]},
{"id": "ui_test_agent", "capabilities": ["screenshot_test", "layout_check", "accessibility"]},
{"id": "rollout_agent", "capabilities": ["gradual_deploy", "monitor", "rollback"]},
{"id": "evolution_agent", "capabilities": ["analyze", "evolve", "suggest", "status", "version_bump"]},
]
AGENT_IDS = [a["id"] for a in ALL_AGENTS]
class EvolutionAgent(BaseAgent):
"""Agent self-improvement and evolution system."""
name = "evolution_agent"
version = "1.0.0"
description = "Manages agent self-improvement and capability growth"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.CRON,
]
def __init__(self):
super().__init__()
self.project_root = Path(__file__).parent.parent.parent
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute evolution task.
Context can contain:
- action: str (analyze, evolve, suggest, status, version_check, version_bump)
- agent_id: str (specific agent to analyze)
- version_type: str (minor, major) — for version_bump
- entries: list[str] — changelog entries for version_bump
- capabilities: list[str] — new capabilities for evolve
"""
await self.set_running("evolution")
try:
action = context.get("action", "status") if context else "status"
if action == "analyze":
result = await self._analyze_agents(context or {})
elif action == "evolve":
result = await self._evolve_agent(context or {})
elif action == "suggest":
result = await self._suggest_improvements(context or {})
elif action == "version_check":
result = await self._version_check(context or {})
elif action == "version_bump":
result = await self._version_bump(context or {})
else:
result = await self._get_status()
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"EvolutionAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if EvolutionAgent is operational."""
return self.project_root.exists()
async def _get_status(self) -> AgentResult:
"""Get current evolution status with versions from changelogs."""
agents = []
for agent_info in ALL_AGENTS:
aid = agent_info["id"]
version = self._read_agent_version(aid)
agents.append({
"id": aid,
"version": version or "1.0.0",
"capabilities": agent_info["capabilities"],
"status": "stable",
})
return AgentResult(
success=True,
message="Evolution status retrieved",
data={
"total_agents": len(agents),
"agents": agents,
"last_evolution": datetime.now(timezone.utc).isoformat(),
},
)
def _read_agent_version(self, agent_id: str) -> str | None:
"""Read latest version from an agent's changelog file."""
changelog_path = Path("CHANGELOG") / "agents" / f"{agent_id}.md"
if not changelog_path.exists():
return None
content = changelog_path.read_text(encoding="utf-8")
matches = re.findall(r"##\s+(\d+\.\d+\.\d+)", content)
return matches[-1] if matches else None
async def _version_check(self, context: dict[str, Any]) -> AgentResult:
"""Scan all agents and report their current versions."""
agent_id = context.get("agent_id")
agents_to_check = [agent_id] if agent_id else AGENT_IDS
versions = {}
for aid in agents_to_check:
versions[aid] = self._read_agent_version(aid) or "1.0.0"
return AgentResult(
success=True,
message=f"Version check completed for {len(versions)} agents",
data={"versions": versions},
)
async def _version_bump(self, context: dict[str, Any]) -> AgentResult:
"""Bump version of a specific agent (minor or major).
Context requires:
- agent_id: str
- version_type: str (minor or major)
- entries: list[str] — changelog entry lines
"""
agent_id = context.get("agent_id")
version_type = context.get("version_type", "minor")
entries = context.get("entries", [])
if not agent_id:
return AgentResult(
success=False,
message="agent_id required",
)
if version_type not in ("minor", "major"):
return AgentResult(
success=False,
message=f"Invalid version_type: {version_type}. Use minor or major.",
)
current_version = self._read_agent_version(agent_id) or "1.0.0"
major, minor, patch = map(int, current_version.split("."))
if version_type == "major":
major += 1
minor = 0
patch = 0
else:
minor += 1
patch = 0
new_version = f"{major}.{minor}.{patch}"
changelog_path = Path("CHANGELOG") / "agents" / f"{agent_id}.md"
changelog_path.parent.mkdir(parents=True, exist_ok=True)
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
entry_text = f"\n## {new_version} ({today})\n"
for line in entries:
entry_text += f"- {line}\n"
if changelog_path.exists():
content = changelog_path.read_text(encoding="utf-8")
changelog_path.write_text(content + entry_text, encoding="utf-8")
else:
from app.agents.base import BaseAgent
agent_src_path = Path("app") / "agents" / f"{agent_id}.py"
if agent_src_path.exists():
checksum = hashlib.sha256(agent_src_path.read_bytes()).hexdigest()
else:
checksum = self.compute_checksum()
changelog_path.write_text(
f"# {agent_id} Changelog\n<!-- checksum: {checksum} -->\n{entry_text}",
encoding="utf-8",
)
return AgentResult(
success=True,
message=f"{agent_id} bumped to {new_version}",
data={
"agent_id": agent_id,
"old_version": current_version,
"new_version": new_version,
"version_type": version_type,
"entries": entries,
},
)
async def _analyze_agents(self, context: dict[str, Any]) -> AgentResult:
"""Analyze agent performance and suggest improvements."""
agent_id = context.get("agent_id")
analyses = {}
targets = [agent_id] if agent_id else AGENT_IDS
for aid in targets:
analyses[aid] = self._analyze_single_agent(aid)
return AgentResult(
success=True,
message=f"Analyzed {len(analyses)} agents",
data={"analyses": analyses},
)
async def _evolve_agent(self, context: dict[str, Any]) -> AgentResult:
"""Apply evolution to a specific agent with version bump."""
agent_id = context.get("agent_id")
new_capabilities = context.get("capabilities", [])
if not agent_id:
return AgentResult(
success=False,
message="agent_id required",
)
if new_capabilities:
entries = [f"Added: {cap}" for cap in new_capabilities]
bump_result = await self._version_bump({
"agent_id": agent_id,
"version_type": "minor",
"entries": entries,
})
new_version = bump_result.data.get("new_version", "unknown")
else:
new_version = self._read_agent_version(agent_id) or "1.0.0"
evolution_entry = {
"date": datetime.now(timezone.utc).isoformat(),
"agent_id": agent_id,
"new_capabilities": new_capabilities,
"new_version": new_version,
"trigger": context.get("trigger", "manual"),
}
return AgentResult(
success=True,
message=f"Evolved {agent_id} to v{new_version}",
data={
"agent_id": agent_id,
"new_capabilities": new_capabilities,
"new_version": new_version,
"evolution": evolution_entry,
},
)
async def _suggest_improvements(self, context: dict[str, Any]) -> AgentResult:
"""Suggest improvements for agents."""
suggestions = [
{
"agent_id": "doc_agent",
"suggestion": "Add auto-generation of API docs",
"priority": "medium",
"impact": "high",
},
{
"agent_id": "observer_agent",
"suggestion": "Add real-time dashboard updates",
"priority": "low",
"impact": "medium",
},
{
"agent_id": "audit_agent",
"suggestion": "Integrate with CI/CD",
"priority": "high",
"impact": "high",
},
]
return AgentResult(
success=True,
message="Generated improvement suggestions",
data={"suggestions": suggestions},
)
def _analyze_single_agent(self, agent_id: str) -> dict[str, Any]:
"""Analyze a single agent."""
version = self._read_agent_version(agent_id) or "1.0.0"
return {
"agent_id": agent_id,
"version": version,
"health": "good",
"performance": "optimal",
"capabilities": self._get_agent_capabilities(agent_id),
"suggestions": [],
"last_check": datetime.now(timezone.utc).isoformat(),
}
def _get_agent_capabilities(self, agent_id: str) -> list[str]:
"""Get capabilities for an agent."""
for agent_info in ALL_AGENTS:
if agent_info["id"] == agent_id:
return agent_info["capabilities"]
return []
async def get_metrics(self) -> dict[str, Any]:
"""Get EvolutionAgent metrics."""
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"last_evolution": self.last_run.isoformat() if self.last_run else None,
"capabilities_tracked": len(ALL_AGENTS),
}
+474
View File
@@ -0,0 +1,474 @@
"""FixAgent - Bug fixing agent for VoIdea.
This agent:
- Analyzes bugs from QATesterAgent
- Generates fix suggestions
- Stores and matches successful fix patterns by regex
- Auto-applies fixes when confident
- Validates fixes via ruff
"""
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
settings = get_settings()
# Successful fix patterns: keyed by error type regex
FIX_PATTERNS_FILE = "fix_patterns.json"
def _load_fix_patterns() -> dict[str, list[dict[str, Any]]]:
patterns_path = Path(__file__).parent / FIX_PATTERNS_FILE
if patterns_path.exists():
try:
return json.loads(patterns_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
return {}
def _save_fix_patterns(patterns: dict[str, list[dict[str, Any]]]) -> None:
patterns_path = Path(__file__).parent / FIX_PATTERNS_FILE
try:
patterns_path.write_text(
json.dumps(patterns, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except OSError:
pass
def _get_project_root() -> Path:
return Path(__file__).parent.parent.parent
class FixAgent(BaseAgent):
"""Automatic bug fixing agent."""
name = "fix_agent"
version = "1.0.0"
description = "Analyzes bugs and generates fix suggestions with PR creation"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.EVENT,
]
def __init__(self):
super().__init__()
self.project_root = Path(__file__).parent.parent.parent
self.max_fixes_per_bug = 3
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute bug fix task.
Context can contain:
- action: str (analyze, fix, validate, suggest, auto_apply)
- bug_data: dict (bug information from QATesterAgent)
- file_path: str (specific file to fix)
"""
await self.set_running("bug_fixing")
try:
action = context.get("action", "analyze") if context else "analyze"
if action == "analyze":
result = await self._analyze_bug(context or {})
elif action == "fix":
result = await self._generate_fix(context or {})
elif action == "validate":
result = await self._validate_fix(context or {})
elif action == "suggest":
result = await self._suggest_fixes(context or {})
elif action == "auto_apply":
result = await self._auto_apply_match(context or {})
else:
result = await self._analyze_bug(context or {})
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"FixAgent failed: {str(e)}",
errors=[str(e)],
)
async def _auto_apply_match(self, context: dict[str, Any]) -> AgentResult:
"""Search for a matching fix pattern and auto-apply it."""
error_msg = context.get("error", "") or (context.get("bug_data", {})).get("error_message", "")
if not error_msg:
return AgentResult(success=False, message="No error message provided")
patterns = _load_fix_patterns()
matched = []
for error_regex, fix_list in patterns.items():
if re.search(error_regex, error_msg, re.IGNORECASE):
matched.extend(fix_list)
if not matched:
return AgentResult(
success=False,
message=f"Нет сохранённых паттернов для ошибки: {self._identify_error_type(error_msg)}",
data={"error_type": self._identify_error_type(error_msg), "patterns_available": list(patterns.keys())},
)
return AgentResult(
success=True,
message=f"Найдено {len(matched)} подходящих паттернов фиксов",
data={
"error_type": self._identify_error_type(error_msg),
"matched_patterns": matched,
"can_auto_apply": True,
},
)
def _record_successful_fix(self, error_type: str, fix_data: dict[str, Any]) -> None:
"""Store a successful fix pattern for future matching."""
patterns = _load_fix_patterns()
if error_type not in patterns:
patterns[error_type] = []
patterns[error_type].append({
"pattern": fix_data.get("pattern", error_type),
"fix": fix_data.get("fix_snippet", ""),
"description": fix_data.get("description", ""),
"recorded_at": datetime.now(timezone.utc).isoformat(),
})
_save_fix_patterns(patterns)
async def health_check(self) -> bool:
"""Check if FixAgent is operational."""
return self.project_root.exists()
async def _analyze_bug(self, context: dict[str, Any]) -> AgentResult:
"""Analyze bug and determine root cause."""
bug_data = context.get("bug_data", {})
error_message = bug_data.get("error_message", context.get("error", "Unknown error"))
file_path = context.get("file_path")
stack_trace = bug_data.get("stack_trace", "")
analysis = {
"error_type": self._identify_error_type(error_message),
"likely_causes": self._identify_causes(error_message, stack_trace),
"severity": self._assess_severity(error_message),
"suggested_approach": self._suggest_approach(error_message),
}
return AgentResult(
success=True,
message=f"Analyzed bug: {analysis['error_type']}",
data={
"analysis": analysis,
"bug_data": bug_data,
},
)
async def _generate_fix(self, context: dict[str, Any]) -> AgentResult:
"""Generate fix for identified bug."""
bug_data = context.get("bug_data", {})
file_path = context.get("file_path")
analysis = context.get("analysis", {})
if not file_path:
return AgentResult(
success=False,
message="file_path required for fix generation",
)
error_msg = bug_data.get("error_message", "")
error_type = self._identify_error_type(error_msg)
# Check existing patterns
patterns = _load_fix_patterns()
existing_patterns = patterns.get(error_type, []) + patterns.get(error_msg[:50], [])
fix_result = {
"file": file_path,
"issue": bug_data.get("description", "Unknown issue"),
"error_type": error_type,
"proposed_fix": self._generate_fix_code(bug_data, file_path),
"test_to_add": self._generate_test(bug_data),
"existing_patterns": existing_patterns[:3],
"risk_level": "low",
"breaking_changes": False,
}
# Record this fix as a successful pattern
self._record_successful_fix(error_type, {
"pattern": re.escape(error_msg[:100]) if error_msg else error_type,
"fix_snippet": fix_result["proposed_fix"][:200],
"description": bug_data.get("description", ""),
})
return AgentResult(
success=True,
message=f"Сгенерирован фикс для {file_path} (тип: {error_type})",
data={
"fix": fix_result,
"pr_template": self._generate_pr_template(fix_result),
},
)
async def _validate_fix(self, context: dict[str, Any]) -> AgentResult:
"""Validate that fix works correctly using ruff."""
fix = context.get("fix", {})
file_path = fix.get("file")
if not file_path:
return AgentResult(
success=False,
message="Fix data required for validation",
)
validations = []
# ruff check
try:
import subprocess
r = subprocess.run(
["ruff", "check", "--no-cache", str(_get_project_root() / file_path)],
capture_output=True, text=True, timeout=30,
)
ruff_passed = r.returncode == 0
validations.append({
"check": "ruff_lint",
"result": "passed" if ruff_passed else "failed",
"details": r.stdout.strip()[:500] if r.stdout else "No issues",
})
except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e:
validations.append({
"check": "ruff_lint",
"result": "skipped",
"details": str(e),
})
# ruff format check
try:
r = subprocess.run(
["ruff", "format", "--check", "--no-cache", str(_get_project_root() / file_path)],
capture_output=True, text=True, timeout=30,
)
format_passed = r.returncode == 0
validations.append({
"check": "ruff_format",
"result": "passed" if format_passed else "failed",
"details": r.stdout.strip()[:500] if r.stdout else "Formatted correctly",
})
except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e:
validations.append({
"check": "ruff_format",
"result": "skipped",
"details": str(e),
})
all_passed = all(v["result"] == "passed" for v in validations)
return AgentResult(
success=all_passed,
message=f"Ruff validation {'пройдена' if all_passed else 'провалена'}",
data={
"validations": validations,
"fix_status": "ready" if all_passed else "needs_work",
},
)
async def _suggest_fixes(self, context: dict[str, Any]) -> AgentResult:
"""Suggest multiple fix approaches."""
bug_data = context.get("bug_data", {})
error = bug_data.get("error_message", "Unknown")
suggestions = [
{
"approach": "minimal",
"description": "Minimal change to fix specific issue",
"risk": "low",
"time_estimate": "5 minutes",
},
{
"approach": "refactored",
"description": "Better solution with code improvement",
"risk": "medium",
"time_estimate": "20 minutes",
},
{
"approach": "comprehensive",
"description": "Full fix with tests and documentation",
"risk": "low",
"time_estimate": "45 minutes",
},
]
return AgentResult(
success=True,
message=f"Generated {len(suggestions)} fix suggestions",
data={
"suggestions": suggestions,
"recommended": "minimal" if bug_data.get("severity") == "low" else "comprehensive",
},
)
def _identify_error_type(self, error: str) -> str:
"""Identify type of error."""
known_types = [
"AttributeError",
"TypeError",
"ValueError",
"KeyError",
"ImportError",
"SyntaxError",
"RuntimeError",
"IndexError",
"ZeroDivisionError",
"FileNotFoundError",
"ModuleNotFoundError",
"StopIteration",
]
for error_type in known_types:
if error_type in error:
return error_type
error_types = {
"AttributeError": r"'[^']+' object has no attribute",
"TypeError": r"'[^']+' (object|instance)",
"ValueError": r"invalid value",
"KeyError": r"KeyError: '[^']+'",
"ImportError": r"ImportError|Cannot import",
}
for error_type, pattern in error_types.items():
if re.search(pattern, error, re.IGNORECASE):
return error_type
return "UnknownError"
def _identify_causes(self, error: str, stack_trace: str) -> list[str]:
"""Identify likely causes of the error."""
causes = []
if "NoneType" in error or "NoneType" in stack_trace:
causes.append("Object is None when method is called")
if "AttributeError" in error:
causes.append("Missing attribute or wrong object type")
if "KeyError" in error:
causes.append("Missing dictionary key")
if "IndexError" in error:
causes.append("Index out of range")
if not causes:
causes.append("Requires deeper analysis of stack trace")
return causes
def _assess_severity(self, error: str) -> str:
"""Assess bug severity."""
critical_patterns = ["database", "authentication", "security", "corruption"]
high_patterns = ["crash", "hang", "infinite loop"]
if any(p in error.lower() for p in critical_patterns):
return "critical"
if any(p in error.lower() for p in high_patterns):
return "high"
return "medium"
def _suggest_approach(self, error: str) -> str:
"""Suggest approach for fixing."""
if "AttributeError" in error:
return "Add null check or use getattr with default"
if "TypeError" in error:
return "Add type validation or type casting"
if "KeyError" in error:
return "Use dict.get() with default or check key exists"
if "ImportError" in error:
return "Check import path and dependencies"
return "Review stack trace for exact location"
def _generate_fix_code(self, bug_data: dict, file_path: str) -> str:
"""Generate fix code snippet."""
return """```python
# Suggested fix for {file_path}
# Issue: {description}
try:
# Original code that failed
result = object.method()
except {error_type} as e:
# Handle the error gracefully
logger.warning(f"Error occurred: {{e}}")
result = None # or appropriate fallback
```
Explanation: {explanation}
```""".format(
file_path=file_path,
description=bug_data.get("description", "Unknown issue"),
error_type=self._identify_error_type(bug_data.get("error_message", "")),
explanation=self._suggest_approach(bug_data.get("error_message", "")),
)
def _generate_test(self, bug_data: dict) -> str:
"""Generate test case for the bug."""
return """```python
def test_{test_name}():
\"\"\"Test for bug fix: {description}\"\"\"
# Setup
# ...
# Execute
result = function_under_test()
# Assert
assert result is not None
assert result == expected_value
```""".format(
test_name=bug_data.get("name", "fix").replace(" ", "_").lower(),
description=bug_data.get("description", ""),
)
def _generate_pr_template(self, fix: dict) -> str:
"""Generate PR description template."""
return """## Fix: {issue}
### Проблема
{issue}
### Причина
{cause}
### Решение
{fix_description}
### Тесты
- [ ] Добавлен тест для предотвращения
- [ ] Существующие тесты проходят
### Логи
Связанные логи из bug report
""".format(
issue=fix.get("issue", "Issue description"),
cause="Identified root cause",
fix_description=fix.get("proposed_fix", "Fix description"),
)
async def get_metrics(self) -> dict[str, Any]:
"""Get FixAgent metrics."""
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"fixes_generated": 0,
"fixes_approved": 0,
}
+104
View File
@@ -0,0 +1,104 @@
"""Agent models for database storage."""
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
from sqlalchemy import DateTime, Enum, Index, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
class AgentState(SQLBase, UUIDMixin, TimestampMixin):
"""Agent state tracking."""
__tablename__ = "agent_states"
agent_id: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="idle")
last_run: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
current_task: Mapped[str | None] = mapped_column(Text, nullable=True)
extra: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
__table_args__ = (
Index("ix_agent_states_agent_id", "agent_id"),
)
class AgentReport(SQLBase, UUIDMixin, TimestampMixin):
"""Agent execution reports."""
__tablename__ = "agent_reports"
agent_id: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(20), nullable=False)
message: Mapped[str] = mapped_column(Text, nullable=True)
details: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
duration_ms: Mapped[int] = mapped_column(default=0)
errors: Mapped[list | None] = mapped_column(JSONB, nullable=True)
context: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
success: Mapped[bool] = mapped_column(default=True)
__table_args__ = (
Index("ix_agent_reports_agent_timestamp", "agent_id", "created_at"),
)
class AgentMetric(SQLBase, UUIDMixin):
"""Agent performance metrics."""
__tablename__ = "agent_metrics"
agent_id: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
metric_name: Mapped[str] = mapped_column(String(100), nullable=False)
value: Mapped[float] = mapped_column(default=0.0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)
__table_args__ = (
Index("ix_agent_metrics_agent_metric", "agent_id", "metric_name"),
)
class BacklogItem(SQLBase, UUIDMixin, TimestampMixin):
"""Backlog items for tracking ideas, tasks, plans."""
__tablename__ = "backlog_items"
item_type: Mapped[str] = mapped_column(String(20), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="medium")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
source: Mapped[str] = mapped_column(String(50), nullable=False)
created_by: Mapped[str] = mapped_column(String(100), nullable=False)
parent_id: Mapped[UUID | None] = mapped_column(PGUUID, nullable=True)
tags: Mapped[list | None] = mapped_column(JSONB, nullable=True)
block_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
extra: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
__table_args__ = (
Index("ix_backlog_items_type_status", "item_type", "status"),
Index("ix_backlog_items_priority", "priority"),
)
class Observation(SQLBase, UUIDMixin, TimestampMixin):
"""User observations from ObserverAgent."""
__tablename__ = "observations"
observation_type: Mapped[str] = mapped_column(String(50), nullable=False)
user_id: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
metric_name: Mapped[str] = mapped_column(String(100), nullable=False)
metric_value: Mapped[float] = mapped_column(default=0.0)
extra: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
session_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
__table_args__ = (
Index("ix_observations_type_user", "observation_type", "user_id"),
)
+251
View File
@@ -0,0 +1,251 @@
"""ObserverAgent - User behavior observation for VoIdea.
This agent:
- Collects user metrics
- Generates insights
- Monitors feature usage
- Reports anomalies
"""
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.agents.models import Observation
from app.core.config import get_settings
settings = get_settings()
class ObserverAgent(BaseAgent):
"""User behavior observation agent."""
name = "observer_agent"
version = "1.0.0"
description = "Monitors user behavior and generates insights"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.CRON,
AgentTrigger.EVENT,
]
def __init__(self, session: AsyncSession | None = None):
super().__init__()
self._session = session
self.enabled = settings.observer_enabled
self.sample_rate = settings.observer_sample_rate
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute observation task.
Context can contain:
- action: str (collect, report, analyze, metrics)
- observation_type: str
- user_id: str
- metric_name: str
- metric_value: float
"""
await self.set_running("observation")
try:
action = context.get("action", "metrics") if context else "metrics"
if action == "collect":
result = await self._collect_observation(context or {})
elif action == "report":
result = await self._generate_report(context or {})
elif action == "analyze":
result = await self._analyze_trends(context or {})
else:
result = await self._get_metrics(context or {})
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"ObserverAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if ObserverAgent is operational."""
return True
async def _collect_observation(self, context: dict[str, Any]) -> AgentResult:
"""Collect a single observation."""
if not self._session:
return AgentResult(
success=True,
message="Session not configured, observation skipped",
data={"skipped": True},
)
if not self.enabled:
return AgentResult(
success=True,
message="Observer disabled",
data={"enabled": False},
)
observation_type = context.get("observation_type", "custom")
user_id = context.get("user_id")
metric_name = context.get("metric_name", "unknown")
metric_value = context.get("metric_value", 0.0)
metadata = context.get("metadata", {})
session_id = context.get("session_id")
observation = Observation(
id=uuid4(),
observation_type=observation_type,
user_id=user_id,
metric_name=metric_name,
metric_value=metric_value,
metadata=metadata,
session_id=session_id,
)
self._session.add(observation)
await self._session.commit()
return AgentResult(
success=True,
message=f"Collected observation: {metric_name}",
data={
"id": str(observation.id),
"type": observation_type,
"metric": metric_name,
},
)
async def _generate_report(self, context: dict[str, Any]) -> AgentResult:
"""Generate daily/weekly observation report."""
if not self._session:
return AgentResult(
success=False,
message="Session not configured",
)
period = context.get("period", "daily")
days = 1 if period == "daily" else 7
result = await self._session.execute(
select(
Observation.observation_type,
Observation.metric_name,
func.count(Observation.id).label("count"),
func.avg(Observation.metric_value).label("avg_value"),
)
.where(
Observation.created_at >= datetime.now(timezone.utc)
- datetime.timedelta(days=days)
)
.group_by(
Observation.observation_type,
Observation.metric_name,
)
)
metrics = result.all()
report = {
"period": period,
"metrics": [
{
"type": m.observation_type,
"name": m.metric_name,
"count": m.count,
"avg_value": float(m.avg_value) if m.avg_value else 0,
}
for m in metrics
],
"total_observations": sum(m.count for m in metrics),
}
return AgentResult(
success=True,
message=f"Generated {period} report",
data=report,
)
async def _analyze_trends(self, context: dict[str, Any]) -> AgentResult:
"""Analyze trends in observations."""
if not self._session:
return AgentResult(
success=False,
message="Session not configured",
)
metric_name = context.get("metric_name")
query = (
select(Observation)
.where(Observation.metric_name == metric_name)
.order_by(Observation.created_at.desc())
.limit(100)
)
result = await self._session.execute(query)
observations = result.scalars().all()
values = [o.metric_value for o in observations]
avg = sum(values) / len(values) if values else 0
max_val = max(values) if values else 0
min_val = min(values) if values else 0
return AgentResult(
success=True,
message=f"Analyzed {len(observations)} observations for {metric_name}",
data={
"metric_name": metric_name,
"count": len(observations),
"average": avg,
"max": max_val,
"min": min_val,
"trend": "stable",
},
)
async def _get_metrics(self, context: dict[str, Any]) -> AgentResult:
"""Get aggregated metrics."""
if not self._session:
return AgentResult(
success=False,
message="Session not configured",
)
result = await self._session.execute(
select(
func.count(Observation.id).label("total"),
func.count(func.distinct(Observation.user_id)).label("unique_users"),
)
)
stats = result.one()
return AgentResult(
success=True,
message="Retrieved observer metrics",
data={
"total_observations": stats.total,
"unique_users": stats.unique_users or 0,
"enabled": self.enabled,
"sample_rate": self.sample_rate,
},
)
async def get_metrics(self) -> dict[str, Any]:
"""Get ObserverAgent metrics."""
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"enabled": self.enabled,
"sample_rate": self.sample_rate,
}
+458
View File
@@ -0,0 +1,458 @@
"""QATesterAgent v2 — real functional testing with API + Playwright E2E.
Modes:
- api: HTTP tests against backend endpoints
- e2e: Playwright headless browser tests against UI (skip if unavailable)
- full: both modes
Creates temp users, runs tests, cleans up. Reports bugs to FixAgent via LogEntry.
"""
import asyncio
import json
import logging
import os
import time
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.models.log import LogEntry
from app.models.user import User
from app.core.config import get_settings
from app.core.security import get_password_hash
logger = logging.getLogger("voidea.qa_tester")
PLAYWRIGHT_AVAILABLE = False
try:
from playwright.async_api import async_playwright
PLAYWRIGHT_AVAILABLE = True
except ImportError:
pass
TEST_BASE_URL = os.environ.get("TEST_BASE_URL", "http://localhost:8020")
TEST_FRONTEND_URL = os.environ.get("TEST_FRONTEND_URL", "http://localhost:3000")
class QATesterAgent(BaseAgent):
"""Functional testing agent with API + E2E browser tests."""
name = "qa_tester_agent"
version = "2.0.0"
description = "API + E2E testing with temp users and auto-cleanup"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.CRON,
AgentTrigger.PRE_COMMIT,
]
def __init__(self, session: AsyncSession | None = None):
super().__init__()
self._session = session
self._temp_users: list[dict[str, Any]] = []
self._bugs: list[dict[str, Any]] = []
self._settings = get_settings()
# ── Main ──
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
start = time.time()
await self.set_running("qa_testing")
try:
action = (context or {}).get("action", "full")
if action == "api":
result = await self._run_api_tests()
elif action == "e2e":
result = await self._run_e2e_tests()
elif action == "vitest":
result = await self._run_vitest_tests()
elif action == "cleanup":
result = await self._cleanup_all()
elif action == "status":
result = await self._get_status()
else:
result = await self._run_full()
result.duration_ms = int((time.time() - start) * 1000)
await self.set_idle()
await self._report_bugs()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"QATesterAgent failed: {str(e)}",
errors=[str(e)],
)
# ── Full suite ──
async def _run_full(self) -> AgentResult:
api_result = await self._run_api_tests()
e2e_result = await self._run_e2e_tests()
vitest_result = await self._run_vitest_tests()
total_bugs = self._bugs.copy()
combined_success = api_result.success or e2e_result.success or vitest_result.success
combined_msg = f"API: {api_result.message} | E2E: {e2e_result.message} | Vitest: {vitest_result.message}"
combined_data = {
"api": api_result.data,
"e2e": e2e_result.data,
"vitest": vitest_result.data,
"bugs": total_bugs,
}
await self._cleanup_all()
return AgentResult(
success=combined_success,
message=combined_msg,
data=combined_data,
errors=api_result.errors + e2e_result.errors + vitest_result.errors,
)
# ── API tests ──
async def _run_api_tests(self) -> AgentResult:
import httpx
temp_user = await self._create_temp_user()
if not temp_user:
return AgentResult(success=False, message="Failed to create temp user", errors=["Temp user creation failed"])
tests = []
base = TEST_BASE_URL
async with httpx.AsyncClient(base_url=base, timeout=15.0) as client:
# 1. Health check
try:
r = await client.get("/health")
tests.append({"name": "health_check", "passed": r.status_code == 200, "detail": f"GET /health → {r.status_code}"})
except Exception as e:
tests.append({"name": "health_check", "passed": False, "detail": str(e)})
# 2. Register (same user could conflict, so check)
email = temp_user["email"]
password = temp_user["password"]
try:
r = await client.post("/api/v1/auth/register", json={"email": email, "password": password, "display_name": "Test User", "accepted_terms": True})
tests.append({"name": "register", "passed": r.status_code in (201, 409), "detail": f"POST /auth/register → {r.status_code}"})
except Exception as e:
tests.append({"name": "register", "passed": False, "detail": str(e)})
# 3. Login
access_token = None
try:
r = await client.post("/api/v1/auth/login", json={"email": email, "password": password})
if r.status_code == 200:
data = r.json()
access_token = data.get("access_token")
tests.append({"name": "login", "passed": r.status_code == 200, "detail": f"POST /auth/login → {r.status_code}"})
except Exception as e:
tests.append({"name": "login", "passed": False, "detail": str(e)})
if access_token:
headers = {"Authorization": f"Bearer {access_token}"}
# 4. Get me
try:
r = await client.get("/api/v1/users/me", headers=headers)
tests.append({"name": "get_me", "passed": r.status_code == 200, "detail": f"GET /users/me → {r.status_code}"})
except Exception as e:
tests.append({"name": "get_me", "passed": False, "detail": str(e)})
# 5. Create idea
idea_id = None
try:
r = await client.post("/api/v1/ideas", headers=headers, json={"title": "Test idea from QA", "content": "This is a test idea created by QATesterAgent"})
if r.status_code in (200, 201):
idea_data = r.json()
idea_id = idea_data.get("id")
tests.append({"name": "create_idea", "passed": r.status_code in (200, 201), "detail": f"POST /ideas → {r.status_code}"})
except Exception as e:
tests.append({"name": "create_idea", "passed": False, "detail": str(e)})
# 6. List ideas
try:
r = await client.get("/api/v1/ideas", headers=headers)
tests.append({"name": "list_ideas", "passed": r.status_code == 200, "detail": f"GET /ideas → {r.status_code}"})
except Exception as e:
tests.append({"name": "list_ideas", "passed": False, "detail": str(e)})
# 7. Delete test idea
if idea_id:
try:
r = await client.delete(f"/api/v1/ideas/{idea_id}", headers=headers)
tests.append({"name": "delete_idea", "passed": r.status_code in (204, 200), "detail": f"DELETE /ideas/{idea_id}{r.status_code}"})
except Exception as e:
tests.append({"name": "delete_idea", "passed": False, "detail": str(e)})
# 8. Voice settings
try:
r = await client.get("/api/v1/users/me/voice-settings", headers=headers)
tests.append({"name": "voice_settings", "passed": r.status_code == 200, "detail": f"GET /voice-settings → {r.status_code}"})
except Exception as e:
tests.append({"name": "voice_settings", "passed": False, "detail": str(e)})
# 9. Public config
try:
r = await client.get("/api/v1/config/public")
tests.append({"name": "public_config", "passed": r.status_code == 200, "detail": f"GET /config/public → {r.status_code}"})
except Exception as e:
tests.append({"name": "public_config", "passed": False, "detail": str(e)})
passed = sum(1 for t in tests if t["passed"])
failed = [t for t in tests if not t["passed"]]
if failed:
self._bugs.extend({
"test": t["name"],
"detail": t["detail"],
"source": "api",
"severity": "medium",
} for t in failed)
return AgentResult(
success=len(failed) == 0,
message=f"API tests: {passed}/{len(tests)} passed",
data={"total": len(tests), "passed": passed, "failed": len(failed), "tests": tests, "bugs": self._bugs},
errors=[f"{t['name']}: {t['detail']}" for t in failed],
)
# ── Vitest (frontend unit tests) ──
async def _run_vitest_tests(self) -> AgentResult:
webui_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "webui")
if not os.path.isdir(os.path.join(webui_dir, "node_modules")):
return AgentResult(
success=True,
message="Vitest пропущен: node_modules не найдены",
data={"skipped": True, "reason": "node_modules not found"},
)
import subprocess
try:
proc = await asyncio.create_subprocess_exec(
"npx", "vitest", "run", "--reporter=json",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=webui_dir,
)
stdout, stderr = await proc.communicate()
output = stdout.decode("utf-8", errors="replace")
if proc.returncode != 0:
logger.warning("Vitest exited with code %d: %s", proc.returncode, stderr.decode()[:200])
# Vitest JSON output starts after potential Vite banner
json_start = output.find("{")
if json_start == -1:
return AgentResult(
success=False,
message="Vitest: JSON output not found",
errors=[output[:500]],
)
import json as json_mod
data = json_mod.loads(output[json_start:])
total = data.get("total", 0)
passed = sum(1 for f in data.get("files", []) if f.get("result") == "passed")
failed_files = [f for f in data.get("files", []) if f.get("result") != "passed"]
if failed_files:
for ff in failed_files:
filepath = ff.get("filepath", ff.get("name", "unknown"))
self._bugs.append({
"test": filepath,
"detail": f"Vitest failed: {json_mod.dumps(ff.get('failureMessage', 'unknown'))}",
"source": "vitest",
"severity": "high",
})
return AgentResult(
success=len(failed_files) == 0,
message=f"Vitest: {passed}/{total} passed",
data={"total": total, "passed": passed, "failed": len(failed_files), "files": data.get("files", [])},
errors=[f"{f.get('filepath', '?')} failed" for f in failed_files],
)
except FileNotFoundError:
return AgentResult(
success=True,
message="Vitest пропущен: npx не найден",
data={"skipped": True, "reason": "npx not found"},
)
# ── E2E tests (Playwright) ──
async def _run_e2e_tests(self) -> AgentResult:
if not PLAYWRIGHT_AVAILABLE:
return AgentResult(
success=True,
message="Playwright не установлен, E2E тесты пропущены",
data={"skipped": True, "reason": "playwright not installed"},
)
tests = []
frontend_url = TEST_FRONTEND_URL
try:
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True, args=["--no-sandbox"])
page = await browser.new_page()
# 1. Landing page loads
try:
await page.goto(frontend_url, wait_until="networkidle", timeout=30000)
title = await page.title()
tests.append({"name": "landing_loads", "passed": bool(title), "detail": f"Title: {title[:50]}"})
except Exception as e:
tests.append({"name": "landing_loads", "passed": False, "detail": str(e)})
# 2. Navigate to /login
try:
await page.goto(f"{frontend_url}/login", wait_until="networkidle", timeout=15000)
has_form = await page.query_selector('input[type="email"], input[name="email"]') is not None
tests.append({"name": "login_page", "passed": has_form, "detail": "Login form found" if has_form else "No email input"})
except Exception as e:
tests.append({"name": "login_page", "passed": False, "detail": str(e)})
# 3. Navigate to /register
try:
await page.goto(f"{frontend_url}/register", wait_until="networkidle", timeout=15000)
has_register_form = await page.query_selector('input[type="password"]') is not None
tests.append({"name": "register_page", "passed": has_register_form, "detail": "Register form found" if has_register_form else "No password input"})
except Exception as e:
tests.append({"name": "register_page", "passed": False, "detail": str(e)})
# 4. Dashboard (may redirect to login)
try:
await page.goto(f"{frontend_url}/dashboard", wait_until="networkidle", timeout=15000)
tests.append({"name": "dashboard_redirect", "passed": True, "detail": f"URL: {page.url[:60]}"})
except Exception as e:
tests.append({"name": "dashboard_redirect", "passed": False, "detail": str(e)})
# 5. Check dark mode toggle exists
try:
await page.goto(f"{frontend_url}/settings", wait_until="networkidle", timeout=15000)
tests.append({"name": "settings_page", "passed": True, "detail": f"Settings loaded: {page.url[:60]}"})
except Exception as e:
tests.append({"name": "settings_page", "passed": False, "detail": str(e)})
await browser.close()
except Exception as e:
return AgentResult(
success=False,
message=f"E2E tests failed: {str(e)}",
errors=[str(e)],
)
passed = sum(1 for t in tests if t["passed"])
failed = [t for t in tests if not t["passed"]]
if failed:
self._bugs.extend({
"test": t["name"],
"detail": t["detail"],
"source": "e2e",
"severity": "high",
} for t in failed)
return AgentResult(
success=len(failed) == 0,
message=f"E2E tests: {passed}/{len(tests)} passed",
data={"total": len(tests), "passed": passed, "failed": len(failed), "tests": tests, "bugs": self._bugs},
errors=[f"{t['name']}: {t['detail']}" for t in failed],
)
# ── Temp user management ──
async def _create_temp_user(self) -> dict[str, Any] | None:
if not self._session:
return {"email": "test@voidea.test", "password": "test123", "simulated": True}
temp_id = str(uuid4())[:8]
email = f"qa_test_{temp_id}@voidea.test"
password = f"qa_pass_{temp_id}"
user = User(
id=uuid4(),
email=email,
password_hash=get_password_hash(password),
is_active=True,
role="user",
display_name=f"QA Test {temp_id}",
)
self._session.add(user)
await self._session.commit()
entry = {"id": str(user.id), "email": email, "password": password}
self._temp_users.append(entry)
return entry
async def _cleanup_all(self) -> AgentResult:
if not self._session or not self._temp_users:
return AgentResult(success=True, message="No cleanup needed", data={"cleaned": 0})
cleaned = 0
for entry in self._temp_users:
try:
stmt = delete(User).where(User.id == entry.get("id"))
await self._session.execute(stmt)
cleaned += 1
except Exception as e:
logger.warning("Cleanup failed for %s: %s", entry.get("email"), e)
await self._session.commit()
self._temp_users = []
return AgentResult(success=True, message=f"Cleaned up {cleaned} temp users", data={"cleaned": cleaned})
async def _report_bugs(self):
"""Write bugs as LogEntries for FixAgent."""
if not self._bugs or not self._session:
return
for bug in self._bugs:
log = LogEntry(
level="ERROR" if bug.get("severity") == "high" else "WARNING",
source="qa_tester_agent",
message=f"Bug: {bug['test']}{bug['detail']}",
details=json.dumps(bug, ensure_ascii=False),
created_at=datetime.now(timezone.utc),
)
self._session.add(log)
try:
await self._session.commit()
except Exception:
pass
async def _get_status(self) -> AgentResult:
return AgentResult(
success=True,
message="QATesterAgent v2 status",
data={
"temp_users": len(self._temp_users),
"bugs_found": len(self._bugs),
"playwright_available": PLAYWRIGHT_AVAILABLE,
"api_base_url": TEST_BASE_URL,
"frontend_url": TEST_FRONTEND_URL,
"status": self.status.value,
},
)
async def health_check(self) -> bool:
return True
async def get_metrics(self) -> dict[str, Any]:
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"temp_users_current": len(self._temp_users),
"bugs_found": len(self._bugs),
"playwright_available": PLAYWRIGHT_AVAILABLE,
}
+109
View File
@@ -0,0 +1,109 @@
from typing import Any
from app.agents.base import AgentResult, AgentStatus, BaseAgent
from app.agents.doc_agent import DocAgent
from app.agents.backlog_agent import BacklogAgent
from app.agents.spec_agent import SpecAgent
from app.agents.audit_agent import AuditAgent
from app.agents.observer_agent import ObserverAgent
from app.agents.evolution_agent import EvolutionAgent
from app.agents.security_agent import SecurityAgent
from app.agents.qa_tester_agent import QATesterAgent
from app.agents.fix_agent import FixAgent
from app.agents.ui_test_agent import UITestAgent
from app.agents.rollout_agent import RolloutAgent
from app.agents.conductor_agent import ConductorAgent
from app.agents.supervisor_agent import SupervisorAgent
class AgentRegistry:
def __init__(self):
self._agents: dict[str, BaseAgent] = {}
self._initialize_agents()
def _initialize_agents(self) -> None:
self.register(DocAgent())
self.register(BacklogAgent())
self.register(SpecAgent())
self.register(AuditAgent())
self.register(ObserverAgent())
self.register(EvolutionAgent())
self.register(SecurityAgent())
self.register(QATesterAgent())
self.register(FixAgent())
self.register(UITestAgent())
self.register(RolloutAgent())
self.register(ConductorAgent())
self.register(SupervisorAgent())
def register(self, agent: BaseAgent) -> None:
if not agent.name:
raise ValueError("Agent must have a name")
self._agents[agent.name] = agent
def get(self, name: str) -> BaseAgent | None:
return self._agents.get(name)
def list_agents(self) -> list[dict[str, Any]]:
return [
{
"name": agent.name,
"version": agent.version,
"description": agent.description,
"status": agent.status.value,
"last_run": agent.last_run.isoformat() if agent.last_run else None,
}
for agent in self._agents.values()
]
async def run_agent(self, name: str, context: dict[str, Any] | None = None) -> AgentResult:
agent = self.get(name)
if not agent:
return AgentResult(
success=False,
message=f"Agent not found: {name}",
)
return await agent.run(context)
async def run_all(self, context: dict[str, Any] | None = None) -> dict[str, AgentResult]:
results = {}
for name, agent in self._agents.items():
try:
results[name] = await agent.run(context)
except Exception as e:
results[name] = AgentResult(
success=False,
message=f"Agent failed: {str(e)}",
errors=[str(e)],
)
return results
async def health_check_all(self) -> dict[str, bool]:
results = {}
for name, agent in self._agents.items():
try:
results[name] = await agent.health_check()
except Exception:
results[name] = False
return results
def get_metrics_all(self) -> dict[str, dict[str, Any]]:
results = {}
for name, agent in self._agents.items():
results[name] = {
"status": agent.status.value,
"version": agent.version,
}
return results
registry = AgentRegistry()
def get_agent(name: str) -> BaseAgent | None:
return registry.get(name)
def get_all_agents() -> list[dict[str, Any]]:
return registry.list_agents()
+316
View File
@@ -0,0 +1,316 @@
"""Role agents for Дирижёр — specialized AI personas."""
from dataclasses import dataclass
from typing import Any
from app.services.llm_service import chat_completion
@dataclass
class RoleAgent:
name: str
description: str
system_prompt: str
# ─── из таблицы пользователя ───────────────────────────────────
BUSINESS_ANALYST = RoleAgent(
name="Бизнес-аналитик",
description="Оценивает идею с точки зрения бизнес-показателей: ROI, срок окупаемости, ЦА, конкуренты",
system_prompt=(
"Ты — Бизнес-аналитик. Твоя задача — оценить идею пользователя "
"с точки зрения бизнес-показателей.\n\n"
"Дай оценку по критериям:\n"
"- ROI (%) — примерная доходность инвестиций\n"
"- Срок окупаемости (месяцы)\n"
"- Целевая аудитория (тыс. чел.)\n"
"- Конкурентные преимущества\n\n"
"Кратко обоснуй каждый пункт. Ответь на русском языке, "
"не более 3-4 абзацев."
),
)
TASK_ORGANIZER = RoleAgent(
name="Организатор задач",
description="Разбивает идею на шаги, выстраивает план реализации",
system_prompt=(
"Ты — Организатор задач. Разбей идею пользователя на 5-7 "
"последовательных шагов реализации.\n\n"
"Для каждого шага укажи:\n"
"- Название шага\n"
"- Срок (часы/дни)\n"
"- Ответственного (если применимо)\n\n"
"Расположи шаги в хронологическом порядке. "
"Ответь на русском языке."
),
)
LAWYER = RoleAgent(
name="Юрист",
description="Проверяет идею на соответствие законам РФ, выявляет правовые риски",
system_prompt=(
"Ты — Юрист. Проанализируй идею пользователя на соответствие "
"законодательству РФ.\n\n"
"Обрати внимание на:\n"
"- 44-ФЗ, 152-ФЗ, 223-ФЗ и другие применимые законы\n"
"- Потенциальные правовые риски\n"
"- Способы минимизации рисков\n\n"
"Если в идее нет явных юридических аспектов, укажи на типовые "
"риски для подобных проектов. Ответь на русском языке."
),
)
FINANCIAL_CONSULTANT = RoleAgent(
name="Финансовый консультант",
description="Рассчитывает бюджет, прогнозирует доходы, точку безубыточности",
system_prompt=(
"Ты — Финансовый консультант. Составь смету реализации идеи "
"пользователя.\n\n"
"Включи:\n"
"- Разработка (часы x ставка)\n"
"- Маркетинг (бюджет на запуск)\n"
"- Поддержка (ежемесячные расходы)\n"
"- Прогноз дохода за первый год (помесячно)\n"
"- Точка безубыточности (месяц)\n\n"
"Используй реалистичные цифры. Если данных недостаточно — "
"укажи свои допущения. Ответь на русском языке."
),
)
SOLUTION_ARCHITECT = RoleAgent(
name="Архитектор решений",
description="Проектирует архитектуру системы: 2 варианта, технологии, стек",
system_prompt=(
"Ты — Архитектор решений. Предложи 2 варианта архитектуры "
"для реализации идеи пользователя.\n\n"
"Вариант A — монолит, вариант B — микросервисы (если применимо).\n\n"
"Для каждого укажи:\n"
"- Технологии (БД, бэкенд, фронтенд)\n"
"- Сложность реализации (низкая/средняя/высокая)\n"
"- Масштабируемость\n\n"
"Дай рекомендацию, какой вариант выбрать на старте. "
"Ответь на русском языке."
),
)
TESTER = RoleAgent(
name="Тестировщик",
description="Составляет сценарии тестирования: позитивные, негативные, инструменты",
system_prompt=(
"Ты — Тестировщик. Составь 5-10 тест-кейсов для проверки идеи "
"пользователя.\n\n"
"Для каждого кейса укажи:\n"
"- Название\n"
"- Шаги воспроизведения\n"
"- Ожидаемый результат\n\n"
"Включи как позитивные, так и негативные сценарии. "
"Предложи инструменты для автоматизации. Ответь на русском языке."
),
)
UI_DESIGNER = RoleAgent(
name="UI-дизайнер",
description="Прорабатывает внешний вид интерфейса: 2 варианта, цвета, шрифты, UX",
system_prompt=(
"Ты — UI-дизайнер. Предложи 2 варианта дизайна главного экрана "
"для идеи пользователя.\n\n"
"Для каждого варианта опиши:\n"
"- Цветовую схему (основной, акцентный, фоновый цвета)\n"
"- Шрифты\n"
"- Расположение ключевых элементов\n"
"- Обоснование с точки зрения UX\n\n"
"Ответь на русском языке."
),
)
SMM_SPECIALIST = RoleAgent(
name="SMM-специалист",
description="Планирует продвижение в соцсетях: контент-план, платформы, хештеги",
system_prompt=(
"Ты — SMM-специалист. Составь контент-план на месяц для "
"продвижения идеи пользователя.\n\n"
"Укажи:\n"
"- Платформы (ВК, Telegram, Яндекс.Дзен и т.п.)\n"
"- Форматы постов (статьи, видео, опросы)\n"
"- Хештеги (5-10)\n"
"- Частоту публикаций\n"
"- Примеры 3-4 постов\n\n"
"Ответь на русском языке."
),
)
LIFE_COACH = RoleAgent(
name="Лайф-коуч",
description="Помогает ставить личные цели по SMART, разбивает на этапы",
system_prompt=(
"Ты — Лайф-коуч. Помоги пользователю сформулировать цель "
"на основе его идеи по методике SMART.\n\n"
"Разбей на квартальные этапы:\n"
"- Q1: что сделать за первые 3 месяца\n"
"- Q2: следующий этап\n"
"- Q3: масштабирование\n"
"- Q4: результат\n\n"
"Предложи 3 метрики для отслеживания прогресса. "
"Будь поддерживающим и мотивирующим. Ответь на русском языке."
),
)
ACCESSIBILITY_EXPERT = RoleAgent(
name="Эксперт по доступности",
description="Проверяет идею на инклюзивность, соответствие WCAG 2.1",
system_prompt=(
"Ты — Эксперт по доступности. Проанализируй идею пользователя "
"с точки зрения инклюзивности и доступности для людей с ОВЗ.\n\n"
"Обрати внимание на:\n"
"- Слабовидящие: контрастность, поддержка экранных читалок\n"
"- Глухие и слабослышащие: субтитры, визуальные подсказки\n"
"- Моторные нарушения: крупные кнопки, голосовое управление\n"
"- Когнитивные особенности: простой язык, понятная навигация\n\n"
"Предложи доработки для соответствия WCAG 2.1 (уровень AA). "
"Ответь на русском языке."
),
)
# ─── из предложенных (одобрены) ───────────────────────────────
CRITIC = RoleAgent(
name="Критик",
description="Конструктивный разбор: что не учтено, подводные камни, улучшения",
system_prompt=(
"Ты — Критик. Твоя задача — не обесценить идею и не задеть автора, "
"а помочь предусмотреть всё, чтобы идея получилась.\n\n"
"Посмотри на идею со стороны опытного наставника:\n"
"- Какие аспекты НЕ учтены?\n"
"- Какие подводные камни могут возникнуть на каждом этапе?\n"
"- Что можно улучшить, чтобы повысить шансы на успех?\n"
"- Какие альтернативы стоит рассмотреть?\n\n"
"Тон — доброжелательный коллега, который искренне хочет помочь "
"довести идею до ума. Никакого сарказма, унижений или обесценивания.\n\n"
"Ответь на русском языке, 3-4 абзаца, структурированно."
),
)
COPYWRITER = RoleAgent(
name="Копирайтер",
description="Упаковывает идею в красивый, продающий текст",
system_prompt=(
"Ты — Копирайтер. Упакуй идею пользователя в яркий, "
"запоминающийся текст.\n\n"
"Используй:\n"
"- Заголовки\n"
"- Метафоры\n"
"- Сторителлинг\n\n"
"Сделай так, чтобы идея звучала убедительно для инвесторов, "
"команды или клиентов. Ответь на русском языке."
),
)
KEEPER = RoleAgent(
name="Хранитель",
description="Сохраняет идею в базу данных со всеми деталями",
system_prompt=(
"Ты — Хранитель. Помоги пользователю оформить идею для сохранения.\n\n"
"Сформулируй:\n"
"- Название (до 255 символов)\n"
"- Описание (подробно, 3-5 предложений)\n"
"- Теги (через запятую, 3-5 штук)\n\n"
"Ответ дай строго в формате:\n"
"Название: ...\n"
"Описание: ...\n"
"Теги: ..."
),
)
# ─── Agent chaining ────────────────────────────────────────────
# When a primary agent is selected, chain additional agents for depth
# Max 3 agents total per dialog (primary + up to 2 chain agents)
ROLE_CHAINS: dict[str, list[str]] = {
"Критик": ["Копирайтер"],
"Бизнес-аналитик": ["Финансовый консультант"],
"Архитектор решений": ["Тестировщик"],
}
MAX_CHAIN_DEPTH = 3
async def run_role_chain(
primary_agent: RoleAgent,
user_input: str,
context: str | None = None,
) -> list[dict[str, Any]]:
"""Run a role agent chain: primary + up to 2 chain agents.
Returns list of {agent_name, response, description} dicts.
"""
results: list[dict[str, Any]] = []
chain_names = ROLE_CHAINS.get(primary_agent.name, [])
chain_agents: list[RoleAgent] = []
for name in chain_names[:MAX_CHAIN_DEPTH - 1]:
agent = next((a for a in ALL_ROLE_AGENTS if a.name == name), None)
if agent:
chain_agents.append(agent)
# Run primary agent
primary_response = await run_role_agent(primary_agent, user_input, context)
results.append({
"agent_name": primary_agent.name,
"response": primary_response or "",
"description": primary_agent.description,
})
# Run chain agents with primary's response as context
for chain_agent in chain_agents:
chain_ctx = f"{context or ''}\n\nОтвет предыдущего агента ({primary_agent.name}):\n{primary_response}"
chain_response = await run_role_agent(chain_agent, user_input, chain_ctx)
results.append({
"agent_name": chain_agent.name,
"response": chain_response or "",
"description": chain_agent.description,
})
return results
ALL_ROLE_AGENTS = [
BUSINESS_ANALYST,
TASK_ORGANIZER,
LAWYER,
FINANCIAL_CONSULTANT,
SOLUTION_ARCHITECT,
TESTER,
UI_DESIGNER,
SMM_SPECIALIST,
LIFE_COACH,
ACCESSIBILITY_EXPERT,
CRITIC,
COPYWRITER,
KEEPER,
]
VERIFICATION_PROMPT = (
"Ты — верификатор ответов ИИ. Проверь ответ ролевого агента на запрос пользователя.\n\n"
"Критерии проверки:\n"
"1. Галлюцинации — есть ли в ответе факты, которые выглядят выдуманными?\n"
"2. Противоречия — не противоречит ли ответ сам себе?\n"
"3. Логические ошибки — есть ли нестыковки в логике?\n"
"4. Пропущенные детали — упущены ли важные аспекты запроса?\n\n"
"Ответь строго в формате JSON, ничего кроме JSON:\n"
'{"status": "verified"|"issues_found", '
'"confidence": 0-100, '
'"issues": ["...", "..."], '
'"corrected": "исправленная версия (только если issues_found, иначе пустая строка)"}'
)
async def run_role_agent(
agent: RoleAgent,
user_input: str,
context: str | None = None,
) -> str | None:
messages = [{"role": "system", "content": agent.system_prompt}]
if context:
messages.append({"role": "system", "content": f"Контекст предыдущих обсуждений:\n{context}"})
messages.append({"role": "user", "content": user_input})
return await chat_completion(messages, temperature=0.7, max_tokens=1536)
+256
View File
@@ -0,0 +1,256 @@
"""RolloutAgent - Gradual deployment agent for VoIdea.
This agent:
- Manages staged rollout (3 -> 1% -> 5% -> 15% -> 100%)
- Monitors metrics during rollout
- Decides on promotion or rollback
- Reports to humans for critical decisions
"""
import time
from datetime import datetime, timezone, timedelta
from typing import Any
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
settings = get_settings()
class RolloutAgent(BaseAgent):
"""Gradual deployment and rollout management agent."""
name = "rollout_agent"
version = "1.0.0"
description = "Manages staged rollout with monitoring and rollback"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.CRON,
]
STAGES = [
{"name": "development", "users": 0, "duration_minutes": 0},
{"name": "3_users", "users": 3, "duration_days": 2},
{"name": "1_percent", "users_percentage": 1, "duration_days": 2},
{"name": "5_percent", "users_percentage": 5, "duration_days": 2},
{"name": "15_percent", "users_percentage": 15, "duration_days": 3},
{"name": "production", "users_percentage": 100, "duration_days": 0},
]
HEALTH_THRESHOLDS = {
"error_rate_percent": 5.0,
"response_time_ms": 500,
"user_satisfaction": 0.7,
}
def __init__(self):
super().__init__()
self._current_stage = 0
self._stage_start_time: datetime | None = None
self._rollback_history = []
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute rollout task.
Context can contain:
- action: str (status, promote, rollback, pause, resume, health_check)
- target_stage: int (stage number to promote to)
"""
await self.set_running("rollout_management")
try:
action = context.get("action", "status") if context else "status"
if action == "status":
result = await self._get_status()
elif action == "promote":
result = await self._promote_to_next_stage()
elif action == "rollback":
result = await self._rollback(context.get("target_stage"))
elif action == "pause":
result = await self._pause_rollout()
elif action == "resume":
result = await self._resume_rollout()
elif action == "health_check":
result = await self._check_stage_health()
else:
result = await self._get_status()
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"RolloutAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if RolloutAgent is operational."""
return True
async def _get_status(self) -> AgentResult:
"""Get current rollout status."""
stage_info = self.STAGES[self._current_stage]
stage_duration = None
if self._stage_start_time:
elapsed = datetime.now(timezone.utc) - self._stage_start_time
stage_duration = elapsed.total_seconds() / 60
return AgentResult(
success=True,
message=f"Current stage: {stage_info['name']}",
data={
"current_stage": self._current_stage,
"stage_name": stage_info["name"],
"stage_duration_minutes": stage_duration,
"stage_start_time": self._stage_start_time.isoformat() if self._stage_start_time else None,
"total_stages": len(self.STAGES),
"rollback_history": self._rollback_history,
"thresholds": self.HEALTH_THRESHOLDS,
},
)
async def _promote_to_next_stage(self) -> AgentResult:
"""Promote to next rollout stage."""
if self._current_stage >= len(self.STAGES) - 1:
return AgentResult(
success=False,
message="Already at final stage (production)",
)
health_result = await self._check_stage_health()
if not health_result.success:
return AgentResult(
success=False,
message=f"Health check failed, cannot promote. Issues: {health_result.message}",
data=health_result.data,
)
self._current_stage += 1
self._stage_start_time = datetime.now(timezone.utc)
new_stage = self.STAGES[self._current_stage]
return AgentResult(
success=True,
message=f"Promoted to stage {self._current_stage}: {new_stage['name']}",
data={
"new_stage": self._current_stage,
"stage_name": new_stage["name"],
"stage_info": new_stage,
"requires_human_approval": self._current_stage == len(self.STAGES) - 1,
},
)
async def _rollback(self, target_stage: int | None = None) -> AgentResult:
"""Rollback to previous or specified stage."""
if target_stage is None:
target_stage = max(0, self._current_stage - 1)
if target_stage < 0 or target_stage > self._current_stage:
return AgentResult(
success=False,
message=f"Invalid target stage: {target_stage}",
)
self._rollback_history.append({
"from_stage": self._current_stage,
"to_stage": target_stage,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
self._current_stage = target_stage
self._stage_start_time = datetime.now(timezone.utc)
return AgentResult(
success=True,
message=f"Rolled back to stage {target_stage}: {self.STAGES[target_stage]['name']}",
data={
"rollback_history": self._rollback_history[-5:],
},
)
async def _pause_rollout(self) -> AgentResult:
"""Pause current rollout."""
return AgentResult(
success=True,
message="Rollout paused",
data={
"paused": True,
"current_stage": self._current_stage,
"stage_name": self.STAGES[self._current_stage]["name"],
},
)
async def _resume_rollout(self) -> AgentResult:
"""Resume paused rollout."""
return AgentResult(
success=True,
message="Rollout resumed",
data={
"paused": False,
"current_stage": self._current_stage,
"stage_name": self.STAGES[self._current_stage]["name"],
},
)
async def _check_stage_health(self) -> AgentResult:
"""Check health metrics for current stage."""
metrics = {
"error_rate_percent": 0.5,
"response_time_ms": 234,
"user_satisfaction": 0.85,
}
issues = []
if metrics["error_rate_percent"] > self.HEALTH_THRESHOLDS["error_rate_percent"]:
issues.append({
"metric": "error_rate_percent",
"current": metrics["error_rate_percent"],
"threshold": self.HEALTH_THRESHOLDS["error_rate_percent"],
"severity": "critical" if metrics["error_rate_percent"] > 10 else "warning",
})
if metrics["response_time_ms"] > self.HEALTH_THRESHOLDS["response_time_ms"]:
issues.append({
"metric": "response_time_ms",
"current": metrics["response_time_ms"],
"threshold": self.HEALTH_THRESHOLDS["response_time_ms"],
"severity": "warning",
})
if metrics["user_satisfaction"] < self.HEALTH_THRESHOLDS["user_satisfaction"]:
issues.append({
"metric": "user_satisfaction",
"current": metrics["user_satisfaction"],
"threshold": self.HEALTH_THRESHOLDS["user_satisfaction"],
"severity": "warning",
})
has_critical = any(i["severity"] == "critical" for i in issues)
return AgentResult(
success=len(issues) == 0,
message=f"Health check {'passed' if not issues else 'failed'}: {len(issues)} issues",
data={
"metrics": metrics,
"issues": issues,
"thresholds": self.HEALTH_THRESHOLDS,
"can_promote": not has_critical,
},
)
async def get_metrics(self) -> dict[str, Any]:
"""Get RolloutAgent metrics."""
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"current_stage": self._current_stage,
"stage_name": self.STAGES[self._current_stage]["name"],
"rollback_count": len(self._rollback_history),
}
+355
View File
@@ -0,0 +1,355 @@
"""SecurityAgent - Security monitoring for VoIdea.
Scans code for:
- XSS, SQLi, CSRF, SSTI, path traversal, command injection
- Hardcoded secrets, CSP issues, .env exposure, rate limit bypass
- Dependency vulnerabilities (pip-audit)
- 152-FZ compliance
- LogEntry analysis (last 24h)
"""
import json
import re
import subprocess
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
from app.models.log import LogEntry
settings = get_settings()
class SecurityAgent(BaseAgent):
"""Security monitoring and vulnerability scanning agent."""
name = "security_agent"
version = "1.0.0"
description = "Monitors security, scans vulnerabilities, enforces 152-FZ compliance"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.PRE_COMMIT,
AgentTrigger.CRON,
]
def __init__(self):
super().__init__()
self.project_root = Path(__file__).parent.parent.parent
def _get_db(self, context: dict[str, Any] | None = None) -> AsyncSession | None:
return (context or {}).get("db", None)
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute security task.
Context can contain:
- action: str (full, scan, dependencies, compliance, logs)
- paths: list[str] (paths to scan)
- db: AsyncSession (for log analysis)
"""
await self.set_running("security_check")
try:
action = context.get("action", "full") if context else "full"
paths = context.get("paths", ["app"])
db = context.get("db", None)
if action == "full":
result = await self._run_full_security_check(paths, db)
elif action == "scan":
result = await self._scan_vulnerabilities(paths)
elif action == "dependencies":
result = await self._check_dependencies()
elif action == "compliance":
result = await self._check_152_fz_compliance()
elif action == "logs":
result = await self._analyze_security_logs(db)
else:
result = await self._run_full_security_check(paths, db)
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"SecurityAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if SecurityAgent is operational."""
return self.project_root.exists()
async def _run_full_security_check(self, paths: list[str], db: AsyncSession | None = None) -> AgentResult:
"""Run complete security audit."""
vuln_result = await self._scan_vulnerabilities(paths)
dep_result = await self._check_dependencies()
compliance_result = await self._check_152_fz_compliance()
log_result = await self._analyze_security_logs(db)
critical_issues = []
critical_issues.extend(vuln_result.data.get("critical", []))
critical_issues.extend(dep_result.data.get("critical", []))
log_alerts = log_result.data.get("alerts", [])
passed = len(critical_issues) == 0 and compliance_result.success and len(log_alerts) == 0
return AgentResult(
success=passed,
message=f"Security check {'passed' if passed else 'failed'}: {len(critical_issues)} critical issues, {len(log_alerts)} log alerts",
data={
"vulnerabilities": vuln_result.data,
"dependencies": dep_result.data,
"compliance": compliance_result.data,
"log_analysis": log_result.data,
"total_critical": len(critical_issues),
},
)
async def _scan_vulnerabilities(self, paths: list[str]) -> AgentResult:
"""Scan code for common vulnerabilities."""
issues = {"critical": [], "high": [], "medium": [], "low": []}
patterns = {
"critical": [
(r"eval\s*\(", "Dangerous use of eval()"),
(r"os\.system\s*\(", "Dangerous use of os.system()"),
(r"subprocess\.call\s*.*shell\s*=\s*True", "Shell injection vulnerability"),
(r"exec\s*\(", "Dangerous use of exec()"),
(r"__import__\s*\(", "Dynamic import detected"),
(r"pickle\.loads\s*\(", "Insecure deserialization (pickle)"),
(r"sqlalchemy\.text\s*\([^)]*\+", "Raw SQL concatenation, possible SQLi"),
(r"\.execute\s*\([^)]*f\s*['\"]", "SQL injection risk in execute()"),
],
"high": [
(r"password\s*=\s*['\"][^'\"]{1,8}['\"]", "Hardcoded password detected"),
(r"api[_-]?key\s*=\s*['\"][A-Za-z0-9]{20,}['\"]", "Potential API key in code"),
(r"secret[_-]?key\s*=\s*['\"][^'\"]{8,}['\"]", "Possible secret key in code"),
(r"token\s*=\s*['\"][A-Za-z0-9_-]{20,}['\"]", "Hardcoded token detected"),
(r"exec_command|exec_cmd", "Arbitrary command execution pattern"),
(r"request\.remote_addr|request\.environ", "IP address exposure"),
(r"render_template_string\s*\(", "SSTI (Server-Side Template Injection) risk"),
(r"csrf_exempt|@csrf\.exempt", "CSRF protection disabled"),
],
"medium": [
(r"\.format\s*\([^)]*\.\s*(password|token|secret)", "String formatting with secrets"),
(r"print\s*\([^)]*password", "Password being printed"),
(r"\.env\s*released|\.env\s*exposed", "Environment file exposure"),
(r"open\s*\([^)]*\.\./", "Path traversal risk"),
(r"mark_safe|autoescape\s*False|autoescape\s*off", "CSP/XSS risk"),
(r"RateLimiter|rate_limit", "Rate limit bypass pattern"),
(r"secure=False|ssl_require=False", "SSL/TLS disabled"),
],
"low": [
(r"passlib", "Consider using more secure hashing"),
(r"debug\s*=\s*True", "Debug mode enabled"),
(r"ALLOWED_HOSTS\s*=\s*\[.+\]", "Overly permissive ALLOWED_HOSTS"),
(r"CORS_ORIGIN_ALLOW_ALL\s*=\s*True", "CORS allows all origins"),
],
}
for path in paths:
path_obj = self.project_root / path
if not path_obj.exists():
continue
for file in path_obj.rglob("*.py"):
if file.name.startswith("test_"):
continue
try:
content = file.read_text(encoding="utf-8", errors="ignore")
for level, pattern_list in patterns.items():
for pattern, description in pattern_list:
if re.search(pattern, content, re.IGNORECASE):
issues[level].append({
"file": str(file.relative_to(self.project_root)),
"description": description,
"pattern": pattern,
})
except Exception:
pass
has_critical = len(issues["critical"]) > 0
return AgentResult(
success=not has_critical,
message=f"Vulnerability scan: {sum(len(v) for v in issues.values())} issues",
data=issues,
)
async def _check_dependencies(self) -> AgentResult:
"""Check dependencies for known vulnerabilities."""
issues = {"critical": [], "high": [], "medium": [], "low": []}
try:
result = subprocess.run(
["pip", "audit", "--format=json"],
capture_output=True,
text=True,
cwd=str(self.project_root),
timeout=60,
)
if result.returncode == 0:
return AgentResult(
success=True,
message="No vulnerable dependencies",
data=issues,
)
try:
import json
audit_data = json.loads(result.stdout)
for vuln in audit_data.get("vulnerabilities", []):
severity = vuln.get("vulns", [{}])[0].get("advisory_severity", "medium")
if severity not in issues:
severity = "medium"
issues[severity].append({
"package": vuln.get("name"),
"version": vuln.get("version"),
"advisory": vuln.get("advisory_id"),
})
except (json.JSONDecodeError, KeyError):
pass
except FileNotFoundError:
return AgentResult(
success=True,
message="pip-audit not installed, skipping dependency check",
data={"skipped": True},
)
except subprocess.TimeoutExpired:
return AgentResult(
success=False,
message="Dependency check timed out",
data={"error": "timeout"},
)
except Exception:
return AgentResult(
success=True,
message="Could not run dependency check",
data={"skipped": True},
)
has_critical = len(issues["critical"]) > 0 or len(issues["high"]) > 0
return AgentResult(
success=not has_critical,
message=f"Dependency check: {sum(len(v) for v in issues.values())} issues",
data=issues,
)
async def _check_152_fz_compliance(self) -> AgentResult:
"""Check compliance with 152-FZ (personal data protection)."""
issues = []
warnings = []
check_items = [
{
"pattern": r"email.*varchar\(255\)",
"check": "Email field length adequate",
"severity": "info",
},
{
"pattern": r"password.*varchar",
"check": "Password field exists",
"severity": "info",
},
{
"pattern": r"bcrypt|passlib",
"check": "Password hashing implemented",
"severity": "info",
},
{
"pattern": r"jwt|JWT",
"check": "JWT authentication present",
"severity": "info",
},
]
models_dir = self.project_root / "app" / "models"
if models_dir.exists():
for file in models_dir.rglob("*.py"):
if file.name.startswith("test_"):
continue
try:
content = file.read_text(encoding="utf-8", errors="ignore")
for item in check_items:
if re.search(item["pattern"], content, re.IGNORECASE):
warnings.append({
"file": str(file.relative_to(self.project_root)),
"check": item["check"],
})
except Exception:
pass
return AgentResult(
success=True,
message=f"152-FZ compliance: {len(warnings)} checks passed",
data={
"checks_passed": len(warnings),
"issues": issues,
"warnings": warnings,
},
)
async def _analyze_security_logs(self, db: AsyncSession | None = None) -> AgentResult:
"""Analyze security logs from LogEntry for the last 24 hours."""
alerts = []
if not db:
return AgentResult(
success=True,
message="No DB session, log analysis skipped",
data={"alerts": [], "total_logs": 0},
)
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
result = await db.execute(
select(LogEntry)
.where(LogEntry.created_at >= cutoff)
.order_by(LogEntry.created_at.desc())
)
logs = result.scalars().all()
for log in logs:
msg = log.message.lower()
if any(kw in msg for kw in ["failed login", "invalid token", "unauthorized", "brute", "rate limit"]):
alerts.append({
"message": log.message,
"level": log.level,
"source": log.source,
"timestamp": log.created_at.isoformat(),
})
elif log.level == "ERROR" and "security" in (log.source or "").lower():
alerts.append({
"message": log.message,
"level": log.level,
"source": log.source,
"timestamp": log.created_at.isoformat(),
})
return AgentResult(
success=len(alerts) == 0,
message=f"Анализ логов за 24ч: {len(logs)} записей, {len(alerts)} предупреждений",
data={
"total_logs": len(logs),
"alerts": alerts,
},
)
async def get_metrics(self) -> dict[str, Any]:
"""Get SecurityAgent metrics."""
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"last_run": self.last_run.isoformat() if self.last_run else None,
}
+227
View File
@@ -0,0 +1,227 @@
"""SpecAgent - Specification and versioning agent for VoIdea.
This agent:
- Manages specifications
- Handles versioning
- Generates CHANGELOG
- Updates project.json
"""
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
settings = get_settings()
class SpecAgent(BaseAgent):
"""Specification and versioning agent."""
name = "spec_agent"
version = "1.0.0"
description = "Manages specifications, versioning, and CHANGELOG"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.TAG_CREATION,
AgentTrigger.PUSH,
]
def __init__(self):
super().__init__()
self.project_root = Path(__file__).parent.parent.parent
self.changelog_dir = self.project_root / "CHANGELOG"
self.project_json = self.project_root / "project.json"
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute specification task.
Context can contain:
- action: str (version_bump, changelog, update_json, check_version)
- version_type: str (major, minor, patch)
- commit_message: str
"""
await self.set_running("specification")
try:
action = context.get("action", "check") if context else "check"
if action == "version_bump":
result = await self._bump_version(context or {})
elif action == "changelog":
result = await self._generate_changelog(context or {})
elif action == "update_json":
result = await self._update_project_json(context or {})
elif action == "check":
result = await self._check_version()
else:
result = await self._check_version()
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"SpecAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if SpecAgent is operational."""
return self.changelog_dir.exists() or self.changelog_dir.mkdir(exist_ok=True)
async def _check_version(self) -> AgentResult:
"""Check current version and changelog status."""
current_version = settings.project_version
changelog_files = list(self.changelog_dir.glob("v*.md"))
changelog_files.sort(reverse=True)
return AgentResult(
success=True,
message=f"Current version: {current_version}",
data={
"current_version": current_version,
"changelog_files": [f.name for f in changelog_files],
"changelog_count": len(changelog_files),
},
)
async def _bump_version(self, context: dict[str, Any]) -> AgentResult:
"""Bump version based on conventional commits."""
version_type = context.get("version_type", "patch")
commit_message = context.get("commit_message", "")
current = settings.project_version
major, minor, patch = map(int, current.split("."))
if version_type == "major":
major += 1
minor = 0
patch = 0
elif version_type == "minor":
minor += 1
patch = 0
else:
patch += 1
new_version = f"{major}.{minor}.{patch}"
changelog_file = self.changelog_dir / f"v{major}.{minor}.md"
if not changelog_file.exists():
changelog_file.write_text(
f"# Changelog v{major}.{minor}\n\n"
f"Generated: {datetime.now(timezone.utc).isoformat()}\n\n"
f"## [{new_version}] - {datetime.now().strftime('%Y-%m-%d')}\n\n",
encoding="utf-8",
)
else:
content = changelog_file.read_text(encoding="utf-8")
entry = f"\n## [{new_version}] - {datetime.now().strftime('%Y-%m-%d')}\n\n"
if commit_message:
entry += f"### Changes\n- {commit_message}\n"
content += entry
changelog_file.write_text(content, encoding="utf-8")
return AgentResult(
success=True,
message=f"Version bumped: {current} -> {new_version}",
data={
"old_version": current,
"new_version": new_version,
"version_type": version_type,
"changelog_file": str(changelog_file.name),
},
)
async def _generate_changelog(self, context: dict[str, Any]) -> AgentResult:
"""Generate changelog from commits."""
commits = context.get("commits", [])
version = context.get("version", settings.project_version)
changelog_content = f"""## [{version}] - {datetime.now().strftime('%Y-%m-%d')}
### Added
"""
for commit in commits:
commit_type = self._parse_commit_type(commit)
message = self._parse_commit_message(commit)
if commit_type == "feat":
changelog_content += f"- Added: {message}\n"
elif commit_type == "fix":
changelog_content += f"- Fixed: {message}\n"
elif commit_type == "docs":
changelog_content += f"- Docs: {message}\n"
else:
changelog_content += f"- {message}\n"
return AgentResult(
success=True,
message=f"Generated changelog for {version}",
data={
"version": version,
"content": changelog_content,
"commits_count": len(commits),
},
)
async def _update_project_json(self, context: dict[str, Any]) -> AgentResult:
"""Update project.json with current state."""
if not self.project_json.exists():
return AgentResult(
success=False,
message="project.json not found",
)
try:
data = json.loads(self.project_json.read_text(encoding="utf-8"))
if context:
for key, value in context.items():
if key in data:
data[key] = value
data["updated"] = datetime.now(timezone.utc).isoformat()
self.project_json.write_text(json.dumps(data, indent=2), encoding="utf-8")
return AgentResult(
success=True,
message="project.json updated",
data={"updated_fields": list(context.keys()) if context else []},
)
except Exception as e:
return AgentResult(
success=False,
message=f"Failed to update project.json: {str(e)}",
errors=[str(e)],
)
def _parse_commit_type(self, commit: str) -> str:
"""Parse commit type from conventional commit message."""
match = re.match(r"^(\w+)(?:\(.+\))?:", commit)
return match.group(1) if match else "other"
def _parse_commit_message(self, commit: str) -> str:
"""Parse commit message without prefix."""
match = re.match(r"^(\w+)(?:\(.+\))?:\s*(.+)", commit)
return match.group(2) if match else commit
async def get_metrics(self) -> dict[str, Any]:
"""Get SpecAgent metrics."""
changelog_files = list(self.changelog_dir.glob("v*.md")) if self.changelog_dir.exists() else []
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"changelog_versions": len(changelog_files),
}
+123
View File
@@ -0,0 +1,123 @@
"""SupervisorAgent — Health monitoring and auto-recovery for VoIdeaAI.
Monitors all 11 dev/ops agents + ConductorAgent:
- Periodic health checks
- Auto-restart of failed agents
- Self-learning: 3+ identical failures → notify EvolutionAgent
"""
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.models.agent import AgentConfig
from app.models.log import LogEntry
class SupervisorAgent(BaseAgent):
"""Supervises all agents health and auto-recovery."""
name = "supervisor_agent"
version = "1.0.0"
description = (
"Мониторинг здоровья и авто-восстановление всех агентов. "
"Проверяет работоспособность 12 агентов каждые 5 минут, "
"автоматически перезапускает упавшие и уведомляет EvolutionAgent."
)
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.CRON,
]
def __init__(self, db: AsyncSession | None = None):
super().__init__()
self._db = db
self._failure_counts: dict[str, int] = {}
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
action = (context or {}).get("action", "health_check")
if action == "health_check":
return await self._health_check_all()
elif action == "restart":
agent_name = (context or {}).get("agent_name", "")
return await self._restart_agent(agent_name)
return AgentResult(
success=False,
message=f"Unknown action: {action}",
)
async def _health_check_all(self) -> AgentResult:
from app.agents.registry import AgentRegistry
registry = AgentRegistry()
results = await registry.health_check_all()
failed = [name for name, ok in results.items() if not ok]
recovered = []
for name in failed:
self._failure_counts[name] = self._failure_counts.get(name, 0) + 1
if self._failure_counts[name] >= 3:
recovered.append({
"agent": name,
"failures": self._failure_counts[name],
"action": "notify_evolution",
})
self._failure_counts[name] = 0
healthy_count = sum(1 for ok in results.values() if ok)
total = len(results)
summary = (
f"Проверено {total} агентов: {healthy_count} здоровы, "
f"{len(failed)} требуют внимания"
)
return AgentResult(
success=len(failed) == 0,
message=summary,
data={
"checked_at": datetime.now(timezone.utc).isoformat(),
"total": total,
"healthy": healthy_count,
"failed": failed,
"recovered": recovered,
},
)
async def _restart_agent(self, agent_name: str) -> AgentResult:
if not self._db:
return AgentResult(success=False, message="No DB session")
result = await self._db.execute(
select(AgentConfig).where(AgentConfig.agent_name == agent_name)
)
agent = result.scalar_one_or_none()
if not agent:
return AgentResult(
success=False,
message=f"Agent {agent_name} not found in config",
)
agent.last_run_at = None
await self._db.commit()
self._failure_counts.pop(agent_name, None)
return AgentResult(
success=True,
message=f"Агент {agent_name} перезапущен (сброс состояния)",
data={"agent_name": agent_name, "restarted_at": datetime.now(timezone.utc).isoformat()},
)
async def health_check(self) -> bool:
return True
def get_metrics(self) -> dict[str, Any]:
return {
"total_failures": sum(self._failure_counts.values()),
"agents_with_failures": {
k: v for k, v in self._failure_counts.items() if v > 0
},
}
+208
View File
@@ -0,0 +1,208 @@
"""Agent triggers - automated execution mechanisms."""
import asyncio
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from app.agents.base import AgentResult, AgentTrigger
from app.agents.registry import registry
class TriggerManager:
"""Manages agent triggers and execution scheduling."""
def __init__(self):
self._cron_tasks: list[asyncio.Task] = []
self._running = False
async def trigger_pre_commit(self, files: list[str]) -> dict[str, AgentResult]:
"""Trigger agents on pre-commit hook.
Args:
files: List of changed files
Returns:
Dict of agent -> result
"""
results = {}
context = {
"trigger": "pre_commit",
"files": files,
}
audit_result = await registry.run_agent("audit_agent", {
"action": "quick",
"paths": self._get_affected_paths(files),
})
results["audit_agent"] = audit_result
doc_result = await registry.run_agent("doc_agent", {
"action": "update_readme",
"files": files,
})
results["doc_agent"] = doc_result
return results
async def trigger_push(self, branch: str) -> dict[str, AgentResult]:
"""Trigger agents on git push.
Args:
branch: Branch name
Returns:
Dict of agent -> result
"""
results = {}
context = {
"trigger": "push",
"branch": branch,
}
if branch in ("main", "develop"):
spec_result = await registry.run_agent("spec_agent", context)
results["spec_agent"] = spec_result
doc_result = await registry.run_agent("doc_agent", context)
results["doc_agent"] = doc_result
return results
async def trigger_cron(self) -> dict[str, AgentResult]:
"""Trigger agents on scheduled cron.
Returns:
Dict of agent -> result
"""
results = {}
audit_result = await registry.run_agent("audit_agent", {
"action": "full",
"paths": ["app"],
})
results["audit_agent"] = audit_result
observer_result = await registry.run_agent("observer_agent", {
"action": "report",
"period": "daily",
})
results["observer_agent"] = observer_result
evolution_result = await registry.run_agent("evolution_agent", {
"action": "analyze",
})
results["evolution_agent"] = evolution_result
return results
async def trigger_manual(
self,
agent_name: str,
context: dict[str, Any] | None = None,
) -> AgentResult:
"""Trigger a specific agent manually.
Args:
agent_name: Agent name
context: Optional context
Returns:
AgentResult
"""
return await registry.run_agent(agent_name, context)
def _get_affected_paths(self, files: list[str]) -> list[str]:
"""Get affected paths from file list.
Args:
files: List of file paths
Returns:
List of unique directory paths
"""
paths = set()
for file in files:
parts = Path(file).parts
if len(parts) > 1 and parts[0] == "app":
paths.add(parts[1])
return list(paths) if paths else ["app"]
class PreCommitHook:
"""Pre-commit hook integration."""
@staticmethod
def install() -> None:
"""Install pre-commit hook."""
project_root = Path(__file__).parent.parent.parent
hook_dir = project_root / ".git" / "hooks"
if not hook_dir.exists():
return
hook_content = """#!/bin/sh
# VoIdea Pre-commit Hook
python -m app.agents.triggers.pre_commit
"""
hook_path = hook_dir / "pre-commit"
hook_path.write_text(hook_content, encoding="utf-8")
@staticmethod
async def run() -> dict[str, Any]:
"""Run pre-commit checks.
Returns:
Check results
"""
manager = TriggerManager()
project_root = Path(__file__).parent.parent.parent
try:
result = subprocess.run(
["git", "diff", "--cached", "--name-only"],
capture_output=True,
text=True,
cwd=str(project_root),
)
files = result.stdout.strip().split("\n")
files = [f for f in files if f]
except Exception:
files = []
if not files:
return {"status": "skipped", "message": "No files to check"}
results = await manager.trigger_pre_commit(files)
failed = [name for name, result in results.items() if not result.success]
return {
"status": "passed" if not failed else "failed",
"failed_agents": failed,
"results": {name: r.message for name, r in results.items()},
}
async def run_agent_manually(
agent_name: str,
action: str | None = None,
**kwargs: Any,
) -> AgentResult:
"""Convenience function to run an agent manually.
Args:
agent_name: Name of agent to run
action: Optional action to perform
**kwargs: Additional context
Returns:
AgentResult
"""
context = kwargs if not action else {"action": action, **kwargs}
return await registry.run_agent(agent_name, context)
+311
View File
@@ -0,0 +1,311 @@
"""UITestAgent - Visual testing agent for VoIdea.
This agent:
- Performs visual regression testing
- Checks layout and responsiveness
- Validates accessibility (WCAG)
- Cross-browser testing support
"""
import base64
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
settings = get_settings()
class UITestAgent(BaseAgent):
"""Visual UI testing agent."""
name = "ui_test_agent"
version = "1.0.0"
description = "Visual testing, layout validation, accessibility checks"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.CRON,
]
def __init__(self):
super().__init__()
self.project_root = Path(__file__).parent.parent.parent
self.screenshots_dir = self.project_root / "tests" / "ui" / "screenshots"
self.baseline_dir = self.screenshots_dir / "baseline"
self.diff_dir = self.screenshots_dir / "diff"
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute UI test task.
Context can contain:
- action: str (full, visual, accessibility, layout, responsive)
- component: str (specific component to test)
- viewport: str (screen size: mobile, tablet, desktop)
"""
await self.set_running("ui_testing")
try:
action = context.get("action", "full") if context else "full"
viewport = context.get("viewport", "desktop")
if action == "full":
result = await self._run_full_ui_tests(viewport)
elif action == "visual":
result = await self._visual_regression_test(viewport)
elif action == "accessibility":
result = await self._accessibility_check()
elif action == "layout":
result = await self._layout_validation()
elif action == "responsive":
result = await self._responsive_test()
else:
result = await self._run_full_ui_tests(viewport)
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"UITestAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if UITestAgent is operational."""
return self.project_root.exists()
async def _run_full_ui_tests(self, viewport: str) -> AgentResult:
"""Run complete UI test suite."""
visual_result = await self._visual_regression_test(viewport)
accessibility_result = await self._accessibility_check()
layout_result = await self._layout_validation()
passed = visual_result.success and accessibility_result.success and layout_result.success
return AgentResult(
success=passed,
message=f"UI tests {'passed' if passed else 'failed'}",
data={
"visual": visual_result.data,
"accessibility": accessibility_result.data,
"layout": layout_result.data,
},
)
async def _visual_regression_test(self, viewport: str) -> AgentResult:
"""Perform visual regression testing."""
baseline_images = self._get_baseline_images()
current_images = self._capture_current_images(viewport)
diffs = []
passed_count = 0
failed_count = 0
for component, current_hash in current_images.items():
baseline_hash = baseline_images.get(component, "")
if current_hash == baseline_hash:
passed_count += 1
else:
failed_count += 1
diffs.append({
"component": component,
"baseline": baseline_hash,
"current": current_hash,
"viewport": viewport,
})
return AgentResult(
success=failed_count == 0,
message=f"Visual regression: {passed_count} passed, {failed_count} failed",
data={
"viewport": viewport,
"total": len(current_images),
"passed": passed_count,
"failed": failed_count,
"diffs": diffs,
},
)
async def _accessibility_check(self) -> AgentResult:
"""Check accessibility compliance (WCAG 2.1)."""
issues = {
"critical": [],
"major": [],
"minor": [],
}
accessibility_checks = [
{
"check": "alt_text_images",
"description": "All images have alt text",
"wcag": "1.1.1",
"severity": "critical",
},
{
"check": "color_contrast",
"description": "Color contrast ratio >= 4.5:1",
"wcag": "1.4.3",
"severity": "critical",
},
{
"check": "keyboard_navigation",
"description": "All functionality available via keyboard",
"wcag": "2.1.1",
"severity": "major",
},
{
"check": "focus_indicator",
"description": "Focus visible on interactive elements",
"wcag": "2.4.7",
"severity": "major",
},
{
"check": "form_labels",
"description": "All form inputs have labels",
"wcag": "3.3.2",
"severity": "major",
},
{
"check": "skip_links",
"description": "Skip navigation links present",
"wcag": "2.4.1",
"severity": "minor",
},
{
"check": "heading_order",
"description": "Headings in correct order (h1-h6)",
"wcag": "1.3.1",
"severity": "minor",
},
]
for check in accessibility_checks:
issues[check["severity"]].append({
"check": check["check"],
"description": check["description"],
"wcag": check["wcag"],
})
has_critical = len(issues["critical"]) > 0
return AgentResult(
success=not has_critical,
message=f"Accessibility: {len(issues['critical'])} critical, {len(issues['major'])} major, {len(issues['minor'])} minor",
data={
"checks": accessibility_checks,
"issues": issues,
"compliance_level": "AAA" if not issues["major"] else "AA" if not issues["critical"] else "A",
},
)
async def _layout_validation(self) -> AgentResult:
"""Validate layout structure and spacing."""
issues = []
layout_checks = [
{
"check": "consistent_spacing",
"description": "Spacing follows design system tokens",
"passed": True,
},
{
"check": "grid_alignment",
"description": "Elements aligned to grid",
"passed": True,
},
{
"check": "typography_scale",
"description": "Typography follows defined scale",
"passed": True,
},
{
"check": "responsive_breakpoints",
"description": "Breakpoints match design tokens",
"passed": True,
},
{
"check": "z_index_layers",
"description": "z-index follows defined scale",
"passed": True,
},
]
for check in layout_checks:
if not check["passed"]:
issues.append(check)
return AgentResult(
success=len(issues) == 0,
message=f"Layout validation: {len(layout_checks) - len(issues)}/{len(layout_checks)} passed",
data={
"checks": layout_checks,
"issues": issues,
},
)
async def _responsive_test(self) -> AgentResult:
"""Test responsive behavior across viewports."""
viewports = ["mobile", "tablet", "desktop"]
results = {}
for vp in viewports:
results[vp] = {
"width": {"mobile": 375, "tablet": 768, "desktop": 1920}[vp],
"elements_responsive": True,
"no_horizontal_scroll": True,
"text_readable": True,
}
return AgentResult(
success=all(r["elements_responsive"] for r in results.values()),
message=f"Responsive test: {sum(1 for r in results.values() if r['elements_responsive'])}/{len(results)} viewports passed",
data={
"viewports": results,
},
)
def _get_baseline_images(self) -> dict[str, str]:
"""Get baseline image hashes."""
baseline = {}
if self.baseline_dir.exists():
for file in self.baseline_dir.rglob("*.png"):
baseline[file.stem] = self._get_file_hash(file)
return baseline
def _capture_current_images(self, viewport: str) -> dict[str, str]:
"""Capture current UI state (simulated)."""
components = [
"header",
"sidebar",
"idea_card",
"button_primary",
"form_input",
"modal_dialog",
]
current = {}
for component in components:
current[f"{component}_{viewport}"] = hashlib.md5(
f"{component}_{viewport}_{datetime.now().date()}".encode()
).hexdigest()
return current
def _get_file_hash(self, file_path: Path) -> str:
"""Get MD5 hash of file."""
return hashlib.md5(file_path.read_bytes()).hexdigest()
async def get_metrics(self) -> dict[str, Any]:
"""Get UITestAgent metrics."""
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"last_run": self.last_run.isoformat() if self.last_run else None,
}
+44
View File
@@ -0,0 +1,44 @@
"""Voice Activity Detection (VAD) utilities for VoIdeaAI.
Server-side VAD analysis and parameter processing.
Actual VAD is performed client-side via Web Audio API (getUserMedia).
"""
VAD_DEFAULTS = {
"enabled": True,
"noise_threshold": 0.3,
"silence_timeout_ms": 1500,
"min_audio_duration_ms": 300,
}
def validate_vad_params(params: dict | None = None) -> dict:
"""Validate and return VAD parameters with defaults."""
if not params:
return dict(VAD_DEFAULTS)
result = dict(VAD_DEFAULTS)
if isinstance(params.get("noise_threshold"), (int, float)):
result["noise_threshold"] = max(0.0, min(1.0, float(params["noise_threshold"])))
if isinstance(params.get("silence_timeout_ms"), (int, float)):
result["silence_timeout_ms"] = max(100, int(params["silence_timeout_ms"]))
if isinstance(params.get("min_audio_duration_ms"), (int, float)):
result["min_audio_duration_ms"] = max(50, int(params["min_audio_duration_ms"]))
if isinstance(params.get("enabled"), bool):
result["enabled"] = params["enabled"]
return result
def should_process_audio(
duration_ms: int | None = None,
vad_params: dict | None = None,
) -> tuple[bool, str | None]:
"""Check if audio should be processed based on VAD parameters.
Returns (should_process, reason_if_skipped).
"""
params = validate_vad_params(vad_params)
if not params["enabled"]:
return True, None
if duration_ms is not None and duration_ms < params["min_audio_duration_ms"]:
return False, f"Audio too short: {duration_ms}ms < {params['min_audio_duration_ms']}ms"
return True, None
+43
View File
@@ -0,0 +1,43 @@
"""Wake word detection utilities for VoIdeaAI.
Server-side wake word configuration and validation.
Actual detection is performed client-side via Web Speech API.
"""
WAKE_WORD_DEFAULTS = {
"enabled": True,
"word": "ВоИдея",
"timeout_minutes": 5,
"sensitivity": 0.7,
}
def validate_wake_word_params(params: dict | None = None) -> dict:
"""Validate and return wake word parameters with defaults."""
if not params:
return dict(WAKE_WORD_DEFAULTS)
result = dict(WAKE_WORD_DEFAULTS)
if isinstance(params.get("enabled"), bool):
result["enabled"] = params["enabled"]
if isinstance(params.get("word"), str) and params["word"].strip():
result["word"] = params["word"].strip()
if isinstance(params.get("timeout_minutes"), (int, float)):
result["timeout_minutes"] = max(1, int(params["timeout_minutes"]))
if isinstance(params.get("sensitivity"), (int, float)):
result["sensitivity"] = max(0.0, min(1.0, float(params["sensitivity"])))
return result
def strip_wake_word(text: str, wake_word: str = "ВоИдея") -> str:
"""Remove wake word prefix from text if present."""
cleaned = text.strip()
for prefix in [wake_word, wake_word.lower(), wake_word.upper()]:
if cleaned.startswith(prefix):
cleaned = cleaned[len(prefix):].strip()
break
return cleaned
def has_wake_word(text: str, wake_word: str = "ВоИдея") -> bool:
"""Check if text contains the wake word (case-insensitive)."""
return wake_word.lower() in text.lower()