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.

    +
    + +
    +
    + + +
    + Добавить оператора в пул +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + +
    +

    Эскалации AI → оператор

    +

    Живая лента: какие звонки Voice AI передал(-ает) на L2 и кто из операторов их принял.

    +
    + +
    +
    + +
    +

    Очередь