Spaces:
Paused
Paused
| /** | |
| * Solo Mode Dashboard | |
| * | |
| * Tab management, API data loading, interactive tables, | |
| * and rule review actions for the Solo Mode status page. | |
| */ | |
| class SoloDashboard { | |
| constructor() { | |
| this.currentTab = 'overview'; | |
| this.viz = null; | |
| this.refreshInterval = null; | |
| this._confusionLoaded = false; | |
| this._confusionData = null; | |
| this._lfLoaded = false; | |
| this._disagreeLoaded = false; | |
| this._disagreeData = null; | |
| } | |
| init() { | |
| this._initTabs(); | |
| this._loadOverview(); | |
| this._loadRules(); | |
| // Auto-refresh overview every 30s | |
| this.refreshInterval = setInterval(() => { | |
| if (this.currentTab === 'overview') this._loadOverview(); | |
| }, 30000); | |
| } | |
| // ── Tab Navigation ────────────────────────────────────── | |
| _initTabs() { | |
| document.querySelectorAll('.solo-tab').forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| this._switchTab(tab.dataset.tab); | |
| }); | |
| }); | |
| } | |
| _switchTab(tabName) { | |
| document.querySelectorAll('.solo-tab').forEach(tab => { | |
| tab.classList.toggle('active', tab.dataset.tab === tabName); | |
| }); | |
| document.querySelectorAll('.solo-tab-content').forEach(el => { | |
| el.classList.toggle('active', el.id === 'tab-' + tabName); | |
| }); | |
| this.currentTab = tabName; | |
| if (tabName === 'confusion' && !this._confusionLoaded) { | |
| this._loadConfusion(); | |
| } | |
| if (tabName === 'labeling-fns' && !this._lfLoaded) { | |
| this._loadLabelingFunctions(); | |
| } | |
| if (tabName === 'disagreements' && !this._disagreeLoaded) { | |
| this._loadDisagreements(); | |
| } | |
| if (tabName === 'clusters' && !this.viz) { | |
| this._initViz(); | |
| } | |
| } | |
| // ── Overview Tab ──────────────────────────────────────── | |
| async _loadOverview() { | |
| try { | |
| const resp = await fetch('/solo/api/status'); | |
| if (!resp.ok) return; | |
| const data = await resp.json(); | |
| this._renderOverview(data); | |
| } catch (e) { | |
| console.warn('Solo dashboard: failed to load overview', e); | |
| } | |
| this._loadRefinementStatus(); | |
| this._loadLabelingFunctionStatus(); | |
| } | |
| _renderOverview(data) { | |
| const stats = data.annotation_stats || {}; | |
| const agreement = data.agreement_metrics || {}; | |
| const llm = data.llm_stats || {}; | |
| // Annotation counts | |
| this._setText('ov-human', stats.human_labeled || 0); | |
| this._setText('ov-llm', stats.llm_labeled || 0); | |
| this._setText('ov-remaining', stats.remaining || 0); | |
| // Progress bar | |
| const total = (stats.human_labeled || 0) + (stats.llm_labeled || 0) + (stats.remaining || 0); | |
| const pct = total > 0 ? Math.round(((stats.human_labeled || 0) + (stats.llm_labeled || 0)) / total * 100) : 0; | |
| this._setText('ov-progress-text', pct + '%'); | |
| const bar = document.getElementById('ov-progress-bar'); | |
| if (bar) { | |
| bar.style.width = pct + '%'; | |
| bar.style.background = pct >= 80 ? 'var(--success-color, #10b981)' : 'var(--primary, #6e56cf)'; | |
| } | |
| // Agreement | |
| const rate = Math.round((agreement.agreement_rate || 0) * 100); | |
| this._setText('ov-agreement', rate + '%'); | |
| const agEl = document.getElementById('ov-agreement'); | |
| if (agEl) { | |
| agEl.className = 'card-value'; | |
| if (rate >= 90) agEl.classList.add('text-success'); | |
| else if (rate >= 70) agEl.classList.add('text-warning'); | |
| else agEl.classList.add('text-danger'); | |
| } | |
| this._setText('ov-comparisons', agreement.total_compared || 0); | |
| this._setText('ov-agreements', agreement.agreements || 0); | |
| this._setText('ov-disagreements', agreement.disagreements || 0); | |
| // LLM stats | |
| this._setText('ov-llm-labeled', llm.labeled_count || 0); | |
| this._setText('ov-llm-queue', llm.queue_size || 0); | |
| this._setText('ov-llm-errors', llm.error_count || 0); | |
| const statusEl = document.getElementById('ov-llm-status'); | |
| if (statusEl) { | |
| if (llm.is_paused) { | |
| statusEl.textContent = 'Paused'; | |
| statusEl.className = 'status-badge status-pending'; | |
| } else if (llm.is_running) { | |
| statusEl.textContent = 'Running'; | |
| statusEl.className = 'status-badge status-active'; | |
| } else { | |
| statusEl.textContent = 'Stopped'; | |
| statusEl.className = 'status-badge status-inactive'; | |
| } | |
| } | |
| // Phase | |
| const phaseName = (data.phase_name || data.phase || '').replace(/_/g, ' '); | |
| this._setText('ov-phase', phaseName); | |
| // Confidence routing | |
| const routing = llm.confidence_routing || {}; | |
| if (routing.enabled) { | |
| this._setText('ov-routing-total', routing.total_routed || 0); | |
| this._setText('ov-routing-human', routing.human_routed_count || 0); | |
| // Update per-tier stats if the card exists | |
| const routingCard = document.getElementById('routing-card'); | |
| if (routingCard && routing.tiers) { | |
| // Re-render tier rows dynamically | |
| const metricRows = routingCard.querySelectorAll('.metric-row'); | |
| // First two rows are Total Routed and Human Routed, rest are tiers | |
| routing.tiers.forEach((tier, i) => { | |
| const rowIdx = i + 2; | |
| if (metricRows[rowIdx]) { | |
| const name = tier.name || ('Tier ' + (i + 1)); | |
| const rate = Math.round((tier.acceptance_rate || 0) * 100); | |
| metricRows[rowIdx].querySelector('span').textContent = name; | |
| metricRows[rowIdx].querySelector('strong').textContent = | |
| tier.instances_accepted + ' accepted (' + rate + '%)'; | |
| } | |
| }); | |
| } | |
| } | |
| } | |
| // ── Rules Tab ─────────────────────────────────────────── | |
| async _loadRules() { | |
| try { | |
| const resp = await fetch('/solo/api/rules'); | |
| if (!resp.ok) return; | |
| const data = await resp.json(); | |
| this._renderRulesTable(data); | |
| } catch (e) { | |
| console.warn('Solo dashboard: failed to load rules', e); | |
| } | |
| } | |
| _renderRulesTable(data) { | |
| const tbody = document.getElementById('rules-table-body'); | |
| if (!tbody) return; | |
| const rules = data.rules || []; | |
| const stats = data.stats || {}; | |
| this._setText('rules-total', stats.total_rules || 0); | |
| this._setText('rules-categories', stats.total_categories || 0); | |
| this._setText('rules-pending', stats.pending_categories || 0); | |
| this._setText('rules-approved', stats.approved_categories || 0); | |
| if (rules.length === 0) { | |
| tbody.innerHTML = '<tr><td colspan="5" class="empty-cell">No edge case rules discovered yet</td></tr>'; | |
| return; | |
| } | |
| // Sort: pending first, then confidence ascending | |
| rules.sort((a, b) => { | |
| if (a.reviewed !== b.reviewed) return a.reviewed ? 1 : -1; | |
| return (a.source_confidence || 0) - (b.source_confidence || 0); | |
| }); | |
| tbody.innerHTML = rules.map(rule => { | |
| const conf = Math.round((rule.source_confidence || 0) * 100); | |
| let badge; | |
| if (rule.approved) badge = '<span class="status-badge status-approved">Approved</span>'; | |
| else if (rule.reviewed) badge = '<span class="status-badge status-rejected">Rejected</span>'; | |
| else badge = '<span class="status-badge status-pending">Pending</span>'; | |
| return `<tr> | |
| <td class="rule-text-cell" title="${this._esc(rule.rule_text || '')}">${this._esc(rule.rule_text || '')}</td> | |
| <td>${this._esc(rule.instance_id || '')}</td> | |
| <td>${conf}%</td> | |
| <td>${this._esc(rule.cluster_id || '—')}</td> | |
| <td>${badge}</td> | |
| </tr>`; | |
| }).join(''); | |
| } | |
| // ── Confusion Tab ────────────────────────────────────── | |
| async _loadConfusion() { | |
| try { | |
| const resp = await fetch('/solo/api/confusion-analysis'); | |
| if (!resp.ok) return; | |
| const data = await resp.json(); | |
| this._confusionData = data; | |
| this._confusionLoaded = true; | |
| if (!data.enabled) { | |
| this._setText('confusion-total-disagreements', 'Disabled'); | |
| return; | |
| } | |
| // Summary cards | |
| this._setText('confusion-total-disagreements', data.total_disagreements || 0); | |
| const patterns = data.patterns || []; | |
| this._setText('confusion-distinct-patterns', patterns.length); | |
| if (patterns.length > 0) { | |
| const top = patterns[0]; | |
| this._setText('confusion-top-pattern', | |
| top.predicted_label + ' \u2192 ' + top.actual_label); | |
| this._setText('confusion-top-count', top.count + ' times (' + top.percent + '%)'); | |
| } | |
| // Render components | |
| if (data.matrix_data) { | |
| this._renderConfusionHeatmap(data.matrix_data); | |
| this._renderLabelAccuracy(data.matrix_data.label_accuracy || {}); | |
| } | |
| this._renderPatternsTable(patterns); | |
| } catch (e) { | |
| console.warn('Solo dashboard: failed to load confusion data', e); | |
| } | |
| } | |
| _renderConfusionHeatmap(matrixData) { | |
| const container = document.getElementById('confusion-heatmap'); | |
| if (!container || typeof d3 === 'undefined') return; | |
| container.innerHTML = ''; | |
| const labels = matrixData.labels || []; | |
| if (labels.length === 0) { | |
| container.innerHTML = '<p style="color: var(--muted-foreground); text-align: center; padding: 2rem;">No labels to display</p>'; | |
| return; | |
| } | |
| const margin = { top: 60, right: 20, bottom: 20, left: 100 }; | |
| const width = container.clientWidth - margin.left - margin.right; | |
| const cellSize = Math.min(Math.floor(width / labels.length), 60); | |
| const height = cellSize * labels.length; | |
| const svg = d3.select(container) | |
| .append('svg') | |
| .attr('width', width + margin.left + margin.right) | |
| .attr('height', height + margin.top + margin.bottom) | |
| .append('g') | |
| .attr('transform', `translate(${margin.left},${margin.top})`); | |
| const x = d3.scaleBand().domain(labels).range([0, cellSize * labels.length]).padding(0.05); | |
| const y = d3.scaleBand().domain(labels).range([0, cellSize * labels.length]).padding(0.05); | |
| const maxCount = matrixData.max_count || 1; | |
| const colorScale = d3.scaleSequential(d3.interpolateReds).domain([0, maxCount]); | |
| // Build cell lookup | |
| const cellMap = {}; | |
| (matrixData.cells || []).forEach(c => { | |
| cellMap[c.predicted + '|' + c.actual] = c.count; | |
| }); | |
| // Draw cells | |
| const self = this; | |
| labels.forEach(predicted => { | |
| labels.forEach(actual => { | |
| const count = cellMap[predicted + '|' + actual] || 0; | |
| const g = svg.append('g'); | |
| g.append('rect') | |
| .attr('class', 'heatmap-cell') | |
| .attr('x', x(actual)) | |
| .attr('y', y(predicted)) | |
| .attr('width', x.bandwidth()) | |
| .attr('height', y.bandwidth()) | |
| .attr('fill', count > 0 ? colorScale(count) : '#f8f8f8') | |
| .attr('rx', 3) | |
| .on('click', () => self._showPatternDetail(predicted, actual)); | |
| if (count > 0) { | |
| g.append('text') | |
| .attr('class', 'heatmap-count' + (count < maxCount * 0.4 ? ' dark-text' : '')) | |
| .attr('x', x(actual) + x.bandwidth() / 2) | |
| .attr('y', y(predicted) + y.bandwidth() / 2 + 4) | |
| .text(count); | |
| } | |
| }); | |
| }); | |
| // X axis labels (top) — actual / human corrected | |
| svg.selectAll('.x-label') | |
| .data(labels) | |
| .enter() | |
| .append('text') | |
| .attr('class', 'heatmap-label') | |
| .attr('x', d => x(d) + x.bandwidth() / 2) | |
| .attr('y', -8) | |
| .attr('text-anchor', 'middle') | |
| .text(d => d.length > 12 ? d.substring(0, 10) + '..' : d); | |
| // X axis title | |
| svg.append('text') | |
| .attr('class', 'heatmap-label') | |
| .attr('x', (cellSize * labels.length) / 2) | |
| .attr('y', -35) | |
| .attr('text-anchor', 'middle') | |
| .attr('font-weight', '600') | |
| .text('Human Corrected'); | |
| // Y axis labels (left) — predicted | |
| svg.selectAll('.y-label') | |
| .data(labels) | |
| .enter() | |
| .append('text') | |
| .attr('class', 'heatmap-label') | |
| .attr('x', -8) | |
| .attr('y', d => y(d) + y.bandwidth() / 2 + 4) | |
| .attr('text-anchor', 'end') | |
| .text(d => d.length > 14 ? d.substring(0, 12) + '..' : d); | |
| // Y axis title | |
| svg.append('text') | |
| .attr('class', 'heatmap-label') | |
| .attr('transform', 'rotate(-90)') | |
| .attr('x', -(cellSize * labels.length) / 2) | |
| .attr('y', -75) | |
| .attr('text-anchor', 'middle') | |
| .attr('font-weight', '600') | |
| .text('LLM Predicted'); | |
| } | |
| _renderPatternsTable(patterns) { | |
| const tbody = document.getElementById('confusion-patterns-body'); | |
| if (!tbody) return; | |
| if (!patterns || patterns.length === 0) { | |
| tbody.innerHTML = '<tr><td colspan="5" class="empty-cell">No confusion patterns detected yet</td></tr>'; | |
| return; | |
| } | |
| tbody.innerHTML = patterns.map(p => { | |
| return `<tr> | |
| <td><strong>${this._esc(p.predicted_label)}</strong></td> | |
| <td><strong>${this._esc(p.actual_label)}</strong></td> | |
| <td>${p.count}</td> | |
| <td>${p.percent}%</td> | |
| <td> | |
| <button class="suggest-btn" | |
| onclick="soloDashboard.suggestGuideline('${this._esc(p.predicted_label)}','${this._esc(p.actual_label)}')"> | |
| Suggest Fix | |
| </button> | |
| </td> | |
| </tr>`; | |
| }).join(''); | |
| } | |
| _renderLabelAccuracy(labelAccuracy) { | |
| const container = document.getElementById('label-accuracy-rows'); | |
| if (!container) return; | |
| const entries = Object.entries(labelAccuracy); | |
| if (entries.length === 0) { | |
| container.innerHTML = '<p style="color: var(--muted-foreground); font-size: 0.8125rem;">No accuracy data yet</p>'; | |
| return; | |
| } | |
| entries.sort((a, b) => a[1] - b[1]); // lowest accuracy first | |
| container.innerHTML = entries.map(([label, accuracy]) => { | |
| const pct = Math.round(accuracy * 100); | |
| let cls = ''; | |
| if (pct >= 90) cls = 'text-success'; | |
| else if (pct >= 70) cls = 'text-warning'; | |
| else cls = 'text-danger'; | |
| return `<div class="metric-row"> | |
| <span>${this._esc(label)}</span> | |
| <strong class="${cls}">${pct}%</strong> | |
| </div>`; | |
| }).join(''); | |
| } | |
| _showPatternDetail(predicted, actual) { | |
| const panel = document.getElementById('confusion-detail-panel'); | |
| if (!panel || !this._confusionData) return; | |
| const patterns = this._confusionData.patterns || []; | |
| const pattern = patterns.find( | |
| p => p.predicted_label === predicted && p.actual_label === actual | |
| ); | |
| if (!pattern) { | |
| panel.innerHTML = ` | |
| <h4 class="detail-header">PATTERN DETAILS</h4> | |
| <p style="font-size: 0.8125rem; color: var(--muted-foreground);"> | |
| ${this._esc(predicted)} \u2192 ${this._esc(actual)}: No confusion recorded | |
| </p>`; | |
| return; | |
| } | |
| let examplesHtml = ''; | |
| if (pattern.examples && pattern.examples.length > 0) { | |
| examplesHtml = '<h4 class="detail-header" style="margin-top: 0.75rem;">EXAMPLES</h4>' + | |
| pattern.examples.map(e => { | |
| let meta = 'ID: ' + this._esc(e.instance_id); | |
| if (e.llm_confidence != null) meta += ' | Conf: ' + Math.round(e.llm_confidence * 100) + '%'; | |
| return `<div class="confusion-example"> | |
| <div class="example-text">${this._esc(e.text || '(no text)')}</div> | |
| <div class="example-meta">${meta}</div> | |
| ${e.llm_reasoning ? '<div class="example-meta" style="margin-top:2px;">Reasoning: ' + this._esc(e.llm_reasoning) + '</div>' : ''} | |
| </div>`; | |
| }).join(''); | |
| } | |
| let rootCauseHtml = ''; | |
| if (pattern.root_cause) { | |
| rootCauseHtml = `<div class="root-cause-box">${this._esc(pattern.root_cause)}</div>`; | |
| } | |
| panel.innerHTML = ` | |
| <h4 class="detail-header">PATTERN DETAILS</h4> | |
| <div class="detail-rule-text">${this._esc(predicted)} \u2192 ${this._esc(actual)}</div> | |
| <div class="detail-row"><span>Count</span><strong>${pattern.count}</strong></div> | |
| <div class="detail-row"><span>% of Errors</span><strong>${pattern.percent}%</strong></div> | |
| ${rootCauseHtml} | |
| ${examplesHtml} | |
| <button class="suggest-btn" style="margin-top: 0.75rem; width: 100%;" | |
| onclick="soloDashboard.suggestGuideline('${this._esc(predicted)}','${this._esc(actual)}')"> | |
| Suggest Guideline Fix | |
| </button>`; | |
| } | |
| async suggestGuideline(predicted, actual) { | |
| try { | |
| const btn = event ? event.target : null; | |
| if (btn) { btn.disabled = true; btn.textContent = 'Analyzing...'; } | |
| const resp = await fetch('/solo/api/confusion-analysis/suggest-guideline', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ predicted_label: predicted, actual_label: actual }), | |
| }); | |
| const data = await resp.json(); | |
| if (btn) { btn.disabled = false; btn.textContent = 'Suggest Fix'; } | |
| if (data.success) { | |
| let msg = 'Suggested guideline:\n\n' + data.suggestion; | |
| if (data.root_cause) msg += '\n\nRoot cause: ' + data.root_cause; | |
| alert(msg); | |
| } else { | |
| alert('Could not generate suggestion: ' + (data.error || 'Unknown error')); | |
| } | |
| } catch (e) { | |
| alert('Error: ' + e.message); | |
| } | |
| } | |
| // ── Labeling Functions ───────────────────────────────── | |
| async _loadLabelingFunctions() { | |
| try { | |
| const resp = await fetch('/solo/api/labeling-functions'); | |
| if (!resp.ok) return; | |
| const data = await resp.json(); | |
| this._lfLoaded = true; | |
| if (!data.enabled) { | |
| const tbody = document.getElementById('lf-table-body'); | |
| if (tbody) tbody.innerHTML = '<tr><td colspan="5" class="empty-cell">Labeling functions not enabled</td></tr>'; | |
| return; | |
| } | |
| this._renderLabelingFunctions(data); | |
| } catch (e) { | |
| console.warn('Solo dashboard: failed to load labeling functions', e); | |
| } | |
| } | |
| _renderLabelingFunctions(data) { | |
| // Summary cards | |
| this._setText('lf-total-count', data.total_functions || 0); | |
| this._setText('lf-enabled-count', data.enabled_functions || 0); | |
| this._setText('lf-instances-labeled', data.instances_labeled || 0); | |
| this._setText('lf-abstained-desc', | |
| (data.instances_abstained || 0) + ' abstained'); | |
| const avgConf = data.avg_confidence || 0; | |
| this._setText('lf-avg-confidence', | |
| avgConf > 0 ? Math.round(avgConf * 100) + '%' : '—'); | |
| // Table | |
| const tbody = document.getElementById('lf-table-body'); | |
| if (!tbody) return; | |
| const functions = data.functions || []; | |
| if (functions.length === 0) { | |
| tbody.innerHTML = '<tr><td colspan="5" class="empty-cell">No labeling functions extracted yet. Click "Extract from Predictions" to discover patterns.</td></tr>'; | |
| return; | |
| } | |
| // Sort: enabled first, then by coverage desc | |
| functions.sort((a, b) => { | |
| if (a.enabled !== b.enabled) return b.enabled ? 1 : -1; | |
| return (b.coverage || 0) - (a.coverage || 0); | |
| }); | |
| tbody.innerHTML = functions.map(fn => { | |
| const conf = Math.round((fn.confidence || 0) * 100); | |
| const checked = fn.enabled ? 'checked' : ''; | |
| return `<tr> | |
| <td class="lf-pattern-text" title="${this._esc(fn.pattern_text || '')}">${this._esc(fn.pattern_text || '')}</td> | |
| <td><span class="lf-label-badge">${this._esc(fn.label || '')}</span></td> | |
| <td>${conf}%</td> | |
| <td>${fn.coverage || 0}</td> | |
| <td> | |
| <label class="lf-toggle"> | |
| <input type="checkbox" ${checked} | |
| onchange="soloDashboard.toggleLabelingFunction('${this._esc(fn.id)}')"> | |
| <span class="slider"></span> | |
| </label> | |
| </td> | |
| </tr>`; | |
| }).join(''); | |
| } | |
| async _loadLabelingFunctionStatus() { | |
| try { | |
| const resp = await fetch('/solo/api/labeling-functions/stats'); | |
| if (!resp.ok) return; | |
| const data = await resp.json(); | |
| this._renderLabelingFunctionCard(data); | |
| } catch (e) { | |
| // LF not available — hide card | |
| } | |
| } | |
| _renderLabelingFunctionCard(data) { | |
| const card = document.getElementById('lf-card'); | |
| if (!card) return; | |
| if (!data.enabled) return; | |
| card.style.display = ''; | |
| this._setText('ov-lf-count', data.total_functions || 0); | |
| this._setText('ov-lf-desc', | |
| (data.total_functions === 1 ? 'function extracted' : 'functions extracted')); | |
| this._setText('ov-lf-labeled', data.instances_labeled || 0); | |
| this._setText('ov-lf-saved', data.instances_labeled || 0); | |
| } | |
| async extractLabelingFunctions() { | |
| try { | |
| const btn = event ? event.target : null; | |
| if (btn) { btn.disabled = true; btn.textContent = 'Extracting...'; } | |
| const resp = await fetch('/solo/api/labeling-functions/extract', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| }); | |
| const data = await resp.json(); | |
| if (btn) { btn.disabled = false; btn.textContent = 'Extract Functions'; } | |
| if (data.success) { | |
| const msg = data.extracted > 0 | |
| ? `Extracted ${data.extracted} new labeling functions (total: ${data.total})` | |
| : (data.message || 'No new functions extracted'); | |
| alert(msg); | |
| this._lfLoaded = false; | |
| this._loadLabelingFunctions(); | |
| this._loadLabelingFunctionStatus(); | |
| } else { | |
| alert('Error: ' + (data.error || 'Unknown error')); | |
| } | |
| } catch (e) { | |
| alert('Error: ' + e.message); | |
| } | |
| } | |
| async toggleLabelingFunction(functionId) { | |
| try { | |
| const resp = await fetch(`/solo/api/labeling-functions/${functionId}/toggle`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| }); | |
| const data = await resp.json(); | |
| if (!data.success) { | |
| alert('Error: ' + (data.error || 'Unknown error')); | |
| // Reload to reset toggle state | |
| this._lfLoaded = false; | |
| this._loadLabelingFunctions(); | |
| } | |
| } catch (e) { | |
| alert('Error: ' + e.message); | |
| } | |
| } | |
| // ── Refinement Loop ──────────────────────────────────── | |
| async _loadRefinementStatus() { | |
| try { | |
| const resp = await fetch('/solo/api/refinement-status'); | |
| if (!resp.ok) return; | |
| const data = await resp.json(); | |
| this._renderRefinement(data); | |
| } catch (e) { | |
| // Refinement not available — hide card | |
| } | |
| } | |
| _renderRefinement(data) { | |
| const card = document.getElementById('refinement-card'); | |
| if (!card) return; | |
| if (!data.enabled) return; | |
| card.style.display = ''; | |
| this._setText('ov-refinement-cycles', data.total_cycles || 0); | |
| this._setText('ov-refinement-desc', | |
| (data.total_cycles === 1 ? 'cycle completed' : 'cycles completed')); | |
| // Status badge | |
| const statusEl = document.getElementById('ov-refinement-status'); | |
| if (statusEl) { | |
| if (data.is_stopped) { | |
| statusEl.innerHTML = '<span class="status-badge status-pending">' + | |
| this._esc(data.stop_reason || 'Stopped') + '</span>'; | |
| } else if (data.is_running) { | |
| statusEl.innerHTML = '<span class="status-badge status-active">Running</span>'; | |
| } else { | |
| statusEl.innerHTML = '<span class="status-badge status-active">Active</span>'; | |
| } | |
| } | |
| // Last improvement | |
| const impEl = document.getElementById('ov-refinement-improvement'); | |
| if (impEl) { | |
| if (data.last_improvement != null) { | |
| const pct = (data.last_improvement * 100).toFixed(1); | |
| const sign = data.last_improvement >= 0 ? '+' : ''; | |
| impEl.textContent = sign + pct + '%'; | |
| impEl.className = data.last_improvement >= 0 ? 'text-success' : 'text-danger'; | |
| } else { | |
| impEl.textContent = '\u2014'; | |
| } | |
| } | |
| // Next check | |
| this._setText('ov-refinement-next', | |
| data.is_stopped ? 'Stopped' : (data.annotations_until_next + ' annotations')); | |
| // Show reset button if stopped | |
| const resetBtn = document.getElementById('ov-refinement-reset-btn'); | |
| if (resetBtn) { | |
| resetBtn.style.display = data.is_stopped ? '' : 'none'; | |
| } | |
| } | |
| async triggerRefinement() { | |
| try { | |
| const resp = await fetch('/solo/api/refinement/trigger', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| }); | |
| const data = await resp.json(); | |
| if (data.success) { | |
| const cycle = data.cycle; | |
| if (cycle) { | |
| alert('Refinement cycle ' + cycle.cycle_number + ': ' + cycle.status + | |
| '\nSuggestions: ' + cycle.suggestions_generated); | |
| } else { | |
| alert(data.message || 'Refinement cycle completed'); | |
| } | |
| this._loadRefinementStatus(); | |
| } else { | |
| alert('Error: ' + (data.error || 'Unknown error')); | |
| } | |
| } catch (e) { | |
| alert('Error: ' + e.message); | |
| } | |
| } | |
| async resetRefinement() { | |
| try { | |
| const resp = await fetch('/solo/api/refinement/reset', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| }); | |
| const data = await resp.json(); | |
| if (data.success) { | |
| this._loadRefinementStatus(); | |
| } else { | |
| alert('Error: ' + (data.error || 'Unknown error')); | |
| } | |
| } catch (e) { | |
| alert('Error: ' + e.message); | |
| } | |
| } | |
| // ── Disagreement Explorer Tab ────────────────────────── | |
| async _loadDisagreements(labelFilter) { | |
| try { | |
| let url = '/solo/api/disagreement-explorer'; | |
| if (labelFilter) url += '?label=' + encodeURIComponent(labelFilter); | |
| const [explorerResp, timelineResp] = await Promise.all([ | |
| fetch(url), | |
| fetch('/solo/api/disagreement-timeline'), | |
| ]); | |
| if (!explorerResp.ok || !timelineResp.ok) return; | |
| const data = await explorerResp.json(); | |
| const timeline = await timelineResp.json(); | |
| this._disagreeData = data; | |
| this._disagreeLoaded = true; | |
| this._renderDisagreementSummary(data.summary || {}); | |
| this._renderDisagreementScatter(data.scatter_points || []); | |
| this._renderDisagreementTimeline(timeline); | |
| this._renderLabelBreakdown(data.label_breakdown || []); | |
| this._renderDisagreementList(data.disagreements || []); | |
| this._populateLabelFilter(data.label_breakdown || []); | |
| } catch (e) { | |
| console.warn('Solo dashboard: failed to load disagreements', e); | |
| } | |
| } | |
| _renderDisagreementSummary(summary) { | |
| this._setText('disagree-total-compared', summary.total_compared || 0); | |
| this._setText('disagree-total-disagreements', summary.total_disagreements || 0); | |
| const rate = Math.round((summary.disagreement_rate || 0) * 100); | |
| const rateEl = document.getElementById('disagree-rate'); | |
| if (rateEl) { | |
| rateEl.textContent = rate + '%'; | |
| rateEl.className = 'card-value'; | |
| if (rate <= 10) rateEl.classList.add('text-success'); | |
| else if (rate <= 25) rateEl.classList.add('text-warning'); | |
| else rateEl.classList.add('text-danger'); | |
| } | |
| const avgConf = summary.avg_disagreement_confidence || 0; | |
| this._setText('disagree-avg-conf', | |
| avgConf > 0 ? Math.round(avgConf * 100) + '%' : '\u2014'); | |
| } | |
| _renderDisagreementScatter(points) { | |
| const container = document.getElementById('disagree-scatter'); | |
| if (!container || typeof d3 === 'undefined') return; | |
| container.innerHTML = ''; | |
| if (points.length === 0) { | |
| container.innerHTML = '<p style="color: var(--muted-foreground); text-align: center; padding: 2rem;">No comparison data yet</p>'; | |
| return; | |
| } | |
| const margin = { top: 20, right: 20, bottom: 40, left: 50 }; | |
| const width = container.clientWidth - margin.left - margin.right; | |
| const height = 310 - margin.top - margin.bottom; | |
| const svg = d3.select(container) | |
| .append('svg') | |
| .attr('width', width + margin.left + margin.right) | |
| .attr('height', height + margin.top + margin.bottom) | |
| .append('g') | |
| .attr('transform', `translate(${margin.left},${margin.top})`); | |
| // Scales | |
| const x = d3.scaleLinear().domain([0, 1]).range([0, width]); | |
| const y = d3.scaleLinear().domain([0, points.length - 1]).range([height, 0]); | |
| // X axis | |
| svg.append('g') | |
| .attr('transform', `translate(0,${height})`) | |
| .call(d3.axisBottom(x).ticks(5).tickFormat(d => Math.round(d * 100) + '%')) | |
| .selectAll('text') | |
| .style('font-size', '0.6875rem') | |
| .style('fill', 'var(--muted-foreground, #71717a)'); | |
| svg.append('text') | |
| .attr('x', width / 2) | |
| .attr('y', height + 35) | |
| .attr('text-anchor', 'middle') | |
| .style('font-size', '0.75rem') | |
| .style('fill', 'var(--muted-foreground, #71717a)') | |
| .text('Confidence'); | |
| // Y axis label | |
| svg.append('text') | |
| .attr('transform', 'rotate(-90)') | |
| .attr('x', -height / 2) | |
| .attr('y', -40) | |
| .attr('text-anchor', 'middle') | |
| .style('font-size', '0.75rem') | |
| .style('fill', 'var(--muted-foreground, #71717a)') | |
| .text('Instance (sorted by confidence)'); | |
| // Tooltip | |
| const tooltip = d3.select(container) | |
| .append('div') | |
| .attr('class', 'scatter-tooltip') | |
| .style('display', 'none'); | |
| // Draw dots | |
| const self = this; | |
| svg.selectAll('.scatter-dot') | |
| .data(points) | |
| .enter() | |
| .append('circle') | |
| .attr('class', 'scatter-dot') | |
| .attr('cx', d => x(d.confidence)) | |
| .attr('cy', (d, i) => y(i)) | |
| .attr('r', 4) | |
| .attr('fill', d => d.agrees | |
| ? 'var(--success-color, #10b981)' | |
| : 'var(--danger-color, #ef4444)') | |
| .attr('opacity', 0.7) | |
| .on('mouseover', function(event, d) { | |
| tooltip.style('display', 'block') | |
| .html(` | |
| <div class="tooltip-label">${self._esc(d.instance_id)}</div> | |
| <div class="tooltip-meta"> | |
| LLM: ${self._esc(d.llm_label)} | |
| ${d.human_label ? ' | Human: ' + self._esc(d.human_label) : ''} | |
| <br>Confidence: ${Math.round(d.confidence * 100)}% | |
| | ${d.agrees ? 'Agrees' : 'Disagrees'} | |
| </div> | |
| ${d.text ? '<div style="margin-top:4px;font-size:0.6875rem;">' + self._esc(d.text.substring(0, 100)) + '</div>' : ''} | |
| `); | |
| }) | |
| .on('mousemove', function(event) { | |
| tooltip | |
| .style('left', (event.offsetX + 12) + 'px') | |
| .style('top', (event.offsetY - 10) + 'px'); | |
| }) | |
| .on('mouseout', function() { | |
| tooltip.style('display', 'none'); | |
| }); | |
| // Legend | |
| const legend = svg.append('g').attr('transform', `translate(${width - 120}, 0)`); | |
| [{ label: 'Agrees', color: 'var(--success-color, #10b981)' }, | |
| { label: 'Disagrees', color: 'var(--danger-color, #ef4444)' }].forEach((item, i) => { | |
| legend.append('circle').attr('cx', 0).attr('cy', i * 18).attr('r', 4).attr('fill', item.color); | |
| legend.append('text').attr('x', 10).attr('y', i * 18 + 4) | |
| .style('font-size', '0.6875rem') | |
| .style('fill', 'var(--muted-foreground, #71717a)') | |
| .text(item.label); | |
| }); | |
| } | |
| _renderDisagreementTimeline(timelineData) { | |
| const container = document.getElementById('disagree-timeline'); | |
| if (!container || typeof d3 === 'undefined') return; | |
| container.innerHTML = ''; | |
| const buckets = timelineData.buckets || []; | |
| if (buckets.length === 0) { | |
| container.innerHTML = '<p style="color: var(--muted-foreground); text-align: center; padding: 1rem; font-size: 0.8125rem;">Not enough data</p>'; | |
| return; | |
| } | |
| // Trend badge | |
| const trendEl = document.getElementById('disagree-trend-badge'); | |
| if (trendEl) { | |
| const trend = timelineData.trend || 'stable'; | |
| trendEl.textContent = trend.charAt(0).toUpperCase() + trend.slice(1); | |
| trendEl.className = 'trend-badge trend-' + trend; | |
| } | |
| const margin = { top: 10, right: 10, bottom: 25, left: 35 }; | |
| const width = container.clientWidth - margin.left - margin.right; | |
| const height = 150 - margin.top - margin.bottom; | |
| const svg = d3.select(container) | |
| .append('svg') | |
| .attr('width', width + margin.left + margin.right) | |
| .attr('height', height + margin.top + margin.bottom) | |
| .append('g') | |
| .attr('transform', `translate(${margin.left},${margin.top})`); | |
| const x = d3.scaleBand() | |
| .domain(buckets.map(b => b.bucket_index)) | |
| .range([0, width]) | |
| .padding(0.2); | |
| const y = d3.scaleLinear().domain([0, 1]).range([height, 0]); | |
| // Y axis | |
| svg.append('g') | |
| .call(d3.axisLeft(y).ticks(4).tickFormat(d => Math.round(d * 100) + '%')) | |
| .selectAll('text') | |
| .style('font-size', '0.625rem') | |
| .style('fill', 'var(--muted-foreground, #71717a)'); | |
| // X axis | |
| svg.append('g') | |
| .attr('transform', `translate(0,${height})`) | |
| .call(d3.axisBottom(x).tickFormat(d => '#' + (d + 1))) | |
| .selectAll('text') | |
| .style('font-size', '0.625rem') | |
| .style('fill', 'var(--muted-foreground, #71717a)'); | |
| // Bars | |
| svg.selectAll('.timeline-bar') | |
| .data(buckets) | |
| .enter() | |
| .append('rect') | |
| .attr('class', 'timeline-bar') | |
| .attr('x', d => x(d.bucket_index)) | |
| .attr('y', d => y(d.agreement_rate)) | |
| .attr('width', x.bandwidth()) | |
| .attr('height', d => height - y(d.agreement_rate)) | |
| .attr('fill', d => d.agreement_rate >= 0.8 | |
| ? 'var(--success-color, #10b981)' | |
| : d.agreement_rate >= 0.6 | |
| ? 'var(--warning-color, #f59e0b)' | |
| : 'var(--danger-color, #ef4444)') | |
| .attr('rx', 2); | |
| } | |
| _renderLabelBreakdown(breakdown) { | |
| const container = document.getElementById('disagree-label-breakdown'); | |
| if (!container) return; | |
| if (!breakdown || breakdown.length === 0) { | |
| container.innerHTML = '<p style="color: var(--muted-foreground); font-size: 0.8125rem;">No breakdown data yet</p>'; | |
| return; | |
| } | |
| container.innerHTML = breakdown.map(b => { | |
| const pct = Math.round(b.agreement_rate * 100); | |
| let cls = ''; | |
| if (pct >= 90) cls = 'text-success'; | |
| else if (pct >= 70) cls = 'text-warning'; | |
| else cls = 'text-danger'; | |
| let confused = ''; | |
| if (b.confused_with && b.confused_with.length > 0) { | |
| confused = '<span style="font-size: 0.625rem; color: var(--muted-foreground);"> confused with: ' + | |
| b.confused_with.map(c => this._esc(c.label) + '(' + c.count + ')').join(', ') + '</span>'; | |
| } | |
| return `<div class="metric-row"> | |
| <span>${this._esc(b.label)} <small>(${b.total_comparisons})</small>${confused}</span> | |
| <strong class="${cls}">${pct}%</strong> | |
| </div>`; | |
| }).join(''); | |
| } | |
| _renderDisagreementList(disagreements) { | |
| const container = document.getElementById('disagree-list'); | |
| if (!container) return; | |
| if (!disagreements || disagreements.length === 0) { | |
| container.innerHTML = '<p style="color: var(--muted-foreground); font-size: 0.8125rem;">No disagreements found</p>'; | |
| return; | |
| } | |
| container.innerHTML = disagreements.map(d => { | |
| const conf = Math.round((d.confidence || 0) * 100); | |
| const resolvedClass = d.resolved ? ' resolved' : ''; | |
| return `<div class="disagree-item${resolvedClass}"> | |
| <div class="disagree-labels"> | |
| <span class="label-pill llm">LLM: ${this._esc(d.llm_label)}</span> | |
| <span class="label-pill human">Human: ${this._esc(d.human_label)}</span> | |
| <span style="font-size: 0.6875rem; color: var(--muted-foreground);">Confidence: ${conf}%</span> | |
| ${d.resolved ? '<span style="font-size: 0.6875rem; color: var(--success-color, #10b981);">Resolved' + (d.resolution_label ? ': ' + this._esc(d.resolution_label) : '') + '</span>' : ''} | |
| </div> | |
| ${d.text ? '<div class="disagree-text">' + this._esc(d.text) + '</div>' : ''} | |
| ${d.reasoning ? '<div class="disagree-meta">Reasoning: ' + this._esc(d.reasoning.substring(0, 200)) + '</div>' : ''} | |
| <div class="disagree-meta">ID: ${this._esc(d.instance_id)}${d.timestamp ? ' | ' + d.timestamp : ''}</div> | |
| </div>`; | |
| }).join(''); | |
| } | |
| _populateLabelFilter(breakdown) { | |
| const select = document.getElementById('disagree-label-filter'); | |
| if (!select) return; | |
| // Preserve current value | |
| const current = select.value; | |
| select.innerHTML = '<option value="">All labels</option>'; | |
| (breakdown || []).forEach(b => { | |
| const opt = document.createElement('option'); | |
| opt.value = b.label; | |
| opt.textContent = b.label + ' (' + b.total_comparisons + ')'; | |
| select.appendChild(opt); | |
| }); | |
| if (current) select.value = current; | |
| } | |
| filterDisagreements() { | |
| const select = document.getElementById('disagree-label-filter'); | |
| const label = select ? select.value : ''; | |
| this._disagreeLoaded = false; | |
| this._loadDisagreements(label || undefined); | |
| } | |
| // ── Cluster Viz Tab ───────────────────────────────────── | |
| async _initViz() { | |
| const el = document.getElementById('rule-cluster-chart'); | |
| if (!el) return; | |
| this.viz = new RuleClusterViz('rule-cluster-chart', { | |
| onRuleSelect: (d) => this._showRuleDetail(d), | |
| }); | |
| await this.viz.init(); | |
| } | |
| _showRuleDetail(d) { | |
| const panel = document.getElementById('rule-detail-panel'); | |
| if (!panel) return; | |
| const conf = Math.round((d.confidence || 0) * 100); | |
| const status = d.approved ? 'Approved' : d.reviewed ? 'Rejected' : 'Pending'; | |
| panel.innerHTML = ` | |
| <h4 class="detail-header">RULE DETAILS</h4> | |
| <div class="detail-rule-text">${this._esc(d.rule_text || '')}</div> | |
| <div class="detail-metrics"> | |
| <div class="detail-row"><span>Instance</span><strong>${this._esc(d.instance_id || '?')}</strong></div> | |
| <div class="detail-row"><span>Confidence</span><strong>${conf}%</strong></div> | |
| <div class="detail-row"><span>Category</span><strong>${this._esc(d.category_summary || 'Unclustered')}</strong></div> | |
| <div class="detail-row"><span>Status</span><strong>${status}</strong></div> | |
| </div>`; | |
| } | |
| // ── Rule Review Actions ───────────────────────────────── | |
| async approveCategory(categoryId, action, notes) { | |
| try { | |
| const resp = await fetch('/solo/api/rules/approve', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ category_id: categoryId, action: action, notes: notes || '' }), | |
| }); | |
| const data = await resp.json(); | |
| if (data.success) window.location.reload(); | |
| else alert('Error: ' + (data.error || 'Unknown error')); | |
| } catch (e) { | |
| alert('Error: ' + e.message); | |
| } | |
| } | |
| async applyRules() { | |
| try { | |
| const resp = await fetch('/solo/api/rules/apply', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| }); | |
| const data = await resp.json(); | |
| if (data.success) { | |
| alert('Rules applied! New prompt version: ' + data.new_prompt_version); | |
| window.location.reload(); | |
| } else { | |
| alert('Error: ' + (data.error || 'Unknown error')); | |
| } | |
| } catch (e) { | |
| alert('Error: ' + e.message); | |
| } | |
| } | |
| async triggerClustering() { | |
| try { | |
| const resp = await fetch('/solo/api/rules/cluster', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| }); | |
| const data = await resp.json(); | |
| if (data.success) { | |
| alert('Clustering triggered. Results will appear shortly.'); | |
| setTimeout(() => { | |
| if (this.viz) this.viz.refresh(true); | |
| this._loadRules(); | |
| }, 3000); | |
| } | |
| } catch (e) { | |
| alert('Error: ' + e.message); | |
| } | |
| } | |
| // ── Helpers ───────────────────────────────────────────── | |
| _setText(id, value) { | |
| const el = document.getElementById(id); | |
| if (el) el.textContent = value; | |
| } | |
| _esc(text) { | |
| const d = document.createElement('div'); | |
| d.textContent = text; | |
| return d.innerHTML; | |
| } | |
| destroy() { | |
| if (this.refreshInterval) clearInterval(this.refreshInterval); | |
| } | |
| } | |