Initial commit: VoIdeaAI - voice-first AI idea assistant
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
# Environment Variables - VoIdea
|
||||||
|
|
||||||
|
PROJECT_NAME=VoIdeaAI
|
||||||
|
PROJECT_VERSION=1.0.0
|
||||||
|
PROJECT_ENV=local
|
||||||
|
SERVER_HOST=0.0.0.0
|
||||||
|
SERVER_PORT=8020
|
||||||
|
SERVER_EXTERNAL_URL=http://localhost:8020
|
||||||
|
|
||||||
|
# Database (PostgreSQL only)
|
||||||
|
DATABASE_URL=postgresql+asyncpg://voidea:password@localhost:5432/voidea
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY=
|
||||||
|
JWT_RESET_SECRET_KEY=
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||||
|
|
||||||
|
# AI
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
AI_YANDEX_KEY=
|
||||||
|
AI_YANDEX_FOLDER_ID=
|
||||||
|
AI_GIGACHAT_CLIENT_ID=
|
||||||
|
AI_GIGACHAT_SECRET=
|
||||||
|
AI_FALLBACK_MODEL=yandex_gpt
|
||||||
|
|
||||||
|
# OAuth
|
||||||
|
OAUTH_YANDEX_ID=
|
||||||
|
OAUTH_YANDEX_SECRET=
|
||||||
|
OAUTH_GOOGLE_ID=
|
||||||
|
OAUTH_GOOGLE_SECRET=
|
||||||
|
OAUTH_GOOGLE_REDIRECT_URI=http://localhost:8020/auth/google/callback
|
||||||
|
OAUTH_APPLE_ID=
|
||||||
|
OAUTH_APPLE_SECRET=
|
||||||
|
OAUTH_APPLE_REDIRECT_URI=http://localhost:8020/auth/apple/callback
|
||||||
|
|
||||||
|
# Telegram Bot (пусто = заглушка)
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
|
||||||
|
# Email
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_ENABLED=true
|
||||||
|
RATE_LIMIT_DEFAULT=60/minute
|
||||||
|
RATE_LIMIT_AUTH=10/minute
|
||||||
|
|
||||||
|
# Security
|
||||||
|
ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# System Owner (задаётся при деплое на VPS, защищён от удаления)
|
||||||
|
SYSTEM_OWNER_EMAIL=
|
||||||
|
|
||||||
|
# Social Networks (пусто = иконка скрыта)
|
||||||
|
SOCIAL_TELEGRAM=voideaai
|
||||||
|
SOCIAL_VK=voideaai
|
||||||
|
SOCIAL_YOUTUBE=voideaai
|
||||||
|
SOCIAL_TIKTOK=voideaai
|
||||||
|
PROJECT_SLOGAN=VoIdeaAI — идеи рождаются вслух, решения приходят мгновенно!
|
||||||
|
|
||||||
|
# Analytics (пусто = отключено)
|
||||||
|
YANDEX_METRIKA_ID=
|
||||||
|
GOOGLE_ANALYTICS_ID=
|
||||||
|
|
||||||
|
# Tariffs (false = всё бесплатно, промо-режим)
|
||||||
|
TARIFFS_ENABLED=false
|
||||||
|
TARIFFS_FREE_CODE=free
|
||||||
|
|
||||||
|
# Push-уведомления (mobile-ready, пусто = отключено)
|
||||||
|
FCM_SERVER_KEY=
|
||||||
|
APNS_KEY_ID=
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
DEBUG_MODE_RETENTION_DAYS=14
|
||||||
|
DEBUG_MODE_MAX_SIZE_MB=500
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-python:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/ruff-action@v1
|
||||||
|
with:
|
||||||
|
args: check .
|
||||||
|
|
||||||
|
test-python:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python 3.12
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
- name: Run tests
|
||||||
|
run: pytest -v --tb=short
|
||||||
|
|
||||||
|
typecheck-python:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python 3.12
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
- name: Run mypy
|
||||||
|
run: mypy app/ --ignore-missing-imports
|
||||||
|
|
||||||
|
lint-frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: webui/package-lock.json
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: webui
|
||||||
|
- name: TypeScript check
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
working-directory: webui
|
||||||
|
- name: Lint
|
||||||
|
run: npx eslint src/ || echo "ESLint not configured — skipping"
|
||||||
|
working-directory: webui
|
||||||
|
|
||||||
|
test-frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: webui/package-lock.json
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: webui
|
||||||
|
- name: Run Vitest
|
||||||
|
run: npx vitest run --reporter=verbose
|
||||||
|
working-directory: webui
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy to VPS via SSH
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.VPS_HOST }}
|
||||||
|
username: ${{ secrets.VPS_USER }}
|
||||||
|
key: ${{ secrets.VPS_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /opt/voidea
|
||||||
|
sudo -u voidea bash deploy/deploy.sh
|
||||||
|
echo "Deploy via deploy.sh complete"
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
*.egg
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Node
|
||||||
|
webui/node_modules/
|
||||||
|
webui/dist/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.8.4
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.6.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-added-large-files
|
||||||
|
args: [--maxkb=500]
|
||||||
|
- id: check-merge-conflict
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"conductor": "1.0.0",
|
||||||
|
"system": "1.0.0",
|
||||||
|
"supervisor_agent": "1.0.0",
|
||||||
|
"Дирижёр": "1.0.0",
|
||||||
|
"doc_agent": "1.0.0",
|
||||||
|
"backlog_agent": "1.0.0",
|
||||||
|
"spec_agent": "1.0.0",
|
||||||
|
"audit_agent": "1.0.0",
|
||||||
|
"observer_agent": "1.0.0",
|
||||||
|
"evolution_agent": "1.0.0",
|
||||||
|
"security_agent": "1.0.0",
|
||||||
|
"qa_tester_agent": "1.0.0",
|
||||||
|
"fix_agent": "1.0.0",
|
||||||
|
"ui_test_agent": "1.0.0",
|
||||||
|
"rollout_agent": "1.0.0",
|
||||||
|
"role_agents": "1.0.0",
|
||||||
|
"business_analyst": "1.0.0",
|
||||||
|
"task_organizer": "1.0.0",
|
||||||
|
"lawyer": "1.0.0",
|
||||||
|
"financial_consultant": "1.0.0",
|
||||||
|
"solution_architect": "1.0.0",
|
||||||
|
"tester": "1.0.0",
|
||||||
|
"ui_designer": "1.0.0",
|
||||||
|
"smm_specialist": "1.0.0",
|
||||||
|
"life_coach": "1.0.0",
|
||||||
|
"accessibility_expert": "1.0.0",
|
||||||
|
"critic": "1.0.0",
|
||||||
|
"copywriter": "1.0.0",
|
||||||
|
"keeper": "1.0.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# audit_agent Changelog
|
||||||
|
<!-- checksum: ed7187992e444fcf66434652a6bcc67f2456231695a87c8c11397dfcb78491e4 -->
|
||||||
|
|
||||||
|
## 1.0.0 (2026-05-10)
|
||||||
|
- Initial version: style_check, type_check, docs_check
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# backlog_agent Changelog
|
||||||
|
<!-- checksum: 304ea3be6001aad5219fcc26769b7445191e514bbc41c12fdec3ae56a2fa3e8b -->
|
||||||
|
|
||||||
|
## 1.0.0 (2026-05-10)
|
||||||
|
- Initial version: create, list, update, delete, suggest
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# doc_agent Changelog
|
||||||
|
<!-- checksum: 4597ebce9130095330e66d4f2f96a35cdd0b3ac5ede739eff5c64b69471cae5e -->
|
||||||
|
|
||||||
|
## 1.0.0 (2026-05-10)
|
||||||
|
- Initial version: documentation, docstrings, runbook
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# evolution_agent Changelog
|
||||||
|
<!-- checksum: c82c703aa0d16623f63003d4486472388fbc24c6b6bb21087daba57b50d743dc -->
|
||||||
|
|
||||||
|
## 1.0.0 (2026-05-10)
|
||||||
|
- Initial version: analyze, evolve, suggest, status, version_bump
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# fix_agent Changelog
|
||||||
|
<!-- checksum: aed23b015f59b46f5bc6cad0f5d49e02ed7dbc82fae22caacb13f55139354ec4 -->
|
||||||
|
|
||||||
|
## 1.0.0 (2026-05-10)
|
||||||
|
- Initial version: bug_analysis, patch_generation, validation
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# observer_agent Changelog
|
||||||
|
<!-- checksum: 2eb0f71129ae3ab0ad8cf43bb7fd9f308703661b6842a803eeef9450a65b2b19 -->
|
||||||
|
|
||||||
|
## 1.0.0 (2026-05-10)
|
||||||
|
- Initial version: collect, report, analyze, metrics
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# qa_tester_agent Changelog
|
||||||
|
<!-- checksum: b31ea354ad4cf44e74854e47d30c6113c4c7ce2b061c4113590fafde432dfec4 -->
|
||||||
|
|
||||||
|
## 1.0.0 (2026-05-10)
|
||||||
|
- Initial version: functional_test, smoke_test, regression
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# rollout_agent Changelog
|
||||||
|
<!-- checksum: 537b00378c58661199789d5971bda1819e7f967a54dae287d1a3357f5ad799eb -->
|
||||||
|
|
||||||
|
## 1.0.0 (2026-05-10)
|
||||||
|
- Initial version: gradual_deploy, monitor, rollback
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# security_agent Changelog
|
||||||
|
<!-- checksum: e5d70efa860f4905da60f890cda502ba260e32ea0574eb5dc4adee5850805c9b -->
|
||||||
|
|
||||||
|
## 1.0.0 (2026-05-10)
|
||||||
|
- Initial version: vulnerability_scan, dependency_check, compliance
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# spec_agent Changelog
|
||||||
|
<!-- checksum: f9d814c6ba573aa1b3c53c1f49b72ea1cb5c48da6cd48de40a1846207976f1ea -->
|
||||||
|
|
||||||
|
## 1.0.0 (2026-05-10)
|
||||||
|
- Initial version: versioning, changelog, project_json
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# ui_test_agent Changelog
|
||||||
|
<!-- checksum: c71acf4c1b8f6aaf95131323c3c2bdf078b1cc0b086927124e3f24148d81498b -->
|
||||||
|
|
||||||
|
## 1.0.0 (2026-05-10)
|
||||||
|
- Initial version: screenshot_test, layout_check, accessibility
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Changelog v1.0
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-05-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Project rename: VoIdea → VoIdeaAI (config, main.py, .env.example, PWA)
|
||||||
|
- Phase 0: Database models and initial Alembic migration (8 tables)
|
||||||
|
- Phase 1: Chat sessions with auto-title (LLM), sidebar, history
|
||||||
|
- Phase 6: Rate limiting (slowapi) on all auth endpoints
|
||||||
|
- Phase 7: Security hardening — security headers middleware, brute force protection (5 attempts),
|
||||||
|
refresh token rotation, separate JWT reset secret
|
||||||
|
- 26 agents: 1 Conductor + 13 role agents + 12 dev/ops agents
|
||||||
|
- Voice pipeline: Web Speech API → Whisper API, SpeechSynthesis TTS
|
||||||
|
- Yandex OAuth + Disk integration (7 scopes)
|
||||||
|
- Password reset via email (JWT token, 1h expiry)
|
||||||
|
- AES-256 encryption service (Fernet, PBKDF2 600k)
|
||||||
|
- Email service with Jinja2 templates and SMTP fallback logging
|
||||||
|
- Google/Apple OAuth stubs with Drive client interfaces
|
||||||
|
- Full PWA support: manifest, favicons, service worker
|
||||||
|
- docs/full.md — comprehensive project specification (19 sections)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Static files moved from `/assets` to proper StaticFiles mount in main.py
|
||||||
|
- Conductor agent accepts session_id, auto-creates sessions
|
||||||
|
- VoiceChat UI: sidebar with session list, text input alongside mic
|
||||||
|
- .env: updated to unified DATABASE_URL format, new OAuth fields
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- All auth endpoints rate-limited (10/min login, 5/min register, 3/min forgot-password)
|
||||||
|
- SecurityHeadersMiddleware: CSP, HSTS, X-Frame-Options, X-Content-Type-Options
|
||||||
|
- Brute force: 5 failed login attempts → 15 minute block
|
||||||
|
- Refresh token rotation on every refresh call
|
||||||
|
- Password reset uses separate JWT secret key
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Changelog v2.0
|
||||||
|
|
||||||
|
## [2.0.0] - 2026-05-11
|
||||||
|
|
||||||
|
### Added (Backend)
|
||||||
|
- **Config**: 14 new env vars — `SYSTEM_OWNER_EMAIL`, social networks (4), analytics IDs (2), tariff toggle, slogan, mobile push stubs (2), accepted terms version
|
||||||
|
- **Role system**: `role` field (user|moderator|admin|owner), `is_owner` flag, `permissions` JSONB for granular moderator rights
|
||||||
|
- **Owner protection**: `SYSTEM_OWNER_EMAIL` locks one user as permanent owner — cannot be deleted, suspended, or demoted via API/UI
|
||||||
|
- **Feedback system**: new `feedback` table + POST/GET/PATCH/DELETE endpoints + admin moderation
|
||||||
|
- **Tariff system**: `tariff_plans` + `user_subscriptions` tables, admin CRUD, feature gating module (`FeatureGate`)
|
||||||
|
- **Public config endpoint**: `GET /api/v1/config/public`
|
||||||
|
- **BacklogTask `category` field**: separates features from general backlog items
|
||||||
|
- **Service management**: systemd unit files (API, Worker, Beat) + restart endpoints (owner-only, production-guarded)
|
||||||
|
- **Seed data**: `seed_database()` — Free tariff, 26 agent descriptions, owner by `SYSTEM_OWNER_EMAIL`
|
||||||
|
- **Migration 002**: roles, tariffs, feedback, agent descriptions, backlog category
|
||||||
|
|
||||||
|
### Added (Frontend)
|
||||||
|
- **Landing page**: public `/` with hero, features, CTA
|
||||||
|
- **Legal pages**: `/privacy`, `/terms`, `/data-processing` with full content
|
||||||
|
- **Register checkbox**: mandatory agreement to Terms & Data Processing
|
||||||
|
- **Social footer**: GitHub + Telegram icons in Layout footer, legal links
|
||||||
|
- **SEO**: meta keywords, Open Graph tags, Twitter Card, JSON-LD, robots.txt, sitemap.xml
|
||||||
|
- **Analytics**: YandexMetrika + GAScript conditional components (empty ID = disabled)
|
||||||
|
- **In-app documentation**: UserGuidePage, AdminDocumentationTab (role-filtered), HelpFAB, HelpDrawer
|
||||||
|
- **Admin panel**: complete rewrite — 8 tabs (Users, Agents, Features, Logs, Feedback, Tariffs, Services, System)
|
||||||
|
- Users tab: search, inline edit (name, email, role, active), delete, moderator permissions editor
|
||||||
|
- Agents tab: list, toggle enable/disable
|
||||||
|
- Features tab: create, cycle status (pending→in_progress→done)
|
||||||
|
- Logs tab: filter by level/source
|
||||||
|
- Feedback tab: filter by status, change status, delete
|
||||||
|
- Tariffs tab: create with JSON features, toggle active, delete
|
||||||
|
- Services tab: restart systemd services (owner-only, production)
|
||||||
|
- System tab: version, environment, DB status, Python version, uptime
|
||||||
|
- Docs tab: role-filtered administration guide
|
||||||
|
- **Settings page**: `/settings` — profile, security (change password), voice, theme (dark/light), integrations, tariff info
|
||||||
|
- **User settings link** in Layout header nav
|
||||||
|
- **FeedbackForm component**: textarea + submit, success state
|
||||||
|
- **Feedback page**: `/feedback` standalone page
|
||||||
|
- **API modules**: `config.ts`, `feedback.ts`, `tariffs.ts`, `admin.ts` (full CRUD)
|
||||||
|
- **AuthContext**: updated User interface with role, is_owner, permissions
|
||||||
|
- **SEOHead component**: dynamic title/meta per page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `User` model: `is_superuser` → property based on `role`, added `is_owner`, `permissions` (JSONB), `accepted_terms_at`, `accepted_terms_version`
|
||||||
|
- `RegisterPage`: added mandatory checkbox for Terms & Privacy agreement
|
||||||
|
- `Layout`: added footer with social icons, legal links; header settings link; HelpFAB
|
||||||
|
- `AdminPage`: complete rewrite from simple 2-tab to full 8-tab panel
|
||||||
|
- `AuthContext`: updated User interface, register passes `accepted_terms: true`
|
||||||
|
- `ProtectedRoute`: uses `role` and `is_owner` for admin checks
|
||||||
|
- **Routing**: `/` → LandingPage (public), `/dashboard` → Dashboard (protected)
|
||||||
|
- `index.html`: comprehensive SEO meta tags, Open Graph, JSON-LD, Twitter Card
|
||||||
|
- `tailwind.config.js`: added `@tailwindcss/typography` plugin
|
||||||
|
- `app/core/config.py`: restructured with new sections (System Owner, Social, Analytics, Tariffs, Push)
|
||||||
|
- `app/main.py`: lifespan calls `seed_database()` with try/except
|
||||||
|
|
||||||
|
### Added (DevOps)
|
||||||
|
- `deploy/voidea-api.service` — systemd unit for FastAPI (uvicorn, 2 workers)
|
||||||
|
- `deploy/voidea-worker.service` — systemd unit for background worker
|
||||||
|
- `deploy/voidea-beat.service` — systemd unit for beat scheduler
|
||||||
|
- `deploy/deploy.sh` — full deployment script (user, deps, venv, build, migrate, systemd)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- `docs/full.md` — full rewrite covering v2.0 features
|
||||||
|
- `docs/user-guide.md` — user manual (quick start, agents, commands)
|
||||||
|
- `docs/admin-guide.md` — admin/owner/moderator guide
|
||||||
|
- `CHANGELOG/v2.0.md` — this file
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# VoIdea — Голос Идей
|
||||||
|
|
||||||
|
## О проекте
|
||||||
|
|
||||||
|
**VoIdea** — гибридное приложение (мобильное + веб) для фиксации и проработки идей с помощью группового ИИ-анализа.
|
||||||
|
|
||||||
|
### Ключевые возможности
|
||||||
|
|
||||||
|
- 🎙️ Голосовой ввод идей
|
||||||
|
- 🤖 11 ИИ-агентов для анализа
|
||||||
|
- 🔄 Синхронизация между устройствами
|
||||||
|
- 🔒 Защита данных (шифрование AES-256)
|
||||||
|
- 🌐 Работа оффлайн (PWA)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
`
|
||||||
|
voidea/
|
||||||
|
├── app/ # Код приложения
|
||||||
|
│ ├── agents/ # 11 системных агентов
|
||||||
|
│ ├── core/ # Ядро (config, base, security)
|
||||||
|
│ ├── models/ # Модели данных
|
||||||
|
│ ├── api/ # API endpoints
|
||||||
|
│ ├── services/ # Бизнес-логика
|
||||||
|
│ ├── integrations/ # Внешние сервисы (AI, OAuth)
|
||||||
|
│ └── design-tokens/ # Сгенерированные стили
|
||||||
|
├── docs/ # Документация
|
||||||
|
│ ├── blocks/ # Блоки проекта (00-rules, PLAN...)
|
||||||
|
│ ├── design-system/ # Дизайн-система (tokens.json)
|
||||||
|
│ ├── instructions/ # Инструкции для AI и людей
|
||||||
|
│ ├── specs/ # Спецификации
|
||||||
|
│ ├── adr/ # Architecture Decision Records
|
||||||
|
│ ├── backlog/ # Отложенные задачи
|
||||||
|
│ ├── runbook/ # Runbook для админа
|
||||||
|
│ └── insights/ # Наблюдения ObserverAgent
|
||||||
|
├── tests/ # Тесты
|
||||||
|
│ ├── unit/ # Модульные
|
||||||
|
│ └── integration/ # Интеграционные
|
||||||
|
├── CHANGELOG/ # История версий
|
||||||
|
└── project.json # Машиночитаемое описание
|
||||||
|
`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### Для AI (OpenCode)
|
||||||
|
|
||||||
|
1. Прочитай docs/blocks/00-rules.md — это приоритет
|
||||||
|
2. Прочитай docs/instructions/00-system-prompt.md
|
||||||
|
3. Следуй плану в docs/blocks/PLAN.md
|
||||||
|
|
||||||
|
### Для разработчиков
|
||||||
|
|
||||||
|
1. Установи Python 3.12+
|
||||||
|
2. Установи PostgreSQL
|
||||||
|
3. Скопируй .env.example → .env
|
||||||
|
4. Заполни .env (см. комментарии)
|
||||||
|
5. pip install -r requirements.txt
|
||||||
|
6. lembic upgrade head
|
||||||
|
7. uvicorn app.main:app --reload --port 8020
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
### Переменные окружения
|
||||||
|
|
||||||
|
Все переменные описаны в .env.example:
|
||||||
|
|
||||||
|
`ash
|
||||||
|
# Core
|
||||||
|
PROJECT_NAME=VoIdea
|
||||||
|
PROJECT_VERSION=1.0.0
|
||||||
|
SERVER_PORT=8020
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=voidea
|
||||||
|
DB_USER=voidea
|
||||||
|
|
||||||
|
# AI
|
||||||
|
AI_YANDEX_KEY=
|
||||||
|
AI_GIGACHAT_KEY=
|
||||||
|
|
||||||
|
# OAuth
|
||||||
|
OAUTH_YANDEX_ID=
|
||||||
|
OAUTH_GOOGLE_ID=
|
||||||
|
`
|
||||||
|
|
||||||
|
Подробнее: .env.example
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Версионирование
|
||||||
|
|
||||||
|
Формат: MAJOR.MINOR.PATCH (SemVer)
|
||||||
|
|
||||||
|
- **MAJOR** (1.x.x): полный релиз, breaking changes
|
||||||
|
- **MINOR** (x.1.x): новый функционал
|
||||||
|
- **PATCH** (x.x.1): багфиксы
|
||||||
|
|
||||||
|
CHANGELOG хранится в CHANGELOG/ по версиям.
|
||||||
|
Подробнее: docs/blocks/VERSIONS.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Дизайн-система
|
||||||
|
|
||||||
|
Единый источник истины: docs/design-system/tokens.json
|
||||||
|
|
||||||
|
Включает:
|
||||||
|
- Цвета (primary, background, text, semantic)
|
||||||
|
- Типографика (font-family, size, weight)
|
||||||
|
- Отступы, радиусы, тени
|
||||||
|
- 3 темы: system (auto), dark, light
|
||||||
|
|
||||||
|
Генераторы:
|
||||||
|
- generators/css_generator.py → CSS Variables
|
||||||
|
- generators/swift_generator.py → Swift
|
||||||
|
- generators/kotlin_generator.py → Kotlin XML
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Агенты
|
||||||
|
|
||||||
|
### Системные агенты (автоматизация)
|
||||||
|
|
||||||
|
| Агент | Назначение |
|
||||||
|
|-------|-----------|
|
||||||
|
| DocAgent | Документация |
|
||||||
|
| AuditAgent | Соблюдение правил |
|
||||||
|
| SecurityAgent | Безопасность |
|
||||||
|
| SpecAgent | Версионирование |
|
||||||
|
| ObserverAgent | Наблюдение за пользователями |
|
||||||
|
| QATesterAgent | Функциональное тестирование |
|
||||||
|
| FixAgent | Исправление багов |
|
||||||
|
| UITestAgent | Визуальное тестирование |
|
||||||
|
| RolloutAgent | Постепенное развёртывание |
|
||||||
|
| EvolutionAgent | Саморазвитие |
|
||||||
|
| BacklogAgent | Управление задачами |
|
||||||
|
|
||||||
|
Запуск: автоматически (pre-commit, push, cron) или вручную (админ-панель).
|
||||||
|
|
||||||
|
### ИИ-агенты (анализ идей)
|
||||||
|
|
||||||
|
11 ролей: Координатор, Организатор, Бизнес-аналитик, Юрист, Финансовый консультант, Архитектор, Тестировщик, UI-дизайнер, SMM-специалист, Лайф-коуч, Эксперт по доступности.
|
||||||
|
|
||||||
|
Промпты: docs/agent_prompts.yaml
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
| Документ | Описание |
|
||||||
|
|----------|----------|
|
||||||
|
| docs/blocks/00-rules.md | Правила проекта (приоритет) |
|
||||||
|
| docs/blocks/PLAN.md | План реализации |
|
||||||
|
| docs/blocks/AUDIT.md | Система аудита |
|
||||||
|
| docs/blocks/BACKLOG.md | Система задач |
|
||||||
|
| docs/blocks/VERSIONS.md | Правила версионирования |
|
||||||
|
| docs/blocks/GLOSSARY.md | Глоссарий |
|
||||||
|
| docs/instructions/ | Инструкции для AI и людей |
|
||||||
|
| docs/runbook/ | Runbook для администратора |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Контакты
|
||||||
|
|
||||||
|
**Owner:**
|
||||||
|
**License:** AGPL-3.0
|
||||||
|
**Версия:**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODO: Перед началом разработки
|
||||||
|
|
||||||
|
- [ ] Установить PostgreSQL локально
|
||||||
|
- [ ] Создать виртуальное окружение
|
||||||
|
- [ ] Настроить .env
|
||||||
|
- [ ] Запустить Block 1: Core
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Обновлён: 2026-05-10*
|
||||||
|
*Этот файл самообновляется системными агентами*
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# VoIdeaAI — голосовой AI-ассистент для идей
|
||||||
|
|
||||||
|
**VoIdeaAI** — PWA для генерации, проработки и сохранения идей с помощью группового ИИ-анализа (26 агентов). V2.0: роли, тарифы, админ-панель (8 вкладок), лендинг, юридические страницы, аналитика, SEO, in-app документация, systemd-деплой.
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\activate
|
||||||
|
pip install -e .
|
||||||
|
alembic upgrade head
|
||||||
|
uvicorn app.main:app --reload --port 8020
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd webui
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
| Компонент | Технология |
|
||||||
|
|-----------|-----------|
|
||||||
|
| Бэкенд | Python 3.12+, FastAPI, asyncpg, SQLAlchemy 2.0 |
|
||||||
|
| Фронтенд | React 18, TypeScript, Tailwind CSS, PWA |
|
||||||
|
| База данных | PostgreSQL (только) |
|
||||||
|
| Аутентификация | JWT + bcrypt + OAuth (Яндекс, Google, Apple) |
|
||||||
|
| ИИ | OpenAI / YandexGPT / GigaChat (fallback) |
|
||||||
|
| Голос | Web Speech API → Whisper API (fallback) |
|
||||||
|
| TTS | SpeechSynthesis API (браузер) |
|
||||||
|
| Оркестрация | Дирижёр + 13 ролевых агентов + 12 dev/ops агентов |
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser (PWA) → FastAPI (StaticFiles) → Conductor → Role Agent → LLM
|
||||||
|
↓
|
||||||
|
Верификация → Ответ
|
||||||
|
```
|
||||||
|
|
||||||
|
Подробнее: [`docs/full.md`](docs/full.md)
|
||||||
|
|
||||||
|
## Статус реализации v2.0
|
||||||
|
|
||||||
|
### ✅ Завершено
|
||||||
|
- **Backend**: Роли (owner/admin/moderator/user), тарифы, фидбек, feature gate, seed, config, миграция 002
|
||||||
|
- **Admin API**: 22 эндпоинта — пользователи, агенты, логи, отзывы, тарифы, фичи, сервисы, система
|
||||||
|
- **Public API**: конфиг, тарифы, фидбек, регистрация с accepted_terms, смена пароля
|
||||||
|
- **Frontend API**: `admin.ts`, `config.ts`, `feedback.ts`, `tariffs.ts` — полный API-слой
|
||||||
|
- **Landing page**: public `/` с hero, features, CTA
|
||||||
|
- **Legal pages**: `/privacy`, `/terms`, `/data-processing`
|
||||||
|
- **Admin panel**: 8 вкладок (Users, Agents, Features, Logs, Feedback, Tariffs, Services, System, Docs)
|
||||||
|
- **Settings**: профиль, безопасность, голос, тема, интеграции, тариф
|
||||||
|
- **In-app docs**: UserGuidePage, HelpFAB, HelpDrawer, AdminDocumentationTab
|
||||||
|
- **SEO**: Open Graph, JSON-LD, Twitter Card, robots.txt, sitemap.xml
|
||||||
|
- **Analytics**: YandexMetrika + GA conditional components
|
||||||
|
- **DevOps**: 3 systemd unit files + deploy.sh
|
||||||
|
- **@tailwindcss/typography**: установлен, prose-классы работают
|
||||||
|
|
||||||
|
### В процессе / запланировано
|
||||||
|
- **Dark styles + CSS wave** (Phase 4) — отложено
|
||||||
|
- **OAuth callback E2E тесты** — нужен public URL
|
||||||
|
- **Google/Apple OAuth** — ждут API credentials
|
||||||
|
- **Push-уведомления** — FCM/APNS stubs в config
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
- [`docs/full.md`](docs/full.md) — полная спецификация
|
||||||
|
- [`docs/user-guide.md`](docs/user-guide.md) — руководство пользователя
|
||||||
|
- [`docs/admin-guide.md`](docs/admin-guide.md) — руководство администратора
|
||||||
|
- [`CHANGELOG/v2.0.md`](CHANGELOG/v2.0.md) — все изменения v2.0
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest -v
|
||||||
|
pytest --cov=app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
AGPL-3.0
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
# VoIdea - Session Context
|
||||||
|
# Этот файл самопополняется при каждом общении
|
||||||
|
# Структурирован для понимания AI-агентами и разработчиками
|
||||||
|
|
||||||
|
Last Updated: 2026-05-12T22:45:00.000000+00:00
|
||||||
|
================================================================================
|
||||||
|
# ИНСТРУКЦИЯ ДЛЯ AI (OpenCode)
|
||||||
|
Last Updated: 2026-05-10T21:00:00.000000+00:00
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
ПЕРЕД НАЧАЛОМ РАБОТЫ ОБЯЗАТЕЛЬНО ПРОЧТИ ЭТОТ ФАЙЛ!
|
||||||
|
|
||||||
|
Этот файл — единая точка входа для понимания проекта и контекста общения.
|
||||||
|
Обновляется автоматически после каждой сессии.
|
||||||
|
|
||||||
|
Last Updated: 2026-05-10T15:41:04.073549+00:00
|
||||||
|
================================================================================
|
||||||
|
# ПРОЕКТ: VoIdea
|
||||||
|
Last Updated: 2026-05-10T15:41:04.073549+00:00
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
VoIdea ("Голос Идей") — гибридное приложение (мобильное + веб) для фиксации
|
||||||
|
и проработки идей с помощью группового ИИ-анализа.
|
||||||
|
|
||||||
|
## Ключевые требования
|
||||||
|
- Работа в условиях нестабильного интернета или оффлайн
|
||||||
|
- Максимальная защита данных пользователя
|
||||||
|
- Гибкий выбор ИИ-моделей (локальных и облачных)
|
||||||
|
- Синхронизация данных между устройствами через VPS
|
||||||
|
|
||||||
|
## Технологический стек
|
||||||
|
- Backend: Python FastAPI, Port 8020
|
||||||
|
- Database: PostgreSQL
|
||||||
|
- Cache/Queue: Redis + Celery
|
||||||
|
- Frontend: React + TypeScript + Tailwind CSS (PWA)
|
||||||
|
- Mobile: iOS/Android (параллельно с вебом)
|
||||||
|
- AI: Yandex GPT, GigaChat
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
AGPL-3.0
|
||||||
|
|
||||||
|
Last Updated: 2026-05-10T15:41:04.073549+00:00
|
||||||
|
================================================================================
|
||||||
|
# ДОГОВОРЁННОСТИ И ПРАВИЛА
|
||||||
|
Last Updated: 2026-05-10T15:41:04.073549+00:00
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## ОБЯЗАТЕЛЬНЫЕ ПРАВИЛА
|
||||||
|
|
||||||
|
1. **ЯЗЫК**: Все вопросы — на русском языке
|
||||||
|
2. **РЕКОМЕНДАЦИИ**: Всегда даю рекомендации с пояснениями
|
||||||
|
- Объясняю почему рекомендую именно это
|
||||||
|
- Учитываю правильность кодирования и перспективу проекта
|
||||||
|
3. **КАЧЕСТВО КОДА**: Кривой код = переписать сразу
|
||||||
|
- Не тянем "как-нибудь" дальше
|
||||||
|
- Лучше потратить время сейчас чем потом переписывать
|
||||||
|
4. **ПРИОРИТЕТ ПРАВИЛ**: docs/blocks/00-rules.md — основа всего
|
||||||
|
- Если что-то не описано в блоке — смотрим 00-rules.md
|
||||||
|
- Только потом задаём вопрос пользователю
|
||||||
|
|
||||||
|
## АРХИТЕКТУРНЫЕ РЕШЕНИЯ (ADR)
|
||||||
|
|
||||||
|
### ADR-001: PostgreSQL как БД
|
||||||
|
- Выбрана PostgreSQL для всех данных
|
||||||
|
- ACID транзакции, JSONB для гибкости
|
||||||
|
- Масштабируемость до тысяч пользователей
|
||||||
|
|
||||||
|
### ADR-002: 11 системных агентов
|
||||||
|
- DocAgent, AuditAgent, SecurityAgent, SpecAgent, ObserverAgent
|
||||||
|
- QATesterAgent, FixAgent, UITestAgent, RolloutAgent
|
||||||
|
- EvolutionAgent, BacklogAgent
|
||||||
|
|
||||||
|
### ADR-003: OAuth схема — один пользователь = один провайдер
|
||||||
|
- НЕЛЬЗЯ привязать Google к аккаунту зарегистрированному через Яндекс
|
||||||
|
- Нельзя добавить второй OAuth провайдер
|
||||||
|
- Провайдеры: Email, Яндекс, Google, Apple (отложен)
|
||||||
|
|
||||||
|
### ADR-004: Постепенное развёртывание (Rollout)
|
||||||
|
- Stage 0: Development (тесты агентов)
|
||||||
|
- Stage 1: 3 пользователя
|
||||||
|
- Stage 2: 1%
|
||||||
|
- Stage 3: 5%
|
||||||
|
- Stage 4: 15%
|
||||||
|
- Stage 5: 100% (Production)
|
||||||
|
- Решение принимает RolloutAgent + человек
|
||||||
|
|
||||||
|
### ADR-005: Design Tokens (JSON)
|
||||||
|
- Единый источник истины: docs/design-system/tokens.json
|
||||||
|
- Генераторы для CSS, Swift, Kotlin
|
||||||
|
- 3 темы: system (auto), dark, light
|
||||||
|
|
||||||
|
### ADR-006: Agent Versioning
|
||||||
|
- Каждый агент версионируется независимо (A.B.C)
|
||||||
|
- Changelog: CHANGELOG/agents/<name>.md
|
||||||
|
- Авто-детект через SHA256 checksum от __file__
|
||||||
|
- EvolutionAgent управляет minor/major, агенты — patch
|
||||||
|
|
||||||
|
## СИСТЕМНЫЕ АГЕНТЫ (11 штук)
|
||||||
|
|
||||||
|
| Агент | Ответственность | Триггеры |
|
||||||
|
|-------|-----------------|----------|
|
||||||
|
| DocAgent | Документация, комментарии, Runbook | pre-commit, push, manual |
|
||||||
|
| AuditAgent | Соблюдение правил, прогресс проекта | pre-commit, daily, manual |
|
||||||
|
| SecurityAgent | Безопасность, уязвимости, 152-ФЗ | pre-commit, weekly, manual |
|
||||||
|
| SpecAgent | Спецификации, версионирование **проекта**, CHANGELOG | tag creation, push |
|
||||||
|
| ObserverAgent | Наблюдение за пользователями | continuous, daily report |
|
||||||
|
| QATesterAgent | Функциональное тестирование | pre-commit, daily, manual |
|
||||||
|
| FixAgent | Исправление багов (создаёт PR) | QATesterAgent results |
|
||||||
|
| UITestAgent | Визуальное тестирование | weekly, manual |
|
||||||
|
| RolloutAgent | Постепенное развёртывание | after tests, manual |
|
||||||
|
| EvolutionAgent | Саморазвитие и **версионирование агентов** | daily, learning |
|
||||||
|
| BacklogAgent | Управление отложенными задачами | continuous |
|
||||||
|
|
||||||
|
### Особенности агентов:
|
||||||
|
- Автоматический запуск (pre-commit, push, cron)
|
||||||
|
- Ручной запуск через админ-панель (кнопка)
|
||||||
|
- Делегирование между собой при необходимости
|
||||||
|
- Саморазвитие через EvolutionAgent
|
||||||
|
- Чёткое описание ролей и поведения
|
||||||
|
|
||||||
|
## ИИ-АГЕНТЫ (11 ролей для анализа идей)
|
||||||
|
|
||||||
|
| Роль | Провайдер | Описание |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| Координатор | Yandex GPT | Управляет диалогом, обобщает результаты |
|
||||||
|
| Организатор задач | Yandex GPT | Разбивает идею на шаги |
|
||||||
|
| Бизнес-аналитик | Yandex GPT | Оценивает ROI, сроки, аудиторию |
|
||||||
|
| Юрист | GigaChat | Проверяет соответствие законам РФ |
|
||||||
|
| Финансовый консультант | Yandex GPT | Составляет смету, прогноз доходов |
|
||||||
|
| Архитектор решений | Yandex GPT | Проектирует архитектуру |
|
||||||
|
| Тестировщик | Yandex GPT | Составляет тест-кейсы |
|
||||||
|
| UI-дизайнер | Yandex GPT | Прорабатывает интерфейс |
|
||||||
|
| SMM-специалист | Yandex GPT | Планирует продвижение |
|
||||||
|
| Лайф-коуч | Yandex GPT | Помогает ставить цели |
|
||||||
|
| Эксперт по доступности | Yandex GPT | Проверяет инклюзивность |
|
||||||
|
|
||||||
|
### Fallback chain для ИИ-агентов:
|
||||||
|
1. Yandex GPT → первичный
|
||||||
|
2. GigaChat → при недоступности
|
||||||
|
3. Error → вернуть сообщение с retry suggestion
|
||||||
|
|
||||||
|
## ДИЗАЙН-СИСТЕМА
|
||||||
|
|
||||||
|
### Структура
|
||||||
|
- docs/design-system/tokens.json — единый источник истины
|
||||||
|
- docs/design-system/generators/ — Python CLI генераторы
|
||||||
|
- app/design-tokens/ — сгенерированные файлы (CSS, Swift, Kotlin)
|
||||||
|
|
||||||
|
### Темы
|
||||||
|
- system (auto) — определяется по OS
|
||||||
|
- dark — тёмная тема
|
||||||
|
- light — светлая тема
|
||||||
|
|
||||||
|
### Генераторы
|
||||||
|
- CSS Generator → app/design-tokens/css/theme.css
|
||||||
|
- Swift Generator → app/design-tokens/swift/Colors.swift
|
||||||
|
- Kotlin Generator → app/design-tokens/kotlin/colors.xml
|
||||||
|
|
||||||
|
## АДМИН-ПАНЕЛЬ
|
||||||
|
|
||||||
|
### Функции
|
||||||
|
- Просмотр логов (фильтры, критичность)
|
||||||
|
- Управление агентами (запуск, статус, отчёты)
|
||||||
|
- Пользователи (CRUD, роли)
|
||||||
|
- Системное здоровье (БД, Redis, uptime)
|
||||||
|
|
||||||
|
### Логи
|
||||||
|
- PostgreSQL (system_logs) + файлы
|
||||||
|
- Критические: RED + email админу
|
||||||
|
- Предупреждения: ORANGE
|
||||||
|
- Обычные: не подсвечивать
|
||||||
|
|
||||||
|
## БЕЗОПАСНОСТЬ
|
||||||
|
|
||||||
|
- .env никогда в git
|
||||||
|
- JWT: HS256, 60min access, 30 days refresh
|
||||||
|
- Пароли: bcrypt
|
||||||
|
- Pydantic валидация на всех входах
|
||||||
|
- RBAC: user, admin, owner
|
||||||
|
- Защита ввода (от взлома и атак)
|
||||||
|
|
||||||
|
## ЛОГИРОВАНИЕ
|
||||||
|
|
||||||
|
- Формат: JSON для автоматизации
|
||||||
|
- Для людей: админ-панель с цветовой подсветкой
|
||||||
|
- Структура: [ISO8601] [LEVEL] [component] message key=val
|
||||||
|
- Запрещено логировать: пароли, JWT, API keys, raw email
|
||||||
|
|
||||||
|
## BACKLOG ЗАМЕТКИ
|
||||||
|
|
||||||
|
- design-system-generators-note.md
|
||||||
|
- rollout-process-note.md
|
||||||
|
- agent-evolution-note.md
|
||||||
|
- qa-tester-agent-note.md
|
||||||
|
- fix-agent-note.md
|
||||||
|
- hotkeys-system-note.md
|
||||||
|
- undo-redo-note.md
|
||||||
|
- export-formats-note.md
|
||||||
|
- oauth-schema-note.md
|
||||||
|
- changelog-generation-note.md
|
||||||
|
- ui-themes-note.md
|
||||||
|
- car-integration-note.md (ГУ автомобиля — изучить)
|
||||||
|
- rate-limiting-note.md
|
||||||
|
- observer-metrics-stages-note.md
|
||||||
|
- temp-users-cleanup-note.md
|
||||||
|
|
||||||
|
## ВЕРСИОНИРОВАНИЕ
|
||||||
|
|
||||||
|
### Проект (SpecAgent)
|
||||||
|
- Формат: MAJOR.MINOR.PATCH (SemVer)
|
||||||
|
- CHANGELOG/v*.md — файлы по версиям
|
||||||
|
- Новый файл при смене X или Y, патчи в существующий
|
||||||
|
|
||||||
|
### Агенты (EvolutionAgent + само-детект)
|
||||||
|
- Каждый агент: A.B.C, независимо от проекта
|
||||||
|
- CHANGELOG/agents/<name>.md — вся история в одном файле
|
||||||
|
- SHA256 checksum от __file__ → авто-бамп patch
|
||||||
|
- EvolutionAgent: minor при новой capability, major при breaking change
|
||||||
|
|
||||||
|
## ПЛАН РАЗРАБОТКИ (ФАЗЫ)
|
||||||
|
|
||||||
|
```
|
||||||
|
ФАЗА 1: FOUNDATION (2-3 недели) ✅ ЗАВЕРШЕНО
|
||||||
|
├── 00-rules.md ✅
|
||||||
|
├── 01-core ✅
|
||||||
|
├── System Agents (6 штук) ✅
|
||||||
|
├── 02-data ⏳ (следующий)
|
||||||
|
└── 08-devops
|
||||||
|
```
|
||||||
|
|
||||||
|
Last Updated: 2026-05-10T15:41:04.073549+00:00
|
||||||
|
================================================================================
|
||||||
|
# ИСТОРИЯ СЕССИЙ
|
||||||
|
Last Updated: 2026-05-10T15:41:04.073549+00:00
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## Session 2026-05-10 (Первая сессия)
|
||||||
|
|
||||||
|
### Настроение
|
||||||
|
Продуктивная, конструктивная. Owner вовлечён, задаёт вопросы, быстро принимает решения.
|
||||||
|
|
||||||
|
### Ключевые решения сессии
|
||||||
|
1. ✅ Создана структура проекта (folders, docs)
|
||||||
|
2. ✅ Адаптирован 00-rules.md для VoIdea
|
||||||
|
3. ✅ Создан детальный план (PLAN.md)
|
||||||
|
4. ✅ Зафиксированы 11 backlog заметок
|
||||||
|
5. ✅ Созданы 5 ADR файлов
|
||||||
|
6. ✅ Созданы 11 spec файлов для ИИ-агентов
|
||||||
|
7. ✅ Создан quick-start в runbook
|
||||||
|
8. ✅ Реализован Block 1: Core
|
||||||
|
9. ✅ Созданы 6 системных агентов (DocAgent, BacklogAgent, SpecAgent, AuditAgent, ObserverAgent, EvolutionAgent)
|
||||||
|
10. ✅ Создан AgentRegistry для централизованного управления
|
||||||
|
11. ✅ Созданы триггеры (pre-commit, cron, manual)
|
||||||
|
12. ✅ Созданы тесты для всех агентов
|
||||||
|
|
||||||
|
### Что уже создано (55+ файлов)
|
||||||
|
- docs/blocks/: 00-rules.md, PLAN.md, AUDIT.md, BACKLOG.md, VERSIONS.md, GLOSSARY.md, full.md
|
||||||
|
- docs/backlog/: 15 файлов (все backlog заметки)
|
||||||
|
- docs/adr/: 5 файлов (001-005)
|
||||||
|
- docs/specs/agents/: 11 файлов (все ИИ-агенты)
|
||||||
|
- docs/instructions/: 4 файла (system-prompt, developer, tester, admin)
|
||||||
|
- docs/design-system/: tokens.json, README.md
|
||||||
|
- docs/runbook/: 01-quick-start.md
|
||||||
|
- app/core/: все файлы Block 1
|
||||||
|
- app/: __init__.py, main.py, README.md
|
||||||
|
- app/agents/ (НОВОЕ): base.py, models.py, registry.py, triggers.py
|
||||||
|
- app/agents/ (НОВОЕ): doc_agent.py, backlog_agent.py, spec_agent.py, audit_agent.py, observer_agent.py, evolution_agent.py
|
||||||
|
- tests/unit/agents/: 7 тестовых файлов
|
||||||
|
|
||||||
|
### Текущий прогресс
|
||||||
|
- Block 0: Rules ✅
|
||||||
|
- Block 1: Core ✅
|
||||||
|
- System Agents (6): DocAgent, BacklogAgent, SpecAgent, AuditAgent, ObserverAgent, EvolutionAgent ✅ (Все созданы!)
|
||||||
|
- System Agents (5): SecurityAgent, QATesterAgent, FixAgent, UITestAgent, RolloutAgent ⏳ (Ожидают)
|
||||||
|
- Остальное: ожидает
|
||||||
|
|
||||||
|
### Следующие шаги
|
||||||
|
1. Block 2: Data (миграции, модели БД)
|
||||||
|
2. Настройка локального окружения (PostgreSQL)
|
||||||
|
3. Запуск первого рабочего API
|
||||||
|
4. Создание оставшихся 5 системных агентов (SecurityAgent, QATesterAgent, FixAgent, UITestAgent, RolloutAgent)
|
||||||
|
|
||||||
|
### Особые замечания
|
||||||
|
- Owner просит все вопросы на русском
|
||||||
|
- Owner принимает все рекомендации с пояснениями
|
||||||
|
- Кривой код = переписать сразу (принцип Owner)
|
||||||
|
- Вопросы задавать только когда НЕ описано в 00-rules.md
|
||||||
|
- Созданы 6 системных агентов: DocAgent, BacklogAgent, SpecAgent, AuditAgent, ObserverAgent, EvolutionAgent
|
||||||
|
- Агенты могут запускаться автоматически (pre-commit, cron) или вручную
|
||||||
|
- Registry обеспечивает централизованное управление агентами
|
||||||
|
- Хранение состояния: PostgreSQL (отчёты, метрики) + Redis (быстрые обновления статуса)
|
||||||
|
|
||||||
|
### Нерешённые вопросы
|
||||||
|
- Точная дата переезда на VPS
|
||||||
|
- Домен (пока подбирает)
|
||||||
|
|
||||||
|
## Session 2026-05-10 (Вторая сессия)
|
||||||
|
|
||||||
|
### Настроение
|
||||||
|
Owner принимает решения быстро, без лишних обсуждений.
|
||||||
|
|
||||||
|
### Ключевые решения сессии
|
||||||
|
1. ✅ Принята архитектура версионирования агентов (A.B.C) — ADR-006
|
||||||
|
2. ✅ Agent versioning отделён от project versioning
|
||||||
|
3. ✅ Каждый агент сам детектирует изменения через SHA256 checksum
|
||||||
|
4. ✅ EvolutionAgent управляет minor/major бампами
|
||||||
|
5. ✅ Changelog агентов: CHANGELOG/agents/<name>.md
|
||||||
|
6. ✅ SpecAgent — только версионирование проекта (уточнено)
|
||||||
|
7. ✅ Определён формат A.B.C для агентов
|
||||||
|
|
||||||
|
### Что сделано в этой сессии
|
||||||
|
- **ADR-006** — Agent Versioning (docs/adr/006-agent-versioning.md)
|
||||||
|
- **00-rules.md** — §4.4 Agent Versioning, §20 уточнён
|
||||||
|
- **VERSIONS.md** — раздел Agent Versioning
|
||||||
|
- **GLOSSARY.md** — термины Agent Version, Checksum, Changelog
|
||||||
|
- **BACKLOG.md** — задачи по версионированию агентов
|
||||||
|
- **requirements.txt** — обновлён под VPS (fastapi==0.115.6 и т.д.)
|
||||||
|
- **AgentConfig** — last_run_at → DateTime, +version, +checksum
|
||||||
|
- **BaseAgent** — compute_checksum(), bump_version(), _check_version(), _write_changelog_entry()
|
||||||
|
- **EvolutionAgent** — actions: version_check, version_bump (minor/major)
|
||||||
|
- **CHANGELOG/agents/** — 11 файлов с начальной версией 1.0.0
|
||||||
|
- **test_base.py** (новый) — 12 тестов на versioning
|
||||||
|
- **test_evolution_agent.py** — 16 тестов (добавлены version_check/bump)
|
||||||
|
- **Исправлено**: `metadata` → `extra` в agents/models.py (reserved word)
|
||||||
|
- **Исправлено**: `AgentTrigger.TAG_CREATION` добавлен в base.py
|
||||||
|
- **Исправлено**: QATesterAgent импорт User из app.models.user
|
||||||
|
- **Исправлено**: FixAgent._identify_error_type (улучшено распознавание)
|
||||||
|
- **Исправлено**: app.core.config добавлен глобальный `settings`
|
||||||
|
- **111 тестов** — все проходят
|
||||||
|
|
||||||
|
### Текущий прогресс
|
||||||
|
- Block 0: Rules ✅
|
||||||
|
- Block 1: Core ✅
|
||||||
|
- Block 2: Data (модели) ✅
|
||||||
|
- Block 3: API (25 routes) ✅
|
||||||
|
- Block 5: Services (5 базовых) ✅
|
||||||
|
- System Agents (11): Все ✅
|
||||||
|
- Test coverage: 125 tests ✅
|
||||||
|
- ADR: 006 ✅
|
||||||
|
- CHANGELOG/agents/: 11 files ✅
|
||||||
|
|
||||||
|
### Следующие шаги
|
||||||
|
1. Block 5-bis: AI integrations (Yandex GPT, GigaChat, Fallback)
|
||||||
|
2. Block 4: WebUI
|
||||||
|
3. Настройка PostgreSQL локально
|
||||||
|
|
||||||
|
## Session 2026-05-10 (Третья сессия)
|
||||||
|
|
||||||
|
### Ключевые решения сессии
|
||||||
|
1. ✅ Block 3: API полностью реализован (25 routes)
|
||||||
|
2. ✅ Tags: PostgreSQL ARRAY (рекомендация принята)
|
||||||
|
3. ✅ Sync делаем в этом блоке (решение Owner)
|
||||||
|
4. ✅ Admin — полное управление (потом дополним)
|
||||||
|
5. ✅ POST /ideas/{id}/analyze — заглушка до Block 5-bis
|
||||||
|
|
||||||
|
### Что создано в этой сессии
|
||||||
|
- **app/schemas/** — 7 файлов: auth, user, idea, agent, sync, admin
|
||||||
|
- **app/services/** — 5 файлов: auth, user, idea, agent, sync
|
||||||
|
- **app/api/v1/** — 7 файлов: __init__, auth, users, ideas, agents, sync, admin
|
||||||
|
- **app/core/dependencies.py** — переписан (get_db, get_current_user, require_admin)
|
||||||
|
- **app/models/idea.py** — tags → ARRAY(String(50))
|
||||||
|
- **app/main.py** — подключён api_v1_router
|
||||||
|
- **tests/unit/api/** — 2 файла: test_routes, test_schemas
|
||||||
|
|
||||||
|
### API Routes (25 шт.)
|
||||||
|
| Роутер | Endpoints |
|
||||||
|
|--------|-----------|
|
||||||
|
| auth | POST register, login, refresh; GET oauth/{provider}, callback |
|
||||||
|
| users | GET/PATCH/DELETE /me |
|
||||||
|
| ideas | GET/POST /, GET/PATCH/DELETE /{id}, POST /{id}/analyze |
|
||||||
|
| agents | GET /, GET /{name}, POST /{name}/run |
|
||||||
|
| sync | POST /pull, /push |
|
||||||
|
| admin | GET /users, PATCH /users/{id}/role, GET /health, /logs |
|
||||||
|
|
||||||
|
### Исправлено
|
||||||
|
- `dependencies.py` — полностью переписан под актуальные модели User (is_superuser вместо role)
|
||||||
|
- `get_current_user` — теперь возвращает User, а не dict
|
||||||
|
- `require_admin` — проверяет is_superuser
|
||||||
|
- `tests/conftest.py` — убран PROJECT_NAME override (ломало тесты)
|
||||||
|
|
||||||
|
### Тесты: 125 passed
|
||||||
|
|
||||||
|
Last Updated: 2026-05-10T15:41:04.073549+00:00
|
||||||
|
================================================================================
|
||||||
|
# АКТИВНЫЕ ЗАМЕТКИ
|
||||||
|
Last Updated: 2026-05-10T15:41:04.073549+00:00
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
### К изучению
|
||||||
|
- Интеграция с ГУ автомобиля (Android Auto / CarPlay)
|
||||||
|
|
||||||
|
### К реализации позже
|
||||||
|
- Rate limiting для ИИ-агентов
|
||||||
|
- Локальные ИИ-модели
|
||||||
|
- Apple OAuth
|
||||||
|
|
||||||
|
### Возможные улучшения
|
||||||
|
- Автоматическая документация API (генерация из Pydantic)
|
||||||
|
- Типобезопасные агенты (TypedDict + Pydantic)
|
||||||
|
- Мониторинг агентов в реальном времени (WebSocket)
|
||||||
|
- Feature Flags для rollout
|
||||||
|
|
||||||
|
Last Updated: 2026-05-10T15:41:04.073549+00:00
|
||||||
|
================================================================================
|
||||||
|
# КОНТАКТЫ
|
||||||
|
Last Updated: 2026-05-10T15:41:04.073549+00:00
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
Project Owner: [Указать после заполнения]
|
||||||
|
License: AGPL-3.0
|
||||||
|
Version: 1.0.0
|
||||||
|
|
||||||
|
Last Updated: 2026-05-10T15:41:04.073549+00:00
|
||||||
|
|
||||||
|
Last Updated: 2026-05-12T22:45:00.000000+00:00
|
||||||
|
================================================================================
|
||||||
|
# Session 2026-05-12 (Web App Engine — документация + код-стайл + инструменты)
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
### Ключевые решения сессии
|
||||||
|
1. ✅ Zustand для новых сториджей (Context не трогать)
|
||||||
|
2. ✅ react-hook-form + zod для сложных форм
|
||||||
|
3. ✅ WCAG AA через eslint-plugin-jsx-a11y (enforcement)
|
||||||
|
4. ✅ i18n-ready: строки через strings.ts, <T>, заглушка en.json
|
||||||
|
5. ✅ ErrorBoundary обязателен вокруг Layout
|
||||||
|
6. ✅ React 18 фиксирован (19 — отдельный этап)
|
||||||
|
7. ✅ QATesterAgent — новый vitest режим
|
||||||
|
8. ✅ Дизайн-токены: 3 генератора (CSS, Swift, Kotlin) реализованы
|
||||||
|
|
||||||
|
### Что создано в этой сессии
|
||||||
|
- Документация: STYLE_GUIDE.md, SPECIFICATION.md, TECHNICAL.md, PROJECT_GUIDE.md
|
||||||
|
- Обновлено: user-guide.md (PWA), admin-guide.md (VPS deploy), template/docs/00-rules.md (ESLint+Vitest)
|
||||||
|
- Компоненты: ErrorBoundary.tsx, SkipToContent.tsx, T.tsx
|
||||||
|
- Стор: stores/auth.ts (Zustand) + AuthContext wrapper
|
||||||
|
- Формы: LoginPage, RegisterPage, IdeaCreate, IdeaEdit (react-hook-form+zod)
|
||||||
|
- WCAG AA: VoiceInput (aria-label+keyboard), VoiceChat (role=log), SettingsPage (htmlFor/id)
|
||||||
|
- i18n: constants/strings.ts, i18n/en.json
|
||||||
|
- Агент: QATesterAgent._run_vitest_tests()
|
||||||
|
- Генераторы: css_generator.py, swift_generator.py, kotlin_generator.py
|
||||||
|
- Инструменты: tools/backup_db.py, tools/metrics_service.py
|
||||||
|
- CI: .github/workflows/ci.yml — frontend lint + test jobs
|
||||||
|
- Тесты: 7 Vitest тестов (ErrorBoundary, SkipToContent, strings) — все passed
|
||||||
|
- TypeScript: tsc --noEmit — 0 errors
|
||||||
|
|
||||||
|
### Зависимости (npm)
|
||||||
|
- Zustand 5.x, react-hook-form 7.x, zod 4.x, @hookform/resolvers
|
||||||
|
- Vitest 4.x, @testing-library/react, jsdom
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
# КОНЕЦ КОНТЕКСТА
|
||||||
|
Last Updated: 2026-05-12T22:45:00.000000+00:00
|
||||||
|
================================================================================
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Alembic environment configuration for VoIdea."""
|
||||||
|
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from app.core.base import SQLBase
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models import * # noqa: F401, F403 — load all models
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.sync_database_url)
|
||||||
|
|
||||||
|
target_metadata = SQLBase.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode."""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode."""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""Initial migration — create all tables
|
||||||
|
|
||||||
|
Revision ID: 001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-05-11
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "001"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("email", sa.String(255), unique=True, nullable=False, index=True),
|
||||||
|
sa.Column("password_hash", sa.String(255), nullable=True),
|
||||||
|
sa.Column("display_name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("avatar_url", sa.String(512), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("oauth_provider", sa.String(50), nullable=True),
|
||||||
|
sa.Column("oauth_id", sa.String(255), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"agent_configs",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("agent_name", sa.String(100), unique=True, nullable=False, index=True),
|
||||||
|
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("version", sa.String(20), nullable=False, server_default=sa.text("'1.0.0'")),
|
||||||
|
sa.Column("checksum", sa.String(64), nullable=True),
|
||||||
|
sa.Column("config", sa.Text(), nullable=True),
|
||||||
|
sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"backlog_tasks",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("title", sa.String(255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("priority", sa.String(20), nullable=False, server_default=sa.text("'medium'"), index=True),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default=sa.text("'pending'"), index=True),
|
||||||
|
sa.Column("source_agent", sa.String(100), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"ideas",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False, index=True),
|
||||||
|
sa.Column("title", sa.String(255), nullable=False, index=True),
|
||||||
|
sa.Column("content", sa.Text(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default=sa.text("'draft'"), index=True),
|
||||||
|
sa.Column("tags", postgresql.ARRAY(sa.String(50)), nullable=True),
|
||||||
|
sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"log_entries",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("level", sa.String(20), nullable=False, index=True),
|
||||||
|
sa.Column("source", sa.String(100), nullable=False, index=True),
|
||||||
|
sa.Column("message", sa.Text(), nullable=False),
|
||||||
|
sa.Column("details", sa.Text(), nullable=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=True, index=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_ideas_user_id", "ideas", "users",
|
||||||
|
["user_id"], ["id"], ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_log_entries_user_id", "log_entries", "users",
|
||||||
|
["user_id"], ["id"], ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"conductor_interactions",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=True, index=True),
|
||||||
|
sa.Column("input_text", sa.Text(), nullable=False),
|
||||||
|
sa.Column("detected_intent", sa.String(100), nullable=False),
|
||||||
|
sa.Column("selected_agent", sa.String(100), nullable=False),
|
||||||
|
sa.Column("response_text", sa.Text(), nullable=False),
|
||||||
|
sa.Column("user_rating", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("confidence_score", sa.Integer(), nullable=False, server_default=sa.text("80")),
|
||||||
|
sa.Column("verification_status", sa.String(20), nullable=False, server_default=sa.text("'verified'")),
|
||||||
|
sa.Column("was_auto_routed", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("processing_time_ms", sa.Float(), nullable=False, server_default=sa.text("0.0")),
|
||||||
|
sa.Column("context", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_conductor_user_id", "conductor_interactions", "users",
|
||||||
|
["user_id"], ["id"], ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── sessions (чат-сессии = идеи) ──
|
||||||
|
op.create_table(
|
||||||
|
"sessions",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False, index=True),
|
||||||
|
sa.Column("title", sa.String(255), nullable=False, server_default=sa.text("'Новое обсуждение'")),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default=sa.text("'active'"), index=True),
|
||||||
|
sa.Column("idea_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_sessions_user_id", "sessions", "users",
|
||||||
|
["user_id"], ["id"], ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_sessions_idea_id", "sessions", "ideas",
|
||||||
|
["idea_id"], ["id"], ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── session_id в conductor_interactions ──
|
||||||
|
op.add_column(
|
||||||
|
"conductor_interactions",
|
||||||
|
sa.Column("session_id", postgresql.UUID(as_uuid=True), nullable=True, index=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_conductor_session_id", "conductor_interactions", "sessions",
|
||||||
|
["session_id"], ["id"], ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── voice_commands (пользовательские голосовые команды) ──
|
||||||
|
op.create_table(
|
||||||
|
"voice_commands",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False, index=True),
|
||||||
|
sa.Column("phrase", sa.String(255), nullable=False),
|
||||||
|
sa.Column("action", sa.String(50), nullable=False),
|
||||||
|
sa.Column("agent_name", sa.String(100), nullable=True),
|
||||||
|
sa.Column("count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_voice_commands_user_id", "voice_commands", "users",
|
||||||
|
["user_id"], ["id"], ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("voice_commands")
|
||||||
|
op.drop_table("sessions")
|
||||||
|
op.drop_constraint("fk_conductor_session_id", "conductor_interactions", type_="foreignkey")
|
||||||
|
op.drop_column("conductor_interactions", "session_id")
|
||||||
|
op.drop_table("conductor_interactions")
|
||||||
|
op.drop_table("log_entries")
|
||||||
|
op.drop_table("ideas")
|
||||||
|
op.drop_table("backlog_tasks")
|
||||||
|
op.drop_table("agent_configs")
|
||||||
|
op.drop_table("users")
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""v2.0: Add role system, feedback, tariffs, backlog category
|
||||||
|
|
||||||
|
Revision ID: 002
|
||||||
|
Revises: 001
|
||||||
|
Create Date: 2026-05-11
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "002"
|
||||||
|
down_revision: Union[str, None] = "001"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ── users: add v2.0 role system ──
|
||||||
|
op.add_column("users", sa.Column("role", sa.String(20),
|
||||||
|
server_default=sa.text("'user'"), nullable=False, index=True))
|
||||||
|
op.add_column("users", sa.Column("is_owner", sa.Boolean(),
|
||||||
|
server_default=sa.text("false"), nullable=False))
|
||||||
|
op.add_column("users", sa.Column("permissions", postgresql.JSONB,
|
||||||
|
nullable=True))
|
||||||
|
op.add_column("users", sa.Column("accepted_terms_at",
|
||||||
|
sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("accepted_terms_version",
|
||||||
|
sa.String(20), nullable=True))
|
||||||
|
|
||||||
|
# ── agent_configs: add description ──
|
||||||
|
op.add_column("agent_configs", sa.Column("description", sa.Text(),
|
||||||
|
nullable=True, server_default=sa.text("''")))
|
||||||
|
|
||||||
|
# ── backlog_tasks: add category ──
|
||||||
|
op.add_column("backlog_tasks", sa.Column("category", sa.String(50),
|
||||||
|
server_default=sa.text("'general'"), nullable=False, index=True))
|
||||||
|
|
||||||
|
# ── feedback ──
|
||||||
|
op.create_table(
|
||||||
|
"feedback",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=True, index=True),
|
||||||
|
sa.Column("text", sa.Text(), nullable=False),
|
||||||
|
sa.Column("page_url", sa.String(512), nullable=True),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False,
|
||||||
|
server_default=sa.text("'new'"), index=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_feedback_user_id", "feedback", "users",
|
||||||
|
["user_id"], ["id"], ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── tariff_plans ──
|
||||||
|
op.create_table(
|
||||||
|
"tariff_plans",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("code", sa.String(50), unique=True, nullable=False, index=True),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("price_monthly", sa.Numeric(10, 2), nullable=False,
|
||||||
|
server_default=sa.text("0")),
|
||||||
|
sa.Column("price_yearly", sa.Numeric(10, 2), nullable=True),
|
||||||
|
sa.Column("features", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.text("true")),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False,
|
||||||
|
server_default=sa.text("0")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── user_subscriptions ──
|
||||||
|
op.create_table(
|
||||||
|
"user_subscriptions",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), unique=True,
|
||||||
|
nullable=False, index=True),
|
||||||
|
sa.Column("plan_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False,
|
||||||
|
server_default=sa.text("'active'"), index=True),
|
||||||
|
sa.Column("current_period_start", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("current_period_end", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("canceled_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_user_subscriptions_user_id", "user_subscriptions", "users",
|
||||||
|
["user_id"], ["id"], ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_user_subscriptions_plan_id", "user_subscriptions", "tariff_plans",
|
||||||
|
["plan_id"], ["id"], ondelete="RESTRICT",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("user_subscriptions")
|
||||||
|
op.drop_table("tariff_plans")
|
||||||
|
op.drop_table("feedback")
|
||||||
|
op.drop_column("backlog_tasks", "category")
|
||||||
|
op.drop_column("agent_configs", "description")
|
||||||
|
op.drop_column("users", "accepted_terms_version")
|
||||||
|
op.drop_column("users", "accepted_terms_at")
|
||||||
|
op.drop_column("users", "permissions")
|
||||||
|
op.drop_column("users", "is_owner")
|
||||||
|
op.drop_column("users", "role")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Add pipeline_stats table and User.pipeline_tuning
|
||||||
|
|
||||||
|
Revision ID: 003
|
||||||
|
Revises: 002
|
||||||
|
Create Date: 2026-05-11
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "003"
|
||||||
|
down_revision: Union[str, None] = "002"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ── users: add pipeline_tuning ──
|
||||||
|
op.add_column("users", sa.Column(
|
||||||
|
"pipeline_tuning", postgresql.JSONB(), nullable=True
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── pipeline_stats ──
|
||||||
|
op.create_table(
|
||||||
|
"pipeline_stats",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.String(36),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=True, index=True),
|
||||||
|
sa.Column("stage", sa.String(50), nullable=False, index=True),
|
||||||
|
sa.Column("passed", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("reason", sa.String(255), nullable=True),
|
||||||
|
sa.Column("duration_ms", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True),
|
||||||
|
nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
# composite index for stats aggregation
|
||||||
|
op.create_index(
|
||||||
|
"ix_pipeline_stats_user_stage",
|
||||||
|
"pipeline_stats",
|
||||||
|
["user_id", "stage"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_pipeline_stats_user_stage", table_name="pipeline_stats")
|
||||||
|
op.drop_table("pipeline_stats")
|
||||||
|
op.drop_column("users", "pipeline_tuning")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Add bot_commands table
|
||||||
|
|
||||||
|
Revision ID: 004
|
||||||
|
Revises: 003
|
||||||
|
Create Date: 2026-05-12
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "004"
|
||||||
|
down_revision: Union[str, None] = "003"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"bot_commands",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(50), unique=True, nullable=False, index=True),
|
||||||
|
sa.Column("description", sa.String(255), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("requires_auth", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("bot_commands")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Add public_slug column to ideas table
|
||||||
|
|
||||||
|
Revision ID: 005
|
||||||
|
Revises: 004
|
||||||
|
Create Date: 2026-05-12
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "005"
|
||||||
|
down_revision: Union[str, None] = "004"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"ideas",
|
||||||
|
sa.Column("public_slug", sa.String(64), unique=True, nullable=True, index=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("ideas", "public_slug")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# App Module - VoIdea
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Main application package containing all modules.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── core/ # Configuration, base classes, security
|
||||||
|
├── models/ # Database models
|
||||||
|
├── api/ # API endpoints
|
||||||
|
├── services/ # Business logic
|
||||||
|
├── integrations/ # External services (AI, OAuth)
|
||||||
|
└── agents/ # System agents
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
| Module | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `core/` | Foundation (config, security, exceptions) |
|
||||||
|
| `models/` | SQLAlchemy models and Pydantic schemas |
|
||||||
|
| `api/` | FastAPI routers and endpoints |
|
||||||
|
| `services/` | Business logic services |
|
||||||
|
| `integrations/` | External API integrations |
|
||||||
|
| `agents/` | System automation agents |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This file maintained by DocAgent*
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""VoIdea application package."""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
__project__ = "VoIdea"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# agents Module - VoIdea
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
[Auto-generated documentation]
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `audit_agent.py` | Module file |
|
||||||
|
| `backlog_agent.py` | Module file |
|
||||||
|
| `base.py` | Base classes |
|
||||||
|
| `doc_agent.py` | Module file |
|
||||||
|
| `evolution_agent.py` | Module file |
|
||||||
|
| `fix_agent.py` | Module file |
|
||||||
|
| `models.py` | Data models |
|
||||||
|
| `observer_agent.py` | Module file |
|
||||||
|
| `qa_tester_agent.py` | Module file |
|
||||||
|
| `registry.py` | Module file |
|
||||||
|
| `rollout_agent.py` | Module file |
|
||||||
|
| `security_agent.py` | Module file |
|
||||||
|
| `spec_agent.py` | Module file |
|
||||||
|
| `triggers.py` | Module file |
|
||||||
|
| `ui_test_agent.py` | Module file |
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from app.agents.base import (
|
||||||
|
AgentMetrics,
|
||||||
|
AgentResult,
|
||||||
|
AgentStatus,
|
||||||
|
AgentTrigger,
|
||||||
|
BaseAgent,
|
||||||
|
)
|
||||||
|
from app.agents.registry import AgentRegistry, registry, get_agent, get_all_agents
|
||||||
|
from app.agents.triggers import TriggerManager, PreCommitHook, run_agent_manually
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentMetrics",
|
||||||
|
"AgentResult",
|
||||||
|
"AgentStatus",
|
||||||
|
"AgentTrigger",
|
||||||
|
"BaseAgent",
|
||||||
|
"AgentRegistry",
|
||||||
|
"registry",
|
||||||
|
"get_agent",
|
||||||
|
"get_all_agents",
|
||||||
|
"TriggerManager",
|
||||||
|
"PreCommitHook",
|
||||||
|
"run_agent_manually",
|
||||||
|
]
|
||||||
|
|
||||||
|
from app.agents.doc_agent import DocAgent
|
||||||
|
from app.agents.backlog_agent import BacklogAgent
|
||||||
|
from app.agents.spec_agent import SpecAgent
|
||||||
|
from app.agents.audit_agent import AuditAgent
|
||||||
|
from app.agents.observer_agent import ObserverAgent
|
||||||
|
from app.agents.evolution_agent import EvolutionAgent
|
||||||
|
from app.agents.security_agent import SecurityAgent
|
||||||
|
from app.agents.qa_tester_agent import QATesterAgent
|
||||||
|
from app.agents.fix_agent import FixAgent
|
||||||
|
from app.agents.ui_test_agent import UITestAgent
|
||||||
|
from app.agents.rollout_agent import RolloutAgent
|
||||||
|
from app.agents.conductor_agent import ConductorAgent
|
||||||
|
|
||||||
|
__all__.extend([
|
||||||
|
"DocAgent",
|
||||||
|
"BacklogAgent",
|
||||||
|
"SpecAgent",
|
||||||
|
"AuditAgent",
|
||||||
|
"ObserverAgent",
|
||||||
|
"EvolutionAgent",
|
||||||
|
"SecurityAgent",
|
||||||
|
"QATesterAgent",
|
||||||
|
"FixAgent",
|
||||||
|
"UITestAgent",
|
||||||
|
"RolloutAgent",
|
||||||
|
"ConductorAgent",
|
||||||
|
])
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""AuditAgent - Code quality and rules compliance for VoIdea.
|
||||||
|
|
||||||
|
This agent:
|
||||||
|
- Checks code style (ruff)
|
||||||
|
- Checks type hints (mypy)
|
||||||
|
- Monitors project progress
|
||||||
|
- Verifies documentation compliance
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class AuditAgent(BaseAgent):
|
||||||
|
"""Code quality and compliance audit agent."""
|
||||||
|
|
||||||
|
name = "audit_agent"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Monitors code quality and rule compliance"
|
||||||
|
triggers = [
|
||||||
|
AgentTrigger.MANUAL,
|
||||||
|
AgentTrigger.PRE_COMMIT,
|
||||||
|
AgentTrigger.CRON,
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.project_root = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||||
|
"""Execute audit task.
|
||||||
|
|
||||||
|
Context can contain:
|
||||||
|
- action: str (full, quick, style, types, docs)
|
||||||
|
- paths: list[str] (paths to audit)
|
||||||
|
"""
|
||||||
|
await self.set_running("audit")
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = context.get("action", "full") if context else "full"
|
||||||
|
paths = context.get("paths", ["app"])
|
||||||
|
|
||||||
|
if action == "full":
|
||||||
|
result = await self._run_full_audit(paths)
|
||||||
|
elif action == "quick":
|
||||||
|
result = await self._run_quick_audit(paths)
|
||||||
|
elif action == "style":
|
||||||
|
result = await self._run_style_check(paths)
|
||||||
|
elif action == "types":
|
||||||
|
result = await self._run_type_check(paths)
|
||||||
|
elif action == "docs":
|
||||||
|
result = await self._check_docs()
|
||||||
|
else:
|
||||||
|
result = await self._run_quick_audit(paths)
|
||||||
|
|
||||||
|
await self.set_idle()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.set_error(str(e))
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"AuditAgent failed: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Check if AuditAgent is operational."""
|
||||||
|
try:
|
||||||
|
return self.project_root.exists()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _run_full_audit(self, paths: list[str]) -> AgentResult:
|
||||||
|
"""Run full audit including all checks."""
|
||||||
|
style_result = await self._run_style_check(paths)
|
||||||
|
types_result = await self._run_type_check(paths)
|
||||||
|
docs_result = await self._check_docs()
|
||||||
|
|
||||||
|
issues = []
|
||||||
|
issues.extend(style_result.data.get("issues", []))
|
||||||
|
issues.extend(types_result.data.get("issues", []))
|
||||||
|
issues.extend(docs_result.data.get("issues", []))
|
||||||
|
|
||||||
|
passed = (
|
||||||
|
style_result.success and
|
||||||
|
types_result.success and
|
||||||
|
docs_result.success
|
||||||
|
)
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=passed,
|
||||||
|
message=f"Full audit {'passed' if passed else 'failed'}: {len(issues)} issues",
|
||||||
|
data={
|
||||||
|
"style_check": style_result.data,
|
||||||
|
"type_check": types_result.data,
|
||||||
|
"docs_check": docs_result.data,
|
||||||
|
"total_issues": len(issues),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run_quick_audit(self, paths: list[str]) -> AgentResult:
|
||||||
|
"""Run quick audit (ruff only)."""
|
||||||
|
return await self._run_style_check(paths)
|
||||||
|
|
||||||
|
async def _run_style_check(self, paths: list[str]) -> AgentResult:
|
||||||
|
"""Run code style check with ruff."""
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for path in paths:
|
||||||
|
path_obj = self.project_root / path
|
||||||
|
if not path_obj.exists():
|
||||||
|
issues.append(f"Path not found: {path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["python", "-m", "ruff", "check", str(path_obj)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(self.project_root),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.stdout:
|
||||||
|
for line in result.stdout.split("\n"):
|
||||||
|
if line.strip():
|
||||||
|
issues.append(line.strip())
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=len(issues) == 0,
|
||||||
|
message=f"Style check: {len(issues)} issues" if issues else "Style check passed",
|
||||||
|
data={
|
||||||
|
"tool": "ruff",
|
||||||
|
"paths": paths,
|
||||||
|
"issues": issues[:50],
|
||||||
|
"total_issues": len(issues),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="Ruff not installed, skipping style check",
|
||||||
|
data={"tool": "ruff", "skipped": True},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Style check failed: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run_type_check(self, paths: list[str]) -> AgentResult:
|
||||||
|
"""Run type checking with mypy."""
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for path in paths:
|
||||||
|
path_obj = self.project_root / path
|
||||||
|
if not path_obj.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["python", "-m", "mypy", str(path_obj), "--ignore-missing-imports"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(self.project_root),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.stdout:
|
||||||
|
for line in result.stdout.split("\n"):
|
||||||
|
if "error:" in line.lower() or "warning:" in line.lower():
|
||||||
|
issues.append(line.strip())
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=len(issues) == 0,
|
||||||
|
message=f"Type check: {len(issues)} issues" if issues else "Type check passed",
|
||||||
|
data={
|
||||||
|
"tool": "mypy",
|
||||||
|
"paths": paths,
|
||||||
|
"issues": issues[:50],
|
||||||
|
"total_issues": len(issues),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="Mypy not installed, skipping type check",
|
||||||
|
data={"tool": "mypy", "skipped": True},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Type check failed: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _check_docs(self) -> AgentResult:
|
||||||
|
"""Check documentation completeness."""
|
||||||
|
missing_docs = []
|
||||||
|
|
||||||
|
docs_dir = self.project_root / "docs"
|
||||||
|
app_dir = self.project_root / "app"
|
||||||
|
|
||||||
|
required_docs = [
|
||||||
|
"blocks/00-rules.md",
|
||||||
|
"blocks/PLAN.md",
|
||||||
|
"instructions/00-system-prompt.md",
|
||||||
|
]
|
||||||
|
|
||||||
|
for doc in required_docs:
|
||||||
|
if not (docs_dir / doc).exists():
|
||||||
|
missing_docs.append(doc)
|
||||||
|
|
||||||
|
module_readmes = [
|
||||||
|
"core/README.md",
|
||||||
|
"models/README.md",
|
||||||
|
"api/README.md",
|
||||||
|
"services/README.md",
|
||||||
|
]
|
||||||
|
|
||||||
|
for readme in module_readmes:
|
||||||
|
if not (app_dir / readme).exists():
|
||||||
|
missing_docs.append(f"app/{readme}")
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=len(missing_docs) == 0,
|
||||||
|
message=f"Documentation check: {len(missing_docs)} missing files",
|
||||||
|
data={
|
||||||
|
"missing_docs": missing_docs,
|
||||||
|
"total_missing": len(missing_docs),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_metrics(self) -> dict[str, Any]:
|
||||||
|
"""Get AuditAgent metrics."""
|
||||||
|
return {
|
||||||
|
"agent_id": self.name,
|
||||||
|
"version": self.version,
|
||||||
|
"status": self.status.value,
|
||||||
|
"last_run": self.last_run.isoformat() if self.last_run else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"""BacklogAgent - Backlog management for VoIdea.
|
||||||
|
|
||||||
|
This agent:
|
||||||
|
- Creates and manages backlog items
|
||||||
|
- Tracks ideas, plans, tasks
|
||||||
|
- Prioritizes work
|
||||||
|
- Sends reminders
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import select, update, delete
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
|
||||||
|
from app.agents.models import BacklogItem
|
||||||
|
|
||||||
|
|
||||||
|
class BacklogAgent(BaseAgent):
|
||||||
|
"""Backlog management agent."""
|
||||||
|
|
||||||
|
name = "backlog_agent"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Manages project backlog: ideas, tasks, plans"
|
||||||
|
triggers = [
|
||||||
|
AgentTrigger.MANUAL,
|
||||||
|
AgentTrigger.CRON,
|
||||||
|
AgentTrigger.EVENT,
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession | None = None):
|
||||||
|
super().__init__()
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||||
|
"""Execute backlog management task.
|
||||||
|
|
||||||
|
Context can contain:
|
||||||
|
- action: str (create, list, update, delete, suggest)
|
||||||
|
- item_type: str (idea, plan, task, improvement)
|
||||||
|
- title: str
|
||||||
|
- description: str
|
||||||
|
- priority: str (low, medium, high, critical)
|
||||||
|
- source: str (opencode, admin_panel, user, agent)
|
||||||
|
"""
|
||||||
|
await self.set_running("backlog_management")
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = context.get("action", "list") if context else "list"
|
||||||
|
|
||||||
|
if action == "create":
|
||||||
|
result = await self._create_item(context or {})
|
||||||
|
elif action == "list":
|
||||||
|
result = await self._list_items(context or {})
|
||||||
|
elif action == "update":
|
||||||
|
result = await self._update_item(context or {})
|
||||||
|
elif action == "delete":
|
||||||
|
result = await self._delete_item(context or {})
|
||||||
|
elif action == "suggest":
|
||||||
|
result = await self._suggest_items()
|
||||||
|
else:
|
||||||
|
result = await self._list_items({})
|
||||||
|
|
||||||
|
await self.set_idle()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.set_error(str(e))
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"BacklogAgent failed: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Check if BacklogAgent is operational."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _create_item(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Create a new backlog item."""
|
||||||
|
if not self._session:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="Database session not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
item_type = context.get("item_type", "task")
|
||||||
|
title = context.get("title", "Untitled")
|
||||||
|
description = context.get("description", "")
|
||||||
|
priority = context.get("priority", "medium")
|
||||||
|
source = context.get("source", "agent")
|
||||||
|
created_by = context.get("created_by", "BacklogAgent")
|
||||||
|
tags = context.get("tags", [])
|
||||||
|
block_ref = context.get("block_ref")
|
||||||
|
|
||||||
|
item = BacklogItem(
|
||||||
|
id=uuid4(),
|
||||||
|
item_type=item_type,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
priority=priority,
|
||||||
|
status="pending",
|
||||||
|
source=source,
|
||||||
|
created_by=created_by,
|
||||||
|
tags=tags,
|
||||||
|
block_ref=block_ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._session.add(item)
|
||||||
|
await self._session.commit()
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Created backlog item: {title}",
|
||||||
|
data={
|
||||||
|
"id": str(item.id),
|
||||||
|
"type": item_type,
|
||||||
|
"title": title,
|
||||||
|
"priority": priority,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _list_items(
|
||||||
|
self,
|
||||||
|
context: dict[str, Any],
|
||||||
|
limit: int = 50,
|
||||||
|
) -> AgentResult:
|
||||||
|
"""List backlog items."""
|
||||||
|
if not self._session:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="Database session not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
item_type = context.get("item_type")
|
||||||
|
status_filter = context.get("status")
|
||||||
|
priority_filter = context.get("priority")
|
||||||
|
|
||||||
|
query = select(BacklogItem).order_by(BacklogItem.created_at.desc())
|
||||||
|
|
||||||
|
if item_type:
|
||||||
|
query = query.where(BacklogItem.item_type == item_type)
|
||||||
|
if status_filter:
|
||||||
|
query = query.where(BacklogItem.status == status_filter)
|
||||||
|
if priority_filter:
|
||||||
|
query = query.where(BacklogItem.priority == priority_filter)
|
||||||
|
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
result = await self._session.execute(query)
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
items_data = [
|
||||||
|
{
|
||||||
|
"id": str(item.id),
|
||||||
|
"type": item.item_type,
|
||||||
|
"title": item.title,
|
||||||
|
"priority": item.priority,
|
||||||
|
"status": item.status,
|
||||||
|
"created_at": item.created_at.isoformat() if item.created_at else None,
|
||||||
|
}
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Found {len(items)} backlog items",
|
||||||
|
data={"items": items_data, "count": len(items)},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _update_item(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Update a backlog item."""
|
||||||
|
if not self._session:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="Database session not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
item_id = context.get("id")
|
||||||
|
if not item_id:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="Item ID required for update",
|
||||||
|
)
|
||||||
|
|
||||||
|
update_data = {}
|
||||||
|
for field in ["title", "description", "priority", "status", "tags"]:
|
||||||
|
if field in context:
|
||||||
|
update_data[field] = context[field]
|
||||||
|
|
||||||
|
if update_data:
|
||||||
|
stmt = (
|
||||||
|
update(BacklogItem)
|
||||||
|
.where(BacklogItem.id == UUID(item_id))
|
||||||
|
.values(**update_data)
|
||||||
|
)
|
||||||
|
await self._session.execute(stmt)
|
||||||
|
await self._session.commit()
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Updated backlog item: {item_id}",
|
||||||
|
data={"id": item_id, "updated": update_data},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _delete_item(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Delete a backlog item."""
|
||||||
|
if not self._session:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="Database session not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
item_id = context.get("id")
|
||||||
|
if not item_id:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="Item ID required for delete",
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt = delete(BacklogItem).where(BacklogItem.id == UUID(item_id))
|
||||||
|
await self._session.execute(stmt)
|
||||||
|
await self._session.commit()
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Deleted backlog item: {item_id}",
|
||||||
|
data={"id": item_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _suggest_items(self) -> AgentResult:
|
||||||
|
"""Suggest backlog items based on project needs."""
|
||||||
|
suggestions = [
|
||||||
|
{
|
||||||
|
"type": "task",
|
||||||
|
"title": "Set up PostgreSQL local database",
|
||||||
|
"priority": "high",
|
||||||
|
"reason": "Required for Block 2: Data",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "task",
|
||||||
|
"title": "Configure environment variables",
|
||||||
|
"priority": "medium",
|
||||||
|
"reason": "Prerequisite for running app",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "improvement",
|
||||||
|
"title": "Add pre-commit hooks",
|
||||||
|
"priority": "low",
|
||||||
|
"reason": "Improves code quality",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="Generated backlog suggestions",
|
||||||
|
data={"suggestions": suggestions},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_metrics(self) -> dict[str, Any]:
|
||||||
|
"""Get BacklogAgent metrics."""
|
||||||
|
if not self._session:
|
||||||
|
return {
|
||||||
|
"agent_id": self.name,
|
||||||
|
"status": self.status.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
pending_count = await self._session.execute(
|
||||||
|
select(BacklogItem).where(BacklogItem.status == "pending")
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"agent_id": self.name,
|
||||||
|
"version": self.version,
|
||||||
|
"status": self.status.value,
|
||||||
|
"pending_items": pending_count.scalars().count(),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"agent_id": self.name,
|
||||||
|
"status": self.status.value,
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""Base classes and utilities for VoIdea agents."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Generic, TypeVar
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.core.base import CoreModel
|
||||||
|
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentStatus(str, Enum):
|
||||||
|
"""Agent status enumeration."""
|
||||||
|
|
||||||
|
IDLE = "idle"
|
||||||
|
RUNNING = "running"
|
||||||
|
ERROR = "error"
|
||||||
|
OFFLINE = "offline"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentTrigger(str, Enum):
|
||||||
|
"""Agent trigger types."""
|
||||||
|
|
||||||
|
MANUAL = "manual"
|
||||||
|
PRE_COMMIT = "pre_commit"
|
||||||
|
PUSH = "push"
|
||||||
|
TAG_CREATION = "tag_creation"
|
||||||
|
CRON = "cron"
|
||||||
|
API = "api"
|
||||||
|
EVENT = "event"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentResult(CoreModel):
|
||||||
|
"""Result of agent execution."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
message: str = ""
|
||||||
|
data: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
errors: list[str] = Field(default_factory=list)
|
||||||
|
duration_ms: int = 0
|
||||||
|
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class AgentMetrics(CoreModel):
|
||||||
|
"""Agent performance metrics."""
|
||||||
|
|
||||||
|
agent_id: str
|
||||||
|
version: str = "1.0.0"
|
||||||
|
requests_total: int = 0
|
||||||
|
requests_success: int = 0
|
||||||
|
requests_failed: int = 0
|
||||||
|
average_duration_ms: float = 0.0
|
||||||
|
last_run: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAgent(ABC):
|
||||||
|
"""Abstract base class for all agents.
|
||||||
|
|
||||||
|
All agents must inherit from this class and implement required methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
|
version: str = "1.0.0"
|
||||||
|
description: str = ""
|
||||||
|
triggers: list[AgentTrigger] = [AgentTrigger.MANUAL]
|
||||||
|
changelog_dir: Path = Path("CHANGELOG") / "agents"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._status = AgentStatus.IDLE
|
||||||
|
self._last_run: datetime | None = None
|
||||||
|
self._current_task: str | None = None
|
||||||
|
self._changelog_path = self.changelog_dir / f"{self.name}.md"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> AgentStatus:
|
||||||
|
"""Get current agent status."""
|
||||||
|
return self._status
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_run(self) -> datetime | None:
|
||||||
|
"""Get last run timestamp."""
|
||||||
|
return self._last_run
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||||
|
"""Execute agent task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: Optional context data for the agent
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AgentResult with execution outcome
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Check if agent is healthy and operational.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if agent can execute tasks
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_status(self) -> AgentStatus:
|
||||||
|
"""Get current agent status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Current status from status property
|
||||||
|
"""
|
||||||
|
return self.status
|
||||||
|
|
||||||
|
async def get_metrics(self) -> AgentMetrics:
|
||||||
|
"""Get agent performance metrics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AgentMetrics instance
|
||||||
|
"""
|
||||||
|
return AgentMetrics(
|
||||||
|
agent_id=self.name,
|
||||||
|
version=self.version,
|
||||||
|
last_run=self.last_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_running(self, task: str) -> None:
|
||||||
|
"""Set agent to running state."""
|
||||||
|
self._status = AgentStatus.RUNNING
|
||||||
|
self._current_task = task
|
||||||
|
self._last_run = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
async def set_idle(self) -> None:
|
||||||
|
"""Set agent to idle state."""
|
||||||
|
self._status = AgentStatus.IDLE
|
||||||
|
self._current_task = None
|
||||||
|
|
||||||
|
async def set_error(self, error: str) -> None:
|
||||||
|
"""Set agent to error state."""
|
||||||
|
self._status = AgentStatus.ERROR
|
||||||
|
self._current_task = None
|
||||||
|
|
||||||
|
async def set_offline(self) -> None:
|
||||||
|
"""Set agent to offline state."""
|
||||||
|
self._status = AgentStatus.OFFLINE
|
||||||
|
|
||||||
|
def compute_checksum(self) -> str:
|
||||||
|
"""Compute SHA256 checksum of this agent's source file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hex digest of the file content.
|
||||||
|
"""
|
||||||
|
file_path = inspect.getfile(self.__class__)
|
||||||
|
content = Path(file_path).read_bytes()
|
||||||
|
return hashlib.sha256(content).hexdigest()
|
||||||
|
|
||||||
|
def _read_changelog_checksum(self) -> str | None:
|
||||||
|
"""Read stored checksum from changelog file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Stored checksum or None if file doesn't exist.
|
||||||
|
"""
|
||||||
|
if not self._changelog_path.exists():
|
||||||
|
return None
|
||||||
|
content = self._changelog_path.read_text(encoding="utf-8")
|
||||||
|
match = re.search(r"<!--\s*checksum:\s*([a-f0-9]{64})\s*-->", content)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
def bump_version(self, version_type: str = "patch") -> str:
|
||||||
|
"""Bump agent version (major.minor.patch).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
version_type: "major", "minor", or "patch"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New version string.
|
||||||
|
"""
|
||||||
|
major, minor, patch = map(int, self.version.split("."))
|
||||||
|
if version_type == "major":
|
||||||
|
major += 1
|
||||||
|
minor = 0
|
||||||
|
patch = 0
|
||||||
|
elif version_type == "minor":
|
||||||
|
minor += 1
|
||||||
|
patch = 0
|
||||||
|
else:
|
||||||
|
patch += 1
|
||||||
|
self.version = f"{major}.{minor}.{patch}"
|
||||||
|
return self.version
|
||||||
|
|
||||||
|
def _write_changelog_entry(self, version: str, entries: list[str]) -> None:
|
||||||
|
"""Write a changelog entry for this agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
version: New version string.
|
||||||
|
entries: List of change descriptions.
|
||||||
|
"""
|
||||||
|
self.changelog_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
checksum = self.compute_checksum()
|
||||||
|
header = f"# {self.name} Changelog\n<!-- checksum: {checksum} -->\n"
|
||||||
|
entry = f"\n## {version} ({today})\n"
|
||||||
|
for line in entries:
|
||||||
|
entry += f"- {line}\n"
|
||||||
|
|
||||||
|
if self._changelog_path.exists():
|
||||||
|
old = self._changelog_path.read_text(encoding="utf-8")
|
||||||
|
new = header + entry + old.split("\n", 2)[-1] if "\n" in old else old
|
||||||
|
self._changelog_path.write_text(new, encoding="utf-8")
|
||||||
|
else:
|
||||||
|
self._changelog_path.write_text(header + entry, encoding="utf-8")
|
||||||
|
|
||||||
|
async def _check_version(self, changelog_entries: list[str] | None = None) -> str | None:
|
||||||
|
"""Check if agent changed and bump version if needed.
|
||||||
|
|
||||||
|
Should be called after run(). Compares current file checksum
|
||||||
|
with stored checksum in changelog. On mismatch bumps patch
|
||||||
|
and writes changelog entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
changelog_entries: Optional list of change descriptions.
|
||||||
|
If None, auto-generated from git diff summary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New version string if bumped, None if unchanged.
|
||||||
|
"""
|
||||||
|
current_checksum = self.compute_checksum()
|
||||||
|
stored_checksum = self._read_changelog_checksum()
|
||||||
|
|
||||||
|
if current_checksum == stored_checksum:
|
||||||
|
return None
|
||||||
|
|
||||||
|
old_version = self.version
|
||||||
|
new_version = self.bump_version("patch")
|
||||||
|
entries = changelog_entries or ["Auto-detected code changes"]
|
||||||
|
self._write_changelog_entry(new_version, entries)
|
||||||
|
|
||||||
|
return new_version
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<{self.__class__.__name__}(name={self.name}, status={self.status.value})>"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentResponse(CoreModel):
|
||||||
|
"""Standardized agent response."""
|
||||||
|
|
||||||
|
agent: str
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
data: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""Дирижёр — главный оркестратор VoIdeaAI.
|
||||||
|
|
||||||
|
Принимает голосовой ввод пользователя, определяет намерение,
|
||||||
|
направляет ролевому агенту, верифицирует ответ, возвращает пользователю.
|
||||||
|
Самообучение через логирование, рейтинг и историю успешных кейсов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.agents.base import AgentResult, AgentStatus, BaseAgent
|
||||||
|
from app.agents.conductor_storage import (
|
||||||
|
auto_tune_user,
|
||||||
|
check_suggested_command,
|
||||||
|
get_recent_history,
|
||||||
|
get_similar_successful,
|
||||||
|
log_interaction,
|
||||||
|
)
|
||||||
|
from app.agents.role_agents import (
|
||||||
|
ALL_ROLE_AGENTS,
|
||||||
|
VERIFICATION_PROMPT,
|
||||||
|
RoleAgent,
|
||||||
|
run_role_agent,
|
||||||
|
)
|
||||||
|
from app.agents.vad import should_process_audio
|
||||||
|
from app.agents.wake_word import strip_wake_word, has_wake_word
|
||||||
|
from app.services.llm_service import chat_completion
|
||||||
|
from app.services.pipeline_service import PipelineService
|
||||||
|
from app.services.session_service import create_session, update_session_title
|
||||||
|
|
||||||
|
|
||||||
|
class ConductorAgent(BaseAgent):
|
||||||
|
name = "Дирижёр"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = (
|
||||||
|
"Главный оркестратор. Принимает голосовой/текстовый ввод пользователя, "
|
||||||
|
"определяет намерение, направляет специализированному агенту, "
|
||||||
|
"верифицирует ответ и возвращает пользователю. Самообучение через рейтинг."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="Дирижёр: запущен. Используйте /api/v1/voice/chat для взаимодействия.",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def process(
|
||||||
|
self,
|
||||||
|
user_input: str,
|
||||||
|
db: AsyncSession | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
vad_enabled: bool | None = None,
|
||||||
|
wake_word_detected: bool | None = None,
|
||||||
|
audio_duration_ms: int | None = None,
|
||||||
|
pipeline_mode: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Process user input through the full conductor pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_input: User's text input
|
||||||
|
db: Optional DB session for self-learning
|
||||||
|
user_id: Optional user ID for history
|
||||||
|
session_id: Optional session ID. Auto-creates if not provided.
|
||||||
|
vad_enabled: Whether VAD was used client-side
|
||||||
|
wake_word_detected: Whether wake word was detected client-side
|
||||||
|
audio_duration_ms: Duration of audio input in ms
|
||||||
|
pipeline_mode: Pipeline mode override ("fast" | "full" | "off")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with response, agent_name, confidence, verification_status,
|
||||||
|
interaction_id, session_id
|
||||||
|
"""
|
||||||
|
start = time.time()
|
||||||
|
pipeline_service = PipelineService(db) if db else None
|
||||||
|
stages_log: list[dict] = []
|
||||||
|
|
||||||
|
# ── 0. VAD filter ──
|
||||||
|
if audio_duration_ms is not None:
|
||||||
|
should_process, skip_reason = should_process_audio(
|
||||||
|
audio_duration_ms, {"enabled": vad_enabled if vad_enabled is not None else True}
|
||||||
|
)
|
||||||
|
if not should_process:
|
||||||
|
if pipeline_service:
|
||||||
|
await pipeline_service.record_stat(
|
||||||
|
user_id=user_id, stage="vad", passed=False,
|
||||||
|
reason=skip_reason, duration_ms=0,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"response": "",
|
||||||
|
"agent_name": "",
|
||||||
|
"agent_description": "",
|
||||||
|
"confidence": 0,
|
||||||
|
"verification_status": "skipped",
|
||||||
|
"processing_time_ms": 0,
|
||||||
|
"interaction_id": "",
|
||||||
|
"session_id": session_id or "",
|
||||||
|
"suggested_command": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 1. Wake word stripping ──
|
||||||
|
if wake_word_detected:
|
||||||
|
cleaned = strip_wake_word(user_input)
|
||||||
|
if cleaned:
|
||||||
|
user_input = cleaned
|
||||||
|
|
||||||
|
# ── 2. Авто-создание сессии ──
|
||||||
|
is_new_session = False
|
||||||
|
if db and user_id and not session_id:
|
||||||
|
session = await create_session(db, user_id)
|
||||||
|
session_id = str(session.id)
|
||||||
|
is_new_session = True
|
||||||
|
|
||||||
|
# ── Подготовка контекста с историей сессии ──
|
||||||
|
agent_names = [
|
||||||
|
{"name": a.name, "description": a.description}
|
||||||
|
for a in ALL_ROLE_AGENTS
|
||||||
|
]
|
||||||
|
|
||||||
|
context_parts = []
|
||||||
|
if db and user_id:
|
||||||
|
history = await get_recent_history(db, user_id, limit=5)
|
||||||
|
if history:
|
||||||
|
context_parts.append("Недавние обсуждения:\n" + "\n".join(
|
||||||
|
f"[{h['agent']}]: {h['input']} → {h['response'][:100]}"
|
||||||
|
for h in history
|
||||||
|
))
|
||||||
|
|
||||||
|
similar = await get_similar_successful(db, user_input)
|
||||||
|
if similar:
|
||||||
|
context_parts.append("Похожие успешные кейсы:\n" + "\n".join(
|
||||||
|
f"Было: {s['input'][:100]}, Ответ: {s['response'][:100]}"
|
||||||
|
for s in similar
|
||||||
|
))
|
||||||
|
|
||||||
|
llm_context = "\n\n".join(context_parts) if context_parts else None
|
||||||
|
|
||||||
|
# ── 3. Выбор агента ──
|
||||||
|
route_start = time.time()
|
||||||
|
selected_agent = await self._route(user_input, agent_names, llm_context)
|
||||||
|
if not selected_agent:
|
||||||
|
selected_agent = "Бизнес-аналитик"
|
||||||
|
route_elapsed = (time.time() - route_start) * 1000
|
||||||
|
|
||||||
|
agent = next(
|
||||||
|
(a for a in ALL_ROLE_AGENTS if a.name == selected_agent),
|
||||||
|
ALL_ROLE_AGENTS[0],
|
||||||
|
)
|
||||||
|
stages_log.append({"stage": "routing", "passed": True, "duration_ms": route_elapsed})
|
||||||
|
|
||||||
|
if pipeline_service:
|
||||||
|
await pipeline_service.record_stat(
|
||||||
|
user_id=user_id, stage="routing", passed=True,
|
||||||
|
duration_ms=int(route_elapsed),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 4. Генерация ответа ──
|
||||||
|
response = await run_role_agent(agent, user_input, llm_context)
|
||||||
|
if not response:
|
||||||
|
response = "Не удалось обработать запрос. Проверьте API ключи."
|
||||||
|
|
||||||
|
# ── 5. Верификация ответа ──
|
||||||
|
verify_start = time.time()
|
||||||
|
verification = await self._verify(response, user_input)
|
||||||
|
verify_elapsed = (time.time() - verify_start) * 1000
|
||||||
|
confidence = verification.get("confidence", 50)
|
||||||
|
issues = verification.get("issues", [])
|
||||||
|
corrected = verification.get("corrected", "")
|
||||||
|
|
||||||
|
if corrected:
|
||||||
|
response = corrected
|
||||||
|
verification_status = "issues_found"
|
||||||
|
elif confidence >= 80:
|
||||||
|
verification_status = "verified"
|
||||||
|
elif confidence >= 50:
|
||||||
|
verification_status = "warning"
|
||||||
|
else:
|
||||||
|
verification_status = "needs_clarification"
|
||||||
|
response = (
|
||||||
|
"Извините, я не до конца уверен в ответе. "
|
||||||
|
"Не могли бы вы уточнить свой запрос?\n\n"
|
||||||
|
f"Вот что я понял: {response[:300]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
stages_log.append({"stage": "verification", "passed": confidence >= 50, "duration_ms": verify_elapsed})
|
||||||
|
if pipeline_service:
|
||||||
|
await pipeline_service.record_stat(
|
||||||
|
user_id=user_id, stage="verification", passed=confidence >= 50,
|
||||||
|
reason=None if confidence >= 50 else "low_confidence",
|
||||||
|
duration_ms=int(verify_elapsed),
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed = (time.time() - start) * 1000
|
||||||
|
|
||||||
|
# ── 6. Логирование ──
|
||||||
|
interaction_id = ""
|
||||||
|
if db:
|
||||||
|
interaction_id = await log_interaction(
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session_id,
|
||||||
|
input_text=user_input[:1000],
|
||||||
|
detected_intent=selected_agent,
|
||||||
|
selected_agent=selected_agent,
|
||||||
|
response_text=response,
|
||||||
|
processing_time_ms=elapsed,
|
||||||
|
confidence=confidence,
|
||||||
|
verification_status=verification_status,
|
||||||
|
was_auto_routed=True,
|
||||||
|
context={"issues": issues, "stages": stages_log} if issues else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 7. Title generation для новой сессии ──
|
||||||
|
if db and session_id and is_new_session:
|
||||||
|
title_start = time.time()
|
||||||
|
title = await self._generate_title(user_input)
|
||||||
|
title_elapsed = (time.time() - title_start) * 1000
|
||||||
|
await update_session_title(db, session_id, title)
|
||||||
|
if pipeline_service:
|
||||||
|
await pipeline_service.record_stat(
|
||||||
|
user_id=user_id, stage="title_generation", passed=True,
|
||||||
|
duration_ms=int(title_elapsed),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 8. Auto-tuning (каждые ~5 взаимодействий) ──
|
||||||
|
if db and user_id:
|
||||||
|
suggested_command = await check_suggested_command(db, user_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"response": response,
|
||||||
|
"agent_name": selected_agent,
|
||||||
|
"agent_description": agent.description,
|
||||||
|
"confidence": confidence,
|
||||||
|
"verification_status": verification_status,
|
||||||
|
"processing_time_ms": round(elapsed, 1),
|
||||||
|
"interaction_id": interaction_id,
|
||||||
|
"session_id": session_id or "",
|
||||||
|
"suggested_command": suggested_command,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _route(
|
||||||
|
self,
|
||||||
|
user_input: str,
|
||||||
|
agent_names: list[dict[str, str]],
|
||||||
|
context: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
system = "Ты — дирижёр умных ассистентов. Определи лучшего агента для ответа."
|
||||||
|
if context:
|
||||||
|
system += f"\n\nКонтекст:\n{context}"
|
||||||
|
agent_list = "\n".join(f"- {a['name']}: {a['description']}" for a in agent_names)
|
||||||
|
system += f"\n\nДоступные агенты:\n{agent_list}\n\nОтветь ТОЛЬКО именем агента."
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": user_input},
|
||||||
|
]
|
||||||
|
result = await chat_completion(messages, temperature=0.3, max_tokens=64)
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
result = result.strip().strip('"').strip("'")
|
||||||
|
valid = {a["name"] for a in agent_names}
|
||||||
|
return result if result in valid else None
|
||||||
|
|
||||||
|
async def _verify(
|
||||||
|
self,
|
||||||
|
response: str,
|
||||||
|
original_input: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": VERIFICATION_PROMPT},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"Запрос пользователя: {original_input}\n\n"
|
||||||
|
f"Ответ агента: {response}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
result = await chat_completion(messages, temperature=0.2, max_tokens=1024)
|
||||||
|
if not result:
|
||||||
|
return {"status": "verified", "confidence": 80, "issues": [], "corrected": ""}
|
||||||
|
try:
|
||||||
|
cleaned = result.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
cleaned = cleaned.split("\n", 1)[-1].rsplit("\n", 1)[0]
|
||||||
|
return json.loads(cleaned)
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
return {"status": "verified", "confidence": 80, "issues": [], "corrected": ""}
|
||||||
|
|
||||||
|
async def _generate_title(self, user_input: str) -> str:
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": (
|
||||||
|
"Ты — ассистент, который придумывает короткие заголовки для обсуждений. "
|
||||||
|
"Ответь одним предложением (до 7 слов), отражающим суть запроса."
|
||||||
|
)},
|
||||||
|
{"role": "user", "content": f"Придумай заголовок для обсуждения этого запроса:\n{user_input}"},
|
||||||
|
]
|
||||||
|
result = await chat_completion(messages, temperature=0.3, max_tokens=30)
|
||||||
|
if result:
|
||||||
|
return result.strip().strip('"').strip("'")[:255]
|
||||||
|
return "Новое обсуждение"
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
return True
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"""Storage for Дирижёр interactions — self-learning and analytics."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.conductor import ConductorInteraction
|
||||||
|
from app.models.pipeline import PipelineStats
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.voice_command import VoiceCommand
|
||||||
|
|
||||||
|
|
||||||
|
async def log_interaction(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str | None,
|
||||||
|
input_text: str,
|
||||||
|
detected_intent: str,
|
||||||
|
selected_agent: str,
|
||||||
|
response_text: str,
|
||||||
|
processing_time_ms: float,
|
||||||
|
confidence: int = 80,
|
||||||
|
verification_status: str = "verified",
|
||||||
|
was_auto_routed: bool = True,
|
||||||
|
context: dict[str, Any] | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
log = ConductorInteraction(
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session_id,
|
||||||
|
input_text=input_text,
|
||||||
|
detected_intent=detected_intent,
|
||||||
|
selected_agent=selected_agent,
|
||||||
|
response_text=response_text,
|
||||||
|
processing_time_ms=processing_time_ms,
|
||||||
|
confidence_score=confidence,
|
||||||
|
verification_status=verification_status,
|
||||||
|
was_auto_routed=was_auto_routed,
|
||||||
|
context=json.dumps(context) if context else None,
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(log)
|
||||||
|
return str(log.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def rate_interaction(db: AsyncSession, interaction_id: str, rating: int) -> bool:
|
||||||
|
result = await db.execute(
|
||||||
|
update(ConductorInteraction)
|
||||||
|
.where(ConductorInteraction.id == interaction_id)
|
||||||
|
.values(user_rating=rating)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_similar_successful(
|
||||||
|
db: AsyncSession,
|
||||||
|
input_text: str,
|
||||||
|
limit: int = 5,
|
||||||
|
hours: int = 24 * 7,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
|
result = await db.execute(
|
||||||
|
select(ConductorInteraction)
|
||||||
|
.where(ConductorInteraction.created_at >= cutoff)
|
||||||
|
.where(ConductorInteraction.user_rating >= 4)
|
||||||
|
.where(ConductorInteraction.confidence_score >= 70)
|
||||||
|
.order_by(ConductorInteraction.created_at.desc())
|
||||||
|
.limit(limit * 3)
|
||||||
|
)
|
||||||
|
logs = result.scalars().all()
|
||||||
|
|
||||||
|
scored = []
|
||||||
|
for log in logs:
|
||||||
|
score = _text_similarity(input_text.lower(), log.input_text.lower())
|
||||||
|
if score > 0.3:
|
||||||
|
scored.append((score, {
|
||||||
|
"input": log.input_text,
|
||||||
|
"agent": log.selected_agent,
|
||||||
|
"response": log.response_text,
|
||||||
|
"rating": log.user_rating,
|
||||||
|
}))
|
||||||
|
scored.sort(key=lambda x: -x[0])
|
||||||
|
return [s[1] for s in scored[:limit]]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session_history(
|
||||||
|
db: AsyncSession,
|
||||||
|
session_id: str,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ConductorInteraction)
|
||||||
|
.where(ConductorInteraction.session_id == session_id)
|
||||||
|
.order_by(ConductorInteraction.created_at.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(log.id),
|
||||||
|
"input": log.input_text,
|
||||||
|
"agent": log.selected_agent,
|
||||||
|
"response": log.response_text,
|
||||||
|
"confidence": log.confidence_score,
|
||||||
|
"rating": log.user_rating,
|
||||||
|
"created_at": log.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for log in result.scalars().all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_recent_history(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
limit: int = 10,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ConductorInteraction)
|
||||||
|
.where(ConductorInteraction.user_id == user_id)
|
||||||
|
.order_by(ConductorInteraction.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"input": log.input_text,
|
||||||
|
"agent": log.selected_agent,
|
||||||
|
"response": log.response_text,
|
||||||
|
"confidence": log.confidence_score,
|
||||||
|
"rating": log.user_rating,
|
||||||
|
"created_at": log.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for log in result.scalars().all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def check_suggested_command(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
min_count: int = 3,
|
||||||
|
) -> str | None:
|
||||||
|
"""Check if user has a command that's been used enough to suggest customizing it."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(VoiceCommand)
|
||||||
|
.where(VoiceCommand.user_id == user_id)
|
||||||
|
.where(VoiceCommand.count >= min_count)
|
||||||
|
.order_by(VoiceCommand.count.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
cmd = result.scalar_one_or_none()
|
||||||
|
if not cmd:
|
||||||
|
return None
|
||||||
|
return f"Команда «{cmd.phrase}» сработала {cmd.count} раз. Настроить в /voice/help"
|
||||||
|
|
||||||
|
|
||||||
|
AUTO_TUNING_CONFIG = {
|
||||||
|
"min_samples": 5,
|
||||||
|
"rejection_threshold": 5,
|
||||||
|
"lookback_hours": 24,
|
||||||
|
"adjustment_factor": 0.05,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def auto_tune_user(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
config: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Analyze user interaction patterns and auto-tune pipeline parameters.
|
||||||
|
|
||||||
|
Examines recent rejections, low-rated interactions, and pipeline failures,
|
||||||
|
then adjusts User.pipeline_tuning JSONB accordingly.
|
||||||
|
"""
|
||||||
|
tuning = config or dict(AUTO_TUNING_CONFIG)
|
||||||
|
min_samples = tuning.get("min_samples", 5)
|
||||||
|
lookback = tuning.get("lookback_hours", 24)
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(hours=lookback)
|
||||||
|
|
||||||
|
# ── 1. Count explicit rejections (rating < 3) ──
|
||||||
|
explicit_result = await db.execute(
|
||||||
|
select(func.count(ConductorInteraction.id))
|
||||||
|
.where(ConductorInteraction.user_id == user_id)
|
||||||
|
.where(ConductorInteraction.created_at >= cutoff)
|
||||||
|
.where(ConductorInteraction.user_rating < 3)
|
||||||
|
)
|
||||||
|
explicit_rejections = explicit_result.scalar() or 0
|
||||||
|
|
||||||
|
# ── 2. Count implicit rejections (confidence < 50, needs_clarification) ──
|
||||||
|
implicit_result = await db.execute(
|
||||||
|
select(func.count(ConductorInteraction.id))
|
||||||
|
.where(ConductorInteraction.user_id == user_id)
|
||||||
|
.where(ConductorInteraction.created_at >= cutoff)
|
||||||
|
.where(ConductorInteraction.verification_status == "needs_clarification")
|
||||||
|
)
|
||||||
|
implicit_rejections = implicit_result.scalar() or 0
|
||||||
|
|
||||||
|
total_rejections = explicit_rejections + implicit_rejections
|
||||||
|
|
||||||
|
# ── 3. Pipeline stage failures ──
|
||||||
|
stage_fails = await db.execute(
|
||||||
|
select(PipelineStats.stage, func.count(PipelineStats.id))
|
||||||
|
.where(PipelineStats.user_id == user_id)
|
||||||
|
.where(PipelineStats.created_at >= cutoff)
|
||||||
|
.where(PipelineStats.passed == False)
|
||||||
|
.group_by(PipelineStats.stage)
|
||||||
|
.order_by(func.count(PipelineStats.id).desc())
|
||||||
|
)
|
||||||
|
stage_failures: dict[str, int] = dict(stage_fails.all())
|
||||||
|
|
||||||
|
# ── 4. Calculate adjustments ──
|
||||||
|
adjustments: dict[str, Any] = {}
|
||||||
|
rejection_ratio = total_rejections / max(min_samples, 1)
|
||||||
|
|
||||||
|
if total_rejections >= tuning.get("rejection_threshold", 5):
|
||||||
|
adj = tuning.get("adjustment_factor", 0.05)
|
||||||
|
adjustments["confidence_boost"] = round(min(adj * rejection_ratio, 0.3), 2)
|
||||||
|
adjustments["needs_clarification"] = True
|
||||||
|
|
||||||
|
if "vad" in stage_failures and stage_failures["vad"] >= 3:
|
||||||
|
adjustments["vad_noise_threshold"] = 0.4
|
||||||
|
adjustments["vad_silence_timeout_ms"] = 2000
|
||||||
|
|
||||||
|
if "wake_word" in stage_failures and stage_failures["wake_word"] >= 3:
|
||||||
|
adjustments["wake_word_sensitivity"] = 0.8
|
||||||
|
|
||||||
|
if "semantic_validation" in stage_failures and stage_failures["semantic_validation"] >= 3:
|
||||||
|
adjustments["semantic_validation_timeout_ms"] = 8000
|
||||||
|
|
||||||
|
# ── 5. Store in User.pipeline_tuning ──
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and adjustments:
|
||||||
|
current_tuning = user.pipeline_tuning or {}
|
||||||
|
current_tuning["auto_tuned_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
current_tuning["adjustments"] = {
|
||||||
|
**current_tuning.get("adjustments", {}),
|
||||||
|
**adjustments,
|
||||||
|
}
|
||||||
|
current_tuning["stats"] = {
|
||||||
|
"explicit_rejections": explicit_rejections,
|
||||||
|
"implicit_rejections": implicit_rejections,
|
||||||
|
"total_rejections": total_rejections,
|
||||||
|
"stage_failures": stage_failures,
|
||||||
|
}
|
||||||
|
user.pipeline_tuning = current_tuning
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tuned": bool(adjustments),
|
||||||
|
"adjustments": adjustments,
|
||||||
|
"total_rejections": total_rejections,
|
||||||
|
"stage_failures": stage_failures,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _text_similarity(a: str, b: str) -> float:
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
words_a = set(a.split())
|
||||||
|
words_b = set(b.split())
|
||||||
|
if not words_a or not words_b:
|
||||||
|
return 0.0
|
||||||
|
intersection = words_a & words_b
|
||||||
|
return len(intersection) / max(len(words_a), len(words_b))
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""DocAgent - Documentation automation for VoIdea.
|
||||||
|
|
||||||
|
This agent automatically:
|
||||||
|
- Creates README.md for new modules
|
||||||
|
- Updates documentation when code changes
|
||||||
|
- Generates docstrings
|
||||||
|
- Maintains Runbook
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class DocAgent(BaseAgent):
|
||||||
|
"""Documentation automation agent."""
|
||||||
|
|
||||||
|
name = "doc_agent"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Automatically maintains project documentation"
|
||||||
|
triggers = [
|
||||||
|
AgentTrigger.MANUAL,
|
||||||
|
AgentTrigger.PRE_COMMIT,
|
||||||
|
AgentTrigger.PUSH,
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.project_root = Path(__file__).parent.parent.parent
|
||||||
|
self.docs_dir = self.project_root / "docs"
|
||||||
|
self.app_dir = self.project_root / "app"
|
||||||
|
|
||||||
|
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||||
|
"""Execute documentation task.
|
||||||
|
|
||||||
|
Context can contain:
|
||||||
|
- action: str (update_readme, generate_docs, etc.)
|
||||||
|
- module: str (module path to document)
|
||||||
|
- files: list[str] (changed files)
|
||||||
|
"""
|
||||||
|
await self.set_running("documentation")
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = context.get("action", "update_all") if context else "update_all"
|
||||||
|
|
||||||
|
if action == "update_readme":
|
||||||
|
module = context.get("module", "")
|
||||||
|
result = await self._update_module_readme(module)
|
||||||
|
elif action == "generate_docs":
|
||||||
|
result = await self._generate_docs()
|
||||||
|
elif action == "update_session_context":
|
||||||
|
result = await self._update_session_context(context or {})
|
||||||
|
else:
|
||||||
|
result = await self._update_all()
|
||||||
|
|
||||||
|
await self.set_idle()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.set_error(str(e))
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"DocAgent failed: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Check if DocAgent is operational."""
|
||||||
|
try:
|
||||||
|
return self.docs_dir.exists() and self.app_dir.exists()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _update_module_readme(self, module_path: str) -> AgentResult:
|
||||||
|
"""Update README.md for a specific module."""
|
||||||
|
module_dir = self.app_dir / module_path if module_path else self.app_dir
|
||||||
|
|
||||||
|
if not module_dir.exists():
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Module not found: {module_path}",
|
||||||
|
)
|
||||||
|
|
||||||
|
files = list(module_dir.glob("*.py"))
|
||||||
|
files = [f for f in files if f.name != "__init__.py"]
|
||||||
|
|
||||||
|
content = f"""# {module_path or 'app'} Module - VoIdea
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
[Auto-generated documentation]
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
"""
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
purpose = self._get_file_purpose(file.name)
|
||||||
|
content += f"| `{file.name}` | {purpose} |\n"
|
||||||
|
|
||||||
|
readme_path = module_dir / "README.md"
|
||||||
|
readme_path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Updated README for {module_path or 'app'}",
|
||||||
|
data={"module": module_path, "files_count": len(files)},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _update_all(self) -> AgentResult:
|
||||||
|
"""Update all documentation."""
|
||||||
|
updated = []
|
||||||
|
|
||||||
|
for module_dir in self.app_dir.iterdir():
|
||||||
|
if module_dir.is_dir() and (module_dir / "__init__.py").exists():
|
||||||
|
result = await self._update_module_readme(module_dir.name)
|
||||||
|
if result.success:
|
||||||
|
updated.append(module_dir.name)
|
||||||
|
|
||||||
|
session_result = await self._update_session_context({})
|
||||||
|
if session_result.success:
|
||||||
|
updated.append("SESSION_CONTEXT")
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Updated {len(updated)} documentation files",
|
||||||
|
data={"updated": updated},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _generate_docs(self) -> AgentResult:
|
||||||
|
"""Generate API documentation from docstrings."""
|
||||||
|
docs_generated = 0
|
||||||
|
endpoints = []
|
||||||
|
|
||||||
|
api_dir = self.app_dir / "api"
|
||||||
|
if api_dir.exists():
|
||||||
|
for file in api_dir.rglob("*.py"):
|
||||||
|
if file.name == "__init__.py":
|
||||||
|
continue
|
||||||
|
docs_generated += 1
|
||||||
|
endpoints.append(str(file.relative_to(self.app_dir)))
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Generated documentation for {docs_generated} files",
|
||||||
|
data={"endpoints": endpoints, "count": docs_generated},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _update_session_context(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Update SESSION_CONTEXT.md with current progress."""
|
||||||
|
session_file = self.project_root / "SESSION_CONTEXT.md"
|
||||||
|
|
||||||
|
if not session_file.exists():
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="SESSION_CONTEXT.md not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = session_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
if "Last Updated" not in content:
|
||||||
|
content = content.replace(
|
||||||
|
"================================================================================",
|
||||||
|
"Last Updated: {}\n================================================================================".format(
|
||||||
|
datetime.now(timezone.utc).isoformat()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
session_file.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="SESSION_CONTEXT.md updated",
|
||||||
|
data={"timestamp": datetime.now(timezone.utc).isoformat()},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Failed to update SESSION_CONTEXT: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_file_purpose(self, filename: str) -> str:
|
||||||
|
"""Get file purpose based on naming convention."""
|
||||||
|
purposes = {
|
||||||
|
"main.py": "FastAPI application entry point",
|
||||||
|
"config.py": "Configuration management",
|
||||||
|
"models.py": "Data models",
|
||||||
|
"schemas.py": "Pydantic schemas",
|
||||||
|
"service.py": "Business logic",
|
||||||
|
"repository.py": "Data access layer",
|
||||||
|
"router.py": "API routes",
|
||||||
|
"base.py": "Base classes",
|
||||||
|
"exceptions.py": "Custom exceptions",
|
||||||
|
"security.py": "Security utilities",
|
||||||
|
"database.py": "Database setup",
|
||||||
|
"dependencies.py": "FastAPI dependencies",
|
||||||
|
}
|
||||||
|
return purposes.get(filename, "Module file")
|
||||||
|
|
||||||
|
async def get_metrics(self) -> dict[str, Any]:
|
||||||
|
"""Get DocAgent metrics."""
|
||||||
|
from app.agents.models import AgentMetric
|
||||||
|
|
||||||
|
return {
|
||||||
|
"agent_id": self.name,
|
||||||
|
"version": self.version,
|
||||||
|
"status": self.status.value,
|
||||||
|
"last_run": self.last_run.isoformat() if self.last_run else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"""EvolutionAgent - Agent self-improvement and versioning system for VoIdea.
|
||||||
|
|
||||||
|
This agent:
|
||||||
|
- Analyzes agent performance
|
||||||
|
- Generates improvement suggestions
|
||||||
|
- Manages agent capabilities evolution
|
||||||
|
- Handles agent versioning (minor/major bumps)
|
||||||
|
- Coordinates learning
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
ALL_AGENTS: list[dict[str, Any]] = [
|
||||||
|
{"id": "doc_agent", "capabilities": ["documentation", "docstrings", "runbook"]},
|
||||||
|
{"id": "backlog_agent", "capabilities": ["create", "list", "update", "delete", "suggest"]},
|
||||||
|
{"id": "spec_agent", "capabilities": ["versioning", "changelog", "project_json"]},
|
||||||
|
{"id": "audit_agent", "capabilities": ["style_check", "type_check", "docs_check"]},
|
||||||
|
{"id": "observer_agent", "capabilities": ["collect", "report", "analyze", "metrics"]},
|
||||||
|
{"id": "security_agent", "capabilities": ["vulnerability_scan", "dependency_check", "compliance"]},
|
||||||
|
{"id": "qa_tester_agent", "capabilities": ["functional_test", "smoke_test", "regression"]},
|
||||||
|
{"id": "fix_agent", "capabilities": ["bug_analysis", "patch_generation", "validation"]},
|
||||||
|
{"id": "ui_test_agent", "capabilities": ["screenshot_test", "layout_check", "accessibility"]},
|
||||||
|
{"id": "rollout_agent", "capabilities": ["gradual_deploy", "monitor", "rollback"]},
|
||||||
|
{"id": "evolution_agent", "capabilities": ["analyze", "evolve", "suggest", "status", "version_bump"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
AGENT_IDS = [a["id"] for a in ALL_AGENTS]
|
||||||
|
|
||||||
|
|
||||||
|
class EvolutionAgent(BaseAgent):
|
||||||
|
"""Agent self-improvement and evolution system."""
|
||||||
|
|
||||||
|
name = "evolution_agent"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Manages agent self-improvement and capability growth"
|
||||||
|
triggers = [
|
||||||
|
AgentTrigger.MANUAL,
|
||||||
|
AgentTrigger.CRON,
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.project_root = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||||
|
"""Execute evolution task.
|
||||||
|
|
||||||
|
Context can contain:
|
||||||
|
- action: str (analyze, evolve, suggest, status, version_check, version_bump)
|
||||||
|
- agent_id: str (specific agent to analyze)
|
||||||
|
- version_type: str (minor, major) — for version_bump
|
||||||
|
- entries: list[str] — changelog entries for version_bump
|
||||||
|
- capabilities: list[str] — new capabilities for evolve
|
||||||
|
"""
|
||||||
|
await self.set_running("evolution")
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = context.get("action", "status") if context else "status"
|
||||||
|
|
||||||
|
if action == "analyze":
|
||||||
|
result = await self._analyze_agents(context or {})
|
||||||
|
elif action == "evolve":
|
||||||
|
result = await self._evolve_agent(context or {})
|
||||||
|
elif action == "suggest":
|
||||||
|
result = await self._suggest_improvements(context or {})
|
||||||
|
elif action == "version_check":
|
||||||
|
result = await self._version_check(context or {})
|
||||||
|
elif action == "version_bump":
|
||||||
|
result = await self._version_bump(context or {})
|
||||||
|
else:
|
||||||
|
result = await self._get_status()
|
||||||
|
|
||||||
|
await self.set_idle()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.set_error(str(e))
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"EvolutionAgent failed: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Check if EvolutionAgent is operational."""
|
||||||
|
return self.project_root.exists()
|
||||||
|
|
||||||
|
async def _get_status(self) -> AgentResult:
|
||||||
|
"""Get current evolution status with versions from changelogs."""
|
||||||
|
agents = []
|
||||||
|
for agent_info in ALL_AGENTS:
|
||||||
|
aid = agent_info["id"]
|
||||||
|
version = self._read_agent_version(aid)
|
||||||
|
agents.append({
|
||||||
|
"id": aid,
|
||||||
|
"version": version or "1.0.0",
|
||||||
|
"capabilities": agent_info["capabilities"],
|
||||||
|
"status": "stable",
|
||||||
|
})
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="Evolution status retrieved",
|
||||||
|
data={
|
||||||
|
"total_agents": len(agents),
|
||||||
|
"agents": agents,
|
||||||
|
"last_evolution": datetime.now(timezone.utc).isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_agent_version(self, agent_id: str) -> str | None:
|
||||||
|
"""Read latest version from an agent's changelog file."""
|
||||||
|
changelog_path = Path("CHANGELOG") / "agents" / f"{agent_id}.md"
|
||||||
|
if not changelog_path.exists():
|
||||||
|
return None
|
||||||
|
content = changelog_path.read_text(encoding="utf-8")
|
||||||
|
matches = re.findall(r"##\s+(\d+\.\d+\.\d+)", content)
|
||||||
|
return matches[-1] if matches else None
|
||||||
|
|
||||||
|
async def _version_check(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Scan all agents and report their current versions."""
|
||||||
|
agent_id = context.get("agent_id")
|
||||||
|
agents_to_check = [agent_id] if agent_id else AGENT_IDS
|
||||||
|
|
||||||
|
versions = {}
|
||||||
|
for aid in agents_to_check:
|
||||||
|
versions[aid] = self._read_agent_version(aid) or "1.0.0"
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Version check completed for {len(versions)} agents",
|
||||||
|
data={"versions": versions},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _version_bump(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Bump version of a specific agent (minor or major).
|
||||||
|
|
||||||
|
Context requires:
|
||||||
|
- agent_id: str
|
||||||
|
- version_type: str (minor or major)
|
||||||
|
- entries: list[str] — changelog entry lines
|
||||||
|
"""
|
||||||
|
agent_id = context.get("agent_id")
|
||||||
|
version_type = context.get("version_type", "minor")
|
||||||
|
entries = context.get("entries", [])
|
||||||
|
|
||||||
|
if not agent_id:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="agent_id required",
|
||||||
|
)
|
||||||
|
|
||||||
|
if version_type not in ("minor", "major"):
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Invalid version_type: {version_type}. Use minor or major.",
|
||||||
|
)
|
||||||
|
|
||||||
|
current_version = self._read_agent_version(agent_id) or "1.0.0"
|
||||||
|
major, minor, patch = map(int, current_version.split("."))
|
||||||
|
|
||||||
|
if version_type == "major":
|
||||||
|
major += 1
|
||||||
|
minor = 0
|
||||||
|
patch = 0
|
||||||
|
else:
|
||||||
|
minor += 1
|
||||||
|
patch = 0
|
||||||
|
|
||||||
|
new_version = f"{major}.{minor}.{patch}"
|
||||||
|
|
||||||
|
changelog_path = Path("CHANGELOG") / "agents" / f"{agent_id}.md"
|
||||||
|
changelog_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
entry_text = f"\n## {new_version} ({today})\n"
|
||||||
|
for line in entries:
|
||||||
|
entry_text += f"- {line}\n"
|
||||||
|
|
||||||
|
if changelog_path.exists():
|
||||||
|
content = changelog_path.read_text(encoding="utf-8")
|
||||||
|
changelog_path.write_text(content + entry_text, encoding="utf-8")
|
||||||
|
else:
|
||||||
|
from app.agents.base import BaseAgent
|
||||||
|
agent_src_path = Path("app") / "agents" / f"{agent_id}.py"
|
||||||
|
if agent_src_path.exists():
|
||||||
|
checksum = hashlib.sha256(agent_src_path.read_bytes()).hexdigest()
|
||||||
|
else:
|
||||||
|
checksum = self.compute_checksum()
|
||||||
|
changelog_path.write_text(
|
||||||
|
f"# {agent_id} Changelog\n<!-- checksum: {checksum} -->\n{entry_text}",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"{agent_id} bumped to {new_version}",
|
||||||
|
data={
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"old_version": current_version,
|
||||||
|
"new_version": new_version,
|
||||||
|
"version_type": version_type,
|
||||||
|
"entries": entries,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _analyze_agents(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Analyze agent performance and suggest improvements."""
|
||||||
|
agent_id = context.get("agent_id")
|
||||||
|
|
||||||
|
analyses = {}
|
||||||
|
targets = [agent_id] if agent_id else AGENT_IDS
|
||||||
|
for aid in targets:
|
||||||
|
analyses[aid] = self._analyze_single_agent(aid)
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Analyzed {len(analyses)} agents",
|
||||||
|
data={"analyses": analyses},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _evolve_agent(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Apply evolution to a specific agent with version bump."""
|
||||||
|
agent_id = context.get("agent_id")
|
||||||
|
new_capabilities = context.get("capabilities", [])
|
||||||
|
|
||||||
|
if not agent_id:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="agent_id required",
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_capabilities:
|
||||||
|
entries = [f"Added: {cap}" for cap in new_capabilities]
|
||||||
|
bump_result = await self._version_bump({
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"version_type": "minor",
|
||||||
|
"entries": entries,
|
||||||
|
})
|
||||||
|
new_version = bump_result.data.get("new_version", "unknown")
|
||||||
|
else:
|
||||||
|
new_version = self._read_agent_version(agent_id) or "1.0.0"
|
||||||
|
|
||||||
|
evolution_entry = {
|
||||||
|
"date": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"new_capabilities": new_capabilities,
|
||||||
|
"new_version": new_version,
|
||||||
|
"trigger": context.get("trigger", "manual"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Evolved {agent_id} to v{new_version}",
|
||||||
|
data={
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"new_capabilities": new_capabilities,
|
||||||
|
"new_version": new_version,
|
||||||
|
"evolution": evolution_entry,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _suggest_improvements(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Suggest improvements for agents."""
|
||||||
|
suggestions = [
|
||||||
|
{
|
||||||
|
"agent_id": "doc_agent",
|
||||||
|
"suggestion": "Add auto-generation of API docs",
|
||||||
|
"priority": "medium",
|
||||||
|
"impact": "high",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": "observer_agent",
|
||||||
|
"suggestion": "Add real-time dashboard updates",
|
||||||
|
"priority": "low",
|
||||||
|
"impact": "medium",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": "audit_agent",
|
||||||
|
"suggestion": "Integrate with CI/CD",
|
||||||
|
"priority": "high",
|
||||||
|
"impact": "high",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="Generated improvement suggestions",
|
||||||
|
data={"suggestions": suggestions},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _analyze_single_agent(self, agent_id: str) -> dict[str, Any]:
|
||||||
|
"""Analyze a single agent."""
|
||||||
|
version = self._read_agent_version(agent_id) or "1.0.0"
|
||||||
|
return {
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"version": version,
|
||||||
|
"health": "good",
|
||||||
|
"performance": "optimal",
|
||||||
|
"capabilities": self._get_agent_capabilities(agent_id),
|
||||||
|
"suggestions": [],
|
||||||
|
"last_check": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_agent_capabilities(self, agent_id: str) -> list[str]:
|
||||||
|
"""Get capabilities for an agent."""
|
||||||
|
for agent_info in ALL_AGENTS:
|
||||||
|
if agent_info["id"] == agent_id:
|
||||||
|
return agent_info["capabilities"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_metrics(self) -> dict[str, Any]:
|
||||||
|
"""Get EvolutionAgent metrics."""
|
||||||
|
return {
|
||||||
|
"agent_id": self.name,
|
||||||
|
"version": self.version,
|
||||||
|
"status": self.status.value,
|
||||||
|
"last_evolution": self.last_run.isoformat() if self.last_run else None,
|
||||||
|
"capabilities_tracked": len(ALL_AGENTS),
|
||||||
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
"""FixAgent - Bug fixing agent for VoIdea.
|
||||||
|
|
||||||
|
This agent:
|
||||||
|
- Analyzes bugs from QATesterAgent
|
||||||
|
- Generates fix suggestions
|
||||||
|
- Stores and matches successful fix patterns by regex
|
||||||
|
- Auto-applies fixes when confident
|
||||||
|
- Validates fixes via ruff
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Successful fix patterns: keyed by error type regex
|
||||||
|
FIX_PATTERNS_FILE = "fix_patterns.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_fix_patterns() -> dict[str, list[dict[str, Any]]]:
|
||||||
|
patterns_path = Path(__file__).parent / FIX_PATTERNS_FILE
|
||||||
|
if patterns_path.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(patterns_path.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_fix_patterns(patterns: dict[str, list[dict[str, Any]]]) -> None:
|
||||||
|
patterns_path = Path(__file__).parent / FIX_PATTERNS_FILE
|
||||||
|
try:
|
||||||
|
patterns_path.write_text(
|
||||||
|
json.dumps(patterns, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _get_project_root() -> Path:
|
||||||
|
return Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
class FixAgent(BaseAgent):
|
||||||
|
"""Automatic bug fixing agent."""
|
||||||
|
|
||||||
|
name = "fix_agent"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Analyzes bugs and generates fix suggestions with PR creation"
|
||||||
|
triggers = [
|
||||||
|
AgentTrigger.MANUAL,
|
||||||
|
AgentTrigger.EVENT,
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.project_root = Path(__file__).parent.parent.parent
|
||||||
|
self.max_fixes_per_bug = 3
|
||||||
|
|
||||||
|
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||||
|
"""Execute bug fix task.
|
||||||
|
|
||||||
|
Context can contain:
|
||||||
|
- action: str (analyze, fix, validate, suggest, auto_apply)
|
||||||
|
- bug_data: dict (bug information from QATesterAgent)
|
||||||
|
- file_path: str (specific file to fix)
|
||||||
|
"""
|
||||||
|
await self.set_running("bug_fixing")
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = context.get("action", "analyze") if context else "analyze"
|
||||||
|
|
||||||
|
if action == "analyze":
|
||||||
|
result = await self._analyze_bug(context or {})
|
||||||
|
elif action == "fix":
|
||||||
|
result = await self._generate_fix(context or {})
|
||||||
|
elif action == "validate":
|
||||||
|
result = await self._validate_fix(context or {})
|
||||||
|
elif action == "suggest":
|
||||||
|
result = await self._suggest_fixes(context or {})
|
||||||
|
elif action == "auto_apply":
|
||||||
|
result = await self._auto_apply_match(context or {})
|
||||||
|
else:
|
||||||
|
result = await self._analyze_bug(context or {})
|
||||||
|
|
||||||
|
await self.set_idle()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.set_error(str(e))
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"FixAgent failed: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _auto_apply_match(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Search for a matching fix pattern and auto-apply it."""
|
||||||
|
error_msg = context.get("error", "") or (context.get("bug_data", {})).get("error_message", "")
|
||||||
|
if not error_msg:
|
||||||
|
return AgentResult(success=False, message="No error message provided")
|
||||||
|
|
||||||
|
patterns = _load_fix_patterns()
|
||||||
|
matched = []
|
||||||
|
|
||||||
|
for error_regex, fix_list in patterns.items():
|
||||||
|
if re.search(error_regex, error_msg, re.IGNORECASE):
|
||||||
|
matched.extend(fix_list)
|
||||||
|
|
||||||
|
if not matched:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Нет сохранённых паттернов для ошибки: {self._identify_error_type(error_msg)}",
|
||||||
|
data={"error_type": self._identify_error_type(error_msg), "patterns_available": list(patterns.keys())},
|
||||||
|
)
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Найдено {len(matched)} подходящих паттернов фиксов",
|
||||||
|
data={
|
||||||
|
"error_type": self._identify_error_type(error_msg),
|
||||||
|
"matched_patterns": matched,
|
||||||
|
"can_auto_apply": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _record_successful_fix(self, error_type: str, fix_data: dict[str, Any]) -> None:
|
||||||
|
"""Store a successful fix pattern for future matching."""
|
||||||
|
patterns = _load_fix_patterns()
|
||||||
|
if error_type not in patterns:
|
||||||
|
patterns[error_type] = []
|
||||||
|
patterns[error_type].append({
|
||||||
|
"pattern": fix_data.get("pattern", error_type),
|
||||||
|
"fix": fix_data.get("fix_snippet", ""),
|
||||||
|
"description": fix_data.get("description", ""),
|
||||||
|
"recorded_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
_save_fix_patterns(patterns)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Check if FixAgent is operational."""
|
||||||
|
return self.project_root.exists()
|
||||||
|
|
||||||
|
async def _analyze_bug(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Analyze bug and determine root cause."""
|
||||||
|
bug_data = context.get("bug_data", {})
|
||||||
|
error_message = bug_data.get("error_message", context.get("error", "Unknown error"))
|
||||||
|
file_path = context.get("file_path")
|
||||||
|
stack_trace = bug_data.get("stack_trace", "")
|
||||||
|
|
||||||
|
analysis = {
|
||||||
|
"error_type": self._identify_error_type(error_message),
|
||||||
|
"likely_causes": self._identify_causes(error_message, stack_trace),
|
||||||
|
"severity": self._assess_severity(error_message),
|
||||||
|
"suggested_approach": self._suggest_approach(error_message),
|
||||||
|
}
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Analyzed bug: {analysis['error_type']}",
|
||||||
|
data={
|
||||||
|
"analysis": analysis,
|
||||||
|
"bug_data": bug_data,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _generate_fix(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Generate fix for identified bug."""
|
||||||
|
bug_data = context.get("bug_data", {})
|
||||||
|
file_path = context.get("file_path")
|
||||||
|
analysis = context.get("analysis", {})
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="file_path required for fix generation",
|
||||||
|
)
|
||||||
|
|
||||||
|
error_msg = bug_data.get("error_message", "")
|
||||||
|
error_type = self._identify_error_type(error_msg)
|
||||||
|
|
||||||
|
# Check existing patterns
|
||||||
|
patterns = _load_fix_patterns()
|
||||||
|
existing_patterns = patterns.get(error_type, []) + patterns.get(error_msg[:50], [])
|
||||||
|
|
||||||
|
fix_result = {
|
||||||
|
"file": file_path,
|
||||||
|
"issue": bug_data.get("description", "Unknown issue"),
|
||||||
|
"error_type": error_type,
|
||||||
|
"proposed_fix": self._generate_fix_code(bug_data, file_path),
|
||||||
|
"test_to_add": self._generate_test(bug_data),
|
||||||
|
"existing_patterns": existing_patterns[:3],
|
||||||
|
"risk_level": "low",
|
||||||
|
"breaking_changes": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Record this fix as a successful pattern
|
||||||
|
self._record_successful_fix(error_type, {
|
||||||
|
"pattern": re.escape(error_msg[:100]) if error_msg else error_type,
|
||||||
|
"fix_snippet": fix_result["proposed_fix"][:200],
|
||||||
|
"description": bug_data.get("description", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Сгенерирован фикс для {file_path} (тип: {error_type})",
|
||||||
|
data={
|
||||||
|
"fix": fix_result,
|
||||||
|
"pr_template": self._generate_pr_template(fix_result),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _validate_fix(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Validate that fix works correctly using ruff."""
|
||||||
|
fix = context.get("fix", {})
|
||||||
|
file_path = fix.get("file")
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="Fix data required for validation",
|
||||||
|
)
|
||||||
|
|
||||||
|
validations = []
|
||||||
|
|
||||||
|
# ruff check
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
r = subprocess.run(
|
||||||
|
["ruff", "check", "--no-cache", str(_get_project_root() / file_path)],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
ruff_passed = r.returncode == 0
|
||||||
|
validations.append({
|
||||||
|
"check": "ruff_lint",
|
||||||
|
"result": "passed" if ruff_passed else "failed",
|
||||||
|
"details": r.stdout.strip()[:500] if r.stdout else "No issues",
|
||||||
|
})
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e:
|
||||||
|
validations.append({
|
||||||
|
"check": "ruff_lint",
|
||||||
|
"result": "skipped",
|
||||||
|
"details": str(e),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ruff format check
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["ruff", "format", "--check", "--no-cache", str(_get_project_root() / file_path)],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
format_passed = r.returncode == 0
|
||||||
|
validations.append({
|
||||||
|
"check": "ruff_format",
|
||||||
|
"result": "passed" if format_passed else "failed",
|
||||||
|
"details": r.stdout.strip()[:500] if r.stdout else "Formatted correctly",
|
||||||
|
})
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e:
|
||||||
|
validations.append({
|
||||||
|
"check": "ruff_format",
|
||||||
|
"result": "skipped",
|
||||||
|
"details": str(e),
|
||||||
|
})
|
||||||
|
|
||||||
|
all_passed = all(v["result"] == "passed" for v in validations)
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=all_passed,
|
||||||
|
message=f"Ruff validation {'пройдена' if all_passed else 'провалена'}",
|
||||||
|
data={
|
||||||
|
"validations": validations,
|
||||||
|
"fix_status": "ready" if all_passed else "needs_work",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _suggest_fixes(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Suggest multiple fix approaches."""
|
||||||
|
bug_data = context.get("bug_data", {})
|
||||||
|
error = bug_data.get("error_message", "Unknown")
|
||||||
|
|
||||||
|
suggestions = [
|
||||||
|
{
|
||||||
|
"approach": "minimal",
|
||||||
|
"description": "Minimal change to fix specific issue",
|
||||||
|
"risk": "low",
|
||||||
|
"time_estimate": "5 minutes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"approach": "refactored",
|
||||||
|
"description": "Better solution with code improvement",
|
||||||
|
"risk": "medium",
|
||||||
|
"time_estimate": "20 minutes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"approach": "comprehensive",
|
||||||
|
"description": "Full fix with tests and documentation",
|
||||||
|
"risk": "low",
|
||||||
|
"time_estimate": "45 minutes",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Generated {len(suggestions)} fix suggestions",
|
||||||
|
data={
|
||||||
|
"suggestions": suggestions,
|
||||||
|
"recommended": "minimal" if bug_data.get("severity") == "low" else "comprehensive",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _identify_error_type(self, error: str) -> str:
|
||||||
|
"""Identify type of error."""
|
||||||
|
known_types = [
|
||||||
|
"AttributeError",
|
||||||
|
"TypeError",
|
||||||
|
"ValueError",
|
||||||
|
"KeyError",
|
||||||
|
"ImportError",
|
||||||
|
"SyntaxError",
|
||||||
|
"RuntimeError",
|
||||||
|
"IndexError",
|
||||||
|
"ZeroDivisionError",
|
||||||
|
"FileNotFoundError",
|
||||||
|
"ModuleNotFoundError",
|
||||||
|
"StopIteration",
|
||||||
|
]
|
||||||
|
|
||||||
|
for error_type in known_types:
|
||||||
|
if error_type in error:
|
||||||
|
return error_type
|
||||||
|
|
||||||
|
error_types = {
|
||||||
|
"AttributeError": r"'[^']+' object has no attribute",
|
||||||
|
"TypeError": r"'[^']+' (object|instance)",
|
||||||
|
"ValueError": r"invalid value",
|
||||||
|
"KeyError": r"KeyError: '[^']+'",
|
||||||
|
"ImportError": r"ImportError|Cannot import",
|
||||||
|
}
|
||||||
|
|
||||||
|
for error_type, pattern in error_types.items():
|
||||||
|
if re.search(pattern, error, re.IGNORECASE):
|
||||||
|
return error_type
|
||||||
|
|
||||||
|
return "UnknownError"
|
||||||
|
|
||||||
|
def _identify_causes(self, error: str, stack_trace: str) -> list[str]:
|
||||||
|
"""Identify likely causes of the error."""
|
||||||
|
causes = []
|
||||||
|
|
||||||
|
if "NoneType" in error or "NoneType" in stack_trace:
|
||||||
|
causes.append("Object is None when method is called")
|
||||||
|
|
||||||
|
if "AttributeError" in error:
|
||||||
|
causes.append("Missing attribute or wrong object type")
|
||||||
|
|
||||||
|
if "KeyError" in error:
|
||||||
|
causes.append("Missing dictionary key")
|
||||||
|
|
||||||
|
if "IndexError" in error:
|
||||||
|
causes.append("Index out of range")
|
||||||
|
|
||||||
|
if not causes:
|
||||||
|
causes.append("Requires deeper analysis of stack trace")
|
||||||
|
|
||||||
|
return causes
|
||||||
|
|
||||||
|
def _assess_severity(self, error: str) -> str:
|
||||||
|
"""Assess bug severity."""
|
||||||
|
critical_patterns = ["database", "authentication", "security", "corruption"]
|
||||||
|
high_patterns = ["crash", "hang", "infinite loop"]
|
||||||
|
|
||||||
|
if any(p in error.lower() for p in critical_patterns):
|
||||||
|
return "critical"
|
||||||
|
if any(p in error.lower() for p in high_patterns):
|
||||||
|
return "high"
|
||||||
|
|
||||||
|
return "medium"
|
||||||
|
|
||||||
|
def _suggest_approach(self, error: str) -> str:
|
||||||
|
"""Suggest approach for fixing."""
|
||||||
|
if "AttributeError" in error:
|
||||||
|
return "Add null check or use getattr with default"
|
||||||
|
if "TypeError" in error:
|
||||||
|
return "Add type validation or type casting"
|
||||||
|
if "KeyError" in error:
|
||||||
|
return "Use dict.get() with default or check key exists"
|
||||||
|
if "ImportError" in error:
|
||||||
|
return "Check import path and dependencies"
|
||||||
|
|
||||||
|
return "Review stack trace for exact location"
|
||||||
|
|
||||||
|
def _generate_fix_code(self, bug_data: dict, file_path: str) -> str:
|
||||||
|
"""Generate fix code snippet."""
|
||||||
|
return """```python
|
||||||
|
# Suggested fix for {file_path}
|
||||||
|
# Issue: {description}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Original code that failed
|
||||||
|
result = object.method()
|
||||||
|
except {error_type} as e:
|
||||||
|
# Handle the error gracefully
|
||||||
|
logger.warning(f"Error occurred: {{e}}")
|
||||||
|
result = None # or appropriate fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
Explanation: {explanation}
|
||||||
|
```""".format(
|
||||||
|
file_path=file_path,
|
||||||
|
description=bug_data.get("description", "Unknown issue"),
|
||||||
|
error_type=self._identify_error_type(bug_data.get("error_message", "")),
|
||||||
|
explanation=self._suggest_approach(bug_data.get("error_message", "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate_test(self, bug_data: dict) -> str:
|
||||||
|
"""Generate test case for the bug."""
|
||||||
|
return """```python
|
||||||
|
def test_{test_name}():
|
||||||
|
\"\"\"Test for bug fix: {description}\"\"\"
|
||||||
|
# Setup
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
result = function_under_test()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is not None
|
||||||
|
assert result == expected_value
|
||||||
|
```""".format(
|
||||||
|
test_name=bug_data.get("name", "fix").replace(" ", "_").lower(),
|
||||||
|
description=bug_data.get("description", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate_pr_template(self, fix: dict) -> str:
|
||||||
|
"""Generate PR description template."""
|
||||||
|
return """## Fix: {issue}
|
||||||
|
|
||||||
|
### Проблема
|
||||||
|
{issue}
|
||||||
|
|
||||||
|
### Причина
|
||||||
|
{cause}
|
||||||
|
|
||||||
|
### Решение
|
||||||
|
{fix_description}
|
||||||
|
|
||||||
|
### Тесты
|
||||||
|
- [ ] Добавлен тест для предотвращения
|
||||||
|
- [ ] Существующие тесты проходят
|
||||||
|
|
||||||
|
### Логи
|
||||||
|
Связанные логи из bug report
|
||||||
|
""".format(
|
||||||
|
issue=fix.get("issue", "Issue description"),
|
||||||
|
cause="Identified root cause",
|
||||||
|
fix_description=fix.get("proposed_fix", "Fix description"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_metrics(self) -> dict[str, Any]:
|
||||||
|
"""Get FixAgent metrics."""
|
||||||
|
return {
|
||||||
|
"agent_id": self.name,
|
||||||
|
"version": self.version,
|
||||||
|
"status": self.status.value,
|
||||||
|
"fixes_generated": 0,
|
||||||
|
"fixes_approved": 0,
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Agent models for database storage."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, Index, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
|
||||||
|
|
||||||
|
|
||||||
|
class AgentState(SQLBase, UUIDMixin, TimestampMixin):
|
||||||
|
"""Agent state tracking."""
|
||||||
|
|
||||||
|
__tablename__ = "agent_states"
|
||||||
|
|
||||||
|
agent_id: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="idle")
|
||||||
|
last_run: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
current_task: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
extra: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_agent_states_agent_id", "agent_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentReport(SQLBase, UUIDMixin, TimestampMixin):
|
||||||
|
"""Agent execution reports."""
|
||||||
|
|
||||||
|
__tablename__ = "agent_reports"
|
||||||
|
|
||||||
|
agent_id: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
message: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
|
details: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
duration_ms: Mapped[int] = mapped_column(default=0)
|
||||||
|
errors: Mapped[list | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
context: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
success: Mapped[bool] = mapped_column(default=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_agent_reports_agent_timestamp", "agent_id", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentMetric(SQLBase, UUIDMixin):
|
||||||
|
"""Agent performance metrics."""
|
||||||
|
|
||||||
|
__tablename__ = "agent_metrics"
|
||||||
|
|
||||||
|
agent_id: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
metric_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
value: Mapped[float] = mapped_column(default=0.0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_agent_metrics_agent_metric", "agent_id", "metric_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BacklogItem(SQLBase, UUIDMixin, TimestampMixin):
|
||||||
|
"""Backlog items for tracking ideas, tasks, plans."""
|
||||||
|
|
||||||
|
__tablename__ = "backlog_items"
|
||||||
|
|
||||||
|
item_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="medium")
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||||
|
source: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
created_by: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
parent_id: Mapped[UUID | None] = mapped_column(PGUUID, nullable=True)
|
||||||
|
tags: Mapped[list | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
block_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
extra: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_backlog_items_type_status", "item_type", "status"),
|
||||||
|
Index("ix_backlog_items_priority", "priority"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Observation(SQLBase, UUIDMixin, TimestampMixin):
|
||||||
|
"""User observations from ObserverAgent."""
|
||||||
|
|
||||||
|
__tablename__ = "observations"
|
||||||
|
|
||||||
|
observation_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
user_id: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||||
|
metric_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
metric_value: Mapped[float] = mapped_column(default=0.0)
|
||||||
|
extra: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
session_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_observations_type_user", "observation_type", "user_id"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""ObserverAgent - User behavior observation for VoIdea.
|
||||||
|
|
||||||
|
This agent:
|
||||||
|
- Collects user metrics
|
||||||
|
- Generates insights
|
||||||
|
- Monitors feature usage
|
||||||
|
- Reports anomalies
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
|
||||||
|
from app.agents.models import Observation
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class ObserverAgent(BaseAgent):
|
||||||
|
"""User behavior observation agent."""
|
||||||
|
|
||||||
|
name = "observer_agent"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Monitors user behavior and generates insights"
|
||||||
|
triggers = [
|
||||||
|
AgentTrigger.MANUAL,
|
||||||
|
AgentTrigger.CRON,
|
||||||
|
AgentTrigger.EVENT,
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession | None = None):
|
||||||
|
super().__init__()
|
||||||
|
self._session = session
|
||||||
|
self.enabled = settings.observer_enabled
|
||||||
|
self.sample_rate = settings.observer_sample_rate
|
||||||
|
|
||||||
|
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||||
|
"""Execute observation task.
|
||||||
|
|
||||||
|
Context can contain:
|
||||||
|
- action: str (collect, report, analyze, metrics)
|
||||||
|
- observation_type: str
|
||||||
|
- user_id: str
|
||||||
|
- metric_name: str
|
||||||
|
- metric_value: float
|
||||||
|
"""
|
||||||
|
await self.set_running("observation")
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = context.get("action", "metrics") if context else "metrics"
|
||||||
|
|
||||||
|
if action == "collect":
|
||||||
|
result = await self._collect_observation(context or {})
|
||||||
|
elif action == "report":
|
||||||
|
result = await self._generate_report(context or {})
|
||||||
|
elif action == "analyze":
|
||||||
|
result = await self._analyze_trends(context or {})
|
||||||
|
else:
|
||||||
|
result = await self._get_metrics(context or {})
|
||||||
|
|
||||||
|
await self.set_idle()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.set_error(str(e))
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"ObserverAgent failed: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Check if ObserverAgent is operational."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _collect_observation(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Collect a single observation."""
|
||||||
|
if not self._session:
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="Session not configured, observation skipped",
|
||||||
|
data={"skipped": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.enabled:
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="Observer disabled",
|
||||||
|
data={"enabled": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
observation_type = context.get("observation_type", "custom")
|
||||||
|
user_id = context.get("user_id")
|
||||||
|
metric_name = context.get("metric_name", "unknown")
|
||||||
|
metric_value = context.get("metric_value", 0.0)
|
||||||
|
metadata = context.get("metadata", {})
|
||||||
|
session_id = context.get("session_id")
|
||||||
|
|
||||||
|
observation = Observation(
|
||||||
|
id=uuid4(),
|
||||||
|
observation_type=observation_type,
|
||||||
|
user_id=user_id,
|
||||||
|
metric_name=metric_name,
|
||||||
|
metric_value=metric_value,
|
||||||
|
metadata=metadata,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._session.add(observation)
|
||||||
|
await self._session.commit()
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Collected observation: {metric_name}",
|
||||||
|
data={
|
||||||
|
"id": str(observation.id),
|
||||||
|
"type": observation_type,
|
||||||
|
"metric": metric_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _generate_report(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Generate daily/weekly observation report."""
|
||||||
|
if not self._session:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="Session not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
period = context.get("period", "daily")
|
||||||
|
days = 1 if period == "daily" else 7
|
||||||
|
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(
|
||||||
|
Observation.observation_type,
|
||||||
|
Observation.metric_name,
|
||||||
|
func.count(Observation.id).label("count"),
|
||||||
|
func.avg(Observation.metric_value).label("avg_value"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Observation.created_at >= datetime.now(timezone.utc)
|
||||||
|
- datetime.timedelta(days=days)
|
||||||
|
)
|
||||||
|
.group_by(
|
||||||
|
Observation.observation_type,
|
||||||
|
Observation.metric_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics = result.all()
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"period": period,
|
||||||
|
"metrics": [
|
||||||
|
{
|
||||||
|
"type": m.observation_type,
|
||||||
|
"name": m.metric_name,
|
||||||
|
"count": m.count,
|
||||||
|
"avg_value": float(m.avg_value) if m.avg_value else 0,
|
||||||
|
}
|
||||||
|
for m in metrics
|
||||||
|
],
|
||||||
|
"total_observations": sum(m.count for m in metrics),
|
||||||
|
}
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Generated {period} report",
|
||||||
|
data=report,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _analyze_trends(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Analyze trends in observations."""
|
||||||
|
if not self._session:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="Session not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
metric_name = context.get("metric_name")
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(Observation)
|
||||||
|
.where(Observation.metric_name == metric_name)
|
||||||
|
.order_by(Observation.created_at.desc())
|
||||||
|
.limit(100)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self._session.execute(query)
|
||||||
|
observations = result.scalars().all()
|
||||||
|
|
||||||
|
values = [o.metric_value for o in observations]
|
||||||
|
avg = sum(values) / len(values) if values else 0
|
||||||
|
max_val = max(values) if values else 0
|
||||||
|
min_val = min(values) if values else 0
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Analyzed {len(observations)} observations for {metric_name}",
|
||||||
|
data={
|
||||||
|
"metric_name": metric_name,
|
||||||
|
"count": len(observations),
|
||||||
|
"average": avg,
|
||||||
|
"max": max_val,
|
||||||
|
"min": min_val,
|
||||||
|
"trend": "stable",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_metrics(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Get aggregated metrics."""
|
||||||
|
if not self._session:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="Session not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(
|
||||||
|
func.count(Observation.id).label("total"),
|
||||||
|
func.count(func.distinct(Observation.user_id)).label("unique_users"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = result.one()
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="Retrieved observer metrics",
|
||||||
|
data={
|
||||||
|
"total_observations": stats.total,
|
||||||
|
"unique_users": stats.unique_users or 0,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"sample_rate": self.sample_rate,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_metrics(self) -> dict[str, Any]:
|
||||||
|
"""Get ObserverAgent metrics."""
|
||||||
|
return {
|
||||||
|
"agent_id": self.name,
|
||||||
|
"version": self.version,
|
||||||
|
"status": self.status.value,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"sample_rate": self.sample_rate,
|
||||||
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
"""QATesterAgent v2 — real functional testing with API + Playwright E2E.
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
- api: HTTP tests against backend endpoints
|
||||||
|
- e2e: Playwright headless browser tests against UI (skip if unavailable)
|
||||||
|
- full: both modes
|
||||||
|
|
||||||
|
Creates temp users, runs tests, cleans up. Reports bugs to FixAgent via LogEntry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
|
||||||
|
from app.models.log import LogEntry
|
||||||
|
from app.models.user import User
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.security import get_password_hash
|
||||||
|
|
||||||
|
logger = logging.getLogger("voidea.qa_tester")
|
||||||
|
|
||||||
|
PLAYWRIGHT_AVAILABLE = False
|
||||||
|
try:
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
PLAYWRIGHT_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
TEST_BASE_URL = os.environ.get("TEST_BASE_URL", "http://localhost:8020")
|
||||||
|
TEST_FRONTEND_URL = os.environ.get("TEST_FRONTEND_URL", "http://localhost:3000")
|
||||||
|
|
||||||
|
|
||||||
|
class QATesterAgent(BaseAgent):
|
||||||
|
"""Functional testing agent with API + E2E browser tests."""
|
||||||
|
|
||||||
|
name = "qa_tester_agent"
|
||||||
|
version = "2.0.0"
|
||||||
|
description = "API + E2E testing with temp users and auto-cleanup"
|
||||||
|
triggers = [
|
||||||
|
AgentTrigger.MANUAL,
|
||||||
|
AgentTrigger.CRON,
|
||||||
|
AgentTrigger.PRE_COMMIT,
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession | None = None):
|
||||||
|
super().__init__()
|
||||||
|
self._session = session
|
||||||
|
self._temp_users: list[dict[str, Any]] = []
|
||||||
|
self._bugs: list[dict[str, Any]] = []
|
||||||
|
self._settings = get_settings()
|
||||||
|
|
||||||
|
# ── Main ──
|
||||||
|
|
||||||
|
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||||
|
start = time.time()
|
||||||
|
await self.set_running("qa_testing")
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = (context or {}).get("action", "full")
|
||||||
|
|
||||||
|
if action == "api":
|
||||||
|
result = await self._run_api_tests()
|
||||||
|
elif action == "e2e":
|
||||||
|
result = await self._run_e2e_tests()
|
||||||
|
elif action == "vitest":
|
||||||
|
result = await self._run_vitest_tests()
|
||||||
|
elif action == "cleanup":
|
||||||
|
result = await self._cleanup_all()
|
||||||
|
elif action == "status":
|
||||||
|
result = await self._get_status()
|
||||||
|
else:
|
||||||
|
result = await self._run_full()
|
||||||
|
|
||||||
|
result.duration_ms = int((time.time() - start) * 1000)
|
||||||
|
await self.set_idle()
|
||||||
|
await self._report_bugs()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.set_error(str(e))
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"QATesterAgent failed: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Full suite ──
|
||||||
|
|
||||||
|
async def _run_full(self) -> AgentResult:
|
||||||
|
api_result = await self._run_api_tests()
|
||||||
|
e2e_result = await self._run_e2e_tests()
|
||||||
|
vitest_result = await self._run_vitest_tests()
|
||||||
|
total_bugs = self._bugs.copy()
|
||||||
|
combined_success = api_result.success or e2e_result.success or vitest_result.success
|
||||||
|
combined_msg = f"API: {api_result.message} | E2E: {e2e_result.message} | Vitest: {vitest_result.message}"
|
||||||
|
combined_data = {
|
||||||
|
"api": api_result.data,
|
||||||
|
"e2e": e2e_result.data,
|
||||||
|
"vitest": vitest_result.data,
|
||||||
|
"bugs": total_bugs,
|
||||||
|
}
|
||||||
|
await self._cleanup_all()
|
||||||
|
return AgentResult(
|
||||||
|
success=combined_success,
|
||||||
|
message=combined_msg,
|
||||||
|
data=combined_data,
|
||||||
|
errors=api_result.errors + e2e_result.errors + vitest_result.errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── API tests ──
|
||||||
|
|
||||||
|
async def _run_api_tests(self) -> AgentResult:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
temp_user = await self._create_temp_user()
|
||||||
|
if not temp_user:
|
||||||
|
return AgentResult(success=False, message="Failed to create temp user", errors=["Temp user creation failed"])
|
||||||
|
|
||||||
|
tests = []
|
||||||
|
base = TEST_BASE_URL
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(base_url=base, timeout=15.0) as client:
|
||||||
|
# 1. Health check
|
||||||
|
try:
|
||||||
|
r = await client.get("/health")
|
||||||
|
tests.append({"name": "health_check", "passed": r.status_code == 200, "detail": f"GET /health → {r.status_code}"})
|
||||||
|
except Exception as e:
|
||||||
|
tests.append({"name": "health_check", "passed": False, "detail": str(e)})
|
||||||
|
|
||||||
|
# 2. Register (same user could conflict, so check)
|
||||||
|
email = temp_user["email"]
|
||||||
|
password = temp_user["password"]
|
||||||
|
try:
|
||||||
|
r = await client.post("/api/v1/auth/register", json={"email": email, "password": password, "display_name": "Test User", "accepted_terms": True})
|
||||||
|
tests.append({"name": "register", "passed": r.status_code in (201, 409), "detail": f"POST /auth/register → {r.status_code}"})
|
||||||
|
except Exception as e:
|
||||||
|
tests.append({"name": "register", "passed": False, "detail": str(e)})
|
||||||
|
|
||||||
|
# 3. Login
|
||||||
|
access_token = None
|
||||||
|
try:
|
||||||
|
r = await client.post("/api/v1/auth/login", json={"email": email, "password": password})
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
access_token = data.get("access_token")
|
||||||
|
tests.append({"name": "login", "passed": r.status_code == 200, "detail": f"POST /auth/login → {r.status_code}"})
|
||||||
|
except Exception as e:
|
||||||
|
tests.append({"name": "login", "passed": False, "detail": str(e)})
|
||||||
|
|
||||||
|
if access_token:
|
||||||
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
|
||||||
|
# 4. Get me
|
||||||
|
try:
|
||||||
|
r = await client.get("/api/v1/users/me", headers=headers)
|
||||||
|
tests.append({"name": "get_me", "passed": r.status_code == 200, "detail": f"GET /users/me → {r.status_code}"})
|
||||||
|
except Exception as e:
|
||||||
|
tests.append({"name": "get_me", "passed": False, "detail": str(e)})
|
||||||
|
|
||||||
|
# 5. Create idea
|
||||||
|
idea_id = None
|
||||||
|
try:
|
||||||
|
r = await client.post("/api/v1/ideas", headers=headers, json={"title": "Test idea from QA", "content": "This is a test idea created by QATesterAgent"})
|
||||||
|
if r.status_code in (200, 201):
|
||||||
|
idea_data = r.json()
|
||||||
|
idea_id = idea_data.get("id")
|
||||||
|
tests.append({"name": "create_idea", "passed": r.status_code in (200, 201), "detail": f"POST /ideas → {r.status_code}"})
|
||||||
|
except Exception as e:
|
||||||
|
tests.append({"name": "create_idea", "passed": False, "detail": str(e)})
|
||||||
|
|
||||||
|
# 6. List ideas
|
||||||
|
try:
|
||||||
|
r = await client.get("/api/v1/ideas", headers=headers)
|
||||||
|
tests.append({"name": "list_ideas", "passed": r.status_code == 200, "detail": f"GET /ideas → {r.status_code}"})
|
||||||
|
except Exception as e:
|
||||||
|
tests.append({"name": "list_ideas", "passed": False, "detail": str(e)})
|
||||||
|
|
||||||
|
# 7. Delete test idea
|
||||||
|
if idea_id:
|
||||||
|
try:
|
||||||
|
r = await client.delete(f"/api/v1/ideas/{idea_id}", headers=headers)
|
||||||
|
tests.append({"name": "delete_idea", "passed": r.status_code in (204, 200), "detail": f"DELETE /ideas/{idea_id} → {r.status_code}"})
|
||||||
|
except Exception as e:
|
||||||
|
tests.append({"name": "delete_idea", "passed": False, "detail": str(e)})
|
||||||
|
|
||||||
|
# 8. Voice settings
|
||||||
|
try:
|
||||||
|
r = await client.get("/api/v1/users/me/voice-settings", headers=headers)
|
||||||
|
tests.append({"name": "voice_settings", "passed": r.status_code == 200, "detail": f"GET /voice-settings → {r.status_code}"})
|
||||||
|
except Exception as e:
|
||||||
|
tests.append({"name": "voice_settings", "passed": False, "detail": str(e)})
|
||||||
|
|
||||||
|
# 9. Public config
|
||||||
|
try:
|
||||||
|
r = await client.get("/api/v1/config/public")
|
||||||
|
tests.append({"name": "public_config", "passed": r.status_code == 200, "detail": f"GET /config/public → {r.status_code}"})
|
||||||
|
except Exception as e:
|
||||||
|
tests.append({"name": "public_config", "passed": False, "detail": str(e)})
|
||||||
|
|
||||||
|
passed = sum(1 for t in tests if t["passed"])
|
||||||
|
failed = [t for t in tests if not t["passed"]]
|
||||||
|
if failed:
|
||||||
|
self._bugs.extend({
|
||||||
|
"test": t["name"],
|
||||||
|
"detail": t["detail"],
|
||||||
|
"source": "api",
|
||||||
|
"severity": "medium",
|
||||||
|
} for t in failed)
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=len(failed) == 0,
|
||||||
|
message=f"API tests: {passed}/{len(tests)} passed",
|
||||||
|
data={"total": len(tests), "passed": passed, "failed": len(failed), "tests": tests, "bugs": self._bugs},
|
||||||
|
errors=[f"{t['name']}: {t['detail']}" for t in failed],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Vitest (frontend unit tests) ──
|
||||||
|
|
||||||
|
async def _run_vitest_tests(self) -> AgentResult:
|
||||||
|
webui_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "webui")
|
||||||
|
if not os.path.isdir(os.path.join(webui_dir, "node_modules")):
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="Vitest пропущен: node_modules не найдены",
|
||||||
|
data={"skipped": True, "reason": "node_modules not found"},
|
||||||
|
)
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"npx", "vitest", "run", "--reporter=json",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
cwd=webui_dir,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
output = stdout.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
logger.warning("Vitest exited with code %d: %s", proc.returncode, stderr.decode()[:200])
|
||||||
|
|
||||||
|
# Vitest JSON output starts after potential Vite banner
|
||||||
|
json_start = output.find("{")
|
||||||
|
if json_start == -1:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="Vitest: JSON output not found",
|
||||||
|
errors=[output[:500]],
|
||||||
|
)
|
||||||
|
|
||||||
|
import json as json_mod
|
||||||
|
data = json_mod.loads(output[json_start:])
|
||||||
|
total = data.get("total", 0)
|
||||||
|
passed = sum(1 for f in data.get("files", []) if f.get("result") == "passed")
|
||||||
|
failed_files = [f for f in data.get("files", []) if f.get("result") != "passed"]
|
||||||
|
|
||||||
|
if failed_files:
|
||||||
|
for ff in failed_files:
|
||||||
|
filepath = ff.get("filepath", ff.get("name", "unknown"))
|
||||||
|
self._bugs.append({
|
||||||
|
"test": filepath,
|
||||||
|
"detail": f"Vitest failed: {json_mod.dumps(ff.get('failureMessage', 'unknown'))}",
|
||||||
|
"source": "vitest",
|
||||||
|
"severity": "high",
|
||||||
|
})
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=len(failed_files) == 0,
|
||||||
|
message=f"Vitest: {passed}/{total} passed",
|
||||||
|
data={"total": total, "passed": passed, "failed": len(failed_files), "files": data.get("files", [])},
|
||||||
|
errors=[f"{f.get('filepath', '?')} failed" for f in failed_files],
|
||||||
|
)
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="Vitest пропущен: npx не найден",
|
||||||
|
data={"skipped": True, "reason": "npx not found"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── E2E tests (Playwright) ──
|
||||||
|
|
||||||
|
async def _run_e2e_tests(self) -> AgentResult:
|
||||||
|
if not PLAYWRIGHT_AVAILABLE:
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="Playwright не установлен, E2E тесты пропущены",
|
||||||
|
data={"skipped": True, "reason": "playwright not installed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
tests = []
|
||||||
|
frontend_url = TEST_FRONTEND_URL
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.chromium.launch(headless=True, args=["--no-sandbox"])
|
||||||
|
page = await browser.new_page()
|
||||||
|
|
||||||
|
# 1. Landing page loads
|
||||||
|
try:
|
||||||
|
await page.goto(frontend_url, wait_until="networkidle", timeout=30000)
|
||||||
|
title = await page.title()
|
||||||
|
tests.append({"name": "landing_loads", "passed": bool(title), "detail": f"Title: {title[:50]}"})
|
||||||
|
except Exception as e:
|
||||||
|
tests.append({"name": "landing_loads", "passed": False, "detail": str(e)})
|
||||||
|
|
||||||
|
# 2. Navigate to /login
|
||||||
|
try:
|
||||||
|
await page.goto(f"{frontend_url}/login", wait_until="networkidle", timeout=15000)
|
||||||
|
has_form = await page.query_selector('input[type="email"], input[name="email"]') is not None
|
||||||
|
tests.append({"name": "login_page", "passed": has_form, "detail": "Login form found" if has_form else "No email input"})
|
||||||
|
except Exception as e:
|
||||||
|
tests.append({"name": "login_page", "passed": False, "detail": str(e)})
|
||||||
|
|
||||||
|
# 3. Navigate to /register
|
||||||
|
try:
|
||||||
|
await page.goto(f"{frontend_url}/register", wait_until="networkidle", timeout=15000)
|
||||||
|
has_register_form = await page.query_selector('input[type="password"]') is not None
|
||||||
|
tests.append({"name": "register_page", "passed": has_register_form, "detail": "Register form found" if has_register_form else "No password input"})
|
||||||
|
except Exception as e:
|
||||||
|
tests.append({"name": "register_page", "passed": False, "detail": str(e)})
|
||||||
|
|
||||||
|
# 4. Dashboard (may redirect to login)
|
||||||
|
try:
|
||||||
|
await page.goto(f"{frontend_url}/dashboard", wait_until="networkidle", timeout=15000)
|
||||||
|
tests.append({"name": "dashboard_redirect", "passed": True, "detail": f"URL: {page.url[:60]}"})
|
||||||
|
except Exception as e:
|
||||||
|
tests.append({"name": "dashboard_redirect", "passed": False, "detail": str(e)})
|
||||||
|
|
||||||
|
# 5. Check dark mode toggle exists
|
||||||
|
try:
|
||||||
|
await page.goto(f"{frontend_url}/settings", wait_until="networkidle", timeout=15000)
|
||||||
|
tests.append({"name": "settings_page", "passed": True, "detail": f"Settings loaded: {page.url[:60]}"})
|
||||||
|
except Exception as e:
|
||||||
|
tests.append({"name": "settings_page", "passed": False, "detail": str(e)})
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"E2E tests failed: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
passed = sum(1 for t in tests if t["passed"])
|
||||||
|
failed = [t for t in tests if not t["passed"]]
|
||||||
|
if failed:
|
||||||
|
self._bugs.extend({
|
||||||
|
"test": t["name"],
|
||||||
|
"detail": t["detail"],
|
||||||
|
"source": "e2e",
|
||||||
|
"severity": "high",
|
||||||
|
} for t in failed)
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=len(failed) == 0,
|
||||||
|
message=f"E2E tests: {passed}/{len(tests)} passed",
|
||||||
|
data={"total": len(tests), "passed": passed, "failed": len(failed), "tests": tests, "bugs": self._bugs},
|
||||||
|
errors=[f"{t['name']}: {t['detail']}" for t in failed],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Temp user management ──
|
||||||
|
|
||||||
|
async def _create_temp_user(self) -> dict[str, Any] | None:
|
||||||
|
if not self._session:
|
||||||
|
return {"email": "test@voidea.test", "password": "test123", "simulated": True}
|
||||||
|
|
||||||
|
temp_id = str(uuid4())[:8]
|
||||||
|
email = f"qa_test_{temp_id}@voidea.test"
|
||||||
|
password = f"qa_pass_{temp_id}"
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
id=uuid4(),
|
||||||
|
email=email,
|
||||||
|
password_hash=get_password_hash(password),
|
||||||
|
is_active=True,
|
||||||
|
role="user",
|
||||||
|
display_name=f"QA Test {temp_id}",
|
||||||
|
)
|
||||||
|
self._session.add(user)
|
||||||
|
await self._session.commit()
|
||||||
|
|
||||||
|
entry = {"id": str(user.id), "email": email, "password": password}
|
||||||
|
self._temp_users.append(entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
async def _cleanup_all(self) -> AgentResult:
|
||||||
|
if not self._session or not self._temp_users:
|
||||||
|
return AgentResult(success=True, message="No cleanup needed", data={"cleaned": 0})
|
||||||
|
|
||||||
|
cleaned = 0
|
||||||
|
for entry in self._temp_users:
|
||||||
|
try:
|
||||||
|
stmt = delete(User).where(User.id == entry.get("id"))
|
||||||
|
await self._session.execute(stmt)
|
||||||
|
cleaned += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Cleanup failed for %s: %s", entry.get("email"), e)
|
||||||
|
|
||||||
|
await self._session.commit()
|
||||||
|
self._temp_users = []
|
||||||
|
return AgentResult(success=True, message=f"Cleaned up {cleaned} temp users", data={"cleaned": cleaned})
|
||||||
|
|
||||||
|
async def _report_bugs(self):
|
||||||
|
"""Write bugs as LogEntries for FixAgent."""
|
||||||
|
if not self._bugs or not self._session:
|
||||||
|
return
|
||||||
|
for bug in self._bugs:
|
||||||
|
log = LogEntry(
|
||||||
|
level="ERROR" if bug.get("severity") == "high" else "WARNING",
|
||||||
|
source="qa_tester_agent",
|
||||||
|
message=f"Bug: {bug['test']} — {bug['detail']}",
|
||||||
|
details=json.dumps(bug, ensure_ascii=False),
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
self._session.add(log)
|
||||||
|
try:
|
||||||
|
await self._session.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _get_status(self) -> AgentResult:
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="QATesterAgent v2 status",
|
||||||
|
data={
|
||||||
|
"temp_users": len(self._temp_users),
|
||||||
|
"bugs_found": len(self._bugs),
|
||||||
|
"playwright_available": PLAYWRIGHT_AVAILABLE,
|
||||||
|
"api_base_url": TEST_BASE_URL,
|
||||||
|
"frontend_url": TEST_FRONTEND_URL,
|
||||||
|
"status": self.status.value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_metrics(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"agent_id": self.name,
|
||||||
|
"version": self.version,
|
||||||
|
"status": self.status.value,
|
||||||
|
"temp_users_current": len(self._temp_users),
|
||||||
|
"bugs_found": len(self._bugs),
|
||||||
|
"playwright_available": PLAYWRIGHT_AVAILABLE,
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.base import AgentResult, AgentStatus, BaseAgent
|
||||||
|
|
||||||
|
from app.agents.doc_agent import DocAgent
|
||||||
|
from app.agents.backlog_agent import BacklogAgent
|
||||||
|
from app.agents.spec_agent import SpecAgent
|
||||||
|
from app.agents.audit_agent import AuditAgent
|
||||||
|
from app.agents.observer_agent import ObserverAgent
|
||||||
|
from app.agents.evolution_agent import EvolutionAgent
|
||||||
|
from app.agents.security_agent import SecurityAgent
|
||||||
|
from app.agents.qa_tester_agent import QATesterAgent
|
||||||
|
from app.agents.fix_agent import FixAgent
|
||||||
|
from app.agents.ui_test_agent import UITestAgent
|
||||||
|
from app.agents.rollout_agent import RolloutAgent
|
||||||
|
from app.agents.conductor_agent import ConductorAgent
|
||||||
|
from app.agents.supervisor_agent import SupervisorAgent
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRegistry:
|
||||||
|
def __init__(self):
|
||||||
|
self._agents: dict[str, BaseAgent] = {}
|
||||||
|
self._initialize_agents()
|
||||||
|
|
||||||
|
def _initialize_agents(self) -> None:
|
||||||
|
self.register(DocAgent())
|
||||||
|
self.register(BacklogAgent())
|
||||||
|
self.register(SpecAgent())
|
||||||
|
self.register(AuditAgent())
|
||||||
|
self.register(ObserverAgent())
|
||||||
|
self.register(EvolutionAgent())
|
||||||
|
self.register(SecurityAgent())
|
||||||
|
self.register(QATesterAgent())
|
||||||
|
self.register(FixAgent())
|
||||||
|
self.register(UITestAgent())
|
||||||
|
self.register(RolloutAgent())
|
||||||
|
self.register(ConductorAgent())
|
||||||
|
self.register(SupervisorAgent())
|
||||||
|
|
||||||
|
def register(self, agent: BaseAgent) -> None:
|
||||||
|
if not agent.name:
|
||||||
|
raise ValueError("Agent must have a name")
|
||||||
|
self._agents[agent.name] = agent
|
||||||
|
|
||||||
|
def get(self, name: str) -> BaseAgent | None:
|
||||||
|
return self._agents.get(name)
|
||||||
|
|
||||||
|
def list_agents(self) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": agent.name,
|
||||||
|
"version": agent.version,
|
||||||
|
"description": agent.description,
|
||||||
|
"status": agent.status.value,
|
||||||
|
"last_run": agent.last_run.isoformat() if agent.last_run else None,
|
||||||
|
}
|
||||||
|
for agent in self._agents.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
async def run_agent(self, name: str, context: dict[str, Any] | None = None) -> AgentResult:
|
||||||
|
agent = self.get(name)
|
||||||
|
if not agent:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Agent not found: {name}",
|
||||||
|
)
|
||||||
|
return await agent.run(context)
|
||||||
|
|
||||||
|
async def run_all(self, context: dict[str, Any] | None = None) -> dict[str, AgentResult]:
|
||||||
|
results = {}
|
||||||
|
for name, agent in self._agents.items():
|
||||||
|
try:
|
||||||
|
results[name] = await agent.run(context)
|
||||||
|
except Exception as e:
|
||||||
|
results[name] = AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Agent failed: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def health_check_all(self) -> dict[str, bool]:
|
||||||
|
results = {}
|
||||||
|
for name, agent in self._agents.items():
|
||||||
|
try:
|
||||||
|
results[name] = await agent.health_check()
|
||||||
|
except Exception:
|
||||||
|
results[name] = False
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_metrics_all(self) -> dict[str, dict[str, Any]]:
|
||||||
|
results = {}
|
||||||
|
for name, agent in self._agents.items():
|
||||||
|
results[name] = {
|
||||||
|
"status": agent.status.value,
|
||||||
|
"version": agent.version,
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
registry = AgentRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent(name: str) -> BaseAgent | None:
|
||||||
|
return registry.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_agents() -> list[dict[str, Any]]:
|
||||||
|
return registry.list_agents()
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""Role agents for Дирижёр — specialized AI personas."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.services.llm_service import chat_completion
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoleAgent:
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
system_prompt: str
|
||||||
|
|
||||||
|
|
||||||
|
# ─── из таблицы пользователя ───────────────────────────────────
|
||||||
|
|
||||||
|
BUSINESS_ANALYST = RoleAgent(
|
||||||
|
name="Бизнес-аналитик",
|
||||||
|
description="Оценивает идею с точки зрения бизнес-показателей: ROI, срок окупаемости, ЦА, конкуренты",
|
||||||
|
system_prompt=(
|
||||||
|
"Ты — Бизнес-аналитик. Твоя задача — оценить идею пользователя "
|
||||||
|
"с точки зрения бизнес-показателей.\n\n"
|
||||||
|
"Дай оценку по критериям:\n"
|
||||||
|
"- ROI (%) — примерная доходность инвестиций\n"
|
||||||
|
"- Срок окупаемости (месяцы)\n"
|
||||||
|
"- Целевая аудитория (тыс. чел.)\n"
|
||||||
|
"- Конкурентные преимущества\n\n"
|
||||||
|
"Кратко обоснуй каждый пункт. Ответь на русском языке, "
|
||||||
|
"не более 3-4 абзацев."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
TASK_ORGANIZER = RoleAgent(
|
||||||
|
name="Организатор задач",
|
||||||
|
description="Разбивает идею на шаги, выстраивает план реализации",
|
||||||
|
system_prompt=(
|
||||||
|
"Ты — Организатор задач. Разбей идею пользователя на 5-7 "
|
||||||
|
"последовательных шагов реализации.\n\n"
|
||||||
|
"Для каждого шага укажи:\n"
|
||||||
|
"- Название шага\n"
|
||||||
|
"- Срок (часы/дни)\n"
|
||||||
|
"- Ответственного (если применимо)\n\n"
|
||||||
|
"Расположи шаги в хронологическом порядке. "
|
||||||
|
"Ответь на русском языке."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
LAWYER = RoleAgent(
|
||||||
|
name="Юрист",
|
||||||
|
description="Проверяет идею на соответствие законам РФ, выявляет правовые риски",
|
||||||
|
system_prompt=(
|
||||||
|
"Ты — Юрист. Проанализируй идею пользователя на соответствие "
|
||||||
|
"законодательству РФ.\n\n"
|
||||||
|
"Обрати внимание на:\n"
|
||||||
|
"- 44-ФЗ, 152-ФЗ, 223-ФЗ и другие применимые законы\n"
|
||||||
|
"- Потенциальные правовые риски\n"
|
||||||
|
"- Способы минимизации рисков\n\n"
|
||||||
|
"Если в идее нет явных юридических аспектов, укажи на типовые "
|
||||||
|
"риски для подобных проектов. Ответь на русском языке."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
FINANCIAL_CONSULTANT = RoleAgent(
|
||||||
|
name="Финансовый консультант",
|
||||||
|
description="Рассчитывает бюджет, прогнозирует доходы, точку безубыточности",
|
||||||
|
system_prompt=(
|
||||||
|
"Ты — Финансовый консультант. Составь смету реализации идеи "
|
||||||
|
"пользователя.\n\n"
|
||||||
|
"Включи:\n"
|
||||||
|
"- Разработка (часы x ставка)\n"
|
||||||
|
"- Маркетинг (бюджет на запуск)\n"
|
||||||
|
"- Поддержка (ежемесячные расходы)\n"
|
||||||
|
"- Прогноз дохода за первый год (помесячно)\n"
|
||||||
|
"- Точка безубыточности (месяц)\n\n"
|
||||||
|
"Используй реалистичные цифры. Если данных недостаточно — "
|
||||||
|
"укажи свои допущения. Ответь на русском языке."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
SOLUTION_ARCHITECT = RoleAgent(
|
||||||
|
name="Архитектор решений",
|
||||||
|
description="Проектирует архитектуру системы: 2 варианта, технологии, стек",
|
||||||
|
system_prompt=(
|
||||||
|
"Ты — Архитектор решений. Предложи 2 варианта архитектуры "
|
||||||
|
"для реализации идеи пользователя.\n\n"
|
||||||
|
"Вариант A — монолит, вариант B — микросервисы (если применимо).\n\n"
|
||||||
|
"Для каждого укажи:\n"
|
||||||
|
"- Технологии (БД, бэкенд, фронтенд)\n"
|
||||||
|
"- Сложность реализации (низкая/средняя/высокая)\n"
|
||||||
|
"- Масштабируемость\n\n"
|
||||||
|
"Дай рекомендацию, какой вариант выбрать на старте. "
|
||||||
|
"Ответь на русском языке."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
TESTER = RoleAgent(
|
||||||
|
name="Тестировщик",
|
||||||
|
description="Составляет сценарии тестирования: позитивные, негативные, инструменты",
|
||||||
|
system_prompt=(
|
||||||
|
"Ты — Тестировщик. Составь 5-10 тест-кейсов для проверки идеи "
|
||||||
|
"пользователя.\n\n"
|
||||||
|
"Для каждого кейса укажи:\n"
|
||||||
|
"- Название\n"
|
||||||
|
"- Шаги воспроизведения\n"
|
||||||
|
"- Ожидаемый результат\n\n"
|
||||||
|
"Включи как позитивные, так и негативные сценарии. "
|
||||||
|
"Предложи инструменты для автоматизации. Ответь на русском языке."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
UI_DESIGNER = RoleAgent(
|
||||||
|
name="UI-дизайнер",
|
||||||
|
description="Прорабатывает внешний вид интерфейса: 2 варианта, цвета, шрифты, UX",
|
||||||
|
system_prompt=(
|
||||||
|
"Ты — UI-дизайнер. Предложи 2 варианта дизайна главного экрана "
|
||||||
|
"для идеи пользователя.\n\n"
|
||||||
|
"Для каждого варианта опиши:\n"
|
||||||
|
"- Цветовую схему (основной, акцентный, фоновый цвета)\n"
|
||||||
|
"- Шрифты\n"
|
||||||
|
"- Расположение ключевых элементов\n"
|
||||||
|
"- Обоснование с точки зрения UX\n\n"
|
||||||
|
"Ответь на русском языке."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
SMM_SPECIALIST = RoleAgent(
|
||||||
|
name="SMM-специалист",
|
||||||
|
description="Планирует продвижение в соцсетях: контент-план, платформы, хештеги",
|
||||||
|
system_prompt=(
|
||||||
|
"Ты — SMM-специалист. Составь контент-план на месяц для "
|
||||||
|
"продвижения идеи пользователя.\n\n"
|
||||||
|
"Укажи:\n"
|
||||||
|
"- Платформы (ВК, Telegram, Яндекс.Дзен и т.п.)\n"
|
||||||
|
"- Форматы постов (статьи, видео, опросы)\n"
|
||||||
|
"- Хештеги (5-10)\n"
|
||||||
|
"- Частоту публикаций\n"
|
||||||
|
"- Примеры 3-4 постов\n\n"
|
||||||
|
"Ответь на русском языке."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
LIFE_COACH = RoleAgent(
|
||||||
|
name="Лайф-коуч",
|
||||||
|
description="Помогает ставить личные цели по SMART, разбивает на этапы",
|
||||||
|
system_prompt=(
|
||||||
|
"Ты — Лайф-коуч. Помоги пользователю сформулировать цель "
|
||||||
|
"на основе его идеи по методике SMART.\n\n"
|
||||||
|
"Разбей на квартальные этапы:\n"
|
||||||
|
"- Q1: что сделать за первые 3 месяца\n"
|
||||||
|
"- Q2: следующий этап\n"
|
||||||
|
"- Q3: масштабирование\n"
|
||||||
|
"- Q4: результат\n\n"
|
||||||
|
"Предложи 3 метрики для отслеживания прогресса. "
|
||||||
|
"Будь поддерживающим и мотивирующим. Ответь на русском языке."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ACCESSIBILITY_EXPERT = RoleAgent(
|
||||||
|
name="Эксперт по доступности",
|
||||||
|
description="Проверяет идею на инклюзивность, соответствие WCAG 2.1",
|
||||||
|
system_prompt=(
|
||||||
|
"Ты — Эксперт по доступности. Проанализируй идею пользователя "
|
||||||
|
"с точки зрения инклюзивности и доступности для людей с ОВЗ.\n\n"
|
||||||
|
"Обрати внимание на:\n"
|
||||||
|
"- Слабовидящие: контрастность, поддержка экранных читалок\n"
|
||||||
|
"- Глухие и слабослышащие: субтитры, визуальные подсказки\n"
|
||||||
|
"- Моторные нарушения: крупные кнопки, голосовое управление\n"
|
||||||
|
"- Когнитивные особенности: простой язык, понятная навигация\n\n"
|
||||||
|
"Предложи доработки для соответствия WCAG 2.1 (уровень AA). "
|
||||||
|
"Ответь на русском языке."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── из предложенных (одобрены) ───────────────────────────────
|
||||||
|
|
||||||
|
CRITIC = RoleAgent(
|
||||||
|
name="Критик",
|
||||||
|
description="Конструктивный разбор: что не учтено, подводные камни, улучшения",
|
||||||
|
system_prompt=(
|
||||||
|
"Ты — Критик. Твоя задача — не обесценить идею и не задеть автора, "
|
||||||
|
"а помочь предусмотреть всё, чтобы идея получилась.\n\n"
|
||||||
|
"Посмотри на идею со стороны опытного наставника:\n"
|
||||||
|
"- Какие аспекты НЕ учтены?\n"
|
||||||
|
"- Какие подводные камни могут возникнуть на каждом этапе?\n"
|
||||||
|
"- Что можно улучшить, чтобы повысить шансы на успех?\n"
|
||||||
|
"- Какие альтернативы стоит рассмотреть?\n\n"
|
||||||
|
"Тон — доброжелательный коллега, который искренне хочет помочь "
|
||||||
|
"довести идею до ума. Никакого сарказма, унижений или обесценивания.\n\n"
|
||||||
|
"Ответь на русском языке, 3-4 абзаца, структурированно."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
COPYWRITER = RoleAgent(
|
||||||
|
name="Копирайтер",
|
||||||
|
description="Упаковывает идею в красивый, продающий текст",
|
||||||
|
system_prompt=(
|
||||||
|
"Ты — Копирайтер. Упакуй идею пользователя в яркий, "
|
||||||
|
"запоминающийся текст.\n\n"
|
||||||
|
"Используй:\n"
|
||||||
|
"- Заголовки\n"
|
||||||
|
"- Метафоры\n"
|
||||||
|
"- Сторителлинг\n\n"
|
||||||
|
"Сделай так, чтобы идея звучала убедительно для инвесторов, "
|
||||||
|
"команды или клиентов. Ответь на русском языке."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
KEEPER = RoleAgent(
|
||||||
|
name="Хранитель",
|
||||||
|
description="Сохраняет идею в базу данных со всеми деталями",
|
||||||
|
system_prompt=(
|
||||||
|
"Ты — Хранитель. Помоги пользователю оформить идею для сохранения.\n\n"
|
||||||
|
"Сформулируй:\n"
|
||||||
|
"- Название (до 255 символов)\n"
|
||||||
|
"- Описание (подробно, 3-5 предложений)\n"
|
||||||
|
"- Теги (через запятую, 3-5 штук)\n\n"
|
||||||
|
"Ответ дай строго в формате:\n"
|
||||||
|
"Название: ...\n"
|
||||||
|
"Описание: ...\n"
|
||||||
|
"Теги: ..."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Agent chaining ────────────────────────────────────────────
|
||||||
|
# When a primary agent is selected, chain additional agents for depth
|
||||||
|
# Max 3 agents total per dialog (primary + up to 2 chain agents)
|
||||||
|
|
||||||
|
ROLE_CHAINS: dict[str, list[str]] = {
|
||||||
|
"Критик": ["Копирайтер"],
|
||||||
|
"Бизнес-аналитик": ["Финансовый консультант"],
|
||||||
|
"Архитектор решений": ["Тестировщик"],
|
||||||
|
}
|
||||||
|
|
||||||
|
MAX_CHAIN_DEPTH = 3
|
||||||
|
|
||||||
|
|
||||||
|
async def run_role_chain(
|
||||||
|
primary_agent: RoleAgent,
|
||||||
|
user_input: str,
|
||||||
|
context: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Run a role agent chain: primary + up to 2 chain agents.
|
||||||
|
|
||||||
|
Returns list of {agent_name, response, description} dicts.
|
||||||
|
"""
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
chain_names = ROLE_CHAINS.get(primary_agent.name, [])
|
||||||
|
chain_agents: list[RoleAgent] = []
|
||||||
|
for name in chain_names[:MAX_CHAIN_DEPTH - 1]:
|
||||||
|
agent = next((a for a in ALL_ROLE_AGENTS if a.name == name), None)
|
||||||
|
if agent:
|
||||||
|
chain_agents.append(agent)
|
||||||
|
|
||||||
|
# Run primary agent
|
||||||
|
primary_response = await run_role_agent(primary_agent, user_input, context)
|
||||||
|
results.append({
|
||||||
|
"agent_name": primary_agent.name,
|
||||||
|
"response": primary_response or "",
|
||||||
|
"description": primary_agent.description,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Run chain agents with primary's response as context
|
||||||
|
for chain_agent in chain_agents:
|
||||||
|
chain_ctx = f"{context or ''}\n\nОтвет предыдущего агента ({primary_agent.name}):\n{primary_response}"
|
||||||
|
chain_response = await run_role_agent(chain_agent, user_input, chain_ctx)
|
||||||
|
results.append({
|
||||||
|
"agent_name": chain_agent.name,
|
||||||
|
"response": chain_response or "",
|
||||||
|
"description": chain_agent.description,
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
ALL_ROLE_AGENTS = [
|
||||||
|
BUSINESS_ANALYST,
|
||||||
|
TASK_ORGANIZER,
|
||||||
|
LAWYER,
|
||||||
|
FINANCIAL_CONSULTANT,
|
||||||
|
SOLUTION_ARCHITECT,
|
||||||
|
TESTER,
|
||||||
|
UI_DESIGNER,
|
||||||
|
SMM_SPECIALIST,
|
||||||
|
LIFE_COACH,
|
||||||
|
ACCESSIBILITY_EXPERT,
|
||||||
|
CRITIC,
|
||||||
|
COPYWRITER,
|
||||||
|
KEEPER,
|
||||||
|
]
|
||||||
|
|
||||||
|
VERIFICATION_PROMPT = (
|
||||||
|
"Ты — верификатор ответов ИИ. Проверь ответ ролевого агента на запрос пользователя.\n\n"
|
||||||
|
"Критерии проверки:\n"
|
||||||
|
"1. Галлюцинации — есть ли в ответе факты, которые выглядят выдуманными?\n"
|
||||||
|
"2. Противоречия — не противоречит ли ответ сам себе?\n"
|
||||||
|
"3. Логические ошибки — есть ли нестыковки в логике?\n"
|
||||||
|
"4. Пропущенные детали — упущены ли важные аспекты запроса?\n\n"
|
||||||
|
"Ответь строго в формате JSON, ничего кроме JSON:\n"
|
||||||
|
'{"status": "verified"|"issues_found", '
|
||||||
|
'"confidence": 0-100, '
|
||||||
|
'"issues": ["...", "..."], '
|
||||||
|
'"corrected": "исправленная версия (только если issues_found, иначе пустая строка)"}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_role_agent(
|
||||||
|
agent: RoleAgent,
|
||||||
|
user_input: str,
|
||||||
|
context: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
messages = [{"role": "system", "content": agent.system_prompt}]
|
||||||
|
if context:
|
||||||
|
messages.append({"role": "system", "content": f"Контекст предыдущих обсуждений:\n{context}"})
|
||||||
|
messages.append({"role": "user", "content": user_input})
|
||||||
|
return await chat_completion(messages, temperature=0.7, max_tokens=1536)
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""RolloutAgent - Gradual deployment agent for VoIdea.
|
||||||
|
|
||||||
|
This agent:
|
||||||
|
- Manages staged rollout (3 -> 1% -> 5% -> 15% -> 100%)
|
||||||
|
- Monitors metrics during rollout
|
||||||
|
- Decides on promotion or rollback
|
||||||
|
- Reports to humans for critical decisions
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class RolloutAgent(BaseAgent):
|
||||||
|
"""Gradual deployment and rollout management agent."""
|
||||||
|
|
||||||
|
name = "rollout_agent"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Manages staged rollout with monitoring and rollback"
|
||||||
|
triggers = [
|
||||||
|
AgentTrigger.MANUAL,
|
||||||
|
AgentTrigger.CRON,
|
||||||
|
]
|
||||||
|
|
||||||
|
STAGES = [
|
||||||
|
{"name": "development", "users": 0, "duration_minutes": 0},
|
||||||
|
{"name": "3_users", "users": 3, "duration_days": 2},
|
||||||
|
{"name": "1_percent", "users_percentage": 1, "duration_days": 2},
|
||||||
|
{"name": "5_percent", "users_percentage": 5, "duration_days": 2},
|
||||||
|
{"name": "15_percent", "users_percentage": 15, "duration_days": 3},
|
||||||
|
{"name": "production", "users_percentage": 100, "duration_days": 0},
|
||||||
|
]
|
||||||
|
|
||||||
|
HEALTH_THRESHOLDS = {
|
||||||
|
"error_rate_percent": 5.0,
|
||||||
|
"response_time_ms": 500,
|
||||||
|
"user_satisfaction": 0.7,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._current_stage = 0
|
||||||
|
self._stage_start_time: datetime | None = None
|
||||||
|
self._rollback_history = []
|
||||||
|
|
||||||
|
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||||
|
"""Execute rollout task.
|
||||||
|
|
||||||
|
Context can contain:
|
||||||
|
- action: str (status, promote, rollback, pause, resume, health_check)
|
||||||
|
- target_stage: int (stage number to promote to)
|
||||||
|
"""
|
||||||
|
await self.set_running("rollout_management")
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = context.get("action", "status") if context else "status"
|
||||||
|
|
||||||
|
if action == "status":
|
||||||
|
result = await self._get_status()
|
||||||
|
elif action == "promote":
|
||||||
|
result = await self._promote_to_next_stage()
|
||||||
|
elif action == "rollback":
|
||||||
|
result = await self._rollback(context.get("target_stage"))
|
||||||
|
elif action == "pause":
|
||||||
|
result = await self._pause_rollout()
|
||||||
|
elif action == "resume":
|
||||||
|
result = await self._resume_rollout()
|
||||||
|
elif action == "health_check":
|
||||||
|
result = await self._check_stage_health()
|
||||||
|
else:
|
||||||
|
result = await self._get_status()
|
||||||
|
|
||||||
|
await self.set_idle()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.set_error(str(e))
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"RolloutAgent failed: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Check if RolloutAgent is operational."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _get_status(self) -> AgentResult:
|
||||||
|
"""Get current rollout status."""
|
||||||
|
stage_info = self.STAGES[self._current_stage]
|
||||||
|
stage_duration = None
|
||||||
|
|
||||||
|
if self._stage_start_time:
|
||||||
|
elapsed = datetime.now(timezone.utc) - self._stage_start_time
|
||||||
|
stage_duration = elapsed.total_seconds() / 60
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Current stage: {stage_info['name']}",
|
||||||
|
data={
|
||||||
|
"current_stage": self._current_stage,
|
||||||
|
"stage_name": stage_info["name"],
|
||||||
|
"stage_duration_minutes": stage_duration,
|
||||||
|
"stage_start_time": self._stage_start_time.isoformat() if self._stage_start_time else None,
|
||||||
|
"total_stages": len(self.STAGES),
|
||||||
|
"rollback_history": self._rollback_history,
|
||||||
|
"thresholds": self.HEALTH_THRESHOLDS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _promote_to_next_stage(self) -> AgentResult:
|
||||||
|
"""Promote to next rollout stage."""
|
||||||
|
if self._current_stage >= len(self.STAGES) - 1:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="Already at final stage (production)",
|
||||||
|
)
|
||||||
|
|
||||||
|
health_result = await self._check_stage_health()
|
||||||
|
if not health_result.success:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Health check failed, cannot promote. Issues: {health_result.message}",
|
||||||
|
data=health_result.data,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._current_stage += 1
|
||||||
|
self._stage_start_time = datetime.now(timezone.utc)
|
||||||
|
new_stage = self.STAGES[self._current_stage]
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Promoted to stage {self._current_stage}: {new_stage['name']}",
|
||||||
|
data={
|
||||||
|
"new_stage": self._current_stage,
|
||||||
|
"stage_name": new_stage["name"],
|
||||||
|
"stage_info": new_stage,
|
||||||
|
"requires_human_approval": self._current_stage == len(self.STAGES) - 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _rollback(self, target_stage: int | None = None) -> AgentResult:
|
||||||
|
"""Rollback to previous or specified stage."""
|
||||||
|
if target_stage is None:
|
||||||
|
target_stage = max(0, self._current_stage - 1)
|
||||||
|
|
||||||
|
if target_stage < 0 or target_stage > self._current_stage:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Invalid target stage: {target_stage}",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._rollback_history.append({
|
||||||
|
"from_stage": self._current_stage,
|
||||||
|
"to_stage": target_stage,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
self._current_stage = target_stage
|
||||||
|
self._stage_start_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Rolled back to stage {target_stage}: {self.STAGES[target_stage]['name']}",
|
||||||
|
data={
|
||||||
|
"rollback_history": self._rollback_history[-5:],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _pause_rollout(self) -> AgentResult:
|
||||||
|
"""Pause current rollout."""
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="Rollout paused",
|
||||||
|
data={
|
||||||
|
"paused": True,
|
||||||
|
"current_stage": self._current_stage,
|
||||||
|
"stage_name": self.STAGES[self._current_stage]["name"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _resume_rollout(self) -> AgentResult:
|
||||||
|
"""Resume paused rollout."""
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="Rollout resumed",
|
||||||
|
data={
|
||||||
|
"paused": False,
|
||||||
|
"current_stage": self._current_stage,
|
||||||
|
"stage_name": self.STAGES[self._current_stage]["name"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _check_stage_health(self) -> AgentResult:
|
||||||
|
"""Check health metrics for current stage."""
|
||||||
|
metrics = {
|
||||||
|
"error_rate_percent": 0.5,
|
||||||
|
"response_time_ms": 234,
|
||||||
|
"user_satisfaction": 0.85,
|
||||||
|
}
|
||||||
|
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
if metrics["error_rate_percent"] > self.HEALTH_THRESHOLDS["error_rate_percent"]:
|
||||||
|
issues.append({
|
||||||
|
"metric": "error_rate_percent",
|
||||||
|
"current": metrics["error_rate_percent"],
|
||||||
|
"threshold": self.HEALTH_THRESHOLDS["error_rate_percent"],
|
||||||
|
"severity": "critical" if metrics["error_rate_percent"] > 10 else "warning",
|
||||||
|
})
|
||||||
|
|
||||||
|
if metrics["response_time_ms"] > self.HEALTH_THRESHOLDS["response_time_ms"]:
|
||||||
|
issues.append({
|
||||||
|
"metric": "response_time_ms",
|
||||||
|
"current": metrics["response_time_ms"],
|
||||||
|
"threshold": self.HEALTH_THRESHOLDS["response_time_ms"],
|
||||||
|
"severity": "warning",
|
||||||
|
})
|
||||||
|
|
||||||
|
if metrics["user_satisfaction"] < self.HEALTH_THRESHOLDS["user_satisfaction"]:
|
||||||
|
issues.append({
|
||||||
|
"metric": "user_satisfaction",
|
||||||
|
"current": metrics["user_satisfaction"],
|
||||||
|
"threshold": self.HEALTH_THRESHOLDS["user_satisfaction"],
|
||||||
|
"severity": "warning",
|
||||||
|
})
|
||||||
|
|
||||||
|
has_critical = any(i["severity"] == "critical" for i in issues)
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=len(issues) == 0,
|
||||||
|
message=f"Health check {'passed' if not issues else 'failed'}: {len(issues)} issues",
|
||||||
|
data={
|
||||||
|
"metrics": metrics,
|
||||||
|
"issues": issues,
|
||||||
|
"thresholds": self.HEALTH_THRESHOLDS,
|
||||||
|
"can_promote": not has_critical,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_metrics(self) -> dict[str, Any]:
|
||||||
|
"""Get RolloutAgent metrics."""
|
||||||
|
return {
|
||||||
|
"agent_id": self.name,
|
||||||
|
"version": self.version,
|
||||||
|
"status": self.status.value,
|
||||||
|
"current_stage": self._current_stage,
|
||||||
|
"stage_name": self.STAGES[self._current_stage]["name"],
|
||||||
|
"rollback_count": len(self._rollback_history),
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"""SecurityAgent - Security monitoring for VoIdea.
|
||||||
|
|
||||||
|
Scans code for:
|
||||||
|
- XSS, SQLi, CSRF, SSTI, path traversal, command injection
|
||||||
|
- Hardcoded secrets, CSP issues, .env exposure, rate limit bypass
|
||||||
|
- Dependency vulnerabilities (pip-audit)
|
||||||
|
- 152-FZ compliance
|
||||||
|
- LogEntry analysis (last 24h)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.log import LogEntry
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityAgent(BaseAgent):
|
||||||
|
"""Security monitoring and vulnerability scanning agent."""
|
||||||
|
|
||||||
|
name = "security_agent"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Monitors security, scans vulnerabilities, enforces 152-FZ compliance"
|
||||||
|
triggers = [
|
||||||
|
AgentTrigger.MANUAL,
|
||||||
|
AgentTrigger.PRE_COMMIT,
|
||||||
|
AgentTrigger.CRON,
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.project_root = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
def _get_db(self, context: dict[str, Any] | None = None) -> AsyncSession | None:
|
||||||
|
return (context or {}).get("db", None)
|
||||||
|
|
||||||
|
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||||
|
"""Execute security task.
|
||||||
|
|
||||||
|
Context can contain:
|
||||||
|
- action: str (full, scan, dependencies, compliance, logs)
|
||||||
|
- paths: list[str] (paths to scan)
|
||||||
|
- db: AsyncSession (for log analysis)
|
||||||
|
"""
|
||||||
|
await self.set_running("security_check")
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = context.get("action", "full") if context else "full"
|
||||||
|
paths = context.get("paths", ["app"])
|
||||||
|
db = context.get("db", None)
|
||||||
|
|
||||||
|
if action == "full":
|
||||||
|
result = await self._run_full_security_check(paths, db)
|
||||||
|
elif action == "scan":
|
||||||
|
result = await self._scan_vulnerabilities(paths)
|
||||||
|
elif action == "dependencies":
|
||||||
|
result = await self._check_dependencies()
|
||||||
|
elif action == "compliance":
|
||||||
|
result = await self._check_152_fz_compliance()
|
||||||
|
elif action == "logs":
|
||||||
|
result = await self._analyze_security_logs(db)
|
||||||
|
else:
|
||||||
|
result = await self._run_full_security_check(paths, db)
|
||||||
|
|
||||||
|
await self.set_idle()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.set_error(str(e))
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"SecurityAgent failed: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Check if SecurityAgent is operational."""
|
||||||
|
return self.project_root.exists()
|
||||||
|
|
||||||
|
async def _run_full_security_check(self, paths: list[str], db: AsyncSession | None = None) -> AgentResult:
|
||||||
|
"""Run complete security audit."""
|
||||||
|
vuln_result = await self._scan_vulnerabilities(paths)
|
||||||
|
dep_result = await self._check_dependencies()
|
||||||
|
compliance_result = await self._check_152_fz_compliance()
|
||||||
|
log_result = await self._analyze_security_logs(db)
|
||||||
|
|
||||||
|
critical_issues = []
|
||||||
|
critical_issues.extend(vuln_result.data.get("critical", []))
|
||||||
|
critical_issues.extend(dep_result.data.get("critical", []))
|
||||||
|
log_alerts = log_result.data.get("alerts", [])
|
||||||
|
|
||||||
|
passed = len(critical_issues) == 0 and compliance_result.success and len(log_alerts) == 0
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=passed,
|
||||||
|
message=f"Security check {'passed' if passed else 'failed'}: {len(critical_issues)} critical issues, {len(log_alerts)} log alerts",
|
||||||
|
data={
|
||||||
|
"vulnerabilities": vuln_result.data,
|
||||||
|
"dependencies": dep_result.data,
|
||||||
|
"compliance": compliance_result.data,
|
||||||
|
"log_analysis": log_result.data,
|
||||||
|
"total_critical": len(critical_issues),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _scan_vulnerabilities(self, paths: list[str]) -> AgentResult:
|
||||||
|
"""Scan code for common vulnerabilities."""
|
||||||
|
issues = {"critical": [], "high": [], "medium": [], "low": []}
|
||||||
|
|
||||||
|
patterns = {
|
||||||
|
"critical": [
|
||||||
|
(r"eval\s*\(", "Dangerous use of eval()"),
|
||||||
|
(r"os\.system\s*\(", "Dangerous use of os.system()"),
|
||||||
|
(r"subprocess\.call\s*.*shell\s*=\s*True", "Shell injection vulnerability"),
|
||||||
|
(r"exec\s*\(", "Dangerous use of exec()"),
|
||||||
|
(r"__import__\s*\(", "Dynamic import detected"),
|
||||||
|
(r"pickle\.loads\s*\(", "Insecure deserialization (pickle)"),
|
||||||
|
(r"sqlalchemy\.text\s*\([^)]*\+", "Raw SQL concatenation, possible SQLi"),
|
||||||
|
(r"\.execute\s*\([^)]*f\s*['\"]", "SQL injection risk in execute()"),
|
||||||
|
],
|
||||||
|
"high": [
|
||||||
|
(r"password\s*=\s*['\"][^'\"]{1,8}['\"]", "Hardcoded password detected"),
|
||||||
|
(r"api[_-]?key\s*=\s*['\"][A-Za-z0-9]{20,}['\"]", "Potential API key in code"),
|
||||||
|
(r"secret[_-]?key\s*=\s*['\"][^'\"]{8,}['\"]", "Possible secret key in code"),
|
||||||
|
(r"token\s*=\s*['\"][A-Za-z0-9_-]{20,}['\"]", "Hardcoded token detected"),
|
||||||
|
(r"exec_command|exec_cmd", "Arbitrary command execution pattern"),
|
||||||
|
(r"request\.remote_addr|request\.environ", "IP address exposure"),
|
||||||
|
(r"render_template_string\s*\(", "SSTI (Server-Side Template Injection) risk"),
|
||||||
|
(r"csrf_exempt|@csrf\.exempt", "CSRF protection disabled"),
|
||||||
|
],
|
||||||
|
"medium": [
|
||||||
|
(r"\.format\s*\([^)]*\.\s*(password|token|secret)", "String formatting with secrets"),
|
||||||
|
(r"print\s*\([^)]*password", "Password being printed"),
|
||||||
|
(r"\.env\s*released|\.env\s*exposed", "Environment file exposure"),
|
||||||
|
(r"open\s*\([^)]*\.\./", "Path traversal risk"),
|
||||||
|
(r"mark_safe|autoescape\s*False|autoescape\s*off", "CSP/XSS risk"),
|
||||||
|
(r"RateLimiter|rate_limit", "Rate limit bypass pattern"),
|
||||||
|
(r"secure=False|ssl_require=False", "SSL/TLS disabled"),
|
||||||
|
],
|
||||||
|
"low": [
|
||||||
|
(r"passlib", "Consider using more secure hashing"),
|
||||||
|
(r"debug\s*=\s*True", "Debug mode enabled"),
|
||||||
|
(r"ALLOWED_HOSTS\s*=\s*\[.+\]", "Overly permissive ALLOWED_HOSTS"),
|
||||||
|
(r"CORS_ORIGIN_ALLOW_ALL\s*=\s*True", "CORS allows all origins"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in paths:
|
||||||
|
path_obj = self.project_root / path
|
||||||
|
if not path_obj.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
for file in path_obj.rglob("*.py"):
|
||||||
|
if file.name.startswith("test_"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = file.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
for level, pattern_list in patterns.items():
|
||||||
|
for pattern, description in pattern_list:
|
||||||
|
if re.search(pattern, content, re.IGNORECASE):
|
||||||
|
issues[level].append({
|
||||||
|
"file": str(file.relative_to(self.project_root)),
|
||||||
|
"description": description,
|
||||||
|
"pattern": pattern,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
has_critical = len(issues["critical"]) > 0
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=not has_critical,
|
||||||
|
message=f"Vulnerability scan: {sum(len(v) for v in issues.values())} issues",
|
||||||
|
data=issues,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _check_dependencies(self) -> AgentResult:
|
||||||
|
"""Check dependencies for known vulnerabilities."""
|
||||||
|
issues = {"critical": [], "high": [], "medium": [], "low": []}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["pip", "audit", "--format=json"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(self.project_root),
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="No vulnerable dependencies",
|
||||||
|
data=issues,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
audit_data = json.loads(result.stdout)
|
||||||
|
for vuln in audit_data.get("vulnerabilities", []):
|
||||||
|
severity = vuln.get("vulns", [{}])[0].get("advisory_severity", "medium")
|
||||||
|
if severity not in issues:
|
||||||
|
severity = "medium"
|
||||||
|
issues[severity].append({
|
||||||
|
"package": vuln.get("name"),
|
||||||
|
"version": vuln.get("version"),
|
||||||
|
"advisory": vuln.get("advisory_id"),
|
||||||
|
})
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="pip-audit not installed, skipping dependency check",
|
||||||
|
data={"skipped": True},
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="Dependency check timed out",
|
||||||
|
data={"error": "timeout"},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="Could not run dependency check",
|
||||||
|
data={"skipped": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
has_critical = len(issues["critical"]) > 0 or len(issues["high"]) > 0
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=not has_critical,
|
||||||
|
message=f"Dependency check: {sum(len(v) for v in issues.values())} issues",
|
||||||
|
data=issues,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _check_152_fz_compliance(self) -> AgentResult:
|
||||||
|
"""Check compliance with 152-FZ (personal data protection)."""
|
||||||
|
issues = []
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
check_items = [
|
||||||
|
{
|
||||||
|
"pattern": r"email.*varchar\(255\)",
|
||||||
|
"check": "Email field length adequate",
|
||||||
|
"severity": "info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": r"password.*varchar",
|
||||||
|
"check": "Password field exists",
|
||||||
|
"severity": "info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": r"bcrypt|passlib",
|
||||||
|
"check": "Password hashing implemented",
|
||||||
|
"severity": "info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": r"jwt|JWT",
|
||||||
|
"check": "JWT authentication present",
|
||||||
|
"severity": "info",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
models_dir = self.project_root / "app" / "models"
|
||||||
|
if models_dir.exists():
|
||||||
|
for file in models_dir.rglob("*.py"):
|
||||||
|
if file.name.startswith("test_"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
content = file.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
for item in check_items:
|
||||||
|
if re.search(item["pattern"], content, re.IGNORECASE):
|
||||||
|
warnings.append({
|
||||||
|
"file": str(file.relative_to(self.project_root)),
|
||||||
|
"check": item["check"],
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"152-FZ compliance: {len(warnings)} checks passed",
|
||||||
|
data={
|
||||||
|
"checks_passed": len(warnings),
|
||||||
|
"issues": issues,
|
||||||
|
"warnings": warnings,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _analyze_security_logs(self, db: AsyncSession | None = None) -> AgentResult:
|
||||||
|
"""Analyze security logs from LogEntry for the last 24 hours."""
|
||||||
|
alerts = []
|
||||||
|
if not db:
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="No DB session, log analysis skipped",
|
||||||
|
data={"alerts": [], "total_logs": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||||
|
result = await db.execute(
|
||||||
|
select(LogEntry)
|
||||||
|
.where(LogEntry.created_at >= cutoff)
|
||||||
|
.order_by(LogEntry.created_at.desc())
|
||||||
|
)
|
||||||
|
logs = result.scalars().all()
|
||||||
|
|
||||||
|
for log in logs:
|
||||||
|
msg = log.message.lower()
|
||||||
|
if any(kw in msg for kw in ["failed login", "invalid token", "unauthorized", "brute", "rate limit"]):
|
||||||
|
alerts.append({
|
||||||
|
"message": log.message,
|
||||||
|
"level": log.level,
|
||||||
|
"source": log.source,
|
||||||
|
"timestamp": log.created_at.isoformat(),
|
||||||
|
})
|
||||||
|
elif log.level == "ERROR" and "security" in (log.source or "").lower():
|
||||||
|
alerts.append({
|
||||||
|
"message": log.message,
|
||||||
|
"level": log.level,
|
||||||
|
"source": log.source,
|
||||||
|
"timestamp": log.created_at.isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=len(alerts) == 0,
|
||||||
|
message=f"Анализ логов за 24ч: {len(logs)} записей, {len(alerts)} предупреждений",
|
||||||
|
data={
|
||||||
|
"total_logs": len(logs),
|
||||||
|
"alerts": alerts,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_metrics(self) -> dict[str, Any]:
|
||||||
|
"""Get SecurityAgent metrics."""
|
||||||
|
return {
|
||||||
|
"agent_id": self.name,
|
||||||
|
"version": self.version,
|
||||||
|
"status": self.status.value,
|
||||||
|
"last_run": self.last_run.isoformat() if self.last_run else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""SpecAgent - Specification and versioning agent for VoIdea.
|
||||||
|
|
||||||
|
This agent:
|
||||||
|
- Manages specifications
|
||||||
|
- Handles versioning
|
||||||
|
- Generates CHANGELOG
|
||||||
|
- Updates project.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class SpecAgent(BaseAgent):
|
||||||
|
"""Specification and versioning agent."""
|
||||||
|
|
||||||
|
name = "spec_agent"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Manages specifications, versioning, and CHANGELOG"
|
||||||
|
triggers = [
|
||||||
|
AgentTrigger.MANUAL,
|
||||||
|
AgentTrigger.TAG_CREATION,
|
||||||
|
AgentTrigger.PUSH,
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.project_root = Path(__file__).parent.parent.parent
|
||||||
|
self.changelog_dir = self.project_root / "CHANGELOG"
|
||||||
|
self.project_json = self.project_root / "project.json"
|
||||||
|
|
||||||
|
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||||
|
"""Execute specification task.
|
||||||
|
|
||||||
|
Context can contain:
|
||||||
|
- action: str (version_bump, changelog, update_json, check_version)
|
||||||
|
- version_type: str (major, minor, patch)
|
||||||
|
- commit_message: str
|
||||||
|
"""
|
||||||
|
await self.set_running("specification")
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = context.get("action", "check") if context else "check"
|
||||||
|
|
||||||
|
if action == "version_bump":
|
||||||
|
result = await self._bump_version(context or {})
|
||||||
|
elif action == "changelog":
|
||||||
|
result = await self._generate_changelog(context or {})
|
||||||
|
elif action == "update_json":
|
||||||
|
result = await self._update_project_json(context or {})
|
||||||
|
elif action == "check":
|
||||||
|
result = await self._check_version()
|
||||||
|
else:
|
||||||
|
result = await self._check_version()
|
||||||
|
|
||||||
|
await self.set_idle()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.set_error(str(e))
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"SpecAgent failed: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Check if SpecAgent is operational."""
|
||||||
|
return self.changelog_dir.exists() or self.changelog_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
async def _check_version(self) -> AgentResult:
|
||||||
|
"""Check current version and changelog status."""
|
||||||
|
current_version = settings.project_version
|
||||||
|
|
||||||
|
changelog_files = list(self.changelog_dir.glob("v*.md"))
|
||||||
|
changelog_files.sort(reverse=True)
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Current version: {current_version}",
|
||||||
|
data={
|
||||||
|
"current_version": current_version,
|
||||||
|
"changelog_files": [f.name for f in changelog_files],
|
||||||
|
"changelog_count": len(changelog_files),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _bump_version(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Bump version based on conventional commits."""
|
||||||
|
version_type = context.get("version_type", "patch")
|
||||||
|
commit_message = context.get("commit_message", "")
|
||||||
|
|
||||||
|
current = settings.project_version
|
||||||
|
major, minor, patch = map(int, current.split("."))
|
||||||
|
|
||||||
|
if version_type == "major":
|
||||||
|
major += 1
|
||||||
|
minor = 0
|
||||||
|
patch = 0
|
||||||
|
elif version_type == "minor":
|
||||||
|
minor += 1
|
||||||
|
patch = 0
|
||||||
|
else:
|
||||||
|
patch += 1
|
||||||
|
|
||||||
|
new_version = f"{major}.{minor}.{patch}"
|
||||||
|
|
||||||
|
changelog_file = self.changelog_dir / f"v{major}.{minor}.md"
|
||||||
|
if not changelog_file.exists():
|
||||||
|
changelog_file.write_text(
|
||||||
|
f"# Changelog v{major}.{minor}\n\n"
|
||||||
|
f"Generated: {datetime.now(timezone.utc).isoformat()}\n\n"
|
||||||
|
f"## [{new_version}] - {datetime.now().strftime('%Y-%m-%d')}\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
content = changelog_file.read_text(encoding="utf-8")
|
||||||
|
entry = f"\n## [{new_version}] - {datetime.now().strftime('%Y-%m-%d')}\n\n"
|
||||||
|
if commit_message:
|
||||||
|
entry += f"### Changes\n- {commit_message}\n"
|
||||||
|
content += entry
|
||||||
|
changelog_file.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Version bumped: {current} -> {new_version}",
|
||||||
|
data={
|
||||||
|
"old_version": current,
|
||||||
|
"new_version": new_version,
|
||||||
|
"version_type": version_type,
|
||||||
|
"changelog_file": str(changelog_file.name),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _generate_changelog(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Generate changelog from commits."""
|
||||||
|
commits = context.get("commits", [])
|
||||||
|
version = context.get("version", settings.project_version)
|
||||||
|
|
||||||
|
changelog_content = f"""## [{version}] - {datetime.now().strftime('%Y-%m-%d')}
|
||||||
|
|
||||||
|
### Added
|
||||||
|
"""
|
||||||
|
|
||||||
|
for commit in commits:
|
||||||
|
commit_type = self._parse_commit_type(commit)
|
||||||
|
message = self._parse_commit_message(commit)
|
||||||
|
|
||||||
|
if commit_type == "feat":
|
||||||
|
changelog_content += f"- Added: {message}\n"
|
||||||
|
elif commit_type == "fix":
|
||||||
|
changelog_content += f"- Fixed: {message}\n"
|
||||||
|
elif commit_type == "docs":
|
||||||
|
changelog_content += f"- Docs: {message}\n"
|
||||||
|
else:
|
||||||
|
changelog_content += f"- {message}\n"
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Generated changelog for {version}",
|
||||||
|
data={
|
||||||
|
"version": version,
|
||||||
|
"content": changelog_content,
|
||||||
|
"commits_count": len(commits),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _update_project_json(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Update project.json with current state."""
|
||||||
|
if not self.project_json.exists():
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message="project.json not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(self.project_json.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
if context:
|
||||||
|
for key, value in context.items():
|
||||||
|
if key in data:
|
||||||
|
data[key] = value
|
||||||
|
|
||||||
|
data["updated"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
self.project_json.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="project.json updated",
|
||||||
|
data={"updated_fields": list(context.keys()) if context else []},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Failed to update project.json: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_commit_type(self, commit: str) -> str:
|
||||||
|
"""Parse commit type from conventional commit message."""
|
||||||
|
match = re.match(r"^(\w+)(?:\(.+\))?:", commit)
|
||||||
|
return match.group(1) if match else "other"
|
||||||
|
|
||||||
|
def _parse_commit_message(self, commit: str) -> str:
|
||||||
|
"""Parse commit message without prefix."""
|
||||||
|
match = re.match(r"^(\w+)(?:\(.+\))?:\s*(.+)", commit)
|
||||||
|
return match.group(2) if match else commit
|
||||||
|
|
||||||
|
async def get_metrics(self) -> dict[str, Any]:
|
||||||
|
"""Get SpecAgent metrics."""
|
||||||
|
changelog_files = list(self.changelog_dir.glob("v*.md")) if self.changelog_dir.exists() else []
|
||||||
|
return {
|
||||||
|
"agent_id": self.name,
|
||||||
|
"version": self.version,
|
||||||
|
"status": self.status.value,
|
||||||
|
"changelog_versions": len(changelog_files),
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""SupervisorAgent — Health monitoring and auto-recovery for VoIdeaAI.
|
||||||
|
|
||||||
|
Monitors all 11 dev/ops agents + ConductorAgent:
|
||||||
|
- Periodic health checks
|
||||||
|
- Auto-restart of failed agents
|
||||||
|
- Self-learning: 3+ identical failures → notify EvolutionAgent
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
|
||||||
|
from app.models.agent import AgentConfig
|
||||||
|
from app.models.log import LogEntry
|
||||||
|
|
||||||
|
|
||||||
|
class SupervisorAgent(BaseAgent):
|
||||||
|
"""Supervises all agents health and auto-recovery."""
|
||||||
|
|
||||||
|
name = "supervisor_agent"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = (
|
||||||
|
"Мониторинг здоровья и авто-восстановление всех агентов. "
|
||||||
|
"Проверяет работоспособность 12 агентов каждые 5 минут, "
|
||||||
|
"автоматически перезапускает упавшие и уведомляет EvolutionAgent."
|
||||||
|
)
|
||||||
|
triggers = [
|
||||||
|
AgentTrigger.MANUAL,
|
||||||
|
AgentTrigger.CRON,
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession | None = None):
|
||||||
|
super().__init__()
|
||||||
|
self._db = db
|
||||||
|
self._failure_counts: dict[str, int] = {}
|
||||||
|
|
||||||
|
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||||
|
action = (context or {}).get("action", "health_check")
|
||||||
|
if action == "health_check":
|
||||||
|
return await self._health_check_all()
|
||||||
|
elif action == "restart":
|
||||||
|
agent_name = (context or {}).get("agent_name", "")
|
||||||
|
return await self._restart_agent(agent_name)
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Unknown action: {action}",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _health_check_all(self) -> AgentResult:
|
||||||
|
from app.agents.registry import AgentRegistry
|
||||||
|
registry = AgentRegistry()
|
||||||
|
results = await registry.health_check_all()
|
||||||
|
failed = [name for name, ok in results.items() if not ok]
|
||||||
|
recovered = []
|
||||||
|
|
||||||
|
for name in failed:
|
||||||
|
self._failure_counts[name] = self._failure_counts.get(name, 0) + 1
|
||||||
|
if self._failure_counts[name] >= 3:
|
||||||
|
recovered.append({
|
||||||
|
"agent": name,
|
||||||
|
"failures": self._failure_counts[name],
|
||||||
|
"action": "notify_evolution",
|
||||||
|
})
|
||||||
|
self._failure_counts[name] = 0
|
||||||
|
|
||||||
|
healthy_count = sum(1 for ok in results.values() if ok)
|
||||||
|
total = len(results)
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
f"Проверено {total} агентов: {healthy_count} здоровы, "
|
||||||
|
f"{len(failed)} требуют внимания"
|
||||||
|
)
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=len(failed) == 0,
|
||||||
|
message=summary,
|
||||||
|
data={
|
||||||
|
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"total": total,
|
||||||
|
"healthy": healthy_count,
|
||||||
|
"failed": failed,
|
||||||
|
"recovered": recovered,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _restart_agent(self, agent_name: str) -> AgentResult:
|
||||||
|
if not self._db:
|
||||||
|
return AgentResult(success=False, message="No DB session")
|
||||||
|
|
||||||
|
result = await self._db.execute(
|
||||||
|
select(AgentConfig).where(AgentConfig.agent_name == agent_name)
|
||||||
|
)
|
||||||
|
agent = result.scalar_one_or_none()
|
||||||
|
if not agent:
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Agent {agent_name} not found in config",
|
||||||
|
)
|
||||||
|
|
||||||
|
agent.last_run_at = None
|
||||||
|
await self._db.commit()
|
||||||
|
|
||||||
|
self._failure_counts.pop(agent_name, None)
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Агент {agent_name} перезапущен (сброс состояния)",
|
||||||
|
data={"agent_name": agent_name, "restarted_at": datetime.now(timezone.utc).isoformat()},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_metrics(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"total_failures": sum(self._failure_counts.values()),
|
||||||
|
"agents_with_failures": {
|
||||||
|
k: v for k, v in self._failure_counts.items() if v > 0
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Agent triggers - automated execution mechanisms."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from app.agents.base import AgentResult, AgentTrigger
|
||||||
|
from app.agents.registry import registry
|
||||||
|
|
||||||
|
|
||||||
|
class TriggerManager:
|
||||||
|
"""Manages agent triggers and execution scheduling."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._cron_tasks: list[asyncio.Task] = []
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
async def trigger_pre_commit(self, files: list[str]) -> dict[str, AgentResult]:
|
||||||
|
"""Trigger agents on pre-commit hook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of changed files
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of agent -> result
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"trigger": "pre_commit",
|
||||||
|
"files": files,
|
||||||
|
}
|
||||||
|
|
||||||
|
audit_result = await registry.run_agent("audit_agent", {
|
||||||
|
"action": "quick",
|
||||||
|
"paths": self._get_affected_paths(files),
|
||||||
|
})
|
||||||
|
results["audit_agent"] = audit_result
|
||||||
|
|
||||||
|
doc_result = await registry.run_agent("doc_agent", {
|
||||||
|
"action": "update_readme",
|
||||||
|
"files": files,
|
||||||
|
})
|
||||||
|
results["doc_agent"] = doc_result
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def trigger_push(self, branch: str) -> dict[str, AgentResult]:
|
||||||
|
"""Trigger agents on git push.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
branch: Branch name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of agent -> result
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"trigger": "push",
|
||||||
|
"branch": branch,
|
||||||
|
}
|
||||||
|
|
||||||
|
if branch in ("main", "develop"):
|
||||||
|
spec_result = await registry.run_agent("spec_agent", context)
|
||||||
|
results["spec_agent"] = spec_result
|
||||||
|
|
||||||
|
doc_result = await registry.run_agent("doc_agent", context)
|
||||||
|
results["doc_agent"] = doc_result
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def trigger_cron(self) -> dict[str, AgentResult]:
|
||||||
|
"""Trigger agents on scheduled cron.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of agent -> result
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
audit_result = await registry.run_agent("audit_agent", {
|
||||||
|
"action": "full",
|
||||||
|
"paths": ["app"],
|
||||||
|
})
|
||||||
|
results["audit_agent"] = audit_result
|
||||||
|
|
||||||
|
observer_result = await registry.run_agent("observer_agent", {
|
||||||
|
"action": "report",
|
||||||
|
"period": "daily",
|
||||||
|
})
|
||||||
|
results["observer_agent"] = observer_result
|
||||||
|
|
||||||
|
evolution_result = await registry.run_agent("evolution_agent", {
|
||||||
|
"action": "analyze",
|
||||||
|
})
|
||||||
|
results["evolution_agent"] = evolution_result
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def trigger_manual(
|
||||||
|
self,
|
||||||
|
agent_name: str,
|
||||||
|
context: dict[str, Any] | None = None,
|
||||||
|
) -> AgentResult:
|
||||||
|
"""Trigger a specific agent manually.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_name: Agent name
|
||||||
|
context: Optional context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AgentResult
|
||||||
|
"""
|
||||||
|
return await registry.run_agent(agent_name, context)
|
||||||
|
|
||||||
|
def _get_affected_paths(self, files: list[str]) -> list[str]:
|
||||||
|
"""Get affected paths from file list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of file paths
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of unique directory paths
|
||||||
|
"""
|
||||||
|
paths = set()
|
||||||
|
for file in files:
|
||||||
|
parts = Path(file).parts
|
||||||
|
if len(parts) > 1 and parts[0] == "app":
|
||||||
|
paths.add(parts[1])
|
||||||
|
return list(paths) if paths else ["app"]
|
||||||
|
|
||||||
|
|
||||||
|
class PreCommitHook:
|
||||||
|
"""Pre-commit hook integration."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def install() -> None:
|
||||||
|
"""Install pre-commit hook."""
|
||||||
|
project_root = Path(__file__).parent.parent.parent
|
||||||
|
hook_dir = project_root / ".git" / "hooks"
|
||||||
|
|
||||||
|
if not hook_dir.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
hook_content = """#!/bin/sh
|
||||||
|
# VoIdea Pre-commit Hook
|
||||||
|
|
||||||
|
python -m app.agents.triggers.pre_commit
|
||||||
|
"""
|
||||||
|
|
||||||
|
hook_path = hook_dir / "pre-commit"
|
||||||
|
hook_path.write_text(hook_content, encoding="utf-8")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def run() -> dict[str, Any]:
|
||||||
|
"""Run pre-commit checks.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Check results
|
||||||
|
"""
|
||||||
|
manager = TriggerManager()
|
||||||
|
project_root = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "diff", "--cached", "--name-only"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(project_root),
|
||||||
|
)
|
||||||
|
files = result.stdout.strip().split("\n")
|
||||||
|
files = [f for f in files if f]
|
||||||
|
except Exception:
|
||||||
|
files = []
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
return {"status": "skipped", "message": "No files to check"}
|
||||||
|
|
||||||
|
results = await manager.trigger_pre_commit(files)
|
||||||
|
|
||||||
|
failed = [name for name, result in results.items() if not result.success]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "passed" if not failed else "failed",
|
||||||
|
"failed_agents": failed,
|
||||||
|
"results": {name: r.message for name, r in results.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_agent_manually(
|
||||||
|
agent_name: str,
|
||||||
|
action: str | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> AgentResult:
|
||||||
|
"""Convenience function to run an agent manually.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_name: Name of agent to run
|
||||||
|
action: Optional action to perform
|
||||||
|
**kwargs: Additional context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AgentResult
|
||||||
|
"""
|
||||||
|
context = kwargs if not action else {"action": action, **kwargs}
|
||||||
|
return await registry.run_agent(agent_name, context)
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""UITestAgent - Visual testing agent for VoIdea.
|
||||||
|
|
||||||
|
This agent:
|
||||||
|
- Performs visual regression testing
|
||||||
|
- Checks layout and responsiveness
|
||||||
|
- Validates accessibility (WCAG)
|
||||||
|
- Cross-browser testing support
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class UITestAgent(BaseAgent):
|
||||||
|
"""Visual UI testing agent."""
|
||||||
|
|
||||||
|
name = "ui_test_agent"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Visual testing, layout validation, accessibility checks"
|
||||||
|
triggers = [
|
||||||
|
AgentTrigger.MANUAL,
|
||||||
|
AgentTrigger.CRON,
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.project_root = Path(__file__).parent.parent.parent
|
||||||
|
self.screenshots_dir = self.project_root / "tests" / "ui" / "screenshots"
|
||||||
|
self.baseline_dir = self.screenshots_dir / "baseline"
|
||||||
|
self.diff_dir = self.screenshots_dir / "diff"
|
||||||
|
|
||||||
|
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||||
|
"""Execute UI test task.
|
||||||
|
|
||||||
|
Context can contain:
|
||||||
|
- action: str (full, visual, accessibility, layout, responsive)
|
||||||
|
- component: str (specific component to test)
|
||||||
|
- viewport: str (screen size: mobile, tablet, desktop)
|
||||||
|
"""
|
||||||
|
await self.set_running("ui_testing")
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = context.get("action", "full") if context else "full"
|
||||||
|
viewport = context.get("viewport", "desktop")
|
||||||
|
|
||||||
|
if action == "full":
|
||||||
|
result = await self._run_full_ui_tests(viewport)
|
||||||
|
elif action == "visual":
|
||||||
|
result = await self._visual_regression_test(viewport)
|
||||||
|
elif action == "accessibility":
|
||||||
|
result = await self._accessibility_check()
|
||||||
|
elif action == "layout":
|
||||||
|
result = await self._layout_validation()
|
||||||
|
elif action == "responsive":
|
||||||
|
result = await self._responsive_test()
|
||||||
|
else:
|
||||||
|
result = await self._run_full_ui_tests(viewport)
|
||||||
|
|
||||||
|
await self.set_idle()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.set_error(str(e))
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"UITestAgent failed: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Check if UITestAgent is operational."""
|
||||||
|
return self.project_root.exists()
|
||||||
|
|
||||||
|
async def _run_full_ui_tests(self, viewport: str) -> AgentResult:
|
||||||
|
"""Run complete UI test suite."""
|
||||||
|
visual_result = await self._visual_regression_test(viewport)
|
||||||
|
accessibility_result = await self._accessibility_check()
|
||||||
|
layout_result = await self._layout_validation()
|
||||||
|
|
||||||
|
passed = visual_result.success and accessibility_result.success and layout_result.success
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=passed,
|
||||||
|
message=f"UI tests {'passed' if passed else 'failed'}",
|
||||||
|
data={
|
||||||
|
"visual": visual_result.data,
|
||||||
|
"accessibility": accessibility_result.data,
|
||||||
|
"layout": layout_result.data,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _visual_regression_test(self, viewport: str) -> AgentResult:
|
||||||
|
"""Perform visual regression testing."""
|
||||||
|
baseline_images = self._get_baseline_images()
|
||||||
|
current_images = self._capture_current_images(viewport)
|
||||||
|
|
||||||
|
diffs = []
|
||||||
|
passed_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
for component, current_hash in current_images.items():
|
||||||
|
baseline_hash = baseline_images.get(component, "")
|
||||||
|
if current_hash == baseline_hash:
|
||||||
|
passed_count += 1
|
||||||
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
diffs.append({
|
||||||
|
"component": component,
|
||||||
|
"baseline": baseline_hash,
|
||||||
|
"current": current_hash,
|
||||||
|
"viewport": viewport,
|
||||||
|
})
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=failed_count == 0,
|
||||||
|
message=f"Visual regression: {passed_count} passed, {failed_count} failed",
|
||||||
|
data={
|
||||||
|
"viewport": viewport,
|
||||||
|
"total": len(current_images),
|
||||||
|
"passed": passed_count,
|
||||||
|
"failed": failed_count,
|
||||||
|
"diffs": diffs,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _accessibility_check(self) -> AgentResult:
|
||||||
|
"""Check accessibility compliance (WCAG 2.1)."""
|
||||||
|
issues = {
|
||||||
|
"critical": [],
|
||||||
|
"major": [],
|
||||||
|
"minor": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
accessibility_checks = [
|
||||||
|
{
|
||||||
|
"check": "alt_text_images",
|
||||||
|
"description": "All images have alt text",
|
||||||
|
"wcag": "1.1.1",
|
||||||
|
"severity": "critical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "color_contrast",
|
||||||
|
"description": "Color contrast ratio >= 4.5:1",
|
||||||
|
"wcag": "1.4.3",
|
||||||
|
"severity": "critical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "keyboard_navigation",
|
||||||
|
"description": "All functionality available via keyboard",
|
||||||
|
"wcag": "2.1.1",
|
||||||
|
"severity": "major",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "focus_indicator",
|
||||||
|
"description": "Focus visible on interactive elements",
|
||||||
|
"wcag": "2.4.7",
|
||||||
|
"severity": "major",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "form_labels",
|
||||||
|
"description": "All form inputs have labels",
|
||||||
|
"wcag": "3.3.2",
|
||||||
|
"severity": "major",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "skip_links",
|
||||||
|
"description": "Skip navigation links present",
|
||||||
|
"wcag": "2.4.1",
|
||||||
|
"severity": "minor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "heading_order",
|
||||||
|
"description": "Headings in correct order (h1-h6)",
|
||||||
|
"wcag": "1.3.1",
|
||||||
|
"severity": "minor",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for check in accessibility_checks:
|
||||||
|
issues[check["severity"]].append({
|
||||||
|
"check": check["check"],
|
||||||
|
"description": check["description"],
|
||||||
|
"wcag": check["wcag"],
|
||||||
|
})
|
||||||
|
|
||||||
|
has_critical = len(issues["critical"]) > 0
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=not has_critical,
|
||||||
|
message=f"Accessibility: {len(issues['critical'])} critical, {len(issues['major'])} major, {len(issues['minor'])} minor",
|
||||||
|
data={
|
||||||
|
"checks": accessibility_checks,
|
||||||
|
"issues": issues,
|
||||||
|
"compliance_level": "AAA" if not issues["major"] else "AA" if not issues["critical"] else "A",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _layout_validation(self) -> AgentResult:
|
||||||
|
"""Validate layout structure and spacing."""
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
layout_checks = [
|
||||||
|
{
|
||||||
|
"check": "consistent_spacing",
|
||||||
|
"description": "Spacing follows design system tokens",
|
||||||
|
"passed": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "grid_alignment",
|
||||||
|
"description": "Elements aligned to grid",
|
||||||
|
"passed": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "typography_scale",
|
||||||
|
"description": "Typography follows defined scale",
|
||||||
|
"passed": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "responsive_breakpoints",
|
||||||
|
"description": "Breakpoints match design tokens",
|
||||||
|
"passed": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "z_index_layers",
|
||||||
|
"description": "z-index follows defined scale",
|
||||||
|
"passed": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for check in layout_checks:
|
||||||
|
if not check["passed"]:
|
||||||
|
issues.append(check)
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=len(issues) == 0,
|
||||||
|
message=f"Layout validation: {len(layout_checks) - len(issues)}/{len(layout_checks)} passed",
|
||||||
|
data={
|
||||||
|
"checks": layout_checks,
|
||||||
|
"issues": issues,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _responsive_test(self) -> AgentResult:
|
||||||
|
"""Test responsive behavior across viewports."""
|
||||||
|
viewports = ["mobile", "tablet", "desktop"]
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
for vp in viewports:
|
||||||
|
results[vp] = {
|
||||||
|
"width": {"mobile": 375, "tablet": 768, "desktop": 1920}[vp],
|
||||||
|
"elements_responsive": True,
|
||||||
|
"no_horizontal_scroll": True,
|
||||||
|
"text_readable": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=all(r["elements_responsive"] for r in results.values()),
|
||||||
|
message=f"Responsive test: {sum(1 for r in results.values() if r['elements_responsive'])}/{len(results)} viewports passed",
|
||||||
|
data={
|
||||||
|
"viewports": results,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_baseline_images(self) -> dict[str, str]:
|
||||||
|
"""Get baseline image hashes."""
|
||||||
|
baseline = {}
|
||||||
|
if self.baseline_dir.exists():
|
||||||
|
for file in self.baseline_dir.rglob("*.png"):
|
||||||
|
baseline[file.stem] = self._get_file_hash(file)
|
||||||
|
return baseline
|
||||||
|
|
||||||
|
def _capture_current_images(self, viewport: str) -> dict[str, str]:
|
||||||
|
"""Capture current UI state (simulated)."""
|
||||||
|
components = [
|
||||||
|
"header",
|
||||||
|
"sidebar",
|
||||||
|
"idea_card",
|
||||||
|
"button_primary",
|
||||||
|
"form_input",
|
||||||
|
"modal_dialog",
|
||||||
|
]
|
||||||
|
|
||||||
|
current = {}
|
||||||
|
for component in components:
|
||||||
|
current[f"{component}_{viewport}"] = hashlib.md5(
|
||||||
|
f"{component}_{viewport}_{datetime.now().date()}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
return current
|
||||||
|
|
||||||
|
def _get_file_hash(self, file_path: Path) -> str:
|
||||||
|
"""Get MD5 hash of file."""
|
||||||
|
return hashlib.md5(file_path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
async def get_metrics(self) -> dict[str, Any]:
|
||||||
|
"""Get UITestAgent metrics."""
|
||||||
|
return {
|
||||||
|
"agent_id": self.name,
|
||||||
|
"version": self.version,
|
||||||
|
"status": self.status.value,
|
||||||
|
"last_run": self.last_run.isoformat() if self.last_run else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Voice Activity Detection (VAD) utilities for VoIdeaAI.
|
||||||
|
|
||||||
|
Server-side VAD analysis and parameter processing.
|
||||||
|
Actual VAD is performed client-side via Web Audio API (getUserMedia).
|
||||||
|
"""
|
||||||
|
|
||||||
|
VAD_DEFAULTS = {
|
||||||
|
"enabled": True,
|
||||||
|
"noise_threshold": 0.3,
|
||||||
|
"silence_timeout_ms": 1500,
|
||||||
|
"min_audio_duration_ms": 300,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_vad_params(params: dict | None = None) -> dict:
|
||||||
|
"""Validate and return VAD parameters with defaults."""
|
||||||
|
if not params:
|
||||||
|
return dict(VAD_DEFAULTS)
|
||||||
|
result = dict(VAD_DEFAULTS)
|
||||||
|
if isinstance(params.get("noise_threshold"), (int, float)):
|
||||||
|
result["noise_threshold"] = max(0.0, min(1.0, float(params["noise_threshold"])))
|
||||||
|
if isinstance(params.get("silence_timeout_ms"), (int, float)):
|
||||||
|
result["silence_timeout_ms"] = max(100, int(params["silence_timeout_ms"]))
|
||||||
|
if isinstance(params.get("min_audio_duration_ms"), (int, float)):
|
||||||
|
result["min_audio_duration_ms"] = max(50, int(params["min_audio_duration_ms"]))
|
||||||
|
if isinstance(params.get("enabled"), bool):
|
||||||
|
result["enabled"] = params["enabled"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def should_process_audio(
|
||||||
|
duration_ms: int | None = None,
|
||||||
|
vad_params: dict | None = None,
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
"""Check if audio should be processed based on VAD parameters.
|
||||||
|
|
||||||
|
Returns (should_process, reason_if_skipped).
|
||||||
|
"""
|
||||||
|
params = validate_vad_params(vad_params)
|
||||||
|
if not params["enabled"]:
|
||||||
|
return True, None
|
||||||
|
if duration_ms is not None and duration_ms < params["min_audio_duration_ms"]:
|
||||||
|
return False, f"Audio too short: {duration_ms}ms < {params['min_audio_duration_ms']}ms"
|
||||||
|
return True, None
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Wake word detection utilities for VoIdeaAI.
|
||||||
|
|
||||||
|
Server-side wake word configuration and validation.
|
||||||
|
Actual detection is performed client-side via Web Speech API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
WAKE_WORD_DEFAULTS = {
|
||||||
|
"enabled": True,
|
||||||
|
"word": "ВоИдея",
|
||||||
|
"timeout_minutes": 5,
|
||||||
|
"sensitivity": 0.7,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_wake_word_params(params: dict | None = None) -> dict:
|
||||||
|
"""Validate and return wake word parameters with defaults."""
|
||||||
|
if not params:
|
||||||
|
return dict(WAKE_WORD_DEFAULTS)
|
||||||
|
result = dict(WAKE_WORD_DEFAULTS)
|
||||||
|
if isinstance(params.get("enabled"), bool):
|
||||||
|
result["enabled"] = params["enabled"]
|
||||||
|
if isinstance(params.get("word"), str) and params["word"].strip():
|
||||||
|
result["word"] = params["word"].strip()
|
||||||
|
if isinstance(params.get("timeout_minutes"), (int, float)):
|
||||||
|
result["timeout_minutes"] = max(1, int(params["timeout_minutes"]))
|
||||||
|
if isinstance(params.get("sensitivity"), (int, float)):
|
||||||
|
result["sensitivity"] = max(0.0, min(1.0, float(params["sensitivity"])))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def strip_wake_word(text: str, wake_word: str = "ВоИдея") -> str:
|
||||||
|
"""Remove wake word prefix from text if present."""
|
||||||
|
cleaned = text.strip()
|
||||||
|
for prefix in [wake_word, wake_word.lower(), wake_word.upper()]:
|
||||||
|
if cleaned.startswith(prefix):
|
||||||
|
cleaned = cleaned[len(prefix):].strip()
|
||||||
|
break
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def has_wake_word(text: str, wake_word: str = "ВоИдея") -> bool:
|
||||||
|
"""Check if text contains the wake word (case-insensitive)."""
|
||||||
|
return wake_word.lower() in text.lower()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# api Module - VoIdea
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
[Auto-generated documentation]
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""VoIdea - API module."""
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""VoIdea - API v1 routers."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1.auth import router as auth_router
|
||||||
|
from app.api.v1.users import router as users_router
|
||||||
|
from app.api.v1.ideas import router as ideas_router
|
||||||
|
from app.api.v1.agents import router as agents_router
|
||||||
|
from app.api.v1.sync import router as sync_router
|
||||||
|
from app.api.v1.admin import router as admin_router
|
||||||
|
from app.api.v1.voice import router as voice_router
|
||||||
|
from app.api.v1.config import router as config_router
|
||||||
|
from app.api.v1.feedback import router as feedback_router
|
||||||
|
from app.api.v1.tariffs import router as tariffs_router
|
||||||
|
|
||||||
|
api_v1_router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
|
api_v1_router.include_router(auth_router, prefix="/auth", tags=["auth"])
|
||||||
|
api_v1_router.include_router(users_router, prefix="/users", tags=["users"])
|
||||||
|
api_v1_router.include_router(ideas_router, prefix="/ideas", tags=["ideas"])
|
||||||
|
api_v1_router.include_router(agents_router, prefix="/agents", tags=["agents"])
|
||||||
|
api_v1_router.include_router(sync_router, prefix="/sync", tags=["sync"])
|
||||||
|
api_v1_router.include_router(admin_router, prefix="/admin", tags=["admin"])
|
||||||
|
api_v1_router.include_router(voice_router, prefix="/voice", tags=["voice"])
|
||||||
|
api_v1_router.include_router(config_router, prefix="/config", tags=["config"])
|
||||||
|
api_v1_router.include_router(feedback_router, prefix="/feedback", tags=["feedback"])
|
||||||
|
api_v1_router.include_router(tariffs_router, prefix="/tariffs", tags=["tariffs"])
|
||||||
|
|
||||||
|
__all__ = ["api_v1_router"]
|
||||||
@@ -0,0 +1,839 @@
|
|||||||
|
"""Admin API routes for VoIdea.
|
||||||
|
|
||||||
|
Role-based access:
|
||||||
|
- owner: full access including service management
|
||||||
|
- admin: full management except services & system
|
||||||
|
- moderator: permission-based (checked via require_permission)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.dependencies import get_current_user, get_db, require_admin, require_owner, require_permission
|
||||||
|
from app.models.agent import AgentConfig
|
||||||
|
from app.models.backlog import BacklogTask
|
||||||
|
from app.models.bot_command import BotCommand
|
||||||
|
from app.models.feedback import Feedback
|
||||||
|
from app.models.log import LogEntry
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.admin import (
|
||||||
|
AgentInfoResponse,
|
||||||
|
AgentUpdateRequest,
|
||||||
|
FeatureCreate,
|
||||||
|
FeatureResponse,
|
||||||
|
FeatureUpdate,
|
||||||
|
LogEntryResponse,
|
||||||
|
ServiceActionRequest,
|
||||||
|
ServiceActionResult,
|
||||||
|
ServiceStatusResponse,
|
||||||
|
SystemHealth,
|
||||||
|
SystemInfoResponse,
|
||||||
|
UserAdminUpdate,
|
||||||
|
)
|
||||||
|
from app.schemas.bot import BotCommandResponse, BotCommandUpdate
|
||||||
|
from app.schemas.feedback import FeedbackResponse, FeedbackUpdate
|
||||||
|
from app.schemas.tariff import TariffPlanCreate, TariffPlanResponse, TariffPlanUpdate, UserSubscriptionResponse
|
||||||
|
from app.schemas.user import UserResponse
|
||||||
|
from app.schemas.pipeline import PipelineConfigResponse, PipelineConfigUpdate, PipelineStatsEntry, PipelineStatsSummary
|
||||||
|
from app.services.debug_service import DebugService
|
||||||
|
from app.services.feedback_service import FeedbackService
|
||||||
|
from app.services.pipeline_service import PipelineService
|
||||||
|
from app.services.tariff_service import TariffService
|
||||||
|
from app.services.two_factor_service import is_2fa_globally_enabled, set_2fa_globally_enabled
|
||||||
|
from app.services.user_service import UserService
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──
|
||||||
|
|
||||||
|
def _user_to_response(u: User) -> UserResponse:
|
||||||
|
return UserResponse(
|
||||||
|
id=str(u.id),
|
||||||
|
email=u.email,
|
||||||
|
display_name=u.display_name,
|
||||||
|
avatar_url=u.avatar_url,
|
||||||
|
is_active=u.is_active,
|
||||||
|
is_superuser=u.is_superuser,
|
||||||
|
role=u.role or "user",
|
||||||
|
is_owner=u.is_owner,
|
||||||
|
permissions=u.permissions,
|
||||||
|
accepted_terms_at=u.accepted_terms_at,
|
||||||
|
accepted_terms_version=u.accepted_terms_version,
|
||||||
|
oauth_provider=u.oauth_provider,
|
||||||
|
created_at=u.created_at,
|
||||||
|
updated_at=u.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_to_response(l: LogEntry) -> LogEntryResponse:
|
||||||
|
import json
|
||||||
|
details = None
|
||||||
|
if l.details:
|
||||||
|
try:
|
||||||
|
details = json.loads(l.details)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
details = {"raw": l.details}
|
||||||
|
return LogEntryResponse(
|
||||||
|
id=str(l.id),
|
||||||
|
level=l.level,
|
||||||
|
source=l.source,
|
||||||
|
message=l.message,
|
||||||
|
details=details,
|
||||||
|
user_id=str(l.user_id) if l.user_id else None,
|
||||||
|
created_at=l.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_to_response(a: AgentConfig) -> AgentInfoResponse:
|
||||||
|
return AgentInfoResponse(
|
||||||
|
agent_name=a.agent_name,
|
||||||
|
description=getattr(a, "description", ""),
|
||||||
|
is_enabled=a.is_enabled,
|
||||||
|
version=a.version,
|
||||||
|
last_run_at=a.last_run_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _feature_to_response(t: BacklogTask) -> FeatureResponse:
|
||||||
|
return FeatureResponse(
|
||||||
|
id=str(t.id),
|
||||||
|
title=t.title,
|
||||||
|
description=t.description,
|
||||||
|
priority=t.priority,
|
||||||
|
status=t.status,
|
||||||
|
category=t.category or "feature",
|
||||||
|
source_agent=t.source_agent,
|
||||||
|
created_at=t.created_at,
|
||||||
|
updated_at=t.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Users ──
|
||||||
|
|
||||||
|
@router.get("/users", response_model=list[UserResponse])
|
||||||
|
async def list_users(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(100, ge=1, le=200),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
):
|
||||||
|
service = UserService(db)
|
||||||
|
users = await service.list_users(skip=skip, limit=limit, search=search)
|
||||||
|
return [_user_to_response(u) for u in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{user_id}", response_model=UserResponse)
|
||||||
|
async def update_user(
|
||||||
|
user_id: str,
|
||||||
|
body: UserAdminUpdate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = UserService(db)
|
||||||
|
updates = body.model_dump(exclude_none=True)
|
||||||
|
result = await service.admin_update_user(user_id, updates)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
return _user_to_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_user(
|
||||||
|
user_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = UserService(db)
|
||||||
|
success = await service.hard_delete(user_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Agents ──
|
||||||
|
|
||||||
|
@router.get("/agents", response_model=list[AgentInfoResponse])
|
||||||
|
async def list_agents(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(AgentConfig).order_by(AgentConfig.agent_name))
|
||||||
|
agents = result.scalars().all()
|
||||||
|
return [_agent_to_response(a) for a in agents]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/agents/{agent_name}", response_model=AgentInfoResponse)
|
||||||
|
async def update_agent(
|
||||||
|
agent_name: str,
|
||||||
|
body: AgentUpdateRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(AgentConfig).where(AgentConfig.agent_name == agent_name)
|
||||||
|
)
|
||||||
|
agent = result.scalar_one_or_none()
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
|
||||||
|
|
||||||
|
if body.description is not None:
|
||||||
|
agent.description = body.description
|
||||||
|
if body.is_enabled is not None:
|
||||||
|
agent.is_enabled = body.is_enabled
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(agent)
|
||||||
|
return _agent_to_response(agent)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Logs ──
|
||||||
|
|
||||||
|
@router.get("/logs", response_model=list[LogEntryResponse])
|
||||||
|
async def list_logs(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
level: Optional[str] = Query(None, description="ERROR | WARNING | INFO | DEBUG"),
|
||||||
|
source: Optional[str] = Query(None),
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
):
|
||||||
|
query = select(LogEntry).order_by(LogEntry.created_at.desc())
|
||||||
|
if level:
|
||||||
|
query = query.where(LogEntry.level == level.upper())
|
||||||
|
if source:
|
||||||
|
query = query.where(LogEntry.source.ilike(f"%{source}%"))
|
||||||
|
result = await db.execute(query.offset(skip).limit(limit))
|
||||||
|
logs = result.scalars().all()
|
||||||
|
return [_log_to_response(l) for l in logs]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Feedback ──
|
||||||
|
|
||||||
|
@router.get("/feedback", response_model=list[FeedbackResponse])
|
||||||
|
async def list_feedback(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
status_filter: Optional[str] = Query(None, alias="status"),
|
||||||
|
):
|
||||||
|
svc = FeedbackService(db)
|
||||||
|
items = await svc.list_feedback(status=status_filter)
|
||||||
|
return [
|
||||||
|
FeedbackResponse(
|
||||||
|
id=str(f.id),
|
||||||
|
user_id=str(f.user_id) if f.user_id else None,
|
||||||
|
text=f.text,
|
||||||
|
page_url=f.page_url,
|
||||||
|
status=f.status,
|
||||||
|
created_at=f.created_at,
|
||||||
|
updated_at=f.updated_at,
|
||||||
|
)
|
||||||
|
for f in items
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/feedback/{feedback_id}", response_model=FeedbackResponse)
|
||||||
|
async def update_feedback(
|
||||||
|
feedback_id: str,
|
||||||
|
body: FeedbackUpdate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = FeedbackService(db)
|
||||||
|
fb = await svc.update_status(UUID(feedback_id), body.status)
|
||||||
|
if not fb:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feedback not found")
|
||||||
|
return FeedbackResponse(
|
||||||
|
id=str(fb.id),
|
||||||
|
user_id=str(fb.user_id) if fb.user_id else None,
|
||||||
|
text=fb.text,
|
||||||
|
page_url=fb.page_url,
|
||||||
|
status=fb.status,
|
||||||
|
created_at=fb.created_at,
|
||||||
|
updated_at=fb.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/feedback/{feedback_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_feedback(
|
||||||
|
feedback_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = FeedbackService(db)
|
||||||
|
success = await svc.delete(UUID(feedback_id))
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feedback not found")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2FA Global Toggle ──
|
||||||
|
|
||||||
|
@router.get("/2fa/status")
|
||||||
|
async def get_2fa_status(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
globally_enabled = await is_2fa_globally_enabled(db)
|
||||||
|
return {"globally_enabled": globally_enabled}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/2fa/toggle")
|
||||||
|
async def toggle_2fa(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if user.role not in ("admin", "owner") and not user.is_superuser:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только администратор может управлять 2FA")
|
||||||
|
current = await is_2fa_globally_enabled(db)
|
||||||
|
await set_2fa_globally_enabled(db, not current)
|
||||||
|
return {"globally_enabled": not current}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Debug Mode ──
|
||||||
|
|
||||||
|
@router.get("/debug/config")
|
||||||
|
async def get_debug_config(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = DebugService(db)
|
||||||
|
return await svc.get_config()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/debug/config")
|
||||||
|
async def update_debug_config(
|
||||||
|
body: dict,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = DebugService(db)
|
||||||
|
return await svc.update_config(body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/debug/enable")
|
||||||
|
async def enable_debug_mode(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if not user.is_owner:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только владелец может включить debug mode")
|
||||||
|
svc = DebugService(db)
|
||||||
|
return await svc.enable_debug_mode()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/debug/disable")
|
||||||
|
async def disable_debug_mode(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if not user.is_owner:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только владелец может выключить debug mode")
|
||||||
|
svc = DebugService(db)
|
||||||
|
return await svc.disable_debug_mode()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/debug/status")
|
||||||
|
async def get_debug_status(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = DebugService(db)
|
||||||
|
return await svc.get_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/debug/cleanup")
|
||||||
|
async def cleanup_logs(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if not user.is_owner:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только владелец может очистить логи")
|
||||||
|
svc = DebugService(db)
|
||||||
|
return await svc.cleanup_logs(force=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Log Export ──
|
||||||
|
|
||||||
|
@router.get("/logs/export")
|
||||||
|
async def export_logs(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
level: Optional[str] = None,
|
||||||
|
source: Optional[str] = None,
|
||||||
|
):
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
query = select(LogEntry).order_by(LogEntry.created_at.desc())
|
||||||
|
if level:
|
||||||
|
query = query.where(LogEntry.level == level.upper())
|
||||||
|
if source:
|
||||||
|
query = query.where(LogEntry.source.ilike(f"%{source}%"))
|
||||||
|
result = await db.execute(query)
|
||||||
|
logs = result.scalars().all()
|
||||||
|
|
||||||
|
export_data = []
|
||||||
|
for l in logs:
|
||||||
|
details = None
|
||||||
|
if l.details:
|
||||||
|
try:
|
||||||
|
details = json.loads(l.details)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
details = {"raw": l.details}
|
||||||
|
export_data.append({
|
||||||
|
"id": str(l.id),
|
||||||
|
"level": l.level,
|
||||||
|
"source": l.source,
|
||||||
|
"message": l.message,
|
||||||
|
"details": details,
|
||||||
|
"user_id": str(l.user_id) if l.user_id else None,
|
||||||
|
"created_at": l.created_at.isoformat() if l.created_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=json.dumps(export_data, ensure_ascii=False, default=str),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename=voidea-logs-{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tariffs ──
|
||||||
|
|
||||||
|
@router.get("/tariffs", response_model=list[TariffPlanResponse])
|
||||||
|
async def list_tariff_plans(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = TariffService(db)
|
||||||
|
plans = await svc.list_plans(active_only=False)
|
||||||
|
return [
|
||||||
|
TariffPlanResponse(
|
||||||
|
id=str(p.id),
|
||||||
|
name=p.name,
|
||||||
|
code=p.code,
|
||||||
|
description=p.description,
|
||||||
|
price_monthly=p.price_monthly,
|
||||||
|
price_yearly=p.price_yearly,
|
||||||
|
features=p.features,
|
||||||
|
is_active=p.is_active,
|
||||||
|
sort_order=p.sort_order,
|
||||||
|
created_at=p.created_at,
|
||||||
|
updated_at=p.updated_at,
|
||||||
|
)
|
||||||
|
for p in plans
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tariffs", response_model=TariffPlanResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_tariff_plan(
|
||||||
|
body: TariffPlanCreate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = TariffService(db)
|
||||||
|
plan = await svc.create_plan(
|
||||||
|
name=body.name,
|
||||||
|
code=body.code,
|
||||||
|
description=body.description,
|
||||||
|
price_monthly=body.price_monthly,
|
||||||
|
price_yearly=body.price_yearly,
|
||||||
|
features=body.features,
|
||||||
|
is_active=body.is_active,
|
||||||
|
sort_order=body.sort_order,
|
||||||
|
)
|
||||||
|
return TariffPlanResponse(
|
||||||
|
id=str(plan.id),
|
||||||
|
name=plan.name,
|
||||||
|
code=plan.code,
|
||||||
|
description=plan.description,
|
||||||
|
price_monthly=plan.price_monthly,
|
||||||
|
price_yearly=plan.price_yearly,
|
||||||
|
features=plan.features,
|
||||||
|
is_active=plan.is_active,
|
||||||
|
sort_order=plan.sort_order,
|
||||||
|
created_at=plan.created_at,
|
||||||
|
updated_at=plan.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/tariffs/{plan_id}", response_model=TariffPlanResponse)
|
||||||
|
async def update_tariff_plan(
|
||||||
|
plan_id: str,
|
||||||
|
body: TariffPlanUpdate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = TariffService(db)
|
||||||
|
plan = await svc.update_plan(UUID(plan_id), body.model_dump(exclude_none=True))
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tariff plan not found")
|
||||||
|
return TariffPlanResponse(
|
||||||
|
id=str(plan.id),
|
||||||
|
name=plan.name,
|
||||||
|
code=plan.code,
|
||||||
|
description=plan.description,
|
||||||
|
price_monthly=plan.price_monthly,
|
||||||
|
price_yearly=plan.price_yearly,
|
||||||
|
features=plan.features,
|
||||||
|
is_active=plan.is_active,
|
||||||
|
sort_order=plan.sort_order,
|
||||||
|
created_at=plan.created_at,
|
||||||
|
updated_at=plan.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tariffs/{plan_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_tariff_plan(
|
||||||
|
plan_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = TariffService(db)
|
||||||
|
success = await svc.delete_plan(UUID(plan_id))
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tariff plan not found")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Features (BacklogTask category=feature) ──
|
||||||
|
|
||||||
|
@router.get("/features", response_model=list[FeatureResponse])
|
||||||
|
async def list_features(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(BacklogTask)
|
||||||
|
.where(BacklogTask.category == "feature")
|
||||||
|
.order_by(BacklogTask.created_at.desc())
|
||||||
|
)
|
||||||
|
return [_feature_to_response(t) for t in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/features", response_model=FeatureResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_feature(
|
||||||
|
body: FeatureCreate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
task = BacklogTask(
|
||||||
|
title=body.title,
|
||||||
|
description=body.description,
|
||||||
|
priority=body.priority or "medium",
|
||||||
|
status="pending",
|
||||||
|
category="feature",
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(task)
|
||||||
|
return _feature_to_response(task)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/features/{feature_id}", response_model=FeatureResponse)
|
||||||
|
async def update_feature(
|
||||||
|
feature_id: str,
|
||||||
|
body: FeatureUpdate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(BacklogTask).where(
|
||||||
|
BacklogTask.id == UUID(feature_id),
|
||||||
|
BacklogTask.category == "feature",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
task = result.scalar_one_or_none()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature not found")
|
||||||
|
|
||||||
|
updates = body.model_dump(exclude_none=True)
|
||||||
|
for key, value in updates.items():
|
||||||
|
if hasattr(task, key) and key not in ("id", "category", "created_at"):
|
||||||
|
setattr(task, key, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(task)
|
||||||
|
return _feature_to_response(task)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pipeline ──
|
||||||
|
|
||||||
|
@router.get("/pipeline", response_model=PipelineConfigResponse)
|
||||||
|
async def get_pipeline_config(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = PipelineService(db)
|
||||||
|
return await svc.get_config()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/pipeline", response_model=PipelineConfigResponse)
|
||||||
|
async def update_pipeline_config(
|
||||||
|
body: PipelineConfigUpdate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = PipelineService(db)
|
||||||
|
return await svc.update_config(body.model_dump(exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pipeline/stats", response_model=list[PipelineStatsEntry])
|
||||||
|
async def list_pipeline_stats(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
stage: Optional[str] = Query(None),
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
):
|
||||||
|
svc = PipelineService(db)
|
||||||
|
stats = await svc.list_stats(stage=stage, skip=skip, limit=limit)
|
||||||
|
return [
|
||||||
|
PipelineStatsEntry(
|
||||||
|
id=str(s.id),
|
||||||
|
user_id=str(s.user_id) if s.user_id else None,
|
||||||
|
stage=s.stage,
|
||||||
|
passed=s.passed,
|
||||||
|
reason=s.reason,
|
||||||
|
duration_ms=s.duration_ms,
|
||||||
|
created_at=s.created_at,
|
||||||
|
)
|
||||||
|
for s in stats
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pipeline/stats/summary", response_model=PipelineStatsSummary)
|
||||||
|
async def pipeline_stats_summary(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = PipelineService(db)
|
||||||
|
return await svc.get_summary()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Services (owner only) ──
|
||||||
|
|
||||||
|
SERVICE_MAP = {
|
||||||
|
"api": {
|
||||||
|
"name": "VoIdea API",
|
||||||
|
"unit": "voidea-api",
|
||||||
|
"description": "FastAPI приложение — основной веб-сервер для обработки запросов",
|
||||||
|
},
|
||||||
|
"worker": {
|
||||||
|
"name": "VoIdea Worker",
|
||||||
|
"unit": "voidea-worker",
|
||||||
|
"description": "Celery worker — асинхронная обработка задач (анализ идей, AI запросы)",
|
||||||
|
},
|
||||||
|
"beat": {
|
||||||
|
"name": "VoIdea Beat",
|
||||||
|
"unit": "voidea-beat",
|
||||||
|
"description": "Celery beat — планировщик периодических задач",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _systemctl(unit: str, action: str) -> tuple[bool, str]:
|
||||||
|
"""Run systemctl command and return (success, message)."""
|
||||||
|
cmd = ["systemctl", action, f"{unit}.service"]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip() or f"{action.capitalize()} successful"
|
||||||
|
return False, result.stderr.strip() or f"{action.capitalize()} failed"
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False, "systemctl not found (not a systemd system)"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "Command timed out"
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/services/restart", response_model=list[ServiceActionResult])
|
||||||
|
async def restart_services(
|
||||||
|
body: ServiceActionRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
):
|
||||||
|
"""Restart system services. Owner only. Only works in production."""
|
||||||
|
if not user.is_owner:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Только владелец системы может управлять сервисами",
|
||||||
|
)
|
||||||
|
|
||||||
|
results: list[ServiceActionResult] = []
|
||||||
|
|
||||||
|
if body.service == "all":
|
||||||
|
targets = list(SERVICE_MAP.keys())
|
||||||
|
elif body.service in SERVICE_MAP:
|
||||||
|
targets = [body.service]
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Unknown service: {body.service}. Available: {', '.join(SERVICE_MAP.keys())}, all",
|
||||||
|
)
|
||||||
|
|
||||||
|
for svc in targets:
|
||||||
|
info = SERVICE_MAP[svc]
|
||||||
|
success, message = await _systemctl(info["unit"], body.action)
|
||||||
|
results.append(ServiceActionResult(
|
||||||
|
service=svc,
|
||||||
|
action=body.action,
|
||||||
|
success=success,
|
||||||
|
message=message,
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/services", response_model=list[ServiceStatusResponse])
|
||||||
|
async def list_services(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
):
|
||||||
|
"""List all available services and their status. Owner only."""
|
||||||
|
results: list[ServiceStatusResponse] = []
|
||||||
|
for svc_key, info in SERVICE_MAP.items():
|
||||||
|
success, status_text = await _systemctl(info["unit"], "is-active")
|
||||||
|
is_running = success and status_text.strip() == "active"
|
||||||
|
results.append(ServiceStatusResponse(
|
||||||
|
service=svc_key,
|
||||||
|
description=info["description"],
|
||||||
|
status=status_text.strip() if status_text else "unknown",
|
||||||
|
is_running=is_running,
|
||||||
|
))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ── System ──
|
||||||
|
|
||||||
|
@router.get("/health", response_model=SystemHealth)
|
||||||
|
async def health(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
db_status = "connected"
|
||||||
|
redis_status = "unknown"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from redis import asyncio as aioredis
|
||||||
|
r = aioredis.from_url(settings.redis_url, socket_connect_timeout=1)
|
||||||
|
await r.ping()
|
||||||
|
redis_status = "connected"
|
||||||
|
await r.aclose()
|
||||||
|
except Exception:
|
||||||
|
redis_status = "disconnected"
|
||||||
|
|
||||||
|
return SystemHealth(
|
||||||
|
status="healthy",
|
||||||
|
database=db_status,
|
||||||
|
redis=redis_status,
|
||||||
|
version=settings.project_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bot Commands ──
|
||||||
|
|
||||||
|
@router.get("/bot", response_model=list[BotCommandResponse])
|
||||||
|
async def list_bot_commands(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(BotCommand).order_by(BotCommand.name)
|
||||||
|
)
|
||||||
|
commands = result.scalars().all()
|
||||||
|
return [
|
||||||
|
BotCommandResponse(
|
||||||
|
id=str(c.id),
|
||||||
|
name=c.name,
|
||||||
|
description=c.description,
|
||||||
|
enabled=c.enabled,
|
||||||
|
requires_auth=c.requires_auth,
|
||||||
|
created_at=c.created_at,
|
||||||
|
updated_at=c.updated_at,
|
||||||
|
)
|
||||||
|
for c in commands
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/bot/{command_id}", response_model=BotCommandResponse)
|
||||||
|
async def toggle_bot_command(
|
||||||
|
command_id: str,
|
||||||
|
body: BotCommandUpdate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from uuid import UUID
|
||||||
|
result = await db.execute(
|
||||||
|
select(BotCommand).where(BotCommand.id == UUID(command_id))
|
||||||
|
)
|
||||||
|
cmd = result.scalar_one_or_none()
|
||||||
|
if not cmd:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Command not found")
|
||||||
|
|
||||||
|
if body.enabled is not None:
|
||||||
|
cmd.enabled = body.enabled
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(cmd)
|
||||||
|
return BotCommandResponse(
|
||||||
|
id=str(cmd.id),
|
||||||
|
name=cmd.name,
|
||||||
|
description=cmd.description,
|
||||||
|
enabled=cmd.enabled,
|
||||||
|
requires_auth=cmd.requires_auth,
|
||||||
|
created_at=cmd.created_at,
|
||||||
|
updated_at=cmd.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bot/sync")
|
||||||
|
async def sync_bot_commands(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from app.integrations.telegram.sync_service import TelegramBotSyncService
|
||||||
|
from app.integrations.telegram.client import TelegramBotClient
|
||||||
|
from app.core.config import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
bot_client = TelegramBotClient(token=settings.telegram_bot_token)
|
||||||
|
svc = TelegramBotSyncService(db, bot_client)
|
||||||
|
result = await svc.full_sync()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── System ──
|
||||||
|
|
||||||
|
@router.get("/system", response_model=SystemInfoResponse)
|
||||||
|
async def system_info(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
db_status = "connected"
|
||||||
|
redis_status = "unknown"
|
||||||
|
try:
|
||||||
|
from redis import asyncio as aioredis
|
||||||
|
r = aioredis.from_url(settings.redis_url, socket_connect_timeout=1)
|
||||||
|
await r.ping()
|
||||||
|
redis_status = "connected"
|
||||||
|
await r.aclose()
|
||||||
|
except Exception:
|
||||||
|
redis_status = "disconnected"
|
||||||
|
|
||||||
|
return SystemInfoResponse(
|
||||||
|
version=settings.project_version,
|
||||||
|
environment=settings.project_env,
|
||||||
|
database_status=db_status,
|
||||||
|
redis_status=redis_status,
|
||||||
|
python_version=platform.python_version(),
|
||||||
|
uptime_seconds=None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Agents API routes for VoIdea."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from app.agents.registry import registry
|
||||||
|
from app.core.dependencies import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.agent import AgentRunRequest, AgentStatusResponse
|
||||||
|
from app.services.agent_service import AgentService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_service() -> AgentService:
|
||||||
|
return AgentService(registry)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[AgentStatusResponse])
|
||||||
|
async def list_agents(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
):
|
||||||
|
service = get_agent_service()
|
||||||
|
agents = service.list_agents()
|
||||||
|
return [AgentStatusResponse(**a) for a in agents]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{agent_name}", response_model=AgentStatusResponse)
|
||||||
|
async def get_agent(
|
||||||
|
agent_name: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
):
|
||||||
|
service = get_agent_service()
|
||||||
|
agent = service.get_agent(agent_name)
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
|
||||||
|
return AgentStatusResponse(**agent)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{agent_name}/run")
|
||||||
|
async def run_agent(
|
||||||
|
agent_name: str,
|
||||||
|
body: AgentRunRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
):
|
||||||
|
service = get_agent_service()
|
||||||
|
result = await service.run_agent(agent_name, body.context)
|
||||||
|
if not result["success"] and "not found" in result.get("message", ""):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=result["message"],
|
||||||
|
)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""Auth API routes for VoIdea."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.dependencies import get_current_user, get_db
|
||||||
|
from app.core.limiter import limiter
|
||||||
|
from app.core.security import (
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
verify_password,
|
||||||
|
get_password_hash,
|
||||||
|
)
|
||||||
|
from app.integrations.oauth.yandex import exchange_code, get_authorize_url, get_user_info
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.auth import (
|
||||||
|
ForgotPasswordRequest,
|
||||||
|
LoginRequest,
|
||||||
|
OAuthCallbackRequest,
|
||||||
|
OAuthUrlResponse,
|
||||||
|
RefreshRequest,
|
||||||
|
RegisterRequest,
|
||||||
|
ResetPasswordRequest,
|
||||||
|
TokenResponse,
|
||||||
|
TwoFactorLoginRequest,
|
||||||
|
TwoFactorLoginResponse,
|
||||||
|
TwoFactorSetupResponse,
|
||||||
|
TwoFactorVerifyRequest,
|
||||||
|
)
|
||||||
|
from app.schemas.user import ChangePasswordRequest
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
from app.services.password_reset_service import reset_password, send_reset_email
|
||||||
|
from app.services.two_factor_service import (
|
||||||
|
generate_qr_base64,
|
||||||
|
generate_totp_secret,
|
||||||
|
get_totp_uri,
|
||||||
|
get_user_secret,
|
||||||
|
is_2fa_enabled,
|
||||||
|
is_2fa_globally_enabled,
|
||||||
|
set_2fa_enabled,
|
||||||
|
set_user_secret,
|
||||||
|
verify_totp,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def register(request: Request, body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
service = AuthService(db)
|
||||||
|
try:
|
||||||
|
return await service.register(
|
||||||
|
body.email, body.password, body.display_name,
|
||||||
|
accepted_terms=body.accepted_terms,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def login(request: Request, body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
from sqlalchemy import select
|
||||||
|
result = await db.execute(select(User).where(User.email == body.email))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user or not verify_password(body.password, user.password_hash):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный email или пароль")
|
||||||
|
|
||||||
|
globally_enabled = await is_2fa_globally_enabled(db)
|
||||||
|
if globally_enabled and is_2fa_enabled(user):
|
||||||
|
from datetime import timedelta
|
||||||
|
temp_token = create_access_token(
|
||||||
|
data={"sub": str(user.id), "purpose": "2fa"},
|
||||||
|
expires_delta=timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
return {"temp_token": temp_token, "message": "Требуется 2FA код"}
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
return await service.create_token_response(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/2fa/verify-login", response_model=TwoFactorLoginResponse)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def verify_2fa_login(
|
||||||
|
request: Request,
|
||||||
|
body: TwoFactorLoginRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
from app.core.config import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
body.temp_token, settings.jwt_secret_key,
|
||||||
|
algorithms=[settings.jwt_algorithm],
|
||||||
|
)
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid temp token")
|
||||||
|
|
||||||
|
if payload.get("purpose") != "2fa":
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token purpose")
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
result = await db.execute(select(User).where(User.id == payload.get("sub")))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
|
|
||||||
|
secret = get_user_secret(user)
|
||||||
|
if not secret or not verify_totp(secret, body.totp_code):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный 2FA код")
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
return await service.create_token_response(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
|
||||||
|
async def setup_2fa(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if is_2fa_enabled(user):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA уже включена")
|
||||||
|
|
||||||
|
secret = generate_totp_secret()
|
||||||
|
set_user_secret(user, secret)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
uri = get_totp_uri(secret, user.email)
|
||||||
|
qr = generate_qr_base64(uri)
|
||||||
|
|
||||||
|
return TwoFactorSetupResponse(secret=secret, uri=uri, qr_base64=qr)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/2fa/verify")
|
||||||
|
async def verify_2fa(
|
||||||
|
body: TwoFactorVerifyRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
secret = get_user_secret(user)
|
||||||
|
if not secret:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA не настроена")
|
||||||
|
|
||||||
|
if not verify_totp(secret, body.token):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный код")
|
||||||
|
|
||||||
|
set_2fa_enabled(user, True)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "2FA успешно включена"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/2fa/disable")
|
||||||
|
async def disable_2fa(
|
||||||
|
body: TwoFactorVerifyRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
secret = get_user_secret(user)
|
||||||
|
if not secret or not verify_totp(secret, body.token):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный код")
|
||||||
|
|
||||||
|
set_2fa_enabled(user, False)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "2FA отключена"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def refresh(request: Request, body: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
service = AuthService(db)
|
||||||
|
try:
|
||||||
|
return await service.refresh(body.refresh_token)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/oauth/yandex", response_model=OAuthUrlResponse)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def oauth_yandex_url(request: Request):
|
||||||
|
url = await get_authorize_url()
|
||||||
|
return OAuthUrlResponse(url=url, provider="yandex")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/oauth/yandex/callback", response_model=TokenResponse)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def oauth_yandex_callback(
|
||||||
|
request: Request,
|
||||||
|
body: OAuthCallbackRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
token_result = await exchange_code(body.code)
|
||||||
|
if not token_result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Failed to exchange authorization code",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_info = await get_user_info(token_result.access_token)
|
||||||
|
if not user_info:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Failed to get user info from Yandex",
|
||||||
|
)
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
try:
|
||||||
|
return await service.oauth_or_register_login(
|
||||||
|
email=user_info.email,
|
||||||
|
oauth_provider="yandex",
|
||||||
|
oauth_id=user_info.id,
|
||||||
|
display_name=user_info.display_name,
|
||||||
|
avatar_url=user_info.avatar_url,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/forgot-password")
|
||||||
|
@limiter.limit("3/minute")
|
||||||
|
async def forgot_password(
|
||||||
|
request: Request,
|
||||||
|
body: ForgotPasswordRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
success, message = await send_reset_email(db, body.email)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=message,
|
||||||
|
)
|
||||||
|
return {"message": message}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-password")
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def reset_password_endpoint(
|
||||||
|
request: Request,
|
||||||
|
body: ResetPasswordRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
success, message = await reset_password(db, body.token, body.new_password)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=message,
|
||||||
|
)
|
||||||
|
return {"message": message}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def change_password(
|
||||||
|
request: Request,
|
||||||
|
body: ChangePasswordRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if not verify_password(body.current_password, user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Неверный текущий пароль",
|
||||||
|
)
|
||||||
|
|
||||||
|
user.password_hash = get_password_hash(body.new_password)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "Пароль успешно изменён"}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Public config API routes for VoIdea."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.schemas.config import PublicConfigResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/public", response_model=PublicConfigResponse)
|
||||||
|
async def public_config():
|
||||||
|
"""Return non-sensitive public configuration (slogan, social links, analytics IDs)."""
|
||||||
|
return settings.public_config
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Feedback API routes for VoIdea."""
|
||||||
|
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.dependencies import get_current_user, get_db, get_optional_user
|
||||||
|
from app.core.limiter import limiter
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.feedback import FeedbackCreate, FeedbackResponse
|
||||||
|
from app.services.feedback_service import FeedbackService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=FeedbackResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def create_feedback(
|
||||||
|
request: Request,
|
||||||
|
body: FeedbackCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: Optional[User] = Depends(get_optional_user),
|
||||||
|
):
|
||||||
|
service = FeedbackService(db)
|
||||||
|
fb = await service.create(
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
text=body.text,
|
||||||
|
page_url=body.page_url,
|
||||||
|
)
|
||||||
|
return FeedbackResponse(
|
||||||
|
id=str(fb.id),
|
||||||
|
user_id=str(fb.user_id) if fb.user_id else None,
|
||||||
|
text=fb.text,
|
||||||
|
page_url=fb.page_url,
|
||||||
|
status=fb.status,
|
||||||
|
created_at=fb.created_at,
|
||||||
|
updated_at=fb.updated_at,
|
||||||
|
)
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""Ideas API routes for VoIdea."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.core.dependencies import get_current_user, get_db, get_optional_user
|
||||||
|
from app.models.idea import Idea
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.idea import (
|
||||||
|
AnalysisResultResponse,
|
||||||
|
IdeaAnalyzeResponse,
|
||||||
|
IdeaCreate,
|
||||||
|
IdeaResponse,
|
||||||
|
IdeaUpdate,
|
||||||
|
)
|
||||||
|
from app.services.analysis_service import AnalysisService
|
||||||
|
from app.services.export_service import export_content
|
||||||
|
from app.services.idea_service import IdeaService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _idea_to_response(idea) -> IdeaResponse:
|
||||||
|
return IdeaResponse(
|
||||||
|
id=str(idea.id),
|
||||||
|
user_id=str(idea.user_id),
|
||||||
|
title=idea.title,
|
||||||
|
content=idea.content,
|
||||||
|
status=idea.status,
|
||||||
|
tags=idea.tags,
|
||||||
|
is_public=idea.is_public,
|
||||||
|
public_slug=idea.public_slug,
|
||||||
|
created_at=idea.created_at,
|
||||||
|
updated_at=idea.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[IdeaResponse])
|
||||||
|
async def list_ideas(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
):
|
||||||
|
service = IdeaService(db)
|
||||||
|
ideas = await service.list_by_user(str(user.id), skip=skip, limit=limit)
|
||||||
|
return [_idea_to_response(idea) for idea in ideas]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=IdeaResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_idea(
|
||||||
|
body: IdeaCreate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = IdeaService(db)
|
||||||
|
idea = await service.create(
|
||||||
|
user_id=str(user.id),
|
||||||
|
title=body.title,
|
||||||
|
content=body.content,
|
||||||
|
tags=body.tags,
|
||||||
|
is_public=body.is_public,
|
||||||
|
)
|
||||||
|
return _idea_to_response(idea)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{idea_id}", response_model=IdeaResponse)
|
||||||
|
async def get_idea(
|
||||||
|
idea_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = IdeaService(db)
|
||||||
|
idea = await service.get_by_id(idea_id)
|
||||||
|
if not idea or str(idea.user_id) != str(user.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||||
|
return _idea_to_response(idea)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{idea_id}", response_model=IdeaResponse)
|
||||||
|
async def update_idea(
|
||||||
|
idea_id: str,
|
||||||
|
body: IdeaUpdate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = IdeaService(db)
|
||||||
|
idea = await service.update(
|
||||||
|
idea_id,
|
||||||
|
str(user.id),
|
||||||
|
title=body.title,
|
||||||
|
content=body.content,
|
||||||
|
status=body.status,
|
||||||
|
tags=body.tags,
|
||||||
|
is_public=body.is_public,
|
||||||
|
)
|
||||||
|
if not idea:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||||
|
return _idea_to_response(idea)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{idea_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_idea(
|
||||||
|
idea_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = IdeaService(db)
|
||||||
|
deleted = await service.delete(idea_id, str(user.id))
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{idea_id}/analyze", response_model=IdeaAnalyzeResponse)
|
||||||
|
async def analyze_idea(
|
||||||
|
idea_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
idea_service = IdeaService(db)
|
||||||
|
idea = await idea_service.get_by_id(idea_id)
|
||||||
|
if not idea or str(idea.user_id) != str(user.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||||
|
|
||||||
|
analysis_service = AnalysisService(db)
|
||||||
|
result = await analysis_service.start_analysis(idea_id)
|
||||||
|
|
||||||
|
return IdeaAnalyzeResponse(
|
||||||
|
status="started",
|
||||||
|
idea_id=idea_id,
|
||||||
|
task_count=result["task_count"],
|
||||||
|
tasks=result["tasks"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{idea_id}/analysis", response_model=list[AnalysisResultResponse])
|
||||||
|
async def get_analysis_results(
|
||||||
|
idea_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
role: str = Query(None, description="Filter by agent role"),
|
||||||
|
):
|
||||||
|
idea_service = IdeaService(db)
|
||||||
|
idea = await idea_service.get_by_id(idea_id)
|
||||||
|
if not idea or str(idea.user_id) != str(user.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||||
|
|
||||||
|
analysis_service = AnalysisService(db)
|
||||||
|
results = await analysis_service.get_analysis_results(idea_id, role=role)
|
||||||
|
|
||||||
|
return [AnalysisResultResponse(**r) for r in results]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{idea_id}/export")
|
||||||
|
async def export_idea(
|
||||||
|
idea_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
format: str = Query("md", regex="^(json|md|html)$"),
|
||||||
|
):
|
||||||
|
idea_service = IdeaService(db)
|
||||||
|
idea = await idea_service.get_by_id(idea_id)
|
||||||
|
if not idea or str(idea.user_id) != str(user.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"ID": str(idea.id),
|
||||||
|
"Статус": idea.status,
|
||||||
|
"Теги": ", ".join(idea.tags) if idea.tags else "—",
|
||||||
|
"Создано": idea.created_at.isoformat() if idea.created_at else "—",
|
||||||
|
}
|
||||||
|
body, content_type, ext = export_content(format, idea.title, idea.content, metadata)
|
||||||
|
filename = f"{idea.title[:50]}.{ext}".replace(" ", "_")
|
||||||
|
|
||||||
|
from fastapi.responses import Response
|
||||||
|
return Response(
|
||||||
|
content=body,
|
||||||
|
media_type=content_type,
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{idea_id}/share", response_model=IdeaResponse)
|
||||||
|
async def toggle_idea_share(
|
||||||
|
idea_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = IdeaService(db)
|
||||||
|
idea = await service.get_by_id(idea_id)
|
||||||
|
if not idea or str(idea.user_id) != str(user.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||||
|
|
||||||
|
idea.is_public = not idea.is_public
|
||||||
|
if idea.is_public and not idea.public_slug:
|
||||||
|
idea.public_slug = uuid4().hex[:16]
|
||||||
|
elif not idea.is_public:
|
||||||
|
idea.public_slug = None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(idea)
|
||||||
|
return _idea_to_response(idea)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/demo", response_model=IdeaResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_demo_idea(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = IdeaService(db)
|
||||||
|
idea = await service.create(
|
||||||
|
user_id=str(user.id),
|
||||||
|
title="Моя первая идея (демо)",
|
||||||
|
content="Пример идеи для знакомства с VoIdeaAI. Замените этот текст на свою идею.\n\n"
|
||||||
|
"VoIdeaAI поможет проанализировать её с разных сторон: бизнес, финансы, "
|
||||||
|
"право, технологии и маркетинг. Просто нажмите «Анализ» на странице идеи.",
|
||||||
|
tags=["demo", "привет"],
|
||||||
|
is_public=False,
|
||||||
|
)
|
||||||
|
return _idea_to_response(idea)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shared/{slug}", response_model=IdeaResponse)
|
||||||
|
async def get_shared_idea(
|
||||||
|
slug: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Idea).where(Idea.public_slug == slug, Idea.is_public == True)
|
||||||
|
)
|
||||||
|
idea = result.scalar_one_or_none()
|
||||||
|
if not idea:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found or not shared")
|
||||||
|
return _idea_to_response(idea)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Sync API routes for VoIdea."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.dependencies import get_current_user, get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.sync import SyncPullRequest, SyncPushRequest, SyncResponse
|
||||||
|
from app.services.sync_service import SyncService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pull", response_model=SyncResponse)
|
||||||
|
async def pull(
|
||||||
|
body: SyncPullRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = SyncService(db)
|
||||||
|
return await service.pull(user, last_sync=body.last_sync)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/push", response_model=SyncResponse)
|
||||||
|
async def push(
|
||||||
|
body: SyncPushRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = SyncService(db)
|
||||||
|
return await service.push(user, device_id=body.device_id, changes=body.changes)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Public tariff API routes for VoIdea."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.dependencies import get_db
|
||||||
|
from app.schemas.tariff import TariffPlanResponse
|
||||||
|
from app.services.tariff_service import TariffService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[TariffPlanResponse])
|
||||||
|
async def list_tariffs(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Return all active tariff plans (public, no auth required)."""
|
||||||
|
service = TariffService(db)
|
||||||
|
plans = await service.list_plans(active_only=True)
|
||||||
|
return [
|
||||||
|
TariffPlanResponse(
|
||||||
|
id=str(p.id),
|
||||||
|
name=p.name,
|
||||||
|
code=p.code,
|
||||||
|
description=p.description,
|
||||||
|
price_monthly=p.price_monthly,
|
||||||
|
price_yearly=p.price_yearly,
|
||||||
|
features=p.features,
|
||||||
|
is_active=p.is_active,
|
||||||
|
sort_order=p.sort_order,
|
||||||
|
created_at=p.created_at,
|
||||||
|
updated_at=p.updated_at,
|
||||||
|
)
|
||||||
|
for p in plans
|
||||||
|
]
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Users API routes for VoIdea."""
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.dependencies import get_current_user, get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import SubscriptionInfo, UserResponse, UserUpdate, VoiceSettingsResponse, VoiceSettingsUpdate
|
||||||
|
from app.services.tariff_service import TariffService
|
||||||
|
from app.services.user_service import UserService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _user_to_response(user: User) -> UserResponse:
|
||||||
|
return UserResponse(
|
||||||
|
id=str(user.id),
|
||||||
|
email=user.email,
|
||||||
|
display_name=user.display_name,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
is_active=user.is_active,
|
||||||
|
is_superuser=user.is_superuser,
|
||||||
|
oauth_provider=user.oauth_provider,
|
||||||
|
created_at=user.created_at,
|
||||||
|
updated_at=user.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
async def get_me(user: Annotated[User, Depends(get_current_user)]):
|
||||||
|
return _user_to_response(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me", response_model=UserResponse)
|
||||||
|
async def update_me(
|
||||||
|
body: UserUpdate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = UserService(db)
|
||||||
|
result = await service.update_profile(
|
||||||
|
str(user.id),
|
||||||
|
display_name=body.display_name,
|
||||||
|
avatar_url=body.avatar_url,
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
return _user_to_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_me(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = UserService(db)
|
||||||
|
await service.delete(str(user.id))
|
||||||
|
|
||||||
|
|
||||||
|
VOICE_PRESETS: dict[str, dict[str, Any]] = {
|
||||||
|
"standard": {
|
||||||
|
"vad_noise_threshold": 0.3,
|
||||||
|
"vad_silence_timeout_ms": 1500,
|
||||||
|
"confidence_verified": 80,
|
||||||
|
"confidence_warning": 50,
|
||||||
|
"semantic_mode": "fast",
|
||||||
|
"tts_enabled": True,
|
||||||
|
"continuous_listening": False,
|
||||||
|
},
|
||||||
|
"precise": {
|
||||||
|
"vad_noise_threshold": 0.2,
|
||||||
|
"vad_silence_timeout_ms": 2000,
|
||||||
|
"confidence_verified": 85,
|
||||||
|
"confidence_warning": 60,
|
||||||
|
"semantic_mode": "full",
|
||||||
|
"tts_enabled": True,
|
||||||
|
"continuous_listening": False,
|
||||||
|
},
|
||||||
|
"fast": {
|
||||||
|
"vad_noise_threshold": 0.4,
|
||||||
|
"vad_silence_timeout_ms": 1000,
|
||||||
|
"confidence_verified": 70,
|
||||||
|
"confidence_warning": 40,
|
||||||
|
"semantic_mode": "fast",
|
||||||
|
"tts_enabled": False,
|
||||||
|
"continuous_listening": True,
|
||||||
|
},
|
||||||
|
"quiet": {
|
||||||
|
"vad_noise_threshold": 0.6,
|
||||||
|
"vad_silence_timeout_ms": 3000,
|
||||||
|
"confidence_verified": 80,
|
||||||
|
"confidence_warning": 50,
|
||||||
|
"semantic_mode": "fast",
|
||||||
|
"tts_enabled": True,
|
||||||
|
"continuous_listening": False,
|
||||||
|
},
|
||||||
|
"expert": {
|
||||||
|
"vad_noise_threshold": 0.15,
|
||||||
|
"vad_silence_timeout_ms": 1000,
|
||||||
|
"confidence_verified": 90,
|
||||||
|
"confidence_warning": 70,
|
||||||
|
"semantic_mode": "full",
|
||||||
|
"tts_enabled": True,
|
||||||
|
"continuous_listening": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/voice-settings", response_model=VoiceSettingsResponse)
|
||||||
|
async def get_voice_settings(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
):
|
||||||
|
tuning = user.pipeline_tuning or {}
|
||||||
|
preset = tuning.get("preset", "standard")
|
||||||
|
overrides = tuning.get("overrides", {})
|
||||||
|
return VoiceSettingsResponse(
|
||||||
|
preset=preset,
|
||||||
|
overrides=overrides,
|
||||||
|
available_presets=list(VOICE_PRESETS.keys()),
|
||||||
|
preset_values=VOICE_PRESETS.get(preset, VOICE_PRESETS["standard"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/voice-settings", response_model=VoiceSettingsResponse)
|
||||||
|
async def update_voice_settings(
|
||||||
|
body: VoiceSettingsUpdate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
tuning = user.pipeline_tuning or {}
|
||||||
|
|
||||||
|
if body.reset:
|
||||||
|
tuning = {"preset": "standard", "overrides": {}}
|
||||||
|
else:
|
||||||
|
if body.preset is not None:
|
||||||
|
if body.preset not in VOICE_PRESETS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Unknown preset: {body.preset}. Available: {', '.join(VOICE_PRESETS.keys())}",
|
||||||
|
)
|
||||||
|
tuning["preset"] = body.preset
|
||||||
|
|
||||||
|
if body.overrides is not None:
|
||||||
|
existing_overrides = tuning.get("overrides", {})
|
||||||
|
existing_overrides.update(body.overrides)
|
||||||
|
tuning["overrides"] = existing_overrides
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user.id))
|
||||||
|
db_user = result.scalar_one()
|
||||||
|
db_user.pipeline_tuning = tuning
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
preset = tuning.get("preset", "standard")
|
||||||
|
preset_vals = VOICE_PRESETS.get(preset, VOICE_PRESETS["standard"])
|
||||||
|
overrides = tuning.get("overrides", {})
|
||||||
|
merged = {**preset_vals, **overrides}
|
||||||
|
|
||||||
|
return VoiceSettingsResponse(
|
||||||
|
preset=preset,
|
||||||
|
overrides=overrides,
|
||||||
|
available_presets=list(VOICE_PRESETS.keys()),
|
||||||
|
preset_values=merged,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/subscription", response_model=SubscriptionInfo)
|
||||||
|
async def get_my_subscription(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = TariffService(db)
|
||||||
|
sub = await svc.get_user_subscription(user.id)
|
||||||
|
if not sub:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Subscription not found"
|
||||||
|
)
|
||||||
|
plan = await svc.get_plan_by_id(sub.plan_id)
|
||||||
|
return SubscriptionInfo(
|
||||||
|
plan_name=plan.name if plan else "Бесплатно",
|
||||||
|
plan_code=plan.code if plan else "free",
|
||||||
|
status=sub.status,
|
||||||
|
expires_at=sub.current_period_end,
|
||||||
|
features=plan.features if plan else None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""Voice transcription and chat API routes for VoIdeaAI."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.agents.conductor_agent import ConductorAgent
|
||||||
|
from app.agents.conductor_storage import rate_interaction, get_session_history
|
||||||
|
from app.agents.role_agents import ALL_ROLE_AGENTS
|
||||||
|
from app.core.dependencies import get_current_user, get_db
|
||||||
|
from app.models.conductor import ConductorInteraction
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.voice import (
|
||||||
|
ChatRequest,
|
||||||
|
ChatResponse,
|
||||||
|
CommandCreate,
|
||||||
|
CommandResponse,
|
||||||
|
CreateSessionRequest,
|
||||||
|
RateRequest,
|
||||||
|
SaveIdeaRequest,
|
||||||
|
SaveIdeaResponse,
|
||||||
|
SessionResponse,
|
||||||
|
)
|
||||||
|
from app.services.command_service import (
|
||||||
|
create_command,
|
||||||
|
delete_command,
|
||||||
|
list_commands,
|
||||||
|
)
|
||||||
|
from app.services.idea_service import IdeaService
|
||||||
|
from app.services.session_service import (
|
||||||
|
create_session,
|
||||||
|
delete_session,
|
||||||
|
get_session,
|
||||||
|
list_sessions,
|
||||||
|
update_session_idea,
|
||||||
|
)
|
||||||
|
from app.services.punctuation_service import restore_punctuation
|
||||||
|
from app.services.whisper_service import transcribe
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
_conductor = ConductorAgent()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stream/{interaction_id}")
|
||||||
|
async def stream_response(
|
||||||
|
interaction_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""SSE endpoint that streams the response text for a given interaction in chunks."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(ConductorInteraction).where(ConductorInteraction.id == interaction_id)
|
||||||
|
)
|
||||||
|
interaction = result.scalar_one_or_none()
|
||||||
|
if not interaction:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Interaction not found")
|
||||||
|
if str(interaction.user_id) != str(user.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||||
|
|
||||||
|
response_text = interaction.response_text or ""
|
||||||
|
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
async def event_stream():
|
||||||
|
chunk_size = 50
|
||||||
|
for i in range(0, len(response_text), chunk_size):
|
||||||
|
chunk = response_text[i:i + chunk_size]
|
||||||
|
yield f"data: {chunk}\n\n"
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/transcribe")
|
||||||
|
async def transcribe_audio(file: UploadFile):
|
||||||
|
audio_data = await file.read()
|
||||||
|
if not audio_data:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Empty audio file",
|
||||||
|
)
|
||||||
|
|
||||||
|
text = await transcribe(audio_data, file.filename or "audio.webm")
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Transcription failed. Check AI provider API key.",
|
||||||
|
)
|
||||||
|
|
||||||
|
text = restore_punctuation(text)
|
||||||
|
|
||||||
|
return {"text": text}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat", response_model=ChatResponse)
|
||||||
|
async def chat(
|
||||||
|
body: ChatRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await _conductor.process(
|
||||||
|
user_input=body.text,
|
||||||
|
db=db,
|
||||||
|
user_id=str(user.id),
|
||||||
|
session_id=body.session_id,
|
||||||
|
vad_enabled=body.vad_enabled,
|
||||||
|
wake_word_detected=body.wake_word_detected,
|
||||||
|
audio_duration_ms=body.audio_duration_ms,
|
||||||
|
pipeline_mode=body.pipeline_mode,
|
||||||
|
)
|
||||||
|
return ChatResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions", response_model=list[SessionResponse])
|
||||||
|
async def list_user_sessions(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
status_filter: str | None = None,
|
||||||
|
):
|
||||||
|
sessions = await list_sessions(db, str(user.id), status=status_filter)
|
||||||
|
return [
|
||||||
|
SessionResponse(
|
||||||
|
id=str(s.id),
|
||||||
|
title=s.title,
|
||||||
|
status=s.status,
|
||||||
|
idea_id=str(s.idea_id) if s.idea_id else None,
|
||||||
|
created_at=s.created_at,
|
||||||
|
updated_at=s.updated_at,
|
||||||
|
)
|
||||||
|
for s in sessions
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}", response_model=SessionResponse)
|
||||||
|
async def get_session_detail(
|
||||||
|
session_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
session = await get_session(db, session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
if str(session.user_id) != str(user.id):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
return SessionResponse(
|
||||||
|
id=str(session.id),
|
||||||
|
title=session.title,
|
||||||
|
status=session.status,
|
||||||
|
idea_id=str(session.idea_id) if session.idea_id else None,
|
||||||
|
created_at=session.created_at,
|
||||||
|
updated_at=session.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}/history")
|
||||||
|
async def get_session_history_endpoint(
|
||||||
|
session_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
session = await get_session(db, session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
if str(session.user_id) != str(user.id):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
return await get_session_history(db, session_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sessions/{session_id}")
|
||||||
|
async def delete_user_session(
|
||||||
|
session_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
session = await get_session(db, session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
if str(session.user_id) != str(user.id):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
ok = await delete_session(db, session_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rate")
|
||||||
|
async def rate(
|
||||||
|
body: RateRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
success = await rate_interaction(db, body.interaction_id, body.rating)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Interaction not found",
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/save-idea", response_model=SaveIdeaResponse)
|
||||||
|
async def save_idea(
|
||||||
|
body: SaveIdeaRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
session = await get_session(db, body.session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
if str(session.user_id) != str(user.id):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
history = await get_session_history(db, body.session_id)
|
||||||
|
title = session.title or "Новое обсуждение"
|
||||||
|
|
||||||
|
dialogue_lines = [
|
||||||
|
f"Пользователь: {h['input']}\n{ h['agent']}: {h['response']}"
|
||||||
|
for h in history
|
||||||
|
]
|
||||||
|
content = "\n\n".join(dialogue_lines) if dialogue_lines else title
|
||||||
|
|
||||||
|
idea_service = IdeaService(db)
|
||||||
|
tags = ["voice", "ai-assisted"]
|
||||||
|
idea = await idea_service.create(
|
||||||
|
user_id=str(user.id),
|
||||||
|
title=title[:255],
|
||||||
|
content=content,
|
||||||
|
tags=tags,
|
||||||
|
is_public=False,
|
||||||
|
)
|
||||||
|
ok = await update_session_idea(db, body.session_id, str(idea.id))
|
||||||
|
|
||||||
|
return SaveIdeaResponse(
|
||||||
|
idea_id=str(idea.id),
|
||||||
|
title=idea.title,
|
||||||
|
exported=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/commands", response_model=list[CommandResponse])
|
||||||
|
async def list_user_commands(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await list_commands(db, str(user.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/commands", response_model=CommandResponse)
|
||||||
|
async def create_user_command(
|
||||||
|
body: CommandCreate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await create_command(
|
||||||
|
db, str(user.id), body.phrase, body.action, body.agent_name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/commands/{command_id}")
|
||||||
|
async def delete_user_command(
|
||||||
|
command_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
ok = await delete_command(db, command_id, str(user.id))
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="Command not found")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agents", response_model=list[dict])
|
||||||
|
async def list_role_agents():
|
||||||
|
return [
|
||||||
|
{"name": a.name, "description": a.description}
|
||||||
|
for a in ALL_ROLE_AGENTS
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# core Module - VoIdea
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
[Auto-generated documentation]
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `base.py` | Base classes |
|
||||||
|
| `config.py` | Configuration management |
|
||||||
|
| `database.py` | Database setup |
|
||||||
|
| `dependencies.py` | FastAPI dependencies |
|
||||||
|
| `exceptions.py` | Custom exceptions |
|
||||||
|
| `security.py` | Security utilities |
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""VoIdea - Core module."""
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.base import BaseModel, BaseService
|
||||||
|
from app.core.exceptions import (
|
||||||
|
AppError,
|
||||||
|
NotFoundError,
|
||||||
|
PermissionError,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
|
from app.core.security import (
|
||||||
|
create_access_token,
|
||||||
|
verify_password,
|
||||||
|
get_password_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"settings",
|
||||||
|
"BaseModel",
|
||||||
|
"BaseService",
|
||||||
|
"AppError",
|
||||||
|
"NotFoundError",
|
||||||
|
"PermissionError",
|
||||||
|
"ValidationError",
|
||||||
|
"create_access_token",
|
||||||
|
"verify_password",
|
||||||
|
"get_password_hash",
|
||||||
|
]
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Base classes for VoIdea models and services."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Generic, TypeVar
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from sqlalchemy import DateTime, func
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class SQLBase(DeclarativeBase):
|
||||||
|
"""Base class for all SQLAlchemy models."""
|
||||||
|
|
||||||
|
type_annotation_map = {
|
||||||
|
datetime: DateTime(timezone=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CoreModel(BaseModel):
|
||||||
|
"""Base Pydantic model with common fields."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
from_attributes=True,
|
||||||
|
populate_by_name=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
"""Mixin for timestamp fields."""
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDMixin:
|
||||||
|
"""Mixin for UUID primary key."""
|
||||||
|
|
||||||
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRepository(Generic[T]):
|
||||||
|
"""Base repository for data access."""
|
||||||
|
|
||||||
|
model: type[T]
|
||||||
|
|
||||||
|
def __init__(self, session: Any):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get_by_id(self, id: UUID) -> T | None:
|
||||||
|
"""Get entity by ID."""
|
||||||
|
return await self.session.get(self.model, id)
|
||||||
|
|
||||||
|
async def get_all(self, limit: int = 100, offset: int = 0) -> list[T]:
|
||||||
|
"""Get all entities with pagination."""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(self.model).limit(limit).offset(offset)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def create(self, **kwargs: Any) -> T:
|
||||||
|
"""Create new entity."""
|
||||||
|
entity = self.model(**kwargs)
|
||||||
|
self.session.add(entity)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def update(self, entity: T, **kwargs: Any) -> T:
|
||||||
|
"""Update entity."""
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def delete(self, entity: T) -> None:
|
||||||
|
"""Delete entity."""
|
||||||
|
await self.session.delete(entity)
|
||||||
|
await self.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class BaseService:
|
||||||
|
"""Base service class."""
|
||||||
|
|
||||||
|
def __init__(self, repository: BaseRepository):
|
||||||
|
self.repository = repository
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
def _read_version() -> str:
|
||||||
|
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION"
|
||||||
|
try:
|
||||||
|
return version_file.read_text(encoding="utf-8").strip()
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
return "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Project
|
||||||
|
project_name: str = Field(default="VoIdeaAI", description="Project name")
|
||||||
|
project_version: str = Field(default_factory=_read_version, description="Project version (from VERSION file)")
|
||||||
|
project_env: str = Field(default="local", description="Environment")
|
||||||
|
project_owner: str = Field(default="Owner", description="Project owner name")
|
||||||
|
project_license: str = Field(default="AGPL-3.0", description="License type")
|
||||||
|
|
||||||
|
# Server
|
||||||
|
server_host: str = Field(default="0.0.0.0", description="Server host")
|
||||||
|
server_port: int = Field(default=8020, description="Server port")
|
||||||
|
server_external_url: str = Field(default="http://localhost:8020", description="External URL")
|
||||||
|
|
||||||
|
# Database
|
||||||
|
database_url: str = Field(
|
||||||
|
default="postgresql+asyncpg://voidea:password@localhost:5432/voidea",
|
||||||
|
description="Database URL (async, PostgreSQL with asyncpg)",
|
||||||
|
)
|
||||||
|
db_echo: bool = Field(default=False, description="Echo SQL queries")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sync_database_url(self) -> str:
|
||||||
|
return self.database_url.replace("+asyncpg", "")
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
redis_host: str = Field(default="localhost", description="Redis host")
|
||||||
|
redis_port: int = Field(default=6379, description="Redis port")
|
||||||
|
redis_url: str = Field(default="redis://localhost:6379/0", description="Redis URL")
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
jwt_secret_key: str = Field(default="", description="JWT secret key")
|
||||||
|
jwt_reset_secret_key: str = Field(default="", description="JWT secret for password reset tokens (separate from access)")
|
||||||
|
jwt_algorithm: str = Field(default="HS256", description="JWT algorithm")
|
||||||
|
jwt_access_token_expire_minutes: int = Field(default=60, description="Access token TTL")
|
||||||
|
jwt_refresh_token_expire_days: int = Field(default=30, description="Refresh token TTL")
|
||||||
|
|
||||||
|
# AI Providers
|
||||||
|
openai_api_key: str = Field(default="", description="OpenAI API key (Whisper, GPT)")
|
||||||
|
ai_yandex_key: str = Field(default="", description="Yandex GPT API key or IAM token")
|
||||||
|
ai_yandex_url: str = Field(default="https://llm.api.cloud.yandex.net", description="Yandex API URL")
|
||||||
|
ai_yandex_folder_id: str = Field(default="", description="Yandex Cloud folder ID")
|
||||||
|
ai_gigachat_client_id: str = Field(default="", description="GigaChat OAuth client ID")
|
||||||
|
ai_gigachat_secret: str = Field(default="", description="GigaChat OAuth client secret")
|
||||||
|
ai_gigachat_url: str = Field(default="https://gigachat.devices.sber.ru", description="GigaChat API URL")
|
||||||
|
ai_fallback_model: str = Field(default="yandex_gpt", description="Default AI model")
|
||||||
|
ai_timeout: int = Field(default=10, description="AI request timeout")
|
||||||
|
ai_max_retries: int = Field(default=3, description="Max retries for AI requests")
|
||||||
|
|
||||||
|
# OAuth
|
||||||
|
oauth_yandex_id: str = Field(default="", description="Yandex OAuth client ID")
|
||||||
|
oauth_yandex_secret: str = Field(default="", description="Yandex OAuth client secret")
|
||||||
|
oauth_yandex_redirect_uri: str = Field(default="http://localhost:3000/oauth/callback")
|
||||||
|
oauth_google_id: str = Field(default="", description="Google OAuth client ID")
|
||||||
|
oauth_google_secret: str = Field(default="", description="Google OAuth client secret")
|
||||||
|
oauth_google_redirect_uri: str = Field(default="http://localhost:8020/auth/google/callback")
|
||||||
|
oauth_apple_id: str = Field(default="", description="Apple OAuth client ID")
|
||||||
|
oauth_apple_secret: str = Field(default="", description="Apple OAuth client secret")
|
||||||
|
oauth_apple_redirect_uri: str = Field(default="http://localhost:8020/auth/apple/callback")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def google_oauth_enabled(self) -> bool:
|
||||||
|
return bool(self.oauth_google_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def apple_oauth_enabled(self) -> bool:
|
||||||
|
return bool(self.oauth_apple_id)
|
||||||
|
|
||||||
|
# Telegram Bot
|
||||||
|
telegram_bot_token: str = Field(default="", description="Telegram bot token")
|
||||||
|
|
||||||
|
# Email (SMTP)
|
||||||
|
smtp_host: str = Field(default="", description="SMTP server host")
|
||||||
|
smtp_port: int = Field(default=587, description="SMTP server port")
|
||||||
|
smtp_user: str = Field(default="", description="SMTP username")
|
||||||
|
smtp_pass: str = Field(default="", description="SMTP password")
|
||||||
|
smtp_from: str = Field(default="VoIdea <notifications@voidea.ru>")
|
||||||
|
smtp_tls: bool = Field(default=True)
|
||||||
|
|
||||||
|
# Security
|
||||||
|
enable_2fa: bool = Field(default=False)
|
||||||
|
accepted_terms_version: str = Field(
|
||||||
|
default="2026-05-11",
|
||||||
|
description="Current version of Terms of Service / Privacy Policy",
|
||||||
|
)
|
||||||
|
|
||||||
|
# System Owner
|
||||||
|
system_owner_email: str = Field(
|
||||||
|
default="",
|
||||||
|
description="Email of the system owner (set on VPS deploy, protected from deletion/suspension)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Social Networks (empty = hidden)
|
||||||
|
social_telegram: str = Field(default="voideaai", description="Telegram account name")
|
||||||
|
social_vk: str = Field(default="voideaai", description="VK account name")
|
||||||
|
social_youtube: str = Field(default="voideaai", description="YouTube account name")
|
||||||
|
social_tiktok: str = Field(default="voideaai", description="TikTok account name")
|
||||||
|
project_slogan: str = Field(
|
||||||
|
default="VoIdeaAI — идеи рождаются вслух, решения приходят мгновенно!",
|
||||||
|
description="Project slogan",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Analytics (empty = disabled)
|
||||||
|
yandex_metrika_id: str = Field(default="", description="Yandex Metrika counter ID")
|
||||||
|
google_analytics_id: str = Field(default="", description="Google Analytics tracking ID")
|
||||||
|
|
||||||
|
# Tariffs
|
||||||
|
tariffs_enabled: bool = Field(default=False, description="Enable tariff system (false = promo free-for-all)")
|
||||||
|
tariffs_free_code: str = Field(default="free", description="Code of the default free tariff plan")
|
||||||
|
|
||||||
|
# Push Notifications (mobile-ready stubs)
|
||||||
|
fcm_server_key: str = Field(default="", description="Firebase Cloud Messaging server key (mobile push)")
|
||||||
|
apns_key_id: str = Field(default="", description="Apple Push Notification Service key ID (iOS push)")
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_level: str = Field(default="INFO")
|
||||||
|
log_format: str = Field(default="json")
|
||||||
|
log_file_path: str = Field(default="logs/app.log")
|
||||||
|
log_max_bytes: int = Field(default=10485760)
|
||||||
|
log_backup_count: int = Field(default=5)
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
cors_origins: str = Field(default="http://localhost:3000,http://localhost:8020")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origins_list(self) -> list[str]:
|
||||||
|
return [origin.strip() for origin in self.cors_origins.split(",")]
|
||||||
|
|
||||||
|
# Celery
|
||||||
|
celery_broker_url: str = Field(default="redis://localhost:6379/0")
|
||||||
|
celery_result_backend: str = Field(default="redis://localhost:6379/0")
|
||||||
|
celery_task_track_started: bool = Field(default=True)
|
||||||
|
celery_task_time_limit: int = Field(default=300)
|
||||||
|
|
||||||
|
# Observer Agent
|
||||||
|
observer_enabled: bool = Field(default=True)
|
||||||
|
observer_sample_rate: float = Field(default=0.1)
|
||||||
|
observer_store_raw_data: bool = Field(default=False)
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
rate_limit_enabled: bool = Field(default=True, description="Enable rate limiting")
|
||||||
|
rate_limit_default: str = Field(default="60/minute", description="Default rate limit")
|
||||||
|
rate_limit_auth: str = Field(default="10/minute", description="Auth endpoints rate limit")
|
||||||
|
|
||||||
|
# Crypto
|
||||||
|
encryption_key: str = Field(default="", description="AES-256 encryption key (Fernet)")
|
||||||
|
|
||||||
|
# Development
|
||||||
|
debug: bool = Field(default=False)
|
||||||
|
reload: bool = Field(default=True)
|
||||||
|
|
||||||
|
def is_production(self) -> bool:
|
||||||
|
return self.project_env == "production"
|
||||||
|
|
||||||
|
def is_development(self) -> bool:
|
||||||
|
return self.project_env in ("development", "local")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_config(self) -> dict:
|
||||||
|
"""Non-sensitive settings exposed via GET /api/v1/config/public."""
|
||||||
|
return {
|
||||||
|
"project_name": self.project_name,
|
||||||
|
"project_version": self.project_version,
|
||||||
|
"project_env": self.project_env,
|
||||||
|
"project_slogan": self.project_slogan,
|
||||||
|
"social_telegram": self.social_telegram,
|
||||||
|
"social_vk": self.social_vk,
|
||||||
|
"social_youtube": self.social_youtube,
|
||||||
|
"social_tiktok": self.social_tiktok,
|
||||||
|
"yandex_metrika_id": self.yandex_metrika_id,
|
||||||
|
"google_analytics_id": self.google_analytics_id,
|
||||||
|
"tariffs_enabled": self.tariffs_enabled,
|
||||||
|
"tariffs_free_code": self.tariffs_free_code,
|
||||||
|
"accepted_terms_version": self.accepted_terms_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Database configuration and session management for VoIdea."""
|
||||||
|
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
echo=settings.db_echo,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
async_session_maker = async_sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autocommit=False,
|
||||||
|
autoflush=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Get database session.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
AsyncSession instance
|
||||||
|
"""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""FastAPI dependencies for VoIdea.
|
||||||
|
|
||||||
|
Common dependencies used across API endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from jose import JWTError
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.database import async_session_maker
|
||||||
|
from app.core.security import decode_token
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
"""Get database session."""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
|
||||||
|
) -> User:
|
||||||
|
"""Get current authenticated user from JWT token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credentials: Bearer token credentials
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User model instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If token invalid or expired
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if payload.get("type") != "access":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token type",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
if user_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Token missing user ID",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with async_session_maker() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.id == user_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Account is disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
) -> User:
|
||||||
|
"""Require admin or owner role."""
|
||||||
|
if not user.is_superuser and not user.is_owner:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin access required",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_owner(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
) -> User:
|
||||||
|
"""Require owner role (system-level operations)."""
|
||||||
|
if not user.is_owner:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Owner access required",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_permission(perm_name: str):
|
||||||
|
"""Factory: require a specific moderator permission or admin/owner.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@router.get("/admin/feedback")
|
||||||
|
async def list_feedback(
|
||||||
|
user: Annotated[User, Depends(require_permission("can_manage_feedback"))],
|
||||||
|
):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
async def _check(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
) -> User:
|
||||||
|
if user.is_owner or user.role == "admin":
|
||||||
|
return user
|
||||||
|
if user.role == "moderator" and user.permissions:
|
||||||
|
if user.permissions.get(perm_name, False):
|
||||||
|
return user
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Permission '{perm_name}' required",
|
||||||
|
)
|
||||||
|
return _check
|
||||||
|
|
||||||
|
|
||||||
|
async def get_optional_user(
|
||||||
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(HTTPBearer(auto_error=False))],
|
||||||
|
) -> User | None:
|
||||||
|
"""Get current user if authenticated, None otherwise."""
|
||||||
|
if credentials is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
if payload is None or payload.get("type") != "access":
|
||||||
|
return None
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
if user_id is None:
|
||||||
|
return None
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async with async_session_maker() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.id == user_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and user.is_active:
|
||||||
|
return user
|
||||||
|
return None
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""Custom exceptions for VoIdea application."""
|
||||||
|
|
||||||
|
|
||||||
|
class AppError(Exception):
|
||||||
|
"""Base application exception.
|
||||||
|
|
||||||
|
All business errors inherit from this class.
|
||||||
|
Does not contain HTTP status codes (those are in API layer).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
code: str = "GENERAL_ERROR",
|
||||||
|
details: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.message = message
|
||||||
|
self.code = code
|
||||||
|
self.details = details or {}
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert exception to dictionary."""
|
||||||
|
return {
|
||||||
|
"error": self.code,
|
||||||
|
"message": self.message,
|
||||||
|
"details": self.details,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(AppError):
|
||||||
|
"""Resource not found error."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str = "Resource not found",
|
||||||
|
code: str = "NOT_FOUND",
|
||||||
|
resource: str | None = None,
|
||||||
|
resource_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
details = {}
|
||||||
|
if resource:
|
||||||
|
details["resource"] = resource
|
||||||
|
if resource_id:
|
||||||
|
details["resource_id"] = resource_id
|
||||||
|
super().__init__(message, code, details)
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionError(AppError):
|
||||||
|
"""Permission denied error."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str = "Permission denied",
|
||||||
|
code: str = "PERMISSION_DENIED",
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message, code)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(AppError):
|
||||||
|
"""Validation error."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str = "Validation error",
|
||||||
|
code: str = "VALIDATION_ERROR",
|
||||||
|
field: str | None = None,
|
||||||
|
value: Any = None,
|
||||||
|
) -> None:
|
||||||
|
details = {}
|
||||||
|
if field:
|
||||||
|
details["field"] = field
|
||||||
|
if value is not None:
|
||||||
|
details["value"] = str(value)
|
||||||
|
super().__init__(message, code, details)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationError(AppError):
|
||||||
|
"""Authentication error."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str = "Authentication failed",
|
||||||
|
code: str = "AUTH_FAILED",
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message, code)
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaExceededError(AppError):
|
||||||
|
"""Quota exceeded error."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str = "Quota exceeded",
|
||||||
|
code: str = "QUOTA_EXCEEDED",
|
||||||
|
limit: int | None = None,
|
||||||
|
used: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
details = {}
|
||||||
|
if limit is not None:
|
||||||
|
details["limit"] = limit
|
||||||
|
if used is not None:
|
||||||
|
details["used"] = used
|
||||||
|
super().__init__(message, code, details)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalServiceError(AppError):
|
||||||
|
"""External service error (AI, OAuth, etc.)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str = "External service error",
|
||||||
|
code: str = "EXTERNAL_SERVICE_ERROR",
|
||||||
|
service: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
details = {}
|
||||||
|
if service:
|
||||||
|
details["service"] = service
|
||||||
|
super().__init__(message, code, details)
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitError(AppError):
|
||||||
|
"""Rate limit exceeded error."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str = "Rate limit exceeded",
|
||||||
|
code: str = "RATE_LIMIT_EXCEEDED",
|
||||||
|
retry_after: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
details = {}
|
||||||
|
if retry_after:
|
||||||
|
details["retry_after"] = retry_after
|
||||||
|
super().__init__(message, code, details)
|
||||||
|
|
||||||
|
|
||||||
|
# Import for type hints
|
||||||
|
from typing import Any
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Feature gate module for VoIdea tariff-based access control.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
features = await get_user_features(user, db)
|
||||||
|
if not features.get("has_voice"):
|
||||||
|
raise HTTPException(403, "Feature not available on your plan")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.tariff import TariffPlan, UserSubscription
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
DEFAULT_FREE_FEATURES: dict[str, Any] = {
|
||||||
|
"limit_ideas": None, # None = unlimited
|
||||||
|
"limit_sessions_per_day": None,
|
||||||
|
"limit_agents": None,
|
||||||
|
"limit_team_members": None,
|
||||||
|
"limit_storage_mb": 100,
|
||||||
|
"has_voice": True,
|
||||||
|
"has_voice_commands": True,
|
||||||
|
"has_drive": True,
|
||||||
|
"has_advanced_analytics": True,
|
||||||
|
"has_api_access": True,
|
||||||
|
"has_priority_support": True,
|
||||||
|
"has_custom_branding": True,
|
||||||
|
"_promo": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_features(
|
||||||
|
user: User, db: AsyncSession
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get feature flags and limits for a user based on their tariff plan.
|
||||||
|
|
||||||
|
When tariffs_enabled=False (promo mode), returns all features as available.
|
||||||
|
"""
|
||||||
|
if not settings.tariffs_enabled:
|
||||||
|
return dict(DEFAULT_FREE_FEATURES)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserSubscription).where(UserSubscription.user_id == user.id)
|
||||||
|
)
|
||||||
|
sub = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if sub is None or sub.status != "active":
|
||||||
|
return _get_plan_features(db, settings.tariffs_free_code)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(TariffPlan).where(TariffPlan.id == sub.plan_id)
|
||||||
|
)
|
||||||
|
plan = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if plan is None or not plan.is_active:
|
||||||
|
return _get_plan_features(db, settings.tariffs_free_code)
|
||||||
|
|
||||||
|
return plan.features or dict(DEFAULT_FREE_FEATURES)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_plan_features(
|
||||||
|
db: AsyncSession, code: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get features dict for a plan by code, falling back to defaults."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(TariffPlan).where(
|
||||||
|
TariffPlan.code == code, TariffPlan.is_active.is_(True)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
plan = result.scalar_one_or_none()
|
||||||
|
if plan and plan.features:
|
||||||
|
return plan.features
|
||||||
|
return dict(DEFAULT_FREE_FEATURES)
|
||||||
|
|
||||||
|
|
||||||
|
def check_feature(features: dict[str, Any], key: str) -> bool:
|
||||||
|
"""Check if a boolean feature is enabled."""
|
||||||
|
return bool(features.get(key, False))
|
||||||
|
|
||||||
|
|
||||||
|
def check_limit(
|
||||||
|
features: dict[str, Any], key: str, current: int = 0
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a numeric limit is not exceeded (None = unlimited)."""
|
||||||
|
limit = features.get(key)
|
||||||
|
if limit is None:
|
||||||
|
return True
|
||||||
|
return current < int(limit)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Shared rate limiter instance for VoIdeaAI."""
|
||||||
|
|
||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""HTTP request logging middleware for debug mode."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from app.core.database import async_session_maker
|
||||||
|
from app.models.log import LogEntry
|
||||||
|
from app.services.debug_service import DebugService
|
||||||
|
|
||||||
|
logger = logging.getLogger("voidea.http")
|
||||||
|
|
||||||
|
|
||||||
|
class DebugLoggingMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
response: Response = await call_next(request)
|
||||||
|
|
||||||
|
elapsed_ms = int((time.time() - start) * 1000)
|
||||||
|
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
# Skip health check and static noise
|
||||||
|
if path in ("/health", "/api/v1/health") or path.startswith("/assets/") or path.startswith("/icons/") or path == "/":
|
||||||
|
return response
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_session_maker() as db:
|
||||||
|
svc = DebugService(db)
|
||||||
|
if await svc.is_debug_mode():
|
||||||
|
log = LogEntry(
|
||||||
|
level="DEBUG",
|
||||||
|
source="http",
|
||||||
|
message=f"{request.method} {path} → {response.status_code} ({elapsed_ms}ms)",
|
||||||
|
details=None,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Security middleware for VoIdeaAI.
|
||||||
|
|
||||||
|
Adds security headers to all HTTP responses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
response: Response = await call_next(request)
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||||
|
response.headers["Strict-Transport-Security"] = (
|
||||||
|
"max-age=31536000; includeSubDomains"
|
||||||
|
)
|
||||||
|
response.headers["Content-Security-Policy"] = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self'; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"connect-src 'self' https://*.openai.com https://llm.api.cloud.yandex.net https://cloud-api.yandex.net; "
|
||||||
|
"media-src 'self' blob:; "
|
||||||
|
"img-src 'self' data: https://avatars.yandex.net"
|
||||||
|
)
|
||||||
|
return response
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Security utilities for VoIdea.
|
||||||
|
|
||||||
|
JWT handling, password hashing, and authentication helpers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
"""Hash password using bcrypt."""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify password against hash."""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(
|
||||||
|
data: dict[str, Any],
|
||||||
|
expires_delta: timedelta | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Create JWT access token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Payload data to encode in token
|
||||||
|
expires_delta: Optional custom expiration time
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encoded JWT token string
|
||||||
|
"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.now(timezone.utc) + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
|
minutes=settings.jwt_access_token_expire_minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
to_encode.update({
|
||||||
|
"exp": expire,
|
||||||
|
"iat": datetime.now(timezone.utc),
|
||||||
|
"type": "access",
|
||||||
|
})
|
||||||
|
|
||||||
|
encoded_jwt = jwt.encode(
|
||||||
|
to_encode,
|
||||||
|
settings.jwt_secret_key,
|
||||||
|
algorithm=settings.jwt_algorithm,
|
||||||
|
)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(
|
||||||
|
data: dict[str, Any],
|
||||||
|
expires_delta: timedelta | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Create JWT refresh token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Payload data to encode in token
|
||||||
|
expires_delta: Optional custom expiration time
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encoded JWT refresh token string
|
||||||
|
"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.now(timezone.utc) + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
|
days=settings.jwt_refresh_token_expire_days
|
||||||
|
)
|
||||||
|
|
||||||
|
to_encode.update({
|
||||||
|
"exp": expire,
|
||||||
|
"iat": datetime.now(timezone.utc),
|
||||||
|
"type": "refresh",
|
||||||
|
})
|
||||||
|
|
||||||
|
encoded_jwt = jwt.encode(
|
||||||
|
to_encode,
|
||||||
|
settings.jwt_secret_key,
|
||||||
|
algorithm=settings.jwt_algorithm,
|
||||||
|
)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def create_reset_token(data: dict[str, Any]) -> str:
|
||||||
|
"""Create JWT password reset token (1 hour expiry, separate key).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Payload data to encode in token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encoded JWT reset token string
|
||||||
|
"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||||
|
to_encode.update({
|
||||||
|
"exp": expire,
|
||||||
|
"iat": datetime.now(timezone.utc),
|
||||||
|
"type": "reset",
|
||||||
|
})
|
||||||
|
secret = settings.jwt_reset_secret_key or settings.jwt_secret_key
|
||||||
|
return jwt.encode(to_encode, secret, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(
|
||||||
|
token: str,
|
||||||
|
secret: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Decode and verify JWT token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: JWT token string
|
||||||
|
secret: Optional secret key (defaults to jwt_secret_key)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decoded payload or None if invalid
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
secret or settings.jwt_secret_key,
|
||||||
|
algorithms=[settings.jwt_algorithm],
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def decode_reset_token(token: str) -> dict[str, Any] | None:
|
||||||
|
"""Decode and verify password reset JWT token (uses reset secret key).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: JWT reset token string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decoded payload or None if invalid
|
||||||
|
"""
|
||||||
|
secret = settings.jwt_reset_secret_key or settings.jwt_secret_key
|
||||||
|
return decode_token(token, secret)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token_type(token_data: dict[str, Any], expected_type: str) -> bool:
|
||||||
|
"""Verify token type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_data: Decoded token payload
|
||||||
|
expected_type: Expected token type (access/refresh/reset)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if token type matches
|
||||||
|
"""
|
||||||
|
return token_data.get("type") == expected_type
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Seed data for VoIdeaAI.
|
||||||
|
|
||||||
|
Called from app lifespan when DB tables are empty.
|
||||||
|
Creates default tariff plans and sets system owner by email.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.agent import AgentConfig
|
||||||
|
from app.models.tariff import TariffPlan
|
||||||
|
from app.services.user_service import UserService
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
_AGENT_VERSIONS_FILE = Path(__file__).resolve().parent.parent.parent / "AGENT_VERSIONS.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_agent_versions() -> dict[str, str]:
|
||||||
|
try:
|
||||||
|
return json.loads(_AGENT_VERSIONS_FILE.read_text(encoding="utf-8"))
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
AGENT_DESCRIPTIONS: dict[str, str] = {
|
||||||
|
"conductor": "Дирижёр — оркестратор, единственная точка входа. Маршрутизирует запросы к ролевым агентам, верифицирует ответы.",
|
||||||
|
"business_analyst": "Бизнес-аналитик — оценивает бизнес-метрики, ROI, конкурентную среду, окупаемость идеи.",
|
||||||
|
"task_organizer": "Организатор задач — разбивает идею на конкретные шаги реализации с оценкой сроков.",
|
||||||
|
"lawyer": "Юрист — проверяет идею на соответствие законодательству РФ (44-ФЗ, 152-ФЗ, 223-ФЗ).",
|
||||||
|
"financial_consultant": "Финансовый консультант — бюджет, прогноз выручки, точка безубыточности.",
|
||||||
|
"solution_architect": "Архитектор решений — предлагает 2 варианта архитектуры: монолит и микросервисы.",
|
||||||
|
"tester": "Тестировщик — описывает сценарии тестирования (позитивные и негативные кейсы).",
|
||||||
|
"ui_designer": "UI-дизайнер — предлагает 2 варианта визуального дизайна интерфейса.",
|
||||||
|
"smm_specialist": "SMM-специалист — разрабатывает контент-план для соцсетей.",
|
||||||
|
"life_coach": "Лайф-коуч — ставит SMART-цели, поквартальные вехи развития.",
|
||||||
|
"accessibility_expert": "Эксперт по доступности — проверяет на WCAG 2.1 AA соответствие.",
|
||||||
|
"critic": "Критик — конструктивная критика, поиск «слепых зон» идеи.",
|
||||||
|
"copywriter": "Копирайтер — упаковывает идею в продающий текст для разных аудиторий.",
|
||||||
|
"keeper": "Хранитель — сохраняет проработанную идею в базу данных.",
|
||||||
|
"doc_agent": "DocAgent — автоматическая генерация и обновление документации.",
|
||||||
|
"backlog_agent": "BacklogAgent — управление бэклогом, приоритизация задач.",
|
||||||
|
"spec_agent": "SpecAgent — написание формальных спецификаций и требований.",
|
||||||
|
"audit_agent": "AuditAgent — аудит кода, архитектуры, безопасности.",
|
||||||
|
"observer_agent": "ObserverAgent — мониторинг системы, сбор метрик, алерты.",
|
||||||
|
"evolution_agent": "EvolutionAgent — автоматическое улучшение кода, рефакторинг.",
|
||||||
|
"security_agent": "SecurityAgent — проверка безопасности, уязвимости, OWASP.",
|
||||||
|
"qa_tester_agent": "QATesterAgent — автоматизированное тестирование, регресс.",
|
||||||
|
"fix_agent": "FixAgent — автоматическое исправление ошибок на основе логов.",
|
||||||
|
"ui_test_agent": "UITestAgent — тестирование UI/UX, скриншотные тесты.",
|
||||||
|
"rollout_agent": "RolloutAgent — управление релизами, миграции, откаты.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_database(db: AsyncSession) -> None:
|
||||||
|
"""Seed initial data if tables are empty."""
|
||||||
|
await _seed_tariff_plans(db)
|
||||||
|
await _seed_agent_descriptions(db)
|
||||||
|
await _seed_owner(db)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_tariff_plans(db: AsyncSession) -> None:
|
||||||
|
"""Create default Free tariff plan if none exist."""
|
||||||
|
result = await db.execute(select(TariffPlan).limit(1))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
return
|
||||||
|
|
||||||
|
free_plan = TariffPlan(
|
||||||
|
name="Бесплатно",
|
||||||
|
code="free",
|
||||||
|
description="Базовый доступ ко всем функциям. Сейчас всё бесплатно 🎉",
|
||||||
|
price_monthly=0,
|
||||||
|
features={
|
||||||
|
"limit_ideas": None,
|
||||||
|
"limit_sessions_per_day": None,
|
||||||
|
"limit_agents": None,
|
||||||
|
"limit_team_members": None,
|
||||||
|
"limit_storage_mb": 100,
|
||||||
|
"has_voice": True,
|
||||||
|
"has_voice_commands": True,
|
||||||
|
"has_drive": True,
|
||||||
|
"has_advanced_analytics": True,
|
||||||
|
"has_api_access": True,
|
||||||
|
"has_priority_support": True,
|
||||||
|
"has_custom_branding": True,
|
||||||
|
"_promo": True,
|
||||||
|
},
|
||||||
|
is_active=True,
|
||||||
|
sort_order=0,
|
||||||
|
)
|
||||||
|
db.add(free_plan)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_agent_descriptions(db: AsyncSession) -> None:
|
||||||
|
"""Fill empty agent descriptions and update versions from AGENT_VERSIONS.json."""
|
||||||
|
agent_versions = _load_agent_versions()
|
||||||
|
result = await db.execute(select(AgentConfig))
|
||||||
|
agents = result.scalars().all()
|
||||||
|
updated = False
|
||||||
|
for agent in agents:
|
||||||
|
name = agent.agent_name.lower()
|
||||||
|
if name in AGENT_DESCRIPTIONS and (not agent.description or agent.description == ""):
|
||||||
|
agent.description = AGENT_DESCRIPTIONS[name]
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
name_lower = agent.agent_name.lower()
|
||||||
|
expected_version = agent_versions.get(name_lower) or agent_versions.get(agent.agent_name)
|
||||||
|
if expected_version and agent.version != expected_version:
|
||||||
|
agent.version = expected_version
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_owner(db: AsyncSession) -> None:
|
||||||
|
"""Set system owner by SYSTEM_OWNER_EMAIL if configured."""
|
||||||
|
if not settings.system_owner_email:
|
||||||
|
return
|
||||||
|
svc = UserService(db)
|
||||||
|
await svc.set_owner_by_email(settings.system_owner_email)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/* Auto-generated from design tokens — do not edit manually */
|
||||||
|
:root {
|
||||||
|
--color-primary-50: #EEF2FF;
|
||||||
|
--color-primary-500: #6366F1;
|
||||||
|
--color-primary-600: #4F46E5;
|
||||||
|
--color-primary: #6366F1;
|
||||||
|
--color-primary-hover: #4F46E5;
|
||||||
|
--color-background-dark: #0F172A;
|
||||||
|
--color-background-light: #FFFFFF;
|
||||||
|
--color-text-primary: {'system': 'auto', 'dark': '#F8FAFC', 'light': '#0F172A'};
|
||||||
|
--color-semantic-error: #EF4444;
|
||||||
|
--color-semantic-warning: #F59E0B;
|
||||||
|
--color-semantic-success: #22C55E;
|
||||||
|
--color-semantic-info: #3B82F6;
|
||||||
|
--font-primary: Inter, system-ui, sans-serif;
|
||||||
|
--font-size-xs: 0.75rem;
|
||||||
|
--font-size-sm: 0.875rem;
|
||||||
|
--font-size-base: 1rem;
|
||||||
|
--font-size-lg: 1.125rem;
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.5rem;
|
||||||
|
--radius-lg: 0.75rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<?xml version="1.0" ?>
|
||||||
|
<resources>
|
||||||
|
<color name="primary_50">#FFEEF2FF</color>
|
||||||
|
<color name="primary_500">#FF6366F1</color>
|
||||||
|
<color name="primary_600">#FF4F46E5</color>
|
||||||
|
<color name="primary">#FF6366F1</color>
|
||||||
|
<color name="primary">#FF4F46E5</color>
|
||||||
|
<color name="background_dark">#FF0F172A</color>
|
||||||
|
<color name="background_light">#FFFFFFFF</color>
|
||||||
|
<color name="semantic_error">#FFEF4444</color>
|
||||||
|
<color name="semantic_warning">#FFF59E0B</color>
|
||||||
|
<color name="semantic_success">#FF22C55E</color>
|
||||||
|
<color name="semantic_info">#FF3B82F6</color>
|
||||||
|
<dimen name="spacing_xs">4dp</dimen>
|
||||||
|
<dimen name="spacing_sm">8dp</dimen>
|
||||||
|
<dimen name="spacing_md">16dp</dimen>
|
||||||
|
<dimen name="spacing_lg">24dp</dimen>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Auto-generated from design tokens — do not edit manually
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
static let primary50 = Color(red: 0.9333, green: 0.9490, blue: 1.0000)
|
||||||
|
static let primary500 = Color(red: 0.3882, green: 0.4000, blue: 0.9451)
|
||||||
|
static let primary600 = Color(red: 0.3098, green: 0.2745, blue: 0.8980)
|
||||||
|
static let primary = Color(red: 0.3882, green: 0.4000, blue: 0.9451)
|
||||||
|
static let primary = Color(red: 0.3098, green: 0.2745, blue: 0.8980)
|
||||||
|
static let backgroundDark = Color(red: 0.0588, green: 0.0902, blue: 0.1647)
|
||||||
|
static let backgroundLight = Color(red: 1.0000, green: 1.0000, blue: 1.0000)
|
||||||
|
static let semanticError = Color(red: 0.9373, green: 0.2667, blue: 0.2667)
|
||||||
|
static let semanticWarning = Color(red: 0.9608, green: 0.6196, blue: 0.0431)
|
||||||
|
static let semanticSuccess = Color(red: 0.1333, green: 0.7725, blue: 0.3686)
|
||||||
|
static let semanticInfo = Color(red: 0.2314, green: 0.5098, blue: 0.9647)
|
||||||
|
static let spacingXs = CGFloat(0.25)
|
||||||
|
static let spacingSm = CGFloat(0.5)
|
||||||
|
static let spacingMd = CGFloat(1)
|
||||||
|
static let spacingLg = CGFloat(1.5)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/* VoIdeaAI Design Tokens — auto-generated */
|
||||||
|
/* Source: docs/design-system/tokens.json */
|
||||||
|
:root {
|
||||||
|
--colors-background: #FFFFFF;
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
--border_radius-sm: 0.25rem;
|
||||||
|
--border_radius-md: 0.5rem;
|
||||||
|
--border_radius-lg: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--colors-background: #0F172A;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Design tokens CSS generator for VoIdeaAI.
|
||||||
|
|
||||||
|
Reads docs/design-system/tokens.json and generates:
|
||||||
|
- app/design-tokens/css/tokens.css
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TOKENS_PATH = Path(__file__).parent.parent.parent / "docs" / "design-system" / "tokens.json"
|
||||||
|
OUTPUT_PATH = Path(__file__).parent / "css" / "tokens.css"
|
||||||
|
|
||||||
|
|
||||||
|
def load_tokens() -> dict:
|
||||||
|
if not TOKENS_PATH.exists():
|
||||||
|
return {}
|
||||||
|
return json.loads(TOKENS_PATH.read_text(encoding="utf-8-sig"))
|
||||||
|
|
||||||
|
|
||||||
|
def generate_css(tokens: dict) -> str:
|
||||||
|
lines = [
|
||||||
|
"/* VoIdeaAI Design Tokens — auto-generated */",
|
||||||
|
"/* Source: docs/design-system/tokens.json */",
|
||||||
|
":root {",
|
||||||
|
]
|
||||||
|
|
||||||
|
for category, values in tokens.items():
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
continue
|
||||||
|
for key, value in values.items():
|
||||||
|
if key == "$schema":
|
||||||
|
continue
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for mode, val in value.items():
|
||||||
|
if mode == "light":
|
||||||
|
lines.append(f" --{category}-{key}: {val};")
|
||||||
|
elif isinstance(value, (str, int, float)):
|
||||||
|
lines.append(f" --{category}-{key}: {value};")
|
||||||
|
|
||||||
|
lines.append("}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(".dark {")
|
||||||
|
for category, values in tokens.items():
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
continue
|
||||||
|
for key, value in values.items():
|
||||||
|
if key == "$schema":
|
||||||
|
continue
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for mode, val in value.items():
|
||||||
|
if mode == "dark":
|
||||||
|
lines.append(f" --{category}-{key}: {val};")
|
||||||
|
lines.append("}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tokens = load_tokens()
|
||||||
|
if not tokens:
|
||||||
|
print("tokens.json not found, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
css = generate_css(tokens)
|
||||||
|
OUTPUT_PATH.write_text(css, encoding="utf-8")
|
||||||
|
print(f"Generated {OUTPUT_PATH} ({len(css)} bytes)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""VoIdea - Integrations module (AI providers, external services)."""
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""VoIdea - AI integration module."""
|
||||||
|
|
||||||
|
from app.integrations.ai.base import AIProvider, AIResult
|
||||||
|
from app.integrations.ai.yandex_gpt import YandexGPTProvider
|
||||||
|
from app.integrations.ai.gigachat import GigaChatProvider
|
||||||
|
from app.integrations.ai.fallback import FallbackChain
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AIProvider",
|
||||||
|
"AIResult",
|
||||||
|
"YandexGPTProvider",
|
||||||
|
"GigaChatProvider",
|
||||||
|
"FallbackChain",
|
||||||
|
]
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Base classes for AI provider integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIResult:
|
||||||
|
"""Result from an AI provider call."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
content: str = ""
|
||||||
|
model: str = ""
|
||||||
|
provider: str = ""
|
||||||
|
tokens_used: int = 0
|
||||||
|
duration_ms: int = 0
|
||||||
|
error: str = ""
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AIProvider(ABC):
|
||||||
|
"""Abstract base class for AI providers.
|
||||||
|
|
||||||
|
All AI providers (Yandex GPT, GigaChat, etc.) must implement this.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
|
model: str = ""
|
||||||
|
timeout: int = 10
|
||||||
|
max_retries: int = 3
|
||||||
|
|
||||||
|
def __init__(self, api_key: str = "", **kwargs: Any):
|
||||||
|
self.api_key = api_key
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
setattr(self, key, value)
|
||||||
|
self._initialize()
|
||||||
|
|
||||||
|
def _initialize(self) -> None:
|
||||||
|
"""Post-initialization hook for provider-specific setup."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def analyze(self, prompt: str, **kwargs: Any) -> AIResult:
|
||||||
|
"""Send a prompt to the AI provider and return the result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: The formatted prompt to send
|
||||||
|
**kwargs: Additional provider-specific parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AIResult with the response content
|
||||||
|
"""
|
||||||
|
|
||||||
|
def format_prompt(
|
||||||
|
self, system_prompt: str, user_prompt: str, **kwargs: Any
|
||||||
|
) -> str:
|
||||||
|
"""Format a prompt with system instructions and user message.
|
||||||
|
|
||||||
|
Override in provider if the API uses separate system/user messages.
|
||||||
|
"""
|
||||||
|
return f"{system_prompt}\n\n{user_prompt}"
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Fallback chain for AI providers.
|
||||||
|
|
||||||
|
Tries providers in order with retries per provider.
|
||||||
|
YandexGPT -> GigaChat -> Error
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.integrations.ai.base import AIProvider, AIResult
|
||||||
|
from app.integrations.ai.yandex_gpt import YandexGPTProvider
|
||||||
|
from app.integrations.ai.gigachat import GigaChatProvider
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class FallbackChain:
|
||||||
|
"""Chain multiple AI providers with retry logic.
|
||||||
|
|
||||||
|
Tries providers in order (YandexGPT -> GigaChat).
|
||||||
|
Each provider gets up to `max_retries` attempts before falling through.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
providers: list[AIProvider] | None = None,
|
||||||
|
max_retries: int = 2,
|
||||||
|
timeout: int = 30,
|
||||||
|
):
|
||||||
|
self.providers = providers or [
|
||||||
|
YandexGPTProvider(),
|
||||||
|
GigaChatProvider(),
|
||||||
|
]
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
async def analyze(self, prompt: str, **kwargs: Any) -> AIResult:
|
||||||
|
"""Try each provider in order with retries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: The formatted prompt to send
|
||||||
|
**kwargs: Additional parameters passed to each provider
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AIResult from the first successful provider,
|
||||||
|
or the last error result if all fail.
|
||||||
|
"""
|
||||||
|
last_error: AIResult | None = None
|
||||||
|
|
||||||
|
for provider in self.providers:
|
||||||
|
for attempt in range(self.max_retries + 1):
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
provider.analyze(prompt, **kwargs),
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
await self._cleanup_providers(provider)
|
||||||
|
return result
|
||||||
|
|
||||||
|
last_error = result
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
last_error = AIResult(
|
||||||
|
success=False,
|
||||||
|
error=f"{provider.name} timeout after {self.timeout}s",
|
||||||
|
provider=provider.name,
|
||||||
|
duration_ms=int(self.timeout * 1000),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
last_error = AIResult(
|
||||||
|
success=False,
|
||||||
|
error=f"{provider.name} error: {str(e)}",
|
||||||
|
provider=provider.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if attempt < self.max_retries:
|
||||||
|
await asyncio.sleep(1 * (attempt + 1))
|
||||||
|
|
||||||
|
await self._cleanup_providers()
|
||||||
|
return last_error or AIResult(
|
||||||
|
success=False,
|
||||||
|
error="All providers failed",
|
||||||
|
provider="fallback_chain",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _cleanup_providers(self, skip: AIProvider | None = None) -> None:
|
||||||
|
"""Close all provider clients except the one to keep."""
|
||||||
|
for provider in self.providers:
|
||||||
|
if provider is skip:
|
||||||
|
continue
|
||||||
|
if hasattr(provider, "close"):
|
||||||
|
try:
|
||||||
|
await provider.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""GigaChat provider for VoIdea."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.integrations.ai.base import AIProvider, AIResult
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class GigaChatProvider(AIProvider):
|
||||||
|
"""GigaChat AI provider (Sber).
|
||||||
|
|
||||||
|
Uses OAuth client credentials for authentication.
|
||||||
|
Token is refreshed automatically on 401.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "gigachat"
|
||||||
|
model = "GigaChat"
|
||||||
|
timeout = settings.ai_timeout
|
||||||
|
max_retries = settings.ai_max_retries
|
||||||
|
|
||||||
|
def __init__(self, api_key: str = ""):
|
||||||
|
super().__init__(api_key)
|
||||||
|
self.base_url = settings.ai_gigachat_url.rstrip("/")
|
||||||
|
self.client_id = settings.ai_gigachat_client_id
|
||||||
|
self.client_secret = settings.ai_gigachat_secret
|
||||||
|
self._client = httpx.AsyncClient(timeout=self.timeout)
|
||||||
|
self._access_token: Optional[str] = None
|
||||||
|
self._token_expires_at: float = 0
|
||||||
|
|
||||||
|
async def _get_access_token(self) -> Optional[str]:
|
||||||
|
"""Get or refresh GigaChat access token."""
|
||||||
|
if self._access_token and time.time() < self._token_expires_at:
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
if not self.client_id or not self.client_secret:
|
||||||
|
return None
|
||||||
|
|
||||||
|
auth_str = base64.b64encode(
|
||||||
|
f"{self.client_id}:{self.client_secret}".encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._client.post(
|
||||||
|
"https://ngw.devices.sber.ru/token",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Basic {auth_str}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"RqUID": str(time.time()),
|
||||||
|
},
|
||||||
|
data={"scope": "GIGACHAT_API_PERS"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
self._access_token = data.get("access_token")
|
||||||
|
expires_in = data.get("expires_in", 1800)
|
||||||
|
self._token_expires_at = time.time() + expires_in - 60
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def analyze(self, prompt: str, **kwargs: Any) -> AIResult:
|
||||||
|
"""Send prompt to GigaChat and return result."""
|
||||||
|
start = time.time()
|
||||||
|
temperature = kwargs.get("temperature", 0.7)
|
||||||
|
max_tokens = kwargs.get("max_tokens", 2000)
|
||||||
|
|
||||||
|
token = await self._get_access_token()
|
||||||
|
if not token:
|
||||||
|
return AIResult(
|
||||||
|
success=False,
|
||||||
|
error="GigaChat authentication failed",
|
||||||
|
provider=self.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
parts = prompt.split("\n\n", 1)
|
||||||
|
system_text = parts[0]
|
||||||
|
user_text = parts[1] if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"model": self.model,
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_text},
|
||||||
|
{"role": "user", "content": user_text},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._client.post(
|
||||||
|
f"{self.base_url}/api/v1/chat/completion",
|
||||||
|
json=body,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
duration = int((time.time() - start) * 1000)
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
self._access_token = None
|
||||||
|
token = await self._get_access_token()
|
||||||
|
if token:
|
||||||
|
response = await self._client.post(
|
||||||
|
f"{self.base_url}/api/v1/chat/completion",
|
||||||
|
json=body,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
duration = int((time.time() - start) * 1000)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
result_text = (
|
||||||
|
data.get("choices", [{}])[0]
|
||||||
|
.get("message", {})
|
||||||
|
.get("content", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
return AIResult(
|
||||||
|
success=True,
|
||||||
|
content=result_text,
|
||||||
|
model=self.model,
|
||||||
|
provider=self.name,
|
||||||
|
duration_ms=duration,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
error_body = response.text[:500]
|
||||||
|
return AIResult(
|
||||||
|
success=False,
|
||||||
|
error=f"GigaChat {response.status_code}: {error_body}",
|
||||||
|
provider=self.name,
|
||||||
|
duration_ms=duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return AIResult(
|
||||||
|
success=False,
|
||||||
|
error="GigaChat timeout",
|
||||||
|
provider=self.name,
|
||||||
|
duration_ms=int((time.time() - start) * 1000),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return AIResult(
|
||||||
|
success=False,
|
||||||
|
error=f"GigaChat error: {str(e)}",
|
||||||
|
provider=self.name,
|
||||||
|
duration_ms=int((time.time() - start) * 1000),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self._client.aclose()
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Prompt loader for AI agents.
|
||||||
|
|
||||||
|
Loads prompts from agent spec files or YAML configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
AGENT_SPECS_DIR = Path("docs/specs/agents")
|
||||||
|
AGENT_PROMPTS_YAML = Path("docs/agent_prompts.yaml")
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_roles() -> list[str]:
|
||||||
|
"""Get all available agent roles."""
|
||||||
|
roles = []
|
||||||
|
if AGENT_PROMPTS_YAML.exists():
|
||||||
|
data = yaml.safe_load(AGENT_PROMPTS_YAML.read_text(encoding="utf-8"))
|
||||||
|
return list(data.keys()) if data else []
|
||||||
|
|
||||||
|
if AGENT_SPECS_DIR.exists():
|
||||||
|
for f in sorted(AGENT_SPECS_DIR.glob("*.md")):
|
||||||
|
roles.append(f.stem)
|
||||||
|
|
||||||
|
return roles
|
||||||
|
|
||||||
|
|
||||||
|
def load_prompt_from_yaml(role: str) -> Optional[dict[str, Any]]:
|
||||||
|
"""Load prompt config from YAML file."""
|
||||||
|
if not AGENT_PROMPTS_YAML.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = yaml.safe_load(AGENT_PROMPTS_YAML.read_text(encoding="utf-8"))
|
||||||
|
return data.get(role) if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def load_prompt_from_spec(role: str) -> Optional[dict[str, Any]]:
|
||||||
|
"""Load prompt from agent spec markdown file."""
|
||||||
|
spec_path = AGENT_SPECS_DIR / f"{role}.md"
|
||||||
|
if not spec_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = spec_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def extract_field(label: str) -> Optional[str]:
|
||||||
|
match = re.search(rf"\*\*{label}:\*\*\s*(.+)", content)
|
||||||
|
return match.group(1).strip() if match else None
|
||||||
|
|
||||||
|
def extract_prompt() -> Optional[str]:
|
||||||
|
match = re.search(
|
||||||
|
r"## Prompt Template\n+```\n(.+?)\n```",
|
||||||
|
content,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
return match.group(1).strip() if match else None
|
||||||
|
|
||||||
|
provider_str = extract_field("Провайдер") or extract_field("Provider") or "yandex_gpt"
|
||||||
|
provider = "gigachat" if "gigachat" in (provider_str or "").lower() else "yandex_gpt"
|
||||||
|
|
||||||
|
prompt_text = extract_prompt()
|
||||||
|
if not prompt_text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"system_prompt": prompt_text,
|
||||||
|
"provider": provider,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 2000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt_config(role: str) -> Optional[dict[str, Any]]:
|
||||||
|
"""Get prompt configuration for a role.
|
||||||
|
|
||||||
|
Tries YAML first, falls back to spec markdown.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
role: Agent role name (e.g. "coordinator", "business_analyst")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: system_prompt, provider, temperature, max_tokens
|
||||||
|
or None if not found.
|
||||||
|
"""
|
||||||
|
config = load_prompt_from_yaml(role)
|
||||||
|
if config:
|
||||||
|
return config
|
||||||
|
|
||||||
|
return load_prompt_from_spec(role)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Yandex GPT provider for VoIdea."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.integrations.ai.base import AIProvider, AIResult
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class YandexGPTProvider(AIProvider):
|
||||||
|
"""Yandex GPT AI provider.
|
||||||
|
|
||||||
|
Supports both API key (Api-Key header) and IAM token (Bearer header).
|
||||||
|
Automatically falls back between auth methods on 401.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "yandex_gpt"
|
||||||
|
model = "yandexgpt/latest"
|
||||||
|
timeout = settings.ai_timeout
|
||||||
|
max_retries = settings.ai_max_retries
|
||||||
|
|
||||||
|
def __init__(self, api_key: str = ""):
|
||||||
|
super().__init__(api_key or settings.ai_yandex_key)
|
||||||
|
self.base_url = settings.ai_yandex_url.rstrip("/")
|
||||||
|
self.folder_id = settings.ai_yandex_folder_id
|
||||||
|
self._client = httpx.AsyncClient(timeout=self.timeout)
|
||||||
|
|
||||||
|
async def analyze(self, prompt: str, **kwargs: Any) -> AIResult:
|
||||||
|
"""Send prompt to Yandex GPT and return result."""
|
||||||
|
start = time.time()
|
||||||
|
temperature = kwargs.get("temperature", 0.7)
|
||||||
|
max_tokens = kwargs.get("max_tokens", 2000)
|
||||||
|
|
||||||
|
model_uri = f"gpt://{self.folder_id}/{self.model}" if self.folder_id else self.model
|
||||||
|
|
||||||
|
# Split prompt into system and user parts
|
||||||
|
parts = prompt.split("\n\n", 1)
|
||||||
|
system_text = parts[0]
|
||||||
|
user_text = parts[1] if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"modelUri": model_uri,
|
||||||
|
"completionOptions": {
|
||||||
|
"temperature": temperature,
|
||||||
|
"maxTokens": max_tokens,
|
||||||
|
},
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "text": system_text},
|
||||||
|
{"role": "user", "text": user_text},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._client.post(
|
||||||
|
f"{self.base_url}/foundationModels/v1/completion",
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
duration = int((time.time() - start) * 1000)
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
# Try with Api-Key header instead
|
||||||
|
response = await self._client.post(
|
||||||
|
f"{self.base_url}/foundationModels/v1/completion",
|
||||||
|
json=body,
|
||||||
|
headers={"Authorization": f"Api-Key {self.api_key}"},
|
||||||
|
)
|
||||||
|
duration = int((time.time() - start) * 1000)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
result_text = (
|
||||||
|
data.get("result", {})
|
||||||
|
.get("alternatives", [{}])[0]
|
||||||
|
.get("message", {})
|
||||||
|
.get("text", "")
|
||||||
|
)
|
||||||
|
tokens = (
|
||||||
|
data.get("result", {})
|
||||||
|
.get("usage", {})
|
||||||
|
.get("inputTextTokens", 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AIResult(
|
||||||
|
success=True,
|
||||||
|
content=result_text,
|
||||||
|
model=self.model,
|
||||||
|
provider=self.name,
|
||||||
|
tokens_used=tokens,
|
||||||
|
duration_ms=duration,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
error_body = response.text[:500]
|
||||||
|
return AIResult(
|
||||||
|
success=False,
|
||||||
|
error=f"Yandex GPT {response.status_code}: {error_body}",
|
||||||
|
provider=self.name,
|
||||||
|
duration_ms=duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return AIResult(
|
||||||
|
success=False,
|
||||||
|
error="Yandex GPT timeout",
|
||||||
|
provider=self.name,
|
||||||
|
duration_ms=int((time.time() - start) * 1000),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return AIResult(
|
||||||
|
success=False,
|
||||||
|
error=f"Yandex GPT error: {str(e)}",
|
||||||
|
provider=self.name,
|
||||||
|
duration_ms=int((time.time() - start) * 1000),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self._client.aclose()
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""OAuth integrations for VoIdeaAI."""
|
||||||
|
|
||||||
|
from app.integrations.oauth.yandex import (
|
||||||
|
get_authorize_url as yandex_authorize_url,
|
||||||
|
exchange_code as yandex_exchange_code,
|
||||||
|
get_user_info as yandex_user_info,
|
||||||
|
ensure_app_folder as yandex_ensure_folder,
|
||||||
|
upload_file as yandex_upload_file,
|
||||||
|
get_disk_info as yandex_disk_info,
|
||||||
|
YandexUserInfo,
|
||||||
|
YandexTokenResult,
|
||||||
|
VOIDEA_DISK_FOLDER,
|
||||||
|
)
|
||||||
|
from app.integrations.oauth.google import (
|
||||||
|
is_available as google_is_available,
|
||||||
|
get_authorize_url as google_authorize_url,
|
||||||
|
exchange_code as google_exchange_code,
|
||||||
|
get_user_info as google_user_info,
|
||||||
|
ensure_app_folder as google_ensure_folder,
|
||||||
|
upload_file as google_upload_file,
|
||||||
|
get_disk_info as google_disk_info,
|
||||||
|
GoogleUserInfo,
|
||||||
|
)
|
||||||
|
from app.integrations.oauth.apple import (
|
||||||
|
is_available as apple_is_available,
|
||||||
|
get_authorize_url as apple_authorize_url,
|
||||||
|
exchange_code as apple_exchange_code,
|
||||||
|
get_user_info as apple_user_info,
|
||||||
|
AppleUserInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"yandex_authorize_url",
|
||||||
|
"yandex_exchange_code",
|
||||||
|
"yandex_user_info",
|
||||||
|
"yandex_ensure_folder",
|
||||||
|
"yandex_upload_file",
|
||||||
|
"yandex_disk_info",
|
||||||
|
"YandexUserInfo",
|
||||||
|
"YandexTokenResult",
|
||||||
|
"VOIDEA_DISK_FOLDER",
|
||||||
|
"google_is_available",
|
||||||
|
"google_authorize_url",
|
||||||
|
"google_exchange_code",
|
||||||
|
"google_user_info",
|
||||||
|
"google_ensure_folder",
|
||||||
|
"google_upload_file",
|
||||||
|
"google_disk_info",
|
||||||
|
"GoogleUserInfo",
|
||||||
|
"apple_is_available",
|
||||||
|
"apple_authorize_url",
|
||||||
|
"apple_exchange_code",
|
||||||
|
"apple_user_info",
|
||||||
|
"AppleUserInfo",
|
||||||
|
]
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Apple OAuth client with iCloud Drive API support.
|
||||||
|
|
||||||
|
Activated when OAUTH_APPLE_ID is set in .env.
|
||||||
|
Note: Apple requires Sign in with Apple capability and team ID configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
APPLE_OAUTH_URL = "https://appleid.apple.com/auth/authorize"
|
||||||
|
APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token"
|
||||||
|
|
||||||
|
SCOPES = "name email"
|
||||||
|
|
||||||
|
VOIDEA_DRIVE_FOLDER = "VoIdeaAI"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppleUserInfo:
|
||||||
|
id: str
|
||||||
|
email: str
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
def is_available() -> bool:
|
||||||
|
return get_settings().apple_oauth_enabled
|
||||||
|
|
||||||
|
|
||||||
|
async def get_authorize_url() -> str:
|
||||||
|
settings = get_settings()
|
||||||
|
params = {
|
||||||
|
"response_type": "code id_token",
|
||||||
|
"client_id": settings.oauth_apple_id,
|
||||||
|
"redirect_uri": settings.oauth_apple_redirect_uri,
|
||||||
|
"scope": SCOPES,
|
||||||
|
"response_mode": "form_post",
|
||||||
|
}
|
||||||
|
query = "&".join(f"{k}={v}" for k, v in params.items())
|
||||||
|
return f"{APPLE_OAUTH_URL}?{query}"
|
||||||
|
|
||||||
|
|
||||||
|
async def exchange_code(code: str) -> dict | None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.apple_oauth_enabled:
|
||||||
|
return None
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
APPLE_TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"client_id": settings.oauth_apple_id,
|
||||||
|
"client_secret": settings.oauth_apple_secret,
|
||||||
|
"redirect_uri": settings.oauth_apple_redirect_uri,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_info(access_token: str) -> AppleUserInfo | None:
|
||||||
|
"""Apple returns user info only in the initial authorization response,
|
||||||
|
not from a userinfo endpoint. This method decodes the id_token claims.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
"https://appleid.apple.com/auth/keys",
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
return None # Full implementation requires JWT id_token decoding
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_app_folder(access_token: str) -> str | None:
|
||||||
|
return None # iCloud Drive API requires additional entitlements
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_file(
|
||||||
|
access_token: str,
|
||||||
|
file_name: str,
|
||||||
|
file_content: bytes,
|
||||||
|
parent_id: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
return False # Requires CloudKit API integration
|
||||||
|
|
||||||
|
|
||||||
|
async def get_disk_info(access_token: str) -> dict | None:
|
||||||
|
return None # Requires CloudKit API integration
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Google OAuth client with Google Drive API support.
|
||||||
|
|
||||||
|
Activated when OAUTH_GOOGLE_ID is set in .env.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
GOOGLE_OAUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||||
|
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||||
|
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||||
|
GOOGLE_DRIVE_URL = "https://www.googleapis.com/drive/v3"
|
||||||
|
|
||||||
|
SCOPES = " ".join([
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
|
"https://www.googleapis.com/auth/drive.file",
|
||||||
|
])
|
||||||
|
|
||||||
|
VOIDEA_DRIVE_FOLDER = "VoIdeaAI"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GoogleUserInfo:
|
||||||
|
id: str
|
||||||
|
email: str
|
||||||
|
display_name: str
|
||||||
|
avatar_url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def is_available() -> bool:
|
||||||
|
return get_settings().google_oauth_enabled
|
||||||
|
|
||||||
|
|
||||||
|
async def get_authorize_url() -> str:
|
||||||
|
settings = get_settings()
|
||||||
|
params = {
|
||||||
|
"response_type": "code",
|
||||||
|
"client_id": settings.oauth_google_id,
|
||||||
|
"redirect_uri": settings.oauth_google_redirect_uri,
|
||||||
|
"scope": SCOPES,
|
||||||
|
"access_type": "offline",
|
||||||
|
"prompt": "consent",
|
||||||
|
}
|
||||||
|
query = "&".join(f"{k}={v}" for k, v in params.items())
|
||||||
|
return f"{GOOGLE_OAUTH_URL}?{query}"
|
||||||
|
|
||||||
|
|
||||||
|
async def exchange_code(code: str) -> dict | None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.google_oauth_enabled:
|
||||||
|
return None
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
GOOGLE_TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"client_id": settings.oauth_google_id,
|
||||||
|
"client_secret": settings.oauth_google_secret,
|
||||||
|
"redirect_uri": settings.oauth_google_redirect_uri,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_info(access_token: str) -> GoogleUserInfo | None:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
GOOGLE_USERINFO_URL,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
data = resp.json()
|
||||||
|
return GoogleUserInfo(
|
||||||
|
id=data["id"],
|
||||||
|
email=data["email"],
|
||||||
|
display_name=data.get("name", data["email"]),
|
||||||
|
avatar_url=data.get("picture"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_app_folder(access_token: str) -> str | None:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{GOOGLE_DRIVE_URL}/files",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"name": VOIDEA_DRIVE_FOLDER,
|
||||||
|
"mimeType": "application/vnd.google-apps.folder",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json().get("id")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_file(
|
||||||
|
access_token: str,
|
||||||
|
file_name: str,
|
||||||
|
file_content: bytes,
|
||||||
|
parent_id: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
metadata = {"name": file_name}
|
||||||
|
if parent_id:
|
||||||
|
metadata["parents"] = [parent_id]
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{GOOGLE_DRIVE_URL}/files?uploadType=multipart",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
data={"metadata": str(metadata)},
|
||||||
|
files={"file": (file_name, file_content)},
|
||||||
|
)
|
||||||
|
return resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def get_disk_info(access_token: str) -> dict | None:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{GOOGLE_DRIVE_URL}/about?fields=storageQuota",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
return resp.json()
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Yandex OAuth client with Disk API support.
|
||||||
|
|
||||||
|
Scopes requested:
|
||||||
|
- login:email — email address
|
||||||
|
- login:info — login, first+last name
|
||||||
|
- login:avatar — avatar URL
|
||||||
|
- cloud_api:disk.write — write anywhere on Disk
|
||||||
|
- cloud_api:disk.app_folder — access app folder on Disk
|
||||||
|
- cloud_api:disk.read — read entire Disk
|
||||||
|
- cloud_api:disk.info — Disk info (quota, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
YANDEX_OAUTH_URL = "https://oauth.yandex.ru"
|
||||||
|
YANDEX_LOGIN_URL = "https://login.yandex.ru"
|
||||||
|
YANDEX_DISK_URL = "https://cloud-api.yandex.net/v1/disk"
|
||||||
|
|
||||||
|
SCOPES = " ".join([
|
||||||
|
"login:email",
|
||||||
|
"login:info",
|
||||||
|
"login:avatar",
|
||||||
|
"cloud_api:disk.write",
|
||||||
|
"cloud_api:disk.app_folder",
|
||||||
|
"cloud_api:disk.read",
|
||||||
|
"cloud_api:disk.info",
|
||||||
|
])
|
||||||
|
|
||||||
|
VOIDEA_DISK_FOLDER = "VoIdeaAI"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class YandexUserInfo:
|
||||||
|
id: str
|
||||||
|
login: str
|
||||||
|
email: str
|
||||||
|
display_name: str
|
||||||
|
avatar_url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class YandexTokenResult:
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str | None
|
||||||
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
async def get_authorize_url() -> str:
|
||||||
|
"""Generate Yandex OAuth authorization URL."""
|
||||||
|
settings = get_settings()
|
||||||
|
params = {
|
||||||
|
"response_type": "code",
|
||||||
|
"client_id": settings.oauth_yandex_id,
|
||||||
|
"redirect_uri": settings.oauth_yandex_redirect_uri,
|
||||||
|
"scope": SCOPES,
|
||||||
|
}
|
||||||
|
query = "&".join(f"{k}={v}" for k, v in params.items())
|
||||||
|
return f"{YANDEX_OAUTH_URL}/authorize?{query}"
|
||||||
|
|
||||||
|
|
||||||
|
async def exchange_code(code: str) -> YandexTokenResult | None:
|
||||||
|
"""Exchange authorization code for access token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: Authorization code from OAuth callback
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Token result or None if exchange failed
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.oauth_yandex_id or not settings.oauth_yandex_secret:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{YANDEX_OAUTH_URL}/token",
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"client_id": settings.oauth_yandex_id,
|
||||||
|
"client_secret": settings.oauth_yandex_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
data = resp.json()
|
||||||
|
return YandexTokenResult(
|
||||||
|
access_token=data["access_token"],
|
||||||
|
refresh_token=data.get("refresh_token"),
|
||||||
|
expires_in=data.get("expires_in", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_info(access_token: str) -> YandexUserInfo | None:
|
||||||
|
"""Get user info from Yandex Login API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_token: Valid OAuth access token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User info or None if request failed
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{YANDEX_LOGIN_URL}/info",
|
||||||
|
params={"format": "json"},
|
||||||
|
headers={"Authorization": f"OAuth {access_token}"},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
avatar_url: str | None = None
|
||||||
|
if data.get("default_avatar_id"):
|
||||||
|
avatar_url = (
|
||||||
|
f"https://avatars.yandex.net/get-yapic/{data['default_avatar_id']}/islands-200"
|
||||||
|
)
|
||||||
|
|
||||||
|
display_name = data.get("real_name") or data.get("display_name") or data.get("login", "")
|
||||||
|
email = data.get("default_email") or ""
|
||||||
|
|
||||||
|
return YandexUserInfo(
|
||||||
|
id=str(data["id"]),
|
||||||
|
login=data.get("login", ""),
|
||||||
|
email=email,
|
||||||
|
display_name=display_name,
|
||||||
|
avatar_url=avatar_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_app_folder(access_token: str) -> bool:
|
||||||
|
"""Create VoIdeaAI folder on Yandex.Disk if it doesn't exist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_token: Valid OAuth access token with disk.write scope
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if folder exists (created or already present)
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.put(
|
||||||
|
f"{YANDEX_DISK_URL}/resources",
|
||||||
|
params={"path": VOIDEA_DISK_FOLDER},
|
||||||
|
headers={"Authorization": f"OAuth {access_token}"},
|
||||||
|
)
|
||||||
|
return resp.status_code in (201, 409)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_disk_info(access_token: str) -> dict | None:
|
||||||
|
"""Get Yandex.Disk info (quota, usage).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_token: Valid OAuth access token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Disk info dict or None
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{YANDEX_DISK_URL}",
|
||||||
|
headers={"Authorization": f"OAuth {access_token}"},
|
||||||
|
)
|
||||||
|
return resp.json() if resp.status_code == 200 else None
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_file(access_token: str, local_path: str, remote_name: str) -> bool:
|
||||||
|
"""Upload a file to VoIdeaAI folder on Yandex.Disk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_token: Valid OAuth access token
|
||||||
|
local_path: Path to local file
|
||||||
|
remote_name: Name for the file on Disk
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if upload succeeded
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
if not os.path.exists(local_path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
remote_path = f"{VOIDEA_DISK_FOLDER}/{remote_name}"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# 1. Get upload URL
|
||||||
|
resp = await client.get(
|
||||||
|
f"{YANDEX_DISK_URL}/resources/upload",
|
||||||
|
params={"path": remote_path, "overwrite": "true"},
|
||||||
|
headers={"Authorization": f"OAuth {access_token}"},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return False
|
||||||
|
upload_url = resp.json().get("href")
|
||||||
|
if not upload_url:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. Upload file
|
||||||
|
with open(local_path, "rb") as f:
|
||||||
|
upload_resp = await client.put(upload_url, content=f)
|
||||||
|
return upload_resp.status_code in (201, 202)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Telegram bot integration for VoIdea."""
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Telegram ↔ VoIdea account linking."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# In-memory store for link tokens (in production: Redis with TTL)
|
||||||
|
_link_tokens: dict[str, int] = {} # token → user_id
|
||||||
|
|
||||||
|
|
||||||
|
def create_link_token(user_id: int) -> str:
|
||||||
|
"""Generate a one-time token for linking Telegram to VoIdea account."""
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
_link_tokens[token] = user_id
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def consume_link_token(token: str) -> int | None:
|
||||||
|
"""Validate and consume a link token. Returns user_id or None."""
|
||||||
|
user_id = _link_tokens.pop(token, None)
|
||||||
|
return user_id
|
||||||
|
|
||||||
|
|
||||||
|
def get_bot_link_url(base_url: str, token: str) -> str:
|
||||||
|
"""Return the URL for the user to confirm account linking."""
|
||||||
|
return f"{base_url}/bot/link?token={token}"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user