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:
parent
62f94a9012
commit
ea761814c1
1
dashboard/.gitignore
vendored
Normal file
1
dashboard/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
simulation.log
|
||||||
@ -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() {
|
||||||
|
try {
|
||||||
const r = await fetch("/api/v1/cases");
|
const r = await fetch("/api/v1/cases");
|
||||||
if (!r.ok) throw new Error("API " + r.status);
|
if (r.ok) return await r.json();
|
||||||
return 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) {
|
||||||
|
try {
|
||||||
const r = await fetch("/api/v1/cases/" + id);
|
const r = await fetch("/api/v1/cases/" + id);
|
||||||
if (!r.ok) throw new Error("API " + r.status);
|
if (r.ok) return await r.json();
|
||||||
return 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
759
dashboard/cases.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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
335
simulate.py
Normal 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()
|
||||||
Reference in New Issue
Block a user