/* korrespond.js — page-scoped UI for /korrespond.php
Three-pass flow: Pass 1 may return clarify questions; Pass 2 returns Norwegian
+ working-language drafts with verified law citations. Pass 3 (opt-in) refines
the draft with jurisdiction-scoped formal citations + Rettskilder appendix.
*/
(function () {
'use strict';
const els = {};
let lang = window.DBN_TOOLS_LANG || localStorage.getItem('dbn-ui-lang') || 'en';
let uploadFiles = [];
let lastClassify = null;
let lastFinal = null;
let pendingClarifications = {};
const LANG_LABELS = { en: 'English', no: 'Norsk', uk: 'Українська', pl: 'Polski' };
document.addEventListener('DOMContentLoaded', () => {
if (document.body.dataset.activeTool !== 'korrespond') return;
Object.assign(els, {
form: document.getElementById('korrForm'),
status: document.getElementById('korrStatus'),
runButton: document.getElementById('korrRunButton'),
results: document.getElementById('korrResults'),
langButtons: Array.from(document.querySelectorAll('#korrLangSwitcher .lang-btn')),
modeRadios: Array.from(document.querySelectorAll('input[name="korrMode"]')),
bodySelect: document.getElementById('korrBody'),
outputRadios: Array.from(document.querySelectorAll('input[name="korrOutput"]')),
toneRadios: Array.from(document.querySelectorAll('input[name="korrTone"]')),
caseRef: document.getElementById('korrCaseRef'),
where: document.getElementById('korrWhere'),
deadline: document.getElementById('korrDeadline'),
parties: document.getElementById('korrParties'),
narrative: document.getElementById('korrNarrative'),
goal: document.getElementById('korrGoal'),
goalChips: Array.from(document.querySelectorAll('#korrGoalChips .korr-chip')),
uploadZone: document.getElementById('korrUploadZone'),
uploadInput: document.getElementById('korrUploadInput'),
uploadPrompt: document.getElementById('korrUploadPrompt'),
uploadFileInfo: document.getElementById('korrUploadFileInfo'),
uploadFileList: document.getElementById('korrUploadFileList'),
uploadClear: document.getElementById('korrUploadClear'),
clarifyPanel: document.getElementById('korrClarifyPanel'),
clarifyList: document.getElementById('korrClarifyList'),
clarifyContinue:document.getElementById('korrClarifyContinue'),
clarifyForce: document.getElementById('korrClarifyForce'),
});
if (!els.form) return;
bindLang();
bindGoalChips();
bindUpload();
bindClarify();
els.form.addEventListener('submit', (e) => { e.preventDefault(); runRequest(false); });
});
// ── Language switcher ───────────────────────────────────────────────────────
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);
});
});
}
function bindGoalChips() {
els.goalChips.forEach((chip) => {
chip.addEventListener('click', () => {
els.goal.value = chip.dataset.goal || '';
els.goalChips.forEach((c) => c.classList.remove('is-active'));
chip.classList.add('is-active');
});
});
}
// ── File upload ─────────────────────────────────────────────────────────────
function bindUpload() {
if (!els.uploadZone) return;
const onFiles = (fileList) => {
const files = Array.from(fileList || []).slice(0, 4);
if (uploadFiles.length + files.length > 4) {
setStatus('At most 4 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 `
${esc(f.name)}${kb} KB`;
}).join('');
}
// ── Clarify panel ───────────────────────────────────────────────────────────
function bindClarify() {
els.clarifyContinue?.addEventListener('click', () => {
pendingClarifications = collectClarifications();
hideClarify();
runRequest(false);
});
els.clarifyForce?.addEventListener('click', () => {
pendingClarifications = collectClarifications();
hideClarify();
runRequest(true);
});
}
function showClarify(questions) {
els.clarifyList.innerHTML = (questions || []).map((q, i) => `
`).join('');
els.clarifyPanel.classList.remove('is-hidden');
els.clarifyPanel.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function hideClarify() {
els.clarifyPanel.classList.add('is-hidden');
}
function collectClarifications() {
const inputs = els.clarifyList.querySelectorAll('input[data-key]');
const out = {};
inputs.forEach((inp) => {
const v = (inp.value || '').trim();
if (v) out[inp.dataset.key] = v;
});
return out;
}
// ── Submit ──────────────────────────────────────────────────────────────────
function getRadio(list) {
const checked = list.find((r) => r.checked);
return checked ? checked.value : '';
}
function buildPayload(forceDraft) {
const deadlines = [];
if (els.deadline.value.trim()) deadlines.push(els.deadline.value.trim());
return {
mode: getRadio(els.modeRadios) || 'initiate',
recipient_body: els.bodySelect.value || 'other',
output_type: getRadio(els.outputRadios) || 'email',
tone: getRadio(els.toneRadios) || 'neutral',
language: lang,
case_ref: els.caseRef.value.trim(),
where: els.where.value.trim(),
deadlines,
parties_text: els.parties.value.trim(),
narrative: els.narrative.value.trim(),
goal: els.goal.value.trim(),
clarifications: pendingClarifications,
force_draft: !!forceDraft,
};
}
async function runRequest(forceDraft) {
const payload = buildPayload(forceDraft);
if (!payload.recipient_body) {
setStatus('Pick a recipient body before drafting.', 'error');
return;
}
if (payload.mode === 'initiate' && !payload.narrative) {
setStatus('Describe the situation in "What happened" first.', 'error');
return;
}
if (payload.mode === 'reply' && !uploadFiles.length && !payload.narrative) {
setStatus('Reply mode needs the received letter (upload or paste it).', 'error');
return;
}
setStatus('Analyserer…', 'busy');
els.runButton.disabled = true;
els.results.innerHTML = `Working…
Pass 1 extracts facts. If anything is missing we'll ask for clarification. Otherwise Pass 2 runs hard-RAG retrieval + draft + self-check + translate.
`;
const form = new FormData();
form.append('payload', JSON.stringify(payload));
uploadFiles.forEach((f) => form.append('files[]', f));
let response;
try {
response = await fetch('api/korrespond.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;
let clarifyEvent = null;
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; }
if (!evt || !evt.event) continue;
if (evt.event === 'progress') { setStatus(evt.detail || 'Working…', 'busy'); continue; }
if (evt.event === 'start') { setStatus(`Started — ${evt.body} / ${evt.output_type}`, 'busy'); continue; }
if (evt.event === 'classify') { lastClassify = evt.result; renderClassifySummary(evt.result); continue; }
if (evt.event === 'retrieval'){ setStatus(`Hentet ${evt.sources_count} lovkilder for ${(evt.applicable_acts || []).join(', ')}…`, 'busy'); continue; }
if (evt.event === 'clarify') { clarifyEvent = evt; continue; }
if (evt.event === 'final') { finalResult = evt.result; continue; }
if (evt.event === 'error') { errorEvent = evt; continue; }
}
}
if (done) break;
}
els.runButton.disabled = false;
if (errorEvent) {
setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error');
return;
}
if (clarifyEvent) {
setStatus('Need clarification before drafting.', 'busy');
showClarify(clarifyEvent.questions);
return;
}
if (!finalResult) {
setStatus('Stream ended without a draft.', 'error');
return;
}
setStatus(`Done in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${(finalResult.cited_law || []).length} cited source(s)`, 'ok');
lastFinal = finalResult;
renderFinal(finalResult);
pendingClarifications = {}; // reset for next run
}
// ── Pass 3: refine with jurisdiction-scoped formal citations ────────────────
async function runRefine(jurisdiction) {
if (!lastFinal || !lastClassify) {
setStatus('No draft to refine. Run a draft first.', 'error');
return;
}
const refineBtn = document.getElementById('korrRefineBtn');
if (refineBtn) { refineBtn.disabled = true; refineBtn.textContent = 'Refining…'; }
setStatus(`Refining draft with ${jurisdiction} authorities…`, 'busy');
const payload = {
jurisdiction,
language: lang,
original_draft_no: lastFinal.draft_no || '',
classify: lastClassify,
intake: {
recipient_body: lastFinal.recipient_body,
output_type: lastFinal.output_type,
tone: lastFinal.tone,
goal: lastFinal.goal,
},
};
let response;
try {
response = await fetch('api/korrespond-refine.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch (err) {
setStatus(`Network error: ${err.message || err}`, 'error');
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; }
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(`Refine failed (${response.status}).`, 'error');
}
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; }
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;
while (true) {
let chunk;
try { chunk = await reader.read(); }
catch (err) {
setStatus(`Stream error: ${err.message || err}`, 'error');
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; }
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; }
if (!evt || !evt.event) continue;
if (evt.event === 'progress') { setStatus(evt.detail || 'Refining…', 'busy'); continue; }
if (evt.event === 'start') { setStatus(`Refining (${evt.jurisdiction})…`, 'busy'); continue; }
if (evt.event === 'retrieval') { setStatus(`Hentet ${evt.sources_count} rettskilder for ${evt.jurisdiction}…`, 'busy'); continue; }
if (evt.event === 'final') { finalResult = evt.result; continue; }
if (evt.event === 'error') { errorEvent = evt; continue; }
}
}
if (done) break;
}
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; }
if (errorEvent) {
setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error');
return;
}
if (!finalResult) {
setStatus('Refine stream ended without a result.', 'error');
return;
}
setStatus(`Refined in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${(finalResult.cited_law || []).length} cited authority(ies) · ${finalResult.jurisdiction}`, 'ok');
renderRefined(finalResult);
}
// ── Rendering ───────────────────────────────────────────────────────────────
function renderClassifySummary(c) {
if (!c || !c.summary) return;
let block = els.results.querySelector('#korrClassifyBlock');
if (!block) {
block = document.createElement('div');
block.id = 'korrClassifyBlock';
block.className = 'dr-result-block';
els.results.innerHTML = '';
els.results.appendChild(block);
}
block.innerHTML = `
Sammendrag (Pass 1)
${esc(c.summary)}
${c.applicable_acts && c.applicable_acts.length ? `Antatt rettslig grunnlag: ${c.applicable_acts.map(esc).join(', ')}
` : ''}
${c.deadlines && c.deadlines.length ? `Frister: ${c.deadlines.map(esc).join(', ')}
` : ''}
`;
}
function renderFinal(data) {
const userLang = data.draft_user_lang || 'en';
const userLangLabel = LANG_LABELS[userLang] || userLang.toUpperCase();
const flags = data.self_check || {};
const cited = data.cited_law || [];
const flagBadge = (key, label) => {
const v = flags[key] || 'ok';
const cls = v === 'ok' ? 'is-ok' : (v === 'warn' ? 'is-warn' : 'is-error');
const icon = v === 'ok' ? '✓' : '!';
return `${icon} ${esc(label)}`;
};
const draftNo = data.draft_no || '';
const draftUser = data.draft_user || '';
const isSameLang = userLang === 'no';
els.results.innerHTML = `
${esc(data.recipient_body || '')} · ${esc(data.output_type || '')} · ${esc(data.tone || '')}
${flagBadge('citations_verified', 'Citations verified')}
${flagBadge('deadline_mentioned', 'Deadline')}
${flagBadge('goal_addressed', 'Goal addressed')}
${flagBadge('tone', 'Tone')}
Norsk (bokmål) — kanonisk
${esc(draftNo)}
${isSameLang ? '' : `
${esc(userLangLabel)} — reference
${esc(draftUser)}
`}
${cited.length ? `
Cited law (${cited.length}) — each reference traces to a real corpus passage
${cited.map((s) => `
[${s.n}] ${esc(s.title)}${s.section ? ' — ' + esc(s.section) : ''}
${esc(s.excerpt || '')}
${s.source_url ? `
View source` : ''}
`).join('')}
` : 'No cited law sources — draft is plain-language (no § references available from corpus).
'}
${data.disclaimer ? `${esc(data.disclaimer)}
` : ''}
`;
// Wire copy/download
els.results.querySelectorAll('[data-copy]').forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.dataset.copy === 'no' ? draftNo : draftUser;
navigator.clipboard?.writeText(target).then(
() => { btn.textContent = 'Copied ✓'; setTimeout(() => btn.textContent = 'Copy', 1500); },
() => { btn.textContent = 'Failed'; }
);
});
});
els.results.querySelectorAll('[data-download]').forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.dataset.download === 'no' ? draftNo : draftUser;
const suffix = btn.dataset.download === 'no' ? 'no' : userLang;
downloadText(`korrespond-${data.recipient_body}-${suffix}.txt`, target);
});
});
// Wire refine
const refineBtn = document.getElementById('korrRefineBtn');
refineBtn?.addEventListener('click', () => {
const choice = document.querySelector('input[name="korrJurisdiction"]:checked');
runRefine(choice ? choice.value : 'norwegian');
});
}
function renderRefined(data) {
const slot = document.getElementById('korrRefinedSlot');
if (!slot) return;
const userLang = data.draft_user_lang || 'en';
const userLangLabel = LANG_LABELS[userLang] || userLang.toUpperCase();
const flags = data.self_check || {};
const cited = data.cited_law || [];
const isSameLang = userLang === 'no';
const draftNo = data.draft_no || '';
const draftUser = data.draft_user || '';
const jurLabel = data.jurisdiction === 'echr' ? 'ECHR (EMK + HUDOC)'
: data.jurisdiction === 'both' ? 'Norwegian + ECHR'
: 'Norwegian law';
const flagBadge = (key, label) => {
const v = flags[key] || 'ok';
const cls = v === 'ok' ? 'is-ok' : (v === 'warn' ? 'is-warn' : 'is-error');
const icon = v === 'ok' ? '✓' : '!';
return `${icon} ${esc(label)}`;
};
slot.innerHTML = `
Refined · ${esc(jurLabel)}
${flagBadge('citations_verified', 'Citations verified')}
${flagBadge('deadline_mentioned', 'Deadline')}
${flagBadge('goal_addressed', 'Goal addressed')}
Norsk (bokmål) — refined
${esc(draftNo)}
${isSameLang ? '' : `
${esc(userLangLabel)} — refined
${esc(draftUser)}
`}
${cited.length ? `
Cited authorities (${cited.length}) — ${esc(jurLabel)}
${cited.map((s) => `
[${s.n}] ${esc(s.title)}${s.section ? ' — ' + esc(s.section) : ''}${s.authority_type ? ' (' + esc(s.authority_type) + ')' : ''}
${esc(s.excerpt || '')}
${s.source_url ? `
View source` : ''}
`).join('')}
` : ''}
`;
slot.querySelectorAll('[data-rcopy]').forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.dataset.rcopy === 'no' ? draftNo : draftUser;
navigator.clipboard?.writeText(target).then(
() => { btn.textContent = 'Copied ✓'; setTimeout(() => btn.textContent = 'Copy', 1500); },
() => { btn.textContent = 'Failed'; }
);
});
});
slot.querySelectorAll('[data-rdownload]').forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.dataset.rdownload === 'no' ? draftNo : draftUser;
const suffix = btn.dataset.rdownload === 'no' ? 'no' : userLang;
downloadText(`korrespond-refined-${data.recipient_body}-${data.jurisdiction}-${suffix}.txt`, target);
});
});
slot.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ── utils ───────────────────────────────────────────────────────────────────
function setStatus(message, kind) {
if (!els.status) return;
els.status.textContent = message || '';
els.status.dataset.kind = kind || '';
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(//g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
function downloadText(filename, text) {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
}
})();