Files
dobetternorge-tools/assets/js/discrepancy.js
T
daveadmin b014638f39 feat(corpus): add save-to-corpus + private corpus search scope
- POST /api/save-to-corpus.php — saves tool output text to user's default CaveauAI corpus via ClientRagPipeline
- api/case/upload.php — dual-writes uploaded PDFs to CaveauAI client_documents (best-effort)
- assets/js/corpus-save.js — shared <dialog> handler for .js-save-corpus buttons on all tool pages
- includes/layout_footer.php — injects corpus-save.js + shared save dialog markup
- korrespond/deep-research/barnevernet/discrepancy JS — save-to-corpus buttons on output sections
- api/search.php + LegalTools::search() — corpus_scope param ('shared'|'private'|'both'), merges personal CaveauAI corpus with shared legal library when 'both'
- includes/tool_form.php + assets/js/tools.js — corpus scope radio toggle shown on search tab
- api/user-docs.php — add POST upload method for non-SSO authenticated users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 17:50:32 +02:00

894 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* discrepancy.js — page-scoped UI for /discrepancy.php */
(function () {
'use strict';
const els = {};
let lang = window.DBN_TOOLS_LANG || localStorage.getItem('dbn-ui-lang') || 'en';
let fileA = null;
let fileB = null;
let lastResult = null;
const SLICE_DEFS = [
{ id: 'child_welfare', label: 'Child Welfare' },
{ id: 'echr', label: 'ECHR' },
{ id: 'family_core', label: 'Family Law Core' },
{ id: 'bufdir_guidance', label: 'Bufdir Guidance' },
{ id: 'norwegian_courts', label: 'Norwegian Courts' },
{ id: 'broader_legal', label: 'Broader Legal' },
];
const STEP_LABELS = [
'Classify documents',
'Extract parties',
'Build timelines',
'Cross-reference parties',
'Cross-reference timelines',
'Research questions',
'Retrieve legal context',
'Synthesize report',
];
const stepKeyToIndex = {
doc_classify: 0,
party_extract: 1,
timeline_extract: 2,
cross_parties: 3,
cross_timelines: 4,
sub_question_gen: 5,
retrieval: 6,
synthesis: 7,
};
document.addEventListener('DOMContentLoaded', () => {
if (!document.body.dataset.activeTool || document.body.dataset.activeTool !== 'discrepancy') return;
Object.assign(els, {
form: document.getElementById('dcForm'),
status: document.getElementById('dcStatus'),
runButton: document.getElementById('dcRunButton'),
results: document.getElementById('dcResults'),
traceList: document.getElementById('traceList'),
langButtons: Array.from(document.querySelectorAll('#dcLangSwitcher .lang-btn')),
engineRadios: Array.from(document.querySelectorAll('input[name="dcEngine"]')),
slices: Array.from(document.querySelectorAll('.adv-slice')),
// File A
zoneA: document.getElementById('dcZoneA'),
inputA: document.getElementById('dcInputA'),
promptA: document.getElementById('dcPromptA'),
fileInfoA: document.getElementById('dcFileInfoA'),
fileNameA: document.getElementById('dcFileNameA'),
clearA: document.getElementById('dcClearA'),
// File B
zoneB: document.getElementById('dcZoneB'),
inputB: document.getElementById('dcInputB'),
promptB: document.getElementById('dcPromptB'),
fileInfoB: document.getElementById('dcFileInfoB'),
fileNameB: document.getElementById('dcFileNameB'),
clearB: document.getElementById('dcClearB'),
// Source modal
modal: document.getElementById('dcSourceModal'),
modalClose: document.getElementById('dcSourceModalClose'),
modalTitle: document.getElementById('dcSourceModalTitle'),
modalEyebrow: document.getElementById('dcSourceModalEyebrow'),
modalMeta: document.getElementById('dcSourceModalMeta'),
modalText: document.getElementById('dcSourceModalText'),
});
if (!els.form) return;
bindLang();
bindSlices();
bindUploadZone('A');
bindUploadZone('B');
bindModal();
els.form.addEventListener('submit', onSubmit);
renderTrace(STEP_LABELS.map((label) => ({ label, detail: 'Waiting…', status: 'idle' })));
});
// ── Language ───────────────────────────────────────────────────────────────
function bindLang() {
els.langButtons.forEach((b) => {
b.classList.toggle('is-active', b.dataset.lang === lang);
b.addEventListener('click', () => {
els.langButtons.forEach((x) => x.classList.remove('is-active'));
b.classList.add('is-active');
lang = b.dataset.lang || 'en';
localStorage.setItem('dbn-ui-lang', lang);
});
});
}
// ── Corpus slice toggles ───────────────────────────────────────────────────
function bindSlices() {
els.slices.forEach((btn) => {
btn.addEventListener('click', () => {
const isOn = btn.classList.toggle('is-on');
btn.setAttribute('aria-pressed', isOn ? 'true' : 'false');
const badge = btn.querySelector('.dr-slice__badge');
if (badge) badge.textContent = isOn ? 'on' : 'off';
});
});
}
function getSelectedSlices() {
const out = {};
SLICE_DEFS.forEach((s) => {
const btn = els.slices.find((b) => b.dataset.slice === s.id);
out[s.id] = !!(btn && btn.classList.contains('is-on'));
});
return out;
}
// ── File upload zones ──────────────────────────────────────────────────────
function bindUploadZone(slot) {
const zone = els['zone' + slot];
const input = els['input' + slot];
const prompt = els['prompt' + slot];
const info = els['fileInfo' + slot];
const nameEl = els['fileName' + slot];
const clearEl = els['clear' + slot];
if (!zone) return;
const accept = (file) => {
if (!file) return;
if (file.size > 8 * 1024 * 1024) {
setStatus(`${file.name} exceeds the 8 MB limit.`, 'error');
return;
}
const ext = (file.name.split('.').pop() || '').toLowerCase();
if (!['pdf', 'docx', 'txt'].includes(ext)) {
setStatus(`${file.name} is not a supported file type (PDF, DOCX, TXT).`, 'error');
return;
}
if (slot === 'A') fileA = file;
else fileB = file;
nameEl.textContent = file.name;
prompt.classList.add('is-hidden');
info.classList.remove('is-hidden');
zone.classList.remove('is-drop');
setStatus('', '');
};
input.addEventListener('change', (e) => {
if (e.target.files && e.target.files[0]) accept(e.target.files[0]);
});
zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('is-drop'); });
zone.addEventListener('dragleave', () => zone.classList.remove('is-drop'));
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('is-drop');
const f = e.dataTransfer?.files?.[0];
if (f) accept(f);
});
clearEl?.addEventListener('click', () => {
if (slot === 'A') fileA = null;
else fileB = null;
input.value = '';
info.classList.add('is-hidden');
prompt.classList.remove('is-hidden');
});
}
// ── Form submission ────────────────────────────────────────────────────────
async function onSubmit(e) {
e.preventDefault();
if (!fileA) {
setStatus('Upload Document A (the earlier/original document) before running.', 'error');
return;
}
if (!fileB) {
setStatus('Upload Document B (the later/comparison document) before running.', 'error');
return;
}
const engine = (els.engineRadios.find((r) => r.checked) || {}).value || 'azure_mini';
const slices = getSelectedSlices();
const expectedDuration = engine === 'azure_full' ? '2-3 minutes'
: engine === 'gpu' ? '~90 seconds'
: '60-90 seconds';
setStatus(`Comparing documents… (${expectedDuration})`, 'busy');
els.runButton.disabled = true;
els.results.innerHTML = `<div class="empty-state"><h3>Analysing…</h3><p>Classifying both documents, extracting parties and timelines, then cross-referencing for discrepancies. Expect ${expectedDuration}.</p></div>`;
const stepState = STEP_LABELS.map((label) => ({ label, detail: 'Queued', status: 'idle' }));
renderTrace(stepState);
const payload = { engine, language: lang, slices };
const form = new FormData();
form.append('payload', JSON.stringify(payload));
form.append('file_a', fileA);
form.append('file_b', fileB);
let response;
try {
response = await fetch('api/discrepancy.php', { method: 'POST', body: form, credentials: 'same-origin' });
} catch (err) {
setStatus(`Network error: ${err.message || err}`, 'error');
els.runButton.disabled = false;
return;
}
if (!response.ok || !response.body) {
if (response.status === 402 || response.status === 429) {
const d = await response.json().catch(() => ({}));
if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d);
} else {
setStatus(`Request failed (${response.status}).`, 'error');
}
els.runButton.disabled = false;
return;
}
const creditsRemaining = response.headers.get('X-Credits-Remaining');
if (creditsRemaining !== null && typeof window.dbnUpdateCredits === 'function') {
window.dbnUpdateCredits(parseInt(creditsRemaining, 10));
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let finalResult = null;
let errorEvent = null;
// State for progressive rendering
let metaARendered = false;
let metaBRendered = false;
let partiesARendered = false;
let partiesBRendered = false;
let tlARendered = false;
let tlBRendered = false;
function handleStreamEvent(evt) {
if (!evt || !evt.event) return;
if (evt.event === 'progress') {
if (evt.detail) setStatus(evt.detail, 'busy');
return;
}
if (evt.event === 'start') {
setStatus(`Comparing ${escapeHtml(evt.file_a || 'A')}${escapeHtml(evt.file_b || 'B')}…`, 'busy');
return;
}
if (evt.event === 'step') {
const idx = stepKeyToIndex[evt.step];
if (idx !== undefined) {
if (evt.status === 'running' && stepState[idx].status !== 'running') {
stepState[idx] = { label: evt.label || stepState[idx].label, detail: evt.detail || 'Running…', status: 'running' };
} else if (evt.status !== 'running') {
stepState[idx] = { label: evt.label || stepState[idx].label, detail: evt.detail || stepState[idx].detail, status: evt.status || stepState[idx].status };
}
renderTrace(stepState);
}
return;
}
if (evt.event === 'doc_a_meta' && !metaARendered) {
renderDocMetaCard('A', evt.result || {});
metaARendered = true;
return;
}
if (evt.event === 'doc_b_meta' && !metaBRendered) {
renderDocMetaCard('B', evt.result || {});
metaBRendered = true;
return;
}
if (evt.event === 'parties_a' && !partiesARendered && Array.isArray(evt.parties)) {
renderPartiesPreview('A', evt.parties);
partiesARendered = true;
return;
}
if (evt.event === 'parties_b' && !partiesBRendered && Array.isArray(evt.parties)) {
renderPartiesPreview('B', evt.parties);
partiesBRendered = true;
return;
}
if (evt.event === 'timeline_a' && !tlARendered && Array.isArray(evt.events)) {
renderTimelinePreview('A', evt.events);
tlARendered = true;
return;
}
if (evt.event === 'timeline_b' && !tlBRendered && Array.isArray(evt.events)) {
renderTimelinePreview('B', evt.events);
tlBRendered = true;
return;
}
if (evt.event === 'subq') {
setStatus(`Retrieving ${evt.index}/${evt.total}: ${String(evt.question || '').slice(0, 80)}…`, 'busy');
return;
}
if (evt.event === 'final') {
finalResult = evt.result;
return;
}
if (evt.event === 'error') {
errorEvent = evt;
return;
}
}
while (true) {
let chunk;
try { chunk = await reader.read(); }
catch (err) { setStatus(`Stream error: ${err.message || err}`, 'error'); els.runButton.disabled = false; return; }
const { done, value } = chunk;
if (value) {
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let evt;
try { evt = JSON.parse(trimmed); } catch (_) { continue; }
handleStreamEvent(evt);
}
}
if (done) break;
}
if (errorEvent) {
setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error');
els.runButton.disabled = false;
const runningIdx = stepState.findIndex((s) => s.status === 'running');
if (runningIdx >= 0) {
stepState[runningIdx] = { ...stepState[runningIdx], status: 'error', detail: errorEvent.message };
renderTrace(stepState);
}
return;
}
if (!finalResult) {
setStatus('Stream ended without a final result.', 'error');
els.runButton.disabled = false;
return;
}
lastResult = finalResult;
const meta = finalResult.trace_metadata || {};
setStatus(
`Done · ${meta.conflict_count || 0} contradictions · ${meta.deleted_count || 0} deletions · ${meta.added_count || 0} additions · ${meta.source_count || 0} sources`,
'ok'
);
els.runButton.disabled = false;
renderTrace(finalResult.trace || []);
renderFinalResults(finalResult);
}
// ── Progressive rendering ──────────────────────────────────────────────────
function ensureResultsReady() {
const emptyState = els.results.querySelector('.empty-state');
if (emptyState) emptyState.remove();
// Ensure the doc-meta pair container exists
if (!els.results.querySelector('#dcDocMetaPair')) {
const pair = document.createElement('div');
pair.id = 'dcDocMetaPair';
pair.className = 'dc-doc-meta-pair';
els.results.insertBefore(pair, els.results.firstChild);
}
}
function renderDocMetaCard(slot, meta) {
ensureResultsReady();
const pair = els.results.querySelector('#dcDocMetaPair');
if (!pair) return;
const existing = pair.querySelector(`#dcMeta${slot}`);
if (existing) existing.remove();
const card = document.createElement('div');
card.id = `dcMeta${slot}`;
card.className = 'dc-doc-meta-card';
const fields = [
meta.doc_date ? ['Date', meta.doc_date] : null,
meta.issuing_authority ? ['Authority', meta.issuing_authority] : null,
meta.reference_number ? ['Ref', meta.reference_number] : null,
].filter(Boolean);
card.innerHTML = `
<div class="dc-doc-meta-card__head">
<span class="dc-slot-label">Document ${slot}</span>
<span class="bvj-doc-type-badge">${escapeHtml(meta.doc_type || ('Document ' + slot))}</span>
</div>
${fields.length ? `<div class="bvj-doc-meta__fields">
${fields.map(([k, v]) => `<span class="bvj-doc-meta__field"><strong>${escapeHtml(k)}:</strong> ${escapeHtml(String(v))}</span>`).join('')}
</div>` : ''}
`;
pair.appendChild(card);
}
function renderPartiesPreview(slot, parties) {
if (!parties.length) return;
ensureResultsReady();
const pair = els.results.querySelector('#dcDocMetaPair');
if (!pair) return;
const metaCard = pair.querySelector(`#dcMeta${slot}`);
if (!metaCard) return;
const existing = metaCard.querySelector('.dc-parties-preview');
if (existing) existing.remove();
const preview = document.createElement('div');
preview.className = 'dc-parties-preview';
preview.innerHTML = `<p class="dc-parties-count">${parties.length} party${parties.length === 1 ? '' : 'ies'} identified</p>
<div class="dc-parties-chips">
${parties.slice(0, 6).map((p) => `<span class="dc-party-chip">${escapeHtml(p.name || p.role || '?')}</span>`).join('')}
${parties.length > 6 ? `<span class="dc-party-chip dc-party-chip--more">+${parties.length - 6} more</span>` : ''}
</div>`;
metaCard.appendChild(preview);
}
function renderTimelinePreview(slot, events) {
if (!events.length) return;
ensureResultsReady();
const pair = els.results.querySelector('#dcDocMetaPair');
if (!pair) return;
const metaCard = pair.querySelector(`#dcMeta${slot}`);
if (!metaCard) return;
const existing = metaCard.querySelector('.dc-timeline-preview');
if (existing) existing.remove();
const highCount = events.filter((e) => e.significance === 'high').length;
const preview = document.createElement('div');
preview.className = 'dc-timeline-preview';
preview.innerHTML = `<p class="dc-parties-count">${events.length} events · ${highCount} high-significance</p>`;
metaCard.appendChild(preview);
}
// ── Final render ───────────────────────────────────────────────────────────
function renderFinalResults(data) {
const sources = data.sources || [];
const discrepancies = Array.isArray(data.critical_discrepancies) ? data.critical_discrepancies : [];
const actions = Array.isArray(data.recommended_actions) ? data.recommended_actions : [];
const uncertain = Array.isArray(data.what_remains_uncertain) ? data.what_remains_uncertain : [];
const partiesDiff = data.parties_diff || {};
const tlDiff = data.timeline_diff || {};
const headline = data.headline_finding || '';
const nameA = data.doc_a_name || 'Document A';
const nameB = data.doc_b_name || 'Document B';
// Remove progressive doc meta pair — we'll re-render from authoritative data
els.results.querySelector('#dcDocMetaPair')?.remove();
// Re-render doc meta pair from final data
renderDocMetaCard('A', data.doc_a_meta || {});
renderDocMetaCard('B', data.doc_b_meta || {});
if ((data.parties_a || []).length) renderPartiesPreview('A', data.parties_a);
if ((data.parties_b || []).length) renderPartiesPreview('B', data.parties_b);
if ((data.timeline_a || []).length) renderTimelinePreview('A', data.timeline_a);
if ((data.timeline_b || []).length) renderTimelinePreview('B', data.timeline_b);
// Build tabs
const conflicts = tlDiff.conflicts || [];
const deletedEvents = tlDiff.in_a_only || [];
const addedEvents = tlDiff.in_b_only || [];
const procGaps = tlDiff.procedural_gaps || [];
const narrative = tlDiff.narrative_shifts || {};
const pRemoved = partiesDiff.in_a_only || [];
const pAdded = partiesDiff.in_b_only || [];
const pChanged = partiesDiff.changed_between || [];
const totalDiscrepancies = discrepancies.length;
const tabCountStr = (n) => n > 0 ? ` <span class="dc-tab-count">${n}</span>` : '';
const finalHtml = `
<!-- Headline -->
${headline ? `<div class="dr-result-block dc-headline">
<h3 class="dc-headline__label">Key finding</h3>
<p class="dc-headline__text">${escapeHtml(headline)}</p>
</div>` : ''}
<!-- Tabs -->
<div class="dc-tabs">
<div class="dc-tab-bar" role="tablist">
<button type="button" class="dc-tab is-active" data-tab="summary" role="tab">Summary${tabCountStr(totalDiscrepancies)}</button>
<button type="button" class="dc-tab" data-tab="parties" role="tab">Parties${tabCountStr(pRemoved.length + pAdded.length + pChanged.length)}</button>
<button type="button" class="dc-tab" data-tab="timeline" role="tab">Timeline${tabCountStr(conflicts.length + deletedEvents.length + addedEvents.length)}</button>
<button type="button" class="dc-tab" data-tab="sources" role="tab">Legal context${tabCountStr(sources.length)}</button>
</div>
<!-- Summary tab -->
<div class="dc-tab-panel is-active" data-panel="summary">
${renderDiscrepanciesTab(discrepancies, sources)}
${actions.length ? `<div class="dr-result-block">
<h3 style="margin:0 0 10px;font-size:0.95rem">Recommended actions</h3>
<ol class="dc-action-list">
${actions.map((a) => `<li>${escapeHtml(String(a))}</li>`).join('')}
</ol>
</div>` : ''}
${narrative.summary ? `<div class="dr-result-block">
<h3 style="margin:0 0 8px;font-size:0.95rem">Narrative shift</h3>
<p style="margin:0 0 10px;line-height:1.55;color:var(--ink)">${escapeHtml(narrative.summary)}</p>
${(narrative.new_in_b || []).length ? `<div class="dc-narrative-block dc-narrative-block--added">
<strong>New in ${escapeHtml(nameB)}:</strong>
<ul>${(narrative.new_in_b || []).map((s) => `<li>${escapeHtml(String(s))}</li>`).join('')}</ul>
</div>` : ''}
${(narrative.removed_from_b || []).length ? `<div class="dc-narrative-block dc-narrative-block--removed">
<strong>Removed from ${escapeHtml(nameB)}:</strong>
<ul>${(narrative.removed_from_b || []).map((s) => `<li>${escapeHtml(String(s))}</li>`).join('')}</ul>
</div>` : ''}
</div>` : ''}
${uncertain.length ? `<div class="dr-result-block">
<h3 style="margin:0 0 8px;font-size:0.95rem;color:var(--muted)">What remains uncertain</h3>
<ul style="padding-left:1.2em;margin:0;color:var(--muted);line-height:1.55">
${uncertain.map((u) => `<li>${escapeHtml(String(u))}</li>`).join('')}
</ul>
</div>` : ''}
</div>
<!-- Parties tab -->
<div class="dc-tab-panel" data-panel="parties">
${renderPartiesTab(pRemoved, pAdded, pChanged, nameA, nameB)}
</div>
<!-- Timeline tab -->
<div class="dc-tab-panel" data-panel="timeline">
${renderTimelineTab(conflicts, deletedEvents, addedEvents, procGaps, nameA, nameB)}
</div>
<!-- Sources tab -->
<div class="dc-tab-panel" data-panel="sources">
${renderSourcesTab(sources)}
</div>
</div>
<p class="dc-disclaimer">${escapeHtml(data.disclaimer || 'For legal information and preparation only — not legal advice. Verify all findings with a qualified lawyer.')}</p>
`;
const finalContainer = document.createElement('div');
finalContainer.innerHTML = finalHtml;
while (finalContainer.firstChild) {
els.results.appendChild(finalContainer.firstChild);
}
// Save-to-corpus button (appended after final results)
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'js-save-corpus secondary-button';
saveBtn.dataset.tool = 'discrepancy';
saveBtn.dataset.contentId = 'dcResults';
saveBtn.dataset.suggestedTitle = 'Discrepancy report';
saveBtn.textContent = 'Save to corpus';
saveBtn.style.marginTop = '16px';
els.results.appendChild(saveBtn);
// Bind tabs
els.results.querySelectorAll('.dc-tab').forEach((btn) => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
els.results.querySelectorAll('.dc-tab').forEach((b) => b.classList.remove('is-active'));
els.results.querySelectorAll('.dc-tab-panel').forEach((p) => p.classList.remove('is-active'));
btn.classList.add('is-active');
const panel = els.results.querySelector(`.dc-tab-panel[data-panel="${tab}"]`);
if (panel) panel.classList.add('is-active');
});
});
// Bind source card clicks
els.results.querySelectorAll('.dr-source-card[data-source-n]').forEach((node) => {
node.addEventListener('click', (e) => {
if (e.target.closest('a')) return;
const n = parseInt(node.dataset.sourceN, 10);
const src = sources.find((s) => s.n === n);
if (src) openModal(src);
});
});
}
// ── Tab content renderers ──────────────────────────────────────────────────
function renderDiscrepanciesTab(discrepancies, sources) {
if (!discrepancies.length) {
return '<div class="dr-result-block"><p style="color:var(--muted)"><em>No critical discrepancies were identified in the synthesis.</em></p></div>';
}
const sevClass = (s) => s === 'high' ? 'dc-sev--high' : (s === 'medium' ? 'dc-sev--medium' : 'dc-sev--low');
const catLabel = (c) => ({ timeline_conflict: 'Timeline', narrative_shift: 'Narrative', party_discrepancy: 'Party', procedural_gap: 'Procedure' }[c] || c);
return `<div class="dr-result-block">
<h3 style="margin:0 0 12px;font-size:0.95rem">Critical discrepancies (${discrepancies.length})</h3>
<div class="dc-discrepancies">
${discrepancies.map((d) => `<div class="dc-discrepancy">
<div class="dc-discrepancy__head">
<div>
<span class="dc-cat-tag">${escapeHtml(catLabel(d.category || ''))}</span>
<span class="dc-severity ${sevClass(d.significance || 'low')}">${escapeHtml(d.significance || 'low')}</span>
</div>
<div class="dc-discrepancy__title">${escapeHtml(d.title || '')}</div>
</div>
<div class="dc-discrepancy__compare">
<div class="dc-compare-col dc-compare-col--a">
<span class="dc-compare-label">Document A</span>
<p>${escapeHtml(d.document_a_says || '—')}</p>
</div>
<div class="dc-compare-divider" aria-hidden="true">≠</div>
<div class="dc-compare-col dc-compare-col--b">
<span class="dc-compare-label">Document B</span>
<p>${escapeHtml(d.document_b_says || '—')}</p>
</div>
</div>
${d.legal_relevance ? `<div class="dc-discrepancy__legal">
${renderInlineCitations(escapeHtml(d.legal_relevance), sources)}
</div>` : ''}
</div>`).join('')}
</div>
</div>`;
}
function renderPartiesTab(removed, added, changed, nameA, nameB) {
if (!removed.length && !added.length && !changed.length) {
return '<div class="dr-result-block"><p style="color:var(--muted)"><em>No party discrepancies identified between the two documents.</em></p></div>';
}
let html = '<div class="dr-result-block">';
if (removed.length) {
html += `<h3 style="margin:0 0 10px;font-size:0.92rem;color:#b41e1e">Removed from ${escapeHtml(nameB)} (${removed.length})</h3>
<div class="dc-party-list">
${removed.map((p) => `<div class="dc-party-row dc-party-row--removed">
<div class="dc-party-row__name">${escapeHtml(p.name || '?')}</div>
<div class="dc-party-row__role">${escapeHtml(p.role_in_a || '')}</div>
${p.significance ? `<div class="dc-party-row__sig">${escapeHtml(p.significance)}</div>` : ''}
</div>`).join('')}
</div>`;
}
if (added.length) {
html += `<h3 style="margin:16px 0 10px;font-size:0.92rem;color:var(--teal-dark)">Added in ${escapeHtml(nameB)} (${added.length})</h3>
<div class="dc-party-list">
${added.map((p) => `<div class="dc-party-row dc-party-row--added">
<div class="dc-party-row__name">${escapeHtml(p.name || '?')}</div>
<div class="dc-party-row__role">${escapeHtml(p.role_in_b || '')}</div>
${p.significance ? `<div class="dc-party-row__sig">${escapeHtml(p.significance)}</div>` : ''}
</div>`).join('')}
</div>`;
}
if (changed.length) {
html += `<h3 style="margin:16px 0 10px;font-size:0.92rem;color:var(--ink)">Changed between versions (${changed.length})</h3>
<div class="dc-party-list">
${changed.map((p) => `<div class="dc-party-row dc-party-row--changed">
<div class="dc-party-row__name">${escapeHtml(p.name || '?')}</div>
<div class="dc-compare-col dc-compare-col--a" style="font-size:0.82rem;padding:2px 0">${escapeHtml(p.in_a || '')}</div>
<div class="dc-compare-col dc-compare-col--b" style="font-size:0.82rem;padding:2px 0">${escapeHtml(p.in_b || '')}</div>
${p.significance ? `<div class="dc-party-row__sig">${escapeHtml(p.significance)}</div>` : ''}
</div>`).join('')}
</div>`;
}
html += '</div>';
return html;
}
function renderTimelineTab(conflicts, deleted, added, procGaps, nameA, nameB) {
if (!conflicts.length && !deleted.length && !added.length && !procGaps.length) {
return '<div class="dr-result-block"><p style="color:var(--muted)"><em>No timeline discrepancies identified between the two documents.</em></p></div>';
}
const sigClass = (s) => `dc-sig--${s === 'high' ? 'high' : (s === 'medium' ? 'medium' : 'low')}`;
let html = '';
if (conflicts.length) {
html += `<div class="dr-result-block">
<h3 style="margin:0 0 10px;font-size:0.92rem">Contradictions (${conflicts.length})</h3>
<div class="dc-timeline-list">
${conflicts.map((c) => `<div class="dc-tl-item dc-tl-item--conflict">
<div class="dc-tl-item__head">
<span class="dc-sig-badge ${sigClass(c.significance || 'low')}">${escapeHtml(c.significance || 'low')}</span>
${c.date_a || c.date_b ? `<span class="dc-tl-date">${escapeHtml(c.date_a || '?')} / ${escapeHtml(c.date_b || '?')}</span>` : ''}
</div>
<div class="dc-discrepancy__compare" style="margin-top:6px">
<div class="dc-compare-col dc-compare-col--a">
<span class="dc-compare-label">${escapeHtml(nameA)}</span>
<p>${escapeHtml(c.doc_a_says || '—')}</p>
</div>
<div class="dc-compare-divider" aria-hidden="true">≠</div>
<div class="dc-compare-col dc-compare-col--b">
<span class="dc-compare-label">${escapeHtml(nameB)}</span>
<p>${escapeHtml(c.doc_b_says || '—')}</p>
</div>
</div>
${c.legal_significance ? `<p class="dc-tl-legal">${escapeHtml(c.legal_significance)}</p>` : ''}
</div>`).join('')}
</div>
</div>`;
}
if (deleted.length) {
html += `<div class="dr-result-block">
<h3 style="margin:0 0 10px;font-size:0.92rem;color:#b41e1e">Deleted from ${escapeHtml(nameB)} (${deleted.length})</h3>
<div class="dc-timeline-list">
${deleted.map((ev) => `<div class="dc-tl-item dc-tl-item--deleted">
<div class="dc-tl-item__head">
<span class="dc-sig-badge ${sigClass(ev.significance || 'low')}">${escapeHtml(ev.significance || 'low')}</span>
${ev.date ? `<span class="dc-tl-date">${escapeHtml(ev.date)}</span>` : ''}
${ev.actor ? `<span class="dc-tl-actor">${escapeHtml(ev.actor)}</span>` : ''}
</div>
<p class="dc-tl-desc">${escapeHtml(ev.description || '')}</p>
${ev.legal_significance ? `<p class="dc-tl-legal">${escapeHtml(ev.legal_significance)}</p>` : ''}
</div>`).join('')}
</div>
</div>`;
}
if (added.length) {
html += `<div class="dr-result-block">
<h3 style="margin:0 0 10px;font-size:0.92rem;color:var(--teal-dark)">New in ${escapeHtml(nameB)} (${added.length})</h3>
<div class="dc-timeline-list">
${added.map((ev) => `<div class="dc-tl-item dc-tl-item--added">
<div class="dc-tl-item__head">
<span class="dc-sig-badge ${sigClass(ev.significance || 'low')}">${escapeHtml(ev.significance || 'low')}</span>
${ev.date ? `<span class="dc-tl-date">${escapeHtml(ev.date)}</span>` : ''}
${ev.actor ? `<span class="dc-tl-actor">${escapeHtml(ev.actor)}</span>` : ''}
</div>
<p class="dc-tl-desc">${escapeHtml(ev.description || '')}</p>
${ev.legal_significance ? `<p class="dc-tl-legal">${escapeHtml(ev.legal_significance)}</p>` : ''}
</div>`).join('')}
</div>
</div>`;
}
if (procGaps.length) {
html += `<div class="dr-result-block">
<h3 style="margin:0 0 10px;font-size:0.92rem;color:var(--muted)">Procedural gaps (${procGaps.length})</h3>
<ul style="padding-left:1.2em;margin:0;line-height:1.6">
${procGaps.map((g) => `<li>${escapeHtml(g.gap || '')} <span class="dc-sig-badge ${sigClass(g.significance || 'low')}" style="margin-left:4px">${escapeHtml(g.significance || 'low')}</span></li>`).join('')}
</ul>
</div>`;
}
return html;
}
function renderSourcesTab(sources) {
if (!sources.length) {
return '<div class="dr-result-block"><p style="color:var(--muted)"><em>No corpus sources retrieved. Enable corpus slices and re-run.</em></p></div>';
}
return `<div class="dr-result-block">
<div class="dr-sources-head">
<h3>Legal context sources (${sources.length})</h3>
<small>Click a card to expand · external link opens original source</small>
</div>
<div class="dr-source-list">
${sources.map((s) => renderSourceCard(s)).join('')}
</div>
</div>`;
}
function renderSourceCard(s) {
const score = s.reranker_score != null ? s.reranker_score : s.similarity;
const link = s.deep_link || s.source_url;
const titleHtml = link
? `<a href="${escapeHtml(link)}" target="_blank" rel="noopener" class="dr-source-title-link">${escapeHtml(s.title || 'Untitled')} <span class="dr-external-link" aria-hidden="true">↗</span></a>`
: `${escapeHtml(s.title || 'Untitled')}`;
return `<div class="dr-source-card" data-source-n="${s.n}" role="button" tabindex="0">
<span class="dr-source-number">${s.n}</span>
<div class="dr-source-body">
<div class="dr-source-title">${titleHtml}</div>
${s.section ? `<div class="dr-source-meta"><span class="dr-source-tag">${escapeHtml(s.section)}</span></div>` : ''}
<div class="dr-source-meta">
<span class="dr-source-tag">${escapeHtml(s.package_or_corpus || 'corpus')}</span>
${s.authority_label ? `<span class="dr-source-tag">${escapeHtml(s.authority_label)}</span>` : ''}
${(s.matched_sub_questions || []).map((q) => `<span class="dr-source-tag">${escapeHtml(q)}</span>`).join('')}
</div>
<p class="dr-source-excerpt">${escapeHtml(truncate(s.excerpt || '', 240))}</p>
</div>
<div class="dr-source-aside">
<span>score<br><b>${score != null ? Number(score).toFixed(2) : '—'}</b></span>
</div>
</div>`;
}
// ── Source modal ───────────────────────────────────────────────────────────
function bindModal() {
els.modalClose?.addEventListener('click', closeModal);
els.modal?.addEventListener('click', (e) => { if (e.target === els.modal) closeModal(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && els.modal && !els.modal.classList.contains('is-hidden')) closeModal();
});
}
function closeModal() { els.modal?.classList.add('is-hidden'); }
function openModal(source) {
if (!source) return;
els.modalEyebrow.textContent = 'Corpus source';
els.modalTitle.textContent = source.title || 'Source';
const metaRows = [
['Number', `[${source.n}]`],
source.section ? ['Section', source.section] : null,
['Corpus', source.package_or_corpus || '—'],
source.authority_label ? ['Authority', source.authority_label] : null,
source.similarity != null ? ['Similarity', String(source.similarity)] : null,
source.reranker_score != null ? ['Rerank score', String(source.reranker_score)] : null,
].filter(Boolean);
els.modalMeta.innerHTML = '<dl>' + metaRows.map(([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(String(v))}</dd>`).join('') + '</dl>';
const chunkText = source.chunk_text || source.excerpt || '';
let html = chunkText
? `<button class="dr-modal-chunk-toggle" type="button">Show matching text ▼</button><div class="dr-modal-chunk-text is-hidden">${escapeHtml(chunkText)}</div>`
: '<em>No excerpt available.</em>';
els.modalText.innerHTML = html;
const toggle = els.modalText.querySelector('.dr-modal-chunk-toggle');
const div = els.modalText.querySelector('.dr-modal-chunk-text');
toggle?.addEventListener('click', () => {
const isHidden = div.classList.toggle('is-hidden');
toggle.textContent = isHidden ? 'Show matching text ▼' : 'Hide matching text ▲';
});
els.modal.classList.remove('is-hidden');
}
// ── Trace rendering ────────────────────────────────────────────────────────
function renderTrace(steps) {
if (!els.traceList) return;
els.traceList.classList.add('is-rich');
els.traceList.innerHTML = steps.map((step, i) => {
const statusClass = step.status === 'running' ? 'is-running'
: step.status === 'complete' ? 'is-done'
: step.status === 'warning' ? 'is-warning'
: step.status === 'error' ? 'is-error'
: '';
const marker = step.status === 'complete' ? '✓'
: step.status === 'warning' ? '!'
: step.status === 'error' ? '×'
: (i + 1);
return `<li class="trace-step ${statusClass}">
<span class="trace-step__marker">${marker}</span>
<div>
<span class="trace-step__label">${escapeHtml(step.label || '')}</span>
<span class="trace-step__detail">${escapeHtml(step.detail || '')}</span>
</div>
</li>`;
}).join('');
}
// ── Utility ────────────────────────────────────────────────────────────────
function setStatus(message, kind) {
if (!els.status) return;
els.status.textContent = message;
els.status.style.color = kind === 'error' ? '#b41e1e'
: kind === 'ok' ? 'var(--teal-dark)'
: 'var(--muted)';
}
function renderInlineCitations(escapedHtml, sources) {
return escapedHtml.replace(/\[(\d+(?:\s*[-,]\s*\d+)*)\]/g, (_, group) => {
const nums = expandCiteGroup(group);
return nums.map((n) => `<span class="dr-cite" data-source-n="${n}" role="button" tabindex="0">${n}</span>`).join('');
});
}
function expandCiteGroup(group) {
const out = [];
group.split(',').forEach((part) => {
const range = part.trim().match(/^(\d+)\s*-\s*(\d+)$/);
if (range) {
for (let i = parseInt(range[1], 10); i <= parseInt(range[2], 10); i++) out.push(i);
} else {
const n = parseInt(part.trim(), 10);
if (!Number.isNaN(n)) out.push(n);
}
});
return Array.from(new Set(out));
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function truncate(s, n) {
if (!s || s.length <= n) return s || '';
return s.slice(0, n - 1) + '…';
}
})();