Merge pull request 'Add architecture longread and remove dead code found during review' (#1) from worktree-call-center-review into main
deploy / deploy (push) Canceled after 27h28m15s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-08-20 09:42:15 +00:00
6 changed files with 353 additions and 298 deletions
+219
View File
@@ -0,0 +1,219 @@
# 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. На пикапе оператором — `MVPCCOperatorConnected``process_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-bus**`emit_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.
-206
View File
@@ -1694,212 +1694,6 @@ def _voice_llm_decision(
} }
def _voice_decision_legacy(
*,
language: str,
customer: Customer | None,
interaction: Interaction,
transcript_text: str,
transcript_window: list[VoiceTranscriptSegmentRow],
kb_results: list[Any],
disclosure_required: bool,
operator_config: Any | None = None,
) -> dict[str, Any]:
app = _app()
normalized = str(transcript_text or "").strip()
lower_text = normalized.lower()
if persona.is_identity_request(normalized):
reply_text = persona.identity_reply(language, operator_config)
return {
"language": language,
"intent": "identity_question",
"reply_text": reply_text,
"confidence": 0.98,
"needs_handoff": False,
"handoff_reason": None,
"case_action": "keep_open",
"kb_refs": [],
"summary_text": "AI ответил на вопрос о своей личности.",
"model": "operator_identity_policy",
"latency_ms": 1,
}
needs_handoff = app._looks_like_human_request(lower_text) or app._is_sensitive_request(lower_text)
model = app._ai_model()
if needs_handoff:
reply_text = _voice_handoff_reply(language)
if disclosure_required and not reply_text.startswith(_voice_disclosure_prefix(language)):
reply_text = f"{_voice_disclosure_prefix(language)}{reply_text}"
return {
"language": language,
"intent": "handoff_request",
"reply_text": reply_text,
"confidence": 0.25,
"needs_handoff": True,
"handoff_reason": "Запрос требует участия живого оператора.",
"case_action": "keep_open",
"kb_refs": [],
"summary_text": "AI собрал первичный контекст и запросил живого оператора.",
"model": model,
"latency_ms": 1,
}
customer_name = customer.display_name if customer else "клиент"
if kb_results:
article = kb_results[0]
snippet = app._article_snippet(article, limit=220)
reply_text = f"По базе знаний вижу следующее: {snippet}"
summary_text = f"AI дал первичный ответ по базе знаний для {customer_name}."
kb_refs = [article.article_id]
confidence = 0.82
intent = "kb_answer"
else:
transcript_context = " ".join(segment.text for segment in transcript_window[-3:] if segment.speaker == "caller")
reply_text = (
"Я услышал запрос и уже собрал основной контекст. "
"Пожалуйста, уточните самый важный результат, который вы хотите получить."
)
if transcript_context and transcript_context != normalized:
reply_text += " Если правильно понял, речь идет об этом вопросе из разговора."
summary_text = f"AI уточняет цель звонка и собирает контекст для {customer_name}."
kb_refs = []
confidence = 0.68
intent = "clarification"
if disclosure_required and not reply_text.startswith(_voice_disclosure_prefix(language)):
reply_text = f"{_voice_disclosure_prefix(language)}{reply_text}"
return {
"language": language,
"intent": intent,
"reply_text": reply_text,
"confidence": confidence,
"needs_handoff": False,
"handoff_reason": None,
"case_action": "keep_open",
"kb_refs": kb_refs,
"summary_text": summary_text,
"model": model,
"latency_ms": 1,
}
def _voice_decision(
*,
language: str,
customer: Customer | None,
interaction: Interaction,
transcript_text: str,
transcript_window: list[VoiceTranscriptSegmentRow],
kb_results: list[Any],
disclosure_required: bool,
customer_name_value: str | None = None,
customer_name_status: str | None = None,
operator_config: Any | None = None,
) -> dict[str, Any]:
app = _app()
normalized = str(transcript_text or "").strip()
lower_text = normalized.lower()
caller_texts = _voice_recent_caller_texts(transcript_window)
if persona.is_identity_request(normalized):
reply_text = persona.identity_reply(language, operator_config)
return {
"language": language,
"intent": "identity_question",
"reply_text": reply_text,
"confidence": 0.98,
"needs_handoff": False,
"handoff_reason": None,
"case_action": "keep_open",
"kb_refs": [],
"summary_text": "AI ответил на вопрос о своей личности.",
"model": "operator_identity_policy",
"latency_ms": 1,
}
needs_handoff = app._looks_like_human_request(lower_text) or app._is_sensitive_request(lower_text)
model = app._ai_model()
if needs_handoff:
reply_text = _voice_handoff_reply(language)
if disclosure_required and not reply_text.startswith(_voice_disclosure_prefix(language)):
reply_text = f"{_voice_disclosure_prefix(language)}{reply_text}"
return {
"language": language,
"intent": "handoff_request",
"reply_text": reply_text,
"confidence": 0.25,
"needs_handoff": True,
"handoff_reason": "Запрос требует участия живого оператора.",
"case_action": "keep_open",
"kb_refs": [],
"summary_text": "AI собрал первичный контекст и запросил живого оператора.",
"model": model,
"latency_ms": 1,
}
customer_name = customer.display_name if customer else "клиент"
if kb_results:
article = kb_results[0]
snippet = app._article_snippet(article, limit=220)
reply_text = f"По базе знаний вижу следующее: {snippet}"
summary_text = f"AI дал первичный ответ по базе знаний для {customer_name}."
kb_refs = [article.article_id]
confidence = 0.82
intent = "kb_answer"
else:
clarification_count = _voice_recent_clarification_count(transcript_window)
repeated_reply_count = _voice_repeated_assistant_reply_count(transcript_window)
recent_caller_window = caller_texts[-3:]
low_signal_count = sum(1 for text in recent_caller_window if _voice_is_low_signal_caller_text(text))
caller_confused = _voice_is_confused_caller_text(normalized)
if clarification_count >= 4 or (
clarification_count >= 3 and (caller_confused or low_signal_count >= 2 or repeated_reply_count >= 2)
):
reply_text, handoff_reason, summary_text = _voice_loop_handoff(language)
if disclosure_required and not reply_text.startswith(_voice_disclosure_prefix(language)):
reply_text = f"{_voice_disclosure_prefix(language)}{reply_text}"
return {
"language": language,
"intent": "handoff_request",
"reply_text": reply_text,
"confidence": 0.34,
"needs_handoff": True,
"handoff_reason": handoff_reason,
"case_action": "keep_open",
"kb_refs": [],
"summary_text": summary_text,
"model": model,
"latency_ms": 1,
}
if caller_confused:
reply_text = _voice_confusion_prompt(language, caller_texts)
else:
topic_prompt = _voice_topic_prompt(language, caller_texts)
if clarification_count >= 2 and not topic_prompt:
reply_text = _voice_confusion_prompt(language, caller_texts)
else:
reply_text = topic_prompt or _voice_generic_prompt(language)
summary_text = f"AI уточняет цель звонка и собирает контекст для {customer_name}."
kb_refs = []
confidence = 0.68
intent = "clarification"
if disclosure_required and not reply_text.startswith(_voice_disclosure_prefix(language)):
reply_text = f"{_voice_disclosure_prefix(language)}{reply_text}"
return {
"language": language,
"intent": intent,
"reply_text": reply_text,
"confidence": confidence,
"needs_handoff": False,
"handoff_reason": None,
"case_action": "keep_open",
"kb_refs": kb_refs,
"summary_text": summary_text,
"model": model,
"latency_ms": 1,
}
def _voice_decision( def _voice_decision(
*, *,
language: str, language: str,
@@ -777,6 +777,67 @@ class ElevenLabsTTSProvider(TTSProvider):
self._write_cached_synthesis(synthesis, language=language) self._write_cached_synthesis(synthesis, language=language)
return synthesis return synthesis
def synthesize_chunks(
self,
text: str,
*,
language: str | None = None,
style_hints: dict[str, object] | None = None,
):
del style_hints
if not text:
return
cached = self._load_cached_synthesis(text, language=language)
if cached is not None:
if cached.audio_bytes:
yield cached
return
if not self._api_key:
raise RuntimeError("AI_VOICE_TTS_ELEVENLABS_API_KEY is required for ElevenLabs TTS")
# Raw PCM16 samples must stay 2-byte aligned across network chunk
# boundaries, or a split sample corrupts playback at that boundary.
collected = bytearray()
leftover = b""
with httpx.Client(timeout=self._timeout_seconds) as client:
with client.stream(
"POST",
f"{self._api_base}/v1/text-to-speech/{self._voice(language)}/stream",
headers={
"xi-api-key": self._api_key,
"Accept": "application/octet-stream",
"Content-Type": "application/json",
},
params={"output_format": self._output_format},
json={
"text": text,
"model_id": self._model(language),
"language_code": self._language_code(language),
},
) as response:
response.raise_for_status()
for raw_chunk in response.iter_bytes():
if not raw_chunk:
continue
data = leftover + raw_chunk
if len(data) % 2:
leftover = data[-1:]
data = data[:-1]
else:
leftover = b""
if not data:
continue
collected.extend(data)
yield TTSSynthesis(text=text, audio_bytes=data, sample_rate_hz=self._sample_rate_hz)
if leftover:
collected.extend(leftover)
if collected:
full_synthesis = TTSSynthesis(text=text, audio_bytes=bytes(collected), sample_rate_hz=self._sample_rate_hz)
with self._cache_lock:
if self._load_cached_synthesis(text, language=language) is None:
self._write_cached_synthesis(full_synthesis, language=language)
def build_tts_provider(name: str) -> TTSProvider: def build_tts_provider(name: str) -> TTSProvider:
normalized = str(name or "stub").strip().lower() normalized = str(name or "stub").strip().lower()
+73
View File
@@ -328,6 +328,79 @@ def test_elevenlabs_tts_provider_posts_voice_id_model_and_language_code(tmp_path
assert calls[0]["json"]["language_code"] == "kk" assert calls[0]["json"]["language_code"] == "kk"
def test_elevenlabs_tts_provider_streams_chunks_and_caches_full_audio(tmp_path, monkeypatch):
calls: list[dict] = []
class _DummyStreamResponse:
def __init__(self, chunks: list[bytes]) -> None:
self._chunks = chunks
def raise_for_status(self) -> None:
return None
def iter_bytes(self):
yield from self._chunks
class _DummyStreamContext:
def __init__(self, response: _DummyStreamResponse) -> None:
self._response = response
def __enter__(self) -> _DummyStreamResponse:
return self._response
def __exit__(self, exc_type, exc, tb) -> None:
return None
class _DummyClient:
def __init__(self, *, timeout: float) -> None:
self.timeout = timeout
def __enter__(self) -> _DummyClient:
return self
def __exit__(self, exc_type, exc, tb) -> None:
return None
def stream(self, method: str, url: str, *, headers: dict[str, str], params: dict[str, str], json: dict):
calls.append({"method": method, "url": url, "headers": headers, "params": params, "json": json})
# Split mid-sample on purpose to exercise the 2-byte alignment guard.
return _DummyStreamContext(_DummyStreamResponse([b"\x01\x00\x02", b"\x00\x03\x00\x04\x00"]))
monkeypatch.setenv("AI_VOICE_TTS_ELEVENLABS_API_KEY", "elevenlabs-key")
monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "1")
monkeypatch.setenv("AI_VOICE_TTS_CACHE_DIR", str(tmp_path))
monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient)
provider = tts_module.ElevenLabsTTSProvider(
api_base="https://api.elevenlabs.example",
ru_voice="nPczCjzI2devNBz1zQrb",
output_format="pcm_16000",
)
chunks = list(provider.synthesize_chunks("Привет из ElevenLabs", language="ru"))
assert b"".join(chunk.audio_bytes for chunk in chunks) == b"\x01\x00\x02\x00\x03\x00\x04\x00"
assert all(len(chunk.audio_bytes) % 2 == 0 for chunk in chunks)
assert len(calls) == 1
assert calls[0]["url"] == "https://api.elevenlabs.example/v1/text-to-speech/nPczCjzI2devNBz1zQrb/stream"
assert list(Path(tmp_path).rglob("*.pcm"))
def _unexpected_stream(*args, **kwargs):
raise AssertionError("ElevenLabs streaming TTS should not be called again once cached")
monkeypatch.setattr(_DummyClient, "stream", _unexpected_stream)
replay_provider = tts_module.ElevenLabsTTSProvider(
api_base="https://api.elevenlabs.example",
ru_voice="nPczCjzI2devNBz1zQrb",
output_format="pcm_16000",
)
replay_chunks = list(replay_provider.synthesize_chunks("Привет из ElevenLabs", language="ru"))
assert len(replay_chunks) == 1
assert replay_chunks[0].audio_bytes == b"\x01\x00\x02\x00\x03\x00\x04\x00"
assert len(calls) == 1
def test_runtime_configured_tts_provider_uses_database_selected_provider(monkeypatch): def test_runtime_configured_tts_provider_uses_database_selected_provider(monkeypatch):
calls: list[dict] = [] calls: list[dict] = []
-90
View File
@@ -2213,28 +2213,6 @@ function normalizeSavedAnalyticsView(item = {}) {
}; };
} }
async function loadSavedAnalyticsViews() {
try {
const items = await api('reporting', 'reports/views');
state.analytics.savedViews = Array.isArray(items)
? items
.filter((item) => item && typeof item === 'object')
.map((item) => normalizeSavedAnalyticsView(item))
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
: [];
} catch (err) {
state.analytics.savedViews = [];
state.analytics.statusMessage = `Не удалось загрузить сохранённые виды: ${err.message}`;
}
if (
state.analytics.activeViewId
&& !state.analytics.savedViews.some((item) => item.id === state.analytics.activeViewId)
) {
state.analytics.activeViewId = '';
}
renderSavedAnalyticsViews();
}
function renderSavedAnalyticsViews() { function renderSavedAnalyticsViews() {
const select = $('analyticsSavedViewSelect'); const select = $('analyticsSavedViewSelect');
const input = $('analyticsSavedViewName'); const input = $('analyticsSavedViewName');
@@ -2274,56 +2252,6 @@ function renderSavedAnalyticsViews() {
meta.textContent = parts.join(' · '); meta.textContent = parts.join(' · ');
} }
async function saveAnalyticsView() {
syncAnalyticsStateFromControls();
const input = $('analyticsSavedViewName');
const name = input?.value.trim() || suggestAnalyticsViewName();
try {
const view = await api('reporting', 'reports/views', {
method: 'POST',
body: JSON.stringify({
id: state.analytics.activeViewId || null,
name,
snapshot: analyticsCurrentSnapshot(),
}),
});
const normalized = normalizeSavedAnalyticsView(view);
const existingIndex = state.analytics.savedViews.findIndex((item) => item.id === normalized.id);
if (existingIndex >= 0) {
state.analytics.savedViews.splice(existingIndex, 1, normalized);
state.analytics.statusMessage = `Вид «${name}» обновлён.`;
} else {
state.analytics.savedViews.unshift(normalized);
state.analytics.statusMessage = `Вид «${name}» сохранён.`;
}
state.analytics.savedViews = state.analytics.savedViews
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
.slice(0, 12);
state.analytics.activeViewId = normalized.id;
} catch (err) {
state.analytics.statusMessage = `Не удалось сохранить вид: ${err.message}`;
}
renderSavedAnalyticsViews();
}
async function deleteAnalyticsView() {
const activeView = state.analytics.savedViews.find((item) => item.id === state.analytics.activeViewId) || null;
if (!activeView) {
state.analytics.statusMessage = 'Сначала выберите вид, который нужно удалить.';
renderSavedAnalyticsViews();
return;
}
try {
await api('reporting', `reports/views/${encodeURIComponent(activeView.id)}`, { method: 'DELETE' });
state.analytics.savedViews = state.analytics.savedViews.filter((item) => item.id !== activeView.id);
state.analytics.activeViewId = '';
state.analytics.statusMessage = `Вид «${activeView.name}» удалён.`;
} catch (err) {
state.analytics.statusMessage = `Не удалось удалить вид: ${err.message}`;
}
renderSavedAnalyticsViews();
}
function detachActiveAnalyticsView() { function detachActiveAnalyticsView() {
if (!state.analytics.activeViewId) { if (!state.analytics.activeViewId) {
return; return;
@@ -5021,24 +4949,6 @@ function buildTrendBuckets(range, preset, custom) {
return buckets; return buckets;
} }
async function loadAnalyticsTrend(rangeMeta, requestId) {
const buckets = buildTrendBuckets(rangeMeta.current, rangeMeta.preset, rangeMeta.custom);
const items = await Promise.all(
buckets.map(async (bucket) => {
try {
const payload = await fetchAnalyticsKpi(bucket.range);
return { ...bucket, payload };
} catch {
return { ...bucket, payload: emptyKpiEnvelope(bucket.range.from.toISOString(), bucket.range.to.toISOString()) };
}
}),
);
if (requestId !== state.analytics.requestId) {
return [];
}
return items;
}
function aiAnalyticsIntervalForRange(rangeMeta) { function aiAnalyticsIntervalForRange(rangeMeta) {
const durationMs = Math.max(0, rangeMeta.current.to.getTime() - rangeMeta.current.from.getTime()); const durationMs = Math.max(0, rangeMeta.current.to.getTime() - rangeMeta.current.from.getTime());
return durationMs <= 36 * 60 * 60 * 1000 ? 'hour' : 'day'; return durationMs <= 36 * 60 * 60 * 1000 ? 'hour' : 'day';
File diff suppressed because one or more lines are too long