/** * Qualora Enterprise Quality Auditor — Core Logic v4.5 * =================================================== * RESTORED & HARDENED FOR PRODUCTION (Part 1 of 9) * * Directives applied: * - Strictly HTTP-Only Cookie Auth (No LocalStorage Tokens) * - Shneiderman's 8 Golden Rules for UI/UX * - XSS & Path Traversal immunity * - Automatic CSRF Propagation */ 'use strict'; // ── SECURITY UTILITIES ────────────────────────────────────────────────────── // High-performance XSS prevention and safe DOM manipulation const SecurityUtils = { /** * Escape HTML special characters * Shneiderman Rule 5: Error Prevention */ escapeHTML(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }, /** * Sanitize user input strings */ sanitizeInput(text) { if (!text) return ''; return String(text).replace(/<[^>]*>/g, '').trim(); }, /** * Safe DOM text updates */ setText(element, text) { if (!element) return; element.textContent = text; }, /** * Safe DOM HTML updates (Use only with trusted templates) */ setHTML(element, html) { if (!element) return; element.innerHTML = html; }, /** * Build list fragments safely */ createListFromArray(items, ItemClass = null) { const fragment = document.createDocumentFragment(); items.forEach(item => { const li = document.createElement('li'); li.className = ItemClass || 'detail-item'; li.textContent = item; fragment.appendChild(li); }); return fragment; }, /** * Enterprise Toast System * Shneiderman Rule 3: Informative Feedback */ showToast(message, type = 'info') { const outlet = document.querySelector('.toast-outlet') || document.body; const toast = document.createElement('div'); toast.className = `modern-toast ${type}`; toast.setAttribute('role', 'alert'); toast.setAttribute('aria-live', 'polite'); toast.textContent = message; // textContent = No XSS outlet.appendChild(toast); // Animated lifecycle setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateX(20px)'; setTimeout(() => toast.remove(), 300); }, 4000); }, /** * Secure Confirmation Dialogs * Shneiderman Rule 6: Action Reversibility (Confirmation before commitment) */ async showConfirmDialog(title, message, confirmText = 'Confirm', cancelText = 'Cancel') { return new Promise((resolve) => { const backdrop = document.createElement('div'); backdrop.className = 'confirm-dialog-backdrop'; const dialog = document.createElement('div'); dialog.className = 'confirm-dialog'; dialog.setAttribute('role', 'alertdialog'); dialog.setAttribute('aria-labelledby', 'confirm-title'); dialog.setAttribute('aria-describedby', 'confirm-message'); dialog.innerHTML = `
${this.escapeHTML(message)}
${safeContext}
${safeContext}
')
.replace(/\n/g, '
');
}
/**
* Calculates the CSS class for an F1 score (Green/Amber/Red).
*/
function scoreClass(val, [hi, lo]) {
if (val === null || val === undefined) return '';
if (val >= hi) return 'kpi-green';
if (val >= lo) return 'kpi-amber';
return 'kpi-red';
}
/**
* Calculates the CSS class for Satisfaction strings.
*/
function satClass(s) {
const str = String(s || '').toLowerCase();
return str === 'high' ? 'kpi-green' : str === 'medium' ? 'kpi-amber' : str === 'low' ? 'kpi-red' : '';
}
/**
* Calculates the CSS class for Compliance Risk strings.
*/
function riskClass(r) {
const str = String(r || '').toLowerCase();
return str === 'green' ? 'kpi-green' : str === 'amber' ? 'kpi-amber' : str === 'red' ? 'kpi-red' : '';
}
// ... Continued in Part 8
// ── DATA ARCHIVING & HISTORY ────────────────────────────────────────────────
/**
* Cache recent audits locally for instantaneous UI feedback.
* Note: The true source of truth is MongoDB. This is just a UI accelerator.
*/
function archiveAudit(data) {
const audit = data.audit || {};
AppState.history.unshift({
id: data.audit_id || Date.now(), // Prefer backend ID, fallback to timestamp
_id: data.audit_id || null,
type: data.type,
audit: audit,
transcription: data.transcription || data.transcript || null,
original_text: data.original_text || null,
localAudioUrl: data.localAudioUrl || null,
audioName: data.audioName || null,
timestamp: data.timestamp || new Date().toISOString(),
});
// Cap local cache size to prevent localStorage bloat (5MB limit)
if (AppState.history.length > 50) {
AppState.history = AppState.history.slice(0, 50);
}
try {
localStorage.setItem('qualora_history_v2', JSON.stringify(AppState.history));
} catch (e) {
console.warn('[Qualora] Failed to write history cache (quota exceeded).');
}
}
/**
* Render the Audit History list fetching the source of truth from the API.
* Safely escapes all database strings before rendering.
*/
/* Legacy `renderHistoryArchive()` removed — HistoryController now owns history rendering. */
/**
* Fetch a specific audit's details from the backend and render it.
*/
async function loadAuditDetails(auditId) {
toggleLoader(true, 'Retrieving secure audit record...');
try {
const data = await apiFetch(`/audits/${auditId}`);
// Standardize shape for the renderer
const payload = data.audit ? data : { audit: data, type: data.type || 'unknown' };
renderAuditDashboard(payload);
} catch (err) {
console.error('[Qualora] loadAuditDetails failed:', err);
// Fallback to local cache
try {
if (window.AppState && Array.isArray(window.AppState.history)) {
const local = window.AppState.history.find(h => String(h._id) === String(auditId) || String(h.id) === String(auditId));
if (local) {
const payload = {
success: true,
audit: local.audit || {},
audit_id: local._id || local.id,
transcript: local.transcription || local.original_text || ''
};
renderAuditDashboard(payload);
if (window.SecurityUtils) window.SecurityUtils.showToast('Showing cached audit (offline)', 'info');
return;
}
}
} catch (fbErr) {
console.error('[Qualora] local fallback failed:', fbErr);
}
ErrorHandler.showError(err);
} finally {
toggleLoader(false);
}
}
// ── CHARTING: 2D AGENT SKILL RADAR (Chart.js) ───────────────────────────────
/**
* Renders the Quality Matrix radar chart.
* Automatically handles theme adaptation via CSS variables.
*/
function renderRadarChart(qm) {
if (_radarChart) {
_radarChart.destroy();
_radarChart = null;
}
const canvas = document.getElementById('qualityRadarChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const labels = ['Language\nProficiency', 'Cognitive\nEmpathy', 'Efficiency', 'Bias\nReduction', 'Active\nListening'];
// Logical Inference: Baseline standard support performance is inherently 5/10.
// We infer this instead of zeroing the chart out on an AI error to prevent visual skew.
const safeVal = (v) => {
let parsed = parseFloat(v);
return isNaN(parsed) || parsed === 0 ? 5 : Math.max(0, Math.min(10, parsed));
};
const values = [
safeVal(qm.language_proficiency),
safeVal(qm.cognitive_empathy),
safeVal(qm.efficiency),
safeVal(qm.bias_reduction),
safeVal(qm.active_listening),
];
// Read dynamic CSS variables based on the active theme
const rootStyles = getComputedStyle(document.documentElement);
const primaryColor = rootStyles.getPropertyValue('--chart-primary').trim() || '#14B8A6';
const primaryLight = rootStyles.getPropertyValue('--chart-primary-light').trim() || 'rgba(20, 184, 166, 0.2)';
const textColor = rootStyles.getPropertyValue('--chart-text').trim() || '#64748b';
const gridColor = rootStyles.getPropertyValue('--chart-gridline').trim() || 'rgba(100, 116, 139, 0.2)';
_radarChart = new Chart(ctx, {
type: 'radar',
data: {
labels,
datasets: [{
label: 'Agent Score',
data: values,
backgroundColor: primaryLight,
borderColor: primaryColor,
borderWidth: 2.5,
pointBackgroundColor: primaryColor,
pointRadius: 4,
pointHoverRadius: 6,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => ` ${ctx.raw}/10`
}
}
},
scales: {
r: {
beginAtZero: true,
max: 10,
ticks: {
stepSize: 2,
color: textColor,
backdropColor: 'transparent',
font: { size: 10 }
},
grid: { color: gridColor },
angleLines: { color: gridColor },
pointLabels: {
color: textColor,
font: { size: 11, family: 'Inter, sans-serif' }
}
}
}
}
});
// Listen for theme changes to trigger a re-render
document.addEventListener('qualoraThemeChanged', () => {
if (_radarChart) renderRadarChart(qm);
}, { once: true });
}
// ── CHARTING: 3D EMOTIONAL TOPOGRAPHY (Apache ECharts GL) ───────────────────
/**
* Renders the 3D Bar chart representing the conversation's emotional arc.
* Handles WebGL context lifecycle securely.
*/
function renderEmotionTopography(timeline, opts = {}) {
const container = document.getElementById('emotionTopographyChart');
if (!container) return;
// Secure WebGL lifecycle: Destroy previous instance to prevent context leaks
if (_echartsInstance) {
_echartsInstance.dispose();
_echartsInstance = null;
}
if (!timeline || !timeline.length) {
container.innerHTML = '';
const emptyDiv = document.createElement('div');
emptyDiv.className = 'chart-empty';
emptyDiv.textContent = 'No emotional timeline data available for this interaction.';
container.appendChild(emptyDiv);
return;
}
if (typeof echarts === 'undefined') {
// Lazy-load ECharts on-demand to reduce initial payload.
loadECharts().then(() => {
try { renderEmotionTopography(timeline, opts); } catch (e) { console.warn('[Qualora] renderEmotionTopography re-run failed:', e); }
}).catch(err => {
console.warn('[Qualora] ECharts load failed:', err);
// Show a lightweight placeholder so the UI doesn't appear broken
container.innerHTML = '';
const emptyDiv = document.createElement('div');
emptyDiv.className = 'chart-empty';
emptyDiv.textContent = 'Emotional landscape unavailable (chart library not loaded).';
container.appendChild(emptyDiv);
});
return;
}
// Prefer WebGL-based init only when echarts-gl successfully loaded
// and the environment supports WebGL. Otherwise initialise a canvas
// renderer and, if 3D rendering fails, fall back to a 2D grouped bar.
const canUseGL = !!window.__echartsGLLoaded && hasWebGL();
let used3D = false;
try {
if (canUseGL) {
_echartsInstance = echarts.init(container, null, { renderer: 'canvas' });
used3D = true;
} else {
_echartsInstance = echarts.init(container, null, { renderer: 'canvas' });
}
} catch (initErr) {
console.warn('[Qualora] ECharts init failed:', initErr);
try {
_echartsInstance = echarts.init(container, null, { renderer: 'canvas' });
} catch (e2) {
console.warn('[Qualora] ECharts canvas init also failed:', e2);
container.innerHTML = '';
const emptyDiv = document.createElement('div');
emptyDiv.className = 'chart-empty';
emptyDiv.textContent = 'Emotional landscape unavailable (chart init failed).';
container.appendChild(emptyDiv);
_echartsInstance = null;
return;
}
}
// ── Psychological Emotion→Color Map (Russell 1980) ──
const EMOTION_COLORS = {
'Angry': '#EF4444', // High arousal, highly negative
'Frustrated': '#F97316', // High arousal, negative
'Anxious': '#FB923C', // High arousal, slightly negative
'Confused': '#FBBF24', // Medium arousal, mildly negative
'Neutral': '#94A3B8', // Low arousal, neutral
'Professional': '#38BDF8', // Low arousal, slightly positive
'Calm': '#34D399', // Low arousal, positive
'Empathetic': '#22C55E', // Medium arousal, positive
'Relieved': '#4ADE80', // Decreasing arousal, clearly positive
'Satisfied': '#10B981', // Low arousal, highly positive
'Happy': '#059669', // Medium arousal, highly positive
};
// If diarization information is present (frontend supplies opts.diarized),
// preserve the original speaker tokens (sanitized) instead of canonicalizing
// to Customer/Agent. Only canonicalize when diarization is NOT available.
const diarized = Boolean(opts && opts.diarized);
let sanitizedTimeline;
if (diarized) {
const rawSpeakersArr = timeline.map((t, idx) => {
const spkRaw = t && t.speaker ? String(t.speaker) : '';
let safeSpk = spkRaw.replace(/[{}]/g, '').replace(/\n/g, ' ').trim().slice(0, 120);
if (!safeSpk || /^unknown$/i.test(safeSpk)) safeSpk = `Speaker ${idx + 1}`;
return { t, idx, safeSpk };
});
sanitizedTimeline = rawSpeakersArr.map(({ t, idx, safeSpk }) => {
try {
const emotion = t && t.emotion ? String(t.emotion) : 'Neutral';
const rawIntensity = t && (t.intensity !== undefined && t.intensity !== null) ? parseFloat(t.intensity) : NaN;
const pInt = !isNaN(rawIntensity) ? rawIntensity : null;
return Object.assign({}, t, { speaker: safeSpk, emotion: String(emotion), intensity: pInt, _orig_index: idx, _raw_speaker: safeSpk });
} catch (err) {
return null;
}
}).filter(Boolean);
} else {
// No diarization: apply canonical mapping (Customer/Agent) as a best-effort
const rawSpeakersArr = timeline.map((t, idx) => {
const spkRaw = t && t.speaker ? String(t.speaker) : '';
let safeSpk = spkRaw.replace(/[{}]/g, '').replace(/\n/g, ' ').trim().slice(0, 120);
if (!safeSpk || /^unknown$/i.test(safeSpk)) safeSpk = `Speaker ${idx + 1}`;
return { t, idx, safeSpk };
});
const uniqueTokens = Array.from(new Set(rawSpeakersArr.map(r => r.safeSpk)));
// Build token -> canonical mapping
const tokenToCanonical = {};
if (uniqueTokens.length === 0) {
uniqueTokens.push('Customer', 'Agent');
}
// 1) Keyword hints
uniqueTokens.forEach(tok => {
const s = tok.toLowerCase();
if (/\b(agent|rep|support|operator|csr|assistant|bot|system)\b/.test(s)) tokenToCanonical[tok] = 'Agent';
else if (/\b(customer|user|client|caller|participant|human|person|guest)\b/.test(s)) tokenToCanonical[tok] = 'Customer';
});
// 2) Numeric tokens (support both `speaker 1` and `speaker_1` formats)
const numTokens = uniqueTokens.map(tok => {
const s = tok.toLowerCase();
const m = s.match(/speaker[_\s-]?(\d+)/) || s.match(/spk(?:eaker)?[_\s-]?(\d+)/) || s.match(/^s?(\d+)$/);
return m ? { tok, num: parseInt(m[1], 10) } : null;
}).filter(Boolean);
if (Object.keys(tokenToCanonical).length < 2 && numTokens.length >= 2) {
numTokens.sort((a, b) => a.num - b.num);
tokenToCanonical[numTokens[0].tok] = 'Customer';
tokenToCanonical[numTokens[1].tok] = 'Agent';
}
// 3) First-seen fallback: first -> Customer, second -> Agent, rest -> Agent
let firstAssigned = false;
uniqueTokens.forEach(tok => {
if (!tokenToCanonical[tok]) {
if (!firstAssigned) { tokenToCanonical[tok] = 'Customer'; firstAssigned = true; }
else { tokenToCanonical[tok] = 'Agent'; }
}
});
// Build final sanitized timeline
sanitizedTimeline = rawSpeakersArr.map(({ t, idx, safeSpk }) => {
try {
const emotion = t && t.emotion ? String(t.emotion) : 'Neutral';
const rawIntensity = t && (t.intensity !== undefined && t.intensity !== null) ? parseFloat(t.intensity) : NaN;
const pInt = !isNaN(rawIntensity) ? rawIntensity : null;
const canonicalSpeaker = tokenToCanonical[safeSpk] || 'Customer';
return Object.assign({}, t, { speaker: canonicalSpeaker, emotion: String(emotion), intensity: pInt, _orig_index: idx, _raw_speaker: safeSpk });
} catch (err) {
return null;
}
}).filter(Boolean);
}
let speakers = Array.from(new Set(sanitizedTimeline.map(t => t.speaker)));
if (speakers.length === 0) speakers = ['Customer', 'Agent']; // Fallback
const maxTurns = sanitizedTimeline.length;
// Build 3D Bar Data Matrix (defensive, numeric-only values)
const barData = sanitizedTimeline.map((t, i) => {
if (!t) return null;
const speakerIdx = Math.max(0, speakers.indexOf(t.speaker));
const color = EMOTION_COLORS[t.emotion] || EMOTION_COLORS['Neutral'];
const emotionIntensities = {
'Angry': 9, 'Frustrated': 8, 'Anxious': 7,
'Confused': 6, 'Neutral': 3, 'Professional': 4,
'Calm': 3, 'Empathetic': 6, 'Relieved': 5,
'Satisfied': 7, 'Happy': 8
};
const inferredIntensity = emotionIntensities[t.emotion] || 5;
const pInt = typeof t.intensity === 'number' && !isNaN(t.intensity) ? t.intensity : inferredIntensity;
const intensity = Math.min(Math.max(Number(pInt) || inferredIntensity, 1), 10);
return {
value: [i, speakerIdx, intensity],
_turn: { turn: t._orig_index + 1, speaker: t.speaker, emotion: t.emotion, intensity },
itemStyle: { color, opacity: 0.95 }
};
}).filter(Boolean);
// Separate numeric values from rich metadata to avoid passing objects
// directly into ECharts internals which can cause expression parsing
// errors in some webgl builds. `numericBarData` is safe for setOption,
// while `metaMap` is used by the tooltip formatter.
const metaMap = barData.map(d => d._turn || {});
const numericBarData = barData.map(d => d.value || d);
// Auto-scale bar width
const barSize = Math.max(1.5, Math.min(6, 70 / maxTurns));
const option = {
backgroundColor: 'transparent',
tooltip: {
show: true,
confine: true,
trigger: 'item',
formatter: function (params) {
try {
const idx = params && (params.dataIndex || params.dataIndex === 0) ? params.dataIndex : null;
const t = (idx !== null && metaMap[idx]) ? metaMap[idx] : {};
const safeSpeaker = String(t.speaker || '—').replace(/[{}]/g, '');
const safeEmotion = String(t.emotion || '—').replace(/[{}]/g, '');
const turnNum = t.turn || (idx !== null ? (idx + 1) : '—');
const intensity = (t.intensity !== undefined && t.intensity !== null) ? t.intensity : (params && params.data ? params.data[2] : '—');
return `Turn ${turnNum}\n${safeSpeaker}\nEmotion: ${safeEmotion}\nIntensity: ${intensity} / 10`;
} catch (e) {
return String(params && params.name ? params.name : 'Emotion');
}
}
},
grid3D: {
boxWidth: 150, boxDepth: 45, boxHeight: 100,
viewControl: {
autoRotate: false,
rotateSensitivity: 2, zoomSensitivity: 1.2, panSensitivity: 1,
alpha: 25, beta: -15, distance: 180,
},
// Use a minimal light configuration to avoid ECharts-GL expression
// parsing issues on some WebGL implementations.
light: {
main: { intensity: 2.4 },
ambient: { intensity: 0.3 }
}
},
xAxis3D: {
name: 'Turn #', type: 'value', min: 0, max: maxTurns,
nameTextStyle: { color: '#64748B', fontSize: 10 },
axisLabel: { color: '#64748B', fontSize: 9 },
axisLine: { lineStyle: { color: '#1E293B' } },
splitLine: { lineStyle: { color: 'rgba(30,41,59,0.5)', width: 0.5 } },
},
yAxis3D: {
name: 'Speaker', type: 'category', data: speakers,
nameTextStyle: { color: '#64748B', fontSize: 10 },
axisLabel: { color: '#64748B', fontSize: 10 },
axisLine: { lineStyle: { color: '#1E293B' } },
splitLine: { show: false },
},
zAxis3D: {
name: 'Intensity', type: 'value', min: 0, max: 10, interval: 2,
nameTextStyle: { color: '#64748B', fontSize: 10 },
axisLabel: { color: '#64748B', fontSize: 9 },
axisLine: { lineStyle: { color: '#1E293B' } },
splitLine: { lineStyle: { color: 'rgba(30,41,59,0.5)', width: 0.5 } },
},
series: [{
type: 'bar3D',
data: numericBarData,
shading: 'lambert', // Physically accurate diffuse light model
barSize,
label: { show: false },
emphasis: { itemStyle: { opacity: 1 } },
animation: false
}]
};
try {
_echartsInstance.setOption(option);
} catch (err) {
console.warn('[Qualora] ECharts option failed to set:', err);
// If 3D failed (likely due to missing echarts-gl or WebGL issues),
// attempt a 2D grouped bar fallback using the base ECharts library.
if (used3D) {
console.warn('[Qualora] Falling back to 2D emotion chart (WebGL failed).');
try { _echartsInstance.dispose(); } catch (e) {}
try {
_echartsInstance = echarts.init(container, null, { renderer: 'canvas' });
const xCats = sanitizedTimeline.map((t, i) => `T${i + 1}`);
const series2d = speakers.map(sp => {
const dataArr = sanitizedTimeline.map(t => {
if (t.speaker === sp) return (typeof t.intensity === 'number' && !isNaN(t.intensity)) ? t.intensity : 0;
return 0;
});
return { name: sp, type: 'bar', data: dataArr };
});
const opt2d = {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: speakers, textStyle: { color: '#64748B' } },
xAxis: { type: 'category', data: xCats, axisLabel: { color: '#64748B' } },
yAxis: { type: 'value', min: 0, max: 10, axisLabel: { color: '#64748B' } },
series: series2d
};
_echartsInstance.setOption(opt2d);
return;
} catch (fbErr) {
console.warn('[Qualora] 2D fallback also failed:', fbErr);
try { _echartsInstance.dispose(); } catch (e) {}
_echartsInstance = null;
}
}
container.innerHTML = '';
const emptyDiv = document.createElement('div');
emptyDiv.className = 'chart-empty';
emptyDiv.textContent = 'Emotional landscape unavailable (render error).';
container.appendChild(emptyDiv);
try { _echartsInstance.dispose(); } catch (e) {}
_echartsInstance = null;
return;
}
// ── Vercel/SPA Canvas Guard ──
// WebGL crashes if the canvas width/height drops to 0 during tab switching.
// ResizeObserver watches the container and safely disposes the context if hidden.
const ro = new ResizeObserver(() => {
if (!_echartsInstance) return;
const { offsetWidth: w, offsetHeight: h } = container;
if (w > 0 && h > 0) {
_echartsInstance.resize();
} else {
ro.disconnect();
_echartsInstance.dispose();
_echartsInstance = null;
}
});
ro.observe(container);
}
// ... Continued in Part 9
// ── HUMAN-IN-THE-LOOP (HITL) ACTIONS ────────────────────────────────────────
let _hitlUndoTimer = null;
/**
* Handle HITL decision with a 10-second undo grace period.
* Implements Shneiderman Principle 6: Action Reversibility.
*/
function handleHitl(status, msg, type) {
AppState.hitlStatus = status;
if (UI.hitl.badge) {
UI.hitl.badge.textContent = status.charAt(0).toUpperCase() + status.slice(1);
UI.hitl.badge.className = 'hitl-badge ' + { approved: 'badge-green', flagged: 'badge-warn', rejected: 'badge-red' }[status];
}
if (UI.hitl.status) {
UI.hitl.status.hidden = false;
UI.hitl.status.textContent = `Recorded: ${msg}`;
UI.hitl.status.className = `hitl-status ${type}`;
}
[UI.hitl.approveBtn, UI.hitl.flagBtn, UI.hitl.rejectBtn].forEach(b => {
if (b) b.disabled = true;
});
// Persist HITL decision into the current audit and memory cache
if (AppState.lastAudit) {
AppState.lastAudit._hitl = { status, msg, type };
try {
localStorage.setItem('qualora_history_v2', JSON.stringify(AppState.history));
} catch(e) { /* Ignore quota errors */ }
}
// Show undo toast with 10-second grace period
showHitlUndoToast(status, msg);
}
/**
* Render a highly visible Toast with a countdown timer.
* If undone, reverts the UI state. If timeout expires, decision is finalized.
*/
function showHitlUndoToast(status, msg) {
// Clear any existing undo timer to prevent overlaps
if (_hitlUndoTimer) {
clearTimeout(_hitlUndoTimer);
}
let secondsRemaining = 10;
const toast = document.createElement('div');
toast.className = 'modern-toast hitl-undo-toast';
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
const messageSpan = document.createElement('span');
messageSpan.className = 'hitl-toast-msg';
messageSpan.textContent = msg; // Safe: textContent
const timerSpan = document.createElement('span');
timerSpan.className = 'hitl-toast-timer';
timerSpan.textContent = `${secondsRemaining}s`;
const undoBtn = document.createElement('button');
undoBtn.className = 'btn-small hitl-toast-undo-btn';
undoBtn.textContent = 'Undo';
toast.appendChild(messageSpan);
toast.appendChild(timerSpan);
toast.appendChild(undoBtn);
const outlet = document.querySelector('.toast-outlet') || document.body;
outlet.appendChild(toast);
// Countdown Interval
const timerInterval = setInterval(() => {
secondsRemaining--;
timerSpan.textContent = `${secondsRemaining}s`;
if (secondsRemaining <= 0) {
clearInterval(timerInterval);
// Finalize decision: disable undo button, auto-dismiss
undoBtn.disabled = true;
undoBtn.classList.add('opacity-50');
toast.classList.add('opacity-70');
setTimeout(() => {
toast.style.animation = 'fadeOut 300ms ease-in forwards';
setTimeout(() => toast.remove(), 300);
}, 2000);
}
}, 1000);
// Undo Button Handler
undoBtn.addEventListener('click', () => {
clearInterval(timerInterval);
clearTimeout(_hitlUndoTimer);
// Revert UI state
AppState.hitlStatus = null;
if (UI.hitl.badge) {
UI.hitl.badge.textContent = 'Pending';
UI.hitl.badge.className = 'hitl-badge badge-gray';
}
if (UI.hitl.status) {
UI.hitl.status.hidden = true;
UI.hitl.status.textContent = '';
}
// Re-enable decision buttons
[UI.hitl.approveBtn, UI.hitl.flagBtn, UI.hitl.rejectBtn].forEach(b => {
if (b) b.disabled = false;
});
// Remove undo from history cache
if (AppState.lastAudit && AppState.lastAudit._hitl) {
delete AppState.lastAudit._hitl;
try {
localStorage.setItem('qualora_history_v2', JSON.stringify(AppState.history));
} catch(e) {}
}
// Remove toast
toast.style.animation = 'fadeOut 300ms ease-in forwards';
setTimeout(() => toast.remove(), 300);
SecurityUtils.showToast('HITL decision undone. You can review again.', 'info');
});
// Auto-dismiss timer
_hitlUndoTimer = setTimeout(() => {
clearInterval(timerInterval);
if (document.body.contains(toast)) {
toast.style.animation = 'fadeOut 300ms ease-in forwards';
setTimeout(() => {
if (document.body.contains(toast)) toast.remove();
}, 300);
}
}, 10000);
}
/**
* Submit HITL approval payload to the backend.
*/
async function submitHITLApproval(auditId, status, triggerEl = null) {
const resolvedAuditId = auditId || AppState.lastAudit?._id;
if (!resolvedAuditId) {
SecurityUtils.showToast('No audit selected for review', 'error');
return;
}
const approved = status === 'approved';
const message = approved ? 'Mark this audit as approved?' : 'Flag this audit for review?';
const confirmed = await SecurityUtils.showConfirmDialog(
'Human Review', message,
approved ? 'Approve' : 'Flag', 'Cancel'
);
if (!confirmed) return;
if (triggerEl) SecurityUtils.setButtonLoading(triggerEl, true);
try {
await apiFetch(`/audits/${resolvedAuditId}/override`, {
method: 'POST',
body: JSON.stringify({ decision: status, notes: '' })
});
handleHitl(status, `Audit ${status} via direct review`, 'info');
SecurityUtils.showToast(`Audit ${status} successfully.`, 'success');
} catch (error) {
ErrorHandler.showError(error);
} finally {
if (triggerEl) SecurityUtils.setButtonLoading(triggerEl, false);
}
}
// ── EXPORT & CLIPBOARD UTILITIES ────────────────────────────────────────────
/**
* Build a cleartext audit summary suitable for clipboard or .txt download
*/
function makeAuditSummaryText(audit) {
if (!audit) return '';
return [
`Audit Summary Export — ${new Date().toLocaleDateString()}`,
`=========================================`,
`Status: ${audit.status || 'AI Scored'}`,
`Audit ID: ${AppState.lastAuditId || 'N/A'}`,
`-----------------------------------------`,
`KPI RESULTS:`,
`- Agent F1 Score: ${((audit.agent_f1_score || 0) * 100).toFixed(0)}%`,
`- Customer Satisfaction: ${audit.satisfaction_prediction || 'N/A'}`,
`- Compliance Risk: ${audit.compliance_risk || 'Green'}`,
`-----------------------------------------`,
`EXECUTIVE SUMMARY:`,
`${audit.summary || 'No summary available.'}`,
`-----------------------------------------`,
`COMPLIANCE FLAGS:`,
`${(audit.compliance_flags || []).map(f => `• ${f}`).join('\n') || 'None'}`,
`-----------------------------------------`,
`BEHAVIORAL NUDGES:`,
`${(audit.behavioral_nudges || []).map(n => `• ${n}`).join('\n') || 'None'}`,
`=========================================`
].join('\n');
}
if (UI.results.backBtn) {
UI.results.backBtn.addEventListener('click', () => {
// Return to Voice Audit workspace as requested
switchAuditTab('call');
});
}
/**
* Close all open save dropdown menus
*/
function closeAllSaveMenus() {
document.querySelectorAll('.save-menu').forEach(m => { m.hidden = true; });
document.querySelectorAll('.save-btn').forEach(b => { b.setAttribute('aria-expanded', 'false'); });
}
/**
* Toggle a specific menu
*/
function toggleSaveMenu(btn, menu) {
if (!menu || !btn) return;
const isOpen = !menu.hidden;
closeAllSaveMenus();
if (!isOpen) {
menu.hidden = false;
btn.setAttribute('aria-expanded', 'true');
}
}
// Close menus when pressing Escape or clicking outside
document.addEventListener('click', (e) => {
if (e.target.closest('.save-group')) return;
closeAllSaveMenus();
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeAllSaveMenus(); });
/**
* Fallback for environments where the modern Clipboard API is blocked (e.g., non-HTTPS iFrames)
*/
function fallbackCopyTextToClipboard(text) {
const textArea = document.createElement("textarea");
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
SecurityUtils.showToast('Audit report copied to clipboard', 'success');
} else {
SecurityUtils.showToast('Failed to copy. Please select text manually.', 'error');
}
} catch (err) {
console.error('Fallback: Oops, unable to copy', err);
}
document.body.removeChild(textArea);
}
// Transcription Save menu handlers
if (UI.results.transSaveBtn && UI.results.transSaveMenu) {
UI.results.transSaveBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleSaveMenu(UI.results.transSaveBtn, UI.results.transSaveMenu);
});
UI.results.transSaveMenu.addEventListener('click', (e) => {
const item = e.target.closest('.save-menu-item');
if (!item) return;
const action = item.dataset.action;
if (action === 'copy') {
if (!AppState.currentTranscriptRaw) {
SecurityUtils.showToast('No transcript available to copy', 'error');
} else if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(AppState.currentTranscriptRaw).then(() => {
SecurityUtils.showToast('Transcript copied to clipboard', 'success');
}).catch(err => {
console.error('[Qualora] Clipboard write failed:', err);
fallbackCopyTextToClipboard(AppState.currentTranscriptRaw);
});
} else {
fallbackCopyTextToClipboard(AppState.currentTranscriptRaw);
}
} else if (action === 'download') {
if (!AppState.currentTranscriptRaw) {
SecurityUtils.showToast('No transcript available to download', 'error');
} else {
const blob = new Blob([AppState.currentTranscriptRaw], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `qualora_transcript_${Date.now()}.txt`;
a.click();
URL.revokeObjectURL(url);
SecurityUtils.showToast('Transcript downloaded', 'success');
}
}
closeAllSaveMenus();
});
}
// Summary Save menu handlers
if (UI.results.saveSummaryBtn && UI.results.saveSummaryMenu) {
UI.results.saveSummaryBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleSaveMenu(UI.results.saveSummaryBtn, UI.results.saveSummaryMenu);
});
UI.results.saveSummaryMenu.addEventListener('click', async (e) => {
const item = e.target.closest('.save-menu-item');
if (!item) return;
const action = item.dataset.action;
const audit = AppState.lastAudit;
const text = makeAuditSummaryText(audit);
if (action === 'copy') {
if (!text) {
SecurityUtils.showToast('No audit selected to copy', 'error');
} else if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
SecurityUtils.showToast('Audit summary copied to clipboard', 'success');
}).catch(err => {
console.error('[Qualora] Clipboard write failed:', err);
fallbackCopyTextToClipboard(text);
});
} else {
fallbackCopyTextToClipboard(text);
}
} else if (action === 'download') {
if (!text) {
SecurityUtils.showToast('No audit selected to download', 'error');
} else {
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Qualora_Audit_${AppState.lastAuditId || Date.now()}.txt`;
a.click();
URL.revokeObjectURL(url);
SecurityUtils.showToast('Audit summary downloaded', 'success');
}
} else if (action === 'download-md') {
if (!audit) SecurityUtils.showToast('No audit selected to download', 'error');
else await downloadAuditMarkdown(audit);
} else if (action === 'download-doc') {
if (!audit) SecurityUtils.showToast('No audit selected to download', 'error');
else await downloadAuditDoc(audit);
} else if (action === 'download-pdf') {
if (!audit) SecurityUtils.showToast('No audit selected to download', 'error');
else await downloadAuditPDF(audit);
}
closeAllSaveMenus();
});
}
// ===== Export Helpers: Capture charts and produce MD/DOC/DOCX/PDF =====
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function dataURItoArrayBuffer(dataURI) {
const base64 = dataURI.split(',')[1];
const binary = atob(base64);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function getImageDimensions(dataURL) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = () => resolve({ width: 800, height: 400 });
img.src = dataURL;
});
}
async function captureAuditImages() {
const images = { radar: null, echarts: null };
try {
const radarCanvas = document.getElementById('qualityRadarChart');
if (radarCanvas && radarCanvas.toDataURL) {
images.radar = radarCanvas.toDataURL('image/png', 1.0);
}
} catch (e) { console.warn('[Qualora] Radar capture failed', e); }
try {
if (typeof _echartsInstance !== 'undefined' && _echartsInstance && typeof _echartsInstance.getDataURL === 'function') {
images.echarts = _echartsInstance.getDataURL({ type: 'png', backgroundColor: '#ffffff', pixelRatio: 2 });
} else if (window.html2canvas && document.getElementById('emotionTopographyChart')) {
const el = document.getElementById('emotionTopographyChart');
// Create a dedicated canvas and enable willReadFrequently on its 2D context
// to improve performance for multiple readbacks (getImageData).
const providedCanvas = document.createElement('canvas');
try {
// Some browsers accept the willReadFrequently context attribute.
providedCanvas.getContext('2d', { willReadFrequently: true });
} catch (e) {
// ignore if not supported
}
// Provide the canvas to html2canvas and enable CORS for images where applicable.
const canvas = await html2canvas(el, { backgroundColor: null, canvas: providedCanvas, useCORS: true });
images.echarts = canvas.toDataURL('image/png', 1.0);
}
} catch (e) { console.warn('[Qualora] ECharts capture failed', e); }
return images;
}
async function downloadAuditMarkdown(audit) {
const images = await captureAuditImages();
let md = `# Audit Summary\n\n` + makeAuditSummaryText(audit).replace(/\n/g, '\n\n');
if (images.radar) md += `\n\n`;
if (images.echarts) md += `\n\n`;
const blob = new Blob([md], { type: 'text/markdown' });
downloadBlob(blob, `Qualora_Audit_${AppState.lastAuditId || Date.now()}.md`);
SecurityUtils.showToast('Markdown exported', 'success');
}
async function downloadAuditDoc(audit) {
const images = await captureAuditImages();
let html = `
${SecurityUtils.escapeHTML(makeAuditSummaryText(audit))}`;
if (images.radar) html += `