Files
call-center/longread.md
T

37 KiB
Raw Blame History

call-center — лонгрид по архитектуре и флоу

Документ собран по результатам сквозного ревью репозитория (25+ Python-сервисов, 6 вэб-UI, Asterisk-телефония, "AI-стек"). Задача документа — дать целостную картину: что здесь на самом деле есть, как это работает end-to-end, и где основной технический долг. Короткие архитектурные факты уже были описаны в docs/architecture/*.md — этот файл идёт глубже и местами поправляет/уточняет то, что там написано (см. раздел 8 "Расхождения с существующей документацией").


1. Что это за проект — TL;DR

call-center — омниканальная контакт-центр платформа собственной разработки: телефония (Asterisk), Telegram, WhatsApp, Webchat, Email, плюс встроенный "AI-оператор" первой линии и полноценный CRM/sales-модуль поверх того же ядра. Технически это не микросервисы в строгом смысле — это модульный монолит, физически нарезанный на ~25 FastAPI-процессов, но использующий одну общую SQLAlchemy-схему и одну БД. Сервисы регулярно напрямую читают/пишут таблицы, которыми формально владеют другие сервисы (пример: customer_service джойнит 6 чужих таблиц, sales_service пишет прямо в Customer, supervisor_service читает Interaction).

Есть ли здесь фронт, бэк и ML?

  • Фронт — да, но без фреймворка: 6 отдельных vanilla-JS SPA (ui/operator, ui/supervisor, ui/admin, ui/analyst, ui/sales, ui/login), без сборщика, без TypeScript, ~28 000 строк JS суммарно. Самые крупные — ui/analyst/app.js (7 927 строк) и ui/operator/app.js (7 456 строк, включает встроенный SIP.js WebRTC-софтфон).
  • Бэк — да, это основной объём кода: 25 FastAPI-сервисов на Python, SQLite по умолчанию / PostgreSQL для прод, событийная шина поверх RabbitMQ (transactional outbox/inbox), но шина выключена по умолчанию (EVENT_BUS_ENABLED=False).
  • ML — здесь стоит быть точным: это почти не ML, а оркестрация сторонних AI-API (OpenAI-совместимый чат-эндпоинт, Yandex SpeechKit, ElevenLabs) плюс много ручных rule-based эвристик (regex/keyword-словари на русском и казахском). Единственный по-настоящему локальный ML-компонент — faster-whisper (модель Whisper) в отдельном сервисе streaming_asr_sidecar_service, и то это вспомогательный "partial transcript" ускоритель, а не основной ASR-путь. Поиск по базе знаний ("RAG") — это keyword-скоринг без эмбеддингов и без векторного индекса.

2. Состав системы (карта сервисов)

2.1 Инфраструктурные

Сервис Роль
gateway (gateway/app.py) Единая точка входа: реверс-прокси /proxy/{service}/{path}SERVICE_URLS, раздаёт статику всех 6 UI, шлёт алерты об ошибках в Telegram-бот
auth_service Логин/пароль (в открытом виде!), OIDC/Keycloak PKCE-флоу, кастомный HMAC-токен (не JWT-библиотека, а самописный HS256-подобный формат)
audit_service Лог аудита; пишется либо напрямую (POST /audit/events), либо через подписку на шину событий (которая по умолчанию выключена — см. §5)
event_bus_service RabbitMQ dispatcher поверх transactional outbox/inbox

2.2 Клиентские/роутинг

Сервис Роль
customer_service Профиль клиента + кросс-канальные внешние идентичности (CustomerExternalIdentity)
interaction_service Канонический "Interaction" (обращение/тикет) + таймлайн
routing_service Распределение по очередям — на деле не skill-based, см. §7.3

2.3 Телефония и медиа-каналы

Сервис Роль
asterisk_bridge_service Мост к Asterisk: AMI-события, FastAGI/IVR, управление звонком, хендофф в AI, запись
ivr_service Граф IVR-флоу (DTMF state machine), не знает про Asterisk напрямую
recording_service Хранилище записей звонков (SHA-256, локальный диск)
voice_adapter_service Пассивный append-only лог voice-событий для идемпотентности bridge'а (не эквивалент interaction/state)
telegram_adapter_service, whatsapp_adapter_service, webchat_adapter_service, email_adapter_service Входящие/исходящие интеграции по каналам

2.4 AI и база знаний

Сервис Роль
kb_service Хранилище статей + keyword-поиск (services/shared/kb_search.py)
ai_orchestrator_service "Мозг": решает reply/handoff для Telegram, WhatsApp И голоса (все три канала — в одном сервисе)
ai_voice_runtime_service Real-time аудио-слой: ASR/TTS, barge-in, AudioSocket-протокол
streaming_asr_sidecar_service Локальный faster-whisper для partial-транскриптов (опциональный ускоритель)
real_time_voice Не отдельный сервис — 15-строчный alias-реэкспорт ai_voice_runtime_service.app:app

2.5 Аналитика/CRM/надзор

Сервис Роль
reporting_service KPI-агрегация (SLA/ASA/AHT/Abandon/FCR)
supervisor_service Live-мониторинг очередей и статусов агентов
sales_service Отдельный полноценный CRM (лиды/сделки/оплаты/эскалации), 7 140 строк в одном app.py, 94 REST-эндпоинта — самый большой файл в репозитории, см. §7.4

3. Данные и схема

  • По умолчанию — SQLite (.data/mvp_cc.db), для прод/K8s — PostgreSQL.
  • Схема управляется тремя параллельными механизмами одновременно, которые надо вручную синхронизировать при любом изменении:
    1. SQLAlchemy ORM-модели (services/shared/sql_models.py, sales_sql_models.py) — единый Base на все "микросервисы".
    2. migrations/sql/*_{postgres,sqlite}.sql — 31 миграция × 2 диалекта = 62 файла, написанные вручную (не codegen), почти механический перевод друг друга.
    3. services/shared/sql_init.py::_apply_runtime_schema_compatibility() — ~700-строчный императивный шим ALTER TABLE ... ADD COLUMN IF NOT EXISTS, который выполняется при каждом старте каждого сервиса, чтобы "дотянуть" SQLite-схему до актуального состояния ORM.
  • Миграции запускаются scripts/migrate_core_db.py; отслеживаются только по имени файла (без checksum), без down-миграций.
  • Найдена коллизия номеров: 0013_asterisk_bridge_idempotency и 0013_telegram_ai — оба претендуют на номер 13.
  • Контракты (contracts/openapi/*.yaml, contracts/events/*.json) существуют только как документация — нигде не валидируются. Нет jsonschema/openapi-spec-validator в зависимостях, event-конверты никем не сверяются со схемой перед публикацией.

4. Как это работает: два сквозных флоу

4.1 Входящий телефонный звонок (ring → AI или человек)

  1. Оператор связи → Asterisk (telecom-kz trunk) → dialplan [from-telecom] жёстко маршрутизирует всё на extension 7100 (AI-first — это зашито в dialplan, а не конфигурируется).
  2. [mvpcc-call-init]: генерируется CALL_ID/LINKED_ID, стартует запись (MixMonitor), летит AMI UserEvent(MVPCCCallStarted).
  3. asterisk_bridge_service.ami.ami_loop() (фоновый поток, персистентное AMI-соединение) ловит событие → пишет в AsteriskEventLogRow (inbox/outbox-паттерн с at-least-once доставкой) → process_call_started: создаёт Interaction(channel="voice") через interaction_service, создаёт/обновляет AsteriskCallLinkRow (телефонный state machine: ringing → claimed → connected → ended, плюс отдельный ai_state: queued → greeting → active/handoff_required → human_owned/closed).
  4. Так как очередь сконфигурирована как ai_first, бридж стартует Voice AI сессию (POST .../internal/voice-ai/sessions в ai_voice_runtime_service), Asterisk запускает AudioSocket() — сырой TCP-канал аудио к рантайму.
  5. Внутри ai_voice_runtime_service/ai_orchestrator_service идёт разговор (ASR → LLM-решение → TTS), подробности в §6.
  6. Если AI решает передать на человека — вызывает бридж (POST /internal/voice-ai/calls/{id}/handoff), тот резолвит текущий live-канал (нетривиальная эвристика — Asterisk меняет UniqueID на каждом хопе Dial/Redirect), ставит MVPCC_* channel vars и делает AMI Redirect в [mvpcc-transfer].
  7. На пикапе оператором — MVPCCOperatorConnectedprocess_operator_connected: AsteriskCallLinkRow переходит в human_owned, в таймлайн обращения падает запись "AI запросил перевод на специалиста".
  8. Хэнгап → h-extension: StopMixMonitor + MVPCCCallEnded/MVPCCRecordingReady → бридж скачивает файл (локально или по SFTP) и загружает в recording_service, который публикует call.recording.ready в реальную шину событий.
  9. Если что-то из AMI-событий потерялось (обрыв AMI-сессии, рестарт бриджа) — фоновый reconcile.py каждые N секунд сверяет "зависшие" звонки через CoreShowChannels и синтезирует недостающие call.ended/recording.ready.

Важное расхождение с документацией: события call.connected и call.transferred, хотя и описаны в contracts/events/, реально никогда не долетают до event-busemit_voice_event() в бридже — это просто синхронный POST в voice_adapter_service (который только пишет лог-таблицу и не публикует в шину). event_bus.ensure_rabbitmq_topology() даже не биндит эти два типа событий ни к одной очереди. А call.recording.ready, наоборот, до шины доходит, но со схемным дрейфом: контракт требует поле recording_path, а реальный паблишер шлёт file_name — такого поля вообще нет.

4.2 Входящее сообщение в Telegram → создание обращения

  1. POST /integrations/telegram/bot/webhook (защита — только статичный shared-secret заголовок, без HMAC, в отличие от WhatsApp).
  2. Парсинг апдейта → дедуп по external_message_id (на случай повторной доставки вебхука).
  3. _upsert_thread_for_inbound: резолвит/создаёт клиента через CustomerExternalIdentity (telegram:{user_id}), находит/создаёт TelegramThreadRow; если нет открытого треда — создаёт Interaction(channel="telegram") + таймлайн.
  4. Два побочных эффекта: _sync_sales_thread() (синхронизация с CRM — только у Telegram, у WhatsApp такой синхронизации нет, необъяснённая асимметрия) и _maybe_enqueue_ai_for_thread() (если тред не забрал человек — просит ai_orchestrator_service сгенерировать ответ).
  5. Исходящая доставка ответа идёт через отдельный фоновый worker с claim/lease/retry-backoff — этот же паттерн дословно продублирован в whatsapp_adapter_service под другими именами функций.

5. Событийная шина: что реально работает, а что нет

Документация (docs/architecture/overview.md, event-schemas.md) описывает event-driven архитектуру поверх RabbitMQ с transactional outbox/inbox — это правда как код, но не как поведение по умолчанию:

  • append_outbox_event() действительно пишет событие в той же транзакции, что и доменные данные — это честный outbox.
  • Но EVENT_BUS_ENABLED по умолчанию False. Диспетчер (event_bus_service), который открывает pika-соединение к RabbitMQ и реально публикует, стартует только если флаг включён.
  • audit_service подписан на шину — но раз шина выключена по умолчанию, аудит из доменных событий в демо/дефолтной конфигурации не работает вообще.
  • interaction_service/supervisor_service оборачивают запись в outbox условием if event_bus_enabled(), а вот sales_service — нет: пишет outbox-строки всегда, даже когда шина выключена (просто копятся неотправленные записи).

Итог: в дефолтной конфигурации это набор сервисов, синхронно общающихся HTTP-вызовами и делящих одну БД, а не событийная архитектура. Событийность — реальная, но опциональная фича, которая явно включается только в проде (и то не факт, что везде).


6. AI/ML-стек: честная оценка

Главный вывод: это оркестрация сторонних AI-API + большой объём rule-based логики на regex/keyword-словарях, а не собственные ML-модели. Единственное исключение — локальный faster-whisper в отдельном сервисе.

6.1 Кто с кем говорит

  • ai_orchestrator_service — единственный "мозг" для всех текстовых/голосовых AI-веток: Telegram AI, WhatsApp AI и голосовой AI живут в одном app.py (3 717 строк) + voice.py (2 853 строки, специфика голоса). Из них добрая половина app.py (~1400 строк) — это аналитика/дэшборды (AI containment/handoff funnel), а не сама логика решений.
  • LLM-вызов — не через официальный SDK, а сырой httpx POST на {AI_API_BASE}/chat/completions (OpenAI-совместимый формат, AI_PROVIDER=openai_compatible). По умолчанию AI_PROVIDER=stub — работает вообще без LLM, чисто на детерминированных правилах (_stub_decision): проверка идентичности → запрос человека → чувствительная тема → лучшее совпадение в KB → generic-уточнение.
  • Хэндофф на человека — не отдельный классификатор, а поле needs_handoff, которое либо возвращает сама LLM в JSON-ответе, либо жёстко срабатывает раньше LLM по regex-правилам (_looks_like_human_request, _is_sensitive_request).
  • Голосовая ветка (voice.py) — многослойная rule-based политика (проверка идентичности → "вы ещё здесь?" → счётчик уточнений ≥4 → off-domain-отказ → LLM-вызов только если явно включён llm_guarded/v2_* режим → фоллбэк на KB → generic-уточнение). Голос может полностью работать без единого обращения к LLM.

6.2 Реальный аудио-пайплайн (ai_voice_runtime_service)

  • ASR/TTS-провайдеры, подтверждено по коду: Yandex SpeechKit (дефолт), OpenAI, ElevenLabs — всё через самописные httpx/grpc-клиенты (нет openai/anthropic пакетов в requirements.txt вообще — ни одной официальной SDK).
  • AudioSocket — протокол Asterisk для стриминга аудио, реализован вручную (3-байтовый заголовок + payload).
  • VAD (детекция речи) и barge-in — не ML, а простой RMS-энергетический порог (EnergyVAD): если энергия кадра выше порога дольше 220 мс во время проигрывания ответа бота — прерываем воспроизведение. Ни Silero-VAD, ни WebRTC-VAD — ничего ML-based здесь нет.
  • ack_bank.py — файловый кэш коротких "заполняющих" фраз ("секунду, посмотрю...") с ключом по SHA-256, чтобы маскировать задержку LLM/TTS без живого TTS-раунд-трипа на каждую реплику.

6.3 "RAG" по базе знаний

services/shared/kb_search.pyне векторный поиск. Загружает все строки в Python и скорит каждую вручную (совпадение токенов + префиксный fuzzy-match + бонус за фразу), без обратного индекса, без учёта частоты термина (IDF), без SQLite FTS5/pgvector. При росте базы знаний это не масштабируется — сейчас это полный скан таблицы на каждый запрос.

6.4 Единственный настоящий локальный ML-компонент

streaming_asr_sidecar_service — отдельный FastAPI-сервис, реально грузит модель faster-whisper (WhisperModel, CPU, int8-квантование по умолчанию). Используется только как опциональный "partial transcript" ускоритель в режиме voice v2 (быстрые промежуточные транскрипты для более ранней реакции бота), финальный/авторитетный транскрипт по умолчанию всё равно идёт через облачный Yandex SpeechKit.

6.5 Найденные проблемы (детали ниже в §7)

  • Мёртвый дублированный код в voice.py (закрытая копия _voice_decision, полностью неиспользуемая _voice_decision_legacy).
  • Три почти идентичных модуля "singleton JSON config в БД" (ai_operator_config.py, voice_tts_config.py, voice_name_config.py).
  • God-class на 1954 строки (media_runtime.py) — протокол, VAD, barge-in, стриминг ASR, воспроизведение и метрики в одном классе.
  • Казахстанские гео-специфичные словари (город, алиасы) захардкожены прямо в services/shared/ai_context_summary.py — не вынесены в конфиг, что мешает мультирегиональному переиспользованию.
  • Три независимых механизма кэширования TTS, решающих одну и ту же задачу по-разному (offline-материализация IVR-промптов, live-синтез, ack_bank).

7. Технический долг и находки (сведено по приоритету)

7.1 🔴 Безопасность: подмена ролей через заголовки

get_actor() (services/shared/security.py) принимает либо подписанный Bearer-токен, либо, если ALLOW_LEGACY_HEADER_AUTH=True (это дефолт), сырые несигнированные заголовки X-User/X-Role. Gateway (gateway/app.py::_forward) слепо форвардит любые X-User/X-Role, которые прислал клиент, без сверки с токеном. Итог: любой клиент, достучавшийся до /proxy/*, может выставить себе X-Role: admin и пройти любую require_roles()-проверку, пока legacy header auth явно не выключен во всех окружениях. В UI это ровно та лазейка, о которой предупреждает access-guards.js (клиентский guard в ui/operator — чисто косметический, обходится правкой localStorage). Судя по deployment/helm/values.track9-strict.yaml/values.track9-live.yaml (allowLegacyHeaderAuth: "0", authMode: bearer), это уже осознанная проблема, которую в проде отключают через overlay — но дефолт остаётся небезопасным.

7.2 🔴 Пароли и секреты

  • auth_service: пароли пользователей сравниваются и хранятся в открытом виде (user.password != payload.password), нет ни одного вызова хэш-функции нигде в auth-пути. Демо-пользователи вида admin123 захардкожены в сидинге.
  • services/shared/security.py: секрет токена по умолчанию "dev-secret-change-me", если APP_TOKEN_SECRET не задан — тихий даунгрейд безопасности при забытой переменной окружения.
  • deployment/docker-compose.asterisk.server.yml: реальный продовый IP (92.38.48.166) захардкожен прямо в compose-файле.

7.3 🟠 Routing на деле не skill-based

routing_service парсит поле strategy (round_robin|least_loaded|skill_based) из правил очереди, но реально использует только round-robin по захардкоженному в коде словарю _AGENTS = {"voice": [...], "telegram": [...], ...} (6 фейковых username). least_loaded/skill_based — мёртвые значения enum, не влияющие ни на что. Состояние агентов (online/busy) вообще хранится в другом сервисе (supervisor_service) и никогда не читается роутингом — это два несвязанных подсистемы, случайно делящие общее событие agent.state.changed.

7.4 🟠 sales_service — параллельный продукт, слабо связанный с ядром

7 140 строк в одном app.py, 94 эндпоинта — крупнейший файл в репозитории на порядок. Полноценный CRM (лиды/сделки/33 стадии в явном state machine/платежи с HMAC-верификацией вебхуков/эскалации) с собственным мульти-тенантным слоем (tenant_id на каждой таблице — единственное место в системе, где мультитенантность реально используется). Стыкуется с ядром только в одной точке — таблице Customer, куда пишет напрямую, в обход customer_service (та же таблица, но два независимых write-пути без единой каскадной логики — при переименовании клиента через customer_service каскадно обновляются 3 связанные таблицы, через sales_service — нет). Отдельно стоит отметить: automation_worker.py (695 строк, полноценная task-queue с retry/backoff) нигде не инстанциируется вне тестов — задачи автоматизации (напоминания по счетам, эскалации) складываются в таблицу и никогда не обрабатываются в проде.

7.5 🟠 Дублирование кода в адаптерах каналов

  • webchat_adapter_service и email_adapter_service — структурно идентичные файлы (120 и 110 строк), различаются только именами полей.
  • telegram_adapter_service (2 145 строк) и whatsapp_adapter_service (2 328 строк) — почти построчное зеркало друг друга (одинаковые имена функций, одинаковый порядок): резолв клиента, рендер AI-саммари, worker доставки ответов с claim/lease/retry — всё продублировано. Оценка потенциального сокращения при вынесении общего chat_adapter.py в services/shared/~15001800 строк.
  • 6 UI-приложений (ui/*/app.js) независимо реализуют одинаковые persistSession()/api()/log()/escapeHtml() — без единого модуля, потому что нет сборщика и ES-модулей. При этом обработка истёкшего токена (авто-логаут на 401) есть только в ui/operator — остальные 5 приложений просто тихо продолжают фейлить запросы после истечения токена.

7.6 🟡 Мёртвый/дублированный код (конкретные находки)

Файл Проблема
services/ai_orchestrator_service/voice.py:1785-1900 Закрытая (shadowed) первая версия _voice_decision, вторая версия на строке 1903 её перекрывает — редактировать первую бессмысленно
services/ai_orchestrator_service/voice.py:1697-1783 _voice_decision_legacy — нигде не вызывается
ui/analyst/app.js Минимум 6 функций продублированы дважды в файле (loadSavedAnalyticsViews, saveAnalyticsView, deleteAnalyticsView, loadAnalyticsTrend и др.) — похоже на не до конца слитые две версии модуля аналитики; вторая версия по факту рабочая, первая — мёртвый вес (сотни строк)
ui/operator/sip-0.21.2.min.js vs ui/operator/vendor/sip-0.21.2.min.js Побайтово идентичный дубль, vendor/-копия сиротская, ничем не используется
deployment/kubernetes/mvp-cc-platform.yaml Устаревший ручной K8s-манифест, не содержит 5 из текущих сервисов (sales, ai-orchestrator, ai-voice-runtime, whatsapp-adapter, streaming-asr-sidecar) — судя по всему, вытеснен Helm-чартом, но не удалён
services/shared/ai_operator_config.py, voice_tts_config.py, ai_orchestrator_service/voice_name_config.py Три почти идентичных модуля "singleton JSON row в БД" — кандидат на общий SingletonJSONConfigStore[T]

7.7 🟡 Инфраструктура/CI: следы миграций, которые не довели до конца

  • 3 параллельных CI-системы (GitHub Actions, GitLab CI, Gitea) — реально используется только GitLab CI (полный test→build→deploy, включая 5 deploy-джобов и live-патч прод-контейнера в обход образа — hotfix-analyst-assets). GitHub Actions не имеет деплоя вообще, Gitea вызывает непрозрачный внешний скрипт. Похоже на артефакт смены CI-провайдера.
  • "Track 9" — конкретное историческое событие (переход на реальный Asterisk PBX на "scale500" в K8s + переключение auth с legacy-заголовков на bearer-токены). Скрипты track9_*.py/.ps1 и Helm-оверлеи values.track9-*.yaml — это тулинг под один конкретный cutover, не переиспользуемые операционные скрипты; кандидаты на архивацию, если cutover завершён.
  • Параллельно живёт "теневой" Postgres-стек (docker-compose.parallel.server.yml, отдельный env-темплейт) — явно инфраструктура идущей миграции SQLite → PostgreSQL, а не постоянная часть архитектуры.
  • 5 файлов .env*.template с реальным дрейфом контента между .env.example и .env.production.template (не просто "прод — подмножество", а разные наборы переменных в обе стороны).

7.8 🟢 asterisk_bridge_service — архитектурный узел

Самый сложный сервис (5 790 строк суммарно по всем файлам). Каждый вынесенный в отдельный файл модуль (ami.py, bridge_processing.py, voice_ai.py, call_control.py, ivr_fastagi.py, reconcile.py, recording_io.py) на деле не независим — каждый определяет _bridge_app(), который лениво импортирует app.py и лезет обратно за конфигом/функциями/даже реэкспортами stdlib (bridge.datetime). Это не модульность, а один файл на 5 790 строк, искусственно нарезанный, с app.py в роли service-locator/mediator и реальным циклическим импортом, который обходят через ленивый импорт внутри функций. Резолв "какой live-канал сейчас соответствует этому звонку" реализован независимо 3 раза (call_control.py дважды, voice_ai.py один раз) вместо общей функции.


8. Расхождения с существующей документацией (docs/architecture/*.md)

  • overview.md говорит "шина RabbitMQ — канонический механизм обмена" — верно как код, но не как поведение по умолчанию (шина выключена, см. §5).
  • voice-ai.md описывает флоу верно на уровне намерения, но не упоминает, что голосовая ветка может полностью работать без обращения к LLM (rule-based fallback), и что часть "AI Summary" для оператора рендерится прямо в asterisk_bridge_service/voice_ai.py с захардкоженными русскими keyword-словарями, а не в ai_orchestrator_service, как можно было бы предположить по разделению ответственности из документа.
  • real-time-voice-service.md — подтверждено ревью: services/real_time_voice/app.py действительно 15-строчный alias, без расхождений. Единственное уточнение: как самостоятельная директория сервиса это создаёт путаницу при первом знакомстве с деревом каталогов (выглядит как полноценный сервис).
  • event-schemas.md перечисляет call.connected.json/call.transferred.json как часть контракта шины — по факту эти два события до шины не доходят вообще (см. §4.1). call.recording.ready.json доходит, но с несовпадающей схемой (recording_path vs file_name).

9. Резюме: что стоит делать в первую очередь

Если приоритизировать по риску/выгоде (без учёта уже сделанных в этой сессии небольших правок, см. отдельное сообщение с их списком):

  1. Безопасность — выключить legacy header auth по умолчанию (или хотя бы явно предупредить/логировать при старте, что он включён), захэшировать пароли в auth_service.
  2. Контракты событий — либо чинить call.connected/call.transferred/call.recording.ready, либо выпилить их из contracts/events/, чтобы документация не врала.
  3. Дублирование адаптеров каналов — вынести общий chat_adapter.py для telegram/whatsapp (наибольший ROI по сокращению кода — полторы-две тысячи строк).
  4. ui/analyst/app.js — вычистить задублированные функции (быстрая, низкорисковая правка, ощутимо уменьшает файл).
  5. asterisk_bridge_service — если сервис ещё будет активно дорабатываться, стоит разорвать _bridge_app()-индирекцию через явный контекст/DI, иначе долг будет только расти вместе с файлом.
  6. Архивировать/переименовать "track9"-скрипты и устаревший deployment/kubernetes/mvp-cc-platform.yaml, если Helm — единственный актуальный путь деплоя в K8s.