Files
dobetternorge-tools/assets/js/korrespond.js
T
daveadmin d156f8cf6b feat(tools): persona selector across standalone tools + dashboard chat
Wire the legal-domain persona picker into corpus, deep-research, korrespond and
the dashboard chat. Each endpoint reads the chosen profile, resolves its packages
against client 57, and scopes retrieval via package_ids (falling back to family
when omitted). New dashboard tenants now subscribe to all DBN domain packages so
persona switching survives the subscription intersection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:03:31 +02:00

817 lines
50 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.
/* 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 = {};
let persona = '';
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, §§ 207214"), 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, §§ 207214"), 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'),
personaControl: document.getElementById('korrPersonaControl'),
personaSelect: document.getElementById('korrPersonaSelect'),
});
if (!els.form) return;
bindLang();
bindGoalChips();
bindUpload();
bindClarify();
loadPersonas();
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 `<li><span class="upload-filename">${esc(f.name)}</span><span class="upload-chars">${kb} KB</span></li>`;
}).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) => `
<div class="korr-clarify-item">
<label for="clarify_${i}"><strong>${esc(q.question || '')}</strong></label>
<input type="text" id="clarify_${i}" data-key="${esc(q.key || '')}" placeholder="(optional)">
</div>
`).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;
if (persona) payload.profile = persona;
return payload;
}
async function loadPersonas() {
if (!els.personaSelect) return;
try {
const r = await fetch('api/personas.php', { credentials: 'same-origin', headers: { Accept: 'application/json' } });
const data = await r.json().catch(() => ({}));
if (!r.ok || data.ok !== true || !Array.isArray(data.personas) || !data.personas.length) return;
const fallback = data.default_persona || 'family';
els.personaSelect.innerHTML = '';
data.personas.forEach((p) => {
const opt = document.createElement('option');
opt.value = p.slug;
opt.textContent = p.name || p.slug;
els.personaSelect.appendChild(opt);
});
const saved = sessionStorage.getItem('dbnPersona');
const initial = (saved && data.personas.some((p) => p.slug === saved)) ? saved
: (data.personas.some((p) => p.slug === fallback) ? fallback : data.personas[0].slug);
persona = initial;
els.personaSelect.value = initial;
els.personaSelect.addEventListener('change', () => {
persona = els.personaSelect.value;
sessionStorage.setItem('dbnPersona', persona);
});
els.personaControl?.classList.remove('is-hidden');
} catch (_) { /* personas are optional UI sugar; ignore failures */ }
}
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 = `<div class="empty-state"><h3>${esc(t('working'))}</h3><p>${esc(t('working_long'))}</p></div>`;
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 = `
<h3 style="margin:0 0 10px;font-size:0.95rem;color:var(--ink)">${esc(t('summary_pass1'))}</h3>
<p>${esc(c.summary)}</p>
${c.applicable_acts && c.applicable_acts.length ? `<p class="upload-hint"><strong>${esc(t('assumed_legal'))}</strong> ${c.applicable_acts.map(esc).join(', ')}</p>` : ''}
${c.deadlines && c.deadlines.length ? `<p class="upload-hint"><strong>${esc(t('deadlines_label'))}</strong> ${c.deadlines.map(esc).join(', ')}</p>` : ''}
`;
}
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 `<span class="korr-flag ${cls}">${icon} ${esc(label)}</span>`;
};
const draftNo = data.draft_no || '';
const draftUser = data.draft_user || '';
const isSameLang = userLang === 'no';
els.results.innerHTML = `
<div class="korr-result-head">
<span class="tool-badge">${esc(data.recipient_body || '')} · ${esc(data.output_type || '')} · ${esc(data.tone || '')}</span>
<div class="korr-flags">
${flagBadge('citations_verified', t('flag_cites'))}
${flagBadge('deadline_mentioned', t('flag_deadline'))}
${flagBadge('goal_addressed', t('flag_goal'))}
${flagBadge('tone', t('flag_tone'))}
</div>
</div>
<div class="korr-drafts ${isSameLang ? 'is-single' : ''}">
<div class="korr-draft-col">
<div class="korr-draft-head">
<h3>${esc(t('no_column'))}${esc(t('canonical'))}</h3>
<div class="korr-draft-actions">
<button type="button" class="secondary-button" data-copy="no">${esc(t('copy'))}</button>
<button type="button" class="secondary-button" data-download="no">${esc(t('download_txt'))}</button>
</div>
</div>
<pre class="korr-draft-body" id="korrDraftNo">${esc(draftNo)}</pre>
<button type="button" class="js-save-corpus secondary-button"
data-tool="korrespond"
data-content-id="korrDraftNo"
data-suggested-title="${esc((data.output_type || 'Brev') + ' — ' + (data.recipient_body || ''))}">
Save to corpus
</button>
</div>
${isSameLang ? '' : `
<div class="korr-draft-col">
<div class="korr-draft-head">
<h3>${esc(userLangLabel)}${esc(t('reference'))}</h3>
<div class="korr-draft-actions">
<button type="button" class="secondary-button" data-copy="user">${esc(t('copy'))}</button>
<button type="button" class="secondary-button" data-download="user">${esc(t('download_txt'))}</button>
</div>
</div>
<pre class="korr-draft-body" id="korrDraftUser">${esc(draftUser)}</pre>
<button type="button" class="js-save-corpus secondary-button"
data-tool="korrespond"
data-content-id="korrDraftUser"
data-suggested-title="${esc((data.output_type || 'Brev') + ' — ' + (data.recipient_body || '') + ' (translation)')}">
Save to corpus
</button>
</div>`}
</div>
${cited.length ? `
<details class="korr-cited" open>
<summary><strong>${esc(t('cited_law_n', { n: cited.length }))}</strong> — ${esc(t('cited_law_hint'))}</summary>
<div class="korr-cited-list">
${cited.map((s) => `
<div class="korr-cited-item">
<div class="korr-cited-head"><strong>[${s.n}] ${esc(s.title)}</strong>${s.section ? ' — ' + esc(s.section) : ''}</div>
<p class="korr-cited-excerpt">${esc(s.excerpt || '')}</p>
${s.source_url ? `<a href="${esc(s.source_url)}" target="_blank" rel="noopener">${esc(t('view_source'))}</a>` : ''}
</div>
`).join('')}
</div>
</details>
` : `<p class="upload-hint"><em>${esc(t('no_cited'))}</em></p>`}
${data.disclaimer ? `<p class="upload-hint" style="margin-top:16px;font-style:italic">${esc(data.disclaimer)}</p>` : ''}
${(data.legal_check && data.legal_check.length) ? `
<div class="korr-legal-check">
<h4 class="korr-legal-check__title">⚖ Legal threshold check <small>(dbn-legal-agent-v3)</small></h4>
${data.legal_check.map((f) => `
<div class="bvj-red-flag">
<div class="bvj-red-flag__head">
<div class="bvj-red-flag__desc">${esc(f.description || '')}</div>
<span class="bvj-severity bvj-severity-${esc(f.severity || 'low')}">${esc(f.severity || 'low')}</span>
</div>
${f.legal_basis ? `<span class="bvj-red-flag__legal">${esc(f.legal_basis)}</span>` : ''}
${f.what_to_check ? `<details class="bvj-red-flag__details"><summary>What to verify</summary><p class="bvj-red-flag__check">${esc(f.what_to_check)}</p></details>` : ''}
</div>
`).join('')}
</div>
` : ''}
<section class="korr-refine-panel" id="korrRefinePanel" aria-labelledby="korrRefineTitle">
<h3 id="korrRefineTitle">${esc(t('refine_title'))} <small>${esc(t('one_extra_credit'))}</small></h3>
<p class="upload-hint">${esc(t('refine_hint'))}</p>
<div class="korr-refine-controls" role="radiogroup" aria-label="Jurisdiction">
<label><input type="radio" name="korrJurisdiction" value="norwegian" checked> ${esc(t('jur_norwegian'))}</label>
<label><input type="radio" name="korrJurisdiction" value="echr"> ${esc(t('jur_echr'))}</label>
<label><input type="radio" name="korrJurisdiction" value="both"> ${esc(t('jur_both'))}</label>
<button type="button" id="korrRefineBtn" class="primary-button">${esc(t('refine_btn'))}</button>
</div>
<div id="korrRefinedSlot"></div>
</section>
`;
// 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 `<span class="korr-flag ${cls}">${icon} ${esc(label)}</span>`;
};
slot.innerHTML = `
<div class="korr-refined">
<div class="korr-result-head">
<span class="tool-badge">${esc(t('refined_label'))} · ${esc(jurLabel)}</span>
<div class="korr-flags">
${flagBadge('citations_verified', t('flag_cites'))}
${flagBadge('deadline_mentioned', t('flag_deadline'))}
${flagBadge('goal_addressed', t('flag_goal'))}
</div>
</div>
<div class="korr-drafts ${isSameLang ? 'is-single' : ''}">
<div class="korr-draft-col">
<div class="korr-draft-head">
<h3>${esc(t('no_column'))}${esc(t('refined_label'))}</h3>
<div class="korr-draft-actions">
<button type="button" class="secondary-button" data-rcopy="no">${esc(t('copy'))}</button>
<button type="button" class="secondary-button" data-rdownload="no">${esc(t('download_txt'))}</button>
</div>
</div>
<pre class="korr-draft-body">${esc(draftNo)}</pre>
</div>
${isSameLang ? '' : `
<div class="korr-draft-col">
<div class="korr-draft-head">
<h3>${esc(userLangLabel)}${esc(t('refined_label'))}</h3>
<div class="korr-draft-actions">
<button type="button" class="secondary-button" data-rcopy="user">${esc(t('copy'))}</button>
<button type="button" class="secondary-button" data-rdownload="user">${esc(t('download_txt'))}</button>
</div>
</div>
<pre class="korr-draft-body">${esc(draftUser)}</pre>
</div>`}
</div>
${cited.length ? `
<details class="korr-cited" open>
<summary><strong>${esc(t('cited_authorities_n', { n: cited.length }))}</strong> — ${esc(jurLabel)}</summary>
<div class="korr-cited-list">
${cited.map((s) => `
<div class="korr-cited-item">
<div class="korr-cited-head"><strong>[${s.n}] ${esc(s.title)}</strong>${s.section ? ' — ' + esc(s.section) : ''}${s.authority_type ? ' <small>(' + esc(s.authority_type) + ')</small>' : ''}</div>
<p class="korr-cited-excerpt">${esc(s.excerpt || '')}</p>
${s.source_url ? `<a href="${esc(s.source_url)}" target="_blank" rel="noopener">${esc(t('view_source'))}</a>` : ''}
</div>
`).join('')}
</div>
</details>` : ''}
${(data.legal_check && data.legal_check.length) ? `
<div class="korr-legal-check">
<h4 class="korr-legal-check__title">⚖ Legal threshold check <small>(dbn-legal-agent-v3)</small></h4>
${data.legal_check.map((f) => `
<div class="bvj-red-flag">
<div class="bvj-red-flag__head">
<div class="bvj-red-flag__desc">${esc(f.description || '')}</div>
<span class="bvj-severity bvj-severity-${esc(f.severity || 'low')}">${esc(f.severity || 'low')}</span>
</div>
${f.legal_basis ? `<span class="bvj-red-flag__legal">${esc(f.legal_basis)}</span>` : ''}
${f.what_to_check ? `<details class="bvj-red-flag__details"><summary>What to verify</summary><p class="bvj-red-flag__check">${esc(f.what_to_check)}</p></details>` : ''}
</div>
`).join('')}
</div>
` : ''}
</div>
`;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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);
}
})();