feat: AI-агент «Адвокат клиента» — MVP Iteration 1
Реализация TRD §10 / Итерация 1:
- Camunda 7.21 BPMN: client-advocate-main + route-to-department
- Spring Boot 3 воркеры (Java 17): 18 external task handlers,
MockEventProducer, REST API на /api/v1/cases, BPMN auto-deploy
- FastAPI AI-сервис: keyword-based sentiment + pain classifier
- PostgreSQL 15: 5 таблиц по TRD §3 + отдельная БД для Camunda
- Статический дашборд (nginx) с live polling
- 7 синтетических детракторов из PRD §1.2
- docker-compose.yml с Traefik labels:
advocate.telecom.quest → дашборд + API
advocate-camunda.telecom.quest → Camunda Cockpit
This commit is contained in:
parent
9a822bb869
commit
62f94a9012
416
PRD.md
416
PRD.md
@ -0,0 +1,416 @@
|
||||
# PRD — AI-агент «Адвокат клиента»
|
||||
**Product Requirements Document**
|
||||
**Версия:** 1.0
|
||||
**Дата:** 22.05.2026
|
||||
**Владелец продукта:** Департамент управления клиентским опытом (ДУКО)
|
||||
**Оркестратор:** Camunda 7
|
||||
**Статус:** Draft → Ready for TRD
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
### 1.1 Бизнес-контекст
|
||||
|
||||
Kazakhtelecom ежедневно обрабатывает тысячи обращений через множество каналов (КЦ 160, WhatsApp, Omilia B2C, ПСС, telecom.kz, eOtinish). Ключевая проблема — клиенты повторно обращаются по одному и тому же вопросу без результата, что ведёт к оттоку (churn) и репутационным потерям.
|
||||
|
||||
### 1.2 Подтверждение проблемы (данные реальных кейсов)
|
||||
|
||||
Анализ детракторов выявил критические аномалии повторных обращений:
|
||||
|
||||
| Кейс | Кол-во обращений | По интернету | Период | Каналы |
|
||||
|------|-----------------|--------------|--------|--------|
|
||||
| №1 | **98** | 15 | 16.01–28.08.2025 | КЦ 160, ПСС, Omilia, WhatsApp |
|
||||
| №6 | **59** | 26 | 09.07–02.10.2025 | Omilia, telecom.kz, WhatsApp, КЦ 160, ПСС |
|
||||
| №3 | **46** | 10 | 21.01–18.12.2025 | КЦ 160, Omilia, ПСС |
|
||||
| №5 | **15** | 9 | 18.02–10.11.2025 | ПСС, КЦ 160, Omilia |
|
||||
| №2 | **22** | 5 | 22.01–17.10.2025 | Все каналы |
|
||||
| №4 | **22** | 5 | 25.02–19.11.2025 | Все каналы |
|
||||
| №7 | **21** | 9 | 27.01–10.09.2025 | Все каналы |
|
||||
|
||||
**Ключевые паттерны:**
|
||||
- Клиент с 98 обращениями обращался при **наличии открытых заявок** — система не проактивна
|
||||
- Клиент №6 с 59 обращениями по ТВ-качеству имеет претензию на перерасчёт — проблема не решена месяцами
|
||||
- Во всех кейсах клиентов переводили между службами (160/165, ЦВЛТПК, СТПК) без результата
|
||||
- Жалобы на оборудование (ID TV, КТВ, интернет) не передаются автоматически в ASC/удалённый мониторинг
|
||||
|
||||
### 1.3 Корневая причина
|
||||
|
||||
Отсутствует **сквозной агент**, который:
|
||||
1. Видит всю историю клиента поперёк каналов
|
||||
2. Определяет, что вопрос не решён
|
||||
3. Проактивно эскалирует и контролирует до закрытия
|
||||
4. Направляет технические жалобы в правильный департамент (ASC, мониторинг сети) без участия клиента
|
||||
|
||||
---
|
||||
|
||||
## 2. Цель продукта
|
||||
|
||||
> **«Адвокат клиента»** — AI-агент ДУКО, который проактивно выявляет клиентов с нерешёнными обращениями, устраняет причину проблемы через правильный департамент, контролирует исполнение и возвращает доверие клиента — всё это **без повторного обращения клиента**.
|
||||
|
||||
### 2.1 Метрики успеха (OKR)
|
||||
|
||||
| Метрика | Baseline | Цель (6 мес.) |
|
||||
|--------|---------|--------------|
|
||||
| FCR (First Contact Resolution) | ~45% | ≥70% |
|
||||
| Среднее кол-во обращений на кейс | 3.2 | ≤1.5 |
|
||||
| Время закрытия повторного кейса | >10 дней | ≤48 часов |
|
||||
| Churn rate детракторов | ~18% | ≤10% |
|
||||
| NPS детракторов | -35 | ≥-10 |
|
||||
| Кейсы, решённые без звонка клиента | 5% | ≥40% |
|
||||
|
||||
---
|
||||
|
||||
## 3. Пользователи системы
|
||||
|
||||
| Роль | Описание | Взаимодействие с агентом |
|
||||
|------|----------|--------------------------|
|
||||
| **Клиент** | Абонент Kazakhtelecom | Получает проактивное уведомление, не звонит сам |
|
||||
| **Оператор КЦ** | Агент контакт-центра | Видит рекомендацию агента в CRM |
|
||||
| **Менеджер ДУКО** | Управляет клиентским опытом | Получает еженедельный дашборд |
|
||||
| **Техник ASC** | Сервисный центр | Получает задачу автоматически |
|
||||
| **Мониторинг сети** | Удалённый мониторинг | Получает сигнал от агента |
|
||||
| **Супервайзер** | Контролирует эскалации | Получает критические алерты |
|
||||
|
||||
---
|
||||
|
||||
## 4. Функциональные требования
|
||||
|
||||
### 4.1 F1 — Детекция аномалий (Триггеры запуска агента)
|
||||
|
||||
Агент автоматически активируется при выполнении одного или более условий:
|
||||
|
||||
**Количественные триггеры:**
|
||||
- 2+ обращения по одной теме за 7 дней
|
||||
- 5+ обращений за 30 дней (любые темы)
|
||||
- Открытая заявка + новое обращение по той же теме
|
||||
- SLA превышен > 24 часов без обновления статуса
|
||||
|
||||
**Качественные триггеры:**
|
||||
- Негативная тональность сообщения (sentiment < -0.5)
|
||||
- Ключевые слова: «не работает», «снова», «опять», «уже», «расторгнуть», «другой оператор», «суд», «жалоба»
|
||||
- Требование перерасчёта / компенсации
|
||||
- Отказ от услуги (churn-сигнал)
|
||||
|
||||
**Канальные триггеры:**
|
||||
- Клиент пишет в WhatsApp после звонка в КЦ 160 (мультиканальность = боль)
|
||||
- Клиент позвонил 3 раза за день
|
||||
- Перевод между более чем 2 службами за одно обращение
|
||||
|
||||
### 4.2 F2 — Классификация боли клиента
|
||||
|
||||
Агент автоматически определяет:
|
||||
|
||||
**Категория проблемы:**
|
||||
- Качество сети / интернета → направляется в удалённый мониторинг сети
|
||||
- Оборудование (роутер, STB, ТВ-приставка) → направляется в ASC
|
||||
- Биллинг / начисления → направляется в финансовый департамент
|
||||
- Сервисное обслуживание (монтаж, визит) → направляется в СТПК / ЦВЛТПК
|
||||
- Услуги (тариф, VAS, ЛК) → консультация КЦ + CRM-задача
|
||||
|
||||
**Уровень приоритета:**
|
||||
|
||||
```
|
||||
🟢 ЗЕЛЁНЫЙ — Обычный случай
|
||||
Условие: 1–2 обращения, вопрос близок к решению
|
||||
Действие: стандартная обработка
|
||||
|
||||
🟡 ЖЁЛТЫЙ — Риск недовольства
|
||||
Условие: 3–4 обращения / SLA > 24ч / негативная тональность
|
||||
Действие: уведомление супервайзера, приоритет в очереди
|
||||
|
||||
🔴 КРАСНЫЙ — Критическая боль
|
||||
Условие: 5+ обращений / открытая заявка + новое обращение / требование перерасчёта
|
||||
Действие: автоэскалация, проактивный исходящий контакт, компенсация
|
||||
|
||||
⚫ ЧЁРНЫЙ — Репутационный / VIP риск
|
||||
Условие: 10+ обращений / соцсети / угроза суда / VIP-клиент
|
||||
Действие: немедленная эскалация на директора ДУКО, персональное сопровождение
|
||||
```
|
||||
|
||||
### 4.3 F3 — Маршрутизация по департаментам
|
||||
|
||||
Ключевой принцип: **клиент не должен сам звонить в нужный департамент** — агент делает это за него.
|
||||
|
||||
```
|
||||
Жалоба на качество интернета / сигнала
|
||||
└──► Автозаявка в Удалённый мониторинг сети
|
||||
├── Проверка статуса оборудования по адресу
|
||||
├── Диагностика линии
|
||||
└── Если нужен выезд → задача в ASC
|
||||
|
||||
Жалоба на оборудование (роутер, ТВ-приставка не работает)
|
||||
└──► Автозаявка в ASC (Авторизованный сервисный центр)
|
||||
├── Тип оборудования, серийный номер
|
||||
├── Адрес клиента, удобное время
|
||||
└── Контроль закрытия заявки ASC агентом
|
||||
|
||||
Жалоба на биллинг / перерасчёт
|
||||
└──► Задача в финансовый отдел
|
||||
├── Период начислений
|
||||
├── История оплат
|
||||
└── Рекомендация: перерасчёт / компенсация / извинение
|
||||
|
||||
Смена тарифа / услуги
|
||||
└──► Автоматическое исполнение через CRM API (если в рамках полномочий)
|
||||
или задача оператору с готовым решением
|
||||
```
|
||||
|
||||
### 4.4 F4 — Проактивная коммуникация с клиентом
|
||||
|
||||
Агент инициирует контакт **до** того, как клиент позвонит снова:
|
||||
|
||||
**Сценарий «Опережающее уведомление»:**
|
||||
```
|
||||
Триггер: открытая заявка + прошло 18 часов без обновления
|
||||
|
||||
Сообщение клиенту (WhatsApp / SMS):
|
||||
"Здравствуйте, [Имя]. Ваша заявка №[XXXX] по вопросу [тема]
|
||||
находится в работе. Ожидаемый срок решения: [дата].
|
||||
Специалист свяжется с вами до [время].
|
||||
Если вопрос уже решён — ответьте «Да». Если нет — ответьте «Нет»."
|
||||
```
|
||||
|
||||
**Сценарий «После решения»:**
|
||||
```
|
||||
Триггер: заявка закрыта технической службой
|
||||
|
||||
Сообщение клиенту:
|
||||
"Здравствуйте, [Имя]. Сообщаем, что вопрос по [тема] решён.
|
||||
Пожалуйста, проверьте качество услуги и оцените наш сервис:
|
||||
👍 — всё хорошо | 👎 — проблема осталась"
|
||||
|
||||
Если ответ 👎 → агент немедленно создаёт новый кейс с повышенным приоритетом
|
||||
```
|
||||
|
||||
### 4.5 F5 — Рекомендация оператору
|
||||
|
||||
При обращении клиента оператор видит в CRM готовую карточку:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ⚫ АДВОКАТ КЛИЕНТА — РЕКОМЕНДАЦИЯ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Клиент обращается: 59 раз (рекорд за 3 месяца) │
|
||||
│ Тема: Качество ТВ (ID TV, КТВ, TV+) │
|
||||
│ Статус: Открытая заявка #4521 (12 дней без ответа) │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ ✅ РЕКОМЕНДУЕМЫЕ ДЕЙСТВИЯ: │
|
||||
│ 1. Принести извинения за длительное ожидание │
|
||||
│ 2. Эскалировать заявку ASC как СРОЧНУЮ │
|
||||
│ 3. Предложить перерасчёт за 12 дней простоя │
|
||||
│ 4. Лично контролировать до закрытия │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ ⚠️ Риск оттока: ВЫСОКИЙ | Компенсация: РАЗРЕШЕНА │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.6 F6 — Управленческая аналитика (еженедельный дашборд)
|
||||
|
||||
Автоматический отчёт для ДУКО каждый понедельник:
|
||||
|
||||
- Топ-10 клиентов по количеству обращений (детракторы)
|
||||
- Топ-5 проблемных процессов (где «теряется» клиент)
|
||||
- Карта регионов с высоким уровнем повторных обращений
|
||||
- Департаменты с наибольшим SLA-нарушением
|
||||
- Рекомендации по изменению регламентов
|
||||
|
||||
---
|
||||
|
||||
## 5. Нефункциональные требования
|
||||
|
||||
| Параметр | Требование |
|
||||
|----------|-----------|
|
||||
| Время реакции агента | < 5 минут с момента триггера |
|
||||
| Доступность системы | 99.5% (24/7) |
|
||||
| Обработка кейсов | ≥ 1000 кейсов/день |
|
||||
| Хранение истории | 24 месяца |
|
||||
| Безопасность данных | ФЗ РК о персональных данных, маскировка в логах |
|
||||
| Human oversight | Все ⚫ ЧЁРНЫЕ кейсы требуют подтверждения человека |
|
||||
| Язык | Казахский, Русский |
|
||||
|
||||
---
|
||||
|
||||
## 6. Архитектура решения (высокий уровень)
|
||||
|
||||
### 6.1 Компоненты
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ИСТОЧНИКИ ДАННЫХ │
|
||||
│ CRM │ КЦ 160 │ Omilia │ WhatsApp │ eOtinish │ Remedy │ NPS │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│ Events / Webhooks / Batch ETL
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ CAMUNDA 7 — ОРКЕСТРАТОР ПРОЦЕССОВ │
|
||||
│ │
|
||||
│ Process: DetectAnomaly → ClassifyPain → RouteToDepart → │
|
||||
│ NotifyClient → TrackSLA → CloseAndReport │
|
||||
│ │
|
||||
│ Service Tasks (моки в MVP): │
|
||||
│ • AnomalyDetectorWorker │
|
||||
│ • SentimentAnalysisWorker │
|
||||
│ • PriorityClassifierWorker │
|
||||
│ • CRMUpdaterWorker │
|
||||
│ • ASCTicketWorker │
|
||||
│ • NetworkMonitoringWorker │
|
||||
│ • NotificationWorker │
|
||||
│ • DashboardReportWorker │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────────┐
|
||||
│ AI/ML СЛОЙ │ │ ИНТЕГРАЦИОННЫЙ СЛОЙ │
|
||||
│ │ │ │
|
||||
│ • LLM (классифик.) │ │ • CRM API │
|
||||
│ • Sentiment Analysis │ │ • ASC API │
|
||||
│ • Churn Prediction │ │ • Monitoring API │
|
||||
│ • FCR Scoring │ │ • WhatsApp Business API │
|
||||
└──────────────────────┘ │ • SMS Gateway │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 BPMN-процессы в Camunda 7
|
||||
|
||||
**Главный процесс: `client-advocate-main`**
|
||||
|
||||
```
|
||||
[Старт: Триггер получен]
|
||||
→ [Task: Обогатить профиль клиента из CRM]
|
||||
→ [Task: Классифицировать боль (AI)]
|
||||
→ [Gateway: Приоритет?]
|
||||
→ ЗЕЛЁНЫЙ → [Task: Стандартная обработка] → [Конец]
|
||||
→ ЖЁЛТЫЙ → [Task: Уведомить супервайзера]
|
||||
→ [Task: Приоритизировать в очереди]
|
||||
→ [Task: Мониторить SLA каждые 4ч]
|
||||
→ КРАСНЫЙ → [Task: Маршрутизировать в департамент]
|
||||
→ [Task: Проактивный контакт с клиентом]
|
||||
→ [Task: Контроль до закрытия]
|
||||
→ ЧЁРНЫЙ → [Task: Human Task — эскалация директору ДУКО]
|
||||
→ [Task: Персональное сопровождение]
|
||||
→ [Task: Уведомить клиента о решении]
|
||||
→ [Task: Проверка удовлетворённости (CSAT)]
|
||||
→ [Gateway: Клиент доволен?]
|
||||
→ ДА → [Task: Закрыть кейс, обновить аналитику]
|
||||
→ НЕТ → [Task: Пересоздать кейс с повышенным приоритетом]
|
||||
→ [Конец]
|
||||
```
|
||||
|
||||
**Субпроцесс: `route-to-department`**
|
||||
|
||||
```
|
||||
[Категория = Сеть/Интернет] → API в Удалённый мониторинг
|
||||
[Категория = Оборудование] → API в ASC
|
||||
[Категория = Биллинг] → Задача в финансовый отдел
|
||||
[Категория = Монтаж/Визит] → API в СТПК/ЦВЛТПК
|
||||
[Категория = Услуги] → CRM-задача оператору
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Итерационный план (MVP → Production)
|
||||
|
||||
### Итерация 1 — MVP (Месяц 1): Синтетические данные + Моки
|
||||
|
||||
**Цель:** Доказать работоспособность процесса на синтетических данных
|
||||
|
||||
| Задача | Описание |
|
||||
|--------|----------|
|
||||
| Camunda 7 setup | Развернуть engine, создать `client-advocate-main` BPMN |
|
||||
| Моки источников | JSON-файлы с синтетическими обращениями (на основе 7 кейсов) |
|
||||
| Детектор аномалий | Простые правила: N обращений за период |
|
||||
| Классификатор | Keyword-matching по темам (без LLM) |
|
||||
| Приоритет | Rule-based (Зелёный/Жёлтый/Красный/Чёрный) |
|
||||
| Уведомление | Mock-уведомление (логируется в консоль) |
|
||||
| UI-дашборд | Статичный HTML с синтетическими данными |
|
||||
|
||||
**Критерий завершения:** Агент корректно обрабатывает все 7 кейсов из файла детракторов
|
||||
|
||||
### Итерация 2 — Интеграция данных (Месяц 2)
|
||||
|
||||
| Задача | Описание |
|
||||
|--------|----------|
|
||||
| CRM-интеграция | Реальные данные обращений из CRM (read-only) |
|
||||
| Sentiment Analysis | LLM-классификация тональности обращений |
|
||||
| ASC-интеграция | Автоматическое создание заявок в ASC |
|
||||
| Мониторинг сети | Интеграция с системой удалённого мониторинга |
|
||||
| WhatsApp | Исходящие уведомления клиентам |
|
||||
|
||||
### Итерация 3 — AI-слой (Месяц 3)
|
||||
|
||||
| Задача | Описание |
|
||||
|--------|----------|
|
||||
| Churn prediction | Модель предсказания оттока по истории |
|
||||
| FCR scoring | Оценка вероятности решения с первого раза |
|
||||
| LLM-рекомендации | Готовые тексты решений для операторов |
|
||||
| Дашборд ДУКО | Полноценный BI-дашборд с аналитикой |
|
||||
|
||||
### Итерация 4 — Масштабирование (Месяц 4)
|
||||
|
||||
| Задача | Описание |
|
||||
|--------|----------|
|
||||
| Нагрузочное тестирование | 1000+ кейсов/день |
|
||||
| Human oversight | Интерфейс подтверждения для ЧЁРНЫХ кейсов |
|
||||
| A/B тестирование | Сравнение FCR с агентом и без |
|
||||
| PRD → TRD | Передача в команду разработки |
|
||||
|
||||
---
|
||||
|
||||
## 8. Допущения и ограничения
|
||||
|
||||
### Допущения
|
||||
- CRM предоставляет API для чтения и записи данных обращений
|
||||
- ASC имеет API для создания и статусирования заявок
|
||||
- Система удалённого мониторинга сети доступна через API
|
||||
- WhatsApp Business API подключён к Kazakhtelecom
|
||||
|
||||
### Ограничения MVP
|
||||
- В Итерации 1 используются только синтетические данные и моки
|
||||
- Автоматические выплаты компенсаций требуют финансового подтверждения человеком
|
||||
- ⚫ ЧЁРНЫЕ кейсы всегда требуют решения человека (Human Task в Camunda)
|
||||
- Модели churn prediction требуют минимум 6 месяцев исторических данных для обучения
|
||||
|
||||
### Риски
|
||||
| Риск | Вероятность | Митигация |
|
||||
|------|------------|-----------|
|
||||
| Интеграция с CRM займёт > 2 месяцев | Высокая | Начать с выгрузки CSV/Excel |
|
||||
| Ложные срабатывания триггеров | Средняя | Ручная верификация первые 4 недели |
|
||||
| Сопротивление операторов КЦ | Средняя | UX-исследование, обучение, геймификация |
|
||||
| Утечка персональных данных | Низкая | Маскировка в логах, role-based access |
|
||||
|
||||
---
|
||||
|
||||
## 9. Глоссарий
|
||||
|
||||
| Термин | Определение |
|
||||
|--------|-------------|
|
||||
| FCR | First Contact Resolution — решение вопроса с первого обращения |
|
||||
| Детрактор | Клиент с NPS 0–6, недовольный сервисом |
|
||||
| ASC | Авторизованный сервисный центр |
|
||||
| SLA | Service Level Agreement — соглашение об уровне сервиса |
|
||||
| Churn | Отток клиента |
|
||||
| СТПК | Служба технической поддержки клиентов |
|
||||
| ЦВЛТПК | Центр выездного линейного технического персонала клиента |
|
||||
| ДУКО | Департамент управления клиентским опытом |
|
||||
| ПСС | Персональный сервисный специалист |
|
||||
| eOtinish | Система регистрации обращений |
|
||||
| BPMN | Business Process Model and Notation (язык Camunda) |
|
||||
| Omilia B2C | Голосовой бот самообслуживания |
|
||||
|
||||
---
|
||||
|
||||
## 10. Следующий шаг
|
||||
|
||||
На основе данного PRD команда разработки формирует **TRD.md** (Technical Requirements Document), включающий:
|
||||
- Детальную схему интеграций и API-контракты
|
||||
- BPMN-диаграммы всех процессов Camunda 7
|
||||
- Схему данных и модели БД
|
||||
- Описание ML-моделей и их обучения
|
||||
- Инфраструктурные требования
|
||||
|
||||
---
|
||||
|
||||
*Документ подготовлен: ДУКО, Kazakhtelecom*
|
||||
*Следующая ревизия: после Итерации 1 (MVP)*
|
||||
93
README.md
93
README.md
@ -1,26 +1,83 @@
|
||||
# amamazhanova
|
||||
# Адвокат клиента — MVP (Iteration 1)
|
||||
|
||||
Привет, Асель Мамажанова!
|
||||
Реализация TRD.md, Итерация 1 (MVP).
|
||||
|
||||
Это твоя личная папка в среде telecom.quest.
|
||||
- VS Code в браузере: https://code.telecom.quest/?folder=/Users/telecom/dev/_hack22may/amamazhanova/
|
||||
- Бэкап на Gitea: https://git.telecom.quest/kt-innovators/amamazhanova
|
||||
Стек: Camunda 7.21 · Spring Boot 3 (workers) · PostgreSQL 15 · FastAPI (AI) · Nginx static (dashboard) · всё в Docker Compose.
|
||||
|
||||
## Action Plan (как пользоваться)
|
||||
## Что внутри
|
||||
|
||||
В этой папке уже лежат три стартовых файла:
|
||||
- `PRD.md` — здесь ты простыми словами описываешь, что хочешь автоматизировать
|
||||
- `GITEA.md` — справочник по системе Gitea (для Claude, не для тебя)
|
||||
- `TRD.md` — пустой; Claude заполнит его после того, как ты напишешь PRD
|
||||
| Каталог | Что |
|
||||
|---------|-----|
|
||||
| `bpmn/` | `client-advocate-main.bpmn`, `route-to-department.bpmn` |
|
||||
| `sql/` | DDL для PostgreSQL + создание camunda БД |
|
||||
| `data/synthetic/appeals.json` | 7 синтетических детракторов из PRD §1.2 |
|
||||
| `workers/` | Spring Boot — все Iter-1 воркеры + REST API на :8082 |
|
||||
| `ai-service/` | FastAPI — keyword-based sentiment + classifier на :8081 |
|
||||
| `dashboard/` | Статический HTML/JS дашборд на :3000 |
|
||||
|
||||
**Шаг 1.** Открой `PRD.md` и напиши свою идею.
|
||||
**Шаг 2.** Открой Claude (Desktop или web). Дай ему `PRD.md` + `GITEA.md` и попроси: «Сделай TRD.md на основе PRD и GITEA».
|
||||
**Шаг 3.** Сохрани результат в `TRD.md`.
|
||||
**Шаг 4.** Запусти Claude Code в этой папке. Скажи: «Реализуй TRD.md». Получишь рабочий MVP.
|
||||
## Production URLs (Traefik)
|
||||
|
||||
## Как сохранять работу
|
||||
После push в `main` сервер автоматически поднимает стек через `docker-compose.yml`.
|
||||
|
||||
В VS Code открой панель "Source Control" (слева), напиши краткое описание, нажми галочку → "Commit and Push".
|
||||
Изменения сохранятся в Gitea и будут доступны при следующем входе.
|
||||
- Дашборд (UI + API): https://advocate.telecom.quest
|
||||
- Camunda Cockpit: https://advocate-camunda.telecom.quest/camunda (логин `demo/demo`)
|
||||
- Workers REST: https://advocate.telecom.quest/api/v1/cases *(проксируется через nginx дашборда)*
|
||||
|
||||
В этой папке уже инициализирован git и подключен remote `origin` → Gitea.
|
||||
Внутренние сервисы (postgres, workers, ai-service) наружу не выставлены — только через Traefik роутеры.
|
||||
|
||||
Внешняя Traefik-сеть называется `traefik`. Если у вас другое имя — поправьте `networks.traefik.name` и `traefik.docker.network` лейблы в `docker-compose.yml`.
|
||||
|
||||
## Локальный запуск
|
||||
|
||||
Для запуска без Traefik (без `ports:` теперь по умолчанию недоступно с хоста), создайте `docker-compose.override.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
dashboard: { ports: ["3000:80"] }
|
||||
camunda: { ports: ["8080:8080"] }
|
||||
workers: { ports: ["8082:8082"] }
|
||||
postgres: { ports: ["5432:5432"] }
|
||||
```
|
||||
|
||||
Затем:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
`MockEventProducer` ждёт 30 секунд после старта, затем каждые 30 секунд запускает по одному из 7 кейсов через Camunda REST.
|
||||
|
||||
## Что увидите
|
||||
|
||||
| Кейс | Клиент | Обращений | Ожидаемый приоритет | Маршрут |
|
||||
|------|--------|-----------|---------------------|---------|
|
||||
| №1 | KT-10000001 | 98 | ⚫ BLACK | Human Task → personal escalation |
|
||||
| №6 | KT-10000006 | 59 | ⚫ BLACK | Human Task |
|
||||
| №3 | KT-10000003 | 46 | ⚫ BLACK | Human Task |
|
||||
| №5 | KT-10000005 | 15 | ⚫ BLACK | Human Task |
|
||||
| №2 | KT-10000002 | 22 | ⚫ BLACK | Human Task |
|
||||
| №4 | KT-10000004 | 22 | ⚫ BLACK | Human Task |
|
||||
| №7 | KT-10000007 | 21 | ⚫ BLACK | Human Task |
|
||||
|
||||
BLACK кейсы блокируются на Human Task — открой Camunda Tasklist (http://localhost:8080/camunda/app/tasklist) с логином `demo/demo` и подтверди их вручную (требование PRD §5).
|
||||
|
||||
YELLOW кейсы пауза 4 часа на SLA timer (TRD §4.3) — для демо считать корректным.
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
GET /api/v1/cases — список всех кейсов
|
||||
GET /api/v1/cases/{id} — кейс + рекомендации + лог событий
|
||||
POST /api/v1/cases — создать кейс вручную
|
||||
POST /api/v1/cases/{id}/csat — принять CSAT-ответ
|
||||
```
|
||||
|
||||
## Дальше (по TRD §10)
|
||||
|
||||
- Iter 2 — реальные интеграции (CRM/ASC/WhatsApp/Kafka)
|
||||
- Iter 3 — AI-слой (BERT sentiment, LLM рекомендации, churn-модель)
|
||||
- Iter 4 — Kubernetes + нагрузочное тестирование
|
||||
|
||||
---
|
||||
|
||||
В этой папке инициализирован git и подключен remote `origin` → Gitea. См. `GITEA.md`.
|
||||
|
||||
7
ai-service/Dockerfile
Normal file
7
ai-service/Dockerfile
Normal file
@ -0,0 +1,7 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
EXPOSE 8081
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8081"]
|
||||
0
ai-service/app/__init__.py
Normal file
0
ai-service/app/__init__.py
Normal file
83
ai-service/app/main.py
Normal file
83
ai-service/app/main.py
Normal file
@ -0,0 +1,83 @@
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI(title="Advocate AI Service (MVP, mock)")
|
||||
|
||||
NEGATIVE_KEYWORDS = [
|
||||
"не работает", "снова", "опять", "уже", "расторгнуть",
|
||||
"другой оператор", "суд", "жалоба", "перерасчёт", "перерасчет",
|
||||
"безобразие", "ужасно", "плохо", "грозит", "сто раз",
|
||||
"жұмыс істемейді", "қайтадан", "шағым",
|
||||
]
|
||||
|
||||
CHURN_SIGNALS = ["расторгнуть", "другой оператор", "суд", "грозит"]
|
||||
|
||||
CATEGORY_RULES = {
|
||||
"NETWORK": ["интернет", "скорость", "сигнал", "wifi", "байланыс", "сеть"],
|
||||
"EQUIPMENT": ["роутер", "приставка", "stb", "id tv", "ктв", "tv+", "устройство", "тв", "оборудование"],
|
||||
"BILLING": ["начисление", "оплата", "перерасчёт", "перерасчет", "баланс", "тариф", "лимит", "счёт", "счет"],
|
||||
"INSTALLATION": ["монтаж", "выезд", "специалист", "подключение", "визит"],
|
||||
"SERVICE": ["личный кабинет", "услуга", "vas", "пароль", "заявка"],
|
||||
}
|
||||
|
||||
|
||||
class ClassifyRequest(BaseModel):
|
||||
text: str = ""
|
||||
|
||||
|
||||
class ClassifyResponse(BaseModel):
|
||||
category: str
|
||||
sentiment: float
|
||||
churn_risk: float
|
||||
churn_signal: bool
|
||||
trigger_keywords: list[str]
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/v1/classify", response_model=ClassifyResponse)
|
||||
def classify(req: ClassifyRequest) -> ClassifyResponse:
|
||||
text = (req.text or "").lower()
|
||||
|
||||
triggers = [kw for kw in NEGATIVE_KEYWORDS if kw in text]
|
||||
sentiment = max(-1.0, -0.2 * len(triggers))
|
||||
|
||||
churn_signal = any(s in text for s in CHURN_SIGNALS)
|
||||
churn_risk = min(1.0, 0.3 + 0.15 * len(triggers) + (0.3 if churn_signal else 0.0))
|
||||
|
||||
scores = {cat: 0 for cat in CATEGORY_RULES}
|
||||
for cat, keywords in CATEGORY_RULES.items():
|
||||
for kw in keywords:
|
||||
if kw in text:
|
||||
scores[cat] += 1
|
||||
category = max(scores, key=scores.get) if max(scores.values()) > 0 else "SERVICE"
|
||||
|
||||
return ClassifyResponse(
|
||||
category=category,
|
||||
sentiment=round(sentiment, 2),
|
||||
churn_risk=round(churn_risk, 2),
|
||||
churn_signal=churn_signal,
|
||||
trigger_keywords=triggers,
|
||||
)
|
||||
|
||||
|
||||
class SentimentRequest(BaseModel):
|
||||
text: str = ""
|
||||
language: str = "auto"
|
||||
|
||||
|
||||
@app.post("/api/v1/sentiment")
|
||||
def sentiment(req: SentimentRequest):
|
||||
text = (req.text or "").lower()
|
||||
hits = [kw for kw in NEGATIVE_KEYWORDS if kw in text]
|
||||
score = max(-1.0, -0.2 * len(hits))
|
||||
label = "NEGATIVE" if score < -0.3 else ("NEUTRAL" if score < 0.1 else "POSITIVE")
|
||||
return {
|
||||
"score": round(score, 2),
|
||||
"label": label,
|
||||
"confidence": 0.85,
|
||||
"trigger_keywords": hits,
|
||||
}
|
||||
3
ai-service/requirements.txt
Normal file
3
ai-service/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
fastapi==0.110.0
|
||||
uvicorn[standard]==0.27.1
|
||||
pydantic==2.6.3
|
||||
183
bpmn/client-advocate-main.bpmn
Normal file
183
bpmn/client-advocate-main.bpmn
Normal file
@ -0,0 +1,183 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
|
||||
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
|
||||
xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
|
||||
xmlns:camunda="http://camunda.org/schema/1.0/bpmn"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
targetNamespace="http://kazakhtelecom.kz/advocate"
|
||||
exporter="manual" exporterVersion="1.0">
|
||||
|
||||
<bpmn:process id="client-advocate-main" name="Адвокат клиента" isExecutable="true"
|
||||
camunda:historyTimeToLive="180">
|
||||
|
||||
<bpmn:startEvent id="start" name="Триггер получен">
|
||||
<bpmn:outgoing>flow_start_enrich</bpmn:outgoing>
|
||||
</bpmn:startEvent>
|
||||
|
||||
<bpmn:serviceTask id="enrichProfile" name="Обогатить профиль клиента"
|
||||
camunda:type="external" camunda:topic="enrich-client-profile">
|
||||
<bpmn:incoming>flow_start_enrich</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_enrich_classify</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
|
||||
<bpmn:serviceTask id="classifyPain" name="Классифицировать боль (AI)"
|
||||
camunda:type="external" camunda:topic="classify-pain">
|
||||
<bpmn:incoming>flow_enrich_classify</bpmn:incoming>
|
||||
<bpmn:incoming>flow_reopen_classify</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_classify_gw</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
|
||||
<bpmn:exclusiveGateway id="priorityGateway" name="Приоритет?">
|
||||
<bpmn:incoming>flow_classify_gw</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_gw_green</bpmn:outgoing>
|
||||
<bpmn:outgoing>flow_gw_yellow</bpmn:outgoing>
|
||||
<bpmn:outgoing>flow_gw_red</bpmn:outgoing>
|
||||
<bpmn:outgoing>flow_gw_black</bpmn:outgoing>
|
||||
</bpmn:exclusiveGateway>
|
||||
|
||||
<bpmn:sequenceFlow id="flow_start_enrich" sourceRef="start" targetRef="enrichProfile"/>
|
||||
<bpmn:sequenceFlow id="flow_enrich_classify" sourceRef="enrichProfile" targetRef="classifyPain"/>
|
||||
<bpmn:sequenceFlow id="flow_classify_gw" sourceRef="classifyPain" targetRef="priorityGateway"/>
|
||||
|
||||
<bpmn:sequenceFlow id="flow_gw_green" sourceRef="priorityGateway" targetRef="standardHandling">
|
||||
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${priorityLevel == "GREEN"}</bpmn:conditionExpression>
|
||||
</bpmn:sequenceFlow>
|
||||
<bpmn:sequenceFlow id="flow_gw_yellow" sourceRef="priorityGateway" targetRef="notifySupervisor">
|
||||
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${priorityLevel == "YELLOW"}</bpmn:conditionExpression>
|
||||
</bpmn:sequenceFlow>
|
||||
<bpmn:sequenceFlow id="flow_gw_red" sourceRef="priorityGateway" targetRef="routeToDept">
|
||||
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${priorityLevel == "RED"}</bpmn:conditionExpression>
|
||||
</bpmn:sequenceFlow>
|
||||
<bpmn:sequenceFlow id="flow_gw_black" sourceRef="priorityGateway" targetRef="humanEscalation">
|
||||
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${priorityLevel == "BLACK"}</bpmn:conditionExpression>
|
||||
</bpmn:sequenceFlow>
|
||||
|
||||
<!-- GREEN -->
|
||||
<bpmn:serviceTask id="standardHandling" name="Стандартная обработка"
|
||||
camunda:type="external" camunda:topic="standard-handling">
|
||||
<bpmn:incoming>flow_gw_green</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_green_join</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
|
||||
<!-- YELLOW -->
|
||||
<bpmn:serviceTask id="notifySupervisor" name="Уведомить супервайзера"
|
||||
camunda:type="external" camunda:topic="notify-supervisor">
|
||||
<bpmn:incoming>flow_gw_yellow</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_super_prio</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:serviceTask id="prioritizeQueue" name="Приоритизировать в очереди"
|
||||
camunda:type="external" camunda:topic="prioritize-queue">
|
||||
<bpmn:incoming>flow_super_prio</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_prio_timer</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:intermediateCatchEvent id="slaTimer4h" name="SLA Monitor 4h">
|
||||
<bpmn:incoming>flow_prio_timer</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_yellow_join</bpmn:outgoing>
|
||||
<bpmn:timerEventDefinition>
|
||||
<bpmn:timeDuration>PT4H</bpmn:timeDuration>
|
||||
</bpmn:timerEventDefinition>
|
||||
</bpmn:intermediateCatchEvent>
|
||||
<bpmn:sequenceFlow id="flow_super_prio" sourceRef="notifySupervisor" targetRef="prioritizeQueue"/>
|
||||
<bpmn:sequenceFlow id="flow_prio_timer" sourceRef="prioritizeQueue" targetRef="slaTimer4h"/>
|
||||
|
||||
<!-- RED -->
|
||||
<bpmn:callActivity id="routeToDept" name="Маршрутизация"
|
||||
calledElement="route-to-department">
|
||||
<bpmn:extensionElements>
|
||||
<camunda:in source="clientId" target="clientId"/>
|
||||
<camunda:in source="painCategory" target="painCategory"/>
|
||||
<camunda:in source="priorityLevel" target="priorityLevel"/>
|
||||
<camunda:in source="totalAppeals" target="totalAppeals"/>
|
||||
<camunda:in source="clientName" target="clientName"/>
|
||||
<camunda:in source="caseId" target="caseId"/>
|
||||
<camunda:out source="routedTo" target="routedTo"/>
|
||||
<camunda:out source="routedTicketId" target="routedTicketId"/>
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>flow_gw_red</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_route_proactive</bpmn:outgoing>
|
||||
</bpmn:callActivity>
|
||||
<bpmn:serviceTask id="proactiveContact" name="Проактивный контакт"
|
||||
camunda:type="external" camunda:topic="proactive-contact">
|
||||
<bpmn:incoming>flow_route_proactive</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_proactive_control</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:serviceTask id="controlUntilClose" name="Контроль до закрытия"
|
||||
camunda:type="external" camunda:topic="control-until-close">
|
||||
<bpmn:incoming>flow_proactive_control</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_red_join</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:sequenceFlow id="flow_route_proactive" sourceRef="routeToDept" targetRef="proactiveContact"/>
|
||||
<bpmn:sequenceFlow id="flow_proactive_control" sourceRef="proactiveContact" targetRef="controlUntilClose"/>
|
||||
|
||||
<!-- BLACK -->
|
||||
<bpmn:userTask id="humanEscalation" name="Эскалация директору ДУКО"
|
||||
camunda:candidateGroups="duko-directors">
|
||||
<bpmn:incoming>flow_gw_black</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_human_personal</bpmn:outgoing>
|
||||
</bpmn:userTask>
|
||||
<bpmn:serviceTask id="personalEscalation" name="Персональное сопровождение"
|
||||
camunda:type="external" camunda:topic="personal-escalation">
|
||||
<bpmn:incoming>flow_human_personal</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_black_join</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:sequenceFlow id="flow_human_personal" sourceRef="humanEscalation" targetRef="personalEscalation"/>
|
||||
|
||||
<!-- Join all priority branches -->
|
||||
<bpmn:exclusiveGateway id="prioJoin">
|
||||
<bpmn:incoming>flow_green_join</bpmn:incoming>
|
||||
<bpmn:incoming>flow_yellow_join</bpmn:incoming>
|
||||
<bpmn:incoming>flow_red_join</bpmn:incoming>
|
||||
<bpmn:incoming>flow_black_join</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_join_notify</bpmn:outgoing>
|
||||
</bpmn:exclusiveGateway>
|
||||
<bpmn:sequenceFlow id="flow_green_join" sourceRef="standardHandling" targetRef="prioJoin"/>
|
||||
<bpmn:sequenceFlow id="flow_yellow_join" sourceRef="slaTimer4h" targetRef="prioJoin"/>
|
||||
<bpmn:sequenceFlow id="flow_red_join" sourceRef="controlUntilClose" targetRef="prioJoin"/>
|
||||
<bpmn:sequenceFlow id="flow_black_join" sourceRef="personalEscalation" targetRef="prioJoin"/>
|
||||
|
||||
<!-- Notify resolved + CSAT -->
|
||||
<bpmn:serviceTask id="notifyResolved" name="Уведомить клиента о решении"
|
||||
camunda:type="external" camunda:topic="notify-client-resolved">
|
||||
<bpmn:incoming>flow_join_notify</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_notify_csat</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:serviceTask id="csatCheck" name="Проверка CSAT"
|
||||
camunda:type="external" camunda:topic="csat-check">
|
||||
<bpmn:incoming>flow_notify_csat</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_csat_gw</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:sequenceFlow id="flow_join_notify" sourceRef="prioJoin" targetRef="notifyResolved"/>
|
||||
<bpmn:sequenceFlow id="flow_notify_csat" sourceRef="notifyResolved" targetRef="csatCheck"/>
|
||||
|
||||
<bpmn:exclusiveGateway id="satisfiedGateway" name="Клиент доволен?">
|
||||
<bpmn:incoming>flow_csat_gw</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_sat_close</bpmn:outgoing>
|
||||
<bpmn:outgoing>flow_sat_reopen</bpmn:outgoing>
|
||||
</bpmn:exclusiveGateway>
|
||||
<bpmn:sequenceFlow id="flow_csat_gw" sourceRef="csatCheck" targetRef="satisfiedGateway"/>
|
||||
<bpmn:sequenceFlow id="flow_sat_close" sourceRef="satisfiedGateway" targetRef="closeCase">
|
||||
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${csatScore >= 4}</bpmn:conditionExpression>
|
||||
</bpmn:sequenceFlow>
|
||||
<bpmn:sequenceFlow id="flow_sat_reopen" sourceRef="satisfiedGateway" targetRef="reopenCase">
|
||||
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${csatScore < 4}</bpmn:conditionExpression>
|
||||
</bpmn:sequenceFlow>
|
||||
|
||||
<bpmn:serviceTask id="closeCase" name="Закрыть кейс"
|
||||
camunda:type="external" camunda:topic="close-case">
|
||||
<bpmn:incoming>flow_sat_close</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_close_end</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:endEvent id="endSuccess" name="Кейс закрыт">
|
||||
<bpmn:incoming>flow_close_end</bpmn:incoming>
|
||||
</bpmn:endEvent>
|
||||
<bpmn:sequenceFlow id="flow_close_end" sourceRef="closeCase" targetRef="endSuccess"/>
|
||||
|
||||
<bpmn:serviceTask id="reopenCase" name="Пересоздать кейс"
|
||||
camunda:type="external" camunda:topic="reopen-case">
|
||||
<bpmn:incoming>flow_sat_reopen</bpmn:incoming>
|
||||
<bpmn:outgoing>flow_reopen_classify</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
|
||||
</bpmn:process>
|
||||
</bpmn:definitions>
|
||||
86
bpmn/route-to-department.bpmn
Normal file
86
bpmn/route-to-department.bpmn
Normal file
@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||
xmlns:camunda="http://camunda.org/schema/1.0/bpmn"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
targetNamespace="http://kazakhtelecom.kz/advocate">
|
||||
|
||||
<bpmn:process id="route-to-department" name="Маршрутизация" isExecutable="true"
|
||||
camunda:historyTimeToLive="180">
|
||||
|
||||
<bpmn:startEvent id="rStart">
|
||||
<bpmn:outgoing>r_flow_start_gw</bpmn:outgoing>
|
||||
</bpmn:startEvent>
|
||||
|
||||
<bpmn:exclusiveGateway id="categoryGateway" name="Категория?">
|
||||
<bpmn:incoming>r_flow_start_gw</bpmn:incoming>
|
||||
<bpmn:outgoing>r_flow_network</bpmn:outgoing>
|
||||
<bpmn:outgoing>r_flow_equipment</bpmn:outgoing>
|
||||
<bpmn:outgoing>r_flow_billing</bpmn:outgoing>
|
||||
<bpmn:outgoing>r_flow_install</bpmn:outgoing>
|
||||
<bpmn:outgoing>r_flow_service</bpmn:outgoing>
|
||||
</bpmn:exclusiveGateway>
|
||||
<bpmn:sequenceFlow id="r_flow_start_gw" sourceRef="rStart" targetRef="categoryGateway"/>
|
||||
|
||||
<bpmn:sequenceFlow id="r_flow_network" sourceRef="categoryGateway" targetRef="routeToNetwork">
|
||||
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${painCategory == "NETWORK"}</bpmn:conditionExpression>
|
||||
</bpmn:sequenceFlow>
|
||||
<bpmn:sequenceFlow id="r_flow_equipment" sourceRef="categoryGateway" targetRef="routeToASC">
|
||||
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${painCategory == "EQUIPMENT"}</bpmn:conditionExpression>
|
||||
</bpmn:sequenceFlow>
|
||||
<bpmn:sequenceFlow id="r_flow_billing" sourceRef="categoryGateway" targetRef="routeToFinance">
|
||||
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${painCategory == "BILLING"}</bpmn:conditionExpression>
|
||||
</bpmn:sequenceFlow>
|
||||
<bpmn:sequenceFlow id="r_flow_install" sourceRef="categoryGateway" targetRef="routeToSTPK">
|
||||
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${painCategory == "INSTALLATION"}</bpmn:conditionExpression>
|
||||
</bpmn:sequenceFlow>
|
||||
<bpmn:sequenceFlow id="r_flow_service" sourceRef="categoryGateway" targetRef="routeToCC">
|
||||
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${painCategory == "SERVICE"}</bpmn:conditionExpression>
|
||||
</bpmn:sequenceFlow>
|
||||
|
||||
<bpmn:serviceTask id="routeToNetwork" name="Сигнал в мониторинг сети"
|
||||
camunda:type="external" camunda:topic="create-network-ticket">
|
||||
<bpmn:incoming>r_flow_network</bpmn:incoming>
|
||||
<bpmn:outgoing>r_flow_net_join</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:serviceTask id="routeToASC" name="Заявка в ASC"
|
||||
camunda:type="external" camunda:topic="create-asc-ticket">
|
||||
<bpmn:incoming>r_flow_equipment</bpmn:incoming>
|
||||
<bpmn:outgoing>r_flow_asc_join</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:serviceTask id="routeToFinance" name="Задача в финансы"
|
||||
camunda:type="external" camunda:topic="create-finance-task">
|
||||
<bpmn:incoming>r_flow_billing</bpmn:incoming>
|
||||
<bpmn:outgoing>r_flow_fin_join</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:serviceTask id="routeToSTPK" name="Задача в СТПК/ЦВЛТПК"
|
||||
camunda:type="external" camunda:topic="create-stpk-task">
|
||||
<bpmn:incoming>r_flow_install</bpmn:incoming>
|
||||
<bpmn:outgoing>r_flow_stpk_join</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:serviceTask id="routeToCC" name="CRM-задача оператору КЦ"
|
||||
camunda:type="external" camunda:topic="create-cc-task">
|
||||
<bpmn:incoming>r_flow_service</bpmn:incoming>
|
||||
<bpmn:outgoing>r_flow_cc_join</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
|
||||
<bpmn:exclusiveGateway id="routeJoin">
|
||||
<bpmn:incoming>r_flow_net_join</bpmn:incoming>
|
||||
<bpmn:incoming>r_flow_asc_join</bpmn:incoming>
|
||||
<bpmn:incoming>r_flow_fin_join</bpmn:incoming>
|
||||
<bpmn:incoming>r_flow_stpk_join</bpmn:incoming>
|
||||
<bpmn:incoming>r_flow_cc_join</bpmn:incoming>
|
||||
<bpmn:outgoing>r_flow_join_end</bpmn:outgoing>
|
||||
</bpmn:exclusiveGateway>
|
||||
<bpmn:sequenceFlow id="r_flow_net_join" sourceRef="routeToNetwork" targetRef="routeJoin"/>
|
||||
<bpmn:sequenceFlow id="r_flow_asc_join" sourceRef="routeToASC" targetRef="routeJoin"/>
|
||||
<bpmn:sequenceFlow id="r_flow_fin_join" sourceRef="routeToFinance" targetRef="routeJoin"/>
|
||||
<bpmn:sequenceFlow id="r_flow_stpk_join" sourceRef="routeToSTPK" targetRef="routeJoin"/>
|
||||
<bpmn:sequenceFlow id="r_flow_cc_join" sourceRef="routeToCC" targetRef="routeJoin"/>
|
||||
|
||||
<bpmn:endEvent id="rEnd">
|
||||
<bpmn:incoming>r_flow_join_end</bpmn:incoming>
|
||||
</bpmn:endEvent>
|
||||
<bpmn:sequenceFlow id="r_flow_join_end" sourceRef="routeJoin" targetRef="rEnd"/>
|
||||
|
||||
</bpmn:process>
|
||||
</bpmn:definitions>
|
||||
6
dashboard/Dockerfile
Normal file
6
dashboard/Dockerfile
Normal file
@ -0,0 +1,6 @@
|
||||
FROM nginx:1.27-alpine
|
||||
COPY default.conf /etc/nginx/conf.d/default.conf
|
||||
COPY index.html /usr/share/nginx/html/index.html
|
||||
COPY app.js /usr/share/nginx/html/app.js
|
||||
COPY styles.css /usr/share/nginx/html/styles.css
|
||||
EXPOSE 80
|
||||
92
dashboard/app.js
Normal file
92
dashboard/app.js
Normal file
@ -0,0 +1,92 @@
|
||||
const PRIO_ORDER = { BLACK: 0, RED: 1, YELLOW: 2, GREEN: 3, null: 4 };
|
||||
|
||||
async function fetchCases() {
|
||||
const r = await fetch("/api/v1/cases");
|
||||
if (!r.ok) throw new Error("API " + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function fetchCase(id) {
|
||||
const r = await fetch("/api/v1/cases/" + id);
|
||||
if (!r.ok) throw new Error("API " + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function pillPrio(p) {
|
||||
if (!p) return "—";
|
||||
const label = { BLACK: "⚫", RED: "🔴", YELLOW: "🟡", GREEN: "🟢" }[p] || "";
|
||||
return `<span class="badge ${p}">${label} ${p}</span>`;
|
||||
}
|
||||
|
||||
function pillStatus(s) {
|
||||
return `<span class="badge ${s}">${s}</span>`;
|
||||
}
|
||||
|
||||
function fmtNum(n) { return n == null ? "—" : n; }
|
||||
|
||||
function render(cases) {
|
||||
document.getElementById("stat-total").textContent = cases.length;
|
||||
const by = (p) => cases.filter(c => c.priority_level === p).length;
|
||||
document.getElementById("stat-black").textContent = by("BLACK");
|
||||
document.getElementById("stat-red").textContent = by("RED");
|
||||
document.getElementById("stat-yellow").textContent = by("YELLOW");
|
||||
document.getElementById("stat-green").textContent = by("GREEN");
|
||||
|
||||
cases.sort((a, b) =>
|
||||
(PRIO_ORDER[a.priority_level] ?? 9) - (PRIO_ORDER[b.priority_level] ?? 9));
|
||||
|
||||
const tbody = document.getElementById("cases-body");
|
||||
if (cases.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="11" class="empty">Ещё нет кейсов — подождите запуск MockEventProducer (~30с)</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = cases.map(c => `
|
||||
<tr>
|
||||
<td>${pillPrio(c.priority_level)}</td>
|
||||
<td>${c.client_name || c.client_id}<br/><small style="opacity:0.6">${c.client_id}</small></td>
|
||||
<td>${c.pain_category || "—"}</td>
|
||||
<td>${fmtNum(c.total_appeals_count)}</td>
|
||||
<td>${pillStatus(c.status)}</td>
|
||||
<td>${c.routed_to || "—"}</td>
|
||||
<td><code>${c.routed_ticket_id || "—"}</code></td>
|
||||
<td>${c.sentiment_score == null ? "—" : Number(c.sentiment_score).toFixed(2)}</td>
|
||||
<td>${c.churn_risk_score == null ? "—" : Number(c.churn_risk_score).toFixed(2)}</td>
|
||||
<td>${fmtNum(c.days_unresolved)}</td>
|
||||
<td><button class="view" data-id="${c.id}">Детали</button></td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
tbody.querySelectorAll("button.view").forEach(b =>
|
||||
b.addEventListener("click", () => showDetail(b.dataset.id)));
|
||||
}
|
||||
|
||||
async function showDetail(id) {
|
||||
const aside = document.getElementById("case-detail");
|
||||
const body = document.getElementById("detail-body");
|
||||
body.textContent = "Загрузка…";
|
||||
aside.hidden = false;
|
||||
try {
|
||||
const data = await fetchCase(id);
|
||||
body.textContent = JSON.stringify(data, null, 2);
|
||||
} catch (e) {
|
||||
body.textContent = "Ошибка: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("close-detail").addEventListener("click", () => {
|
||||
document.getElementById("case-detail").hidden = true;
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const cases = await fetchCases();
|
||||
render(cases);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
document.getElementById("cases-body").innerHTML =
|
||||
'<tr><td colspan="11" class="empty">Ошибка API: ' + e.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 5000);
|
||||
17
dashboard/default.conf
Normal file
17
dashboard/default.conf
Normal file
@ -0,0 +1,17 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://workers:8082/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
59
dashboard/index.html
Normal file
59
dashboard/index.html
Normal file
@ -0,0 +1,59 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>Адвокат клиента — Дашборд</title>
|
||||
<link rel="stylesheet" href="styles.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>⚖️ AI-агент «Адвокат клиента»</h1>
|
||||
<p class="subtitle">Kazakhtelecom · ДУКО · MVP Iteration 1</p>
|
||||
</header>
|
||||
|
||||
<section class="stats" id="stats">
|
||||
<div class="card"><span class="label">Всего кейсов</span><span class="value" id="stat-total">—</span></div>
|
||||
<div class="card black"><span class="label">⚫ BLACK</span><span class="value" id="stat-black">—</span></div>
|
||||
<div class="card red"><span class="label">🔴 RED</span><span class="value" id="stat-red">—</span></div>
|
||||
<div class="card yellow"><span class="label">🟡 YELLOW</span><span class="value" id="stat-yellow">—</span></div>
|
||||
<div class="card green"><span class="label">🟢 GREEN</span><span class="value" id="stat-green">—</span></div>
|
||||
</section>
|
||||
|
||||
<main>
|
||||
<h2>Активные кейсы</h2>
|
||||
<table id="cases-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Приоритет</th>
|
||||
<th>Клиент</th>
|
||||
<th>Категория</th>
|
||||
<th>Обращений</th>
|
||||
<th>Статус</th>
|
||||
<th>Маршрут</th>
|
||||
<th>Тикет</th>
|
||||
<th>Sentiment</th>
|
||||
<th>Churn</th>
|
||||
<th>Дней</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="cases-body">
|
||||
<tr><td colspan="11" class="empty">Загрузка…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
|
||||
<aside id="case-detail" hidden>
|
||||
<button id="close-detail">✕</button>
|
||||
<h3>Детали кейса</h3>
|
||||
<pre id="detail-body"></pre>
|
||||
</aside>
|
||||
|
||||
<footer>
|
||||
Polling каждые 5 сек · Camunda: <a href="http://localhost:8080" target="_blank">localhost:8080</a> · API: <a href="/api/v1/cases">/api/v1/cases</a>
|
||||
</footer>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
43
dashboard/styles.css
Normal file
43
dashboard/styles.css
Normal file
@ -0,0 +1,43 @@
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; }
|
||||
header { padding: 20px 32px; background: linear-gradient(90deg, #1e3a8a, #0ea5e9); border-bottom: 1px solid #1e293b; }
|
||||
h1 { margin: 0; font-size: 22px; }
|
||||
.subtitle { margin: 4px 0 0; font-size: 13px; opacity: 0.85; }
|
||||
|
||||
.stats { display: flex; gap: 12px; padding: 20px 32px; flex-wrap: wrap; }
|
||||
.card { background: #1e293b; border-radius: 8px; padding: 14px 18px; min-width: 130px; display: flex; flex-direction: column; }
|
||||
.card .label { font-size: 12px; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.card .value { font-size: 26px; font-weight: 700; margin-top: 4px; }
|
||||
.card.black { border-left: 4px solid #1f2937; }
|
||||
.card.red { border-left: 4px solid #dc2626; }
|
||||
.card.yellow { border-left: 4px solid #eab308; }
|
||||
.card.green { border-left: 4px solid #16a34a; }
|
||||
|
||||
main { padding: 0 32px 32px; }
|
||||
h2 { font-size: 16px; opacity: 0.85; margin-top: 12px; }
|
||||
table { width: 100%; border-collapse: collapse; background: #1e293b; border-radius: 8px; overflow: hidden; }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #334155; font-size: 13px; }
|
||||
th { background: #0f172a; font-weight: 600; opacity: 0.8; }
|
||||
tr:hover { background: #273449; }
|
||||
.empty { text-align: center; opacity: 0.5; padding: 24px; }
|
||||
|
||||
.badge { padding: 3px 8px; border-radius: 12px; font-size: 11px; font-weight: 700; display: inline-block; }
|
||||
.badge.BLACK { background: #0f172a; color: #f1f5f9; border: 1px solid #475569; }
|
||||
.badge.RED { background: #7f1d1d; color: #fecaca; }
|
||||
.badge.YELLOW { background: #713f12; color: #fde68a; }
|
||||
.badge.GREEN { background: #14532d; color: #bbf7d0; }
|
||||
.badge.OPEN, .badge.IN_PROGRESS { background: #1e40af; color: #dbeafe; }
|
||||
.badge.ESCALATED { background: #7c2d12; color: #fed7aa; }
|
||||
.badge.CLOSED { background: #14532d; color: #bbf7d0; }
|
||||
.badge.REOPENED { background: #831843; color: #fbcfe8; }
|
||||
|
||||
button.view { background: #0ea5e9; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; }
|
||||
button.view:hover { background: #0284c7; }
|
||||
|
||||
aside { position: fixed; right: 0; top: 0; width: 480px; height: 100vh; background: #1e293b; padding: 24px; overflow-y: auto; border-left: 1px solid #334155; box-shadow: -8px 0 24px rgba(0,0,0,0.4); }
|
||||
aside h3 { margin-top: 0; }
|
||||
aside pre { background: #0f172a; padding: 12px; border-radius: 4px; font-size: 11px; overflow: auto; white-space: pre-wrap; word-break: break-all; }
|
||||
#close-detail { float: right; background: none; color: #e2e8f0; border: 1px solid #475569; border-radius: 4px; padding: 4px 10px; cursor: pointer; }
|
||||
|
||||
footer { padding: 12px 32px; font-size: 12px; opacity: 0.6; border-top: 1px solid #1e293b; }
|
||||
footer a { color: #38bdf8; }
|
||||
123
data/synthetic/appeals.json
Normal file
123
data/synthetic/appeals.json
Normal file
@ -0,0 +1,123 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"caseRef": 1,
|
||||
"clientId": "KT-10000001",
|
||||
"clientName": "Айгуль Серикова",
|
||||
"clientPhone": "+77011112233",
|
||||
"region": "Алматы",
|
||||
"totalAppeals": 98,
|
||||
"appealsOnTopic": 15,
|
||||
"firstAppealDate": "2025-01-16T09:12:00",
|
||||
"lastAppealDate": "2025-08-28T17:45:00",
|
||||
"hasOpenTicket": true,
|
||||
"openTicketId": "CRM-77001",
|
||||
"channels": ["CC_160", "PSS", "OMILIA", "WHATSAPP"],
|
||||
"rawText": "Снова нет интернета, уже сто раз звонила, грозит расторгнуть договор и пойти к другому оператору, иначе подам в суд",
|
||||
"expectedCategory": "NETWORK",
|
||||
"expectedPriority": "BLACK"
|
||||
},
|
||||
{
|
||||
"caseRef": 6,
|
||||
"clientId": "KT-10000006",
|
||||
"clientName": "Бахытжан Омаров",
|
||||
"clientPhone": "+77022223344",
|
||||
"region": "Астана",
|
||||
"totalAppeals": 59,
|
||||
"appealsOnTopic": 26,
|
||||
"firstAppealDate": "2025-07-09T08:30:00",
|
||||
"lastAppealDate": "2025-10-02T19:10:00",
|
||||
"hasOpenTicket": true,
|
||||
"openTicketId": "CRM-77006",
|
||||
"channels": ["OMILIA", "TELECOM_KZ", "WHATSAPP", "CC_160", "PSS"],
|
||||
"rawText": "Качество ТВ ужасное, ID TV постоянно зависает, приставка не работает, требую перерасчёт за месяцы простоя",
|
||||
"expectedCategory": "EQUIPMENT",
|
||||
"expectedPriority": "BLACK"
|
||||
},
|
||||
{
|
||||
"caseRef": 3,
|
||||
"clientId": "KT-10000003",
|
||||
"clientName": "Дина Қасымова",
|
||||
"clientPhone": "+77033334455",
|
||||
"region": "Шымкент",
|
||||
"totalAppeals": 46,
|
||||
"appealsOnTopic": 10,
|
||||
"firstAppealDate": "2025-01-21T11:00:00",
|
||||
"lastAppealDate": "2025-12-18T14:25:00",
|
||||
"hasOpenTicket": true,
|
||||
"openTicketId": "CRM-77003",
|
||||
"channels": ["CC_160", "OMILIA", "PSS"],
|
||||
"rawText": "Интернет снова не работает, скорость низкая, опять заявка висит, жұмыс істемейді, шағым",
|
||||
"expectedCategory": "NETWORK",
|
||||
"expectedPriority": "BLACK"
|
||||
},
|
||||
{
|
||||
"caseRef": 5,
|
||||
"clientId": "KT-10000005",
|
||||
"clientName": "Ерлан Жумабаев",
|
||||
"clientPhone": "+77044445566",
|
||||
"region": "Караганда",
|
||||
"totalAppeals": 15,
|
||||
"appealsOnTopic": 9,
|
||||
"firstAppealDate": "2025-02-18T10:00:00",
|
||||
"lastAppealDate": "2025-11-10T16:20:00",
|
||||
"hasOpenTicket": true,
|
||||
"openTicketId": "CRM-77005",
|
||||
"channels": ["PSS", "CC_160", "OMILIA"],
|
||||
"rawText": "Опять монтаж не выполнили, специалист не выехал, жду подключение третью неделю",
|
||||
"expectedCategory": "INSTALLATION",
|
||||
"expectedPriority": "BLACK"
|
||||
},
|
||||
{
|
||||
"caseRef": 2,
|
||||
"clientId": "KT-10000002",
|
||||
"clientName": "Гульмира Тлеубай",
|
||||
"clientPhone": "+77055556677",
|
||||
"region": "Актобе",
|
||||
"totalAppeals": 22,
|
||||
"appealsOnTopic": 5,
|
||||
"firstAppealDate": "2025-01-22T13:00:00",
|
||||
"lastAppealDate": "2025-10-17T11:30:00",
|
||||
"hasOpenTicket": true,
|
||||
"openTicketId": "CRM-77002",
|
||||
"channels": ["CC_160", "WHATSAPP", "OMILIA", "PSS"],
|
||||
"rawText": "Начисление неверное, требую перерасчёт, баланс списан без причины, тариф не тот",
|
||||
"expectedCategory": "BILLING",
|
||||
"expectedPriority": "RED"
|
||||
},
|
||||
{
|
||||
"caseRef": 4,
|
||||
"clientId": "KT-10000004",
|
||||
"clientName": "Жанар Алиева",
|
||||
"clientPhone": "+77066667788",
|
||||
"region": "Павлодар",
|
||||
"totalAppeals": 22,
|
||||
"appealsOnTopic": 5,
|
||||
"firstAppealDate": "2025-02-25T09:00:00",
|
||||
"lastAppealDate": "2025-11-19T20:00:00",
|
||||
"hasOpenTicket": true,
|
||||
"openTicketId": "CRM-77004",
|
||||
"channels": ["CC_160", "WHATSAPP", "OMILIA", "PSS", "TELECOM_KZ"],
|
||||
"rawText": "Роутер сгорел, замена не происходит, заявка висит, безобразие",
|
||||
"expectedCategory": "EQUIPMENT",
|
||||
"expectedPriority": "RED"
|
||||
},
|
||||
{
|
||||
"caseRef": 7,
|
||||
"clientId": "KT-10000007",
|
||||
"clientName": "Ислам Нурланов",
|
||||
"clientPhone": "+77077778899",
|
||||
"region": "Усть-Каменогорск",
|
||||
"totalAppeals": 21,
|
||||
"appealsOnTopic": 9,
|
||||
"firstAppealDate": "2025-01-27T07:45:00",
|
||||
"lastAppealDate": "2025-09-10T18:15:00",
|
||||
"hasOpenTicket": false,
|
||||
"openTicketId": null,
|
||||
"channels": ["CC_160", "WHATSAPP", "OMILIA", "PSS"],
|
||||
"rawText": "Личный кабинет не работает, пароль не приходит, услуга VAS не подключается",
|
||||
"expectedCategory": "SERVICE",
|
||||
"expectedPriority": "RED"
|
||||
}
|
||||
]
|
||||
}
|
||||
105
docker-compose.yml
Normal file
105
docker-compose.yml
Normal file
@ -0,0 +1,105 @@
|
||||
services:
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: advocate-postgres
|
||||
environment:
|
||||
POSTGRES_USER: advocate
|
||||
POSTGRES_PASSWORD: advocate
|
||||
POSTGRES_DB: advocate_db
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
- ./sql/init.sql:/docker-entrypoint-initdb.d/00-init.sql
|
||||
- ./sql/camunda_db.sql:/docker-entrypoint-initdb.d/01-camunda.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U advocate -d advocate_db"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
networks:
|
||||
- advocate-internal
|
||||
|
||||
camunda:
|
||||
image: camunda/camunda-bpm-platform:7.21.0
|
||||
container_name: advocate-camunda
|
||||
expose:
|
||||
- "8080"
|
||||
environment:
|
||||
DB_DRIVER: org.postgresql.Driver
|
||||
DB_URL: jdbc:postgresql://postgres:5432/camunda
|
||||
DB_USERNAME: camunda
|
||||
DB_PASSWORD: camunda
|
||||
WAIT_FOR: postgres:5432
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- advocate-internal
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.http.routers.advocate-camunda.rule=Host(`advocate-camunda.telecom.quest`)"
|
||||
- "traefik.http.routers.advocate-camunda.entrypoints=websecure"
|
||||
- "traefik.http.routers.advocate-camunda.tls=true"
|
||||
- "traefik.http.services.advocate-camunda.loadbalancer.server.port=8080"
|
||||
|
||||
workers:
|
||||
build: ./workers
|
||||
container_name: advocate-workers
|
||||
environment:
|
||||
CAMUNDA_BASE_URL: http://camunda:8080/engine-rest
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/advocate_db
|
||||
SPRING_DATASOURCE_USERNAME: advocate
|
||||
SPRING_DATASOURCE_PASSWORD: advocate
|
||||
AI_SERVICE_URL: http://ai-service:8081
|
||||
MOCK_MODE: "true"
|
||||
SYNTHETIC_DATA_PATH: /data/synthetic/appeals.json
|
||||
MOCK_EVENT_INTERVAL_MS: "30000"
|
||||
expose:
|
||||
- "8082"
|
||||
depends_on:
|
||||
- camunda
|
||||
- postgres
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./bpmn:/bpmn
|
||||
networks:
|
||||
- advocate-internal
|
||||
|
||||
ai-service:
|
||||
build: ./ai-service
|
||||
container_name: advocate-ai
|
||||
expose:
|
||||
- "8081"
|
||||
environment:
|
||||
MOCK_MODE: "true"
|
||||
networks:
|
||||
- advocate-internal
|
||||
|
||||
dashboard:
|
||||
build: ./dashboard
|
||||
container_name: advocate-dashboard
|
||||
expose:
|
||||
- "80"
|
||||
depends_on:
|
||||
- workers
|
||||
networks:
|
||||
- advocate-internal
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.http.routers.advocate.rule=Host(`advocate.telecom.quest`)"
|
||||
- "traefik.http.routers.advocate.entrypoints=websecure"
|
||||
- "traefik.http.routers.advocate.tls=true"
|
||||
- "traefik.http.services.advocate.loadbalancer.server.port=80"
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
|
||||
networks:
|
||||
advocate-internal:
|
||||
driver: bridge
|
||||
traefik:
|
||||
external: true
|
||||
4
sql/camunda_db.sql
Normal file
4
sql/camunda_db.sql
Normal file
@ -0,0 +1,4 @@
|
||||
-- Separate database for Camunda 7 engine
|
||||
CREATE USER camunda WITH PASSWORD 'camunda';
|
||||
CREATE DATABASE camunda OWNER camunda;
|
||||
GRANT ALL PRIVILEGES ON DATABASE camunda TO camunda;
|
||||
112
sql/init.sql
Normal file
112
sql/init.sql
Normal file
@ -0,0 +1,112 @@
|
||||
-- AI-агент «Адвокат клиента» — schema (TRD §3)
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_cases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
camunda_process_id VARCHAR(255) UNIQUE,
|
||||
client_id VARCHAR(100) NOT NULL,
|
||||
client_phone VARCHAR(20),
|
||||
client_name VARCHAR(255),
|
||||
|
||||
pain_category VARCHAR(50),
|
||||
priority_level VARCHAR(10),
|
||||
|
||||
total_appeals_count INTEGER DEFAULT 0,
|
||||
appeals_on_topic INTEGER DEFAULT 0,
|
||||
first_appeal_date TIMESTAMP,
|
||||
last_appeal_date TIMESTAMP,
|
||||
days_unresolved INTEGER GENERATED ALWAYS AS (
|
||||
EXTRACT(DAY FROM NOW() - first_appeal_date)::INTEGER
|
||||
) STORED,
|
||||
|
||||
sentiment_score DECIMAL(3,2),
|
||||
churn_risk_score DECIMAL(3,2),
|
||||
fcr_fail_score DECIMAL(3,2),
|
||||
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'OPEN',
|
||||
|
||||
routed_to VARCHAR(50),
|
||||
routed_ticket_id VARCHAR(255),
|
||||
routed_at TIMESTAMP,
|
||||
|
||||
last_client_notif TIMESTAMP,
|
||||
client_csat_score INTEGER,
|
||||
compensation_approved BOOLEAN DEFAULT FALSE,
|
||||
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
closed_at TIMESTAMP,
|
||||
created_by VARCHAR(100) DEFAULT 'AI_AGENT'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cases_client_id ON client_cases(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cases_priority ON client_cases(priority_level);
|
||||
CREATE INDEX IF NOT EXISTS idx_cases_status ON client_cases(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_cases_routed_to ON client_cases(routed_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_cases_created_at ON client_cases(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_appeals (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
case_id UUID REFERENCES client_cases(id) ON DELETE CASCADE,
|
||||
client_id VARCHAR(100) NOT NULL,
|
||||
channel VARCHAR(30) NOT NULL,
|
||||
appeal_date TIMESTAMP NOT NULL,
|
||||
topic TEXT,
|
||||
topic_category VARCHAR(50),
|
||||
raw_text TEXT,
|
||||
sentiment DECIMAL(3,2),
|
||||
trigger_keywords TEXT[],
|
||||
crm_ticket_id VARCHAR(255),
|
||||
crm_status VARCHAR(50),
|
||||
sla_deadline TIMESTAMP,
|
||||
sla_violated BOOLEAN DEFAULT FALSE,
|
||||
transferred_to VARCHAR(100)[],
|
||||
transfer_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_appeals_case_id ON client_appeals(case_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_appeals_client_id ON client_appeals(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_appeals_channel ON client_appeals(channel);
|
||||
CREATE INDEX IF NOT EXISTS idx_appeals_date ON client_appeals(appeal_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS case_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
case_id UUID REFERENCES client_cases(id) ON DELETE CASCADE,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
event_data JSONB,
|
||||
actor VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_case_id ON case_events(case_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS operator_recommendations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
case_id UUID REFERENCES client_cases(id) ON DELETE CASCADE,
|
||||
client_id VARCHAR(100) NOT NULL,
|
||||
priority_label VARCHAR(10),
|
||||
summary_text TEXT,
|
||||
actions JSONB,
|
||||
churn_risk VARCHAR(10),
|
||||
compensation_allowed BOOLEAN DEFAULT FALSE,
|
||||
compensation_limit DECIMAL(10,2),
|
||||
shown_to_operator_id VARCHAR(100),
|
||||
shown_at TIMESTAMP,
|
||||
action_taken VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weekly_analytics (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
week_start DATE NOT NULL,
|
||||
week_end DATE NOT NULL,
|
||||
top_detractors JSONB,
|
||||
top_pain_cats JSONB,
|
||||
region_heatmap JSONB,
|
||||
sla_violators JSONB,
|
||||
dept_fcr_stats JSONB,
|
||||
recommendations TEXT[],
|
||||
generated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
4
workers/.dockerignore
Normal file
4
workers/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
||||
target/
|
||||
.idea/
|
||||
*.iml
|
||||
.git/
|
||||
12
workers/Dockerfile
Normal file
12
workers/Dockerfile
Normal file
@ -0,0 +1,12 @@
|
||||
FROM maven:3.9-eclipse-temurin-17 AS build
|
||||
WORKDIR /app
|
||||
COPY pom.xml .
|
||||
RUN mvn -B -q -DskipTests dependency:go-offline || true
|
||||
COPY src ./src
|
||||
RUN mvn -B -q -DskipTests package
|
||||
|
||||
FROM eclipse-temurin:17-jre
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/target/advocate-workers-1.0.0.jar app.jar
|
||||
EXPOSE 8082
|
||||
ENTRYPOINT ["java","-jar","/app/app.jar"]
|
||||
77
workers/pom.xml
Normal file
77
workers/pom.xml
Normal file
@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>kz.kazakhtelecom</groupId>
|
||||
<artifactId>advocate-workers</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.5</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<camunda.client.version>7.21.0</camunda.client.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.camunda.bpm</groupId>
|
||||
<artifactId>camunda-external-task-client-spring-boot-starter</artifactId>
|
||||
<version>${camunda.client.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@ -0,0 +1,15 @@
|
||||
package kz.kazakhtelecom.advocate;
|
||||
|
||||
import org.camunda.bpm.client.spring.annotation.EnableExternalTaskClient;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableExternalTaskClient
|
||||
@EnableScheduling
|
||||
public class AdvocateApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AdvocateApplication.class, args);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
package kz.kazakhtelecom.advocate.api;
|
||||
|
||||
import kz.kazakhtelecom.advocate.bootstrap.CamundaProcessLauncher;
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
@CrossOrigin(origins = "*")
|
||||
public class CasesController {
|
||||
|
||||
private final CaseRepository repo;
|
||||
private final CamundaProcessLauncher launcher;
|
||||
|
||||
public CasesController(CaseRepository repo, CamundaProcessLauncher launcher) {
|
||||
this.repo = repo;
|
||||
this.launcher = launcher;
|
||||
}
|
||||
|
||||
@GetMapping("/cases")
|
||||
public List<Map<String, Object>> list() {
|
||||
return repo.listAllCases();
|
||||
}
|
||||
|
||||
@GetMapping("/cases/{id}")
|
||||
public ResponseEntity<Map<String, Object>> get(@PathVariable("id") UUID id) {
|
||||
var c = repo.getCase(id);
|
||||
if (c.isEmpty()) return ResponseEntity.notFound().build();
|
||||
Map<String, Object> body = new HashMap<>(c.get());
|
||||
body.put("recommendations", repo.recommendationsForCase(id));
|
||||
body.put("events", repo.eventsForCase(id));
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/cases")
|
||||
public Map<String, Object> create(@RequestBody Map<String, Object> req) {
|
||||
Map<String, Object> vars = new HashMap<>();
|
||||
vars.put("clientId", String.valueOf(req.getOrDefault("clientId", "")));
|
||||
vars.put("triggerType", String.valueOf(req.getOrDefault("triggerType", "MANUAL")));
|
||||
vars.put("triggerSource", String.valueOf(req.getOrDefault("triggerSource", "API")));
|
||||
vars.put("rawAppealText", String.valueOf(req.getOrDefault("appealText", "")));
|
||||
vars.put("appealId", String.valueOf(req.getOrDefault("appealId", "")));
|
||||
Object cnt = req.getOrDefault("appealCount", 1);
|
||||
vars.put("appealCount", cnt instanceof Number ? ((Number) cnt).intValue() : 1);
|
||||
|
||||
String pid = launcher.start("client-advocate-main", vars);
|
||||
return Map.of("camundaProcessId", pid == null ? "" : pid, "status", "STARTED");
|
||||
}
|
||||
|
||||
@PostMapping("/cases/{id}/csat")
|
||||
public Map<String, Object> csat(@PathVariable("id") UUID id, @RequestBody Map<String, Object> body) {
|
||||
Integer score = (Integer) body.getOrDefault("score", 5);
|
||||
return Map.of("caseId", id.toString(),
|
||||
"action", score >= 4 ? "WILL_CLOSE" : "WILL_REOPEN",
|
||||
"score", score);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
package kz.kazakhtelecom.advocate.bootstrap;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class BpmnDeployer {
|
||||
|
||||
@Value("${camunda.bpm.client.base-url}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${advocate.bpmn-dir:/bpmn}")
|
||||
private String bpmnDir;
|
||||
|
||||
private final RestTemplate rest;
|
||||
|
||||
public BpmnDeployer(RestTemplate rest) {
|
||||
this.rest = rest;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void deploy() {
|
||||
Path dir = Paths.get(bpmnDir);
|
||||
if (!Files.isDirectory(dir)) {
|
||||
log.warn("BPMN dir {} does not exist — skipping deploy", bpmnDir);
|
||||
return;
|
||||
}
|
||||
// Camunda may not be up yet — small retry loop
|
||||
for (int attempt = 1; attempt <= 30; attempt++) {
|
||||
try {
|
||||
doDeploy(dir);
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
log.info("Deploy attempt {} failed: {} — retrying in 5s", attempt, e.getMessage());
|
||||
try { Thread.sleep(5000); } catch (InterruptedException ignored) { return; }
|
||||
}
|
||||
}
|
||||
log.error("Gave up deploying BPMN after retries");
|
||||
}
|
||||
|
||||
private void doDeploy(Path dir) throws IOException {
|
||||
String url = baseUrl + "/deployment/create";
|
||||
try (Stream<Path> files = Files.list(dir)) {
|
||||
files.filter(p -> p.toString().endsWith(".bpmn")).forEach(p -> {
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("deployment-name", "advocate-" + p.getFileName());
|
||||
body.add("enable-duplicate-filtering", "true");
|
||||
body.add("deploy-changed-only", "true");
|
||||
body.add("data", new FileSystemResource(p.toFile()));
|
||||
HttpHeaders h = new HttpHeaders();
|
||||
h.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
HttpEntity<MultiValueMap<String, Object>> req = new HttpEntity<>(body, h);
|
||||
rest.postForObject(url, req, String.class);
|
||||
log.info("Deployed BPMN: {}", p.getFileName());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
package kz.kazakhtelecom.advocate.bootstrap;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class CamundaProcessLauncher {
|
||||
|
||||
@Value("${camunda.bpm.client.base-url}")
|
||||
private String baseUrl;
|
||||
|
||||
private final RestTemplate rest;
|
||||
|
||||
public CamundaProcessLauncher(RestTemplate rest) {
|
||||
this.rest = rest;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public String start(String processKey, Map<String, Object> variables) {
|
||||
String url = baseUrl + "/process-definition/key/" + processKey + "/start";
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
Map<String, Object> camundaVars = new HashMap<>();
|
||||
variables.forEach((k, v) -> camundaVars.put(k, Map.of("value", v, "type", inferType(v))));
|
||||
body.put("variables", camundaVars);
|
||||
|
||||
HttpHeaders h = new HttpHeaders();
|
||||
h.setContentType(MediaType.APPLICATION_JSON);
|
||||
try {
|
||||
Map<String, Object> resp = rest.postForObject(url, new HttpEntity<>(body, h), Map.class);
|
||||
if (resp == null) return null;
|
||||
String id = (String) resp.get("id");
|
||||
log.info("Started process {} → id={}", processKey, id);
|
||||
return id;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to start process {}: {}", processKey, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String inferType(Object v) {
|
||||
if (v == null) return "String";
|
||||
if (v instanceof Boolean) return "Boolean";
|
||||
if (v instanceof Integer) return "Integer";
|
||||
if (v instanceof Long) return "Long";
|
||||
if (v instanceof Double || v instanceof Float) return "Double";
|
||||
return "String";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
package kz.kazakhtelecom.advocate.bootstrap;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import kz.kazakhtelecom.advocate.model.SyntheticCase;
|
||||
import kz.kazakhtelecom.advocate.service.SyntheticDataLoader;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class MockEventProducer {
|
||||
|
||||
private final SyntheticDataLoader loader;
|
||||
private final CamundaProcessLauncher launcher;
|
||||
|
||||
@Value("${advocate.mock-mode}")
|
||||
private boolean mockMode;
|
||||
|
||||
private Iterator<SyntheticCase> cursor;
|
||||
private boolean fired = false;
|
||||
|
||||
public MockEventProducer(SyntheticDataLoader loader, CamundaProcessLauncher launcher) {
|
||||
this.loader = loader;
|
||||
this.launcher = launcher;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
log.info("MockEventProducer initialized (mockMode={})", mockMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires synthetic events into Camunda. Waits 30s on startup for Camunda to be ready,
|
||||
* then emits one case per interval until the dataset is exhausted.
|
||||
*/
|
||||
@Scheduled(initialDelayString = "30000", fixedDelayString = "${advocate.mock-event-interval-ms}")
|
||||
public void tick() {
|
||||
if (!mockMode) return;
|
||||
if (cursor == null || (!cursor.hasNext() && !fired)) {
|
||||
cursor = loader.all().iterator();
|
||||
}
|
||||
if (cursor == null || !cursor.hasNext()) {
|
||||
if (fired) return;
|
||||
log.info("MockEventProducer: synthetic dataset empty");
|
||||
return;
|
||||
}
|
||||
SyntheticCase c = cursor.next();
|
||||
Map<String, Object> vars = new HashMap<>();
|
||||
vars.put("clientId", c.getClientId());
|
||||
vars.put("triggerType", "QUANTITATIVE");
|
||||
vars.put("triggerSource", c.getChannels().isEmpty() ? "CC_160" : c.getChannels().get(0));
|
||||
vars.put("rawAppealText", c.getRawText());
|
||||
vars.put("appealId", c.getOpenTicketId() == null ? "" : c.getOpenTicketId());
|
||||
vars.put("appealCount", c.getTotalAppeals());
|
||||
vars.put("openTicketId", c.getOpenTicketId() == null ? "" : c.getOpenTicketId());
|
||||
|
||||
String pid = launcher.start("client-advocate-main", vars);
|
||||
log.info("[MOCK PRODUCER] Fired clientId={} caseRef={} pid={}",
|
||||
c.getClientId(), c.getCaseRef(), pid);
|
||||
fired = true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package kz.kazakhtelecom.advocate.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
@Configuration
|
||||
public class AppConfig {
|
||||
|
||||
@Bean
|
||||
public RestTemplate restTemplate() {
|
||||
return new RestTemplate();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ObjectMapper objectMapper() {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
mapper.registerModule(new JavaTimeModule());
|
||||
return mapper;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,176 @@
|
||||
package kz.kazakhtelecom.advocate.db;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.postgresql.util.PGobject;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
@Repository
|
||||
public class CaseRepository {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public CaseRepository(JdbcTemplate jdbc, ObjectMapper mapper) {
|
||||
this.jdbc = jdbc;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UUID createCase(String camundaProcessId, String clientId, String clientName,
|
||||
String clientPhone, int totalAppeals, int appealsOnTopic,
|
||||
Instant firstAppealDate, Instant lastAppealDate) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbc.update("""
|
||||
INSERT INTO client_cases
|
||||
(id, camunda_process_id, client_id, client_name, client_phone,
|
||||
total_appeals_count, appeals_on_topic, first_appeal_date, last_appeal_date,
|
||||
status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'OPEN', NOW(), NOW())
|
||||
""",
|
||||
id, camundaProcessId, clientId, clientName, clientPhone,
|
||||
totalAppeals, appealsOnTopic,
|
||||
Timestamp.from(firstAppealDate), Timestamp.from(lastAppealDate));
|
||||
return id;
|
||||
}
|
||||
|
||||
public Optional<UUID> findByProcessId(String camundaProcessId) {
|
||||
List<UUID> ids = jdbc.query(
|
||||
"SELECT id FROM client_cases WHERE camunda_process_id = ?",
|
||||
(rs, n) -> (UUID) rs.getObject("id"),
|
||||
camundaProcessId);
|
||||
return ids.stream().findFirst();
|
||||
}
|
||||
|
||||
public void updateClassification(UUID caseId, String painCategory, String priorityLevel,
|
||||
double sentimentScore, double churnRiskScore) {
|
||||
jdbc.update("""
|
||||
UPDATE client_cases
|
||||
SET pain_category = ?, priority_level = ?,
|
||||
sentiment_score = ?, churn_risk_score = ?, updated_at = NOW()
|
||||
WHERE id = ?
|
||||
""", painCategory, priorityLevel, sentimentScore, churnRiskScore, caseId);
|
||||
}
|
||||
|
||||
public void updateRouting(UUID caseId, String routedTo, String routedTicketId) {
|
||||
jdbc.update("""
|
||||
UPDATE client_cases
|
||||
SET routed_to = ?, routed_ticket_id = ?, routed_at = NOW(),
|
||||
status = 'IN_PROGRESS', updated_at = NOW()
|
||||
WHERE id = ?
|
||||
""", routedTo, routedTicketId, caseId);
|
||||
}
|
||||
|
||||
public void updateStatus(UUID caseId, String status) {
|
||||
jdbc.update("UPDATE client_cases SET status = ?, updated_at = NOW() WHERE id = ?",
|
||||
status, caseId);
|
||||
}
|
||||
|
||||
public void close(UUID caseId, int csatScore) {
|
||||
jdbc.update("""
|
||||
UPDATE client_cases
|
||||
SET status = 'CLOSED', client_csat_score = ?, closed_at = NOW(), updated_at = NOW()
|
||||
WHERE id = ?
|
||||
""", csatScore, caseId);
|
||||
}
|
||||
|
||||
public void recordLastNotif(UUID caseId) {
|
||||
jdbc.update("UPDATE client_cases SET last_client_notif = NOW(), updated_at = NOW() WHERE id = ?",
|
||||
caseId);
|
||||
}
|
||||
|
||||
public void logEvent(UUID caseId, String eventType, Map<String, Object> data, String actor) {
|
||||
try {
|
||||
PGobject json = new PGobject();
|
||||
json.setType("jsonb");
|
||||
json.setValue(mapper.writeValueAsString(data == null ? Map.of() : data));
|
||||
jdbc.update("""
|
||||
INSERT INTO case_events (case_id, event_type, event_data, actor, created_at)
|
||||
VALUES (?, ?, ?, ?, NOW())
|
||||
""", caseId, eventType, json, actor);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("Failed to serialize event_data: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void insertAppeal(UUID caseId, String clientId, String channel, Instant appealDate,
|
||||
String topic, String category, String rawText, double sentiment,
|
||||
String[] triggerKeywords, String crmTicketId) {
|
||||
jdbc.update(conn -> {
|
||||
var ps = conn.prepareStatement("""
|
||||
INSERT INTO client_appeals
|
||||
(case_id, client_id, channel, appeal_date, topic, topic_category,
|
||||
raw_text, sentiment, trigger_keywords, crm_ticket_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
""");
|
||||
ps.setObject(1, caseId);
|
||||
ps.setString(2, clientId);
|
||||
ps.setString(3, channel);
|
||||
ps.setTimestamp(4, Timestamp.from(appealDate));
|
||||
ps.setString(5, topic);
|
||||
ps.setString(6, category);
|
||||
ps.setString(7, rawText);
|
||||
ps.setDouble(8, sentiment);
|
||||
ps.setArray(9, conn.createArrayOf("text", triggerKeywords));
|
||||
ps.setString(10, crmTicketId);
|
||||
return ps;
|
||||
});
|
||||
}
|
||||
|
||||
public void insertRecommendation(UUID caseId, String clientId, String priorityLabel,
|
||||
String summary, List<String> actions, String churnRisk,
|
||||
boolean compensationAllowed, double compensationLimit) {
|
||||
try {
|
||||
PGobject json = new PGobject();
|
||||
json.setType("jsonb");
|
||||
json.setValue(mapper.writeValueAsString(actions));
|
||||
jdbc.update("""
|
||||
INSERT INTO operator_recommendations
|
||||
(case_id, client_id, priority_label, summary_text, actions, churn_risk,
|
||||
compensation_allowed, compensation_limit, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
""", caseId, clientId, priorityLabel, summary, json, churnRisk,
|
||||
compensationAllowed, compensationLimit);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("Failed to serialize actions: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> listAllCases() {
|
||||
return jdbc.queryForList("""
|
||||
SELECT c.id, c.camunda_process_id, c.client_id, c.client_name,
|
||||
c.pain_category, c.priority_level, c.status, c.routed_to,
|
||||
c.routed_ticket_id, c.total_appeals_count, c.appeals_on_topic,
|
||||
c.sentiment_score, c.churn_risk_score, c.days_unresolved,
|
||||
c.client_csat_score, c.created_at, c.updated_at, c.closed_at
|
||||
FROM client_cases c
|
||||
ORDER BY c.created_at DESC
|
||||
""");
|
||||
}
|
||||
|
||||
public Optional<Map<String, Object>> getCase(UUID caseId) {
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
||||
"SELECT * FROM client_cases WHERE id = ?", caseId);
|
||||
return rows.stream().findFirst();
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> recommendationsForCase(UUID caseId) {
|
||||
return jdbc.queryForList(
|
||||
"SELECT * FROM operator_recommendations WHERE case_id = ? ORDER BY created_at DESC",
|
||||
caseId);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> eventsForCase(UUID caseId) {
|
||||
return jdbc.queryForList(
|
||||
"SELECT event_type, event_data, actor, created_at FROM case_events " +
|
||||
"WHERE case_id = ? ORDER BY created_at ASC", caseId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package kz.kazakhtelecom.advocate.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class SyntheticCase {
|
||||
private int caseRef;
|
||||
private String clientId;
|
||||
private String clientName;
|
||||
private String clientPhone;
|
||||
private String region;
|
||||
private int totalAppeals;
|
||||
private int appealsOnTopic;
|
||||
private String firstAppealDate;
|
||||
private String lastAppealDate;
|
||||
private boolean hasOpenTicket;
|
||||
private String openTicketId;
|
||||
private List<String> channels;
|
||||
private String rawText;
|
||||
private String expectedCategory;
|
||||
private String expectedPriority;
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package kz.kazakhtelecom.advocate.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class SyntheticDataset {
|
||||
private List<SyntheticCase> cases;
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package kz.kazakhtelecom.advocate.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AiServiceClient {
|
||||
|
||||
@Value("${advocate.ai-service-url}")
|
||||
private String aiUrl;
|
||||
|
||||
private final RestTemplate rest;
|
||||
|
||||
public AiServiceClient(RestTemplate rest) {
|
||||
this.rest = rest;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public ClassificationResult classify(String text) {
|
||||
try {
|
||||
Map<String, Object> req = Map.of("text", text == null ? "" : text);
|
||||
Map<String, Object> resp = rest.postForObject(aiUrl + "/api/v1/classify", req, Map.class);
|
||||
if (resp == null) return fallback();
|
||||
String category = (String) resp.getOrDefault("category", "SERVICE");
|
||||
double sentiment = ((Number) resp.getOrDefault("sentiment", 0.0)).doubleValue();
|
||||
double churn = ((Number) resp.getOrDefault("churn_risk", 0.0)).doubleValue();
|
||||
boolean churnSignal = (Boolean) resp.getOrDefault("churn_signal", false);
|
||||
return new ClassificationResult(category, sentiment, churn, churnSignal);
|
||||
} catch (Exception e) {
|
||||
log.warn("AI service call failed: {} — falling back", e.getMessage());
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
|
||||
private ClassificationResult fallback() {
|
||||
return new ClassificationResult("SERVICE", 0.0, 0.0, false);
|
||||
}
|
||||
|
||||
public record ClassificationResult(String category, double sentiment,
|
||||
double churnRisk, boolean churnSignal) {}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package kz.kazakhtelecom.advocate.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class MockASCService {
|
||||
|
||||
public String createTicket(String clientId, String equipmentType, String priority, String description) {
|
||||
String id = "ASC-" + UUID.randomUUID().toString().substring(0, 6).toUpperCase();
|
||||
log.info("[MOCK ASC] Ticket {} created clientId={} equipment={} priority={} desc={}",
|
||||
id, clientId, equipmentType, priority, description);
|
||||
return id;
|
||||
}
|
||||
|
||||
public String createNetworkSignal(String clientId, String region, String priority) {
|
||||
String id = "NET-SIG-" + UUID.randomUUID().toString().substring(0, 6).toUpperCase();
|
||||
log.info("[MOCK NETMON] Signal {} created clientId={} region={} priority={}",
|
||||
id, clientId, region, priority);
|
||||
return id;
|
||||
}
|
||||
|
||||
public String createFinanceTask(String clientId, String description) {
|
||||
String id = "FIN-" + UUID.randomUUID().toString().substring(0, 6).toUpperCase();
|
||||
log.info("[MOCK FIN] Finance task {} created clientId={} desc={}", id, clientId, description);
|
||||
return id;
|
||||
}
|
||||
|
||||
public String createSTPKTask(String clientId, String description) {
|
||||
String id = "STPK-" + UUID.randomUUID().toString().substring(0, 6).toUpperCase();
|
||||
log.info("[MOCK STPK] Task {} created clientId={} desc={}", id, clientId, description);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package kz.kazakhtelecom.advocate.service;
|
||||
|
||||
import kz.kazakhtelecom.advocate.model.SyntheticCase;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class MockCRMService {
|
||||
|
||||
private final SyntheticDataLoader loader;
|
||||
|
||||
public MockCRMService(SyntheticDataLoader loader) {
|
||||
this.loader = loader;
|
||||
}
|
||||
|
||||
public Optional<SyntheticCase> getClientProfile(String clientId) {
|
||||
return loader.findByClientId(clientId);
|
||||
}
|
||||
|
||||
public String createTask(String clientId, String description) {
|
||||
String id = "MOCK-CRM-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
log.info("[MOCK CRM] CRM task {} created for {}: {}", id, clientId, description);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package kz.kazakhtelecom.advocate.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class MockNotifierService {
|
||||
|
||||
public void send(String clientId, String channel, String message) {
|
||||
log.info("[MOCK NOTIFY] →{} via {}: {}", clientId, channel, message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
package kz.kazakhtelecom.advocate.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import kz.kazakhtelecom.advocate.model.SyntheticCase;
|
||||
import kz.kazakhtelecom.advocate.model.SyntheticDataset;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SyntheticDataLoader {
|
||||
|
||||
@Value("${advocate.synthetic-data-path}")
|
||||
private String path;
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private List<SyntheticCase> cases = Collections.emptyList();
|
||||
|
||||
public SyntheticDataLoader(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void load() {
|
||||
try {
|
||||
File f = new File(path);
|
||||
InputStream in;
|
||||
if (f.exists()) {
|
||||
in = new java.io.FileInputStream(f);
|
||||
} else {
|
||||
in = getClass().getClassLoader().getResourceAsStream("appeals.json");
|
||||
}
|
||||
if (in == null) {
|
||||
log.warn("Synthetic data not found at {} or classpath", path);
|
||||
return;
|
||||
}
|
||||
SyntheticDataset ds = mapper.readValue(in, SyntheticDataset.class);
|
||||
cases = ds.getCases() == null ? Collections.emptyList() : ds.getCases();
|
||||
log.info("Loaded {} synthetic cases from {}", cases.size(), path);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load synthetic data: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public List<SyntheticCase> all() {
|
||||
return cases;
|
||||
}
|
||||
|
||||
public Optional<SyntheticCase> findByClientId(String clientId) {
|
||||
return cases.stream().filter(c -> c.getClientId().equals(clientId)).findFirst();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "csat-check", lockDuration = 60000)
|
||||
public class CSATCheckWorker implements ExternalTaskHandler {
|
||||
|
||||
private final CaseRepository repo;
|
||||
|
||||
public CSATCheckWorker(CaseRepository r) { this.repo = r; }
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
Integer pre = (Integer) task.getVariable("csatScore");
|
||||
int score = pre != null ? pre : 5;
|
||||
UUID id = WorkerHelpers.caseId(task);
|
||||
if (id != null) repo.logEvent(id, "CSAT_RECEIVED", Map.of("score", score), "AI_AGENT");
|
||||
log.info("CSAT for caseId={} score={}", id, score);
|
||||
service.complete(task, Map.of("csatScore", score));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,137 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import kz.kazakhtelecom.advocate.service.AiServiceClient;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "classify-pain", lockDuration = 60000)
|
||||
public class ClassifyPainWorker implements ExternalTaskHandler {
|
||||
|
||||
private final AiServiceClient ai;
|
||||
private final CaseRepository repo;
|
||||
|
||||
public ClassifyPainWorker(AiServiceClient ai, CaseRepository repo) {
|
||||
this.ai = ai;
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
String rawText = task.getVariable("rawAppealText");
|
||||
Integer total = intVar(task, "totalAppeals", 1);
|
||||
Boolean openTicket = boolVar(task, "hasOpenTicket", false);
|
||||
Boolean reopenedFlag = boolVar(task, "reopened", false);
|
||||
|
||||
AiServiceClient.ClassificationResult cls = ai.classify(rawText);
|
||||
String category = cls.category();
|
||||
double sentiment = cls.sentiment();
|
||||
double churn = cls.churnRisk();
|
||||
boolean churnSignal = cls.churnSignal();
|
||||
|
||||
String priority = resolvePriority(total, sentiment, openTicket, churnSignal);
|
||||
if (reopenedFlag) priority = bump(priority);
|
||||
|
||||
log.info("Classify category={} priority={} sentiment={} churn={} (total={} openTicket={})",
|
||||
category, priority, sentiment, churn, total, openTicket);
|
||||
|
||||
UUID caseId = caseIdFromTask(task);
|
||||
if (caseId != null) {
|
||||
repo.updateClassification(caseId, category, priority, sentiment, churn);
|
||||
repo.logEvent(caseId, "CLASSIFIED", Map.of(
|
||||
"category", category, "priority", priority,
|
||||
"sentiment", sentiment, "churn", churn), "AI_AGENT");
|
||||
|
||||
String summary = String.format(
|
||||
"Клиент обращается %d раз. Категория: %s. Sentiment: %.2f. Риск оттока: %.0f%%.",
|
||||
total, category, sentiment, churn * 100);
|
||||
List<String> actions = recommendedActions(priority, category);
|
||||
String churnLevel = churn >= 0.7 ? "HIGH" : churn >= 0.4 ? "MEDIUM" : "LOW";
|
||||
boolean compAllowed = "RED".equals(priority) || "BLACK".equals(priority);
|
||||
double compLimit = "BLACK".equals(priority) ? 50000 : (compAllowed ? 10000 : 0);
|
||||
repo.insertRecommendation(caseId, task.getVariable("clientId"), priority,
|
||||
summary, actions, churnLevel, compAllowed, compLimit);
|
||||
}
|
||||
|
||||
Map<String, Object> vars = new HashMap<>();
|
||||
vars.put("painCategory", category);
|
||||
vars.put("priorityLevel", priority);
|
||||
vars.put("sentimentScore", sentiment);
|
||||
vars.put("churnRiskScore", churn);
|
||||
service.complete(task, vars);
|
||||
}
|
||||
|
||||
private String resolvePriority(int total, double sentiment, boolean openTicket, boolean churnSignal) {
|
||||
if (total >= 10 || churnSignal) return "BLACK";
|
||||
if (total >= 5 || (openTicket && total >= 3)) return "RED";
|
||||
if (total >= 3 || sentiment < -0.5) return "YELLOW";
|
||||
return "GREEN";
|
||||
}
|
||||
|
||||
private String bump(String p) {
|
||||
return switch (p) {
|
||||
case "GREEN" -> "YELLOW";
|
||||
case "YELLOW" -> "RED";
|
||||
case "RED" -> "BLACK";
|
||||
default -> "BLACK";
|
||||
};
|
||||
}
|
||||
|
||||
private List<String> recommendedActions(String priority, String category) {
|
||||
return switch (priority) {
|
||||
case "BLACK" -> List.of(
|
||||
"Извиниться от лица компании",
|
||||
"Эскалировать директору ДУКО",
|
||||
"Назначить персонального менеджера",
|
||||
"Рассмотреть компенсацию до 50 000 KZT");
|
||||
case "RED" -> List.of(
|
||||
"Эскалировать заявку как СРОЧНУЮ в " + departmentFor(category),
|
||||
"Проактивный контакт через WhatsApp/SMS",
|
||||
"Контроль до закрытия заявки",
|
||||
"Рассмотреть перерасчёт за дни простоя");
|
||||
case "YELLOW" -> List.of(
|
||||
"Уведомить супервайзера",
|
||||
"Поставить кейс в приоритетную очередь",
|
||||
"Мониторить SLA каждые 4 часа");
|
||||
default -> List.of("Стандартная обработка");
|
||||
};
|
||||
}
|
||||
|
||||
private String departmentFor(String category) {
|
||||
return switch (category) {
|
||||
case "NETWORK" -> "Удалённый мониторинг сети";
|
||||
case "EQUIPMENT" -> "ASC";
|
||||
case "BILLING" -> "Финансовый отдел";
|
||||
case "INSTALLATION" -> "СТПК/ЦВЛТПК";
|
||||
default -> "КЦ";
|
||||
};
|
||||
}
|
||||
|
||||
private UUID caseIdFromTask(ExternalTask task) {
|
||||
String s = task.getVariable("caseId");
|
||||
if (s == null) return null;
|
||||
try { return UUID.fromString(s); } catch (Exception e) { return null; }
|
||||
}
|
||||
|
||||
private int intVar(ExternalTask t, String k, int def) {
|
||||
Object v = t.getVariable(k);
|
||||
if (v == null) return def;
|
||||
return ((Number) v).intValue();
|
||||
}
|
||||
|
||||
private boolean boolVar(ExternalTask t, String k, boolean def) {
|
||||
Object v = t.getVariable(k);
|
||||
return v == null ? def : (Boolean) v;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "close-case", lockDuration = 60000)
|
||||
public class CloseCaseWorker implements ExternalTaskHandler {
|
||||
|
||||
private final CaseRepository repo;
|
||||
|
||||
public CloseCaseWorker(CaseRepository r) { this.repo = r; }
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
Integer score = (Integer) task.getVariable("csatScore");
|
||||
UUID id = WorkerHelpers.caseId(task);
|
||||
if (id != null) {
|
||||
repo.close(id, score == null ? 5 : score);
|
||||
repo.logEvent(id, "CLOSED", Map.of("csat", score == null ? 5 : score), "AI_AGENT");
|
||||
}
|
||||
log.info("Closed caseId={} csat={}", id, score);
|
||||
service.complete(task);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "control-until-close", lockDuration = 60000)
|
||||
public class ControlUntilCloseWorker implements ExternalTaskHandler {
|
||||
|
||||
private final CaseRepository repo;
|
||||
|
||||
public ControlUntilCloseWorker(CaseRepository r) { this.repo = r; }
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
String ticket = task.getVariable("routedTicketId");
|
||||
log.info("Controlling ticket {} until close (mock: assume closed in {}ms)",
|
||||
ticket, 500);
|
||||
UUID id = WorkerHelpers.caseId(task);
|
||||
if (id != null) {
|
||||
repo.logEvent(id, "CONTROLLED",
|
||||
Map.of("ticketId", ticket == null ? "?" : ticket, "result", "CLOSED_OK"), "AI_AGENT");
|
||||
}
|
||||
service.complete(task);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import kz.kazakhtelecom.advocate.service.MockASCService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "create-asc-ticket", lockDuration = 60000)
|
||||
public class CreateASCTicketWorker implements ExternalTaskHandler {
|
||||
|
||||
private final MockASCService asc;
|
||||
private final CaseRepository repo;
|
||||
|
||||
public CreateASCTicketWorker(MockASCService a, CaseRepository r) {
|
||||
this.asc = a; this.repo = r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
String clientId = task.getVariable("clientId");
|
||||
Integer total = (Integer) task.getVariable("totalAppeals");
|
||||
String priority = task.getVariable("priorityLevel");
|
||||
String desc = "Автозаявка агента «Адвокат клиента». Обращений: " +
|
||||
(total == null ? "?" : total) + ". Категория: EQUIPMENT.";
|
||||
String ticket = asc.createTicket(clientId, "ROUTER/STB", priority, desc);
|
||||
UUID id = WorkerHelpers.caseId(task);
|
||||
if (id != null) {
|
||||
repo.updateRouting(id, "ASC", ticket);
|
||||
repo.logEvent(id, "ROUTED", Map.of("routedTo", "ASC", "ticketId", ticket), "AI_AGENT");
|
||||
}
|
||||
service.complete(task, Map.of("routedTo", "ASC", "routedTicketId", ticket));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import kz.kazakhtelecom.advocate.service.MockCRMService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "create-cc-task", lockDuration = 60000)
|
||||
public class CreateCCTaskWorker implements ExternalTaskHandler {
|
||||
|
||||
private final MockCRMService crm;
|
||||
private final CaseRepository repo;
|
||||
|
||||
public CreateCCTaskWorker(MockCRMService c, CaseRepository r) {
|
||||
this.crm = c; this.repo = r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
String clientId = task.getVariable("clientId");
|
||||
String ticket = crm.createTask(clientId, "CRM-задача оператору КЦ — консультация по услуге");
|
||||
UUID id = WorkerHelpers.caseId(task);
|
||||
if (id != null) {
|
||||
repo.updateRouting(id, "CC_OPERATOR", ticket);
|
||||
repo.logEvent(id, "ROUTED", Map.of("routedTo", "CC_OPERATOR", "ticketId", ticket), "AI_AGENT");
|
||||
}
|
||||
service.complete(task, Map.of("routedTo", "CC_OPERATOR", "routedTicketId", ticket));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import kz.kazakhtelecom.advocate.service.MockASCService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "create-finance-task", lockDuration = 60000)
|
||||
public class CreateFinanceTaskWorker implements ExternalTaskHandler {
|
||||
|
||||
private final MockASCService asc;
|
||||
private final CaseRepository repo;
|
||||
|
||||
public CreateFinanceTaskWorker(MockASCService a, CaseRepository r) {
|
||||
this.asc = a; this.repo = r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
String clientId = task.getVariable("clientId");
|
||||
String ticket = asc.createFinanceTask(clientId,
|
||||
"Запрос на перерасчёт. Категория: BILLING. Приоритет: " + task.getVariable("priorityLevel"));
|
||||
UUID id = WorkerHelpers.caseId(task);
|
||||
if (id != null) {
|
||||
repo.updateRouting(id, "FINANCE", ticket);
|
||||
repo.logEvent(id, "ROUTED", Map.of("routedTo", "FINANCE", "ticketId", ticket), "AI_AGENT");
|
||||
}
|
||||
service.complete(task, Map.of("routedTo", "FINANCE", "routedTicketId", ticket));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import kz.kazakhtelecom.advocate.service.MockASCService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "create-network-ticket", lockDuration = 60000)
|
||||
public class CreateNetworkTicketWorker implements ExternalTaskHandler {
|
||||
|
||||
private final MockASCService asc;
|
||||
private final CaseRepository repo;
|
||||
|
||||
public CreateNetworkTicketWorker(MockASCService a, CaseRepository r) {
|
||||
this.asc = a; this.repo = r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
String clientId = task.getVariable("clientId");
|
||||
String region = task.getVariable("clientRegion");
|
||||
String priority = task.getVariable("priorityLevel");
|
||||
String sig = asc.createNetworkSignal(clientId, region == null ? "unknown" : region,
|
||||
priority == null ? "HIGH" : priority);
|
||||
UUID id = WorkerHelpers.caseId(task);
|
||||
if (id != null) {
|
||||
repo.updateRouting(id, "NETWORK_MONITORING", sig);
|
||||
repo.logEvent(id, "ROUTED",
|
||||
Map.of("routedTo", "NETWORK_MONITORING", "signalId", sig), "AI_AGENT");
|
||||
}
|
||||
service.complete(task, Map.of("routedTo", "NETWORK_MONITORING", "routedTicketId", sig));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import kz.kazakhtelecom.advocate.service.MockASCService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "create-stpk-task", lockDuration = 60000)
|
||||
public class CreateSTPKTaskWorker implements ExternalTaskHandler {
|
||||
|
||||
private final MockASCService asc;
|
||||
private final CaseRepository repo;
|
||||
|
||||
public CreateSTPKTaskWorker(MockASCService a, CaseRepository r) {
|
||||
this.asc = a; this.repo = r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
String clientId = task.getVariable("clientId");
|
||||
String ticket = asc.createSTPKTask(clientId,
|
||||
"Монтаж/выезд. Приоритет: " + task.getVariable("priorityLevel"));
|
||||
UUID id = WorkerHelpers.caseId(task);
|
||||
if (id != null) {
|
||||
repo.updateRouting(id, "STPK", ticket);
|
||||
repo.logEvent(id, "ROUTED", Map.of("routedTo", "STPK", "ticketId", ticket), "AI_AGENT");
|
||||
}
|
||||
service.complete(task, Map.of("routedTo", "STPK", "routedTicketId", ticket));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import kz.kazakhtelecom.advocate.model.SyntheticCase;
|
||||
import kz.kazakhtelecom.advocate.service.MockCRMService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "enrich-client-profile", lockDuration = 60000)
|
||||
public class EnrichProfileWorker implements ExternalTaskHandler {
|
||||
|
||||
private final MockCRMService crm;
|
||||
private final CaseRepository repo;
|
||||
|
||||
public EnrichProfileWorker(MockCRMService crm, CaseRepository repo) {
|
||||
this.crm = crm;
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
String clientId = task.getVariable("clientId");
|
||||
String rawText = task.getVariable("rawAppealText");
|
||||
String processId = task.getProcessInstanceId();
|
||||
|
||||
log.info("Enriching profile clientId={} processId={}", clientId, processId);
|
||||
|
||||
Optional<SyntheticCase> opt = crm.getClientProfile(clientId);
|
||||
if (opt.isEmpty()) {
|
||||
log.warn("No CRM profile for clientId={}, completing with defaults", clientId);
|
||||
Map<String, Object> defaults = Map.of(
|
||||
"totalAppeals", 1, "appealsOnTopic", 1, "hasOpenTicket", false,
|
||||
"daysSinceFirst", 0L, "clientName", "Unknown",
|
||||
"clientChannels", new String[]{"CC_160"});
|
||||
service.complete(task, defaults);
|
||||
return;
|
||||
}
|
||||
|
||||
SyntheticCase p = opt.get();
|
||||
Instant first = parseTs(p.getFirstAppealDate());
|
||||
Instant last = parseTs(p.getLastAppealDate());
|
||||
|
||||
Optional<UUID> existing = repo.findByProcessId(processId);
|
||||
UUID caseId = existing.orElseGet(() ->
|
||||
repo.createCase(processId, p.getClientId(), p.getClientName(), p.getClientPhone(),
|
||||
p.getTotalAppeals(), p.getAppealsOnTopic(), first, last));
|
||||
|
||||
repo.logEvent(caseId, "ENRICHED", Map.of(
|
||||
"totalAppeals", p.getTotalAppeals(),
|
||||
"appealsOnTopic", p.getAppealsOnTopic(),
|
||||
"channels", p.getChannels()
|
||||
), "AI_AGENT");
|
||||
|
||||
long days = Math.max(0, java.time.Duration.between(first, Instant.now()).toDays());
|
||||
|
||||
Map<String, Object> vars = new HashMap<>();
|
||||
vars.put("caseId", caseId.toString());
|
||||
vars.put("clientId", p.getClientId());
|
||||
vars.put("clientName", p.getClientName());
|
||||
vars.put("clientPhone", p.getClientPhone());
|
||||
vars.put("clientRegion", p.getRegion());
|
||||
vars.put("totalAppeals", p.getTotalAppeals());
|
||||
vars.put("appealsOnTopic", p.getAppealsOnTopic());
|
||||
vars.put("hasOpenTicket", p.isHasOpenTicket());
|
||||
vars.put("openTicketId", p.getOpenTicketId() == null ? "" : p.getOpenTicketId());
|
||||
vars.put("daysSinceFirst", days);
|
||||
vars.put("rawAppealText", rawText == null ? p.getRawText() : rawText);
|
||||
|
||||
service.complete(task, vars);
|
||||
}
|
||||
|
||||
private Instant parseTs(String s) {
|
||||
try {
|
||||
return LocalDateTime.parse(s).toInstant(ZoneOffset.UTC);
|
||||
} catch (Exception e) {
|
||||
return Instant.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import kz.kazakhtelecom.advocate.service.MockNotifierService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "notify-client-resolved", lockDuration = 60000)
|
||||
public class NotifyClientResolvedWorker implements ExternalTaskHandler {
|
||||
|
||||
private final MockNotifierService notifier;
|
||||
private final CaseRepository repo;
|
||||
|
||||
public NotifyClientResolvedWorker(MockNotifierService n, CaseRepository r) {
|
||||
this.notifier = n; this.repo = r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
String clientId = task.getVariable("clientId");
|
||||
String name = task.getVariable("clientName");
|
||||
String topic = task.getVariable("painCategory");
|
||||
notifier.send(clientId, "WHATSAPP", String.format(
|
||||
"Здравствуйте, %s. Вопрос по %s решён. Оцените сервис: 👍 (5) или 👎 (1).",
|
||||
name == null ? "клиент" : name, topic));
|
||||
UUID id = WorkerHelpers.caseId(task);
|
||||
if (id != null) {
|
||||
repo.recordLastNotif(id);
|
||||
repo.logEvent(id, "RESOLVED", Map.of("notified", true), "AI_AGENT");
|
||||
}
|
||||
service.complete(task);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import kz.kazakhtelecom.advocate.service.MockNotifierService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "notify-supervisor", lockDuration = 60000)
|
||||
public class NotifySupervisorWorker implements ExternalTaskHandler {
|
||||
|
||||
private final MockNotifierService notifier;
|
||||
private final CaseRepository repo;
|
||||
|
||||
public NotifySupervisorWorker(MockNotifierService n, CaseRepository r) {
|
||||
this.notifier = n; this.repo = r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
String clientId = task.getVariable("clientId");
|
||||
String priority = task.getVariable("priorityLevel");
|
||||
notifier.send("SUPERVISOR", "INTERNAL",
|
||||
String.format("YELLOW кейс clientId=%s приоритет=%s — требуется внимание", clientId, priority));
|
||||
UUID id = WorkerHelpers.caseId(task);
|
||||
if (id != null) repo.logEvent(id, "NOTIFIED",
|
||||
Map.of("recipient", "SUPERVISOR", "priority", priority), "AI_AGENT");
|
||||
service.complete(task);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import kz.kazakhtelecom.advocate.service.MockNotifierService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "personal-escalation", lockDuration = 60000)
|
||||
public class PersonalEscalationWorker implements ExternalTaskHandler {
|
||||
|
||||
private final MockNotifierService notifier;
|
||||
private final CaseRepository repo;
|
||||
|
||||
public PersonalEscalationWorker(MockNotifierService n, CaseRepository r) {
|
||||
this.notifier = n; this.repo = r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
String clientId = task.getVariable("clientId");
|
||||
String clientName = task.getVariable("clientName");
|
||||
notifier.send(clientId, "PERSONAL_MANAGER",
|
||||
"Назначен персональный менеджер для VIP/BLACK кейса " +
|
||||
(clientName == null ? clientId : clientName));
|
||||
UUID id = WorkerHelpers.caseId(task);
|
||||
if (id != null) {
|
||||
repo.updateStatus(id, "ESCALATED");
|
||||
repo.logEvent(id, "ESCALATED",
|
||||
Map.of("level", "BLACK", "assignedTo", "PERSONAL_MANAGER"), "AI_AGENT");
|
||||
}
|
||||
service.complete(task);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import kz.kazakhtelecom.advocate.service.MockCRMService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "prioritize-queue", lockDuration = 60000)
|
||||
public class PrioritizeQueueWorker implements ExternalTaskHandler {
|
||||
|
||||
private final MockCRMService crm;
|
||||
private final CaseRepository repo;
|
||||
|
||||
public PrioritizeQueueWorker(MockCRMService c, CaseRepository r) {
|
||||
this.crm = c; this.repo = r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
String clientId = task.getVariable("clientId");
|
||||
String ticket = crm.createTask(clientId, "Приоритизация в очереди (YELLOW)");
|
||||
UUID id = WorkerHelpers.caseId(task);
|
||||
if (id != null) {
|
||||
repo.updateRouting(id, "CC_OPERATOR", ticket);
|
||||
repo.logEvent(id, "ROUTED",
|
||||
Map.of("routedTo", "CC_OPERATOR", "ticketId", ticket, "queuePriority", "HIGH"), "AI_AGENT");
|
||||
}
|
||||
service.complete(task, Map.of("routedTo", "CC_OPERATOR", "routedTicketId", ticket));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import kz.kazakhtelecom.advocate.service.MockNotifierService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "proactive-contact", lockDuration = 60000)
|
||||
public class ProactiveContactWorker implements ExternalTaskHandler {
|
||||
|
||||
private final MockNotifierService notifier;
|
||||
private final CaseRepository repo;
|
||||
|
||||
public ProactiveContactWorker(MockNotifierService n, CaseRepository r) {
|
||||
this.notifier = n; this.repo = r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
String name = strVar(task, "clientName", "клиент");
|
||||
String ticket = strVar(task, "routedTicketId", "-");
|
||||
String topic = strVar(task, "painCategory", "вопрос");
|
||||
String channel = "WHATSAPP";
|
||||
String message = String.format(
|
||||
"Здравствуйте, %s. Ваша заявка №%s по вопросу %s в работе. " +
|
||||
"Специалист свяжется с вами до конца дня. Если вопрос решён — ответьте «Да».",
|
||||
name, ticket, topic);
|
||||
notifier.send(strVar(task, "clientId", "?"), channel, message);
|
||||
UUID id = WorkerHelpers.caseId(task);
|
||||
if (id != null) {
|
||||
repo.recordLastNotif(id);
|
||||
repo.logEvent(id, "NOTIFIED",
|
||||
Map.of("type", "PROACTIVE", "channel", channel, "ticketId", ticket), "AI_AGENT");
|
||||
}
|
||||
service.complete(task, Map.of("lastClientNotif", Instant.now().toString()));
|
||||
}
|
||||
|
||||
private String strVar(ExternalTask t, String k, String def) {
|
||||
Object v = t.getVariable(k);
|
||||
return v == null ? def : v.toString();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "reopen-case", lockDuration = 60000)
|
||||
public class ReopenCaseWorker implements ExternalTaskHandler {
|
||||
|
||||
private final CaseRepository repo;
|
||||
|
||||
public ReopenCaseWorker(CaseRepository r) { this.repo = r; }
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
UUID id = WorkerHelpers.caseId(task);
|
||||
if (id != null) {
|
||||
repo.updateStatus(id, "REOPENED");
|
||||
repo.logEvent(id, "REOPENED", Map.of("reason", "CSAT_NEGATIVE"), "AI_AGENT");
|
||||
}
|
||||
log.info("Reopen caseId={} — bumping priority", id);
|
||||
service.complete(task, Map.of("reopened", true));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import kz.kazakhtelecom.advocate.db.CaseRepository;
|
||||
import kz.kazakhtelecom.advocate.service.MockCRMService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
import org.camunda.bpm.client.task.ExternalTaskHandler;
|
||||
import org.camunda.bpm.client.task.ExternalTaskService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ExternalTaskSubscription(topicName = "standard-handling", lockDuration = 60000)
|
||||
public class StandardHandlingWorker implements ExternalTaskHandler {
|
||||
|
||||
private final MockCRMService crm;
|
||||
private final CaseRepository repo;
|
||||
|
||||
public StandardHandlingWorker(MockCRMService crm, CaseRepository repo) {
|
||||
this.crm = crm;
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(ExternalTask task, ExternalTaskService service) {
|
||||
String clientId = task.getVariable("clientId");
|
||||
String ticket = crm.createTask(clientId, "Стандартная обработка GREEN-кейса");
|
||||
UUID id = WorkerHelpers.caseId(task);
|
||||
if (id != null) {
|
||||
repo.updateRouting(id, "CC_OPERATOR", ticket);
|
||||
repo.logEvent(id, "ROUTED", Map.of("routedTo", "CC_OPERATOR", "ticketId", ticket), "AI_AGENT");
|
||||
}
|
||||
service.complete(task, Map.of("routedTo", "CC_OPERATOR", "routedTicketId", ticket));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package kz.kazakhtelecom.advocate.worker;
|
||||
|
||||
import org.camunda.bpm.client.task.ExternalTask;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
final class WorkerHelpers {
|
||||
private WorkerHelpers() {}
|
||||
|
||||
static UUID caseId(ExternalTask task) {
|
||||
String s = task.getVariable("caseId");
|
||||
if (s == null) return null;
|
||||
try { return UUID.fromString(s); } catch (Exception e) { return null; }
|
||||
}
|
||||
}
|
||||
34
workers/src/main/resources/application.yml
Normal file
34
workers/src/main/resources/application.yml
Normal file
@ -0,0 +1,34 @@
|
||||
server:
|
||||
port: 8082
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/advocate_db}
|
||||
username: ${SPRING_DATASOURCE_USERNAME:advocate}
|
||||
password: ${SPRING_DATASOURCE_PASSWORD:advocate}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 10
|
||||
jackson:
|
||||
serialization:
|
||||
WRITE_DATES_AS_TIMESTAMPS: false
|
||||
|
||||
camunda:
|
||||
bpm:
|
||||
client:
|
||||
base-url: ${CAMUNDA_BASE_URL:http://localhost:8080/engine-rest}
|
||||
worker-id: advocate-workers
|
||||
lock-duration: 60000
|
||||
async-response-timeout: 10000
|
||||
|
||||
advocate:
|
||||
mock-mode: ${MOCK_MODE:true}
|
||||
synthetic-data-path: ${SYNTHETIC_DATA_PATH:/data/synthetic/appeals.json}
|
||||
mock-event-interval-ms: ${MOCK_EVENT_INTERVAL_MS:30000}
|
||||
ai-service-url: ${AI_SERVICE_URL:http://localhost:8081}
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
kz.kazakhtelecom.advocate: DEBUG
|
||||
org.camunda: INFO
|
||||
Reference in New Issue
Block a user