/* 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' }; // All user-facing chrome strings, localized. Drafts themselves stay in NO + working lang. const I18N = { pick_recipient: { en: 'Pick a recipient body before drafting.', no: 'Velg en mottaker før utkast.', pl: 'Wybierz odbiorcę przed sporządzeniem projektu.', uk: 'Виберіть отримувача перед чернеткою.' }, initiate_narrative: { en: 'Describe the situation in "What happened" first.', no: 'Beskriv situasjonen i "Hva skjedde" først.', pl: 'Najpierw opisz sytuację w polu "Co się stało".', uk: 'Спочатку опишіть ситуацію у полі "Що сталося".' }, reply_no_input: { en: 'Reply mode needs the received letter (upload or paste it).', no: 'Svarmodus krever det mottatte brevet (last opp eller lim inn).', pl: 'Tryb odpowiedzi wymaga otrzymanego pisma (prześlij lub wklej).', uk: 'Режим відповіді потребує отриманого листа (завантажте або вставте).' }, analyzing: { en: 'Analyzing…', no: 'Analyserer…', pl: 'Analiza…', uk: 'Аналізую…' }, refining: { en: 'Refining…', no: 'Skriver om…', pl: 'Przepisuję…', uk: 'Переписую…' }, started: { en: 'Started — {body} / {output}', no: 'Startet — {body} / {output}', pl: 'Rozpoczęto — {body} / {output}', uk: 'Розпочато — {body} / {output}' }, fetched_sources: { en: 'Fetched {n} sources for {acts}…', no: 'Hentet {n} kilder for {acts}…', pl: 'Pobrano {n} źródeł dla {acts}…', uk: 'Завантажено {n} джерел для {acts}…' }, need_clarify: { en: 'Need clarification before drafting.', no: 'Trenger avklaring før utkast.', pl: 'Wymaga wyjaśnienia przed sporządzeniem projektu.', uk: 'Потрібне уточнення перед чернеткою.' }, network_error: { en: 'Network error: {msg}', no: 'Nettverksfeil: {msg}', pl: 'Błąd sieci: {msg}', uk: 'Помилка мережі: {msg}' }, stream_error: { en: 'Stream error: {msg}', no: 'Strømfeil: {msg}', pl: 'Błąd strumienia: {msg}', uk: 'Помилка потоку: {msg}' }, request_failed: { en: 'Request failed ({status}).', no: 'Forespørselen mislyktes ({status}).', pl: 'Żądanie nie powiodło się ({status}).', uk: 'Запит не вдалося ({status}).' }, stream_no_draft: { en: 'Stream ended without a draft.', no: 'Strømmen sluttet uten utkast.', pl: 'Strumień zakończył się bez projektu.', uk: 'Потік завершився без чернетки.' }, done_summary: { en: 'Done in {s} s · {n} cited source(s)', no: 'Ferdig på {s} s · {n} sitert(e) kilde(r)', pl: 'Gotowe w {s} s · {n} cytowanych źródeł', uk: 'Готово за {s} с · {n} цитованих джерел' }, refining_status: { en: 'Refining draft with {jur} authorities…', no: 'Skriver om utkast med {jur}-kilder…', pl: 'Przepisuję projekt ze źródłami {jur}…', uk: 'Переписую чернетку з джерелами {jur}…' }, refining_short: { en: 'Refining ({jur})…', no: 'Skriver om ({jur})…', pl: 'Przepisuję ({jur})…', uk: 'Переписую ({jur})…' }, refined_summary: { en: 'Refined in {s} s · {n} cited authority(ies) · {jur}', no: 'Omskrevet på {s} s · {n} sitert(e) rettskilde(r) · {jur}', pl: 'Przepisano w {s} s · {n} cytowanych autorytetów · {jur}', uk: 'Переписано за {s} с · {n} цитованих джерел · {jur}' }, refine_failed: { en: 'Refine failed ({status}).', no: 'Omskriving mislyktes ({status}).', pl: 'Przepisanie nie powiodło się ({status}).', uk: 'Переписування не вдалося ({status}).' }, refine_no_result: { en: 'Refine stream ended without a result.', no: 'Strømmen sluttet uten resultat.', pl: 'Strumień zakończył się bez wyniku.', uk: 'Потік завершився без результату.' }, refine_fetched: { en: 'Fetched {n} authorities for {jur}…', no: 'Hentet {n} rettskilder for {jur}…', pl: 'Pobrano {n} autorytetów dla {jur}…', uk: 'Завантажено {n} джерел для {jur}…' }, working: { en: 'Working…', no: 'Arbeider…', pl: 'Pracuję…', uk: 'Працюю…' }, working_long: { en: 'Pass 1 extracts facts. If anything is missing we will ask. Then Pass 2 runs hard-RAG retrieval, draft, self-check, and translation.', no: 'Pass 1 henter fakta. Hvis noe mangler spør vi. Deretter kjører Pass 2: kilder, utkast, kontroll og oversettelse.', pl: 'Pass 1 wyodrębnia fakty. Jeśli czegoś brak, zapytamy. Następnie Pass 2: źródła, projekt, kontrola, tłumaczenie.', uk: 'Pass 1 витягує факти. Якщо чогось бракує, ми спитаємо. Потім Pass 2: джерела, чернетка, перевірка, переклад.' }, no_cited: { en: 'No cited law sources — draft is plain-language (no § references available from corpus).', no: 'Ingen siterte lovkilder — utkastet er på vanlig språk (ingen §-henvisninger fra korpus).', pl: 'Brak cytowanych źródeł prawa — projekt w zwykłym języku (brak odniesień § z korpusu).', uk: 'Немає цитованих джерел права — чернетка звичайною мовою (без § посилань з корпусу).' }, no_draft_yet: { en: 'No draft to refine. Run a draft first.', no: 'Ingen utkast å skrive om. Lag et utkast først.', pl: 'Brak projektu do przepisania. Najpierw stwórz projekt.', uk: 'Немає чернетки для переписування. Спочатку створіть чернетку.' }, // Section/header chrome ready_title: { en: 'Ready', no: 'Klar', pl: 'Gotowe', uk: 'Готовий' }, ready_desc: { en: 'Pick a recipient body, describe the situation, choose an output type and tone, then run. Drafts always come back in Norwegian bokmål + your working language, side-by-side, with verified law citations.', no: 'Velg en mottaker, beskriv situasjonen, velg utdataformat og tone, og kjør. Utkast leveres alltid på norsk bokmål + arbeidsspråket, side ved side, med verifiserte lovhenvisninger.', pl: 'Wybierz odbiorcę, opisz sytuację, wybierz format i ton, uruchom. Projekty zawsze zwracane są po norwesku (bokmål) + w Twoim języku, obok siebie, ze zweryfikowanymi cytatami prawa.', uk: 'Виберіть отримувача, опишіть ситуацію, виберіть формат і тон, запустіть. Чернетки повертаються норвезькою (bokmål) + вашою мовою, поряд, з перевіреними посиланнями на закон.' }, summary_pass1: { en: 'Summary (Pass 1)', no: 'Sammendrag (Pass 1)', pl: 'Podsumowanie (Pass 1)', uk: 'Резюме (Pass 1)' }, assumed_legal: { en: 'Presumed legal basis:', no: 'Antatt rettslig grunnlag:', pl: 'Domniemana podstawa prawna:', uk: 'Передбачувана правова основа:' }, deadlines_label: { en: 'Deadlines:', no: 'Frister:', pl: 'Terminy:', uk: 'Терміни:' }, canonical: { en: 'canonical', no: 'kanonisk', pl: 'kanoniczny', uk: 'канонічний' }, reference: { en: 'reference', no: 'referanse', pl: 'referencja', uk: 'довідка' }, refined_label: { en: 'refined', no: 'omskrevet', pl: 'przepisany', uk: 'переписаний' }, copy: { en: 'Copy', no: 'Kopier', pl: 'Kopiuj', uk: 'Копіювати' }, copied: { en: 'Copied ✓', no: 'Kopiert ✓', pl: 'Skopiowano ✓', uk: 'Скопійовано ✓' }, download_txt: { en: 'Download .txt', no: 'Last ned .txt', pl: 'Pobierz .txt', uk: 'Завантажити .txt' }, cited_law_n: { en: 'Cited law ({n})', no: 'Siterte kilder ({n})', pl: 'Cytowane prawo ({n})', uk: 'Цитоване право ({n})' }, cited_law_hint: { en: 'each reference traces to a real corpus passage', no: 'hver henvisning kan spores til en reell korpuspassasje', pl: 'każde odniesienie pochodzi z prawdziwego fragmentu korpusu', uk: 'кожне посилання простежується до реального уривка корпусу' }, cited_authorities_n:{ en: 'Cited authorities ({n})', no: 'Siterte rettskilder ({n})', pl: 'Cytowane autorytety ({n})', uk: 'Цитовані джерела ({n})' }, view_source: { en: 'View source', no: 'Se kilde', pl: 'Zobacz źródło', uk: 'Переглянути джерело' }, flag_cites: { en: 'Citations verified', no: 'Henvisninger verifisert', pl: 'Cytaty zweryfikowane', uk: 'Цитати перевірено' }, flag_deadline: { en: 'Deadline', no: 'Frist', pl: 'Termin', uk: 'Термін' }, flag_goal: { en: 'Goal addressed', no: 'Mål adressert', pl: 'Cel uwzględniony', uk: 'Мета врахована' }, flag_tone: { en: 'Tone', no: 'Tone', pl: 'Ton', uk: 'Тон' }, refine_title: { en: 'Refine with formal citations', no: 'Skriv om med formelle henvisninger', pl: 'Przepisz z formalnymi cytatami', uk: 'Переписати з формальними цитатами' }, one_extra_credit: { en: '(+1 credit)', no: '(+1 kreditt)', pl: '(+1 kredyt)', uk: '(+1 кредит)' }, refine_hint: { en: '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.', no: 'Valgfri 2. omgang: hent ferske rettskilder, skriv om henvisningene formelt ("jf. forvaltningsloven § 17", "jf. Strand Lobben m.fl. mot Norge, EMD-37283/13, §§ 207–214"), og legg ved en Rettskilder-blokk nederst.', pl: 'Opcjonalna 2. tura: pobierz świeże autorytety, przepisz cytaty w stylu formalnym, dołącz blok Rettskilder na końcu.', uk: 'Опційний 2-й прохід: завантажити свіжі джерела, переписати цитати у формальному стилі, додати блок Rettskilder в кінці.' }, jur_norwegian: { en: 'Norwegian law only', no: 'Kun norsk rett', pl: 'Tylko prawo norweskie', uk: 'Лише норвезьке право' }, jur_echr: { en: 'ECHR (EMK + HUDOC)', no: 'EMK (EMK + HUDOC)', pl: 'EKPC (EMK + HUDOC)', uk: 'ЄКПЛ (EMK + HUDOC)' }, jur_both: { en: 'Both', no: 'Begge', pl: 'Oba', uk: 'Обидва' }, refine_btn: { en: 'Refine citations', no: 'Skriv om henvisninger', pl: 'Przepisz cytaty', uk: 'Переписати цитати' }, refine_btn_busy: { en: 'Refining…', no: 'Skriver om…', pl: 'Przepisuję…', uk: 'Переписую…' }, // Clarify chrome clarify_title: { en: 'Before we draft, clarify:', no: 'Før vi skriver, avklar:', pl: 'Zanim sporządzimy projekt, wyjaśnij:', uk: 'Перш ніж писати, уточніть:' }, clarify_hint: { en: 'Answer what you can, then click Continue draft. Or click Draft anyway to proceed with what we have.', no: 'Svar på det du kan, og klikk Fortsett utkast. Eller klikk Skriv likevel for å fortsette med det vi har.', pl: 'Odpowiedz, co możesz, i kliknij Kontynuuj projekt. Albo kliknij Mimo to napisz, aby kontynuować z tym, co mamy.', uk: 'Дайте відповідь, що можете, потім натисніть Продовжити. Або натисніть Все одно написати, щоб продовжити з тим, що є.' }, clarify_continue: { en: 'Continue draft', no: 'Fortsett utkast', pl: 'Kontynuuj projekt', uk: 'Продовжити' }, clarify_force: { en: 'Draft anyway', no: 'Skriv likevel', pl: 'Mimo to napisz', uk: 'Все одно написати' }, optional: { en: '(optional)', no: '(valgfritt)', pl: '(opcjonalnie)', uk: '(необов’язково)' }, // Bracket label inside drafts column header — value-only (the language NAME stays its own name) no_column: { en: 'Norsk (bokmål)', no: 'Norsk (bokmål)', pl: 'Norsk (bokmål)', uk: 'Norsk (bokmål)' }, }; function t(key, vars) { const m = I18N[key]; if (!m) return key; let s = m[lang] || m.en || key; if (vars) for (const k in vars) s = s.replaceAll('{' + k + '}', String(vars[k])); return s; } 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(); applyStaticI18n(); els.form.addEventListener('submit', (e) => { e.preventDefault(); runRequest(false); }); }); // Apply [data-i18n] attributes on static DOM (and re-apply after lang switch). function applyStaticI18n() { document.querySelectorAll('[data-i18n]').forEach((el) => { const key = el.getAttribute('data-i18n'); if (key) el.textContent = t(key); }); } // ── 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); applyStaticI18n(); }); }); } 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()); const korrDocIds = (document.getElementById('docPickerIds')?.value || '').split(',').map(Number).filter(Boolean); const payload = { 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, use_my_case: (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false, engine: (document.querySelector('[name="korrEngine"]:checked')?.value ?? 'azure_mini'), }; if (korrDocIds.length) payload.doc_ids = korrDocIds; return payload; } async function runRequest(forceDraft) { const payload = buildPayload(forceDraft); if (!payload.recipient_body) { setStatus(t('pick_recipient'), 'error'); return; } if (payload.mode === 'initiate' && !payload.narrative) { setStatus(t('initiate_narrative'), 'error'); return; } if (payload.mode === 'reply' && !uploadFiles.length && !payload.narrative) { setStatus(t('reply_no_input'), 'error'); return; } setStatus(t('analyzing'), 'busy'); els.runButton.disabled = true; els.results.innerHTML = `

    ${esc(t('working'))}

    ${esc(t('working_long'))}

    `; 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(t('network_error', { msg: 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(t('request_failed', { status: 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(t('stream_error', { msg: 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 || t('working'), 'busy'); continue; } if (evt.event === 'start') { setStatus(t('started', { body: evt.body, output: evt.output_type }), 'busy'); continue; } if (evt.event === 'classify') { lastClassify = evt.result; renderClassifySummary(evt.result); continue; } if (evt.event === 'retrieval'){ setStatus(t('fetched_sources', { n: evt.sources_count, acts: (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(t('need_clarify'), 'busy'); showClarify(clarifyEvent.questions); return; } if (!finalResult) { setStatus(t('stream_no_draft'), 'error'); return; } setStatus(t('done_summary', { s: Math.round((finalResult.latency_ms || 0) / 1000), n: (finalResult.cited_law || []).length }), 'ok'); if (typeof finalResult.balance === 'number' && typeof window.dbnUpdateCredits === 'function') { window.dbnUpdateCredits(finalResult.balance); } 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(t('no_draft_yet'), 'error'); return; } const jurLabel = jurisdiction === 'echr' ? t('jur_echr') : jurisdiction === 'both' ? t('jur_both') : t('jur_norwegian'); const refineBtn = document.getElementById('korrRefineBtn'); if (refineBtn) { refineBtn.disabled = true; refineBtn.textContent = t('refine_btn_busy'); } setStatus(t('refining_status', { jur: jurLabel }), '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(t('network_error', { msg: err.message || err }), 'error'); if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); } 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(t('refine_failed', { status: response.status }), 'error'); } if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); } 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(t('stream_error', { msg: err.message || err }), 'error'); if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); } 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 || t('refining'), 'busy'); continue; } if (evt.event === 'start') { setStatus(t('refining_short', { jur: jurLabel }), 'busy'); continue; } if (evt.event === 'retrieval') { setStatus(t('refine_fetched', { n: evt.sources_count, jur: jurLabel }), '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 = t('refine_btn'); } if (errorEvent) { setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error'); return; } if (!finalResult) { setStatus(t('refine_no_result'), 'error'); return; } setStatus(t('refined_summary', { s: Math.round((finalResult.latency_ms || 0) / 1000), n: (finalResult.cited_law || []).length, jur: jurLabel }), 'ok'); if (typeof finalResult.balance === 'number' && typeof window.dbnUpdateCredits === 'function') { window.dbnUpdateCredits(finalResult.balance); } 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 = `

    ${esc(t('summary_pass1'))}

    ${esc(c.summary)}

    ${c.applicable_acts && c.applicable_acts.length ? `

    ${esc(t('assumed_legal'))} ${c.applicable_acts.map(esc).join(', ')}

    ` : ''} ${c.deadlines && c.deadlines.length ? `

    ${esc(t('deadlines_label'))} ${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', t('flag_cites'))} ${flagBadge('deadline_mentioned', t('flag_deadline'))} ${flagBadge('goal_addressed', t('flag_goal'))} ${flagBadge('tone', t('flag_tone'))}

    ${esc(t('no_column'))} — ${esc(t('canonical'))}

    ${esc(draftNo)}
    ${isSameLang ? '' : `

    ${esc(userLangLabel)} — ${esc(t('reference'))}

    ${esc(draftUser)}
    `}
    ${cited.length ? `
    ${esc(t('cited_law_n', { n: cited.length }))} — ${esc(t('cited_law_hint'))}
    ${cited.map((s) => `
    [${s.n}] ${esc(s.title)}${s.section ? ' — ' + esc(s.section) : ''}

    ${esc(s.excerpt || '')}

    ${s.source_url ? `${esc(t('view_source'))}` : ''}
    `).join('')}
    ` : `

    ${esc(t('no_cited'))}

    `} ${data.disclaimer ? `

    ${esc(data.disclaimer)}

    ` : ''} ${(data.legal_check && data.legal_check.length) ? ` ` : ''}

    ${esc(t('refine_title'))} ${esc(t('one_extra_credit'))}

    ${esc(t('refine_hint'))}

    `; // 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 = t('copied'); setTimeout(() => btn.textContent = t('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' ? t('jur_echr') : data.jurisdiction === 'both' ? t('jur_both') : t('jur_norwegian'); 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 = `
    ${esc(t('refined_label'))} · ${esc(jurLabel)}
    ${flagBadge('citations_verified', t('flag_cites'))} ${flagBadge('deadline_mentioned', t('flag_deadline'))} ${flagBadge('goal_addressed', t('flag_goal'))}

    ${esc(t('no_column'))} — ${esc(t('refined_label'))}

    ${esc(draftNo)}
    ${isSameLang ? '' : `

    ${esc(userLangLabel)} — ${esc(t('refined_label'))}

    ${esc(draftUser)}
    `}
    ${cited.length ? `
    ${esc(t('cited_authorities_n', { n: 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 ? `${esc(t('view_source'))}` : ''}
    `).join('')}
    ` : ''} ${(data.legal_check && data.legal_check.length) ? ` ` : ''}
    `; slot.querySelectorAll('[data-rcopy]').forEach((btn) => { btn.addEventListener('click', () => { const target = btn.dataset.rcopy === 'no' ? draftNo : draftUser; navigator.clipboard?.writeText(target).then( () => { btn.textContent = t('copied'); setTimeout(() => btn.textContent = t('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); } })();