Files
dobetternorge-tools/assets/js/barnevernet.js
T
daveadmin bffc714541 Add My Docs picker to deep-research, advocate, barnevernet, korrespond, citations
- PHP: Add docPickerSection (button + chips + hidden input) to all 5 tool pages
- JS: Send doc_ids in payload for deep-research, advocate, barnevernet, korrespond
- Backend: Inject selected corpus doc content into paste_text/narrative/notes via dbnToolsInjectDocContent
- Citations: Add upload zone (file → api/extract.php → textarea) + paste textarea with live Norwegian legal reference extraction (regex) + ref chips → title search; doc picker populates titleInput via MutationObserver

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:04:45 +02:00

1043 lines
44 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.
/* barnevernet.js — page-scoped UI for /barnevernet.php */
(function () {
'use strict';
const els = {};
let lang = window.DBN_TOOLS_LANG || localStorage.getItem('dbn-ui-lang') || 'en';
let uploadFiles = [];
let lastResult = null;
let branchContext = 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: 'hague', label: 'Hague Convention' },
{ id: 'broader_legal', label: 'Broader Legal Support' },
{ id: 'dbn_resources', label: 'DBN Resources' },
];
const STEP_LABELS = [
'Document classification',
'Party extraction',
'Timeline extraction',
'Sub-question generation',
'Corpus retrieval',
'Synthesis',
'Citation confidence',
];
const stepKeyToIndex = {
doc_classify: 0,
party_extract: 1,
timeline_extract: 2,
sub_question_gen: 3,
slice_resolution: 3, // shown under sub-question gen phase
upload_indexing: 4,
retrieval: 4,
synthesis: 5,
confidence: 6,
};
document.addEventListener('DOMContentLoaded', () => {
if (!document.body.dataset.activeTool || document.body.dataset.activeTool !== 'barnevernet') return;
Object.assign(els, {
form: document.getElementById('bvjForm'),
notes: document.getElementById('bvjNotes'),
status: document.getElementById('bvjStatus'),
runButton: document.getElementById('bvjRunButton'),
results: document.getElementById('bvjResults'),
traceList: document.getElementById('traceList'),
roleSelect: document.getElementById('bvjRoleSelect'),
roleCustom: document.getElementById('bvjRoleCustom'),
slices: Array.from(document.querySelectorAll('.adv-slice')),
langButtons: Array.from(document.querySelectorAll('#bvjLangSwitcher .lang-btn')),
engineRadios: Array.from(document.querySelectorAll('input[name="bvjEngine"]')),
subQ: document.getElementById('bvjSubQ'),
subQVal: document.getElementById('bvjSubQValue'),
chunkLimit: document.getElementById('bvjChunkLimit'),
chunkLimitVal: document.getElementById('bvjChunkLimitValue'),
sim: document.getElementById('bvjSim'),
simVal: document.getElementById('bvjSimValue'),
topK: document.getElementById('bvjTopK'),
topKVal: document.getElementById('bvjTopKValue'),
temp: document.getElementById('bvjTemp'),
tempVal: document.getElementById('bvjTempValue'),
uploadZone: document.getElementById('bvjUploadZone'),
uploadInput: document.getElementById('bvjUploadInput'),
uploadPrompt: document.getElementById('bvjUploadPrompt'),
uploadFileInfo: document.getElementById('bvjUploadFileInfo'),
uploadFileList: document.getElementById('bvjUploadFileList'),
uploadClear: document.getElementById('bvjUploadClear'),
modal: document.getElementById('bvjSourceModal'),
modalClose: document.getElementById('bvjSourceModalClose'),
modalTitle: document.getElementById('bvjSourceModalTitle'),
modalEyebrow: document.getElementById('bvjSourceModalEyebrow'),
modalMeta: document.getElementById('bvjSourceModalMeta'),
modalText: document.getElementById('bvjSourceModalText'),
branchPanel: document.getElementById('bvjBranchPanel'),
branchClear: document.getElementById('bvjBranchClear'),
branchOrigin: document.getElementById('bvjBranchOrigin'),
branchSummary: document.getElementById('bvjBranchSummary'),
branchNotes: document.getElementById('bvjBranchNotes'),
});
if (!els.form) return;
bindRole();
bindSlices();
bindLang();
bindRanges();
bindUpload();
bindModal();
bindBranch();
els.form.addEventListener('submit', onSubmit);
els.results.addEventListener('click', (e) => {
const btn = e.target.closest('.dr-branch-btn');
if (btn) branchFromSubQ(btn.dataset.question || '');
});
renderTrace(STEP_LABELS.map((label) => ({ label, detail: 'Waiting…', status: 'idle' })));
});
// ── Role binding ───────────────────────────────────────────────────────────
function bindRole() {
if (!els.roleSelect) return;
els.roleSelect.addEventListener('change', () => {
const isOther = els.roleSelect.value === '__other__';
els.roleCustom.classList.toggle('is-hidden', !isOther);
if (isOther) els.roleCustom.focus();
});
}
function getAdvocateRole() {
if (!els.roleSelect) return '';
if (els.roleSelect.value === '__other__') {
return (els.roleCustom ? els.roleCustom.value.trim() : '');
}
return els.roleSelect.value;
}
// ── 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;
}
// ── 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);
});
});
}
// ── Range controls ─────────────────────────────────────────────────────────
function bindRanges() {
const pairs = [
[els.subQ, els.subQVal, (v) => v],
[els.chunkLimit, els.chunkLimitVal, (v) => v],
[els.sim, els.simVal, (v) => Number(v).toFixed(2)],
[els.topK, els.topKVal, (v) => v],
[els.temp, els.tempVal, (v) => Number(v).toFixed(2)],
];
pairs.forEach(([range, label, fmt]) => {
if (!range || !label) return;
const sync = () => { label.textContent = fmt(range.value); };
range.addEventListener('input', sync);
sync();
});
}
function getControls() {
return {
sub_q_count: parseInt(els.subQ.value, 10),
chunk_limit: parseInt(els.chunkLimit.value, 10),
similarity_threshold: parseFloat(els.sim.value),
reranker_top_k: parseInt(els.topK.value, 10),
temperature: parseFloat(els.temp.value),
};
}
function getEngine() {
const checked = els.engineRadios.find((r) => r.checked);
return checked ? checked.value : 'azure_mini';
}
// ── File upload ────────────────────────────────────────────────────────────
function bindUpload() {
if (!els.uploadZone) return;
const onFiles = (fileList) => {
const files = Array.from(fileList || []).slice(0, 5);
if (uploadFiles.length + files.length > 5) {
setStatus('At most 5 files can be uploaded per request.', 'error');
return;
}
files.forEach((f) => {
if (f.size > 8 * 1024 * 1024) {
setStatus(`${f.name} exceeds the 8 MB limit.`, 'error');
return;
}
const ext = (f.name.split('.').pop() || '').toLowerCase();
if (!['pdf', 'docx', 'txt'].includes(ext)) {
setStatus(`${f.name} is not a supported file type (PDF, DOCX, TXT).`, 'error');
return;
}
uploadFiles.push(f);
});
renderUploadList();
};
els.uploadInput.addEventListener('change', (e) => onFiles(e.target.files));
els.uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); els.uploadZone.classList.add('is-drop'); });
els.uploadZone.addEventListener('dragleave', () => els.uploadZone.classList.remove('is-drop'));
els.uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
els.uploadZone.classList.remove('is-drop');
onFiles(e.dataTransfer?.files);
});
els.uploadClear?.addEventListener('click', () => {
uploadFiles = [];
els.uploadInput.value = '';
renderUploadList();
});
}
function renderUploadList() {
if (!uploadFiles.length) {
els.uploadFileInfo.classList.add('is-hidden');
els.uploadPrompt.classList.remove('is-hidden');
return;
}
els.uploadPrompt.classList.add('is-hidden');
els.uploadFileInfo.classList.remove('is-hidden');
els.uploadFileList.innerHTML = uploadFiles.map((f) => {
const kb = (f.size / 1024).toFixed(0);
return `<li><span class="upload-filename">${escapeHtml(f.name)}</span><span class="upload-chars">${kb} KB</span></li>`;
}).join('');
}
// ── 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 = source.source_origin === 'upload' ? 'Uploaded file' : 'Corpus source';
els.modalTitle.textContent = source.title || 'Source';
const metaRows = [
['Number', `[${source.n}]`],
source.section ? ['Section', source.section] : null,
['Corpus / package', source.package_or_corpus || '—'],
source.authority_type ? ['Authority', source.authority_type] : null,
source.jurisdiction ? ['Jurisdiction', source.jurisdiction] : null,
source.similarity != null ? ['Similarity', String(source.similarity)] : null,
source.reranker_score != null ? ['Rerank score', String(source.reranker_score)] : null,
source.matched_sub_questions?.length ? ['Matched sub-Q', source.matched_sub_questions.join(', ')] : null,
].filter(Boolean);
els.modalMeta.innerHTML = '<dl>' + metaRows.map(([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(String(v))}</dd>`).join('') + '</dl>';
const summary = source.summary || '';
const chunkText = source.chunk_text || source.excerpt || '';
const isUpload = source.source_origin === 'upload';
const hasDocId = source.document_id != null;
let html = summary
? `<div class="dr-modal-summary">${escapeHtml(summary)}</div>`
: `<div class="dr-modal-summary dr-modal-summary--empty"><em>Summary not yet generated — showing raw chunk below.</em></div>`;
if (chunkText) {
html += `<button class="dr-modal-chunk-toggle" type="button">Show matching chunk ▼</button>`;
html += `<div class="dr-modal-chunk-text is-hidden">${escapeHtml(chunkText)}</div>`;
}
if (!isUpload && hasDocId) {
html += `<button class="dr-modal-all-chunks" type="button" data-document-id="${source.document_id}">View all document chunks →</button>`;
html += `<div class="dr-modal-chunks-list"></div>`;
}
els.modalText.innerHTML = html;
const chunkToggle = els.modalText.querySelector('.dr-modal-chunk-toggle');
const chunkDiv = els.modalText.querySelector('.dr-modal-chunk-text');
chunkToggle?.addEventListener('click', () => {
const isHidden = chunkDiv.classList.toggle('is-hidden');
chunkToggle.textContent = isHidden ? 'Show matching chunk ▼' : 'Hide matching chunk ▲';
});
const allChunksBtn = els.modalText.querySelector('.dr-modal-all-chunks');
const chunksListDiv = els.modalText.querySelector('.dr-modal-chunks-list');
if (allChunksBtn && chunksListDiv) {
allChunksBtn.addEventListener('click', async () => {
allChunksBtn.disabled = true;
allChunksBtn.textContent = 'Loading…';
try {
const res = await fetch(`api/document-chunks.php?document_id=${source.document_id}`, { credentials: 'same-origin' });
const data = await res.json();
if (data.ok && data.chunks) {
chunksListDiv.innerHTML =
`<div class="dr-modal-chunks-head">${escapeHtml(data.document?.title || '')} · ${data.chunks.length} chunks</div>` +
data.chunks.map((c) => `<div class="dr-modal-chunk-item">
<span class="dr-modal-chunk-idx">#${c.chunk_index + 1}${c.section_title ? ' · ' + escapeHtml(c.section_title) : ''}</span>
<p class="dr-modal-chunk-preview">${escapeHtml(truncate(c.content, 300))}</p>
</div>`).join('');
allChunksBtn.remove();
} else {
allChunksBtn.textContent = 'Could not load chunks.';
allChunksBtn.disabled = false;
}
} catch (_) {
allChunksBtn.textContent = 'Error loading chunks.';
allChunksBtn.disabled = false;
}
});
}
els.modal.classList.remove('is-hidden');
}
// ── Branch context ─────────────────────────────────────────────────────────
function bindBranch() {
if (!els.branchClear) return;
els.branchClear.addEventListener('click', clearBranch);
}
function clearBranch() {
branchContext = null;
if (els.branchPanel) els.branchPanel.classList.add('is-hidden');
if (els.branchNotes) els.branchNotes.value = '';
}
function branchFromSubQ(question) {
if (!lastResult || !question) return;
branchContext = {
original_query: lastResult.query || '',
brief_summary: (lastResult.advocacy_brief || '').slice(0, 600),
what_we_found: lastResult.what_we_found || '',
top_sources: (lastResult.sources || []).slice(0, 5).map((s) => ({
n: s.n, title: s.title, excerpt: (s.excerpt || '').slice(0, 200),
})),
};
// Pre-fill notes textarea (branch uses notes field, not a query textarea)
if (els.notes) els.notes.value = question;
if (els.branchOrigin) els.branchOrigin.textContent = 'Original query: ' + branchContext.original_query;
if (els.branchSummary) els.branchSummary.textContent = branchContext.brief_summary;
if (els.branchPanel) els.branchPanel.classList.remove('is-hidden');
els.form.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ── Form submission ────────────────────────────────────────────────────────
async function onSubmit(e) {
e.preventDefault();
const advocateRole = getAdvocateRole();
if (!advocateRole) {
setStatus('Select who you are representing before running.', 'error');
return;
}
if (!uploadFiles.length) {
setStatus('Upload at least one BVJ document before running.', 'error');
return;
}
const slices = getSelectedSlices();
if (!Object.values(slices).some(Boolean)) {
setStatus('Enable at least one corpus slice.', 'error');
return;
}
const engine = getEngine();
const additionalNotes = (els.notes ? els.notes.value : '').trim();
const expectedDuration = engine === 'azure_full'
? '90180 seconds with Azure gpt-4o'
: (engine === 'gpu' ? '4590 seconds on GPU'
: (engine === 'dbn_legal' ? '60120 seconds with Norwegian specialist'
: '3060 seconds with Azure gpt-4o-mini'));
setStatus(`Analysing document for ${advocateRole}… (${expectedDuration})`, 'busy');
els.runButton.disabled = true;
// Clear results area but leave room for progressive renders
els.results.innerHTML = `<div class="empty-state"><h3>Analysing…</h3><p>Document classification, party extraction, and timeline are running. Legal corpus retrieval and advocacy synthesis follow. Expect ${expectedDuration}.</p></div>`;
const stepState = STEP_LABELS.map((label) => ({ label, detail: 'Queued', status: 'idle' }));
renderTrace(stepState);
const payload = {
advocate_role: advocateRole,
engine,
language: lang,
slices,
controls: getControls(),
additional_notes: additionalNotes,
use_my_case: (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false,
};
const _bvjDocIds = (document.getElementById('docPickerIds')?.value || '').split(',').map(Number).filter(Boolean);
if (_bvjDocIds.length) payload.doc_ids = _bvjDocIds;
if (branchContext) {
payload.prior_context = branchContext;
payload.branch_notes = (els.branchNotes ? els.branchNotes.value : '').trim();
}
// Always multipart — files are required
const form = new FormData();
form.append('payload', JSON.stringify(payload));
uploadFiles.forEach((f) => form.append('files[]', f));
let response;
try {
response = await fetch('api/barnevernet.php', { method: 'POST', body: form, credentials: 'same-origin' });
} catch (err) {
setStatus(`Network error: ${err.message || err}`, 'error');
els.runButton.disabled = false;
stepState[0] = { ...stepState[0], status: 'error', detail: String(err) };
renderTrace(stepState);
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;
// Track whether progressive sections have been rendered
let docMetaRendered = false;
let partiesRendered = false;
let timelineRendered = 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(`Running… engine=${evt.engine}, files=${evt.file_count || 0}`, '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_meta') {
if (!docMetaRendered) {
renderDocMetaIntoResults(evt.result || {});
docMetaRendered = true;
}
return;
}
if (evt.event === 'parties') {
if (!partiesRendered && Array.isArray(evt.parties)) {
renderPartiesIntoResults(evt.parties);
partiesRendered = true;
}
return;
}
if (evt.event === 'timeline') {
if (!timelineRendered && Array.isArray(evt.events)) {
renderTimelineIntoResults(evt.events);
timelineRendered = true;
}
return;
}
if (evt.event === 'subq') {
setStatus(`Retrieving sub-question ${evt.index}/${evt.total}: ${String(evt.question || '').slice(0, 80)}${String(evt.question || '').length > 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 in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${meta.source_count || 0} sources · confidence ${meta.citation_confidence || '?'}`,
'ok'
);
els.runButton.disabled = false;
renderTrace(finalResult.trace || []);
renderFinalResults(finalResult);
if (typeof window.dbnShowSaveResultButton === 'function') {
window.dbnShowSaveResultButton(
finalResult.tool || 'barnevernet',
payload,
finalResult,
{ model: (finalResult.trace_metadata || {}).deployment || null, latency_ms: finalResult.latency_ms || 0 }
);
}
}
// ── Progressive rendering (renders as stream events arrive) ────────────────
function ensureResultsReady() {
// If the empty-state is still shown, clear it for progressive inserts
const emptyState = els.results.querySelector('.empty-state');
if (emptyState) emptyState.remove();
}
function renderDocMetaIntoResults(meta) {
ensureResultsReady();
const existing = els.results.querySelector('#bvjDocMetaSection');
if (existing) existing.remove();
const docType = meta.doc_type || 'BVJ Document';
const docDate = meta.doc_date || '';
const authority = meta.issuing_authority || '';
const refNo = meta.reference_number || '';
const childInfo = meta.child_info || '';
const fields = [
docDate ? ['Date', docDate] : null,
authority ? ['Issuing authority', authority] : null,
refNo ? ['Reference', refNo] : null,
childInfo ? ['Child', childInfo] : null,
].filter(Boolean);
const section = document.createElement('div');
section.id = 'bvjDocMetaSection';
section.className = 'bvj-doc-meta';
section.innerHTML = `
<div class="bvj-doc-meta__head">
<span class="bvj-doc-meta__authority">${escapeHtml(authority || docType)}</span>
<span class="bvj-doc-type-badge">${escapeHtml(docType)}</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>` : ''}
`;
els.results.insertBefore(section, els.results.firstChild);
}
function renderPartiesIntoResults(parties) {
ensureResultsReady();
const existing = els.results.querySelector('#bvjPartiesSection');
if (existing) existing.remove();
if (!parties.length) return;
const roleClass = (role) => {
const r = (role || '').toLowerCase();
if (r.includes('bvv') || r.includes('barnevern') || r.includes('saksbehandler') || r.includes('casework') || r.includes('melder')) return 'bvj-party-role--bvv';
if (r.includes('mother') || r.includes('mor') || r.includes('father') || r.includes('far') || r.includes('parent') || r.includes('foreldre') || r.includes('foster')) return 'bvj-party-role--parent';
if (r.includes('child') || r.includes('barn')) return 'bvj-party-role--child';
if (r.includes('third') || r.includes('tredje') || r.includes('politi') || r.includes('police')) return 'bvj-party-role--third';
return 'bvj-party-role--other';
};
const section = document.createElement('div');
section.id = 'bvjPartiesSection';
section.className = 'dr-result-block';
section.innerHTML = `
<h3 style="margin:0 0 10px;font-size:0.95rem;color:var(--ink)">Parties identified (${parties.length})</h3>
<div class="bvj-parties-grid">
${parties.map((p) => `
<div class="bvj-party-card">
<span class="bvj-party-role ${roleClass(p.role || '')}">${escapeHtml(p.role || 'Unknown')}</span>
<div class="bvj-party-card__name">${escapeHtml(p.name || '—')}</div>
${p.organization ? `<div class="bvj-party-card__org">${escapeHtml(p.organization)}</div>` : ''}
${p.relationship_to_child ? `<div class="bvj-party-card__rel">${escapeHtml(p.relationship_to_child)}</div>` : ''}
</div>
`).join('')}
</div>
`;
// Insert after doc meta
const docMeta = els.results.querySelector('#bvjDocMetaSection');
if (docMeta && docMeta.nextSibling) {
els.results.insertBefore(section, docMeta.nextSibling);
} else {
els.results.appendChild(section);
}
}
function renderTimelineIntoResults(events) {
ensureResultsReady();
const existing = els.results.querySelector('#bvjTimelineSection');
if (existing) existing.remove();
if (!events.length) return;
const sigClass = (sig) => `bvj-timeline-event--${sig === 'high' ? 'high' : (sig === 'medium' ? 'medium' : 'low')}`;
const section = document.createElement('div');
section.id = 'bvjTimelineSection';
section.className = 'dr-result-block';
section.innerHTML = `
<h3 style="margin:0 0 10px;font-size:0.95rem;color:var(--ink)">Timeline (${events.length} events)</h3>
<div class="bvj-timeline-wrap">
${events.map((ev) => {
const sig = ev.significance || 'low';
const timeStr = ev.time_of_day ? `<br>${escapeHtml(ev.time_of_day)}` : '';
return `<div class="bvj-timeline-event ${sigClass(sig)}">
<div class="bvj-timeline-date">${escapeHtml(ev.date || '?')}${timeStr}</div>
<div class="bvj-timeline-body">
<div class="bvj-timeline-actor">${escapeHtml(ev.actor || '')}</div>
<div class="bvj-timeline-action">${escapeHtml(ev.action || '')}</div>
</div>
</div>`;
}).join('')}
</div>
`;
// Insert after parties section (or doc meta if no parties)
const parties = els.results.querySelector('#bvjPartiesSection');
const docMeta = els.results.querySelector('#bvjDocMetaSection');
const anchor = parties || docMeta;
if (anchor && anchor.nextSibling) {
els.results.insertBefore(section, anchor.nextSibling);
} else {
els.results.appendChild(section);
}
}
// ── Final render (after stream completes) ──────────────────────────────────
function renderFinalResults(data) {
const sources = data.sources || [];
const subs = data.sub_questions || [];
const role = data.advocate_role || '';
const redFlags = Array.isArray(data.procedural_red_flags) ? data.procedural_red_flags : [];
const strengths = Array.isArray(data.client_strengths) ? data.client_strengths : [];
const weaknesses = Array.isArray(data.opposing_weaknesses) ? data.opposing_weaknesses : [];
// Remove any previously rendered progressive sections (will be re-inserted in order below)
const toRemove = ['#bvjDocMetaSection', '#bvjPartiesSection', '#bvjTimelineSection'];
toRemove.forEach((sel) => els.results.querySelector(sel)?.remove());
// Rebuild progressive sections from final data (authoritative)
const docMeta = data.doc_meta || {};
const parties = data.parties || [];
const timeline = data.timeline || {};
// Re-render progressive sections now that we have final data
renderDocMetaIntoResults(docMeta);
if (parties.length) renderPartiesIntoResults(parties);
if ((timeline.events || []).length) renderTimelineIntoResults(timeline.events);
// 4. Advocate banner
const bannerHtml = role ? `
<div class="bvj-banner">
<span class="bvj-banner__label">Representing</span>
<strong class="bvj-banner__role">${escapeHtml(role)}</strong>
</div>` : '';
// 5. Client strengths
const strengthsHtml = strengths.length ? `
<div class="dr-result-block adv-strengths">
<h3 class="adv-strengths__head">Your strongest arguments</h3>
<ul class="adv-strengths__list">
${strengths.map((s) => `<li class="adv-strengths__item">${renderInlineCitations(escapeHtml(String(s)), sources)}</li>`).join('')}
</ul>
</div>` : '';
// 6. Advocacy brief
const briefHtml = renderBrief(data.advocacy_brief || '', sources);
// 7. Procedural red flags
const redFlagsHtml = redFlags.length ? `
<div class="dr-result-block">
<h3 style="margin:0 0 10px;font-size:0.95rem;color:var(--ink)">Procedural red flags (${redFlags.length})</h3>
<div class="bvj-red-flags">
${redFlags.map((f) => renderRedFlag(f, sources)).join('')}
</div>
</div>` : '';
// 8. Opposing weaknesses
const weaknessesHtml = weaknesses.length ? `
<div class="dr-result-block adv-weaknesses">
<h3 class="adv-weaknesses__head">Gaps in the opposing position</h3>
<ul class="adv-weaknesses__list">
${weaknesses.map((w) => `<li class="adv-weaknesses__item">${renderInlineCitations(escapeHtml(String(w)), sources)}</li>`).join('')}
</ul>
</div>` : '';
// 9. Sub-Q cards
const subQReportsHtml = subs.length ? `
<div class="dr-result-block">
<div class="dr-sources-head">
<h3>What each sub-question researched</h3>
<small>${subs.length} sub-question${subs.length === 1 ? '' : 's'} framed for ${escapeHtml(role || 'your client')}</small>
</div>
<div class="dr-subq-list">
${subs.map((sq, i) => renderSubQReport(sq, i)).join('')}
</div>
</div>` : '';
// 10. Sources
const sourcesHtml = sources.length ? `
<div class="dr-result-block">
<div class="dr-sources-head">
<h3>All sources (${sources.length})</h3>
<small>Click a card to see the full source · external link opens the original article</small>
</div>
<div class="dr-source-list">
${sources.map((s) => renderSourceCard(s)).join('')}
</div>
</div>` : '';
// 11. Uncertainty + next step
const uncertHtml = (data.what_remains_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">
${(data.what_remains_uncertain || []).map((u) => `<li>${escapeHtml(String(u))}</li>`).join('')}
</ul>
</div>` : '';
const nextHtml = data.next_practical_step ? `
<div class="dr-result-block">
<h3 style="margin:0 0 6px;font-size:0.95rem">Next practical step</h3>
<p style="margin:0;color:var(--ink);line-height:1.5">${escapeHtml(data.next_practical_step)}</p>
</div>` : '';
// Append final sections after the progressive sections
const finalHtml = `
${bannerHtml}
${strengthsHtml}
<div class="dr-result-block">
<h3 style="margin:0 0 10px;font-size:1rem">Advocacy brief</h3>
<div class="dr-brief">${briefHtml}</div>
</div>
${redFlagsHtml}
${weaknessesHtml}
${subQReportsHtml}
${sourcesHtml}
${uncertHtml}
${nextHtml}
`;
// Append to results (after the progressive sections already in place)
const finalContainer = document.createElement('div');
finalContainer.innerHTML = finalHtml;
while (finalContainer.firstChild) {
els.results.appendChild(finalContainer.firstChild);
}
// Save-to-corpus button
const briefEl = els.results.querySelector('.dr-brief');
if (briefEl) {
briefEl.id = 'bvjBriefText';
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'js-save-corpus secondary-button';
saveBtn.dataset.tool = 'barnevernet';
saveBtn.dataset.contentId = 'bvjBriefText';
saveBtn.dataset.suggestedTitle = 'BVJ analyse: ' + (document.getElementById('bvjQuestion')?.value?.slice(0, 80) ?? 'Svar');
saveBtn.textContent = 'Save to corpus';
saveBtn.style.marginTop = '12px';
briefEl.insertAdjacentElement('afterend', saveBtn);
}
// 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); flashSource(n); }
});
});
els.results.querySelectorAll('.dr-cite[data-source-n]').forEach((node) => {
node.addEventListener('click', (e) => {
if (e.target.closest('a')) return;
flashSource(parseInt(node.dataset.sourceN, 10));
});
});
}
// ── Component renderers ────────────────────────────────────────────────────
function renderRedFlag(flag, sources) {
const severity = flag.severity || 'low';
const sevClass = `bvj-severity-${severity}`;
const legal = flag.legal_basis || '';
const what = flag.what_to_check || '';
return `<div class="bvj-red-flag">
<div class="bvj-red-flag__head">
<div class="bvj-red-flag__desc">${renderInlineCitations(escapeHtml(flag.description || ''), sources)}</div>
<span class="bvj-severity ${sevClass}">${escapeHtml(severity)}</span>
</div>
${legal ? `<span class="bvj-red-flag__legal">${escapeHtml(legal)}</span>` : ''}
${what ? `<details class="bvj-red-flag__details"><summary>What to verify</summary><p class="bvj-red-flag__check">${escapeHtml(what)}</p></details>` : ''}
</div>`;
}
function renderSubQReport(sq, idx) {
const top = sq.top_sources || [];
const sourceItems = top.length
? top.map((s) => {
const link = s.deep_link || s.source_url;
const titleHtml = link
? `<a href="${escapeHtml(link)}" target="_blank" rel="noopener" class="dr-mini-source__title">${escapeHtml(s.title || 'Untitled')} <span class="dr-external-link" aria-hidden="true">↗</span></a>`
: `<span class="dr-mini-source__title">${escapeHtml(s.title || 'Untitled')}</span>`;
const meta = [];
if (s.section) meta.push(escapeHtml(s.section));
if (s.authority_label) meta.push(escapeHtml(s.authority_label));
if (s.source_origin === 'upload') meta.push('your upload');
return `<li class="dr-mini-source">
<span class="dr-mini-source__n">[${s.n ?? '?'}]</span>
<div class="dr-mini-source__body">
${titleHtml}
${meta.length ? `<div class="dr-mini-source__meta">${meta.join(' · ')}</div>` : ''}
<div class="dr-mini-source__excerpt">${escapeHtml(truncate(s.excerpt || '', 180))}</div>
</div>
</li>`;
}).join('')
: `<li class="dr-mini-source dr-mini-source--empty"><em>No sources retrieved for this sub-question.</em></li>`;
return `<div class="dr-subq-report">
<div class="dr-subq-report__head">
<span class="dr-subq-report__index">${escapeHtml(sq.id || ('q' + (idx + 1)))}</span>
<div class="dr-subq-report__body">
<div class="dr-subq-report__question">${escapeHtml(sq.question || '')}</div>
${sq.rationale ? `<div class="dr-subq-report__rationale">${escapeHtml(sq.rationale)}</div>` : ''}
</div>
<button class="dr-branch-btn" type="button" data-question="${escapeHtml(sq.question || '')}" aria-label="Branch research from this sub-question">Branch ↓</button>
</div>
<ul class="dr-mini-source-list">${sourceItems}</ul>
</div>`;
}
function renderSourceCard(s) {
const score = s.reranker_score != null ? s.reranker_score : s.similarity;
const originTagClass = s.source_origin === 'upload' ? 'dr-source-tag dr-source-tag--upload' : 'dr-source-tag';
const originLabel = s.source_origin === 'upload' ? 'upload' : 'corpus';
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="${originTagClass}">${originLabel}</span>
${s.authority_label ? `<span class="dr-source-tag">${escapeHtml(s.authority_label)}</span>` : ''}
<span class="dr-source-tag dr-source-tag--score">${escapeHtml(s.package_or_corpus || '—')}</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>
${s.reranker_score != null && s.similarity != null ? `<span>sim<br><b>${Number(s.similarity).toFixed(2)}</b></span>` : ''}
</div>
</div>`;
}
// ── 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) {
els.status.textContent = message;
els.status.style.color = kind === 'error' ? '#b41e1e' : (kind === 'ok' ? 'var(--teal-dark)' : 'var(--muted)');
}
function flashSource(n) {
document.querySelectorAll('.dr-source-card.is-highlight').forEach((c) => c.classList.remove('is-highlight'));
const target = document.querySelector(`.dr-source-card[data-source-n="${n}"]`);
if (target) {
target.classList.add('is-highlight');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => target.classList.remove('is-highlight'), 1800);
}
}
function renderBrief(markdown, sources) {
if (!markdown) return '<p><em>No brief was returned.</em></p>';
const escaped = escapeHtml(markdown);
const withCites = escaped.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('');
});
// Also mark [DOC] references
const withDoc = withCites.replace(/\[DOC\]/g, '<span class="dr-cite dr-cite--doc" title="Cited from uploaded document">DOC</span>');
const withBold = withDoc
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, '$1<em>$2</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
const paragraphs = withBold.split(/\n{2,}/).map((p) => {
const t = p.trim();
if (!t) return '';
if (/^## /.test(t)) return `<h3 style="margin:16px 0 6px;color:var(--ink);font-size:1rem">${t.replace(/^## /, '')}</h3>`;
if (/^### /.test(t)) return `<h4 style="margin:12px 0 4px;color:var(--ink);font-size:0.95rem">${t.replace(/^### /, '')}</h4>`;
return `<p>${t.replace(/\n/g, '<br>')}</p>`;
}).join('');
return paragraphs;
}
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) {
const a = parseInt(range[1], 10);
const b = parseInt(range[2], 10);
for (let i = a; i <= b; 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) return '';
if (s.length <= n) return s;
return s.slice(0, n - 1) + '…';
}
})();