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:
Асель Мамажанова 2026-05-22 12:30:06 +05:00
parent 9a822bb869
commit 62f94a9012
54 changed files with 4227 additions and 18 deletions

416
PRD.md
View File

@ -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)*

View File

@ -1,26 +1,83 @@
# amamazhanova
# Адвокат клиента — MVP (Iteration 1)
Привет, Асель Мамажанова!
Реализация TRD.md, Итерация 1 (MVP).
Это твоя личная папка в среде telecom.quest.
- VS Code в браузере: https://code.telecom.quest/?folder=/Users/telecom/dev/_hack22may/amamazhanova/
- Бэкап на Gitea: https://git.telecom.quest/kt-innovators/amamazhanova
Стек: Camunda 7.21 · Spring Boot 3 (workers) · PostgreSQL 15 · FastAPI (AI) · Nginx static (dashboard) · всё в Docker Compose.
## Action Plan (как пользоваться)
## Что внутри
В этой папке уже лежат три стартовых файла:
- `PRD.md` — здесь ты простыми словами описываешь, что хочешь автоматизировать
- `GITEA.md` — справочник по системе Gitea (для Claude, не для тебя)
- `TRD.md` — пустой; Claude заполнит его после того, как ты напишешь PRD
| Каталог | Что |
|---------|-----|
| `bpmn/` | `client-advocate-main.bpmn`, `route-to-department.bpmn` |
| `sql/` | DDL для PostgreSQL + создание camunda БД |
| `data/synthetic/appeals.json` | 7 синтетических детракторов из PRD §1.2 |
| `workers/` | Spring Boot — все Iter-1 воркеры + REST API на :8082 |
| `ai-service/` | FastAPI — keyword-based sentiment + classifier на :8081 |
| `dashboard/` | Статический HTML/JS дашборд на :3000 |
**Шаг 1.** Открой `PRD.md` и напиши свою идею.
**Шаг 2.** Открой Claude (Desktop или web). Дай ему `PRD.md` + `GITEA.md` и попроси: «Сделай TRD.md на основе PRD и GITEA».
**Шаг 3.** Сохрани результат в `TRD.md`.
**Шаг 4.** Запусти Claude Code в этой папке. Скажи: «Реализуй TRD.md». Получишь рабочий MVP.
## Production URLs (Traefik)
## Как сохранять работу
После push в `main` сервер автоматически поднимает стек через `docker-compose.yml`.
В VS Code открой панель "Source Control" (слева), напиши краткое описание, нажми галочку → "Commit and Push".
Изменения сохранятся в Gitea и будут доступны при следующем входе.
- Дашборд (UI + API): https://advocate.telecom.quest
- Camunda Cockpit: https://advocate-camunda.telecom.quest/camunda (логин `demo/demo`)
- Workers REST: https://advocate.telecom.quest/api/v1/cases *(проксируется через nginx дашборда)*
В этой папке уже инициализирован git и подключен remote `origin` → Gitea.
Внутренние сервисы (postgres, workers, ai-service) наружу не выставлены — только через Traefik роутеры.
Внешняя Traefik-сеть называется `traefik`. Если у вас другое имя — поправьте `networks.traefik.name` и `traefik.docker.network` лейблы в `docker-compose.yml`.
## Локальный запуск
Для запуска без Traefik (без `ports:` теперь по умолчанию недоступно с хоста), создайте `docker-compose.override.yml`:
```yaml
services:
dashboard: { ports: ["3000:80"] }
camunda: { ports: ["8080:8080"] }
workers: { ports: ["8082:8082"] }
postgres: { ports: ["5432:5432"] }
```
Затем:
```bash
docker compose up --build
```
`MockEventProducer` ждёт 30 секунд после старта, затем каждые 30 секунд запускает по одному из 7 кейсов через Camunda REST.
## Что увидите
| Кейс | Клиент | Обращений | Ожидаемый приоритет | Маршрут |
|------|--------|-----------|---------------------|---------|
| №1 | KT-10000001 | 98 | ⚫ BLACK | Human Task → personal escalation |
| №6 | KT-10000006 | 59 | ⚫ BLACK | Human Task |
| №3 | KT-10000003 | 46 | ⚫ BLACK | Human Task |
| №5 | KT-10000005 | 15 | ⚫ BLACK | Human Task |
| №2 | KT-10000002 | 22 | ⚫ BLACK | Human Task |
| №4 | KT-10000004 | 22 | ⚫ BLACK | Human Task |
| №7 | KT-10000007 | 21 | ⚫ BLACK | Human Task |
BLACK кейсы блокируются на Human Task — открой Camunda Tasklist (http://localhost:8080/camunda/app/tasklist) с логином `demo/demo` и подтверди их вручную (требование PRD §5).
YELLOW кейсы пауза 4 часа на SLA timer (TRD §4.3) — для демо считать корректным.
## API
```
GET /api/v1/cases — список всех кейсов
GET /api/v1/cases/{id} — кейс + рекомендации + лог событий
POST /api/v1/cases — создать кейс вручную
POST /api/v1/cases/{id}/csat — принять CSAT-ответ
```
## Дальше (по TRD §10)
- Iter 2 — реальные интеграции (CRM/ASC/WhatsApp/Kafka)
- Iter 3 — AI-слой (BERT sentiment, LLM рекомендации, churn-модель)
- Iter 4 — Kubernetes + нагрузочное тестирование
---
В этой папке инициализирован git и подключен remote `origin` → Gitea. См. `GITEA.md`.

1156
TRD.md

File diff suppressed because it is too large Load Diff

7
ai-service/Dockerfile Normal file
View 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"]

View File

83
ai-service/app/main.py Normal file
View 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,
}

View File

@ -0,0 +1,3 @@
fastapi==0.110.0
uvicorn[standard]==0.27.1
pydantic==2.6.3

View 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 &lt; 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,4 @@
target/
.idea/
*.iml
.git/

12
workers/Dockerfile Normal file
View 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
View 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>

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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());
});
}
}
}

View File

@ -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";
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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) {}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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();
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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));
}
}

View File

@ -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();
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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; }
}
}

View 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