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