Asmitha-28 commited on
Commit
17d3301
·
verified ·
1 Parent(s): fa1cf79

Upload dashboard/web/app.js with huggingface_hub

Browse files
Files changed (1) hide show
  1. dashboard/web/app.js +930 -930
dashboard/web/app.js CHANGED
@@ -1,930 +1,930 @@
1
- // SupportMind Dashboard app.js
2
- // Interactive demo with real API calls (falls back to simulation if API unavailable)
3
-
4
- let API_BASE = window.location.origin;
5
- let apiOnline = false;
6
-
7
- function apiCandidates() {
8
- const candidates = [];
9
- const url = new URL(window.location.href);
10
- if (url.hostname === '127.0.0.1' && url.port === '7860') {
11
- candidates.push('http://127.0.0.1:7862');
12
- candidates.push('http://127.0.0.1:7861');
13
- }
14
- candidates.push(window.location.origin);
15
- return [...new Set(candidates)];
16
- }
17
-
18
- // Category colors
19
- const CAT_COLORS = {
20
- billing: '#fb923c', technical_support: '#8083ff', account_management: '#89ceff',
21
- feature_request: '#c0c1ff', compliance_legal: '#f87171', onboarding: '#4ade80',
22
- general_inquiry: '#94a3b8', churn_risk: '#facc15',
23
- };
24
-
25
- // ── Init ──────────────────────────────────────────────
26
- document.addEventListener('DOMContentLoaded', () => {
27
- animateCounters();
28
- initPresets();
29
- initDropoutViz();
30
- initScrollAnimations();
31
- initSmoothScroll();
32
- checkAPI();
33
- updateMetrics();
34
- setInterval(updateMetrics, 5000); // Update every 5 seconds
35
- });
36
-
37
- // ── Counter Animation ─────────────────────────────────
38
- function animateCounters() {
39
- document.querySelectorAll('.stat-card').forEach(card => {
40
- const counter = card.querySelector('.counter');
41
- const target = parseFloat(card.dataset.value);
42
- const duration = 1500;
43
- const start = performance.now();
44
- function update(now) {
45
- const elapsed = now - start;
46
- const progress = Math.min(elapsed / duration, 1);
47
- const eased = 1 - Math.pow(1 - progress, 3);
48
- counter.textContent = Math.round(target * eased * 10) / 10;
49
- if (progress < 1) requestAnimationFrame(update);
50
- else counter.textContent = target;
51
- }
52
- requestAnimationFrame(update);
53
- });
54
- }
55
-
56
- // ── Presets ────────────────────────────────────────────
57
- // ── Live Telemetry Engine ───────────────────────────
58
- async function updateMetrics() {
59
- try {
60
- const res = await fetch(`${API_BASE}/metrics`);
61
- if (!res.ok) return;
62
- const data = await res.json();
63
-
64
- // Update Counter
65
- document.getElementById('live-total').textContent = data.total_requests.toLocaleString();
66
-
67
- // Update Model Name
68
- document.getElementById('live-model').textContent = data.model;
69
-
70
- // Update Distribution Bar
71
- const dist = data.routing_distribution;
72
- document.getElementById('dist-route').style.width = `${dist.route_pct}%`;
73
- document.getElementById('dist-clarify').style.width = `${dist.clarify_pct}%`;
74
- document.getElementById('dist-escalate').style.width = `${dist.escalate_pct}%`;
75
-
76
- // Update Status Pulse
77
- const indicator = document.getElementById('live-indicator');
78
- indicator.style.opacity = '1';
79
- setTimeout(() => { indicator.style.opacity = '0.8'; }, 500);
80
-
81
- } catch (err) {
82
- console.warn("Metrics sync failed:", err);
83
- }
84
- }
85
-
86
- // ── Presets ────────────────────────────────────────────
87
- function initPresets() {
88
- document.querySelectorAll('.preset-btn').forEach(btn => {
89
- btn.addEventListener('click', () => {
90
- document.getElementById('ticket-input').value = btn.dataset.text;
91
- });
92
- });
93
- }
94
-
95
- function initSmoothScroll() {
96
- document.querySelectorAll('a[href^="#"]').forEach(anchor => {
97
- anchor.addEventListener('click', function (e) {
98
- e.preventDefault();
99
- document.querySelector(this.getAttribute('href')).scrollIntoView({
100
- behavior: 'smooth'
101
- });
102
- });
103
- });
104
- }
105
-
106
- // ── MC Dropout Visualization ──────────────────────────
107
- function initDropoutViz() {
108
- const grid = document.getElementById('dropout-grid');
109
- if (!grid) return;
110
- for (let pass = 0; pass < 20; pass++) {
111
- const col = document.createElement('div');
112
- col.className = 'dropout-col';
113
- for (let neuron = 0; neuron < 12; neuron++) {
114
- const cell = document.createElement('div');
115
- cell.className = 'dropout-cell';
116
- const active = Math.random() > 0.15;
117
- cell.style.background = active ? 'var(--primary)' : 'rgba(192, 193, 255, 0.05)';
118
- cell.style.border = active ? 'none' : '1px solid rgba(192, 193, 255, 0.1)';
119
- col.appendChild(cell);
120
- }
121
- grid.appendChild(col);
122
- }
123
- // Animate dropout
124
- setInterval(() => {
125
- grid.querySelectorAll('.dropout-cell').forEach(cell => {
126
- const active = Math.random() > 0.15;
127
- cell.style.background = active ? 'var(--primary)' : 'rgba(192, 193, 255, 0.05)';
128
- cell.style.border = active ? 'none' : '1px solid rgba(192, 193, 255, 0.1)';
129
- });
130
- }, 2000);
131
- }
132
-
133
- // ��─ Scroll Animations ─────────────────────────────────
134
- function initScrollAnimations() {
135
- const observer = new IntersectionObserver((entries) => {
136
- entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('visible'); });
137
- }, { threshold: 0.1 });
138
- document.querySelectorAll('.section-header, .stat-card, .arch-stage, .bench-card, .ops-card').forEach(el => {
139
- el.classList.add('fade-in');
140
- observer.observe(el);
141
- });
142
- }
143
-
144
- // ── API Check ─────────────────────────────────────────
145
- async function checkAPI() {
146
- for (const candidate of apiCandidates()) {
147
- try {
148
- const res = await fetch(`${candidate}/health`, { signal: AbortSignal.timeout(2000) });
149
- if (!res.ok) continue;
150
- API_BASE = candidate;
151
- apiOnline = true;
152
- const statusEl = document.querySelector('.status-text');
153
- if (statusEl) statusEl.textContent = API_BASE === window.location.origin ? 'API Connected' : 'Fixed API Connected';
154
- return;
155
- } catch {
156
- // Try the next candidate before falling back to demo mode.
157
- }
158
- }
159
- apiOnline = false;
160
- const statusEl = document.querySelector('.status-text');
161
- if (statusEl) statusEl.textContent = 'Demo Mode';
162
- }
163
-
164
- // ── Live Metrics ──────────────────────────────────────
165
- async function updateLiveMetrics() {
166
- if (!apiOnline) return;
167
- try {
168
- const res = await fetch(`${API_BASE}/metrics`);
169
- const data = await res.json();
170
-
171
- document.getElementById('live-model').textContent = data.model;
172
- document.getElementById('live-total').textContent = data.total_requests;
173
-
174
- const dist = data.routing_distribution;
175
- document.getElementById('dist-route').style.width = dist.route_pct + '%';
176
- document.getElementById('dist-clarify').style.width = dist.clarify_pct + '%';
177
- document.getElementById('dist-escalate').style.width = dist.escalate_pct + '%';
178
- } catch (err) {
179
- console.warn('Metrics update failed:', err);
180
- }
181
- }
182
-
183
- // ── Route Ticket ──────────────────────────────────────
184
- async function routeTicket(extraPayload = {}) {
185
- const text = document.getElementById('ticket-input').value.trim();
186
- if (!text) return;
187
-
188
- const btn = document.getElementById('route-btn');
189
- btn.innerHTML = '<span class="spinner"></span> Routing...';
190
- btn.disabled = true;
191
-
192
- let result;
193
- try {
194
- if (apiOnline) {
195
- const res = await fetch(`${API_BASE}/route`, {
196
- method: 'POST',
197
- headers: { 'Content-Type': 'application/json' },
198
- body: JSON.stringify({ text, ...extraPayload }),
199
- });
200
- result = await res.json();
201
- } else {
202
- result = simulateRouting(text, extraPayload);
203
- }
204
- displayResult(result, text);
205
- } catch (err) {
206
- result = simulateRouting(text, extraPayload);
207
- displayResult(result, text);
208
- }
209
-
210
- btn.innerHTML = '<span class="btn-icon">⚡</span> Route Ticket';
211
- btn.disabled = false;
212
- }
213
-
214
- // ── Display Result ────────────────────────────────────
215
- function displayResult(r, routedText) {
216
- // Handle edge cases
217
- if (r.action === 'invalid_input') {
218
- document.getElementById('result-placeholder').style.display = 'none';
219
- const content = document.getElementById('result-content');
220
- content.style.display = 'block';
221
-
222
- const badge = document.getElementById('action-badge');
223
- badge.textContent = r.error_type.toUpperCase().replace('_', ' ');
224
- badge.className = 'action-badge clarify'; // yellow
225
-
226
- document.getElementById('action-queue').textContent = r.response;
227
- document.getElementById('result-reason').textContent = r.response;
228
-
229
- // Hide gauges for invalid input
230
- document.querySelector('.gauge-row').style.display = 'none';
231
- document.querySelector('.signals-grid').style.display = 'none';
232
- document.getElementById('prob-chart').innerHTML = '';
233
- document.getElementById('clarification-box').style.display = 'none';
234
- const evidenceGrid = document.getElementById('signal-evidence-grid');
235
- if (evidenceGrid) evidenceGrid.style.display = 'none';
236
- const explainBtn = document.getElementById('explain-btn');
237
- if (explainBtn) explainBtn.style.display = 'none';
238
- document.getElementById('explanation-box').style.display = 'none';
239
- return;
240
- }
241
-
242
-
243
- // Show gauges for valid input
244
- document.querySelector('.gauge-row').style.display = 'grid';
245
- document.querySelector('.signals-grid').style.display = 'grid';
246
-
247
- document.getElementById('result-placeholder').style.display = 'none';
248
- const content = document.getElementById('result-content');
249
- content.style.display = 'block';
250
- const evidenceGrid = document.getElementById('signal-evidence-grid');
251
- if (evidenceGrid) evidenceGrid.style.display = 'grid';
252
-
253
- // Action Badge Logic
254
- const badge = document.getElementById('action-badge');
255
- const queue = document.getElementById('action-queue');
256
-
257
- if (r.action === 'multi_route') {
258
- badge.textContent = 'MULTI-ROUTE';
259
- badge.className = 'action-badge';
260
- badge.style.background = 'linear-gradient(90deg, var(--primary), var(--accent))';
261
- queue.innerHTML = `
262
- <div style="display: flex; gap: 8px; margin-top: 4px;">
263
- <span class="tech-tag" style="background: rgba(192, 193, 255, 0.2)">Primary: ${r.primary_queue}</span>
264
- <span class="tech-tag" style="background: rgba(255, 255, 255, 0.1)">Secondary: ${r.secondary_queue}</span>
265
- </div>
266
- `;
267
- } else {
268
- badge.textContent = r.action.toUpperCase();
269
- badge.className = `action-badge ${r.action}`;
270
- queue.textContent = r.action === 'route' ? ` ${r.queue || r.top_category} queue` :
271
- r.action === 'clarify' ? 'Needs 1 clarification question' : 'Immediate human triage';
272
- }
273
-
274
- // Gauges
275
- const confPct = Math.min(r.confidence * 100, 100);
276
- document.getElementById('conf-fill').style.width = confPct + '%';
277
- document.getElementById('conf-value').textContent = r.confidence.toFixed(4);
278
- const maxEnt = Math.log(8);
279
- const entPct = Math.min((r.entropy / maxEnt) * 100, 100);
280
- document.getElementById('ent-fill').style.width = entPct + '%';
281
- document.getElementById('ent-value').textContent = r.entropy.toFixed(4);
282
- if (r.margin !== undefined && document.getElementById('margin-value')) {
283
- document.getElementById('margin-value').textContent = r.margin.toFixed(4);
284
- }
285
-
286
-
287
- // Prob chart
288
- const chart = document.getElementById('prob-chart');
289
- chart.innerHTML = '';
290
- const probs = r.all_probs || {};
291
- const sorted = Object.entries(probs).sort((a, b) => b[1] - a[1]);
292
- const maxProb = sorted.length ? sorted[0][1] : 1;
293
- sorted.forEach(([cat, prob]) => {
294
- const row = document.createElement('div');
295
- row.className = 'prob-row';
296
- const pct = (prob / Math.max(maxProb, 0.01)) * 100;
297
- row.innerHTML = `
298
- <span class="prob-label">${cat.replace(/_/g, ' ')}</span>
299
- <div class="prob-bar-track"><div class="prob-bar-fill" style="width:${pct}%;background:${CAT_COLORS[cat] || '#6366f1'}"></div></div>
300
- <span class="prob-val">${(prob * 100).toFixed(1)}%</span>`;
301
- chart.appendChild(row);
302
- });
303
-
304
- // Clarification
305
- const clarBox = document.getElementById('clarification-box');
306
- if (r.action === 'clarify' && r.clarification) {
307
- clarBox.style.display = 'block';
308
- document.getElementById('clarify-question').textContent = r.clarification.question_text;
309
- const optEl = document.getElementById('clarify-options');
310
- optEl.innerHTML = '';
311
- const optionTargets = r.clarification.option_targets || [];
312
- (r.clarification.options || []).forEach((o, index) => {
313
- const btn = document.createElement('button');
314
- btn.className = 'option-btn';
315
- btn.textContent = o;
316
- btn.onclick = () => {
317
- // Provide visual feedback
318
- document.querySelectorAll('#clarify-options .option-btn').forEach(b => b.disabled = true);
319
- btn.style.background = 'var(--primary)';
320
- btn.style.color = '#fff';
321
-
322
- const target = optionTargets[index]
323
- || inferClarificationTarget(o, r.clarification.relevant_classes || r.top_two_classes || [], index);
324
-
325
- // Keep the selection visible and machine-readable for repeat manual runs.
326
- const input = document.getElementById('ticket-input');
327
- input.value = input.value.trim() + '\n\n[Clarification: ' + target + ' - ' + o + ']';
328
-
329
- // Re-route with new context after a short delay
330
- setTimeout(() => {
331
- routeTicket({
332
- clarification_choice: o,
333
- clarification_target: target,
334
- clarification_question_id: r.clarification.question_id,
335
- });
336
- }, 800);
337
- };
338
- optEl.appendChild(btn);
339
- });
340
-
341
- // Remove existing badge if any
342
- const existingBadge = document.getElementById('source-badge');
343
- if (existingBadge) existingBadge.remove();
344
-
345
- // After displaying the question, add source badge
346
- const sourceBadge = document.createElement('div');
347
- sourceBadge.id = 'source-badge';
348
- sourceBadge.style.cssText = 'font-size:11px;margin-top:8px;opacity:0.6;';
349
- sourceBadge.textContent = r.clarification.source === 'llm_groq'
350
- ? 'Generated by LLaMA3 via Groq'
351
- : '📋 Selected from template bank';
352
- document.getElementById('clarification-box').appendChild(sourceBadge);
353
-
354
- document.getElementById('clarify-gain').textContent =
355
- `Expected information gain: ${r.clarification.expected_gain?.toFixed(4) || 'N/A'}`;
356
- } else {
357
- clarBox.style.display = 'none';
358
- }
359
-
360
- // Signals
361
- const slaRiskVal = r.sla_risk || r.sla_breach_probability || 0;
362
- const slaPct = slaRiskVal * 100;
363
- document.getElementById('sla-value').textContent = slaPct.toFixed(1) + '%';
364
- document.getElementById('sla-fill').style.width = slaPct + '%';
365
- document.getElementById('sla-fill').style.background =
366
- slaPct > 65 ? 'var(--red)' : slaPct > 35 ? 'var(--yellow)' : 'var(--green)';
367
-
368
- const feat = r.features || {};
369
- const sent = feat.sentiment_score;
370
- const sentLabel = feat.sentiment_label || sentimentLabelFromScore(sent);
371
- const sentimentValue = document.getElementById('sentiment-value');
372
- sentimentValue.textContent = sentLabel ? sentLabel.toUpperCase() : '';
373
- sentimentValue.style.color = sentimentColor(sentLabel, sent);
374
- const sentimentScore = document.getElementById('sentiment-score');
375
- if (sentimentScore) {
376
- const raw = typeof feat.sentiment_raw_score === 'number'
377
- ? ` raw ${feat.sentiment_raw_score.toFixed(2)}`
378
- : '';
379
- sentimentScore.textContent = sent !== undefined ? `score ${sent.toFixed(2)}${raw}` : '';
380
- }
381
-
382
- const urgScore = numericValue(r.urgency_score, feat.urgency_score, 0);
383
- const urgLevel = feat.urgency_level || urgencyLevelFromScore(urgScore);
384
- const urgencyCard = document.getElementById('urgency-value').parentElement;
385
- const urgencyValue = document.getElementById('urgency-value');
386
- urgencyValue.textContent = urgLevel.toUpperCase();
387
- urgencyValue.style.color = urgencyColor(urgLevel);
388
- const urgencyScore = document.getElementById('urgency-score');
389
- if (urgencyScore) urgencyScore.textContent = `score ${urgScore.toFixed(2)}`;
390
-
391
- if (urgLevel === 'critical') {
392
- urgencyCard.style.border = '1px solid var(--red)';
393
- urgencyCard.style.boxShadow = '0 0 15px rgba(248, 113, 113, 0.2)';
394
- } else if (urgLevel === 'high') {
395
- urgencyCard.style.border = '1px solid var(--yellow)';
396
- urgencyCard.style.boxShadow = '';
397
- } else {
398
- urgencyCard.style.border = '';
399
- urgencyCard.style.boxShadow = '';
400
- }
401
-
402
- renderEvidenceList('urgency-evidence-list', feat.urgency_evidence || []);
403
- renderEvidenceList('sentiment-evidence-list', feat.sentiment_evidence || []);
404
-
405
- document.getElementById('latency-value').textContent =
406
- r.latency_ms ? r.latency_ms + 'ms' : '';
407
-
408
- // Reason
409
- let decisionReason = '';
410
- if (r.clarification_applied) {
411
- decisionReason = `Clarification answer applied: <strong>${escapeHtml(r.clarification_choice || r.top_category)}</strong>. Routing to <strong>${r.top_category}</strong> without asking another question.`;
412
- } else if (r.action === 'multi_route') {
413
- decisionReason = `Multiple distinct intents detected in the request. Primary intent is <strong>${r.primary_queue}</strong>, secondary is <strong>${r.secondary_queue}</strong>.`;
414
- } else if (r.action === 'clarify') {
415
- decisionReason = `Model uncertainty is high (entropy: ${r.entropy.toFixed(3)}) or the top two classes are too close (margin: ${r.margin?.toFixed(3)}). A clarification question was generated to refine the intent.`;
416
- } else if (r.action === 'escalate') {
417
- decisionReason = `Low model confidence detected (${(r.confidence * 100).toFixed(1)}%). Routing directly to human experts to ensure accuracy.`;
418
- } else {
419
- decisionReason = `High-confidence intent detected: <strong>${r.top_category}</strong>. Automatically routing to specialized queue.`;
420
- }
421
-
422
- document.getElementById('result-reason').innerHTML = `
423
- <div style="padding: 12px; background: rgba(192, 193, 255, 0.05); border: 1px solid rgba(192, 193, 255, 0.1); border-radius: 8px; margin-top: 16px;">
424
- <div style="font-size: 11px; text-transform: uppercase; color: var(--primary); margin-bottom: 8px; font-weight: 600;">Decision Reason</div>
425
- <div style="font-size: 13px; color: var(--on-surface-variant); line-height: 1.5;">${decisionReason}</div>
426
- </div>
427
- `;
428
-
429
- // Show explain button for valid input
430
- const explainBtn = document.getElementById('explain-btn');
431
- if (explainBtn) {
432
- explainBtn.style.display = 'flex';
433
- explainBtn.dataset.text = routedText || document.getElementById('ticket-input').value;
434
- explainBtn.dataset.category = r.top_category;
435
- }
436
- document.getElementById('explanation-box').style.display = 'none';
437
- }
438
-
439
- // ── Explain Decision (SHAP) ───────────────────────────
440
- async function explainDecision() {
441
- const btn = document.getElementById('explain-btn');
442
- const text = btn.dataset.text;
443
- const targetClass = btn.dataset.category;
444
-
445
- btn.innerHTML = '<span class="spinner"></span> Analyzing tokens...';
446
- btn.disabled = true;
447
-
448
- try {
449
- let result;
450
- if (apiOnline) {
451
- const res = await fetch(`${API_BASE}/explain`, {
452
- method: 'POST',
453
- headers: { 'Content-Type': 'application/json' },
454
- body: JSON.stringify({ text, target_class: targetClass }),
455
- });
456
- if (!res.ok) throw new Error(`Explain API returned ${res.status}`);
457
- result = await res.json();
458
- } else {
459
- // Simulate SHAP for demo mode
460
- result = simulateSHAP(text);
461
- }
462
-
463
- renderSHAP(result);
464
- } catch (err) {
465
- console.error('SHAP failed:', err);
466
- renderSHAP(simulateSHAP(text));
467
- }
468
-
469
- btn.innerHTML = '<span class="material-symbols-outlined btn-icon">query_stats</span> Analyze Decision';
470
- btn.disabled = false;
471
- }
472
-
473
- function renderSHAP(data) {
474
- const box = document.getElementById('explanation-box');
475
- const textEl = document.getElementById('explain-text');
476
- box.style.display = 'block';
477
- textEl.innerHTML = '';
478
-
479
- if (data.error) {
480
- textEl.textContent = 'Error generating explanation: ' + data.error;
481
- return;
482
- }
483
-
484
- const source = document.createElement('div');
485
- source.className = 'explain-source';
486
- source.textContent = data.source === 'shap_transformer'
487
- ? 'Transformer SHAP explanation'
488
- : 'Keyword evidence fallback';
489
- if (data.note) source.title = data.note;
490
- textEl.appendChild(source);
491
-
492
- const tokens = data.tokens || [];
493
- const values = data.values || [];
494
-
495
- tokens.forEach((token, i) => {
496
- const val = values[i];
497
- const span = document.createElement('span');
498
- span.className = 'shap-token';
499
- span.textContent = token.replace('##', ''); // Simple handling for subwords
500
-
501
- // Normalize opacity based on value
502
- const absVal = Math.abs(val);
503
- const opacity = Math.min(absVal * 5, 0.8); // Scale for visibility
504
-
505
- if (val > 0) {
506
- span.style.background = `rgba(74, 222, 128, ${opacity})`;
507
- span.style.borderBottom = `2px solid rgba(74, 222, 128, ${opacity + 0.2})`;
508
- } else if (val < 0) {
509
- span.style.background = `rgba(248, 113, 113, ${opacity})`;
510
- span.style.borderBottom = `2px solid rgba(248, 113, 113, ${opacity + 0.2})`;
511
- }
512
-
513
- textEl.appendChild(span);
514
- textEl.appendChild(document.createTextNode(' '));
515
- });
516
-
517
- box.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
518
- }
519
-
520
- function simulateSHAP(text) {
521
- const tokens = text.split(/\s+/);
522
- const values = tokens.map(() => (Math.random() - 0.4) * 0.2);
523
- return { tokens, values, source: 'demo_simulated' };
524
- }
525
-
526
- function numericValue(...values) {
527
- for (const value of values) {
528
- if (typeof value === 'number' && Number.isFinite(value)) return value;
529
- }
530
- return 0;
531
- }
532
-
533
- function sentimentLabelFromScore(score) {
534
- if (typeof score !== 'number') return null;
535
- if (score <= -0.55) return 'frustrated';
536
- if (score <= -0.2) return 'concerned';
537
- if (score >= 0.3) return 'positive';
538
- return 'neutral';
539
- }
540
-
541
- function sentimentColor(label, score) {
542
- const normalized = (label || sentimentLabelFromScore(score) || '').toLowerCase();
543
- if (normalized === 'frustrated') return 'var(--red)';
544
- if (normalized === 'concerned') return 'var(--yellow)';
545
- if (normalized === 'positive') return 'var(--green)';
546
- return 'var(--text)';
547
- }
548
-
549
- function urgencyLevelFromScore(score) {
550
- if (score >= 0.75) return 'critical';
551
- if (score >= 0.5) return 'high';
552
- if (score >= 0.25) return 'medium';
553
- return 'low';
554
- }
555
-
556
- function urgencyColor(level) {
557
- const normalized = (level || '').toLowerCase();
558
- if (normalized === 'critical') return 'var(--red)';
559
- if (normalized === 'high' || normalized === 'medium') return 'var(--yellow)';
560
- return 'var(--green)';
561
- }
562
-
563
- function escapeHtml(value) {
564
- return String(value)
565
- .replace(/&/g, '&amp;')
566
- .replace(/</g, '&lt;')
567
- .replace(/>/g, '&gt;')
568
- .replace(/"/g, '&quot;')
569
- .replace(/'/g, '&#39;');
570
- }
571
-
572
- function renderEvidenceList(elementId, evidence) {
573
- const list = document.getElementById(elementId);
574
- if (!list) return;
575
-
576
- const items = Array.isArray(evidence) ? evidence.filter(Boolean) : [];
577
- if (!items.length) {
578
- list.innerHTML = '<div class="evidence-empty">No contextual evidence triggered.</div>';
579
- return;
580
- }
581
-
582
- list.innerHTML = items.slice(0, 5).map(item => {
583
- const [rawType, ...phraseParts] = String(item).split(':');
584
- const type = rawType ? rawType.replace(/_/g, ' ') : 'signal';
585
- const phrase = phraseParts.join(':').trim() || item;
586
- return `
587
- <div class="evidence-item">
588
- <span class="evidence-type">${escapeHtml(type)}</span>
589
- <span class="evidence-phrase">${escapeHtml(phrase)}</span>
590
- </div>
591
- `;
592
- }).join('');
593
- }
594
-
595
- function inferClarificationTarget(option, relevantClasses, index) {
596
- const optionLow = String(option || '').toLowerCase();
597
- const keywordTargets = [
598
- ['billing', ['billing', 'invoice', 'payment', 'charge', 'refund', 'credit', 'pricing', 'cost', 'bill']],
599
- ['technical_support', ['software', 'error', 'technical', 'broken', 'malfunction', 'functionality', 'api', 'integration', 'performance', 'specific issue', 'data movement']],
600
- ['account_management', ['account', 'plan', 'subscription', 'administrator', 'admin', 'user management', 'regular user', 'settings']],
601
- ['feature_request', ['new capability', 'feature', 'request', 'enhancement']],
602
- ['compliance_legal', ['compliance', 'regulatory', 'audit', 'gdpr', 'security', 'data affected']],
603
- ['onboarding', ['new user', 'onboarding', 'guidance', 'training', 'walkthrough', 'setting up']],
604
- ['churn_risk', ['continuing', 'switching', 'evaluating options', 'mostly negative']],
605
- ['general_inquiry', ['general', 'guidance', 'not urgent', 'no specific deadline', 'positive']],
606
- ];
607
-
608
- for (const [category, keywords] of keywordTargets) {
609
- if (keywords.some(keyword => optionLow.includes(keyword))) return category;
610
- }
611
- return relevantClasses[index] || relevantClasses[0] || 'general_inquiry';
612
- }
613
-
614
- function firstPatternHit(text, patterns) {
615
- for (const pattern of patterns) {
616
- const match = text.match(pattern);
617
- if (match) return match[0];
618
- }
619
- return null;
620
- }
621
-
622
- function inferDemoSignals(t) {
623
- const urgencySpecs = [
624
- ['business_impact', 0.30, [
625
- /\b(?:affecting|impacting|blocking)\s+(?:our\s+)?(?:customers|users|team|business|operations|sales|revenue|payroll|launch|production)\b/,
626
- /\b(?:customers?|clients?)\s+(?:(?:are|is)\s+)?(?:waiting|blocked|affected|unable)\b/,
627
- /\b(?:cannot|can't|unable to)\s+(?:process|ship|launch|serve|sell|invoice|onboard|work|access)\b/,
628
- ]],
629
- ['deadline_pressure', 0.25, [
630
- /\b(?:in|within)\s+\d+\s*(?:min|mins|minutes|hour|hours|hrs|days?)\b/,
631
- /\b(?:by|before)\s+(?:today|tomorrow|eod|end of day|tonight|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/,
632
- /\b(?:launch|demo|go-live|renewal|payroll|board meeting|presentation)\b/,
633
- ]],
634
- ['production_outage', 0.40, [
635
- /\bproduction\s+(?:is\s+)?(?:down|blocked|broken|failing|impacted)\b/,
636
- /\b(?:all|multiple|many)\s+(?:users|customers|accounts|teams)\s+(?:are\s+)?(?:affected|blocked|down|unable)\b/,
637
- /\b(?:system|service|platform|dashboard|api)\s+(?:is\s+)?(?:down|unavailable|not responding)\b/,
638
- ]],
639
- ['access_loss', 0.25, [
640
- /\b(?:locked out|cannot access|can't access|unable to access|access is blocked)\b/,
641
- /\b(?:login|sso|authentication)\s+(?:is\s+)?(?:broken|failing|down|not working)\b/,
642
- ]],
643
- ['repeat_issue', 0.20, [
644
- /\b(?:again|still|keeps?|repeated|recurring)\b/,
645
- /\b(?:second|third|fourth)\s+time\b/,
646
- /\b(?:raised|reported|opened)\s+(?:this\s+)?(?:before|multiple times|again)\b/,
647
- ]],
648
- ];
649
-
650
- const explicitCritical = ['crash', 'blocked', 'down', 'failing', 'cannot access', 'production issue', 'outage', 'emergency', 'critical', 'urgent', 'immediately', 'blocking', 'locked out'];
651
- const explicitGeneral = ['asap', 'deadline', 'sla', 'escalate', 'priority', 'time-sensitive', 'showstopper', 'presentation'];
652
-
653
- const urgencyEvidence = [];
654
- const urgencyFlags = [];
655
- explicitCritical.forEach(word => {
656
- if (t.includes(word)) {
657
- urgencyEvidence.push(`explicit_critical: ${word}`);
658
- urgencyFlags.push(word);
659
- }
660
- });
661
- explicitGeneral.forEach(word => {
662
- if (t.includes(word)) {
663
- urgencyEvidence.push(`explicit_general: ${word}`);
664
- urgencyFlags.push(word);
665
- }
666
- });
667
-
668
- let urgencyScore = (explicitCritical.length ? 0 : 0);
669
- urgencyScore += explicitCritical.filter(word => t.includes(word)).length * 0.25;
670
- urgencyScore += explicitGeneral.filter(word => t.includes(word)).length * 0.12;
671
- urgencySpecs.forEach(([label, weight, patterns]) => {
672
- const phrase = firstPatternHit(t, patterns);
673
- if (phrase) {
674
- urgencyScore += weight;
675
- urgencyEvidence.push(`${label}: ${phrase}`);
676
- urgencyFlags.push(label);
677
- }
678
- });
679
-
680
- if (/\b(?:not urgent|no rush|whenever you can|when you have time)\b/.test(t)) {
681
- urgencyScore = Math.min(urgencyScore, 0.35);
682
- urgencyEvidence.push('deescalation: no immediate pressure');
683
- }
684
-
685
- urgencyScore = Math.max(0, Math.min(1, urgencyScore));
686
-
687
- const sentimentSpecs = [
688
- ['frustration', -0.30, [
689
- /\bfrustrat(?:ed|ing|ion)\b/,
690
- /\bnot happy\b/,
691
- /\bdisappoint(?:ed|ing|ment)\b/,
692
- /\bthis is becoming difficult\b/,
693
- /\bnot ideal\b/,
694
- /\bunacceptable\b/,
695
- /\bterrible\b/,
696
- /\bawful\b/,
697
- ]],
698
- ['trust_risk', -0.25, [
699
- /\b(?:losing|lost)\s+(?:trust|confidence)\b/,
700
- /\b(?:considering|thinking about)\s+(?:switching|leaving|cancelling|canceling)\b/,
701
- ]],
702
- ['polite_negative', -0.22, [
703
- /\b(?:this|it)\s+is\s+(?:affecting|impacting|blocking)\b/,
704
- /\b(?:could you please|please)\b.*\b(?:fix|resolve|help)\b.*\b(?:blocking|affecting|stuck|broken|failing)\b/,
705
- /\b(?:becoming|getting)\s+(?:difficult|hard|painful)\b/,
706
- ]],
707
- ];
708
-
709
- const negWords = ['frustrated','broken','terrible','angry','worst','cancel','bad','issue','error', 'invalid', 'locked out'];
710
- const posWords = ['great','thanks','love','good','happy','please'];
711
- let rawSentiment = 0;
712
- negWords.forEach(w => { if (t.includes(w)) rawSentiment -= 0.18; });
713
- posWords.forEach(w => { if (t.includes(w)) rawSentiment += 0.12; });
714
- rawSentiment = Math.max(-1, Math.min(1, rawSentiment));
715
-
716
- const sentimentEvidence = [];
717
- let sentimentScore = rawSentiment;
718
- sentimentSpecs.forEach(([label, weight, patterns]) => {
719
- const phrase = firstPatternHit(t, patterns);
720
- if (phrase) {
721
- sentimentScore += weight;
722
- sentimentEvidence.push(`${label}: ${phrase}`);
723
- }
724
- });
725
- sentimentScore = Math.max(-1, Math.min(1, sentimentScore));
726
-
727
- return {
728
- urgency_score: Math.round(urgencyScore * 10000) / 10000,
729
- urgency_level: urgencyLevelFromScore(urgencyScore),
730
- urgency_flags: Array.from(new Set(urgencyFlags)),
731
- urgency_evidence: urgencyEvidence,
732
- sentiment_score: Math.round(sentimentScore * 10000) / 10000,
733
- sentiment_raw_score: Math.round(rawSentiment * 10000) / 10000,
734
- sentiment_label: sentimentLabelFromScore(sentimentScore),
735
- sentiment_evidence: sentimentEvidence,
736
- };
737
- }
738
-
739
-
740
- // ── Seeded PRNG (deterministic per text) ──────────────
741
- function hashText(str) {
742
- let h = 0;
743
- for (let i = 0; i < str.length; i++) {
744
- h = ((h << 5) - h + str.charCodeAt(i)) | 0;
745
- }
746
- return Math.abs(h);
747
- }
748
-
749
- function seededRandom(seed) {
750
- let s = seed;
751
- return function() {
752
- s = (s * 1664525 + 1013904223) & 0xffffffff;
753
- return (s >>> 0) / 0xffffffff;
754
- };
755
- }
756
-
757
- // ── Simulation (when API is offline) ──────────────────
758
- function simulateRouting(text, extraPayload = {}) {
759
- const t = text.toLowerCase().trim();
760
- const marker = t.match(/\[clarification:\s*([a-z_]+)\s*-\s*([^\]]+)\]/);
761
- const clarificationTarget = extraPayload.clarification_target || (marker && marker[1]);
762
- const clarificationChoice = extraPayload.clarification_choice || (marker && marker[2]);
763
- const validTargets = Object.keys(CAT_COLORS);
764
-
765
- if (clarificationTarget && validTargets.includes(clarificationTarget)) {
766
- const allProbs = {};
767
- validTargets.forEach(cat => { allProbs[cat] = cat === clarificationTarget ? 0.9 : 0.0143; });
768
- const demoSignals = inferDemoSignals(t);
769
- return {
770
- action: 'route',
771
- confidence: 0.9,
772
- entropy: 0.35,
773
- margin: 0.75,
774
- top_category: clarificationTarget,
775
- all_probs: allProbs,
776
- top_two_classes: [clarificationTarget, validTargets.find(cat => cat !== clarificationTarget)],
777
- queue: clarificationTarget,
778
- reason: `Clarification answer resolved the ambiguity toward ${clarificationTarget}.`,
779
- clarification_applied: true,
780
- clarification_choice: clarificationChoice,
781
- sla_breach_probability: Math.min(0.95, 0.15 + (demoSignals.urgency_score * 0.45)),
782
- urgency_score: demoSignals.urgency_score,
783
- features: {
784
- ...demoSignals,
785
- text_complexity_score: Math.round(text.split(' ').length / 5 * 100) / 100,
786
- },
787
- latency_ms: 28 + (hashText(t) % 20),
788
- };
789
- }
790
-
791
- // Basic validation in simulation to match real API behavior
792
- if (t.length < 10) {
793
- const greetings = ['hi', 'hello', 'hey', 'test'];
794
- if (greetings.some(g => t.startsWith(g))) {
795
- return {
796
- action: 'invalid_input',
797
- error_type: 'greeting',
798
- response: "Hi there! 👋 Could you describe the issue you're experiencing? We're here to help."
799
- };
800
- }
801
- return {
802
- action: 'invalid_input',
803
- error_type: 'too_short',
804
- response: "Could you share a bit more detail about your issue? We're here to help."
805
- };
806
- }
807
-
808
- const rng = seededRandom(hashText(t)); // deterministic per text
809
-
810
- const scores = {
811
- billing: 0.02, technical_support: 0.02, account_management: 0.02,
812
- feature_request: 0.02, compliance_legal: 0.02, onboarding: 0.02,
813
- general_inquiry: 0.02, churn_risk: 0.02,
814
- };
815
-
816
- // Simple keyword scoring
817
- const kw = {
818
- billing: ['invoice','billing','payment','charge','refund','price','cost','subscription','plan','pricing','credit'],
819
- technical_support: ['error','bug','broken','crash','fix','api','endpoint','500','timeout','issue','not working','failed'],
820
- account_management: ['account','user','access','permission','settings','profile','password','role'],
821
- feature_request: ['feature','add','implement','suggest','request','capability','enhancement','wish','could you'],
822
- compliance_legal: ['gdpr','compliance','audit','regulation','privacy','security','data protection','legal'],
823
- onboarding: ['new user','setup','getting started','onboarding','first time','just signed up','configure','install'],
824
- general_inquiry: ['how do','what is','question','information','help','guide','documentation'],
825
- churn_risk: ['cancel','switch','competitor','alternative','frustrated','unacceptable','leaving','terminate','fed up','last straw'],
826
- };
827
-
828
- Object.entries(kw).forEach(([cat, words]) => {
829
- words.forEach(w => { if (t.includes(w)) scores[cat] += 0.15 + rng() * 0.05; });
830
- });
831
-
832
- // Normalize
833
- const total = Object.values(scores).reduce((a, b) => a + b, 0);
834
- Object.keys(scores).forEach(k => scores[k] /= total);
835
-
836
- // Add small deterministic noise (simulate MC Dropout variance)
837
- Object.keys(scores).forEach(k => {
838
- scores[k] += (rng() - 0.5) * 0.03;
839
- scores[k] = Math.max(0.001, scores[k]);
840
- });
841
- const total2 = Object.values(scores).reduce((a, b) => a + b, 0);
842
- Object.keys(scores).forEach(k => scores[k] /= total2);
843
-
844
- const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
845
- const confidence = sorted[0][1];
846
- const entropy = -Object.values(scores).reduce((s, p) => s + p * Math.log(p + 1e-9), 0);
847
- const topCat = sorted[0][0];
848
- const topTwo = [sorted[0][0], sorted[1][0]];
849
- const margin = sorted[0][1] - sorted[1][1];
850
-
851
- let action, reason;
852
- const critical_labels = ['compliance_legal', 'account_management'];
853
-
854
- if (critical_labels.includes(topCat)) {
855
- if (confidence >= 0.90 && margin >= 0.35 && entropy < 0.60) {
856
- action = 'route';
857
- reason = ` Safe to auto-route sensitive intent<br> Confidence: ${(confidence*100).toFixed(1)}%<br> Margin: ${margin.toFixed(2)}`;
858
- } else {
859
- action = 'escalate';
860
- reason = ` Escalated sensitive intent (${topCat.replace(/_/g,' ')})<br> Strict confidence/margin threshold not met`;
861
- }
862
- } else {
863
- if (confidence >= 0.85 && margin >= 0.25 && entropy < 0.70) {
864
- action = 'route';
865
- reason = ` Strong dominant intent<br> Confidence: ${(confidence*100).toFixed(1)}%<br> Margin: ${margin.toFixed(2)}<br> Safe to auto-route`;
866
- } else if (confidence >= 0.60 && entropy < 1.05) {
867
- action = 'clarify';
868
- reason = ` Medium ambiguity detected<br> Clarification needed between ${topTwo[0].replace(/_/g,' ')} and ${topTwo[1].replace(/_/g,' ')}<br> Margin: ${margin.toFixed(2)}`;
869
- } else {
870
- action = 'escalate';
871
- reason = ` High ambiguity / Low confidence (${(confidence*100).toFixed(1)}%)<br> Multiple overlapping intents detected<br> Human triage needed`;
872
- }
873
- }
874
-
875
- // Clarification question
876
- let clarification = null;
877
- if (action === 'clarify') {
878
- const questions = {
879
- 'billing+technical_support': { question_text: 'Is the main issue related to (A) a software error, or (B) your billing or invoice?', options: ['Software error','Billing/invoice'], expected_gain: 0.71 },
880
- 'technical_support+billing': { question_text: 'Is the main issue related to (A) a software error, or (B) your billing or invoice?', options: ['Software error','Billing/invoice'], expected_gain: 0.71 },
881
- 'feature_request+technical_support': { question_text: 'Are you reporting something broken, or requesting a new capability?', options: ['Something broken','New feature'], expected_gain: 0.68 },
882
- 'technical_support+feature_request': { question_text: 'Are you reporting something broken, or requesting a new capability?', options: ['Something broken','New feature'], expected_gain: 0.68 },
883
- 'churn_risk+account_management': { question_text: 'Are you looking to change your plan, or do you have concerns about continuing?', options: ['Change plan','Concerns about continuing'], expected_gain: 0.74 },
884
- 'account_management+churn_risk': { question_text: 'Are you looking to change your plan, or do you have concerns about continuing?', options: ['Change plan','Concerns about continuing'], expected_gain: 0.74 },
885
- 'onboarding+technical_support': { question_text: 'Is this affecting a new user, or an existing user?', options: ['New user','Existing user'], expected_gain: 0.65 },
886
- 'technical_support+onboarding': { question_text: 'Is this affecting a new user, or an existing user?', options: ['New user','Existing user'], expected_gain: 0.65 },
887
- 'compliance_legal+billing': { question_text: 'Does this relate to a regulatory requirement, or to payment/invoicing?', options: ['Regulatory','Payment'], expected_gain: 0.72 },
888
- 'billing+compliance_legal': { question_text: 'Does this relate to a regulatory requirement, or to payment/invoicing?', options: ['Regulatory','Payment'], expected_gain: 0.72 },
889
- 'technical_support+general_inquiry': { question_text: 'Is this a specific technical problem, or a general question about how something works?', options: ['Specific problem','General question'], expected_gain: 0.66 },
890
- 'general_inquiry+technical_support': { question_text: 'Is this a specific technical problem, or a general question about how something works?', options: ['Specific problem','General question'], expected_gain: 0.66 },
891
- 'billing+general_inquiry': { question_text: 'Is your question about a specific charge on your account, or general pricing information?', options: ['Specific charge','General pricing'], expected_gain: 0.64 },
892
- 'general_inquiry+billing': { question_text: 'Is your question about a specific charge on your account, or general pricing information?', options: ['Specific charge','General pricing'], expected_gain: 0.64 },
893
- 'churn_risk+technical_support': { question_text: 'Is the main concern a technical problem you need fixed, or are you considering leaving the platform?', options: ['Technical problem','Considering leaving'], expected_gain: 0.76 },
894
- 'technical_support+churn_risk': { question_text: 'Is the main concern a technical problem you need fixed, or are you considering leaving the platform?', options: ['Technical problem','Considering leaving'], expected_gain: 0.76 },
895
- };
896
- const key = topTwo[0] + '+' + topTwo[1];
897
- clarification = questions[key] || {
898
- question_text: 'Could you specify whether this is about a technical issue or an account/billing matter?',
899
- options: ['Technical issue', 'Account/billing'], expected_gain: 0.62,
900
- };
901
- clarification.question_id = 'Q_SIM';
902
- }
903
-
904
- const demoSignals = inferDemoSignals(t);
905
-
906
- // SLA deterministic based on text features
907
- const outageWords = ['down', 'outage', 'crash', 'failing', 'blocked'];
908
- const outageFlags = outageWords.filter(w => t.includes(w));
909
- const slaBase = 0.15
910
- + (demoSignals.sentiment_score < -0.3 ? 0.2 : 0)
911
- + (demoSignals.urgency_score * 0.45)
912
- + (outageFlags.length * 0.15);
913
- const slaBreach = Math.min(Math.round(slaBase * 1000) / 1000, 0.95);
914
-
915
- return {
916
- action, confidence: Math.round(confidence * 10000) / 10000,
917
- entropy: Math.round(entropy * 10000) / 10000,
918
- margin: Math.round(margin * 10000) / 10000,
919
- top_category: topCat, all_probs: scores,
920
- top_two_classes: topTwo, queue: topCat,
921
- reason, clarification,
922
- sla_breach_probability: slaBreach,
923
- urgency_score: demoSignals.urgency_score,
924
- features: {
925
- ...demoSignals,
926
- text_complexity_score: Math.round(text.split(' ').length / 5 * 100) / 100,
927
- },
928
- latency_ms: 38 + (hashText(t) % 30),
929
- };
930
- }
 
1
+ // SupportMind Dashboard - app.js
2
+ // Interactive demo with real API calls (falls back to simulation if API unavailable)
3
+
4
+ let API_BASE = window.location.origin;
5
+ let apiOnline = false;
6
+
7
+ function apiCandidates() {
8
+ const candidates = [];
9
+ const url = new URL(window.location.href);
10
+ if (url.hostname === '127.0.0.1' && url.port === '7860') {
11
+ candidates.push('http://127.0.0.1:7862');
12
+ candidates.push('http://127.0.0.1:7861');
13
+ }
14
+ candidates.push(window.location.origin);
15
+ return [...new Set(candidates)];
16
+ }
17
+
18
+ // Category colors
19
+ const CAT_COLORS = {
20
+ billing: '#fb923c', technical_support: '#8083ff', account_management: '#89ceff',
21
+ feature_request: '#c0c1ff', compliance_legal: '#f87171', onboarding: '#4ade80',
22
+ general_inquiry: '#94a3b8', churn_risk: '#facc15',
23
+ };
24
+
25
+ // -- Init ----------------------------------------------
26
+ document.addEventListener('DOMContentLoaded', () => {
27
+ animateCounters();
28
+ initPresets();
29
+ initDropoutViz();
30
+ initScrollAnimations();
31
+ initSmoothScroll();
32
+ checkAPI();
33
+ updateMetrics();
34
+ setInterval(updateMetrics, 5000); // Update every 5 seconds
35
+ });
36
+
37
+ // -- Counter Animation ---------------------------------
38
+ function animateCounters() {
39
+ document.querySelectorAll('.stat-card').forEach(card => {
40
+ const counter = card.querySelector('.counter');
41
+ const target = parseFloat(card.dataset.value);
42
+ const duration = 1500;
43
+ const start = performance.now();
44
+ function update(now) {
45
+ const elapsed = now - start;
46
+ const progress = Math.min(elapsed / duration, 1);
47
+ const eased = 1 - Math.pow(1 - progress, 3);
48
+ counter.textContent = Math.round(target * eased * 10) / 10;
49
+ if (progress < 1) requestAnimationFrame(update);
50
+ else counter.textContent = target;
51
+ }
52
+ requestAnimationFrame(update);
53
+ });
54
+ }
55
+
56
+ // -- Presets --------------------------------------------
57
+ // -- Live Telemetry Engine ---------------------------
58
+ async function updateMetrics() {
59
+ try {
60
+ const res = await fetch(`${API_BASE}/metrics`);
61
+ if (!res.ok) return;
62
+ const data = await res.json();
63
+
64
+ // Update Counter
65
+ document.getElementById('live-total').textContent = data.total_requests.toLocaleString();
66
+
67
+ // Update Model Name
68
+ document.getElementById('live-model').textContent = data.model;
69
+
70
+ // Update Distribution Bar
71
+ const dist = data.routing_distribution;
72
+ document.getElementById('dist-route').style.width = `${dist.route_pct}%`;
73
+ document.getElementById('dist-clarify').style.width = `${dist.clarify_pct}%`;
74
+ document.getElementById('dist-escalate').style.width = `${dist.escalate_pct}%`;
75
+
76
+ // Update Status Pulse
77
+ const indicator = document.getElementById('live-indicator');
78
+ indicator.style.opacity = '1';
79
+ setTimeout(() => { indicator.style.opacity = '0.8'; }, 500);
80
+
81
+ } catch (err) {
82
+ console.warn("Metrics sync failed:", err);
83
+ }
84
+ }
85
+
86
+ // -- Presets --------------------------------------------
87
+ function initPresets() {
88
+ document.querySelectorAll('.preset-btn').forEach(btn => {
89
+ btn.addEventListener('click', () => {
90
+ document.getElementById('ticket-input').value = btn.dataset.text;
91
+ });
92
+ });
93
+ }
94
+
95
+ function initSmoothScroll() {
96
+ document.querySelectorAll('a[href^="#"]').forEach(anchor => {
97
+ anchor.addEventListener('click', function (e) {
98
+ e.preventDefault();
99
+ document.querySelector(this.getAttribute('href')).scrollIntoView({
100
+ behavior: 'smooth'
101
+ });
102
+ });
103
+ });
104
+ }
105
+
106
+ // -- MC Dropout Visualization --------------------------
107
+ function initDropoutViz() {
108
+ const grid = document.getElementById('dropout-grid');
109
+ if (!grid) return;
110
+ for (let pass = 0; pass < 20; pass++) {
111
+ const col = document.createElement('div');
112
+ col.className = 'dropout-col';
113
+ for (let neuron = 0; neuron < 12; neuron++) {
114
+ const cell = document.createElement('div');
115
+ cell.className = 'dropout-cell';
116
+ const active = Math.random() > 0.15;
117
+ cell.style.background = active ? 'var(--primary)' : 'rgba(192, 193, 255, 0.05)';
118
+ cell.style.border = active ? 'none' : '1px solid rgba(192, 193, 255, 0.1)';
119
+ col.appendChild(cell);
120
+ }
121
+ grid.appendChild(col);
122
+ }
123
+ // Animate dropout
124
+ setInterval(() => {
125
+ grid.querySelectorAll('.dropout-cell').forEach(cell => {
126
+ const active = Math.random() > 0.15;
127
+ cell.style.background = active ? 'var(--primary)' : 'rgba(192, 193, 255, 0.05)';
128
+ cell.style.border = active ? 'none' : '1px solid rgba(192, 193, 255, 0.1)';
129
+ });
130
+ }, 2000);
131
+ }
132
+
133
+ // -- Scroll Animations ---------------------------------
134
+ function initScrollAnimations() {
135
+ const observer = new IntersectionObserver((entries) => {
136
+ entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('visible'); });
137
+ }, { threshold: 0.1 });
138
+ document.querySelectorAll('.section-header, .stat-card, .arch-stage, .bench-card, .ops-card').forEach(el => {
139
+ el.classList.add('fade-in');
140
+ observer.observe(el);
141
+ });
142
+ }
143
+
144
+ // -- API Check -----------------------------------------
145
+ async function checkAPI() {
146
+ for (const candidate of apiCandidates()) {
147
+ try {
148
+ const res = await fetch(`${candidate}/health`, { signal: AbortSignal.timeout(2000) });
149
+ if (!res.ok) continue;
150
+ API_BASE = candidate;
151
+ apiOnline = true;
152
+ const statusEl = document.querySelector('.status-text');
153
+ if (statusEl) statusEl.textContent = API_BASE === window.location.origin ? 'API Connected' : 'Fixed API Connected';
154
+ return;
155
+ } catch {
156
+ // Try the next candidate before falling back to demo mode.
157
+ }
158
+ }
159
+ apiOnline = false;
160
+ const statusEl = document.querySelector('.status-text');
161
+ if (statusEl) statusEl.textContent = 'Demo Mode';
162
+ }
163
+
164
+ // -- Live Metrics --------------------------------------
165
+ async function updateLiveMetrics() {
166
+ if (!apiOnline) return;
167
+ try {
168
+ const res = await fetch(`${API_BASE}/metrics`);
169
+ const data = await res.json();
170
+
171
+ document.getElementById('live-model').textContent = data.model;
172
+ document.getElementById('live-total').textContent = data.total_requests;
173
+
174
+ const dist = data.routing_distribution;
175
+ document.getElementById('dist-route').style.width = dist.route_pct + '%';
176
+ document.getElementById('dist-clarify').style.width = dist.clarify_pct + '%';
177
+ document.getElementById('dist-escalate').style.width = dist.escalate_pct + '%';
178
+ } catch (err) {
179
+ console.warn('Metrics update failed:', err);
180
+ }
181
+ }
182
+
183
+ // -- Route Ticket --------------------------------------
184
+ async function routeTicket(extraPayload = {}) {
185
+ const text = document.getElementById('ticket-input').value.trim();
186
+ if (!text) return;
187
+
188
+ const btn = document.getElementById('route-btn');
189
+ btn.innerHTML = '<span class="spinner"></span> Routing...';
190
+ btn.disabled = true;
191
+
192
+ let result;
193
+ try {
194
+ if (apiOnline) {
195
+ const res = await fetch(`${API_BASE}/route`, {
196
+ method: 'POST',
197
+ headers: { 'Content-Type': 'application/json' },
198
+ body: JSON.stringify({ text, ...extraPayload }),
199
+ });
200
+ result = await res.json();
201
+ } else {
202
+ result = simulateRouting(text, extraPayload);
203
+ }
204
+ displayResult(result, text);
205
+ } catch (err) {
206
+ result = simulateRouting(text, extraPayload);
207
+ displayResult(result, text);
208
+ }
209
+
210
+ btn.innerHTML = 'Route Ticket';
211
+ btn.disabled = false;
212
+ }
213
+
214
+ // -- Display Result ------------------------------------
215
+ function displayResult(r, routedText) {
216
+ // Handle edge cases
217
+ if (r.action === 'invalid_input') {
218
+ document.getElementById('result-placeholder').style.display = 'none';
219
+ const content = document.getElementById('result-content');
220
+ content.style.display = 'block';
221
+
222
+ const badge = document.getElementById('action-badge');
223
+ badge.textContent = r.error_type.toUpperCase().replace('_', ' ');
224
+ badge.className = 'action-badge clarify'; // yellow
225
+
226
+ document.getElementById('action-queue').textContent = r.response;
227
+ document.getElementById('result-reason').textContent = r.response;
228
+
229
+ // Hide gauges for invalid input
230
+ document.querySelector('.gauge-row').style.display = 'none';
231
+ document.querySelector('.signals-grid').style.display = 'none';
232
+ document.getElementById('prob-chart').innerHTML = '';
233
+ document.getElementById('clarification-box').style.display = 'none';
234
+ const evidenceGrid = document.getElementById('signal-evidence-grid');
235
+ if (evidenceGrid) evidenceGrid.style.display = 'none';
236
+ const explainBtn = document.getElementById('explain-btn');
237
+ if (explainBtn) explainBtn.style.display = 'none';
238
+ document.getElementById('explanation-box').style.display = 'none';
239
+ return;
240
+ }
241
+
242
+
243
+ // Show gauges for valid input
244
+ document.querySelector('.gauge-row').style.display = 'grid';
245
+ document.querySelector('.signals-grid').style.display = 'grid';
246
+
247
+ document.getElementById('result-placeholder').style.display = 'none';
248
+ const content = document.getElementById('result-content');
249
+ content.style.display = 'block';
250
+ const evidenceGrid = document.getElementById('signal-evidence-grid');
251
+ if (evidenceGrid) evidenceGrid.style.display = 'grid';
252
+
253
+ // Action Badge Logic
254
+ const badge = document.getElementById('action-badge');
255
+ const queue = document.getElementById('action-queue');
256
+
257
+ if (r.action === 'multi_route') {
258
+ badge.textContent = 'MULTI-ROUTE';
259
+ badge.className = 'action-badge';
260
+ badge.style.background = 'linear-gradient(90deg, var(--primary), var(--accent))';
261
+ queue.innerHTML = `
262
+ <div style="display: flex; gap: 8px; margin-top: 4px;">
263
+ <span class="tech-tag" style="background: rgba(192, 193, 255, 0.2)">Primary: ${r.primary_queue}</span>
264
+ <span class="tech-tag" style="background: rgba(255, 255, 255, 0.1)">Secondary: ${r.secondary_queue}</span>
265
+ </div>
266
+ `;
267
+ } else {
268
+ badge.textContent = r.action.toUpperCase();
269
+ badge.className = `action-badge ${r.action}`;
270
+ queue.textContent = r.action === 'route' ? `-> ${r.queue || r.top_category} queue` :
271
+ r.action === 'clarify' ? 'Needs 1 clarification question' : 'Immediate human triage';
272
+ }
273
+
274
+ // Gauges
275
+ const confPct = Math.min(r.confidence * 100, 100);
276
+ document.getElementById('conf-fill').style.width = confPct + '%';
277
+ document.getElementById('conf-value').textContent = r.confidence.toFixed(4);
278
+ const maxEnt = Math.log(8);
279
+ const entPct = Math.min((r.entropy / maxEnt) * 100, 100);
280
+ document.getElementById('ent-fill').style.width = entPct + '%';
281
+ document.getElementById('ent-value').textContent = r.entropy.toFixed(4);
282
+ if (r.margin !== undefined && document.getElementById('margin-value')) {
283
+ document.getElementById('margin-value').textContent = r.margin.toFixed(4);
284
+ }
285
+
286
+
287
+ // Prob chart
288
+ const chart = document.getElementById('prob-chart');
289
+ chart.innerHTML = '';
290
+ const probs = r.all_probs || {};
291
+ const sorted = Object.entries(probs).sort((a, b) => b[1] - a[1]);
292
+ const maxProb = sorted.length ? sorted[0][1] : 1;
293
+ sorted.forEach(([cat, prob]) => {
294
+ const row = document.createElement('div');
295
+ row.className = 'prob-row';
296
+ const pct = (prob / Math.max(maxProb, 0.01)) * 100;
297
+ row.innerHTML = `
298
+ <span class="prob-label">${cat.replace(/_/g, ' ')}</span>
299
+ <div class="prob-bar-track"><div class="prob-bar-fill" style="width:${pct}%;background:${CAT_COLORS[cat] || '#6366f1'}"></div></div>
300
+ <span class="prob-val">${(prob * 100).toFixed(1)}%</span>`;
301
+ chart.appendChild(row);
302
+ });
303
+
304
+ // Clarification
305
+ const clarBox = document.getElementById('clarification-box');
306
+ if (r.action === 'clarify' && r.clarification) {
307
+ clarBox.style.display = 'block';
308
+ document.getElementById('clarify-question').textContent = r.clarification.question_text;
309
+ const optEl = document.getElementById('clarify-options');
310
+ optEl.innerHTML = '';
311
+ const optionTargets = r.clarification.option_targets || [];
312
+ (r.clarification.options || []).forEach((o, index) => {
313
+ const btn = document.createElement('button');
314
+ btn.className = 'option-btn';
315
+ btn.textContent = o;
316
+ btn.onclick = () => {
317
+ // Provide visual feedback
318
+ document.querySelectorAll('#clarify-options .option-btn').forEach(b => b.disabled = true);
319
+ btn.style.background = 'var(--primary)';
320
+ btn.style.color = '#fff';
321
+
322
+ const target = optionTargets[index]
323
+ || inferClarificationTarget(o, r.clarification.relevant_classes || r.top_two_classes || [], index);
324
+
325
+ // Keep the selection visible and machine-readable for repeat manual runs.
326
+ const input = document.getElementById('ticket-input');
327
+ input.value = input.value.trim() + '\n\n[Clarification: ' + target + ' - ' + o + ']';
328
+
329
+ // Re-route with new context after a short delay
330
+ setTimeout(() => {
331
+ routeTicket({
332
+ clarification_choice: o,
333
+ clarification_target: target,
334
+ clarification_question_id: r.clarification.question_id,
335
+ });
336
+ }, 800);
337
+ };
338
+ optEl.appendChild(btn);
339
+ });
340
+
341
+ // Remove existing badge if any
342
+ const existingBadge = document.getElementById('source-badge');
343
+ if (existingBadge) existingBadge.remove();
344
+
345
+ // After displaying the question, add source badge
346
+ const sourceBadge = document.createElement('div');
347
+ sourceBadge.id = 'source-badge';
348
+ sourceBadge.style.cssText = 'font-size:11px;margin-top:8px;opacity:0.6;';
349
+ sourceBadge.textContent = r.clarification.source === 'llm_groq'
350
+ ? 'Generated by LLaMA3 via Groq'
351
+ : 'Template: Selected from template bank';
352
+ document.getElementById('clarification-box').appendChild(sourceBadge);
353
+
354
+ document.getElementById('clarify-gain').textContent =
355
+ `Expected information gain: ${r.clarification.expected_gain?.toFixed(4) || 'N/A'}`;
356
+ } else {
357
+ clarBox.style.display = 'none';
358
+ }
359
+
360
+ // Signals
361
+ const slaRiskVal = r.sla_risk || r.sla_breach_probability || 0;
362
+ const slaPct = slaRiskVal * 100;
363
+ document.getElementById('sla-value').textContent = slaPct.toFixed(1) + '%';
364
+ document.getElementById('sla-fill').style.width = slaPct + '%';
365
+ document.getElementById('sla-fill').style.background =
366
+ slaPct > 65 ? 'var(--red)' : slaPct > 35 ? 'var(--yellow)' : 'var(--green)';
367
+
368
+ const feat = r.features || {};
369
+ const sent = feat.sentiment_score;
370
+ const sentLabel = feat.sentiment_label || sentimentLabelFromScore(sent);
371
+ const sentimentValue = document.getElementById('sentiment-value');
372
+ sentimentValue.textContent = sentLabel ? sentLabel.toUpperCase() : '-';
373
+ sentimentValue.style.color = sentimentColor(sentLabel, sent);
374
+ const sentimentScore = document.getElementById('sentiment-score');
375
+ if (sentimentScore) {
376
+ const raw = typeof feat.sentiment_raw_score === 'number'
377
+ ? ` raw ${feat.sentiment_raw_score.toFixed(2)}`
378
+ : '';
379
+ sentimentScore.textContent = sent !== undefined ? `score ${sent.toFixed(2)}${raw}` : '-';
380
+ }
381
+
382
+ const urgScore = numericValue(r.urgency_score, feat.urgency_score, 0);
383
+ const urgLevel = feat.urgency_level || urgencyLevelFromScore(urgScore);
384
+ const urgencyCard = document.getElementById('urgency-value').parentElement;
385
+ const urgencyValue = document.getElementById('urgency-value');
386
+ urgencyValue.textContent = urgLevel.toUpperCase();
387
+ urgencyValue.style.color = urgencyColor(urgLevel);
388
+ const urgencyScore = document.getElementById('urgency-score');
389
+ if (urgencyScore) urgencyScore.textContent = `score ${urgScore.toFixed(2)}`;
390
+
391
+ if (urgLevel === 'critical') {
392
+ urgencyCard.style.border = '1px solid var(--red)';
393
+ urgencyCard.style.boxShadow = '0 0 15px rgba(248, 113, 113, 0.2)';
394
+ } else if (urgLevel === 'high') {
395
+ urgencyCard.style.border = '1px solid var(--yellow)';
396
+ urgencyCard.style.boxShadow = '';
397
+ } else {
398
+ urgencyCard.style.border = '';
399
+ urgencyCard.style.boxShadow = '';
400
+ }
401
+
402
+ renderEvidenceList('urgency-evidence-list', feat.urgency_evidence || []);
403
+ renderEvidenceList('sentiment-evidence-list', feat.sentiment_evidence || []);
404
+
405
+ document.getElementById('latency-value').textContent =
406
+ r.latency_ms ? r.latency_ms + 'ms' : '-';
407
+
408
+ // Reason
409
+ let decisionReason = '';
410
+ if (r.clarification_applied) {
411
+ decisionReason = `Clarification answer applied: <strong>${escapeHtml(r.clarification_choice || r.top_category)}</strong>. Routing to <strong>${r.top_category}</strong> without asking another question.`;
412
+ } else if (r.action === 'multi_route') {
413
+ decisionReason = `Multiple distinct intents detected in the request. Primary intent is <strong>${r.primary_queue}</strong>, secondary is <strong>${r.secondary_queue}</strong>.`;
414
+ } else if (r.action === 'clarify') {
415
+ decisionReason = `Model uncertainty is high (entropy: ${r.entropy.toFixed(3)}) or the top two classes are too close (margin: ${r.margin?.toFixed(3)}). A clarification question was generated to refine the intent.`;
416
+ } else if (r.action === 'escalate') {
417
+ decisionReason = `Low model confidence detected (${(r.confidence * 100).toFixed(1)}%). Routing directly to human experts to ensure accuracy.`;
418
+ } else {
419
+ decisionReason = `High-confidence intent detected: <strong>${r.top_category}</strong>. Automatically routing to specialized queue.`;
420
+ }
421
+
422
+ document.getElementById('result-reason').innerHTML = `
423
+ <div style="padding: 12px; background: rgba(192, 193, 255, 0.05); border: 1px solid rgba(192, 193, 255, 0.1); border-radius: 8px; margin-top: 16px;">
424
+ <div style="font-size: 11px; text-transform: uppercase; color: var(--primary); margin-bottom: 8px; font-weight: 600;">Decision Reason</div>
425
+ <div style="font-size: 13px; color: var(--on-surface-variant); line-height: 1.5;">${decisionReason}</div>
426
+ </div>
427
+ `;
428
+
429
+ // Show explain button for valid input
430
+ const explainBtn = document.getElementById('explain-btn');
431
+ if (explainBtn) {
432
+ explainBtn.style.display = 'flex';
433
+ explainBtn.dataset.text = routedText || document.getElementById('ticket-input').value;
434
+ explainBtn.dataset.category = r.top_category;
435
+ }
436
+ document.getElementById('explanation-box').style.display = 'none';
437
+ }
438
+
439
+ // -- Explain Decision (SHAP) ---------------------------
440
+ async function explainDecision() {
441
+ const btn = document.getElementById('explain-btn');
442
+ const text = btn.dataset.text;
443
+ const targetClass = btn.dataset.category;
444
+
445
+ btn.innerHTML = '<span class="spinner"></span> Analyzing tokens...';
446
+ btn.disabled = true;
447
+
448
+ try {
449
+ let result;
450
+ if (apiOnline) {
451
+ const res = await fetch(`${API_BASE}/explain`, {
452
+ method: 'POST',
453
+ headers: { 'Content-Type': 'application/json' },
454
+ body: JSON.stringify({ text, target_class: targetClass }),
455
+ });
456
+ if (!res.ok) throw new Error(`Explain API returned ${res.status}`);
457
+ result = await res.json();
458
+ } else {
459
+ // Simulate SHAP for demo mode
460
+ result = simulateSHAP(text);
461
+ }
462
+
463
+ renderSHAP(result);
464
+ } catch (err) {
465
+ console.error('SHAP failed:', err);
466
+ renderSHAP(simulateSHAP(text));
467
+ }
468
+
469
+ btn.innerHTML = '<span class="material-symbols-outlined btn-icon">query_stats</span> Analyze Decision';
470
+ btn.disabled = false;
471
+ }
472
+
473
+ function renderSHAP(data) {
474
+ const box = document.getElementById('explanation-box');
475
+ const textEl = document.getElementById('explain-text');
476
+ box.style.display = 'block';
477
+ textEl.innerHTML = '';
478
+
479
+ if (data.error) {
480
+ textEl.textContent = 'Error generating explanation: ' + data.error;
481
+ return;
482
+ }
483
+
484
+ const source = document.createElement('div');
485
+ source.className = 'explain-source';
486
+ source.textContent = data.source === 'shap_transformer'
487
+ ? 'Transformer SHAP explanation'
488
+ : 'Keyword evidence fallback';
489
+ if (data.note) source.title = data.note;
490
+ textEl.appendChild(source);
491
+
492
+ const tokens = data.tokens || [];
493
+ const values = data.values || [];
494
+
495
+ tokens.forEach((token, i) => {
496
+ const val = values[i];
497
+ const span = document.createElement('span');
498
+ span.className = 'shap-token';
499
+ span.textContent = token.replace('##', ''); // Simple handling for subwords
500
+
501
+ // Normalize opacity based on value
502
+ const absVal = Math.abs(val);
503
+ const opacity = Math.min(absVal * 5, 0.8); // Scale for visibility
504
+
505
+ if (val > 0) {
506
+ span.style.background = `rgba(74, 222, 128, ${opacity})`;
507
+ span.style.borderBottom = `2px solid rgba(74, 222, 128, ${opacity + 0.2})`;
508
+ } else if (val < 0) {
509
+ span.style.background = `rgba(248, 113, 113, ${opacity})`;
510
+ span.style.borderBottom = `2px solid rgba(248, 113, 113, ${opacity + 0.2})`;
511
+ }
512
+
513
+ textEl.appendChild(span);
514
+ textEl.appendChild(document.createTextNode(' '));
515
+ });
516
+
517
+ box.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
518
+ }
519
+
520
+ function simulateSHAP(text) {
521
+ const tokens = text.split(/\s+/);
522
+ const values = tokens.map(() => (Math.random() - 0.4) * 0.2);
523
+ return { tokens, values, source: 'demo_simulated' };
524
+ }
525
+
526
+ function numericValue(...values) {
527
+ for (const value of values) {
528
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
529
+ }
530
+ return 0;
531
+ }
532
+
533
+ function sentimentLabelFromScore(score) {
534
+ if (typeof score !== 'number') return null;
535
+ if (score <= -0.55) return 'frustrated';
536
+ if (score <= -0.2) return 'concerned';
537
+ if (score >= 0.3) return 'positive';
538
+ return 'neutral';
539
+ }
540
+
541
+ function sentimentColor(label, score) {
542
+ const normalized = (label || sentimentLabelFromScore(score) || '').toLowerCase();
543
+ if (normalized === 'frustrated') return 'var(--red)';
544
+ if (normalized === 'concerned') return 'var(--yellow)';
545
+ if (normalized === 'positive') return 'var(--green)';
546
+ return 'var(--text)';
547
+ }
548
+
549
+ function urgencyLevelFromScore(score) {
550
+ if (score >= 0.75) return 'critical';
551
+ if (score >= 0.5) return 'high';
552
+ if (score >= 0.25) return 'medium';
553
+ return 'low';
554
+ }
555
+
556
+ function urgencyColor(level) {
557
+ const normalized = (level || '').toLowerCase();
558
+ if (normalized === 'critical') return 'var(--red)';
559
+ if (normalized === 'high' || normalized === 'medium') return 'var(--yellow)';
560
+ return 'var(--green)';
561
+ }
562
+
563
+ function escapeHtml(value) {
564
+ return String(value)
565
+ .replace(/&/g, '&amp;')
566
+ .replace(/</g, '&lt;')
567
+ .replace(/>/g, '&gt;')
568
+ .replace(/"/g, '&quot;')
569
+ .replace(/'/g, '&#39;');
570
+ }
571
+
572
+ function renderEvidenceList(elementId, evidence) {
573
+ const list = document.getElementById(elementId);
574
+ if (!list) return;
575
+
576
+ const items = Array.isArray(evidence) ? evidence.filter(Boolean) : [];
577
+ if (!items.length) {
578
+ list.innerHTML = '<div class="evidence-empty">No contextual evidence triggered.</div>';
579
+ return;
580
+ }
581
+
582
+ list.innerHTML = items.slice(0, 5).map(item => {
583
+ const [rawType, ...phraseParts] = String(item).split(':');
584
+ const type = rawType ? rawType.replace(/_/g, ' ') : 'signal';
585
+ const phrase = phraseParts.join(':').trim() || item;
586
+ return `
587
+ <div class="evidence-item">
588
+ <span class="evidence-type">${escapeHtml(type)}</span>
589
+ <span class="evidence-phrase">${escapeHtml(phrase)}</span>
590
+ </div>
591
+ `;
592
+ }).join('');
593
+ }
594
+
595
+ function inferClarificationTarget(option, relevantClasses, index) {
596
+ const optionLow = String(option || '').toLowerCase();
597
+ const keywordTargets = [
598
+ ['billing', ['billing', 'invoice', 'payment', 'charge', 'refund', 'credit', 'pricing', 'cost', 'bill']],
599
+ ['technical_support', ['software', 'error', 'technical', 'broken', 'malfunction', 'functionality', 'api', 'integration', 'performance', 'specific issue', 'data movement']],
600
+ ['account_management', ['account', 'plan', 'subscription', 'administrator', 'admin', 'user management', 'regular user', 'settings']],
601
+ ['feature_request', ['new capability', 'feature', 'request', 'enhancement']],
602
+ ['compliance_legal', ['compliance', 'regulatory', 'audit', 'gdpr', 'security', 'data affected']],
603
+ ['onboarding', ['new user', 'onboarding', 'guidance', 'training', 'walkthrough', 'setting up']],
604
+ ['churn_risk', ['continuing', 'switching', 'evaluating options', 'mostly negative']],
605
+ ['general_inquiry', ['general', 'guidance', 'not urgent', 'no specific deadline', 'positive']],
606
+ ];
607
+
608
+ for (const [category, keywords] of keywordTargets) {
609
+ if (keywords.some(keyword => optionLow.includes(keyword))) return category;
610
+ }
611
+ return relevantClasses[index] || relevantClasses[0] || 'general_inquiry';
612
+ }
613
+
614
+ function firstPatternHit(text, patterns) {
615
+ for (const pattern of patterns) {
616
+ const match = text.match(pattern);
617
+ if (match) return match[0];
618
+ }
619
+ return null;
620
+ }
621
+
622
+ function inferDemoSignals(t) {
623
+ const urgencySpecs = [
624
+ ['business_impact', 0.30, [
625
+ /\b(?:affecting|impacting|blocking)\s+(?:our\s+)?(?:customers|users|team|business|operations|sales|revenue|payroll|launch|production)\b/,
626
+ /\b(?:customers?|clients?)\s+(?:(?:are|is)\s+)?(?:waiting|blocked|affected|unable)\b/,
627
+ /\b(?:cannot|can't|unable to)\s+(?:process|ship|launch|serve|sell|invoice|onboard|work|access)\b/,
628
+ ]],
629
+ ['deadline_pressure', 0.25, [
630
+ /\b(?:in|within)\s+\d+\s*(?:min|mins|minutes|hour|hours|hrs|days?)\b/,
631
+ /\b(?:by|before)\s+(?:today|tomorrow|eod|end of day|tonight|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/,
632
+ /\b(?:launch|demo|go-live|renewal|payroll|board meeting|presentation)\b/,
633
+ ]],
634
+ ['production_outage', 0.40, [
635
+ /\bproduction\s+(?:is\s+)?(?:down|blocked|broken|failing|impacted)\b/,
636
+ /\b(?:all|multiple|many)\s+(?:users|customers|accounts|teams)\s+(?:are\s+)?(?:affected|blocked|down|unable)\b/,
637
+ /\b(?:system|service|platform|dashboard|api)\s+(?:is\s+)?(?:down|unavailable|not responding)\b/,
638
+ ]],
639
+ ['access_loss', 0.25, [
640
+ /\b(?:locked out|cannot access|can't access|unable to access|access is blocked)\b/,
641
+ /\b(?:login|sso|authentication)\s+(?:is\s+)?(?:broken|failing|down|not working)\b/,
642
+ ]],
643
+ ['repeat_issue', 0.20, [
644
+ /\b(?:again|still|keeps?|repeated|recurring)\b/,
645
+ /\b(?:second|third|fourth)\s+time\b/,
646
+ /\b(?:raised|reported|opened)\s+(?:this\s+)?(?:before|multiple times|again)\b/,
647
+ ]],
648
+ ];
649
+
650
+ const explicitCritical = ['crash', 'blocked', 'down', 'failing', 'cannot access', 'production issue', 'outage', 'emergency', 'critical', 'urgent', 'immediately', 'blocking', 'locked out'];
651
+ const explicitGeneral = ['asap', 'deadline', 'sla', 'escalate', 'priority', 'time-sensitive', 'showstopper', 'presentation'];
652
+
653
+ const urgencyEvidence = [];
654
+ const urgencyFlags = [];
655
+ explicitCritical.forEach(word => {
656
+ if (t.includes(word)) {
657
+ urgencyEvidence.push(`explicit_critical: ${word}`);
658
+ urgencyFlags.push(word);
659
+ }
660
+ });
661
+ explicitGeneral.forEach(word => {
662
+ if (t.includes(word)) {
663
+ urgencyEvidence.push(`explicit_general: ${word}`);
664
+ urgencyFlags.push(word);
665
+ }
666
+ });
667
+
668
+ let urgencyScore = (explicitCritical.length ? 0 : 0);
669
+ urgencyScore += explicitCritical.filter(word => t.includes(word)).length * 0.25;
670
+ urgencyScore += explicitGeneral.filter(word => t.includes(word)).length * 0.12;
671
+ urgencySpecs.forEach(([label, weight, patterns]) => {
672
+ const phrase = firstPatternHit(t, patterns);
673
+ if (phrase) {
674
+ urgencyScore += weight;
675
+ urgencyEvidence.push(`${label}: ${phrase}`);
676
+ urgencyFlags.push(label);
677
+ }
678
+ });
679
+
680
+ if (/\b(?:not urgent|no rush|whenever you can|when you have time)\b/.test(t)) {
681
+ urgencyScore = Math.min(urgencyScore, 0.35);
682
+ urgencyEvidence.push('deescalation: no immediate pressure');
683
+ }
684
+
685
+ urgencyScore = Math.max(0, Math.min(1, urgencyScore));
686
+
687
+ const sentimentSpecs = [
688
+ ['frustration', -0.30, [
689
+ /\bfrustrat(?:ed|ing|ion)\b/,
690
+ /\bnot happy\b/,
691
+ /\bdisappoint(?:ed|ing|ment)\b/,
692
+ /\bthis is becoming difficult\b/,
693
+ /\bnot ideal\b/,
694
+ /\bunacceptable\b/,
695
+ /\bterrible\b/,
696
+ /\bawful\b/,
697
+ ]],
698
+ ['trust_risk', -0.25, [
699
+ /\b(?:losing|lost)\s+(?:trust|confidence)\b/,
700
+ /\b(?:considering|thinking about)\s+(?:switching|leaving|cancelling|canceling)\b/,
701
+ ]],
702
+ ['polite_negative', -0.22, [
703
+ /\b(?:this|it)\s+is\s+(?:affecting|impacting|blocking)\b/,
704
+ /\b(?:could you please|please)\b.*\b(?:fix|resolve|help)\b.*\b(?:blocking|affecting|stuck|broken|failing)\b/,
705
+ /\b(?:becoming|getting)\s+(?:difficult|hard|painful)\b/,
706
+ ]],
707
+ ];
708
+
709
+ const negWords = ['frustrated','broken','terrible','angry','worst','cancel','bad','issue','error', 'invalid', 'locked out'];
710
+ const posWords = ['great','thanks','love','good','happy','please'];
711
+ let rawSentiment = 0;
712
+ negWords.forEach(w => { if (t.includes(w)) rawSentiment -= 0.18; });
713
+ posWords.forEach(w => { if (t.includes(w)) rawSentiment += 0.12; });
714
+ rawSentiment = Math.max(-1, Math.min(1, rawSentiment));
715
+
716
+ const sentimentEvidence = [];
717
+ let sentimentScore = rawSentiment;
718
+ sentimentSpecs.forEach(([label, weight, patterns]) => {
719
+ const phrase = firstPatternHit(t, patterns);
720
+ if (phrase) {
721
+ sentimentScore += weight;
722
+ sentimentEvidence.push(`${label}: ${phrase}`);
723
+ }
724
+ });
725
+ sentimentScore = Math.max(-1, Math.min(1, sentimentScore));
726
+
727
+ return {
728
+ urgency_score: Math.round(urgencyScore * 10000) / 10000,
729
+ urgency_level: urgencyLevelFromScore(urgencyScore),
730
+ urgency_flags: Array.from(new Set(urgencyFlags)),
731
+ urgency_evidence: urgencyEvidence,
732
+ sentiment_score: Math.round(sentimentScore * 10000) / 10000,
733
+ sentiment_raw_score: Math.round(rawSentiment * 10000) / 10000,
734
+ sentiment_label: sentimentLabelFromScore(sentimentScore),
735
+ sentiment_evidence: sentimentEvidence,
736
+ };
737
+ }
738
+
739
+
740
+ // -- Seeded PRNG (deterministic per text) --------------
741
+ function hashText(str) {
742
+ let h = 0;
743
+ for (let i = 0; i < str.length; i++) {
744
+ h = ((h << 5) - h + str.charCodeAt(i)) | 0;
745
+ }
746
+ return Math.abs(h);
747
+ }
748
+
749
+ function seededRandom(seed) {
750
+ let s = seed;
751
+ return function() {
752
+ s = (s * 1664525 + 1013904223) & 0xffffffff;
753
+ return (s >>> 0) / 0xffffffff;
754
+ };
755
+ }
756
+
757
+ // -- Simulation (when API is offline) ------------------
758
+ function simulateRouting(text, extraPayload = {}) {
759
+ const t = text.toLowerCase().trim();
760
+ const marker = t.match(/\[clarification:\s*([a-z_]+)\s*-\s*([^\]]+)\]/);
761
+ const clarificationTarget = extraPayload.clarification_target || (marker && marker[1]);
762
+ const clarificationChoice = extraPayload.clarification_choice || (marker && marker[2]);
763
+ const validTargets = Object.keys(CAT_COLORS);
764
+
765
+ if (clarificationTarget && validTargets.includes(clarificationTarget)) {
766
+ const allProbs = {};
767
+ validTargets.forEach(cat => { allProbs[cat] = cat === clarificationTarget ? 0.9 : 0.0143; });
768
+ const demoSignals = inferDemoSignals(t);
769
+ return {
770
+ action: 'route',
771
+ confidence: 0.9,
772
+ entropy: 0.35,
773
+ margin: 0.75,
774
+ top_category: clarificationTarget,
775
+ all_probs: allProbs,
776
+ top_two_classes: [clarificationTarget, validTargets.find(cat => cat !== clarificationTarget)],
777
+ queue: clarificationTarget,
778
+ reason: `Clarification answer resolved the ambiguity toward ${clarificationTarget}.`,
779
+ clarification_applied: true,
780
+ clarification_choice: clarificationChoice,
781
+ sla_breach_probability: Math.min(0.95, 0.15 + (demoSignals.urgency_score * 0.45)),
782
+ urgency_score: demoSignals.urgency_score,
783
+ features: {
784
+ ...demoSignals,
785
+ text_complexity_score: Math.round(text.split(' ').length / 5 * 100) / 100,
786
+ },
787
+ latency_ms: 28 + (hashText(t) % 20),
788
+ };
789
+ }
790
+
791
+ // Basic validation in simulation to match real API behavior
792
+ if (t.length < 10) {
793
+ const greetings = ['hi', 'hello', 'hey', 'test'];
794
+ if (greetings.some(g => t.startsWith(g))) {
795
+ return {
796
+ action: 'invalid_input',
797
+ error_type: 'greeting',
798
+ response: "Hi there! Could you describe the issue you're experiencing? We're here to help."
799
+ };
800
+ }
801
+ return {
802
+ action: 'invalid_input',
803
+ error_type: 'too_short',
804
+ response: "Could you share a bit more detail about your issue? We're here to help."
805
+ };
806
+ }
807
+
808
+ const rng = seededRandom(hashText(t)); // deterministic per text
809
+
810
+ const scores = {
811
+ billing: 0.02, technical_support: 0.02, account_management: 0.02,
812
+ feature_request: 0.02, compliance_legal: 0.02, onboarding: 0.02,
813
+ general_inquiry: 0.02, churn_risk: 0.02,
814
+ };
815
+
816
+ // Simple keyword scoring
817
+ const kw = {
818
+ billing: ['invoice','billing','payment','charge','refund','price','cost','subscription','plan','pricing','credit'],
819
+ technical_support: ['error','bug','broken','crash','fix','api','endpoint','500','timeout','issue','not working','failed'],
820
+ account_management: ['account','user','access','permission','settings','profile','password','role'],
821
+ feature_request: ['feature','add','implement','suggest','request','capability','enhancement','wish','could you'],
822
+ compliance_legal: ['gdpr','compliance','audit','regulation','privacy','security','data protection','legal'],
823
+ onboarding: ['new user','setup','getting started','onboarding','first time','just signed up','configure','install'],
824
+ general_inquiry: ['how do','what is','question','information','help','guide','documentation'],
825
+ churn_risk: ['cancel','switch','competitor','alternative','frustrated','unacceptable','leaving','terminate','fed up','last straw'],
826
+ };
827
+
828
+ Object.entries(kw).forEach(([cat, words]) => {
829
+ words.forEach(w => { if (t.includes(w)) scores[cat] += 0.15 + rng() * 0.05; });
830
+ });
831
+
832
+ // Normalize
833
+ const total = Object.values(scores).reduce((a, b) => a + b, 0);
834
+ Object.keys(scores).forEach(k => scores[k] /= total);
835
+
836
+ // Add small deterministic noise (simulate MC Dropout variance)
837
+ Object.keys(scores).forEach(k => {
838
+ scores[k] += (rng() - 0.5) * 0.03;
839
+ scores[k] = Math.max(0.001, scores[k]);
840
+ });
841
+ const total2 = Object.values(scores).reduce((a, b) => a + b, 0);
842
+ Object.keys(scores).forEach(k => scores[k] /= total2);
843
+
844
+ const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
845
+ const confidence = sorted[0][1];
846
+ const entropy = -Object.values(scores).reduce((s, p) => s + p * Math.log(p + 1e-9), 0);
847
+ const topCat = sorted[0][0];
848
+ const topTwo = [sorted[0][0], sorted[1][0]];
849
+ const margin = sorted[0][1] - sorted[1][1];
850
+
851
+ let action, reason;
852
+ const critical_labels = ['compliance_legal', 'account_management'];
853
+
854
+ if (critical_labels.includes(topCat)) {
855
+ if (confidence >= 0.90 && margin >= 0.35 && entropy < 0.60) {
856
+ action = 'route';
857
+ reason = `- Safe to auto-route sensitive intent<br>- Confidence: ${(confidence*100).toFixed(1)}%<br>- Margin: ${margin.toFixed(2)}`;
858
+ } else {
859
+ action = 'escalate';
860
+ reason = `- Escalated sensitive intent (${topCat.replace(/_/g,' ')})<br>- Strict confidence/margin threshold not met`;
861
+ }
862
+ } else {
863
+ if (confidence >= 0.85 && margin >= 0.25 && entropy < 0.70) {
864
+ action = 'route';
865
+ reason = `- Strong dominant intent<br>- Confidence: ${(confidence*100).toFixed(1)}%<br>- Margin: ${margin.toFixed(2)}<br>- Safe to auto-route`;
866
+ } else if (confidence >= 0.60 && entropy < 1.05) {
867
+ action = 'clarify';
868
+ reason = `- Medium ambiguity detected<br>- Clarification needed between ${topTwo[0].replace(/_/g,' ')} and ${topTwo[1].replace(/_/g,' ')}<br>- Margin: ${margin.toFixed(2)}`;
869
+ } else {
870
+ action = 'escalate';
871
+ reason = `- High ambiguity / Low confidence (${(confidence*100).toFixed(1)}%)<br>- Multiple overlapping intents detected<br>- Human triage needed`;
872
+ }
873
+ }
874
+
875
+ // Clarification question
876
+ let clarification = null;
877
+ if (action === 'clarify') {
878
+ const questions = {
879
+ 'billing+technical_support': { question_text: 'Is the main issue related to (A) a software error, or (B) your billing or invoice?', options: ['Software error','Billing/invoice'], expected_gain: 0.71 },
880
+ 'technical_support+billing': { question_text: 'Is the main issue related to (A) a software error, or (B) your billing or invoice?', options: ['Software error','Billing/invoice'], expected_gain: 0.71 },
881
+ 'feature_request+technical_support': { question_text: 'Are you reporting something broken, or requesting a new capability?', options: ['Something broken','New feature'], expected_gain: 0.68 },
882
+ 'technical_support+feature_request': { question_text: 'Are you reporting something broken, or requesting a new capability?', options: ['Something broken','New feature'], expected_gain: 0.68 },
883
+ 'churn_risk+account_management': { question_text: 'Are you looking to change your plan, or do you have concerns about continuing?', options: ['Change plan','Concerns about continuing'], expected_gain: 0.74 },
884
+ 'account_management+churn_risk': { question_text: 'Are you looking to change your plan, or do you have concerns about continuing?', options: ['Change plan','Concerns about continuing'], expected_gain: 0.74 },
885
+ 'onboarding+technical_support': { question_text: 'Is this affecting a new user, or an existing user?', options: ['New user','Existing user'], expected_gain: 0.65 },
886
+ 'technical_support+onboarding': { question_text: 'Is this affecting a new user, or an existing user?', options: ['New user','Existing user'], expected_gain: 0.65 },
887
+ 'compliance_legal+billing': { question_text: 'Does this relate to a regulatory requirement, or to payment/invoicing?', options: ['Regulatory','Payment'], expected_gain: 0.72 },
888
+ 'billing+compliance_legal': { question_text: 'Does this relate to a regulatory requirement, or to payment/invoicing?', options: ['Regulatory','Payment'], expected_gain: 0.72 },
889
+ 'technical_support+general_inquiry': { question_text: 'Is this a specific technical problem, or a general question about how something works?', options: ['Specific problem','General question'], expected_gain: 0.66 },
890
+ 'general_inquiry+technical_support': { question_text: 'Is this a specific technical problem, or a general question about how something works?', options: ['Specific problem','General question'], expected_gain: 0.66 },
891
+ 'billing+general_inquiry': { question_text: 'Is your question about a specific charge on your account, or general pricing information?', options: ['Specific charge','General pricing'], expected_gain: 0.64 },
892
+ 'general_inquiry+billing': { question_text: 'Is your question about a specific charge on your account, or general pricing information?', options: ['Specific charge','General pricing'], expected_gain: 0.64 },
893
+ 'churn_risk+technical_support': { question_text: 'Is the main concern a technical problem you need fixed, or are you considering leaving the platform?', options: ['Technical problem','Considering leaving'], expected_gain: 0.76 },
894
+ 'technical_support+churn_risk': { question_text: 'Is the main concern a technical problem you need fixed, or are you considering leaving the platform?', options: ['Technical problem','Considering leaving'], expected_gain: 0.76 },
895
+ };
896
+ const key = topTwo[0] + '+' + topTwo[1];
897
+ clarification = questions[key] || {
898
+ question_text: 'Could you specify whether this is about a technical issue or an account/billing matter?',
899
+ options: ['Technical issue', 'Account/billing'], expected_gain: 0.62,
900
+ };
901
+ clarification.question_id = 'Q_SIM';
902
+ }
903
+
904
+ const demoSignals = inferDemoSignals(t);
905
+
906
+ // SLA - deterministic based on text features
907
+ const outageWords = ['down', 'outage', 'crash', 'failing', 'blocked'];
908
+ const outageFlags = outageWords.filter(w => t.includes(w));
909
+ const slaBase = 0.15
910
+ + (demoSignals.sentiment_score < -0.3 ? 0.2 : 0)
911
+ + (demoSignals.urgency_score * 0.45)
912
+ + (outageFlags.length * 0.15);
913
+ const slaBreach = Math.min(Math.round(slaBase * 1000) / 1000, 0.95);
914
+
915
+ return {
916
+ action, confidence: Math.round(confidence * 10000) / 10000,
917
+ entropy: Math.round(entropy * 10000) / 10000,
918
+ margin: Math.round(margin * 10000) / 10000,
919
+ top_category: topCat, all_probs: scores,
920
+ top_two_classes: topTwo, queue: topCat,
921
+ reason, clarification,
922
+ sla_breach_probability: slaBreach,
923
+ urgency_score: demoSignals.urgency_score,
924
+ features: {
925
+ ...demoSignals,
926
+ text_complexity_score: Math.round(text.split(' ').length / 5 * 100) / 100,
927
+ },
928
+ latency_ms: 38 + (hashText(t) % 30),
929
+ };
930
+ }