feat: add Python pipeline simulator + static fallback for dashboard

simulate.py воспроизводит полный pipeline без Camunda/Java:
  Kafka → EnrichProfile → ClassifyPain (AI) → priority gateway →
  route-to-department → ProactiveContact/HumanTask → CSAT → Close

Эмулирует обращения к моковым внешним системам (CRM, ASC, NetworkMon,
Finance, STPK, WhatsApp, Notifier). Выходные артефакты:

  dashboard/cases.json     — 7 кейсов детракторов в формате REST API
  dashboard/simulation.log — лог симулированных вызовов

Дашборд теперь читает cases.json как fallback когда /api/v1/cases
недоступен — даёт деплой на Gitea Pages без backend (ветка gitea-pages).
This commit is contained in:
Асель Мамажанова 2026-05-22 12:40:03 +05:00
parent 62f94a9012
commit ea761814c1
6 changed files with 1163 additions and 17 deletions

1
dashboard/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
simulation.log

View File

@ -1,26 +1,31 @@
const PRIO_ORDER = { BLACK: 0, RED: 1, YELLOW: 2, GREEN: 3, null: 4 }; const PRIO_ORDER = { BLACK: 0, RED: 1, YELLOW: 2, GREEN: 3, null: 4 };
async function fetchCases() { async function fetchCases() {
const r = await fetch("/api/v1/cases"); try {
if (!r.ok) throw new Error("API " + r.status); const r = await fetch("/api/v1/cases");
return r.json(); if (r.ok) return await r.json();
} catch (_) { /* fall through */ }
const r2 = await fetch("./cases.json");
if (!r2.ok) throw new Error("cases.json missing");
return r2.json();
} }
async function fetchCase(id) { async function fetchCase(id) {
const r = await fetch("/api/v1/cases/" + id); try {
if (!r.ok) throw new Error("API " + r.status); const r = await fetch("/api/v1/cases/" + id);
return r.json(); if (r.ok) return await r.json();
} catch (_) {}
const all = await fetchCases();
return all.find(c => c.id === id);
} }
function pillPrio(p) { function pillPrio(p) {
if (!p) return "—"; if (!p) return "—";
const label = { BLACK: "⚫", RED: "🔴", YELLOW: "🟡", GREEN: "🟢" }[p] || ""; const icon = { BLACK: "⚫", RED: "🔴", YELLOW: "🟡", GREEN: "🟢" }[p] || "";
return `<span class="badge ${p}">${label} ${p}</span>`; return `<span class="badge ${p}">${icon} ${p}</span>`;
} }
function pillStatus(s) { function pillStatus(s) { return `<span class="badge ${s}">${s}</span>`; }
return `<span class="badge ${s}">${s}</span>`;
}
function fmtNum(n) { return n == null ? "—" : n; } function fmtNum(n) { return n == null ? "—" : n; }
@ -60,14 +65,48 @@ function render(cases) {
b.addEventListener("click", () => showDetail(b.dataset.id))); b.addEventListener("click", () => showDetail(b.dataset.id)));
} }
function renderDetail(d) {
if (!d) return "Кейс не найден";
const acts = (d.actions || []).map(a => `<li>${a}</li>`).join("");
const trig = (d.triggers || []).map(t => `<code>${t}</code>`).join(" ");
const events = (d.events || []).map(e =>
`<div class="evt"><b>${e.event_type}</b> <span class="ts">${e.created_at || ""}</span><br/>` +
`<small>${JSON.stringify(e.event_data || {})}</small></div>`
).join("");
return `
<h4>${pillPrio(d.priority_level)} ${d.client_name || d.client_id}</h4>
<p class="meta">${d.client_id} · ${d.client_phone || ""} · ${d.region || ""}</p>
<div class="block">
<div class="kv"><span>Категория:</span><b>${d.pain_category || "—"}</b></div>
<div class="kv"><span>Статус:</span><b>${d.status}</b></div>
<div class="kv"><span>Маршрут:</span><b>${d.routed_to || "—"}</b></div>
<div class="kv"><span>Тикет:</span><code>${d.routed_ticket_id || "—"}</code></div>
<div class="kv"><span>Обращений:</span><b>${d.total_appeals_count}</b> (по теме: ${d.appeals_on_topic})</div>
<div class="kv"><span>Sentiment:</span><b>${Number(d.sentiment_score ?? 0).toFixed(2)}</b></div>
<div class="kv"><span>Churn risk:</span><b>${Number(d.churn_risk_score ?? 0).toFixed(2)}</b></div>
<div class="kv"><span>Дней без решения:</span><b>${d.days_unresolved}</b></div>
<div class="kv"><span>CSAT:</span><b>${d.client_csat_score ?? "—"}</b></div>
</div>
<h5>📋 Карточка оператора</h5>
<p>${d.summary || ""}</p>
<ol>${acts}</ol>
${trig ? `<p>Триггеры: ${trig}</p>` : ""}
<h5>📜 Лог процесса (${(d.events || []).length})</h5>
<div class="events">${events}</div>
`;
}
async function showDetail(id) { async function showDetail(id) {
const aside = document.getElementById("case-detail"); const aside = document.getElementById("case-detail");
const body = document.getElementById("detail-body"); const body = document.getElementById("detail-body");
body.textContent = "Загрузка…"; body.innerHTML = "Загрузка…";
aside.hidden = false; aside.hidden = false;
try { try {
const data = await fetchCase(id); const d = await fetchCase(id);
body.textContent = JSON.stringify(data, null, 2); body.innerHTML = renderDetail(d);
} catch (e) { } catch (e) {
body.textContent = "Ошибка: " + e.message; body.textContent = "Ошибка: " + e.message;
} }
@ -82,9 +121,8 @@ async function refresh() {
const cases = await fetchCases(); const cases = await fetchCases();
render(cases); render(cases);
} catch (e) { } catch (e) {
console.error(e);
document.getElementById("cases-body").innerHTML = document.getElementById("cases-body").innerHTML =
'<tr><td colspan="11" class="empty">Ошибка API: ' + e.message + '</td></tr>'; '<tr><td colspan="11" class="empty">Ошибка загрузки: ' + e.message + '</td></tr>';
} }
} }

759
dashboard/cases.json Normal file
View File

@ -0,0 +1,759 @@
[
{
"id": "b976de4b-8f75-46ad-b68d-1e56e42db4ed",
"camunda_process_id": "proc-59e7878a-ba2e-42a7-9d8d-46789bce664c",
"client_id": "KT-10000001",
"client_name": "Айгуль Серикова",
"client_phone": "+77011112233",
"pain_category": "NETWORK",
"priority_level": "BLACK",
"status": "CLOSED",
"routed_to": "NETWORK_MONITORING",
"routed_ticket_id": "NET-SIG-DC9CA1",
"sentiment_score": -1.0,
"churn_risk_score": 1.0,
"total_appeals_count": 98,
"appeals_on_topic": 15,
"days_unresolved": 491,
"client_csat_score": 4,
"region": "Алматы",
"triggers": [
"снова",
"уже",
"расторгнуть",
"суд",
"грозит",
"сто раз"
],
"actions": [
"Извиниться от лица компании",
"Эскалировать директору ДУКО",
"Назначить персонального менеджера",
"Рассмотреть компенсацию до 50 000 KZT"
],
"summary": "Клиент обращается 98 раз. Категория: NETWORK. Sentiment: -1.00. Риск оттока: 100%.",
"events": [
{
"event_type": "ENRICHED",
"event_data": {
"totalAppeals": 98,
"appealsOnTopic": 15,
"channels": [
"CC_160",
"PSS",
"OMILIA",
"WHATSAPP"
]
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.567197+00:00"
},
{
"event_type": "CLASSIFIED",
"event_data": {
"category": "NETWORK",
"priority": "BLACK",
"sentiment": -1.0,
"churn": 1.0,
"triggers": [
"снова",
"уже",
"расторгнуть",
"суд",
"грозит",
"сто раз"
]
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.567300+00:00"
},
{
"event_type": "ROUTED",
"event_data": {
"routedTo": "NETWORK_MONITORING",
"ticketId": "NET-SIG-DC9CA1"
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.567351+00:00"
},
{
"event_type": "ESCALATED",
"event_data": {
"level": "BLACK",
"assignedTo": "PERSONAL_MANAGER"
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.567448+00:00"
},
{
"event_type": "RESOLVED",
"event_data": {
"notified": true
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.567488+00:00"
},
{
"event_type": "CSAT_RECEIVED",
"event_data": {
"score": 4
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.567522+00:00"
},
{
"event_type": "CLOSED",
"event_data": {
"csat": 4
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.567553+00:00"
}
],
"created_at": "2026-05-22T07:37:30.566970+00:00",
"updated_at": "2026-05-22T07:37:30.567123+00:00",
"closed_at": "2026-05-22T07:37:30.567524+00:00"
},
{
"id": "8492e26a-5950-49ac-a627-620cda9f8aac",
"camunda_process_id": "proc-0f5f41e0-46f8-41f2-86c4-3b93f0b74dfd",
"client_id": "KT-10000006",
"client_name": "Бахытжан Омаров",
"client_phone": "+77022223344",
"pain_category": "EQUIPMENT",
"priority_level": "BLACK",
"status": "CLOSED",
"routed_to": "ASC",
"routed_ticket_id": "ASC-9831D7",
"sentiment_score": -0.6,
"churn_risk_score": 0.75,
"total_appeals_count": 59,
"appeals_on_topic": 26,
"days_unresolved": 317,
"client_csat_score": 4,
"region": "Астана",
"triggers": [
"не работает",
"перерасчёт",
"ужасно"
],
"actions": [
"Извиниться от лица компании",
"Эскалировать директору ДУКО",
"Назначить персонального менеджера",
"Рассмотреть компенсацию до 50 000 KZT"
],
"summary": "Клиент обращается 59 раз. Категория: EQUIPMENT. Sentiment: -0.60. Риск оттока: 75%.",
"events": [
{
"event_type": "ENRICHED",
"event_data": {
"totalAppeals": 59,
"appealsOnTopic": 26,
"channels": [
"OMILIA",
"TELECOM_KZ",
"WHATSAPP",
"CC_160",
"PSS"
]
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.567872+00:00"
},
{
"event_type": "CLASSIFIED",
"event_data": {
"category": "EQUIPMENT",
"priority": "BLACK",
"sentiment": -0.6,
"churn": 0.75,
"triggers": [
"не работает",
"перерасчёт",
"ужасно"
]
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.567962+00:00"
},
{
"event_type": "ROUTED",
"event_data": {
"routedTo": "ASC",
"ticketId": "ASC-9831D7"
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.568003+00:00"
},
{
"event_type": "ESCALATED",
"event_data": {
"level": "BLACK",
"assignedTo": "PERSONAL_MANAGER"
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.568091+00:00"
},
{
"event_type": "RESOLVED",
"event_data": {
"notified": true
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.568123+00:00"
},
{
"event_type": "CSAT_RECEIVED",
"event_data": {
"score": 4
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.568154+00:00"
},
{
"event_type": "CLOSED",
"event_data": {
"csat": 4
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.568191+00:00"
}
],
"created_at": "2026-05-22T07:37:30.567830+00:00",
"updated_at": "2026-05-22T07:37:30.567832+00:00",
"closed_at": "2026-05-22T07:37:30.568156+00:00"
},
{
"id": "36612de2-8cea-4cba-b468-fc0af6269941",
"camunda_process_id": "proc-80b4ea48-7c1c-4e42-932c-5e059dca0313",
"client_id": "KT-10000003",
"client_name": "Дина Қасымова",
"client_phone": "+77033334455",
"pain_category": "NETWORK",
"priority_level": "BLACK",
"status": "CLOSED",
"routed_to": "NETWORK_MONITORING",
"routed_ticket_id": "NET-SIG-94A180",
"sentiment_score": -1.0,
"churn_risk_score": 1.0,
"total_appeals_count": 46,
"appeals_on_topic": 10,
"days_unresolved": 486,
"client_csat_score": 4,
"region": "Шымкент",
"triggers": [
"не работает",
"снова",
"опять",
"жұмыс істемейді",
"шағым"
],
"actions": [
"Извиниться от лица компании",
"Эскалировать директору ДУКО",
"Назначить персонального менеджера",
"Рассмотреть компенсацию до 50 000 KZT"
],
"summary": "Клиент обращается 46 раз. Категория: NETWORK. Sentiment: -1.00. Риск оттока: 100%.",
"events": [
{
"event_type": "ENRICHED",
"event_data": {
"totalAppeals": 46,
"appealsOnTopic": 10,
"channels": [
"CC_160",
"OMILIA",
"PSS"
]
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.568486+00:00"
},
{
"event_type": "CLASSIFIED",
"event_data": {
"category": "NETWORK",
"priority": "BLACK",
"sentiment": -1.0,
"churn": 1.0,
"triggers": [
"не работает",
"снова",
"опять",
"жұмыс істемейді",
"шағым"
]
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.568573+00:00"
},
{
"event_type": "ROUTED",
"event_data": {
"routedTo": "NETWORK_MONITORING",
"ticketId": "NET-SIG-94A180"
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.568611+00:00"
},
{
"event_type": "ESCALATED",
"event_data": {
"level": "BLACK",
"assignedTo": "PERSONAL_MANAGER"
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.568691+00:00"
},
{
"event_type": "RESOLVED",
"event_data": {
"notified": true
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.568722+00:00"
},
{
"event_type": "CSAT_RECEIVED",
"event_data": {
"score": 4
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.568753+00:00"
},
{
"event_type": "CLOSED",
"event_data": {
"csat": 4
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.568782+00:00"
}
],
"created_at": "2026-05-22T07:37:30.568450+00:00",
"updated_at": "2026-05-22T07:37:30.568452+00:00",
"closed_at": "2026-05-22T07:37:30.568755+00:00"
},
{
"id": "35b1c108-013d-4f02-ab0b-f70f131e5e52",
"camunda_process_id": "proc-6b76e5e6-d6cf-46cb-bc5d-c4bdc6efd4f0",
"client_id": "KT-10000005",
"client_name": "Ерлан Жумабаев",
"client_phone": "+77044445566",
"pain_category": "INSTALLATION",
"priority_level": "BLACK",
"status": "CLOSED",
"routed_to": "STPK",
"routed_ticket_id": "STPK-88D4DF",
"sentiment_score": -0.2,
"churn_risk_score": 0.45,
"total_appeals_count": 15,
"appeals_on_topic": 9,
"days_unresolved": 458,
"client_csat_score": 4,
"region": "Караганда",
"triggers": [
"опять"
],
"actions": [
"Извиниться от лица компании",
"Эскалировать директору ДУКО",
"Назначить персонального менеджера",
"Рассмотреть компенсацию до 50 000 KZT"
],
"summary": "Клиент обращается 15 раз. Категория: INSTALLATION. Sentiment: -0.20. Риск оттока: 45%.",
"events": [
{
"event_type": "ENRICHED",
"event_data": {
"totalAppeals": 15,
"appealsOnTopic": 9,
"channels": [
"PSS",
"CC_160",
"OMILIA"
]
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.569067+00:00"
},
{
"event_type": "CLASSIFIED",
"event_data": {
"category": "INSTALLATION",
"priority": "BLACK",
"sentiment": -0.2,
"churn": 0.45,
"triggers": [
"опять"
]
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.569140+00:00"
},
{
"event_type": "ROUTED",
"event_data": {
"routedTo": "STPK",
"ticketId": "STPK-88D4DF"
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.569177+00:00"
},
{
"event_type": "ESCALATED",
"event_data": {
"level": "BLACK",
"assignedTo": "PERSONAL_MANAGER"
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.569256+00:00"
},
{
"event_type": "RESOLVED",
"event_data": {
"notified": true
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.569287+00:00"
},
{
"event_type": "CSAT_RECEIVED",
"event_data": {
"score": 4
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.569315+00:00"
},
{
"event_type": "CLOSED",
"event_data": {
"csat": 4
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.569344+00:00"
}
],
"created_at": "2026-05-22T07:37:30.569033+00:00",
"updated_at": "2026-05-22T07:37:30.569035+00:00",
"closed_at": "2026-05-22T07:37:30.569317+00:00"
},
{
"id": "cb717a9a-d9cf-43e0-a89a-1f691fa49a71",
"camunda_process_id": "proc-f0310136-23aa-410c-ad70-9ee7f03616fe",
"client_id": "KT-10000002",
"client_name": "Гульмира Тлеубай",
"client_phone": "+77055556677",
"pain_category": "BILLING",
"priority_level": "BLACK",
"status": "CLOSED",
"routed_to": "FINANCE",
"routed_ticket_id": "FIN-B29A06",
"sentiment_score": -0.2,
"churn_risk_score": 0.45,
"total_appeals_count": 22,
"appeals_on_topic": 5,
"days_unresolved": 484,
"client_csat_score": 4,
"region": "Актобе",
"triggers": [
"перерасчёт"
],
"actions": [
"Извиниться от лица компании",
"Эскалировать директору ДУКО",
"Назначить персонального менеджера",
"Рассмотреть компенсацию до 50 000 KZT"
],
"summary": "Клиент обращается 22 раз. Категория: BILLING. Sentiment: -0.20. Риск оттока: 45%.",
"events": [
{
"event_type": "ENRICHED",
"event_data": {
"totalAppeals": 22,
"appealsOnTopic": 5,
"channels": [
"CC_160",
"WHATSAPP",
"OMILIA",
"PSS"
]
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.569621+00:00"
},
{
"event_type": "CLASSIFIED",
"event_data": {
"category": "BILLING",
"priority": "BLACK",
"sentiment": -0.2,
"churn": 0.45,
"triggers": [
"перерасчёт"
]
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.569694+00:00"
},
{
"event_type": "ROUTED",
"event_data": {
"routedTo": "FINANCE",
"ticketId": "FIN-B29A06"
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.569730+00:00"
},
{
"event_type": "ESCALATED",
"event_data": {
"level": "BLACK",
"assignedTo": "PERSONAL_MANAGER"
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.569808+00:00"
},
{
"event_type": "RESOLVED",
"event_data": {
"notified": true
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.569837+00:00"
},
{
"event_type": "CSAT_RECEIVED",
"event_data": {
"score": 4
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.569865+00:00"
},
{
"event_type": "CLOSED",
"event_data": {
"csat": 4
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.569904+00:00"
}
],
"created_at": "2026-05-22T07:37:30.569589+00:00",
"updated_at": "2026-05-22T07:37:30.569591+00:00",
"closed_at": "2026-05-22T07:37:30.569867+00:00"
},
{
"id": "1266d99c-de77-4a42-b4a6-dfb24d6c37d6",
"camunda_process_id": "proc-50362431-4e08-49d3-b2f9-7762057b62a7",
"client_id": "KT-10000004",
"client_name": "Жанар Алиева",
"client_phone": "+77066667788",
"pain_category": "EQUIPMENT",
"priority_level": "BLACK",
"status": "CLOSED",
"routed_to": "ASC",
"routed_ticket_id": "ASC-E2258C",
"sentiment_score": -0.2,
"churn_risk_score": 0.45,
"total_appeals_count": 22,
"appeals_on_topic": 5,
"days_unresolved": 451,
"client_csat_score": 4,
"region": "Павлодар",
"triggers": [
"безобразие"
],
"actions": [
"Извиниться от лица компании",
"Эскалировать директору ДУКО",
"Назначить персонального менеджера",
"Рассмотреть компенсацию до 50 000 KZT"
],
"summary": "Клиент обращается 22 раз. Категория: EQUIPMENT. Sentiment: -0.20. Риск оттока: 45%.",
"events": [
{
"event_type": "ENRICHED",
"event_data": {
"totalAppeals": 22,
"appealsOnTopic": 5,
"channels": [
"CC_160",
"WHATSAPP",
"OMILIA",
"PSS",
"TELECOM_KZ"
]
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.570173+00:00"
},
{
"event_type": "CLASSIFIED",
"event_data": {
"category": "EQUIPMENT",
"priority": "BLACK",
"sentiment": -0.2,
"churn": 0.45,
"triggers": [
"безобразие"
]
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.570242+00:00"
},
{
"event_type": "ROUTED",
"event_data": {
"routedTo": "ASC",
"ticketId": "ASC-E2258C"
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.570286+00:00"
},
{
"event_type": "ESCALATED",
"event_data": {
"level": "BLACK",
"assignedTo": "PERSONAL_MANAGER"
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.570367+00:00"
},
{
"event_type": "RESOLVED",
"event_data": {
"notified": true
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.570396+00:00"
},
{
"event_type": "CSAT_RECEIVED",
"event_data": {
"score": 4
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.570424+00:00"
},
{
"event_type": "CLOSED",
"event_data": {
"csat": 4
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.570456+00:00"
}
],
"created_at": "2026-05-22T07:37:30.570142+00:00",
"updated_at": "2026-05-22T07:37:30.570143+00:00",
"closed_at": "2026-05-22T07:37:30.570426+00:00"
},
{
"id": "368057f7-429e-42ed-934e-f86e8bdcb269",
"camunda_process_id": "proc-7a365164-9f22-42cc-aa1a-512ead4c1760",
"client_id": "KT-10000007",
"client_name": "Ислам Нурланов",
"client_phone": "+77077778899",
"pain_category": "SERVICE",
"priority_level": "BLACK",
"status": "CLOSED",
"routed_to": "CC_OPERATOR",
"routed_ticket_id": "MOCK-CRM-2D7315",
"sentiment_score": -0.2,
"churn_risk_score": 0.45,
"total_appeals_count": 21,
"appeals_on_topic": 9,
"days_unresolved": 480,
"client_csat_score": 4,
"region": "Усть-Каменогорск",
"triggers": [
"не работает"
],
"actions": [
"Извиниться от лица компании",
"Эскалировать директору ДУКО",
"Назначить персонального менеджера",
"Рассмотреть компенсацию до 50 000 KZT"
],
"summary": "Клиент обращается 21 раз. Категория: SERVICE. Sentiment: -0.20. Риск оттока: 45%.",
"events": [
{
"event_type": "ENRICHED",
"event_data": {
"totalAppeals": 21,
"appealsOnTopic": 9,
"channels": [
"CC_160",
"WHATSAPP",
"OMILIA",
"PSS"
]
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.570736+00:00"
},
{
"event_type": "CLASSIFIED",
"event_data": {
"category": "SERVICE",
"priority": "BLACK",
"sentiment": -0.2,
"churn": 0.45,
"triggers": [
"не работает"
]
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.570804+00:00"
},
{
"event_type": "ROUTED",
"event_data": {
"routedTo": "CC_OPERATOR",
"ticketId": "MOCK-CRM-2D7315"
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.570840+00:00"
},
{
"event_type": "ESCALATED",
"event_data": {
"level": "BLACK",
"assignedTo": "PERSONAL_MANAGER"
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.570916+00:00"
},
{
"event_type": "RESOLVED",
"event_data": {
"notified": true
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.570945+00:00"
},
{
"event_type": "CSAT_RECEIVED",
"event_data": {
"score": 4
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.570972+00:00"
},
{
"event_type": "CLOSED",
"event_data": {
"csat": 4
},
"actor": "AI_AGENT",
"created_at": "2026-05-22T07:37:30.571000+00:00"
}
],
"created_at": "2026-05-22T07:37:30.570704+00:00",
"updated_at": "2026-05-22T07:37:30.570706+00:00",
"closed_at": "2026-05-22T07:37:30.570974+00:00"
}
]

View File

@ -51,7 +51,7 @@
</aside> </aside>
<footer> <footer>
Polling каждые 5 сек · Camunda: <a href="http://localhost:8080" target="_blank">localhost:8080</a> · API: <a href="/api/v1/cases">/api/v1/cases</a> Polling каждые 5 сек · Источник: <a href="./cases.json" target="_blank">cases.json</a> или <a href="/api/v1/cases">/api/v1/cases</a> · <a href="./simulation.log" target="_blank">simulation.log</a>
</footer> </footer>
<script src="app.js"></script> <script src="app.js"></script>

View File

@ -37,6 +37,19 @@ 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 { 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 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; } aside pre { background: #0f172a; padding: 12px; border-radius: 4px; font-size: 11px; overflow: auto; white-space: pre-wrap; word-break: break-all; }
aside h4 { margin: 0 0 4px; }
aside h5 { margin: 18px 0 6px; font-size: 13px; opacity: 0.8; text-transform: uppercase; letter-spacing: 0.5px; }
aside .meta { font-size: 12px; opacity: 0.7; margin: 0 0 14px; }
aside .block { background: #0f172a; border-radius: 6px; padding: 12px; margin-bottom: 12px; }
aside .kv { display: flex; justify-content: space-between; padding: 3px 0; font-size: 12px; }
aside .kv span { opacity: 0.6; }
aside ol { padding-left: 20px; font-size: 13px; }
aside ol li { margin-bottom: 4px; }
aside code { background: #0f172a; padding: 2px 6px; border-radius: 3px; font-size: 11px; }
aside .events { max-height: 50vh; overflow-y: auto; }
aside .evt { background: #0f172a; border-left: 3px solid #38bdf8; padding: 8px 10px; margin-bottom: 6px; font-size: 12px; border-radius: 3px; }
aside .evt .ts { font-size: 10px; opacity: 0.4; }
aside .evt small { color: #94a3b8; }
#close-detail { float: right; background: none; color: #e2e8f0; border: 1px solid #475569; border-radius: 4px; padding: 4px 10px; cursor: pointer; } #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 { padding: 12px 32px; font-size: 12px; opacity: 0.6; border-top: 1px solid #1e293b; }

335
simulate.py Normal file
View File

@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""
Симулятор полного pipeline «Адвокат клиента» (Iter 1, MVP).
Эмулирует обращения к внешним системам (CRM, ASC, Network Monitoring,
Finance, STPK, WhatsApp, SMS) через моки, проходит все 7 кейсов
детракторов из data/synthetic/appeals.json через BPMN-процесс
client-advocate-main и записывает результат в:
dashboard/cases.json — массив кейсов (формат /api/v1/cases)
dashboard/simulation.log — лог симулированных вызовов
Запуск:
python3 simulate.py
Логика идентична Java-воркерам в workers/src/main/java/.../worker/*.
"""
from __future__ import annotations
import json
import logging
import os
import sys
import uuid
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).parent
APPEALS = ROOT / "data" / "synthetic" / "appeals.json"
OUT_DIR = ROOT / "dashboard"
CASES_OUT = OUT_DIR / "cases.json"
LOG_OUT = OUT_DIR / "simulation.log"
NEGATIVE_KEYWORDS = [
"не работает", "снова", "опять", "уже", "расторгнуть",
"другой оператор", "суд", "жалоба", "перерасчёт", "перерасчет",
"безобразие", "ужасно", "плохо", "грозит", "сто раз",
"жұмыс істемейді", "қайтадан", "шағым",
]
CHURN_SIGNALS = ["расторгнуть", "другой оператор", "суд", "грозит"]
CATEGORY_RULES = {
"NETWORK": ["интернет", "скорость", "сигнал", "wifi", "байланыс", "сеть"],
"EQUIPMENT": ["роутер", "приставка", "stb", "id tv", "ктв", "tv+", "устройство", "тв", "оборудование"],
"BILLING": ["начисление", "оплата", "перерасчёт", "перерасчет", "баланс", "тариф", "лимит", "счёт", "счет"],
"INSTALLATION": ["монтаж", "выезд", "специалист", "подключение", "визит"],
"SERVICE": ["личный кабинет", "услуга", "vas", "пароль", "заявка"],
}
# ─── logging ──────────────────────────────────────────────────────────
LOG_OUT.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-5s | %(message)s",
handlers=[
logging.FileHandler(LOG_OUT, mode="w", encoding="utf-8"),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger("advocate-sim")
# ─── AI service (ai-service/app/main.py port) ─────────────────────────
def classify(text: str) -> dict:
t = (text or "").lower()
triggers = [kw for kw in NEGATIVE_KEYWORDS if kw in t]
sentiment = max(-1.0, -0.2 * len(triggers))
churn_signal = any(s in t 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, kws in CATEGORY_RULES.items():
for kw in kws:
if kw in t:
scores[cat] += 1
category = max(scores, key=scores.get) if max(scores.values()) > 0 else "SERVICE"
return {
"category": category,
"sentiment": round(sentiment, 2),
"churn_risk": round(churn_risk, 2),
"churn_signal": churn_signal,
"triggers": triggers,
}
# ─── priority resolver (ClassifyPainWorker.java port) ─────────────────
def resolve_priority(total: int, sentiment: float, open_ticket: bool, churn_signal: bool) -> str:
if total >= 10 or churn_signal:
return "BLACK"
if total >= 5 or (open_ticket and total >= 3):
return "RED"
if total >= 3 or sentiment < -0.5:
return "YELLOW"
return "GREEN"
# ─── routing (route-to-department.bpmn) ───────────────────────────────
DEPT_MAP = {
"NETWORK": ("NETWORK_MONITORING", "NET-SIG"),
"EQUIPMENT": ("ASC", "ASC"),
"BILLING": ("FINANCE", "FIN"),
"INSTALLATION": ("STPK", "STPK"),
"SERVICE": ("CC_OPERATOR", "MOCK-CRM"),
}
def mock_id(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:6].upper()}"
# ─── recommendation actions (ClassifyPainWorker.recommendedActions) ──
def recommended_actions(priority: str, category: str) -> list[str]:
dept = {
"NETWORK": "Удалённый мониторинг сети",
"EQUIPMENT": "ASC",
"BILLING": "Финансовый отдел",
"INSTALLATION": "СТПК/ЦВЛТПК",
"SERVICE": "КЦ",
}.get(category, "КЦ")
if priority == "BLACK":
return [
"Извиниться от лица компании",
"Эскалировать директору ДУКО",
"Назначить персонального менеджера",
"Рассмотреть компенсацию до 50 000 KZT",
]
if priority == "RED":
return [
f"Эскалировать заявку как СРОЧНУЮ в {dept}",
"Проактивный контакт через WhatsApp/SMS",
"Контроль до закрытия заявки",
"Рассмотреть перерасчёт за дни простоя",
]
if priority == "YELLOW":
return [
"Уведомить супервайзера",
"Поставить кейс в приоритетную очередь",
"Мониторить SLA каждые 4 часа",
]
return ["Стандартная обработка"]
@dataclass
class CaseRecord:
id: str
camunda_process_id: str
client_id: str
client_name: str
client_phone: str
pain_category: str | None = None
priority_level: str | None = None
status: str = "OPEN"
routed_to: str | None = None
routed_ticket_id: str | None = None
sentiment_score: float | None = None
churn_risk_score: float | None = None
total_appeals_count: int = 0
appeals_on_topic: int = 0
days_unresolved: int = 0
client_csat_score: int | None = None
region: str | None = None
triggers: list[str] = field(default_factory=list)
actions: list[str] = field(default_factory=list)
summary: str = ""
events: list[dict] = field(default_factory=list)
created_at: str = ""
updated_at: str = ""
closed_at: str | None = None
def event(self, etype: str, data: dict | None = None, actor: str = "AI_AGENT"):
self.events.append({
"event_type": etype,
"event_data": data or {},
"actor": actor,
"created_at": datetime.now(timezone.utc).isoformat(),
})
# ─── main pipeline ────────────────────────────────────────────────────
def process(case_data: dict) -> CaseRecord:
cid = case_data["clientId"]
log.info("─" * 70)
log.info("[KAFKA ] appeal.created clientId=%s source=%s",
cid, case_data["channels"][0] if case_data["channels"] else "?")
log.info("[EVT RT ] Start Camunda process client-advocate-main for clientId=%s", cid)
pid = f"proc-{uuid.uuid4()}"
first = case_data["firstAppealDate"]
last = case_data["lastAppealDate"]
days_unresolved = (
datetime.now() - datetime.fromisoformat(first)
).days
rec = CaseRecord(
id=str(uuid.uuid4()),
camunda_process_id=pid,
client_id=cid,
client_name=case_data["clientName"],
client_phone=case_data["clientPhone"],
total_appeals_count=case_data["totalAppeals"],
appeals_on_topic=case_data["appealsOnTopic"],
days_unresolved=days_unresolved,
region=case_data["region"],
created_at=datetime.now(timezone.utc).isoformat(),
updated_at=datetime.now(timezone.utc).isoformat(),
)
# 1) EnrichProfileWorker
log.info("[ENRICH ] CRM.getClientProfile(%s) → name=%s totalAppeals=%d hasOpenTicket=%s",
cid, case_data["clientName"], case_data["totalAppeals"], case_data["hasOpenTicket"])
rec.event("ENRICHED", {
"totalAppeals": case_data["totalAppeals"],
"appealsOnTopic": case_data["appealsOnTopic"],
"channels": case_data["channels"],
})
# 2) ClassifyPainWorker → AI service
cls = classify(case_data["rawText"])
priority = resolve_priority(
case_data["totalAppeals"], cls["sentiment"],
case_data["hasOpenTicket"], cls["churn_signal"]
)
log.info("[AI ] classify text → category=%s sentiment=%.2f churn=%.2f signal=%s",
cls["category"], cls["sentiment"], cls["churn_risk"], cls["churn_signal"])
log.info("[CLASS ] priority=%s (total=%d openTicket=%s churnSignal=%s)",
priority, case_data["totalAppeals"],
case_data["hasOpenTicket"], cls["churn_signal"])
rec.pain_category = cls["category"]
rec.priority_level = priority
rec.sentiment_score = cls["sentiment"]
rec.churn_risk_score = cls["churn_risk"]
rec.triggers = cls["triggers"]
rec.summary = (
f"Клиент обращается {case_data['totalAppeals']} раз. "
f"Категория: {cls['category']}. Sentiment: {cls['sentiment']:.2f}. "
f"Риск оттока: {cls['churn_risk'] * 100:.0f}%."
)
rec.actions = recommended_actions(priority, cls["category"])
rec.event("CLASSIFIED", {
"category": cls["category"],
"priority": priority,
"sentiment": cls["sentiment"],
"churn": cls["churn_risk"],
"triggers": cls["triggers"],
})
# 3) Priority gateway → branch
if priority == "GREEN":
ticket = mock_id("MOCK-CRM")
log.info("[CRM ] createTask(clientId=%s, 'Стандартная обработка') → %s", cid, ticket)
rec.routed_to = "CC_OPERATOR"
rec.routed_ticket_id = ticket
rec.status = "IN_PROGRESS"
rec.event("ROUTED", {"routedTo": "CC_OPERATOR", "ticketId": ticket})
elif priority == "YELLOW":
log.info("[NOTIFY ] →SUPERVISOR YELLOW кейс clientId=%s", cid)
ticket = mock_id("MOCK-CRM")
log.info("[CRM ] prioritize-queue → %s (queuePriority=HIGH)", ticket)
rec.routed_to = "CC_OPERATOR"
rec.routed_ticket_id = ticket
rec.status = "IN_PROGRESS"
rec.event("NOTIFIED", {"recipient": "SUPERVISOR"})
rec.event("ROUTED", {"routedTo": "CC_OPERATOR", "ticketId": ticket, "queuePriority": "HIGH"})
log.info("[TIMER ] SLA Monitor 4h armed (PT4H)")
elif priority in ("RED", "BLACK"):
# 3a) route-to-department
dept, prefix = DEPT_MAP[cls["category"]]
ticket = mock_id(prefix)
log.info("[%s] create-%s-ticket clientId=%s priority=%s → %s",
dept[:7].ljust(7), dept.lower(), cid, priority, ticket)
rec.routed_to = dept
rec.routed_ticket_id = ticket
rec.status = "IN_PROGRESS"
rec.event("ROUTED", {"routedTo": dept, "ticketId": ticket})
if priority == "RED":
# 3b) proactive contact
msg = (f"Здравствуйте, {case_data['clientName']}. Ваша заявка №{ticket} "
f"по вопросу {cls['category']} в работе. Специалист свяжется "
"с вами до конца дня. Если вопрос решён — ответьте «Да».")
log.info("[NOTIFY ] →%s via WHATSAPP: %s", cid, msg[:80] + "...")
rec.event("NOTIFIED", {"type": "PROACTIVE", "channel": "WHATSAPP", "ticketId": ticket})
# 3c) control until close
log.info("[CTRL ] Controlling ticket %s until close (mock: closed in 500ms)", ticket)
rec.event("CONTROLLED", {"ticketId": ticket, "result": "CLOSED_OK"})
else: # BLACK
log.info("[HUMAN ] ⏸ HumanTask: эскалация директору ДУКО (candidate-group=duko-directors)")
log.info("[HUMAN ] ✓ HumanTask завершён (имитация подтверждения)")
log.info("[NOTIFY ] →%s PERSONAL_MANAGER assigned for VIP/BLACK %s",
cid, case_data['clientName'])
rec.status = "ESCALATED"
rec.event("ESCALATED", {"level": "BLACK", "assignedTo": "PERSONAL_MANAGER"})
# 4) Notify resolved
log.info("[NOTIFY ] →%s via WHATSAPP: «Вопрос решён, оцените сервис 👍/👎»", cid)
rec.event("RESOLVED", {"notified": True})
# 5) CSAT check — assume satisfied
csat = 5 if priority in ("GREEN", "YELLOW") else 4
log.info("[CSAT ] receive score=%d", csat)
rec.event("CSAT_RECEIVED", {"score": csat})
# 6) Close
rec.status = "CLOSED"
rec.client_csat_score = csat
rec.closed_at = datetime.now(timezone.utc).isoformat()
log.info("[CLOSE ] caseId=%s csat=%d", rec.id, csat)
rec.event("CLOSED", {"csat": csat})
return rec
def main():
if not APPEALS.exists():
log.error("appeals.json not found: %s", APPEALS)
sys.exit(1)
data = json.loads(APPEALS.read_text(encoding="utf-8"))
cases = data.get("cases", [])
log.info("Loaded %d synthetic cases", len(cases))
results = []
for c in cases:
results.append(asdict(process(c)))
OUT_DIR.mkdir(parents=True, exist_ok=True)
CASES_OUT.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
log.info("─" * 70)
log.info("Wrote %d cases → %s", len(results), CASES_OUT)
log.info("Simulation log → %s", LOG_OUT)
if __name__ == "__main__":
main()