diff --git a/services/routing_service/app.py b/services/routing_service/app.py
index f49bc29..dc16848 100644
--- a/services/routing_service/app.py
+++ b/services/routing_service/app.py
@@ -12,6 +12,7 @@ from services.shared.models import (
AgentCreate,
AgentPoolOut,
AgentStatusUpdateIn,
+ EscalationOut,
HealthResponse,
QueueCreate,
QueueOut,
@@ -20,7 +21,7 @@ from services.shared.models import (
)
from services.shared.security import require_roles
from services.shared.sql_init import init_sql_schema
-from services.shared.sql_models import AgentRow, IvrSessionRow, Queue, RoutingCounter
+from services.shared.sql_models import AgentRow, EscalationRow, IvrSessionRow, Queue, RoutingCounter
app = FastAPI(title="routing-service", version="1.1.0")
@@ -328,6 +329,43 @@ def update_agent_status(
session.close()
+def _escalation_to_out(row: EscalationRow) -> EscalationOut:
+ return EscalationOut(
+ escalation_id=row.escalation_id,
+ call_id=row.call_id,
+ tenant_id=row.tenant_id,
+ from_level=row.from_level,
+ to_level=row.to_level,
+ reason_code=row.reason_code,
+ required_skills=routing_engine.parse_list_json(row.required_skills_json),
+ priority=row.priority,
+ topic=row.topic,
+ summary=row.summary,
+ status=row.status,
+ assigned_agent_id=row.assigned_agent_id,
+ requested_at=row.requested_at,
+ connected_at=row.connected_at,
+ completed_at=row.completed_at,
+ )
+
+
+@app.get("/escalations", response_model=list[EscalationOut])
+def list_escalations(
+ status: str | None = None,
+ limit: int = 50,
+ _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
+) -> list[EscalationOut]:
+ session = get_session()
+ try:
+ stmt = select(EscalationRow).order_by(EscalationRow.id.desc()).limit(max(1, min(limit, 200)))
+ if status:
+ stmt = stmt.where(EscalationRow.status == status)
+ rows = session.execute(stmt).scalars().all()
+ return [_escalation_to_out(r) for r in rows]
+ finally:
+ session.close()
+
+
@app.post("/internal/routing/reserve-agent", response_model=RoutingAgentReserveOut)
def reserve_agent_endpoint(
payload: RoutingAgentReserveIn,
diff --git a/ui/operator/styles.css b/ui/operator/styles.css
index c7c83e7..607d72d 100644
--- a/ui/operator/styles.css
+++ b/ui/operator/styles.css
@@ -5987,3 +5987,66 @@ body.analytics-drilldown-open {
padding: 10px 16px 0;
}
}
+
+.agent-pool-item,
+.escalation-item {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px 10px;
+ padding: 12px 14px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ margin-bottom: 8px;
+ background: #fff;
+}
+
+.agent-pool-item strong,
+.escalation-item strong {
+ flex-basis: 100%;
+}
+
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 2px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.status-badge::before {
+ content: "";
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+.status-badge.is-success {
+ background: var(--success-soft);
+ color: var(--success);
+}
+
+.status-badge.is-warning {
+ background: var(--warning-soft);
+ color: var(--warning);
+}
+
+.status-badge.is-danger {
+ background: var(--danger-soft);
+ color: var(--danger);
+}
+
+.status-badge.is-muted {
+ background: #eef1f6;
+ color: #6b7280;
+}
+
+.agent-pool-meta,
+.escalation-meta {
+ font-size: 12px;
+ color: #6b7280;
+}
diff --git a/ui/supervisor/app.js b/ui/supervisor/app.js
index a654d49..159a84c 100644
--- a/ui/supervisor/app.js
+++ b/ui/supervisor/app.js
@@ -698,6 +698,178 @@ async function setAgentState() {
}
}
+const AGENT_STATUS_LABELS = {
+ AVAILABLE: 'Свободен',
+ RESERVED: 'Зарезервирован',
+ RINGING: 'Звонок идёт',
+ TALKING: 'На линии',
+ AFTER_CALL_WORK: 'Постобработка',
+ PAUSED: 'Пауза',
+ OFFLINE: 'Offline',
+};
+
+const AGENT_STATUS_TONE = {
+ AVAILABLE: 'is-success',
+ RESERVED: 'is-warning',
+ RINGING: 'is-warning',
+ TALKING: 'is-success',
+ AFTER_CALL_WORK: 'is-warning',
+ PAUSED: 'is-muted',
+ OFFLINE: 'is-danger',
+};
+
+const ESCALATION_STATUS_LABELS = {
+ requested: 'Запрошена',
+ ringing: 'Дозвон оператора',
+ completed: 'Завершена',
+ failed: 'Не удалась',
+};
+
+const ESCALATION_STATUS_TONE = {
+ requested: 'is-warning',
+ ringing: 'is-warning',
+ completed: 'is-success',
+ failed: 'is-danger',
+};
+
+function statusBadge(labels, tones, value) {
+ const label = labels[value] || value || 'неизвестно';
+ const tone = tones[value] || 'is-muted';
+ return `${escapeHtml(label)} `;
+}
+
+function renderCallAgents(items) {
+ const byStatus = {};
+ items.forEach((item) => {
+ byStatus[item.status] = (byStatus[item.status] || 0) + 1;
+ });
+ $('callAgentsSummary').innerHTML = [
+ renderSummaryCard('Всего в пуле', items.length, 'Операторы L2/L3'),
+ renderSummaryCard('Свободны', byStatus.AVAILABLE || 0, 'Могут принять звонок сейчас'),
+ renderSummaryCard('На линии', (byStatus.TALKING || 0) + (byStatus.RINGING || 0) + (byStatus.RESERVED || 0), 'Заняты звонком или дозвоном'),
+ renderSummaryCard('Offline', byStatus.OFFLINE || 0, 'Недоступны'),
+ ].join('');
+
+ if (!items.length) {
+ $('callAgentsList').innerHTML = '
Пул пуст. Добавьте оператора ниже. ';
+ return;
+ }
+
+ $('callAgentsList').innerHTML = items
+ .map(
+ (item) => `
+
+ ${escapeHtml(item.display_name)} · вн. ${escapeHtml(item.extension)}
+ ${statusBadge(AGENT_STATUS_LABELS, AGENT_STATUS_TONE, item.status)}
+ ${escapeHtml(item.level)}
+ ${item.enabled ? '' : 'отключён '}
+ tenant: ${escapeHtml((item.tenant_ids || []).join(', ') || '-')}
+ навыки: ${escapeHtml((item.skills || []).join(', ') || '-')}
+ принято звонков: ${Number(item.calls_handled_count || 0)}
+ ${item.current_call_id ? `текущий звонок: ${escapeHtml(item.current_call_id)} ` : ''}
+
+ `,
+ )
+ .join('');
+}
+
+async function loadCallAgents(silent = false) {
+ try {
+ const data = await api('routing', 'agents');
+ renderCallAgents(Array.isArray(data) ? data : []);
+ if (!silent) {
+ log('Пул L2-операторов обновлён', { count: data.length });
+ }
+ } catch (err) {
+ $('callAgentsList').innerHTML = 'Не удалось загрузить пул операторов. ';
+ if (!silent) {
+ log('Не удалось загрузить пул операторов', { error: err.message });
+ }
+ }
+}
+
+async function createCallAgent() {
+ try {
+ const extension = $('newAgentExtension').value.trim();
+ if (!extension) {
+ log('Укажите внутренний номер оператора');
+ return;
+ }
+ const skills = $('newAgentSkills').value
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean);
+ const tenantId = $('newAgentTenant').value.trim();
+ const data = await api('routing', 'agents', {
+ method: 'POST',
+ body: JSON.stringify({
+ extension,
+ display_name: $('newAgentDisplayName').value.trim() || `Оператор ${extension}`,
+ level: $('newAgentLevel').value,
+ tenant_ids: tenantId ? [tenantId] : [],
+ skills,
+ max_concurrent_calls: 1,
+ enabled: true,
+ }),
+ });
+ log('Оператор добавлен в пул', { agent_id: data.agent_id, extension: data.extension });
+ $('newAgentExtension').value = '';
+ $('newAgentDisplayName').value = '';
+ $('newAgentSkills').value = '';
+ await loadCallAgents();
+ } catch (err) {
+ log('Не удалось добавить оператора', { error: err.message });
+ }
+}
+
+function renderEscalations(items) {
+ const byStatus = {};
+ items.forEach((item) => {
+ byStatus[item.status] = (byStatus[item.status] || 0) + 1;
+ });
+ $('escalationsSummary').innerHTML = [
+ renderSummaryCard('Активных', (byStatus.requested || 0) + (byStatus.ringing || 0), 'Ждут/дозваниваются до оператора'),
+ renderSummaryCard('Завершено', byStatus.completed || 0, 'Успешно приняты оператором'),
+ renderSummaryCard('Не удалось', byStatus.failed || 0, 'Свободного оператора не нашлось'),
+ ].join('');
+
+ if (!items.length) {
+ $('escalationsList').innerHTML = 'Эскалаций пока не было. ';
+ return;
+ }
+
+ $('escalationsList').innerHTML = items
+ .map(
+ (item) => `
+
+ звонок ${escapeHtml(item.call_id)}
+ ${statusBadge(ESCALATION_STATUS_LABELS, ESCALATION_STATUS_TONE, item.status)}
+ ${escapeHtml(item.from_level)} → ${escapeHtml(item.to_level)}
+ причина: ${escapeHtml(item.reason_code || '-')}
+ оператор: ${escapeHtml(item.assigned_agent_id || 'не назначен')}
+ запрошена: ${formatTime(item.requested_at)}
+ ${item.summary ? `тема: ${escapeHtml(item.summary)} ` : ''}
+
+ `,
+ )
+ .join('');
+}
+
+async function loadEscalations(silent = false) {
+ try {
+ const data = await api('routing', 'escalations?limit=30');
+ renderEscalations(Array.isArray(data) ? data : []);
+ if (!silent) {
+ log('Лента эскалаций обновлена', { count: data.length });
+ }
+ } catch (err) {
+ $('escalationsList').innerHTML = 'Не удалось загрузить эскалации. ';
+ if (!silent) {
+ log('Не удалось загрузить эскалации', { error: err.message });
+ }
+ }
+}
+
async function saveQueueMetrics() {
try {
const queueId = encodeURIComponent($('queueMetricId').value.trim() || 'line2');
@@ -973,7 +1145,16 @@ function wire() {
$('loginBtn').addEventListener('click', login);
$('corporateLoginBtn').addEventListener('click', startCorporateLogin);
$('refreshBtn').addEventListener('click', async () => {
- await Promise.all([loadRealtime(), loadKpi(), loadLiveCalls(), loadAgents(), loadRecordings(), refreshKnowledgeBase(true)]);
+ await Promise.all([
+ loadRealtime(),
+ loadKpi(),
+ loadLiveCalls(),
+ loadAgents(),
+ loadCallAgents(),
+ loadEscalations(),
+ loadRecordings(),
+ refreshKnowledgeBase(true),
+ ]);
log('Данные супервизора обновлены');
});
$('loadRealtimeBtn').addEventListener('click', loadRealtime);
@@ -981,6 +1162,9 @@ function wire() {
$('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls());
$('loadAgentsBtn').addEventListener('click', loadAgents);
$('setAgentBtn').addEventListener('click', setAgentState);
+ $('loadCallAgentsBtn').addEventListener('click', () => loadCallAgents());
+ $('createCallAgentBtn').addEventListener('click', createCallAgent);
+ $('loadEscalationsBtn').addEventListener('click', () => loadEscalations());
$('saveQueueMetricsBtn').addEventListener('click', saveQueueMetrics);
$('createKbCategoryBtn').addEventListener('click', createKnowledgeCategory);
$('loadKbBtn').addEventListener('click', () => refreshKnowledgeBase());
@@ -1015,11 +1199,15 @@ async function init() {
loadKpi(),
loadLiveCalls(true),
loadAgents(),
+ loadCallAgents(true),
+ loadEscalations(true),
loadRecordings(),
refreshKnowledgeBase(true),
]);
window.setInterval(() => {
void loadLiveCalls(true);
+ void loadCallAgents(true);
+ void loadEscalations(true);
}, 5000);
log('Пульт супервизора готов');
}
diff --git a/ui/supervisor/index.html b/ui/supervisor/index.html
index 7531572..94b117c 100644
--- a/ui/supervisor/index.html
+++ b/ui/supervisor/index.html
@@ -35,6 +35,8 @@
Оперативный срез
Живые звонки
Агенты
+ Пул L2 (звонки)
+ Эскалации
Очереди
База знаний
@@ -153,6 +155,45 @@
+
+ Пул операторов L2 (звонки)
+ Реальный пул для эскалации звонков с Voice AI (L1) на живых операторов +7 747 645 6048. Отдельно от демо-статусов в блоке «Агенты» выше — здесь показан фактический пул Routing Engine.
+
+ Обновить пул
+
+
+
+
+
+ Добавить оператора в пул
+
+
+
+
+
+
+ L2 — оператор
+ L3 — технический специалист
+
+
+
+
+
+ Добавить в пул
+
+
+
+
+
+ Эскалации AI → оператор
+ Живая лента: какие звонки Voice AI передал(-ает) на L2 и кто из операторов их принял.
+
+ Обновить эскалации
+
+
+
+
+
Очередь