/* 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)}

    ` : ''}

    Refine with formal citations (+1 credit)

    Optional 2nd pass: pull fresh authorities, rewrite citations in formal style ("jf. forvaltningsloven § 17", "jf. Strand Lobben m.fl. mot Norge, EMD-37283/13, §§ 207–214"), and append a Rettskilder block at the bottom.

    `; // 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); } })();