// SupportMind Dashboard - app.js
// Interactive demo with real API calls (falls back to simulation if API unavailable)
let API_BASE = window.location.origin;
let apiOnline = false;
function apiCandidates() {
const candidates = [];
const url = new URL(window.location.href);
if (url.hostname === '127.0.0.1' && url.port === '7860') {
candidates.push('http://127.0.0.1:7862');
candidates.push('http://127.0.0.1:7861');
}
candidates.push(window.location.origin);
return [...new Set(candidates)];
}
// Category colors
const CAT_COLORS = {
billing: '#fb923c', technical_support: '#8083ff', account_management: '#89ceff',
feature_request: '#c0c1ff', compliance_legal: '#f87171', onboarding: '#4ade80',
general_inquiry: '#94a3b8', churn_risk: '#facc15',
};
// -- Init ----------------------------------------------
document.addEventListener('DOMContentLoaded', () => {
animateCounters();
initPresets();
initDropoutViz();
initScrollAnimations();
initSmoothScroll();
checkAPI();
updateMetrics();
setInterval(updateMetrics, 5000); // Update every 5 seconds
});
// -- Counter Animation ---------------------------------
function animateCounters() {
document.querySelectorAll('.stat-card').forEach(card => {
const counter = card.querySelector('.counter');
const target = parseFloat(card.dataset.value);
const duration = 1500;
const start = performance.now();
function update(now) {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
counter.textContent = Math.round(target * eased * 10) / 10;
if (progress < 1) requestAnimationFrame(update);
else counter.textContent = target;
}
requestAnimationFrame(update);
});
}
// -- Presets --------------------------------------------
// -- Live Telemetry Engine ---------------------------
async function updateMetrics() {
try {
const res = await fetch(`${API_BASE}/metrics`);
if (!res.ok) return;
const data = await res.json();
// Update Counter
document.getElementById('live-total').textContent = data.total_requests.toLocaleString();
// Update Model Name
document.getElementById('live-model').textContent = data.model;
// Update Distribution Bar
const dist = data.routing_distribution;
document.getElementById('dist-route').style.width = `${dist.route_pct}%`;
document.getElementById('dist-clarify').style.width = `${dist.clarify_pct}%`;
document.getElementById('dist-escalate').style.width = `${dist.escalate_pct}%`;
// Update Status Pulse
const indicator = document.getElementById('live-indicator');
indicator.style.opacity = '1';
setTimeout(() => { indicator.style.opacity = '0.8'; }, 500);
} catch (err) {
console.warn("Metrics sync failed:", err);
}
}
// -- Presets --------------------------------------------
function initPresets() {
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('ticket-input').value = btn.dataset.text;
});
});
}
function initSmoothScroll() {
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
document.querySelector(this.getAttribute('href')).scrollIntoView({
behavior: 'smooth'
});
});
});
}
// -- MC Dropout Visualization --------------------------
function initDropoutViz() {
const grid = document.getElementById('dropout-grid');
if (!grid) return;
for (let pass = 0; pass < 20; pass++) {
const col = document.createElement('div');
col.className = 'dropout-col';
for (let neuron = 0; neuron < 12; neuron++) {
const cell = document.createElement('div');
cell.className = 'dropout-cell';
const active = Math.random() > 0.15;
cell.style.background = active ? 'var(--primary)' : 'rgba(192, 193, 255, 0.05)';
cell.style.border = active ? 'none' : '1px solid rgba(192, 193, 255, 0.1)';
col.appendChild(cell);
}
grid.appendChild(col);
}
// Animate dropout
setInterval(() => {
grid.querySelectorAll('.dropout-cell').forEach(cell => {
const active = Math.random() > 0.15;
cell.style.background = active ? 'var(--primary)' : 'rgba(192, 193, 255, 0.05)';
cell.style.border = active ? 'none' : '1px solid rgba(192, 193, 255, 0.1)';
});
}, 2000);
}
// -- Scroll Animations ---------------------------------
function initScrollAnimations() {
const observer = new IntersectionObserver((entries) => {
entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('visible'); });
}, { threshold: 0.1 });
document.querySelectorAll('.section-header, .stat-card, .arch-stage, .bench-card, .ops-card').forEach(el => {
el.classList.add('fade-in');
observer.observe(el);
});
}
// -- API Check -----------------------------------------
async function checkAPI() {
for (const candidate of apiCandidates()) {
try {
const res = await fetch(`${candidate}/health`, { signal: AbortSignal.timeout(2000) });
if (!res.ok) continue;
API_BASE = candidate;
apiOnline = true;
const statusEl = document.querySelector('.status-text');
if (statusEl) statusEl.textContent = API_BASE === window.location.origin ? 'API Connected' : 'Fixed API Connected';
return;
} catch {
// Try the next candidate before falling back to demo mode.
}
}
apiOnline = false;
const statusEl = document.querySelector('.status-text');
if (statusEl) statusEl.textContent = 'Demo Mode';
}
// -- Live Metrics --------------------------------------
async function updateLiveMetrics() {
if (!apiOnline) return;
try {
const res = await fetch(`${API_BASE}/metrics`);
const data = await res.json();
document.getElementById('live-model').textContent = data.model;
document.getElementById('live-total').textContent = data.total_requests;
const dist = data.routing_distribution;
document.getElementById('dist-route').style.width = dist.route_pct + '%';
document.getElementById('dist-clarify').style.width = dist.clarify_pct + '%';
document.getElementById('dist-escalate').style.width = dist.escalate_pct + '%';
} catch (err) {
console.warn('Metrics update failed:', err);
}
}
// -- Route Ticket --------------------------------------
async function routeTicket(extraPayload = {}) {
const text = document.getElementById('ticket-input').value.trim();
if (!text) return;
const btn = document.getElementById('route-btn');
btn.innerHTML = ' Routing...';
btn.disabled = true;
let result;
try {
if (apiOnline) {
const res = await fetch(`${API_BASE}/route`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, ...extraPayload }),
});
result = await res.json();
} else {
result = simulateRouting(text, extraPayload);
}
displayResult(result, text);
} catch (err) {
result = simulateRouting(text, extraPayload);
displayResult(result, text);
}
btn.innerHTML = 'Route Ticket';
btn.disabled = false;
}
// -- Display Result ------------------------------------
function displayResult(r, routedText) {
// Handle edge cases
if (r.action === 'invalid_input') {
document.getElementById('result-placeholder').style.display = 'none';
const content = document.getElementById('result-content');
content.style.display = 'block';
const badge = document.getElementById('action-badge');
badge.textContent = r.error_type.toUpperCase().replace('_', ' ');
badge.className = 'action-badge clarify'; // yellow
document.getElementById('action-queue').textContent = r.response;
document.getElementById('result-reason').textContent = r.response;
// Hide gauges for invalid input
document.querySelector('.gauge-row').style.display = 'none';
document.querySelector('.signals-grid').style.display = 'none';
document.getElementById('prob-chart').innerHTML = '';
document.getElementById('clarification-box').style.display = 'none';
const evidenceGrid = document.getElementById('signal-evidence-grid');
if (evidenceGrid) evidenceGrid.style.display = 'none';
const explainBtn = document.getElementById('explain-btn');
if (explainBtn) explainBtn.style.display = 'none';
document.getElementById('explanation-box').style.display = 'none';
return;
}
// Show gauges for valid input
document.querySelector('.gauge-row').style.display = 'grid';
document.querySelector('.signals-grid').style.display = 'grid';
document.getElementById('result-placeholder').style.display = 'none';
const content = document.getElementById('result-content');
content.style.display = 'block';
const evidenceGrid = document.getElementById('signal-evidence-grid');
if (evidenceGrid) evidenceGrid.style.display = 'grid';
// Action Badge Logic
const badge = document.getElementById('action-badge');
const queue = document.getElementById('action-queue');
if (r.action === 'multi_route') {
badge.textContent = 'MULTI-ROUTE';
badge.className = 'action-badge';
badge.style.background = 'linear-gradient(90deg, var(--primary), var(--accent))';
queue.innerHTML = `
Primary: ${r.primary_queue}
Secondary: ${r.secondary_queue}
`;
} else {
badge.textContent = r.action.toUpperCase();
badge.className = `action-badge ${r.action}`;
queue.textContent = r.action === 'route' ? `-> ${r.queue || r.top_category} queue` :
r.action === 'clarify' ? 'Needs 1 clarification question' : 'Immediate human triage';
}
// Gauges
const confPct = Math.min(r.confidence * 100, 100);
document.getElementById('conf-fill').style.width = confPct + '%';
document.getElementById('conf-value').textContent = r.confidence.toFixed(4);
const maxEnt = Math.log(8);
const entPct = Math.min((r.entropy / maxEnt) * 100, 100);
document.getElementById('ent-fill').style.width = entPct + '%';
document.getElementById('ent-value').textContent = r.entropy.toFixed(4);
if (r.margin !== undefined && document.getElementById('margin-value')) {
document.getElementById('margin-value').textContent = r.margin.toFixed(4);
}
// Prob chart
const chart = document.getElementById('prob-chart');
chart.innerHTML = '';
const probs = r.all_probs || {};
const sorted = Object.entries(probs).sort((a, b) => b[1] - a[1]);
const maxProb = sorted.length ? sorted[0][1] : 1;
sorted.forEach(([cat, prob]) => {
const row = document.createElement('div');
row.className = 'prob-row';
const pct = (prob / Math.max(maxProb, 0.01)) * 100;
row.innerHTML = `
${cat.replace(/_/g, ' ')}
${(prob * 100).toFixed(1)}%`;
chart.appendChild(row);
});
// Clarification
const clarBox = document.getElementById('clarification-box');
if (r.action === 'clarify' && r.clarification) {
clarBox.style.display = 'block';
document.getElementById('clarify-question').textContent = r.clarification.question_text;
const optEl = document.getElementById('clarify-options');
optEl.innerHTML = '';
const optionTargets = r.clarification.option_targets || [];
(r.clarification.options || []).forEach((o, index) => {
const btn = document.createElement('button');
btn.className = 'option-btn';
btn.textContent = o;
btn.onclick = () => {
// Provide visual feedback
document.querySelectorAll('#clarify-options .option-btn').forEach(b => b.disabled = true);
btn.style.background = 'var(--primary)';
btn.style.color = '#fff';
const target = optionTargets[index]
|| inferClarificationTarget(o, r.clarification.relevant_classes || r.top_two_classes || [], index);
// Keep the selection visible and machine-readable for repeat manual runs.
const input = document.getElementById('ticket-input');
input.value = input.value.trim() + '\n\n[Clarification: ' + target + ' - ' + o + ']';
// Re-route with new context after a short delay
setTimeout(() => {
routeTicket({
clarification_choice: o,
clarification_target: target,
clarification_question_id: r.clarification.question_id,
});
}, 800);
};
optEl.appendChild(btn);
});
// Remove existing badge if any
const existingBadge = document.getElementById('source-badge');
if (existingBadge) existingBadge.remove();
// After displaying the question, add source badge
const sourceBadge = document.createElement('div');
sourceBadge.id = 'source-badge';
sourceBadge.style.cssText = 'font-size:11px;margin-top:8px;opacity:0.6;';
sourceBadge.textContent = r.clarification.source === 'llm_groq'
? 'Generated by LLaMA3 via Groq'
: 'Template: Selected from template bank';
document.getElementById('clarification-box').appendChild(sourceBadge);
document.getElementById('clarify-gain').textContent =
`Expected information gain: ${r.clarification.expected_gain?.toFixed(4) || 'N/A'}`;
} else {
clarBox.style.display = 'none';
}
// Signals
const slaRiskVal = r.sla_risk || r.sla_breach_probability || 0;
const slaPct = slaRiskVal * 100;
document.getElementById('sla-value').textContent = slaPct.toFixed(1) + '%';
document.getElementById('sla-fill').style.width = slaPct + '%';
document.getElementById('sla-fill').style.background =
slaPct > 65 ? 'var(--red)' : slaPct > 35 ? 'var(--yellow)' : 'var(--green)';
const feat = r.features || {};
const sent = feat.sentiment_score;
const sentLabel = feat.sentiment_label || sentimentLabelFromScore(sent);
const sentimentValue = document.getElementById('sentiment-value');
sentimentValue.textContent = sentLabel ? sentLabel.toUpperCase() : '-';
sentimentValue.style.color = sentimentColor(sentLabel, sent);
const sentimentScore = document.getElementById('sentiment-score');
if (sentimentScore) {
const raw = typeof feat.sentiment_raw_score === 'number'
? ` raw ${feat.sentiment_raw_score.toFixed(2)}`
: '';
sentimentScore.textContent = sent !== undefined ? `score ${sent.toFixed(2)}${raw}` : '-';
}
const urgScore = numericValue(r.urgency_score, feat.urgency_score, 0);
const urgLevel = feat.urgency_level || urgencyLevelFromScore(urgScore);
const urgencyCard = document.getElementById('urgency-value').parentElement;
const urgencyValue = document.getElementById('urgency-value');
urgencyValue.textContent = urgLevel.toUpperCase();
urgencyValue.style.color = urgencyColor(urgLevel);
const urgencyScore = document.getElementById('urgency-score');
if (urgencyScore) urgencyScore.textContent = `score ${urgScore.toFixed(2)}`;
if (urgLevel === 'critical') {
urgencyCard.style.border = '1px solid var(--red)';
urgencyCard.style.boxShadow = '0 0 15px rgba(248, 113, 113, 0.2)';
} else if (urgLevel === 'high') {
urgencyCard.style.border = '1px solid var(--yellow)';
urgencyCard.style.boxShadow = '';
} else {
urgencyCard.style.border = '';
urgencyCard.style.boxShadow = '';
}
renderEvidenceList('urgency-evidence-list', feat.urgency_evidence || []);
renderEvidenceList('sentiment-evidence-list', feat.sentiment_evidence || []);
document.getElementById('latency-value').textContent =
r.latency_ms ? r.latency_ms + 'ms' : '-';
// Reason
let decisionReason = '';
if (r.clarification_applied) {
decisionReason = `Clarification answer applied: ${escapeHtml(r.clarification_choice || r.top_category)}. Routing to ${r.top_category} without asking another question.`;
} else if (r.action === 'multi_route') {
decisionReason = `Multiple distinct intents detected in the request. Primary intent is ${r.primary_queue}, secondary is ${r.secondary_queue}.`;
} else if (r.action === 'clarify') {
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.`;
} else if (r.action === 'escalate') {
decisionReason = `Low model confidence detected (${(r.confidence * 100).toFixed(1)}%). Routing directly to human experts to ensure accuracy.`;
} else {
decisionReason = `High-confidence intent detected: ${r.top_category}. Automatically routing to specialized queue.`;
}
document.getElementById('result-reason').innerHTML = `
Decision Reason
${decisionReason}
`;
// Show explain button for valid input
const explainBtn = document.getElementById('explain-btn');
if (explainBtn) {
explainBtn.style.display = 'flex';
explainBtn.dataset.text = routedText || document.getElementById('ticket-input').value;
explainBtn.dataset.category = r.top_category;
}
document.getElementById('explanation-box').style.display = 'none';
}
// -- Explain Decision (SHAP) ---------------------------
async function explainDecision() {
const btn = document.getElementById('explain-btn');
const text = btn.dataset.text;
const targetClass = btn.dataset.category;
btn.innerHTML = ' Analyzing tokens...';
btn.disabled = true;
try {
let result;
if (apiOnline) {
const res = await fetch(`${API_BASE}/explain`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, target_class: targetClass }),
});
if (!res.ok) throw new Error(`Explain API returned ${res.status}`);
result = await res.json();
} else {
// Simulate SHAP for demo mode
result = simulateSHAP(text);
}
renderSHAP(result);
} catch (err) {
console.error('SHAP failed:', err);
renderSHAP(simulateSHAP(text));
}
btn.innerHTML = 'query_stats Analyze Decision';
btn.disabled = false;
}
function renderSHAP(data) {
const box = document.getElementById('explanation-box');
const textEl = document.getElementById('explain-text');
box.style.display = 'block';
textEl.innerHTML = '';
if (data.error) {
textEl.textContent = 'Error generating explanation: ' + data.error;
return;
}
const source = document.createElement('div');
source.className = 'explain-source';
source.textContent = data.source === 'shap_transformer'
? 'Transformer SHAP explanation'
: 'Keyword evidence fallback';
if (data.note) source.title = data.note;
textEl.appendChild(source);
const tokens = data.tokens || [];
const values = data.values || [];
tokens.forEach((token, i) => {
const val = values[i];
const span = document.createElement('span');
span.className = 'shap-token';
span.textContent = token.replace('##', ''); // Simple handling for subwords
// Normalize opacity based on value
const absVal = Math.abs(val);
const opacity = Math.min(absVal * 5, 0.8); // Scale for visibility
if (val > 0) {
span.style.background = `rgba(74, 222, 128, ${opacity})`;
span.style.borderBottom = `2px solid rgba(74, 222, 128, ${opacity + 0.2})`;
} else if (val < 0) {
span.style.background = `rgba(248, 113, 113, ${opacity})`;
span.style.borderBottom = `2px solid rgba(248, 113, 113, ${opacity + 0.2})`;
}
textEl.appendChild(span);
textEl.appendChild(document.createTextNode(' '));
});
box.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function simulateSHAP(text) {
const tokens = text.split(/\s+/);
const values = tokens.map(() => (Math.random() - 0.4) * 0.2);
return { tokens, values, source: 'demo_simulated' };
}
function numericValue(...values) {
for (const value of values) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
}
return 0;
}
function sentimentLabelFromScore(score) {
if (typeof score !== 'number') return null;
if (score <= -0.55) return 'frustrated';
if (score <= -0.2) return 'concerned';
if (score >= 0.3) return 'positive';
return 'neutral';
}
function sentimentColor(label, score) {
const normalized = (label || sentimentLabelFromScore(score) || '').toLowerCase();
if (normalized === 'frustrated') return 'var(--red)';
if (normalized === 'concerned') return 'var(--yellow)';
if (normalized === 'positive') return 'var(--green)';
return 'var(--text)';
}
function urgencyLevelFromScore(score) {
if (score >= 0.75) return 'critical';
if (score >= 0.5) return 'high';
if (score >= 0.25) return 'medium';
return 'low';
}
function urgencyColor(level) {
const normalized = (level || '').toLowerCase();
if (normalized === 'critical') return 'var(--red)';
if (normalized === 'high' || normalized === 'medium') return 'var(--yellow)';
return 'var(--green)';
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function renderEvidenceList(elementId, evidence) {
const list = document.getElementById(elementId);
if (!list) return;
const items = Array.isArray(evidence) ? evidence.filter(Boolean) : [];
if (!items.length) {
list.innerHTML = 'No contextual evidence triggered.
';
return;
}
list.innerHTML = items.slice(0, 5).map(item => {
const [rawType, ...phraseParts] = String(item).split(':');
const type = rawType ? rawType.replace(/_/g, ' ') : 'signal';
const phrase = phraseParts.join(':').trim() || item;
return `
${escapeHtml(type)}
${escapeHtml(phrase)}
`;
}).join('');
}
function inferClarificationTarget(option, relevantClasses, index) {
const optionLow = String(option || '').toLowerCase();
const keywordTargets = [
['billing', ['billing', 'invoice', 'payment', 'charge', 'refund', 'credit', 'pricing', 'cost', 'bill']],
['technical_support', ['software', 'error', 'technical', 'broken', 'malfunction', 'functionality', 'api', 'integration', 'performance', 'specific issue', 'data movement']],
['account_management', ['account', 'plan', 'subscription', 'administrator', 'admin', 'user management', 'regular user', 'settings']],
['feature_request', ['new capability', 'feature', 'request', 'enhancement']],
['compliance_legal', ['compliance', 'regulatory', 'audit', 'gdpr', 'security', 'data affected']],
['onboarding', ['new user', 'onboarding', 'guidance', 'training', 'walkthrough', 'setting up']],
['churn_risk', ['continuing', 'switching', 'evaluating options', 'mostly negative']],
['general_inquiry', ['general', 'guidance', 'not urgent', 'no specific deadline', 'positive']],
];
for (const [category, keywords] of keywordTargets) {
if (keywords.some(keyword => optionLow.includes(keyword))) return category;
}
return relevantClasses[index] || relevantClasses[0] || 'general_inquiry';
}
function firstPatternHit(text, patterns) {
for (const pattern of patterns) {
const match = text.match(pattern);
if (match) return match[0];
}
return null;
}
function inferDemoSignals(t) {
const urgencySpecs = [
['business_impact', 0.30, [
/\b(?:affecting|impacting|blocking)\s+(?:our\s+)?(?:customers|users|team|business|operations|sales|revenue|payroll|launch|production)\b/,
/\b(?:customers?|clients?)\s+(?:(?:are|is)\s+)?(?:waiting|blocked|affected|unable)\b/,
/\b(?:cannot|can't|unable to)\s+(?:process|ship|launch|serve|sell|invoice|onboard|work|access)\b/,
]],
['deadline_pressure', 0.25, [
/\b(?:in|within)\s+\d+\s*(?:min|mins|minutes|hour|hours|hrs|days?)\b/,
/\b(?:by|before)\s+(?:today|tomorrow|eod|end of day|tonight|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/,
/\b(?:launch|demo|go-live|renewal|payroll|board meeting|presentation)\b/,
]],
['production_outage', 0.40, [
/\bproduction\s+(?:is\s+)?(?:down|blocked|broken|failing|impacted)\b/,
/\b(?:all|multiple|many)\s+(?:users|customers|accounts|teams)\s+(?:are\s+)?(?:affected|blocked|down|unable)\b/,
/\b(?:system|service|platform|dashboard|api)\s+(?:is\s+)?(?:down|unavailable|not responding)\b/,
]],
['access_loss', 0.25, [
/\b(?:locked out|cannot access|can't access|unable to access|access is blocked)\b/,
/\b(?:login|sso|authentication)\s+(?:is\s+)?(?:broken|failing|down|not working)\b/,
]],
['repeat_issue', 0.20, [
/\b(?:again|still|keeps?|repeated|recurring)\b/,
/\b(?:second|third|fourth)\s+time\b/,
/\b(?:raised|reported|opened)\s+(?:this\s+)?(?:before|multiple times|again)\b/,
]],
];
const explicitCritical = ['crash', 'blocked', 'down', 'failing', 'cannot access', 'production issue', 'outage', 'emergency', 'critical', 'urgent', 'immediately', 'blocking', 'locked out'];
const explicitGeneral = ['asap', 'deadline', 'sla', 'escalate', 'priority', 'time-sensitive', 'showstopper', 'presentation'];
const urgencyEvidence = [];
const urgencyFlags = [];
explicitCritical.forEach(word => {
if (t.includes(word)) {
urgencyEvidence.push(`explicit_critical: ${word}`);
urgencyFlags.push(word);
}
});
explicitGeneral.forEach(word => {
if (t.includes(word)) {
urgencyEvidence.push(`explicit_general: ${word}`);
urgencyFlags.push(word);
}
});
let urgencyScore = (explicitCritical.length ? 0 : 0);
urgencyScore += explicitCritical.filter(word => t.includes(word)).length * 0.25;
urgencyScore += explicitGeneral.filter(word => t.includes(word)).length * 0.12;
urgencySpecs.forEach(([label, weight, patterns]) => {
const phrase = firstPatternHit(t, patterns);
if (phrase) {
urgencyScore += weight;
urgencyEvidence.push(`${label}: ${phrase}`);
urgencyFlags.push(label);
}
});
if (/\b(?:not urgent|no rush|whenever you can|when you have time)\b/.test(t)) {
urgencyScore = Math.min(urgencyScore, 0.35);
urgencyEvidence.push('deescalation: no immediate pressure');
}
urgencyScore = Math.max(0, Math.min(1, urgencyScore));
const sentimentSpecs = [
['frustration', -0.30, [
/\bfrustrat(?:ed|ing|ion)\b/,
/\bnot happy\b/,
/\bdisappoint(?:ed|ing|ment)\b/,
/\bthis is becoming difficult\b/,
/\bnot ideal\b/,
/\bunacceptable\b/,
/\bterrible\b/,
/\bawful\b/,
]],
['trust_risk', -0.25, [
/\b(?:losing|lost)\s+(?:trust|confidence)\b/,
/\b(?:considering|thinking about)\s+(?:switching|leaving|cancelling|canceling)\b/,
]],
['polite_negative', -0.22, [
/\b(?:this|it)\s+is\s+(?:affecting|impacting|blocking)\b/,
/\b(?:could you please|please)\b.*\b(?:fix|resolve|help)\b.*\b(?:blocking|affecting|stuck|broken|failing)\b/,
/\b(?:becoming|getting)\s+(?:difficult|hard|painful)\b/,
]],
];
const negWords = ['frustrated','broken','terrible','angry','worst','cancel','bad','issue','error', 'invalid', 'locked out'];
const posWords = ['great','thanks','love','good','happy','please'];
let rawSentiment = 0;
negWords.forEach(w => { if (t.includes(w)) rawSentiment -= 0.18; });
posWords.forEach(w => { if (t.includes(w)) rawSentiment += 0.12; });
rawSentiment = Math.max(-1, Math.min(1, rawSentiment));
const sentimentEvidence = [];
let sentimentScore = rawSentiment;
sentimentSpecs.forEach(([label, weight, patterns]) => {
const phrase = firstPatternHit(t, patterns);
if (phrase) {
sentimentScore += weight;
sentimentEvidence.push(`${label}: ${phrase}`);
}
});
sentimentScore = Math.max(-1, Math.min(1, sentimentScore));
return {
urgency_score: Math.round(urgencyScore * 10000) / 10000,
urgency_level: urgencyLevelFromScore(urgencyScore),
urgency_flags: Array.from(new Set(urgencyFlags)),
urgency_evidence: urgencyEvidence,
sentiment_score: Math.round(sentimentScore * 10000) / 10000,
sentiment_raw_score: Math.round(rawSentiment * 10000) / 10000,
sentiment_label: sentimentLabelFromScore(sentimentScore),
sentiment_evidence: sentimentEvidence,
};
}
// -- Seeded PRNG (deterministic per text) --------------
function hashText(str) {
let h = 0;
for (let i = 0; i < str.length; i++) {
h = ((h << 5) - h + str.charCodeAt(i)) | 0;
}
return Math.abs(h);
}
function seededRandom(seed) {
let s = seed;
return function() {
s = (s * 1664525 + 1013904223) & 0xffffffff;
return (s >>> 0) / 0xffffffff;
};
}
// -- Simulation (when API is offline) ------------------
function simulateRouting(text, extraPayload = {}) {
const t = text.toLowerCase().trim();
const marker = t.match(/\[clarification:\s*([a-z_]+)\s*-\s*([^\]]+)\]/);
const clarificationTarget = extraPayload.clarification_target || (marker && marker[1]);
const clarificationChoice = extraPayload.clarification_choice || (marker && marker[2]);
const validTargets = Object.keys(CAT_COLORS);
if (clarificationTarget && validTargets.includes(clarificationTarget)) {
const allProbs = {};
validTargets.forEach(cat => { allProbs[cat] = cat === clarificationTarget ? 0.9 : 0.0143; });
const demoSignals = inferDemoSignals(t);
return {
action: 'route',
confidence: 0.9,
entropy: 0.35,
margin: 0.75,
top_category: clarificationTarget,
all_probs: allProbs,
top_two_classes: [clarificationTarget, validTargets.find(cat => cat !== clarificationTarget)],
queue: clarificationTarget,
reason: `Clarification answer resolved the ambiguity toward ${clarificationTarget}.`,
clarification_applied: true,
clarification_choice: clarificationChoice,
sla_breach_probability: Math.min(0.95, 0.15 + (demoSignals.urgency_score * 0.45)),
urgency_score: demoSignals.urgency_score,
features: {
...demoSignals,
text_complexity_score: Math.round(text.split(' ').length / 5 * 100) / 100,
},
latency_ms: 28 + (hashText(t) % 20),
};
}
// Basic validation in simulation to match real API behavior
if (t.length < 10) {
const greetings = ['hi', 'hello', 'hey', 'test'];
if (greetings.some(g => t.startsWith(g))) {
return {
action: 'invalid_input',
error_type: 'greeting',
response: "Hi there! Could you describe the issue you're experiencing? We're here to help."
};
}
return {
action: 'invalid_input',
error_type: 'too_short',
response: "Could you share a bit more detail about your issue? We're here to help."
};
}
const rng = seededRandom(hashText(t)); // deterministic per text
const scores = {
billing: 0.02, technical_support: 0.02, account_management: 0.02,
feature_request: 0.02, compliance_legal: 0.02, onboarding: 0.02,
general_inquiry: 0.02, churn_risk: 0.02,
};
// Simple keyword scoring
const kw = {
billing: ['invoice','billing','payment','charge','refund','price','cost','subscription','plan','pricing','credit'],
technical_support: ['error','bug','broken','crash','fix','api','endpoint','500','timeout','issue','not working','failed'],
account_management: ['account','user','access','permission','settings','profile','password','role'],
feature_request: ['feature','add','implement','suggest','request','capability','enhancement','wish','could you'],
compliance_legal: ['gdpr','compliance','audit','regulation','privacy','security','data protection','legal'],
onboarding: ['new user','setup','getting started','onboarding','first time','just signed up','configure','install'],
general_inquiry: ['how do','what is','question','information','help','guide','documentation'],
churn_risk: ['cancel','switch','competitor','alternative','frustrated','unacceptable','leaving','terminate','fed up','last straw'],
};
Object.entries(kw).forEach(([cat, words]) => {
words.forEach(w => { if (t.includes(w)) scores[cat] += 0.15 + rng() * 0.05; });
});
// Normalize
const total = Object.values(scores).reduce((a, b) => a + b, 0);
Object.keys(scores).forEach(k => scores[k] /= total);
// Add small deterministic noise (simulate MC Dropout variance)
Object.keys(scores).forEach(k => {
scores[k] += (rng() - 0.5) * 0.03;
scores[k] = Math.max(0.001, scores[k]);
});
const total2 = Object.values(scores).reduce((a, b) => a + b, 0);
Object.keys(scores).forEach(k => scores[k] /= total2);
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const confidence = sorted[0][1];
const entropy = -Object.values(scores).reduce((s, p) => s + p * Math.log(p + 1e-9), 0);
const topCat = sorted[0][0];
const topTwo = [sorted[0][0], sorted[1][0]];
const margin = sorted[0][1] - sorted[1][1];
let action, reason;
const critical_labels = ['compliance_legal', 'account_management'];
if (critical_labels.includes(topCat)) {
if (confidence >= 0.90 && margin >= 0.35 && entropy < 0.60) {
action = 'route';
reason = `- Safe to auto-route sensitive intent
- Confidence: ${(confidence*100).toFixed(1)}%
- Margin: ${margin.toFixed(2)}`;
} else {
action = 'escalate';
reason = `- Escalated sensitive intent (${topCat.replace(/_/g,' ')})
- Strict confidence/margin threshold not met`;
}
} else {
if (confidence >= 0.85 && margin >= 0.25 && entropy < 0.70) {
action = 'route';
reason = `- Strong dominant intent
- Confidence: ${(confidence*100).toFixed(1)}%
- Margin: ${margin.toFixed(2)}
- Safe to auto-route`;
} else if (confidence >= 0.60 && entropy < 1.05) {
action = 'clarify';
reason = `- Medium ambiguity detected
- Clarification needed between ${topTwo[0].replace(/_/g,' ')} and ${topTwo[1].replace(/_/g,' ')}
- Margin: ${margin.toFixed(2)}`;
} else {
action = 'escalate';
reason = `- High ambiguity / Low confidence (${(confidence*100).toFixed(1)}%)
- Multiple overlapping intents detected
- Human triage needed`;
}
}
// Clarification question
let clarification = null;
if (action === 'clarify') {
const questions = {
'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 },
'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 },
'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 },
'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 },
'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 },
'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 },
'onboarding+technical_support': { question_text: 'Is this affecting a new user, or an existing user?', options: ['New user','Existing user'], expected_gain: 0.65 },
'technical_support+onboarding': { question_text: 'Is this affecting a new user, or an existing user?', options: ['New user','Existing user'], expected_gain: 0.65 },
'compliance_legal+billing': { question_text: 'Does this relate to a regulatory requirement, or to payment/invoicing?', options: ['Regulatory','Payment'], expected_gain: 0.72 },
'billing+compliance_legal': { question_text: 'Does this relate to a regulatory requirement, or to payment/invoicing?', options: ['Regulatory','Payment'], expected_gain: 0.72 },
'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 },
'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 },
'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 },
'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 },
'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 },
'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 },
};
const key = topTwo[0] + '+' + topTwo[1];
clarification = questions[key] || {
question_text: 'Could you specify whether this is about a technical issue or an account/billing matter?',
options: ['Technical issue', 'Account/billing'], expected_gain: 0.62,
};
clarification.question_id = 'Q_SIM';
}
const demoSignals = inferDemoSignals(t);
// SLA - deterministic based on text features
const outageWords = ['down', 'outage', 'crash', 'failing', 'blocked'];
const outageFlags = outageWords.filter(w => t.includes(w));
const slaBase = 0.15
+ (demoSignals.sentiment_score < -0.3 ? 0.2 : 0)
+ (demoSignals.urgency_score * 0.45)
+ (outageFlags.length * 0.15);
const slaBreach = Math.min(Math.round(slaBase * 1000) / 1000, 0.95);
return {
action, confidence: Math.round(confidence * 10000) / 10000,
entropy: Math.round(entropy * 10000) / 10000,
margin: Math.round(margin * 10000) / 10000,
top_category: topCat, all_probs: scores,
top_two_classes: topTwo, queue: topCat,
reason, clarification,
sla_breach_probability: slaBreach,
urgency_score: demoSignals.urgency_score,
features: {
...demoSignals,
text_complexity_score: Math.round(text.split(' ').length / 5 * 100) / 100,
},
latency_ms: 38 + (hashText(t) % 30),
};
}