Skip to content

Целевая архитектура: модульная декомпозиция #481

@shikhalev

Description

@shikhalev

Контекст

Описание желаемой модульной архитектуры AI Secretary System. Не план миграции, а целевое состояние — каким должен быть проект при идеальной декомпозиции.

Основано на аудите текущего состояния: #480


Принципы

  1. Каждый домен = пакет с собственными models, repository, service, router
  2. Зависимости однонаправленные — нижние слои не знают о верхних
  3. Взаимодействие через интерфейсы — модули зависят от протоколов, не от реализаций
  4. Нет файлов-монолитов — ни один файл не отвечает за все домены сразу
  5. Deployment mode — переключение на уровне модулей, не if-statements

1. Каталог модулей

modules/
├── core/           ← Фундамент (DB, auth, config, events, health)
├── knowledge/      ← RAG, wiki, коллекции, эмбеддинги
├── llm/            ← LLM-провайдеры, генерация, fallback
├── chat/           ← Сессии, сообщения, стриминг, бранчинг
├── channels/
│   ├── telegram/   ← Telegram бот-менеджер + подпроцесс
│   ├── whatsapp/   ← WhatsApp бот-менеджер + подпроцесс
│   └── widget/     ← Web-виджет, lead tracking
├── crm/            ← amoCRM (OAuth, leads, contacts, inbox)
├── ecommerce/      ← WooCommerce (products, orders)
├── sales/          ← Воронки, сегменты, платежи
├── kanban/         ← Проекты, задачи, roadmap
├── claude_code/    ← WebSocket CLI, сессии, проекты
├── speech/         ← TTS/STT/Voice (local-only)
├── telephony/      ← GSM/SIM (local-only)
├── monitoring/     ← Audit, usage, system health
└── admin/          ← Backup, GDPR, settings, FAQ

2. Структура каждого модуля

Каждый модуль — Python-пакет со стандартной структурой:

modules/{domain}/
    __init__.py      # Публичный API модуля (экспорты)
    models.py        # SQLAlchemy модели ТОЛЬКО этого домена
    repository.py    # Репозиторий (данные)
    service.py       # Бизнес-логика
    router.py        # FastAPI роутер
    events.py        # Определения событий (dataclasses)
    schemas.py       # Pydantic модели запросов/ответов
    tasks.py         # Background tasks (если есть)

Модуль экспортирует через __init__.py:

  • Service class (основной интерфейс)
  • Event dataclasses (для подписки другими модулями)
  • Router (для регистрации в приложении)

Модуль НЕ экспортирует:

  • Репозитории (внутренняя деталь)
  • SQLAlchemy модели напрямую (только через service API)

3. Модуль core — фундамент

core/
    database.py      # Engine, session factory, init/shutdown
    auth/
        models.py    # User, Role, RolePermission, UserSession, WorkspaceMember, UserIdentity
        service.py   # AuthService: login, JWT, RBAC checks, session management
        guards.py    # require_permission(), get_current_user(), workspace_context()
        cache.py     # SessionCache, PermissionsCache, MemberRoleCache
        router.py    # /admin/auth/*, /admin/roles/*, /admin/workspace/*
    config.py        # Единый ConfigService: .env + DB, приоритет DB > env
    events.py        # EventBus (in-process pub/sub)
    cache.py         # CacheManager: in-memory + Redis, unified invalidation
    health.py        # HealthRegistry: каждый модуль регистрирует свой health check
    tasks.py         # TaskRegistry: named background tasks, cancel_all()
    base.py          # BaseRepository, BaseService

Правила:

  • core не зависит ни от одного модуля
  • Все модули зависят от core
  • Auth — часть core (нужен всем)

EventBus — простой in-process pub/sub:

class EventBus:
    async def publish(self, event: BaseEvent) -> None: ...
    def subscribe(self, event_type: type[BaseEvent], handler: Callable) -> None: ...

4. Граф зависимостей

                        ┌──────────┐
                        │   core   │
                        │ db, auth,│
                        │ config,  │
                        │ events   │
                        └────┬─────┘
                 ┌───────────┼───────────────┐
                 │           │               │
           ┌─────┴─────┐ ┌──┴──┐    ┌───────┴───────┐
           │ knowledge  │ │ llm │    │    speech      │
           │ RAG, wiki  │ │     │    │   TTS/STT      │
           │ collections│ │     │    │  (local-only)   │
           └─────┬──────┘ └──┬──┘    └───────────────┘
                 │           │
                 └─────┬─────┘
                       │
                 ┌─────┴─────┐
                 │    chat    │
                 │ sessions,  │
                 │ streaming  │
                 └─────┬──────┘
           ┌───────────┼──────────────┐
           │           │              │
     ┌─────┴─────┐ ┌──┴──────┐ ┌─────┴──────┐
     │ channels   │ │ kanban  │ │claude_code │
     │ TG/WA/     │ │         │ │            │
     │ Widget     │ └─────────┘ └────────────┘
     └─────┬──────┘
           │
     ┌─────┴─────┐
     │   sales    │
     └────────────┘

     ┌─────────┐  ┌──────────┐
     │   crm   │  │ecommerce │
     │ amoCRM  │  │ WooCom.  │
     └────┬────┘  └────┬─────┘
          └──────┬─────┘
                 │
           зависят от:
           core + knowledge + chat

Таблица зависимостей

Модуль Зависит от Что берёт
knowledge core DB, auth guards
llm core DB (provider configs), config
speech core DB (presets), config
chat core, knowledge, llm auth, RAG-поиск, LLM-генерация
channels/telegram core, chat auth (internal tokens), chat API
channels/whatsapp core, chat auth (internal tokens), chat API
channels/widget core, chat chat API, public endpoints
sales core, channels подписчики, доставка сообщений
kanban core DB, auth
claude_code core DB, auth, WebSocket
crm core, knowledge, chat dataset sync, session linking
ecommerce core, knowledge dataset sync
monitoring core DB, health registry
admin core DB, backup
telephony core DB, config

Запрещённые зависимости:

  • core → любой модуль
  • knowledge → chat, channels, crm
  • llm → chat, channels
  • chat → channels, crm, kanban
  • kanban → chat (связь через события, не импорт)

5. Протоколы взаимодействия

5A. Синхронные интерфейсы (вызовы внутри процесса)

Каждый модуль экспортирует service с типизированным API:

# knowledge/__init__.py
class KnowledgeService(Protocol):
    async def search(self, query: str, collection_ids: list[int] | None,
                     top_k: int = 3, max_chars: int = 2500) -> list[SearchResult]: ...
    async def search_multi(self, query: str, collection_ids: list[int],
                           top_k: int = 3) -> list[SearchResult]: ...
    async def get_collections(self, enabled_only: bool = True) -> list[Collection]: ...
    async def sync_documents(self, collection_id: int, base_dir: str) -> SyncResult: ...
# llm/__init__.py
class LLMService(Protocol):
    async def generate(self, messages: list[Message], config: LLMConfig) -> str: ...
    async def stream(self, messages: list[Message], config: LLMConfig) -> AsyncIterator[str]: ...
    async def resolve_backend(self, backend_id: str) -> LLMProvider: ...
# chat/__init__.py
class ChatService(Protocol):
    async def create_session(self, source: str, source_id: str | None,
                             system_prompt: str | None, owner_id: int | None,
                             workspace_id: int) -> Session: ...
    async def send_message(self, session_id: str, content: str,
                           llm_override: LLMConfig | None = None) -> Message: ...
    async def stream_message(self, session_id: str, content: str,
                             llm_override: LLMConfig | None = None) -> AsyncIterator[StreamChunk]: ...
    async def get_session(self, session_id: str) -> Session | None: ...
    async def get_history(self, session_id: str) -> list[Message]: ...

Chat внутри себя вызывает Knowledge и LLM:

# chat/service.py
class ChatServiceImpl:
    def __init__(self, knowledge: KnowledgeService, llm: LLMService):
        self.knowledge = knowledge
        self.llm = llm

    async def stream_message(self, session_id, content, llm_override=None):
        session = await self._get_session(session_id)
        rag_context = await self.knowledge.search(content, session.collection_ids)
        prompt = self._build_prompt(session.system_prompt, rag_context)
        messages = await self._build_message_list(session_id, content, prompt)
        backend = await self.llm.resolve_backend(llm_override or session.llm_backend)
        async for chunk in self.llm.stream(messages, backend):
            yield chunk

5B. Асинхронные события (fire-and-forget)

Модули публикуют события через EventBus. Другие модули подписываются. Нет прямых зависимостей.

# channels/widget/events.py
@dataclass
class WidgetSessionCreated:
    session_id: str
    widget_instance_id: str
    visitor_metadata: dict

@dataclass
class WidgetMessageSent:
    session_id: str
    content: str
    is_first: bool

# crm/service.py — подписывается
class CRMService:
    def setup_events(self, bus: EventBus):
        bus.subscribe(WidgetSessionCreated, self._on_widget_session)
        bus.subscribe(WidgetMessageSent, self._on_widget_message)

    async def _on_widget_session(self, event: WidgetSessionCreated):
        """Создать lead в amoCRM при создании виджет-сессии."""
        ...

Какие события нужны:

Событие Публикует Подписчики
UserRoleChanged core/auth core/cache (invalidate)
SessionRevoked core/auth core/cache (invalidate)
WidgetSessionCreated channels/widget crm (create lead)
WidgetMessageSent channels/widget crm (append note)
WidgetContactSubmitted channels/widget crm (link contact)
BotProcessDied channels/telegram, /whatsapp monitoring (alert), self (auto-restart)
KnowledgeUpdated knowledge llm (reload FAQ cache)
ConfigChanged core/config affected modules (reload)
DatasetSynced crm, ecommerce knowledge (reindex)

5C. HTTP-контракты (кросс-процессные)

Бот-подпроцессы общаются с основным процессом только через HTTP. Контракт:

# Бот → Основной процесс
POST /internal/chat/sessions                          # Создать сессию
POST /internal/chat/sessions/{id}/stream              # Отправить сообщение (SSE)
GET  /internal/chat/sessions/{id}/history             # Получить историю (при рестарте)
POST /internal/bot/heartbeat                          # Health ping каждые 30с
GET  /internal/bot/config/{instance_id}               # Получить/обновить конфиг

# Авторизация: Bearer token (internal JWT, role=bot)
# Prefix /internal/ отделяет от /admin/ и /widget/

Принципы:

  • Боты НЕ импортируют DB-модули основного процесса
  • Боты НЕ обращаются к SQLite напрямую
  • Вся персистентность — через API
  • Config polling: бот проверяет конфиг через API раз в N минут

6. Композиция приложения (startup)

Вместо одного startup event на 300 строк — каждый модуль регистрирует свой lifecycle:

# app.py (замена orchestrator.py, ~50 строк)
from core import create_app, EventBus, HealthRegistry, TaskRegistry
from modules import knowledge, llm, chat, channels, crm, ...

app = create_app()

# Фаза 1: Инициализация core
db = await init_database()
bus = EventBus()
health = HealthRegistry()
tasks = TaskRegistry()

# Фаза 2: Создание сервисов (порядок = граф зависимостей)
knowledge_svc = knowledge.create_service(db)
llm_svc = llm.create_service(db, config)
chat_svc = chat.create_service(db, knowledge_svc, llm_svc)

# Фаза 3: Регистрация роутеров
app.include_router(knowledge.router)
app.include_router(llm.router)
app.include_router(chat.router)

# Фаза 4: Подписка на события
crm_svc.setup_events(bus)
monitoring_svc.setup_events(bus)

# Фаза 5: Запуск background tasks
tasks.register("session_cleanup", chat_svc.periodic_cleanup, interval=3600)
tasks.register("vacuum", db.periodic_vacuum, interval=7*24*3600, initial_delay=24*3600)
tasks.register("bot_health", channels.health_monitor, interval=30)

# Shutdown
@app.on_event("shutdown")
async def shutdown():
    await tasks.cancel_all()
    await channels.stop_all_bots()
    await db.close()

7. Deployment modes

Вместо if DEPLOYMENT_MODE != "cloud": — модули просто не загружаются:

# app.py
MODULES = {
    "full":  [knowledge, llm, chat, channels, crm, ecommerce, sales,
              kanban, claude_code, speech, telephony, monitoring, admin],
    "cloud": [knowledge, llm, chat, channels, crm, ecommerce, sales,
              kanban, claude_code, monitoring, admin],
    "local": [knowledge, llm, chat, channels, crm, ecommerce, sales,
              kanban, claude_code, speech, telephony, monitoring, admin],
}

for module in MODULES[DEPLOYMENT_MODE]:
    svc = module.create_service(db, **deps)
    app.include_router(module.router)

speech и telephony просто не загружаются в cloud mode. Никаких if-statements внутри других модулей.

Health check автоматически показывает только загруженные модули.

Роутер /admin/deployment-mode возвращает список загруженных модулей → фронтенд скрывает ненужные вкладки.


8. Cross-cutting concerns

Кеширование

Единый CacheManager с namespace:

cache.get("auth:session:{jti}")
cache.get("knowledge:faq:{workspace_id}")
cache.invalidate_pattern("auth:session:*")  # при mass-revoke

Слои: L1 in-memory (TTL) → L2 Redis (optional). Инвалидация через EventBus: при UserRoleChangedcache.invalidate_pattern("auth:permissions:{role}").

Кросс-доменные FK

Слабые связи (не через FK, а через ID + event):

# claude_code/models.py
class ClaudeCodeSession:
    chat_session_id: str | None = None    # Просто строка, не FK
    kanban_task_id: int | None = None     # Просто int, не FK

# Целостность — через events:
# При удалении ChatSession → publish ChatSessionDeleted
# claude_code подписывается → обнуляет chat_session_id

Конфигурация

Единый источник: DB (таблица config). .env — только для секретов и bootstrap (DB URL, JWT secret). Модули объявляют свои config-ключи:

# llm/config.py
class LLMConfig(ModuleConfig):
    default_backend: str = "vllm"
    max_tokens: int = 4096

9. Сравнение с текущим состоянием

Аспект Сейчас Целевое
Точка входа orchestrator.py (4100 строк) app.py (~50 строк)
Модели 1 файл, 55 классов По файлу на модуль
Менеджеры 1 файл, 28 классов service.py в каждом модуле
DI ServiceContainer (только hardware) + глобальные синглтоны Explicit constructor injection
Кросс-модульная связь Прямой импорт Protocol interfaces + EventBus
Background tasks fire-and-forget create_task() TaskRegistry с cancel_all()
Кеширование 5 независимых кешей Единый CacheManager + event invalidation
Deploy modes if-statements Модули не загружаются
Bot ↔ Main Прямой DB import + HTTP Только HTTP (/internal/ API)
Shutdown Только DB close Tasks → Bots → Bridge → DB

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentation

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions