diff --git a/tests/test_gateway_ui.py b/tests/test_gateway_ui.py index 1b50259..29b5110 100644 --- a/tests/test_gateway_ui.py +++ b/tests/test_gateway_ui.py @@ -386,6 +386,7 @@ def test_supervisor_ui_no_longer_renders_analytics_dashboard(): def test_analyst_ui_contains_analytics_dashboard_contract(): app_js = (Path(__file__).resolve().parents[1] / "ui" / "analyst" / "app.js").read_text(encoding="utf-8") index_html = (Path(__file__).resolve().parents[1] / "ui" / "analyst" / "index.html").read_text(encoding="utf-8") + mock_json = (Path(__file__).resolve().parents[1] / "ui" / "analyst" / "mock-analytics.json") assert 'id="analyticsPanel"' in index_html assert 'id="analyticsPreset"' in index_html assert 'id="analyticsFrom"' in index_html @@ -488,6 +489,9 @@ def test_analyst_ui_contains_analytics_dashboard_contract(): assert "function syncAnalyticsUrlState" in app_js assert "function parseAnalyticsDeepLinkState" in app_js assert "function restoreAnalyticsDrilldownFromUrlState" in app_js + assert "const ANALYTICS_MOCK_DATA_URL = '/analyst/assets/mock-analytics.json';" in app_js + assert "await ensureAnalyticsMockDataLoaded();" in app_js + assert mock_json.exists() assert "window.history.replaceState(null, '', nextUrl);" in app_js assert "url.searchParams.set('dd_mode', drilldown.mode);" in app_js assert "url.searchParams.set('dd_selected', drilldown.selectedId);" in app_js diff --git a/ui/analyst/app.js b/ui/analyst/app.js index cb61d0e..d1e74b2 100644 --- a/ui/analyst/app.js +++ b/ui/analyst/app.js @@ -34,6 +34,8 @@ const state = { trendInterval: 'day', agentTrendInterval: 'day', statusMessage: '', + mockData: null, + mockDataError: '', loading: false, error: '', aiError: '', @@ -93,20 +95,7 @@ const AGENT_ANALYTICS_TREND_OPTIONS = [ 'fcr_rate', ]; const ENABLE_ANALYTICS_MOCK_DATA = true; -const ANALYTICS_MOCK_QUEUE_OPTIONS = [ - { queue_id: 'sales_kz', name: 'Продажи KZ' }, - { queue_id: 'support_kz', name: 'Поддержка KZ' }, - { queue_id: 'vip_kz', name: 'VIP-линия' }, - { queue_id: 'retention_kz', name: 'Удержание' }, -]; -const ANALYTICS_MOCK_AGENTS = [ - 'agent.aigerim', - 'agent.dias', - 'agent.nurdaulet', - 'agent.madina', - 'agent.ruslan', - 'agent.tomiris', -]; +const ANALYTICS_MOCK_DATA_URL = '/analyst/assets/mock-analytics.json'; const ANALYTICS_DEEP_LINK_KEYS = [ 'preset', 'from', @@ -803,6 +792,33 @@ function analyticsMockEnabled() { return ENABLE_ANALYTICS_MOCK_DATA === true; } +function analyticsMockData() { + return state.analytics.mockData || {}; +} + +async function ensureAnalyticsMockDataLoaded() { + if (!analyticsMockEnabled()) { + return null; + } + if (state.analytics.mockData) { + return state.analytics.mockData; + } + try { + const response = await fetch(ANALYTICS_MOCK_DATA_URL, { cache: 'no-store' }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const payload = await response.json(); + state.analytics.mockData = payload && typeof payload === 'object' ? payload : {}; + state.analytics.mockDataError = ''; + return state.analytics.mockData; + } catch (error) { + state.analytics.mockData = {}; + state.analytics.mockDataError = error?.message || 'Не удалось загрузить mock-analytics.json'; + return state.analytics.mockData; + } +} + function mockAnalyticsScale(range) { if (!range?.from || !range?.to) { return 1; @@ -813,16 +829,20 @@ function mockAnalyticsScale(range) { } function mockAnalyticsQueueOptions() { - return ANALYTICS_MOCK_QUEUE_OPTIONS.map((item) => ({ ...item })); + const queueOptions = Array.isArray(analyticsMockData().queue_options) + ? analyticsMockData().queue_options + : []; + return queueOptions.map((item) => ({ ...item })); +} + +function mockAnalyticsAgents() { + return Array.isArray(analyticsMockData().agents) + ? analyticsMockData().agents.slice() + : []; } function mockAnalyticsQueueFactor(queueId = state.analytics.queueId) { - const factors = { - sales_kz: 1.08, - support_kz: 0.96, - vip_kz: 0.58, - retention_kz: 0.72, - }; + const factors = analyticsMockData().queue_factors || {}; if (!queueId || queueId === 'all') { return 1; } @@ -830,32 +850,25 @@ function mockAnalyticsQueueFactor(queueId = state.analytics.queueId) { } function mockAnalyticsChannelBase() { - return { - voice: { total: 620, answered: 584, abandoned: 36 }, - telegram: { total: 214, answered: 205, abandoned: 9 }, - whatsapp: { total: 182, answered: 171, abandoned: 11 }, - webchat: { total: 141, answered: 132, abandoned: 9 }, - email: { total: 108, answered: 94, abandoned: 14 }, - }; + return analyticsMockData().channel_base || {}; } function mockAiReasonLabel(reasonKey) { - const labels = { - requested_human: 'Запрос оператора', - knowledge_or_tool_gap: 'Не хватило знаний или инструмента', - policy_or_sensitive: 'Чувствительный сценарий', - delivery_or_runtime_error: 'Ошибка доставки или runtime', - manual_claim: 'Ручной takeover', - other: 'Другая причина', - }; + const labels = analyticsMockData().ai_reason_labels || {}; return labels[reasonKey] || reasonKey || 'Другая причина'; } function mockInteractionStatus(index = 0) { - return ['closed', 'in_progress', 'closed', 'new', 'closed', 'escalated'][index % 6]; + const statuses = Array.isArray(analyticsMockData().interaction_statuses) + ? analyticsMockData().interaction_statuses + : []; + if (!statuses.length) { + return 'closed'; + } + return statuses[index % statuses.length]; } -function mockAnalyticsCoverage(totalRows, note = 'Показаны демонстрационные данные для временного наполнения витрины.') { +function mockAnalyticsCoverage(totalRows, note = analyticsMockData().coverage_note || 'Показаны демонстрационные данные для временного наполнения витрины.') { const metricNames = ['total', 'answered', 'SL', 'ASA', 'AHT', 'Abandon', 'FCR', 'DigitalShare']; const metricStatus = {}; const supportedChannels = {}; @@ -1007,8 +1020,12 @@ function buildMockAgentOverview(rangeMeta, options = {}) { const previous = Boolean(options.previous); const queueId = options.queueId ?? state.analytics.queueId; const queueOptions = mockAnalyticsQueueOptions(); - const rows = ANALYTICS_MOCK_AGENTS.map((agentId, index) => { - const queue = queueOptions[index % queueOptions.length]; + const agents = mockAnalyticsAgents(); + const stateCycle = Array.isArray(analyticsMockData().agent_state_cycle) + ? analyticsMockData().agent_state_cycle + : []; + const rows = agents.map((agentId, index) => { + const queue = queueOptions[index % Math.max(queueOptions.length, 1)] || { queue_id: 'sales_kz', name: 'Продажи KZ' }; const dominantQueue = queueId !== 'all' ? queueId : queue.queue_id; const interactionsTotal = Math.round((24 - index * 2) * (previous ? 0.88 : 1)); const answeredTotal = Math.max(0, Math.round(interactionsTotal * (0.88 - index * 0.015))); @@ -1019,7 +1036,7 @@ function buildMockAgentOverview(rangeMeta, options = {}) { closed_total: Math.max(0, answeredTotal - (index % 3)), avg_handle_seconds: Number((290 + index * 24 + (previous ? 18 : 0)).toFixed(2)), fcr_rate: Number((78 - index * 3 + (previous ? -2 : 0)).toFixed(2)), - current_state: ['READY', 'BUSY', 'READY', 'BREAK', 'BUSY', 'OFFLINE'][index % 6], + current_state: stateCycle.length ? stateCycle[index % stateCycle.length] : 'READY', current_queue_id: dominantQueue, dominant_queue_id: dominantQueue, last_activity_at: new Date(rangeMeta.current.to.getTime() - index * 37 * 60 * 1000).toISOString(), @@ -1053,11 +1070,18 @@ function buildMockAgentOverview(rangeMeta, options = {}) { }; }) .filter((item) => item.agents_total > 0); - const byShift = [ - { shift_key: 'night', label: 'Ночная смена', agents_with_activity: Math.max(2, Math.round(agentsWithActivity * 0.3)), interactions_total: Math.round(interactionsTotal * 0.18), answered_total: Math.round(answeredTotal * 0.17), avg_handle_seconds: 356, fcr_rate: 69.5 }, - { shift_key: 'day', label: 'Дневная смена', agents_with_activity: Math.max(4, Math.round(agentsWithActivity * 0.9)), interactions_total: Math.round(interactionsTotal * 0.52), answered_total: Math.round(answeredTotal * 0.53), avg_handle_seconds: 318, fcr_rate: 76.8 }, - { shift_key: 'evening', label: 'Вечерняя смена', agents_with_activity: Math.max(3, Math.round(agentsWithActivity * 0.55)), interactions_total: Math.round(interactionsTotal * 0.30), answered_total: Math.round(answeredTotal * 0.30), avg_handle_seconds: 334, fcr_rate: 73.4 }, - ]; + const shiftSeeds = Array.isArray(analyticsMockData().agent_shift_rows) + ? analyticsMockData().agent_shift_rows + : []; + const byShift = shiftSeeds.map((seed) => ({ + shift_key: seed.shift_key, + label: seed.label, + agents_with_activity: Math.max(1, Math.round(Number(seed.agents_with_activity || 0) * (previous ? 0.92 : 1))), + interactions_total: Math.max(0, Math.round(Number(seed.interactions_total || 0) * (previous ? 0.9 : 1))), + answered_total: Math.max(0, Math.round(Number(seed.answered_total || 0) * (previous ? 0.9 : 1))), + avg_handle_seconds: Number((Number(seed.avg_handle_seconds || 0) + (previous ? 18 : 0)).toFixed(2)), + fcr_rate: Number((Number(seed.fcr_rate || 0) + (previous ? -2 : 0)).toFixed(2)), + })); return { window: { from_ts: (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), @@ -1156,10 +1180,15 @@ function buildMockAiOverview(rangeMeta, options = {}) { ); } const scale = mockAnalyticsScale(previous ? rangeMeta.previous : rangeMeta.current) * (previous ? 0.91 : 1); - const baseRows = [ - { channel: 'telegram', sessions_started: Math.round(176 * scale), ai_only_sessions: Math.round(106 * scale), human_touched_sessions: Math.round(58 * scale), handoff_rate: 24.4, ai_latency_avg_ms: 1380, containment_rate: 60.2, closed_without_operator_rate: 52.1 }, - { channel: 'whatsapp', sessions_started: Math.round(142 * scale), ai_only_sessions: Math.round(80 * scale), human_touched_sessions: Math.round(54 * scale), handoff_rate: 28.9, ai_latency_avg_ms: 1524, containment_rate: 56.4, closed_without_operator_rate: 48.6 }, - ].filter((item) => channel === 'all' || item.channel === channel); + const aiChannelRows = Array.isArray(analyticsMockData().ai_channel_rows) + ? analyticsMockData().ai_channel_rows + : []; + const baseRows = aiChannelRows.map((item) => ({ + ...item, + sessions_started: Math.round(Number(item.sessions_started || 0) * scale), + ai_only_sessions: Math.round(Number(item.ai_only_sessions || 0) * scale), + human_touched_sessions: Math.round(Number(item.human_touched_sessions || 0) * scale), + })).filter((item) => channel === 'all' || item.channel === channel); const sessionsStarted = baseRows.reduce((acc, item) => acc + item.sessions_started, 0); const sessionsContained = Math.round(baseRows.reduce((acc, item) => acc + item.sessions_started * (item.containment_rate / 100), 0)); const sessionsHandoff = Math.round(baseRows.reduce((acc, item) => acc + item.sessions_started * (item.handoff_rate / 100), 0)); @@ -1270,9 +1299,11 @@ function buildMockAiTrend(rangeMeta, metric) { function buildMockInteractionDrilldownData(filters, limit, offset, mode) { const normalized = normalizeAnalyticsDrilldownFilters(filters); + const queueOptions = mockAnalyticsQueueOptions(); + const agents = mockAnalyticsAgents(); const total = 18; const rows = Array.from({ length: total }, (_, index) => { - const queueId = normalized.queue_id || mockAnalyticsQueueOptions()[index % mockAnalyticsQueueOptions().length].queue_id; + const queueId = normalized.queue_id || queueOptions[index % Math.max(queueOptions.length, 1)]?.queue_id || 'sales_kz'; let channel = normalized.channel || ['voice', 'telegram', 'whatsapp', 'webchat', 'email'][index % 5]; if (mode === 'metric' && normalized.metric === 'DigitalShare') { channel = ['telegram', 'whatsapp', 'webchat', 'email'][index % 4]; @@ -1288,7 +1319,7 @@ function buildMockInteractionDrilldownData(filters, limit, offset, mode) { channel, status: abandoned ? 'closed' : mockInteractionStatus(index), queue_id: queueId, - assigned_to: normalized.agent_id || (abandoned ? null : ANALYTICS_MOCK_AGENTS[index % ANALYTICS_MOCK_AGENTS.length]), + assigned_to: normalized.agent_id || (abandoned ? null : (agents[index % Math.max(agents.length, 1)] || null)), created_at: createdAt.toISOString(), updated_at: new Date(createdAt.getTime() + 42 * 60 * 1000).toISOString(), answered, @@ -1323,13 +1354,15 @@ function buildMockInteractionDrilldownData(filters, limit, offset, mode) { } function buildMockInteractionDetail(interactionId) { + const queueOptions = mockAnalyticsQueueOptions(); + const agents = mockAnalyticsAgents(); const interaction = { interaction_id: interactionId, subject: `Карточка ${interactionId}`, channel: ['voice', 'telegram', 'whatsapp'][interactionId.length % 3], status: 'closed', - queue_id: mockAnalyticsQueueOptions()[interactionId.length % mockAnalyticsQueueOptions().length].queue_id, - assigned_to: ANALYTICS_MOCK_AGENTS[interactionId.length % ANALYTICS_MOCK_AGENTS.length], + queue_id: queueOptions[interactionId.length % Math.max(queueOptions.length, 1)]?.queue_id || 'sales_kz', + assigned_to: agents[interactionId.length % Math.max(agents.length, 1)] || null, created_at: new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString(), updated_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), wait_seconds: 18, @@ -1350,9 +1383,11 @@ function buildMockInteractionDetail(interactionId) { function buildMockAiDrilldownData(filters, limit, offset) { const normalized = normalizeAnalyticsDrilldownFilters(filters); + const queueOptions = mockAnalyticsQueueOptions(); + const agents = mockAnalyticsAgents(); const total = 14; const allItems = Array.from({ length: total }, (_, index) => { - const queue = mockAnalyticsQueueOptions()[index % mockAnalyticsQueueOptions().length]; + const queue = queueOptions[index % Math.max(queueOptions.length, 1)] || { queue_id: 'sales_kz' }; const channel = normalized.channel && normalized.channel !== 'all' ? normalized.channel : ['telegram', 'whatsapp'][index % 2]; @@ -1373,7 +1408,7 @@ function buildMockAiDrilldownData(filters, limit, offset) { reason_key: handoff ? reasonKey : '', reason_label: handoff ? mockAiReasonLabel(reasonKey) : '', raw_handoff_reason: handoff ? `raw:${reasonKey}` : '', - assigned_to: handoff ? ANALYTICS_MOCK_AGENTS[index % ANALYTICS_MOCK_AGENTS.length] : null, + assigned_to: handoff ? (agents[index % Math.max(agents.length, 1)] || null) : null, claimed_by_user: handoff ? `sup-${(index % 3) + 1}` : null, assistant_turns: 6 + index, user_turns: 3 + (index % 4), @@ -1464,9 +1499,15 @@ function buildMockAnalyticsDashboard(rangeMeta) { overview, compare: buildMockAnalyticsOverview(rangeMeta, { previous: true }), coverage: { - implemented_metrics: ['SL', 'ASA', 'AHT', 'Abandon', 'FCR', 'DigitalShare'], - dimensions: ['queue', 'channel', 'agent', 'shift'], - supported_filters: ['preset', 'from_ts', 'to_ts', 'queue_id', 'channel'], + implemented_metrics: Array.isArray(analyticsMockData().dashboard_coverage?.implemented_metrics) + ? analyticsMockData().dashboard_coverage.implemented_metrics + : [], + dimensions: Array.isArray(analyticsMockData().dashboard_coverage?.dimensions) + ? analyticsMockData().dashboard_coverage.dimensions + : [], + supported_filters: Array.isArray(analyticsMockData().dashboard_coverage?.supported_filters) + ? analyticsMockData().dashboard_coverage.supported_filters + : [], }, aiOverview: buildMockAiOverview(rangeMeta), aiCompare: buildMockAiOverview(rangeMeta, { previous: true }), @@ -5554,6 +5595,7 @@ async function loadAiAnalyticsDrilldownDetailsState(sessionId) { syncAnalyticsUrlState(); if (analyticsMockEnabled()) { + await ensureAnalyticsMockDataLoaded(); const detail = buildMockAiDetail(sessionId); state.drilldown.selectedInteraction = { ...(state.drilldown.selectedInteraction || {}), @@ -5608,6 +5650,7 @@ loadAnalyticsDrilldownDetails = async function loadAnalyticsDrilldownDetails(int syncAnalyticsUrlState(); if (analyticsMockEnabled()) { + await ensureAnalyticsMockDataLoaded(); const detail = buildMockInteractionDetail(interactionId); state.drilldown.selectedInteraction = { ...(state.drilldown.selectedInteraction || {}), @@ -5657,6 +5700,7 @@ async function loadAiAnalyticsDrilldownPageState(options = {}) { renderAnalyticsDrilldown(); if (analyticsMockEnabled()) { + await ensureAnalyticsMockDataLoaded(); const data = buildMockAiDrilldownData(state.drilldown.filters, state.drilldown.limit, state.drilldown.offset); state.drilldown.items = Array.isArray(data?.items) ? data.items : []; state.drilldown.total = Number(data?.total || 0); @@ -5760,6 +5804,7 @@ loadAnalyticsDrilldownPage = async function loadAnalyticsDrilldownPage(options = renderAnalyticsDrilldown(); if (analyticsMockEnabled()) { + await ensureAnalyticsMockDataLoaded(); const data = buildMockInteractionDrilldownData(state.drilldown.filters, state.drilldown.limit, state.drilldown.offset, state.drilldown.mode); state.drilldown.items = Array.isArray(data?.items) ? data.items : []; state.drilldown.total = Number(data?.total || 0); @@ -5988,6 +6033,7 @@ async function loadAnalyticsDashboard(_silent = false) { renderAnalyticsDashboard(rangeMeta); if (analyticsMockEnabled()) { + await ensureAnalyticsMockDataLoaded(); const payload = buildMockAnalyticsDashboard(rangeMeta); if (requestId !== state.analytics.requestId) { return; @@ -6008,7 +6054,9 @@ async function loadAnalyticsDashboard(_silent = false) { state.analytics.aiTrend = payload.aiTrend; state.analytics.agentTrend = payload.agentTrend; state.analytics.loading = false; - state.analytics.statusMessage = 'Показаны демонстрационные данные.'; + state.analytics.statusMessage = state.analytics.mockDataError + ? `Показаны демонстрационные данные. Не удалось загрузить внешний mock-файл: ${state.analytics.mockDataError}` + : 'Показаны демонстрационные данные из внешнего mock-файла.'; renderAnalyticsDashboard(rangeMeta); syncAnalyticsUrlState(); if (state.drilldown.open) { diff --git a/ui/analyst/mock-analytics.json b/ui/analyst/mock-analytics.json new file mode 100644 index 0000000..9f68c5e --- /dev/null +++ b/ui/analyst/mock-analytics.json @@ -0,0 +1,110 @@ +{ + "queue_options": [ + { "queue_id": "sales_kz", "name": "Продажи KZ" }, + { "queue_id": "support_kz", "name": "Поддержка KZ" }, + { "queue_id": "vip_kz", "name": "VIP-линия" }, + { "queue_id": "retention_kz", "name": "Удержание" } + ], + "agents": [ + "agent.aigerim", + "agent.dias", + "agent.nurdaulet", + "agent.madina", + "agent.ruslan", + "agent.tomiris" + ], + "queue_factors": { + "sales_kz": 1.08, + "support_kz": 0.96, + "vip_kz": 0.58, + "retention_kz": 0.72 + }, + "channel_base": { + "voice": { "total": 620, "answered": 584, "abandoned": 36 }, + "telegram": { "total": 214, "answered": 205, "abandoned": 9 }, + "whatsapp": { "total": 182, "answered": 171, "abandoned": 11 }, + "webchat": { "total": 141, "answered": 132, "abandoned": 9 }, + "email": { "total": 108, "answered": 94, "abandoned": 14 } + }, + "ai_reason_labels": { + "requested_human": "Запрос оператора", + "knowledge_or_tool_gap": "Не хватило знаний или инструмента", + "policy_or_sensitive": "Чувствительный сценарий", + "delivery_or_runtime_error": "Ошибка доставки или runtime", + "manual_claim": "Ручной takeover", + "other": "Другая причина" + }, + "interaction_statuses": [ + "closed", + "in_progress", + "closed", + "new", + "closed", + "escalated" + ], + "agent_state_cycle": [ + "READY", + "BUSY", + "READY", + "BREAK", + "BUSY", + "OFFLINE" + ], + "coverage_note": "Показаны демонстрационные данные для временного наполнения витрины.", + "dashboard_coverage": { + "implemented_metrics": ["SL", "ASA", "AHT", "Abandon", "FCR", "DigitalShare"], + "dimensions": ["queue", "channel", "agent", "shift"], + "supported_filters": ["preset", "from_ts", "to_ts", "queue_id", "channel"] + }, + "ai_channel_rows": [ + { + "channel": "telegram", + "sessions_started": 176, + "ai_only_sessions": 106, + "human_touched_sessions": 58, + "handoff_rate": 24.4, + "ai_latency_avg_ms": 1380, + "containment_rate": 60.2, + "closed_without_operator_rate": 52.1 + }, + { + "channel": "whatsapp", + "sessions_started": 142, + "ai_only_sessions": 80, + "human_touched_sessions": 54, + "handoff_rate": 28.9, + "ai_latency_avg_ms": 1524, + "containment_rate": 56.4, + "closed_without_operator_rate": 48.6 + } + ], + "agent_shift_rows": [ + { + "shift_key": "night", + "label": "Ночная смена", + "agents_with_activity": 2, + "interactions_total": 18, + "answered_total": 17, + "avg_handle_seconds": 356, + "fcr_rate": 69.5 + }, + { + "shift_key": "day", + "label": "Дневная смена", + "agents_with_activity": 5, + "interactions_total": 52, + "answered_total": 53, + "avg_handle_seconds": 318, + "fcr_rate": 76.8 + }, + { + "shift_key": "evening", + "label": "Вечерняя смена", + "agents_with_activity": 3, + "interactions_total": 30, + "answered_total": 30, + "avg_handle_seconds": 334, + "fcr_rate": 73.4 + } + ] +}