| |
| |
| |
| |
| |
| |
| |
|
|
| import { state, emit, on, api, toast } from '../app.js'; |
|
|
| const $ = id => document.getElementById(id); |
|
|
| function resetComparisonState() { |
| state.comparison = { |
| base: null, |
| runs: [], |
| selectedSlotId: null, |
| isRunning: false, |
| expanded: false, |
| }; |
| } |
|
|
| function setStatus(message = '', type = '') { |
| $('comparison-status').textContent = message; |
| const badge = $('comparison-status-badge'); |
| if (!type) { |
| badge.className = 'status-badge hidden'; |
| badge.textContent = ''; |
| return; |
| } |
| badge.className = `status-badge ${type}`; |
| badge.classList.remove('hidden'); |
| badge.textContent = type === 'status-loaded' ? 'Ready' : 'Running'; |
| } |
|
|
| |
| |
| |
| function updateComparisonVisibility() { |
| const base = state.comparison.base; |
| const toggle = $('btn-compare-toggle'); |
| const panel = $('comparison-panel'); |
| const resultsPanel = $('results-panel'); |
|
|
| if (!base) { |
| toggle.classList.add('hidden'); |
| toggle.classList.remove('active'); |
| panel.classList.add('hidden'); |
| resultsPanel.classList.remove('compare-mode'); |
| state.comparison.expanded = false; |
| return; |
| } |
|
|
| toggle.classList.remove('hidden'); |
| const expanded = !!state.comparison.expanded; |
| toggle.classList.toggle('active', expanded); |
| panel.classList.toggle('hidden', !expanded); |
| |
| |
| resultsPanel.classList.toggle('compare-mode', expanded); |
| } |
|
|
| function updateBaseSummary() { |
| const base = state.comparison.base; |
| const summary = $('comparison-base-summary'); |
| const help = $('comparison-help'); |
| if (!base) { |
| summary.textContent = ''; |
| help.textContent = ''; |
| return; |
| } |
|
|
| if (base.segSource === 'page') { |
| summary.textContent = `${base.label} uses a page-level result. Run a segmented base transcription first to enable comparison.`; |
| help.textContent = 'Comparison V1 uses cached line segmentation from the base transcription so all engines can be aligned line-by-line.'; |
| } else { |
| summary.textContent = `Base result: ${base.label} · ${base.lineCount} lines`; |
| help.textContent = 'To add a comparison, switch to another engine on the left, load its model, and run a comparison on the same cached line segments.'; |
| } |
| } |
|
|
| function collectLiveOverrides() { |
| const overrides = {}; |
| for (const el of $('config-form').querySelectorAll('[data-key]')) { |
| if (el.dataset.saveFor) continue; |
| if (el.dataset.passwordField) continue; |
| const key = el.dataset.key; |
| if (el.type === 'checkbox') overrides[key] = el.checked; |
| else if (el.type === 'number') overrides[key] = Number(el.value); |
| else overrides[key] = el.value; |
| } |
| return overrides; |
| } |
|
|
| function renderSlotSelect() { |
| const select = $('comparison-slot-select'); |
| const label = document.querySelector('label[for="comparison-slot-select"]'); |
| select.innerHTML = ''; |
|
|
| if (state.comparison.runs.length === 0) { |
| select.classList.add('hidden'); |
| label.classList.add('hidden'); |
| return; |
| } |
|
|
| for (const run of state.comparison.runs) { |
| const opt = document.createElement('option'); |
| opt.value = run.comparison_slot.slot_id; |
| opt.textContent = run.comparison_slot.label; |
| select.appendChild(opt); |
| } |
|
|
| select.value = state.comparison.selectedSlotId || state.comparison.runs[0].comparison_slot.slot_id; |
| select.classList.remove('hidden'); |
| label.classList.remove('hidden'); |
| } |
|
|
| function summaryCard(label, value, note = '') { |
| return `<div class="comparison-summary-card"> |
| <span class="label">${label}</span> |
| <span class="value">${value}</span> |
| ${note ? `<span class="note">${note}</span>` : ''} |
| </div>`; |
| } |
|
|
| function renderSummary(run) { |
| const box = $('comparison-summary'); |
| if (!run) { |
| box.classList.add('hidden'); |
| box.innerHTML = ''; |
| return; |
| } |
|
|
| const labels = run.labels; |
| const summary = run.summary; |
| const identical = summary.identical_lines ?? 0; |
| const identicalPct = summary.line_count |
| ? ` (${((identical / summary.line_count) * 100).toFixed(0)}%)` |
| : ''; |
| box.innerHTML = [ |
| summaryCard(labels.macro_char_rate, `${summary.macro_char_rate.toFixed(2)}%`, `${summary.line_count} lines`), |
| summaryCard(labels.micro_char_rate, `${summary.micro_char_rate.toFixed(2)}%`, `${summary.total_edit_distance} edits`), |
| summaryCard(labels.macro_word_rate, `${summary.macro_word_rate.toFixed(2)}%`), |
| summaryCard(`Avg ${labels.match_rate}`, `${summary.avg_match_percent.toFixed(2)}%`, run.comparison_slot.label), |
| summaryCard('Identical lines', `${identical} / ${summary.line_count}`, `agree${identicalPct}`), |
| ].join(''); |
| box.classList.remove('hidden'); |
| } |
|
|
| function renderLines(run) { |
| const container = $('comparison-lines'); |
| if (!run) { |
| container.classList.add('hidden'); |
| container.innerHTML = ''; |
| return; |
| } |
|
|
| const labels = run.labels; |
| const [low, mid] = labels.color_thresholds || [15, 35]; |
| container.innerHTML = ''; |
|
|
| |
| |
| const header = document.createElement('div'); |
| header.className = 'comparison-col-header'; |
| const gutterTitle = document.createElement('span'); |
| gutterTitle.className = 'comparison-col-title comparison-gutter-title'; |
| gutterTitle.textContent = '#'; |
| gutterTitle.title = `Per-line ${labels.char_rate.toLowerCase()}`; |
| const baseTitle = document.createElement('span'); |
| baseTitle.className = 'comparison-col-title'; |
| baseTitle.textContent = run.base_slot.label; |
| const compTitle = document.createElement('span'); |
| compTitle.className = 'comparison-col-title'; |
| compTitle.textContent = run.comparison_slot.label; |
| header.appendChild(gutterTitle); |
| header.appendChild(baseTitle); |
| header.appendChild(compTitle); |
| container.appendChild(header); |
|
|
| for (const row of run.lines) { |
| const block = document.createElement('div'); |
| block.className = `comparison-line${row.has_disagreement ? ' comparison-line-disagree' : ' comparison-line-match'}`; |
| block.title = `${labels.char_rate} ${row.metrics.char_rate.toFixed(1)}% · ${labels.word_rate} ${row.metrics.word_rate.toFixed(1)}% · ${labels.match_rate} ${row.metrics.match_percent.toFixed(1)}%`; |
|
|
| block.appendChild(createGutter(row, low, mid)); |
| block.appendChild(createCell('base', row)); |
| block.appendChild(createCell('comparison', row)); |
| container.appendChild(block); |
| } |
| container.classList.remove('hidden'); |
| } |
|
|
| |
| |
| function createGutter(row, low, mid) { |
| const gutter = document.createElement('div'); |
| gutter.className = 'comparison-gutter'; |
|
|
| const num = document.createElement('span'); |
| num.className = 'comparison-line-num'; |
| num.textContent = row.index + 1; |
|
|
| const pct = document.createElement('span'); |
| const value = row.metrics.char_rate; |
| const level = value <= low ? 'good' : value <= mid ? 'warn' : 'bad'; |
| pct.className = `comparison-mismatch ${level}`; |
| pct.textContent = `${value.toFixed(0)}%`; |
|
|
| gutter.appendChild(num); |
| gutter.appendChild(pct); |
| gutter.addEventListener('click', () => emit('highlight-line', { index: row.index })); |
| return gutter; |
| } |
|
|
| function createCell(side, row) { |
| const cell = document.createElement('div'); |
| cell.className = 'comparison-cell'; |
|
|
| const value = document.createElement('div'); |
| value.className = 'comparison-cell-text'; |
|
|
| const text = side === 'base' ? row.base_text : row.comparison_text; |
| if (!text) { |
| value.textContent = '(empty)'; |
| value.classList.add('empty'); |
| } else if (row.diff_ops && row.diff_ops.length) { |
| renderDiff(value, row.diff_ops, side); |
| } else { |
| value.textContent = text; |
| } |
|
|
| value.addEventListener('click', () => emit('highlight-line', { index: row.index })); |
| cell.appendChild(value); |
| return cell; |
| } |
|
|
| |
| |
| |
| |
| |
| function renderDiff(target, diffOps, side) { |
| for (const op of diffOps) { |
| if (op.op === 'equal') { |
| target.appendChild(document.createTextNode(side === 'base' ? op.r : op.h)); |
| } else if (op.op === 'replace') { |
| appendDiffSpan(target, side === 'base' ? op.r : op.h, 'diff-sub'); |
| } else if (op.op === 'delete' && side === 'base') { |
| appendDiffSpan(target, op.r, 'diff-del'); |
| } else if (op.op === 'insert' && side === 'comparison') { |
| appendDiffSpan(target, op.h, 'diff-ins'); |
| } |
| } |
| } |
|
|
| function appendDiffSpan(target, ch, cls) { |
| const span = document.createElement('span'); |
| span.className = cls; |
| span.textContent = ch; |
| target.appendChild(span); |
| } |
|
|
| function renderSelectedRun() { |
| const run = state.comparison.runs.find( |
| entry => entry.comparison_slot.slot_id === state.comparison.selectedSlotId, |
| ) || null; |
| renderSummary(run); |
| renderLines(run); |
| } |
|
|
| function updateControls() { |
| updateComparisonVisibility(); |
| updateBaseSummary(); |
| renderSlotSelect(); |
| renderSelectedRun(); |
|
|
| const btn = $('btn-run-comparison'); |
| const base = state.comparison.base; |
| const ready = !!base && base.segSource !== 'page' && state.engineLoaded && state.imageId && !state.comparison.isRunning; |
| btn.disabled = !ready; |
| if (!base) { |
| setStatus('', ''); |
| } |
| } |
|
|
| async function onRunComparison() { |
| if (state.comparison.isRunning) return; |
| const base = state.comparison.base; |
| if (!base || !state.imageId) return; |
| if (base.segSource === 'page') { |
| toast('Comparison requires a segmented base transcription.', 'error'); |
| return; |
| } |
| if (!state.engineLoaded) { |
| toast('Load the comparison engine in the left panel first.', 'error'); |
| return; |
| } |
|
|
| state.comparison.isRunning = true; |
| emit('comparison-start', {}); |
| setStatus(`Running comparison with current engine…`, 'status-loading'); |
| $('btn-run-comparison').classList.add('loading'); |
| $('btn-run-comparison').textContent = 'Comparing…'; |
| updateControls(); |
|
|
| try { |
| const resp = await fetch('/api/compare/run', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| image_id: state.imageId, |
| engine_config_overrides: collectLiveOverrides(), |
| }), |
| }); |
| if (!resp.ok) { |
| const err = await resp.json().catch(() => ({ detail: resp.statusText })); |
| throw new Error(err.detail || 'Comparison failed'); |
| } |
|
|
| const reader = resp.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ''; |
|
|
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| buffer += decoder.decode(value, { stream: true }); |
|
|
| const parts = buffer.split('\n\n'); |
| buffer = parts.pop() || ''; |
| for (const part of parts) { |
| if (!part.trim()) continue; |
| const eventMatch = part.match(/event: (\w+)/); |
| const dataMatch = part.match(/data: (.+)/s); |
| if (!eventMatch || !dataMatch) continue; |
| const eventName = eventMatch[1]; |
| const data = JSON.parse(dataMatch[1]); |
| emit(`comparison-sse-${eventName}`, data); |
| } |
| } |
| } catch (err) { |
| emit('comparison-error', { message: err.message }); |
| } |
| } |
|
|
| export function initComparisonPanel() { |
| resetComparisonState(); |
|
|
| $('btn-compare-toggle').addEventListener('click', () => { |
| state.comparison.expanded = !state.comparison.expanded; |
| updateControls(); |
| if (state.comparison.expanded) { |
| $('comparison-panel').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); |
| } |
| }); |
|
|
| $('btn-run-comparison').addEventListener('click', onRunComparison); |
| $('comparison-slot-select').addEventListener('change', e => { |
| state.comparison.selectedSlotId = e.target.value; |
| renderSelectedRun(); |
| }); |
|
|
| on('image-uploaded', () => { |
| resetComparisonState(); |
| updateControls(); |
| }); |
| on('batch-item-start', () => { |
| resetComparisonState(); |
| updateControls(); |
| }); |
| on('transcription-start', () => { |
| resetComparisonState(); |
| updateControls(); |
| }); |
| on('engine-loaded', () => updateControls()); |
|
|
| on('transcription-complete', data => { |
| if (!data?.lines?.length || !data?.result_slot) return; |
| state.comparison.base = { |
| slotId: data.result_slot.slot_id, |
| label: data.result_slot.label, |
| engineName: data.result_slot.engine_name, |
| segSource: data.seg_source || data.result_slot.seg_source || 'unknown', |
| lineCount: data.result_slot.line_count || data.lines.length, |
| }; |
| state.comparison.runs = []; |
| state.comparison.selectedSlotId = null; |
| updateControls(); |
| }); |
|
|
| on('comparison-sse-status', data => { |
| setStatus(data.message, 'status-loading'); |
| }); |
|
|
| on('comparison-sse-progress', data => { |
| setStatus(`Comparing ${data.current} / ${data.total} lines…`, 'status-loading'); |
| }); |
|
|
| on('comparison-sse-complete', data => { |
| state.comparison.isRunning = false; |
| $('btn-run-comparison').classList.remove('loading'); |
| $('btn-run-comparison').textContent = 'Run Comparison'; |
|
|
| const existingIndex = state.comparison.runs.findIndex( |
| run => run.comparison_slot.slot_id === data.comparison_slot.slot_id, |
| ); |
| if (existingIndex >= 0) state.comparison.runs[existingIndex] = data; |
| else state.comparison.runs.push(data); |
| state.comparison.selectedSlotId = data.comparison_slot.slot_id; |
|
|
| setStatus(`Comparison ready: ${data.comparison_slot.label} (${data.total_time_s}s)`, 'status-loaded'); |
| updateControls(); |
| }); |
|
|
| on('comparison-sse-error', data => { |
| state.comparison.isRunning = false; |
| $('btn-run-comparison').classList.remove('loading'); |
| $('btn-run-comparison').textContent = 'Run Comparison'; |
| setStatus(`Comparison failed: ${data.message}`, ''); |
| updateControls(); |
| toast(`Comparison failed: ${data.message}`, 'error'); |
| }); |
|
|
| on('comparison-error', data => { |
| state.comparison.isRunning = false; |
| $('btn-run-comparison').classList.remove('loading'); |
| $('btn-run-comparison').textContent = 'Run Comparison'; |
| setStatus(`Comparison failed: ${data.message}`, ''); |
| updateControls(); |
| toast(`Comparison failed: ${data.message}`, 'error'); |
| }); |
|
|
| on('comparison-start', () => { |
| state.comparison.isRunning = true; |
| updateControls(); |
| }); |
|
|
| updateControls(); |
| } |
|
|