feat: surface L2 agent pool and escalations on supervisor screen

- routing-service: GET /escalations (list recent escalation attempts)
- supervisor UI: new panel showing the real agent pool (status, level,
  tenant, skills, calls handled) fed by GET /agents, with a form to add
  operators to the pool
- supervisor UI: new live escalation feed (AI->L2 handoffs, status,
  assigned operator), auto-refreshed every 5s alongside live calls
This commit is contained in:
Hermes Agent
2026-08-29 13:28:14 +05:00
parent 2243f305b8
commit 1ad4e1ec6e
4 changed files with 332 additions and 2 deletions
+39 -1
View File
@@ -12,6 +12,7 @@ from services.shared.models import (
AgentCreate, AgentCreate,
AgentPoolOut, AgentPoolOut,
AgentStatusUpdateIn, AgentStatusUpdateIn,
EscalationOut,
HealthResponse, HealthResponse,
QueueCreate, QueueCreate,
QueueOut, QueueOut,
@@ -20,7 +21,7 @@ from services.shared.models import (
) )
from services.shared.security import require_roles from services.shared.security import require_roles
from services.shared.sql_init import init_sql_schema 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") app = FastAPI(title="routing-service", version="1.1.0")
@@ -328,6 +329,43 @@ def update_agent_status(
session.close() 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) @app.post("/internal/routing/reserve-agent", response_model=RoutingAgentReserveOut)
def reserve_agent_endpoint( def reserve_agent_endpoint(
payload: RoutingAgentReserveIn, payload: RoutingAgentReserveIn,
+63
View File
@@ -5987,3 +5987,66 @@ body.analytics-drilldown-open {
padding: 10px 16px 0; 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;
}
+189 -1
View File
@@ -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 `<span class="status-badge ${tone}">${escapeHtml(label)}</span>`;
}
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 = '<li class="empty-state">Пул пуст. Добавьте оператора ниже.</li>';
return;
}
$('callAgentsList').innerHTML = items
.map(
(item) => `
<li class="agent-pool-item">
<strong>${escapeHtml(item.display_name)} · вн. ${escapeHtml(item.extension)}</strong>
${statusBadge(AGENT_STATUS_LABELS, AGENT_STATUS_TONE, item.status)}
<span class="status-badge is-muted">${escapeHtml(item.level)}</span>
${item.enabled ? '' : '<span class="status-badge is-danger">отключён</span>'}
<span class="agent-pool-meta">tenant: ${escapeHtml((item.tenant_ids || []).join(', ') || '-')}</span>
<span class="agent-pool-meta">навыки: ${escapeHtml((item.skills || []).join(', ') || '-')}</span>
<span class="agent-pool-meta">принято звонков: ${Number(item.calls_handled_count || 0)}</span>
${item.current_call_id ? `<span class="agent-pool-meta">текущий звонок: ${escapeHtml(item.current_call_id)}</span>` : ''}
</li>
`,
)
.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 = '<li class="empty-state">Не удалось загрузить пул операторов.</li>';
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 = '<li class="empty-state">Эскалаций пока не было.</li>';
return;
}
$('escalationsList').innerHTML = items
.map(
(item) => `
<li class="escalation-item">
<strong>звонок ${escapeHtml(item.call_id)}</strong>
${statusBadge(ESCALATION_STATUS_LABELS, ESCALATION_STATUS_TONE, item.status)}
<span class="status-badge is-muted">${escapeHtml(item.from_level)}${escapeHtml(item.to_level)}</span>
<span class="escalation-meta">причина: ${escapeHtml(item.reason_code || '-')}</span>
<span class="escalation-meta">оператор: ${escapeHtml(item.assigned_agent_id || 'не назначен')}</span>
<span class="escalation-meta">запрошена: ${formatTime(item.requested_at)}</span>
${item.summary ? `<span class="escalation-meta">тема: ${escapeHtml(item.summary)}</span>` : ''}
</li>
`,
)
.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 = '<li class="empty-state">Не удалось загрузить эскалации.</li>';
if (!silent) {
log('Не удалось загрузить эскалации', { error: err.message });
}
}
}
async function saveQueueMetrics() { async function saveQueueMetrics() {
try { try {
const queueId = encodeURIComponent($('queueMetricId').value.trim() || 'line2'); const queueId = encodeURIComponent($('queueMetricId').value.trim() || 'line2');
@@ -973,7 +1145,16 @@ function wire() {
$('loginBtn').addEventListener('click', login); $('loginBtn').addEventListener('click', login);
$('corporateLoginBtn').addEventListener('click', startCorporateLogin); $('corporateLoginBtn').addEventListener('click', startCorporateLogin);
$('refreshBtn').addEventListener('click', async () => { $('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('Данные супервизора обновлены'); log('Данные супервизора обновлены');
}); });
$('loadRealtimeBtn').addEventListener('click', loadRealtime); $('loadRealtimeBtn').addEventListener('click', loadRealtime);
@@ -981,6 +1162,9 @@ function wire() {
$('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls()); $('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls());
$('loadAgentsBtn').addEventListener('click', loadAgents); $('loadAgentsBtn').addEventListener('click', loadAgents);
$('setAgentBtn').addEventListener('click', setAgentState); $('setAgentBtn').addEventListener('click', setAgentState);
$('loadCallAgentsBtn').addEventListener('click', () => loadCallAgents());
$('createCallAgentBtn').addEventListener('click', createCallAgent);
$('loadEscalationsBtn').addEventListener('click', () => loadEscalations());
$('saveQueueMetricsBtn').addEventListener('click', saveQueueMetrics); $('saveQueueMetricsBtn').addEventListener('click', saveQueueMetrics);
$('createKbCategoryBtn').addEventListener('click', createKnowledgeCategory); $('createKbCategoryBtn').addEventListener('click', createKnowledgeCategory);
$('loadKbBtn').addEventListener('click', () => refreshKnowledgeBase()); $('loadKbBtn').addEventListener('click', () => refreshKnowledgeBase());
@@ -1015,11 +1199,15 @@ async function init() {
loadKpi(), loadKpi(),
loadLiveCalls(true), loadLiveCalls(true),
loadAgents(), loadAgents(),
loadCallAgents(true),
loadEscalations(true),
loadRecordings(), loadRecordings(),
refreshKnowledgeBase(true), refreshKnowledgeBase(true),
]); ]);
window.setInterval(() => { window.setInterval(() => {
void loadLiveCalls(true); void loadLiveCalls(true);
void loadCallAgents(true);
void loadEscalations(true);
}, 5000); }, 5000);
log('Пульт супервизора готов'); log('Пульт супервизора готов');
} }
+41
View File
@@ -35,6 +35,8 @@
<a class="nav-link" href="#realtime"><span class="nav-bullet"></span>Оперативный срез</a> <a class="nav-link" href="#realtime"><span class="nav-bullet"></span>Оперативный срез</a>
<a class="nav-link" href="#liveCalls"><span class="nav-bullet"></span>Живые звонки</a> <a class="nav-link" href="#liveCalls"><span class="nav-bullet"></span>Живые звонки</a>
<a class="nav-link" href="#agents"><span class="nav-bullet"></span>Агенты</a> <a class="nav-link" href="#agents"><span class="nav-bullet"></span>Агенты</a>
<a class="nav-link" href="#callRoutingAgents"><span class="nav-bullet"></span>Пул L2 (звонки)</a>
<a class="nav-link" href="#escalations"><span class="nav-bullet"></span>Эскалации</a>
<a class="nav-link" href="#queues"><span class="nav-bullet"></span>Очереди</a> <a class="nav-link" href="#queues"><span class="nav-bullet"></span>Очереди</a>
<a class="nav-link" href="#knowledge"><span class="nav-bullet"></span>База знаний</a> <a class="nav-link" href="#knowledge"><span class="nav-bullet"></span>База знаний</a>
</div> </div>
@@ -153,6 +155,45 @@
<ul id="agentsList" class="list"></ul> <ul id="agentsList" class="list"></ul>
</article> </article>
<article class="panel reveal" id="callRoutingAgents">
<h2>Пул операторов L2 (звонки)</h2>
<p class="hint">Реальный пул для эскалации звонков с Voice AI (L1) на живых операторов +7 747 645 6048. Отдельно от демо-статусов в блоке «Агенты» выше — здесь показан фактический пул Routing Engine.</p>
<div class="actions compact-actions">
<button id="loadCallAgentsBtn" class="btn ghost">Обновить пул</button>
</div>
<div id="callAgentsSummary" class="summary-grid"></div>
<ul id="callAgentsList" class="list"></ul>
<details class="details-block">
<summary>Добавить оператора в пул</summary>
<div class="inline-form split-two">
<input id="newAgentExtension" placeholder="Внутренний номер (напр. 2001)" />
<input id="newAgentDisplayName" placeholder="Имя оператора" />
</div>
<div class="inline-form split-two">
<select id="newAgentLevel">
<option value="L2" selected>L2 — оператор</option>
<option value="L3">L3 — технический специалист</option>
</select>
<input id="newAgentTenant" placeholder="Tenant ID" value="konturai" />
</div>
<div class="inline-form compact">
<input id="newAgentSkills" placeholder="Навыки через запятую (необязательно)" />
<button id="createCallAgentBtn" class="btn">Добавить в пул</button>
</div>
</details>
</article>
<article class="panel reveal" id="escalations">
<h2>Эскалации AI → оператор</h2>
<p class="hint">Живая лента: какие звонки Voice AI передал(-ает) на L2 и кто из операторов их принял.</p>
<div class="actions compact-actions">
<button id="loadEscalationsBtn" class="btn ghost">Обновить эскалации</button>
</div>
<div id="escalationsSummary" class="summary-grid"></div>
<ul id="escalationsList" class="list"></ul>
</article>
<article class="panel reveal" id="queues"> <article class="panel reveal" id="queues">
<h2>Очередь</h2> <h2>Очередь</h2>
<div class="inline-form split-two"> <div class="inline-form split-two">