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.
|
Стек: Camunda 7.21 · Spring Boot 3 (workers) · PostgreSQL 15 · FastAPI (AI) · Nginx static (dashboard) · всё в Docker Compose.
|
||||||
- VS Code в браузере: https://code.telecom.quest/?folder=/Users/telecom/dev/_hack22may/amamazhanova/
|
|
||||||
- Бэкап на Gitea: https://git.telecom.quest/kt-innovators/amamazhanova
|
|
||||||
|
|
||||||
## Action Plan (как пользоваться)
|
## Что внутри
|
||||||
|
|
||||||
В этой папке уже лежат три стартовых файла:
|
| Каталог | Что |
|
||||||
- `PRD.md` — здесь ты простыми словами описываешь, что хочешь автоматизировать
|
|---------|-----|
|
||||||
- `GITEA.md` — справочник по системе Gitea (для Claude, не для тебя)
|
| `bpmn/` | `client-advocate-main.bpmn`, `route-to-department.bpmn` |
|
||||||
- `TRD.md` — пустой; Claude заполнит его после того, как ты напишешь PRD
|
| `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` и напиши свою идею.
|
## Production URLs (Traefik)
|
||||||
**Шаг 2.** Открой Claude (Desktop или web). Дай ему `PRD.md` + `GITEA.md` и попроси: «Сделай TRD.md на основе PRD и GITEA».
|
|
||||||
**Шаг 3.** Сохрани результат в `TRD.md`.
|
|
||||||
**Шаг 4.** Запусти Claude Code в этой папке. Скажи: «Реализуй TRD.md». Получишь рабочий MVP.
|
|
||||||
|
|
||||||
## Как сохранять работу
|
После push в `main` сервер автоматически поднимает стек через `docker-compose.yml`.
|
||||||
|
|
||||||
В VS Code открой панель "Source Control" (слева), напиши краткое описание, нажми галочку → "Commit and Push".
|
- Дашборд (UI + API): https://advocate.telecom.quest
|
||||||
Изменения сохранятся в Gitea и будут доступны при следующем входе.
|
- 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