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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+189
-1
@@ -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() {
|
||||
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('Пульт супервизора готов');
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
<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="#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="#knowledge"><span class="nav-bullet"></span>База знаний</a>
|
||||
</div>
|
||||
@@ -153,6 +155,45 @@
|
||||
<ul id="agentsList" class="list"></ul>
|
||||
</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">
|
||||
<h2>Очередь</h2>
|
||||
<div class="inline-form split-two">
|
||||
|
||||
Reference in New Issue
Block a user