Korrespond: stop mixing UI languages — all chrome follows user UI lang

Drafts still come back in Norwegian + working language (that is intentional),
but every piece of *chrome* now respects the user's UI lang consistently:

- Pass 1 classify LLM now writes missing-fact questions in the user's language
  (not always Norwegian), fixing the case where an English-UI user got "Hva er
  saksnummeret?" in the clarify panel.
- All PHP-emitted progress/status messages go through DbnKorrespondAgent::L()
  with en/no/pl/uk variants instead of hardcoded Norwegian.
- JS introduces an I18N dictionary + t() helper covering status messages,
  button labels, column headers, flag labels, refine panel title/hint,
  jurisdiction radio labels, clarify panel title/hint/buttons, the empty-state
  "Ready" block, and Copy/Copied/Download .txt.
- Static clarify and empty-state chrome use [data-i18n] attributes resolved at
  init and re-applied on every lang-switcher click.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 12:11:16 +02:00
parent 5d8ae6b447
commit dfb9692f45
4 changed files with 256 additions and 87 deletions
+5 -2
View File
@@ -108,7 +108,10 @@ try {
$attachmentsText .= "\n\n--- " . $extracted['filename'] . " ---\n\n" . $extracted['text']; $attachmentsText .= "\n\n--- " . $extracted['filename'] . " ---\n\n" . $extracted['text'];
} }
$emit('progress', [ $emit('progress', [
'detail' => sprintf('Lest %s (%d tegn)', $extracted['filename'], $extracted['chars']), 'detail' => DbnKorrespondAgent::L('file_read', $language, [
'name' => $extracted['filename'],
'chars' => $extracted['chars'],
]),
]); ]);
} }
} }
@@ -137,7 +140,7 @@ try {
]); ]);
// ── Pass 1: classify + gap-check ──────────────────────────────────────────── // ── Pass 1: classify + gap-check ────────────────────────────────────────────
$emit('progress', ['detail' => 'Analyserer situasjonen…']); $emit('progress', ['detail' => DbnKorrespondAgent::L('analyzing', $language)]);
$agent = new DbnKorrespondAgent(); $agent = new DbnKorrespondAgent();
$classify = $agent->classify($intake); $classify = $agent->classify($intake);
$emit('classify', ['result' => [ $emit('classify', ['result' => [
+152 -68
View File
@@ -15,6 +15,77 @@
const LANG_LABELS = { en: 'English', no: 'Norsk', uk: 'Українська', pl: 'Polski' }; 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', () => { document.addEventListener('DOMContentLoaded', () => {
if (document.body.dataset.activeTool !== 'korrespond') return; if (document.body.dataset.activeTool !== 'korrespond') return;
@@ -53,9 +124,18 @@
bindGoalChips(); bindGoalChips();
bindUpload(); bindUpload();
bindClarify(); bindClarify();
applyStaticI18n();
els.form.addEventListener('submit', (e) => { e.preventDefault(); runRequest(false); }); 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 ─────────────────────────────────────────────────────── // ── Language switcher ───────────────────────────────────────────────────────
function bindLang() { function bindLang() {
els.langButtons.forEach((b) => { els.langButtons.forEach((b) => {
@@ -65,6 +145,7 @@
b.classList.add('is-active'); b.classList.add('is-active');
lang = b.dataset.lang || 'en'; lang = b.dataset.lang || 'en';
localStorage.setItem('dbn-ui-lang', lang); localStorage.setItem('dbn-ui-lang', lang);
applyStaticI18n();
}); });
}); });
} }
@@ -199,21 +280,21 @@
async function runRequest(forceDraft) { async function runRequest(forceDraft) {
const payload = buildPayload(forceDraft); const payload = buildPayload(forceDraft);
if (!payload.recipient_body) { if (!payload.recipient_body) {
setStatus('Pick a recipient body before drafting.', 'error'); setStatus(t('pick_recipient'), 'error');
return; return;
} }
if (payload.mode === 'initiate' && !payload.narrative) { if (payload.mode === 'initiate' && !payload.narrative) {
setStatus('Describe the situation in "What happened" first.', 'error'); setStatus(t('initiate_narrative'), 'error');
return; return;
} }
if (payload.mode === 'reply' && !uploadFiles.length && !payload.narrative) { if (payload.mode === 'reply' && !uploadFiles.length && !payload.narrative) {
setStatus('Reply mode needs the received letter (upload or paste it).', 'error'); setStatus(t('reply_no_input'), 'error');
return; return;
} }
setStatus('Analyserer…', 'busy'); setStatus(t('analyzing'), 'busy');
els.runButton.disabled = true; els.runButton.disabled = true;
els.results.innerHTML = `<div class="empty-state"><h3>Working…</h3><p>Pass 1 extracts facts. If anything is missing we'll ask for clarification. Otherwise Pass 2 runs hard-RAG retrieval + draft + self-check + translate.</p></div>`; els.results.innerHTML = `<div class="empty-state"><h3>${esc(t('working'))}</h3><p>${esc(t('working_long'))}</p></div>`;
const form = new FormData(); const form = new FormData();
form.append('payload', JSON.stringify(payload)); form.append('payload', JSON.stringify(payload));
@@ -223,7 +304,7 @@
try { try {
response = await fetch('api/korrespond.php', { method: 'POST', body: form, credentials: 'same-origin' }); response = await fetch('api/korrespond.php', { method: 'POST', body: form, credentials: 'same-origin' });
} catch (err) { } catch (err) {
setStatus(`Network error: ${err.message || err}`, 'error'); setStatus(t('network_error', { msg: err.message || err }), 'error');
els.runButton.disabled = false; els.runButton.disabled = false;
return; return;
} }
@@ -233,7 +314,7 @@
const d = await response.json().catch(() => ({})); const d = await response.json().catch(() => ({}));
if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d); if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d);
} else { } else {
setStatus(`Request failed (${response.status}).`, 'error'); setStatus(t('request_failed', { status: response.status }), 'error');
} }
els.runButton.disabled = false; els.runButton.disabled = false;
return; return;
@@ -254,7 +335,7 @@
let chunk; let chunk;
try { chunk = await reader.read(); } try { chunk = await reader.read(); }
catch (err) { catch (err) {
setStatus(`Stream error: ${err.message || err}`, 'error'); setStatus(t('stream_error', { msg: err.message || err }), 'error');
els.runButton.disabled = false; els.runButton.disabled = false;
return; return;
} }
@@ -268,10 +349,10 @@
if (!trimmed) continue; if (!trimmed) continue;
let evt; try { evt = JSON.parse(trimmed); } catch (_) { continue; } let evt; try { evt = JSON.parse(trimmed); } catch (_) { continue; }
if (!evt || !evt.event) continue; if (!evt || !evt.event) continue;
if (evt.event === 'progress') { setStatus(evt.detail || 'Working', 'busy'); continue; } if (evt.event === 'progress') { setStatus(evt.detail || t('working'), 'busy'); continue; }
if (evt.event === 'start') { setStatus(`Started — ${evt.body} / ${evt.output_type}`, '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 === '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 === '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 === 'clarify') { clarifyEvent = evt; continue; }
if (evt.event === 'final') { finalResult = evt.result; continue; } if (evt.event === 'final') { finalResult = evt.result; continue; }
if (evt.event === 'error') { errorEvent = evt; continue; } if (evt.event === 'error') { errorEvent = evt; continue; }
@@ -287,16 +368,16 @@
return; return;
} }
if (clarifyEvent) { if (clarifyEvent) {
setStatus('Need clarification before drafting.', 'busy'); setStatus(t('need_clarify'), 'busy');
showClarify(clarifyEvent.questions); showClarify(clarifyEvent.questions);
return; return;
} }
if (!finalResult) { if (!finalResult) {
setStatus('Stream ended without a draft.', 'error'); setStatus(t('stream_no_draft'), 'error');
return; return;
} }
setStatus(`Done in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${(finalResult.cited_law || []).length} cited source(s)`, 'ok'); setStatus(t('done_summary', { s: Math.round((finalResult.latency_ms || 0) / 1000), n: (finalResult.cited_law || []).length }), 'ok');
lastFinal = finalResult; lastFinal = finalResult;
renderFinal(finalResult); renderFinal(finalResult);
pendingClarifications = {}; // reset for next run pendingClarifications = {}; // reset for next run
@@ -305,12 +386,15 @@
// ── Pass 3: refine with jurisdiction-scoped formal citations ──────────────── // ── Pass 3: refine with jurisdiction-scoped formal citations ────────────────
async function runRefine(jurisdiction) { async function runRefine(jurisdiction) {
if (!lastFinal || !lastClassify) { if (!lastFinal || !lastClassify) {
setStatus('No draft to refine. Run a draft first.', 'error'); setStatus(t('no_draft_yet'), 'error');
return; return;
} }
const jurLabel = jurisdiction === 'echr' ? t('jur_echr')
: jurisdiction === 'both' ? t('jur_both')
: t('jur_norwegian');
const refineBtn = document.getElementById('korrRefineBtn'); const refineBtn = document.getElementById('korrRefineBtn');
if (refineBtn) { refineBtn.disabled = true; refineBtn.textContent = 'Refining…'; } if (refineBtn) { refineBtn.disabled = true; refineBtn.textContent = t('refine_btn_busy'); }
setStatus(`Refining draft with ${jurisdiction} authorities…`, 'busy'); setStatus(t('refining_status', { jur: jurLabel }), 'busy');
const payload = { const payload = {
jurisdiction, jurisdiction,
@@ -333,8 +417,8 @@
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} catch (err) { } catch (err) {
setStatus(`Network error: ${err.message || err}`, 'error'); setStatus(t('network_error', { msg: err.message || err }), 'error');
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; } if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); }
return; return;
} }
@@ -343,9 +427,9 @@
const d = await response.json().catch(() => ({})); const d = await response.json().catch(() => ({}));
if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d); if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d);
} else { } else {
setStatus(`Refine failed (${response.status}).`, 'error'); setStatus(t('refine_failed', { status: response.status }), 'error');
} }
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; } if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); }
return; return;
} }
const creditsRemaining = response.headers.get('X-Credits-Remaining'); const creditsRemaining = response.headers.get('X-Credits-Remaining');
@@ -363,8 +447,8 @@
let chunk; let chunk;
try { chunk = await reader.read(); } try { chunk = await reader.read(); }
catch (err) { catch (err) {
setStatus(`Stream error: ${err.message || err}`, 'error'); setStatus(t('stream_error', { msg: err.message || err }), 'error');
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; } if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); }
return; return;
} }
const { done, value } = chunk; const { done, value } = chunk;
@@ -377,9 +461,9 @@
if (!trimmed) continue; if (!trimmed) continue;
let evt; try { evt = JSON.parse(trimmed); } catch (_) { continue; } let evt; try { evt = JSON.parse(trimmed); } catch (_) { continue; }
if (!evt || !evt.event) continue; if (!evt || !evt.event) continue;
if (evt.event === 'progress') { setStatus(evt.detail || 'Refining', 'busy'); continue; } if (evt.event === 'progress') { setStatus(evt.detail || t('refining'), 'busy'); continue; }
if (evt.event === 'start') { setStatus(`Refining (${evt.jurisdiction})…`, 'busy'); continue; } if (evt.event === 'start') { setStatus(t('refining_short', { jur: jurLabel }), 'busy'); continue; }
if (evt.event === 'retrieval') { setStatus(`Hentet ${evt.sources_count} rettskilder for ${evt.jurisdiction}`, '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 === 'final') { finalResult = evt.result; continue; }
if (evt.event === 'error') { errorEvent = evt; continue; } if (evt.event === 'error') { errorEvent = evt; continue; }
} }
@@ -387,18 +471,18 @@
if (done) break; if (done) break;
} }
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; } if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); }
if (errorEvent) { if (errorEvent) {
setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error'); setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error');
return; return;
} }
if (!finalResult) { if (!finalResult) {
setStatus('Refine stream ended without a result.', 'error'); setStatus(t('refine_no_result'), 'error');
return; return;
} }
setStatus(`Refined in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${(finalResult.cited_law || []).length} cited authority(ies) · ${finalResult.jurisdiction}`, 'ok'); setStatus(t('refined_summary', { s: Math.round((finalResult.latency_ms || 0) / 1000), n: (finalResult.cited_law || []).length, jur: jurLabel }), 'ok');
renderRefined(finalResult); renderRefined(finalResult);
} }
@@ -414,10 +498,10 @@
els.results.appendChild(block); els.results.appendChild(block);
} }
block.innerHTML = ` block.innerHTML = `
<h3 style="margin:0 0 10px;font-size:0.95rem;color:var(--ink)">Sammendrag (Pass 1)</h3> <h3 style="margin:0 0 10px;font-size:0.95rem;color:var(--ink)">${esc(t('summary_pass1'))}</h3>
<p>${esc(c.summary)}</p> <p>${esc(c.summary)}</p>
${c.applicable_acts && c.applicable_acts.length ? `<p class="upload-hint"><strong>Antatt rettslig grunnlag:</strong> ${c.applicable_acts.map(esc).join(', ')}</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>Frister:</strong> ${c.deadlines.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>` : ''}
`; `;
} }
@@ -442,20 +526,20 @@
<div class="korr-result-head"> <div class="korr-result-head">
<span class="tool-badge">${esc(data.recipient_body || '')} · ${esc(data.output_type || '')} · ${esc(data.tone || '')}</span> <span class="tool-badge">${esc(data.recipient_body || '')} · ${esc(data.output_type || '')} · ${esc(data.tone || '')}</span>
<div class="korr-flags"> <div class="korr-flags">
${flagBadge('citations_verified', 'Citations verified')} ${flagBadge('citations_verified', t('flag_cites'))}
${flagBadge('deadline_mentioned', 'Deadline')} ${flagBadge('deadline_mentioned', t('flag_deadline'))}
${flagBadge('goal_addressed', 'Goal addressed')} ${flagBadge('goal_addressed', t('flag_goal'))}
${flagBadge('tone', 'Tone')} ${flagBadge('tone', t('flag_tone'))}
</div> </div>
</div> </div>
<div class="korr-drafts ${isSameLang ? 'is-single' : ''}"> <div class="korr-drafts ${isSameLang ? 'is-single' : ''}">
<div class="korr-draft-col"> <div class="korr-draft-col">
<div class="korr-draft-head"> <div class="korr-draft-head">
<h3>Norsk (bokmål) — kanonisk</h3> <h3>${esc(t('no_column'))}${esc(t('canonical'))}</h3>
<div class="korr-draft-actions"> <div class="korr-draft-actions">
<button type="button" class="secondary-button" data-copy="no">Copy</button> <button type="button" class="secondary-button" data-copy="no">${esc(t('copy'))}</button>
<button type="button" class="secondary-button" data-download="no">Download .txt</button> <button type="button" class="secondary-button" data-download="no">${esc(t('download_txt'))}</button>
</div> </div>
</div> </div>
<pre class="korr-draft-body" id="korrDraftNo">${esc(draftNo)}</pre> <pre class="korr-draft-body" id="korrDraftNo">${esc(draftNo)}</pre>
@@ -463,10 +547,10 @@
${isSameLang ? '' : ` ${isSameLang ? '' : `
<div class="korr-draft-col"> <div class="korr-draft-col">
<div class="korr-draft-head"> <div class="korr-draft-head">
<h3>${esc(userLangLabel)}reference</h3> <h3>${esc(userLangLabel)}${esc(t('reference'))}</h3>
<div class="korr-draft-actions"> <div class="korr-draft-actions">
<button type="button" class="secondary-button" data-copy="user">Copy</button> <button type="button" class="secondary-button" data-copy="user">${esc(t('copy'))}</button>
<button type="button" class="secondary-button" data-download="user">Download .txt</button> <button type="button" class="secondary-button" data-download="user">${esc(t('download_txt'))}</button>
</div> </div>
</div> </div>
<pre class="korr-draft-body" id="korrDraftUser">${esc(draftUser)}</pre> <pre class="korr-draft-body" id="korrDraftUser">${esc(draftUser)}</pre>
@@ -475,29 +559,29 @@
${cited.length ? ` ${cited.length ? `
<details class="korr-cited" open> <details class="korr-cited" open>
<summary><strong>Cited law (${cited.length})</strong> — each reference traces to a real corpus passage</summary> <summary><strong>${esc(t('cited_law_n', { n: cited.length }))}</strong> — ${esc(t('cited_law_hint'))}</summary>
<div class="korr-cited-list"> <div class="korr-cited-list">
${cited.map((s) => ` ${cited.map((s) => `
<div class="korr-cited-item"> <div class="korr-cited-item">
<div class="korr-cited-head"><strong>[${s.n}] ${esc(s.title)}</strong>${s.section ? ' — ' + esc(s.section) : ''}</div> <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> <p class="korr-cited-excerpt">${esc(s.excerpt || '')}</p>
${s.source_url ? `<a href="${esc(s.source_url)}" target="_blank" rel="noopener">View source</a>` : ''} ${s.source_url ? `<a href="${esc(s.source_url)}" target="_blank" rel="noopener">${esc(t('view_source'))}</a>` : ''}
</div> </div>
`).join('')} `).join('')}
</div> </div>
</details> </details>
` : '<p class="upload-hint"><em>No cited law sources — draft is plain-language (no § references available from corpus).</em></p>'} ` : `<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.disclaimer ? `<p class="upload-hint" style="margin-top:16px;font-style:italic">${esc(data.disclaimer)}</p>` : ''}
<section class="korr-refine-panel" id="korrRefinePanel" aria-labelledby="korrRefineTitle"> <section class="korr-refine-panel" id="korrRefinePanel" aria-labelledby="korrRefineTitle">
<h3 id="korrRefineTitle">Refine with formal citations <small>(+1 credit)</small></h3> <h3 id="korrRefineTitle">${esc(t('refine_title'))} <small>${esc(t('one_extra_credit'))}</small></h3>
<p class="upload-hint">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 <em>Rettskilder</em> block at the bottom.</p> <p class="upload-hint">${esc(t('refine_hint'))}</p>
<div class="korr-refine-controls" role="radiogroup" aria-label="Jurisdiction"> <div class="korr-refine-controls" role="radiogroup" aria-label="Jurisdiction">
<label><input type="radio" name="korrJurisdiction" value="norwegian" checked> Norwegian law only</label> <label><input type="radio" name="korrJurisdiction" value="norwegian" checked> ${esc(t('jur_norwegian'))}</label>
<label><input type="radio" name="korrJurisdiction" value="echr"> ECHR (EMK + HUDOC)</label> <label><input type="radio" name="korrJurisdiction" value="echr"> ${esc(t('jur_echr'))}</label>
<label><input type="radio" name="korrJurisdiction" value="both"> Both</label> <label><input type="radio" name="korrJurisdiction" value="both"> ${esc(t('jur_both'))}</label>
<button type="button" id="korrRefineBtn" class="primary-button">Refine citations</button> <button type="button" id="korrRefineBtn" class="primary-button">${esc(t('refine_btn'))}</button>
</div> </div>
<div id="korrRefinedSlot"></div> <div id="korrRefinedSlot"></div>
</section> </section>
@@ -508,7 +592,7 @@
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const target = btn.dataset.copy === 'no' ? draftNo : draftUser; const target = btn.dataset.copy === 'no' ? draftNo : draftUser;
navigator.clipboard?.writeText(target).then( navigator.clipboard?.writeText(target).then(
() => { btn.textContent = 'Copied'; setTimeout(() => btn.textContent = 'Copy', 1500); }, () => { btn.textContent = t('copied'); setTimeout(() => btn.textContent = t('copy'), 1500); },
() => { btn.textContent = 'Failed'; } () => { btn.textContent = 'Failed'; }
); );
}); });
@@ -539,9 +623,9 @@
const isSameLang = userLang === 'no'; const isSameLang = userLang === 'no';
const draftNo = data.draft_no || ''; const draftNo = data.draft_no || '';
const draftUser = data.draft_user || ''; const draftUser = data.draft_user || '';
const jurLabel = data.jurisdiction === 'echr' ? 'ECHR (EMK + HUDOC)' const jurLabel = data.jurisdiction === 'echr' ? t('jur_echr')
: data.jurisdiction === 'both' ? 'Norwegian + ECHR' : data.jurisdiction === 'both' ? t('jur_both')
: 'Norwegian law'; : t('jur_norwegian');
const flagBadge = (key, label) => { const flagBadge = (key, label) => {
const v = flags[key] || 'ok'; const v = flags[key] || 'ok';
@@ -553,21 +637,21 @@
slot.innerHTML = ` slot.innerHTML = `
<div class="korr-refined"> <div class="korr-refined">
<div class="korr-result-head"> <div class="korr-result-head">
<span class="tool-badge">Refined · ${esc(jurLabel)}</span> <span class="tool-badge">${esc(t('refined_label'))} · ${esc(jurLabel)}</span>
<div class="korr-flags"> <div class="korr-flags">
${flagBadge('citations_verified', 'Citations verified')} ${flagBadge('citations_verified', t('flag_cites'))}
${flagBadge('deadline_mentioned', 'Deadline')} ${flagBadge('deadline_mentioned', t('flag_deadline'))}
${flagBadge('goal_addressed', 'Goal addressed')} ${flagBadge('goal_addressed', t('flag_goal'))}
</div> </div>
</div> </div>
<div class="korr-drafts ${isSameLang ? 'is-single' : ''}"> <div class="korr-drafts ${isSameLang ? 'is-single' : ''}">
<div class="korr-draft-col"> <div class="korr-draft-col">
<div class="korr-draft-head"> <div class="korr-draft-head">
<h3>Norsk (bokmål) — refined</h3> <h3>${esc(t('no_column'))}${esc(t('refined_label'))}</h3>
<div class="korr-draft-actions"> <div class="korr-draft-actions">
<button type="button" class="secondary-button" data-rcopy="no">Copy</button> <button type="button" class="secondary-button" data-rcopy="no">${esc(t('copy'))}</button>
<button type="button" class="secondary-button" data-rdownload="no">Download .txt</button> <button type="button" class="secondary-button" data-rdownload="no">${esc(t('download_txt'))}</button>
</div> </div>
</div> </div>
<pre class="korr-draft-body">${esc(draftNo)}</pre> <pre class="korr-draft-body">${esc(draftNo)}</pre>
@@ -575,10 +659,10 @@
${isSameLang ? '' : ` ${isSameLang ? '' : `
<div class="korr-draft-col"> <div class="korr-draft-col">
<div class="korr-draft-head"> <div class="korr-draft-head">
<h3>${esc(userLangLabel)}refined</h3> <h3>${esc(userLangLabel)}${esc(t('refined_label'))}</h3>
<div class="korr-draft-actions"> <div class="korr-draft-actions">
<button type="button" class="secondary-button" data-rcopy="user">Copy</button> <button type="button" class="secondary-button" data-rcopy="user">${esc(t('copy'))}</button>
<button type="button" class="secondary-button" data-rdownload="user">Download .txt</button> <button type="button" class="secondary-button" data-rdownload="user">${esc(t('download_txt'))}</button>
</div> </div>
</div> </div>
<pre class="korr-draft-body">${esc(draftUser)}</pre> <pre class="korr-draft-body">${esc(draftUser)}</pre>
@@ -587,13 +671,13 @@
${cited.length ? ` ${cited.length ? `
<details class="korr-cited" open> <details class="korr-cited" open>
<summary><strong>Cited authorities (${cited.length})</strong> — ${esc(jurLabel)}</summary> <summary><strong>${esc(t('cited_authorities_n', { n: cited.length }))}</strong> — ${esc(jurLabel)}</summary>
<div class="korr-cited-list"> <div class="korr-cited-list">
${cited.map((s) => ` ${cited.map((s) => `
<div class="korr-cited-item"> <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> <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> <p class="korr-cited-excerpt">${esc(s.excerpt || '')}</p>
${s.source_url ? `<a href="${esc(s.source_url)}" target="_blank" rel="noopener">View source</a>` : ''} ${s.source_url ? `<a href="${esc(s.source_url)}" target="_blank" rel="noopener">${esc(t('view_source'))}</a>` : ''}
</div> </div>
`).join('')} `).join('')}
</div> </div>
@@ -605,7 +689,7 @@
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const target = btn.dataset.rcopy === 'no' ? draftNo : draftUser; const target = btn.dataset.rcopy === 'no' ? draftNo : draftUser;
navigator.clipboard?.writeText(target).then( navigator.clipboard?.writeText(target).then(
() => { btn.textContent = 'Copied'; setTimeout(() => btn.textContent = 'Copy', 1500); }, () => { btn.textContent = t('copied'); setTimeout(() => btn.textContent = t('copy'), 1500); },
() => { btn.textContent = 'Failed'; } () => { btn.textContent = 'Failed'; }
); );
}); });
+93 -11
View File
@@ -63,6 +63,85 @@ final class DbnKorrespondAgent
$this->azure = $azure ?: new DbnAzureOpenAiGateway(); $this->azure = $azure ?: new DbnAzureOpenAiGateway();
} }
/** Localized chrome/progress strings, keyed by user UI language. */
public static function L(string $key, string $lang, array $vars = []): string
{
$strings = [
'analyzing' => [
'en' => 'Analyzing the situation…',
'no' => 'Analyserer situasjonen…',
'pl' => 'Analiza sytuacji…',
'uk' => 'Аналізую ситуацію…',
],
'fetching_law' => [
'en' => 'Fetching relevant legal sources…',
'no' => 'Henter relevante lovkilder…',
'pl' => 'Pobieranie odpowiednich źródeł prawnych…',
'uk' => 'Завантажую відповідні юридичні джерела…',
],
'drafting_no' => [
'en' => 'Writing draft in Norwegian (bokmål)…',
'no' => 'Skriver utkast på bokmål…',
'pl' => 'Pisanie projektu po norwesku (bokmål)…',
'uk' => 'Пишу чернетку норвезькою (bokmål)…',
],
'quality_check' => [
'en' => 'Quality-checking the draft…',
'no' => 'Kvalitetskontroll av utkastet…',
'pl' => 'Sprawdzanie jakości projektu…',
'uk' => 'Перевірка якості чернетки…',
],
'translating_to' => [
'en' => 'Translating to {lang}…',
'no' => 'Oversetter til {lang}…',
'pl' => 'Tłumaczenie na {lang}…',
'uk' => 'Переклад на {lang}…',
],
'fetching_for_jur' => [
'en' => 'Fetching authorities for {jur}…',
'no' => 'Henter rettskilder for {jur}…',
'pl' => 'Pobieranie autorytetów dla {jur}…',
'uk' => 'Завантажую джерела для {jur}…',
],
'rewriting_formal' => [
'en' => 'Rewriting with formal citations…',
'no' => 'Skriver om med formelle henvisninger…',
'pl' => 'Przepisywanie z formalnymi cytatami…',
'uk' => 'Переписую з формальними цитатами…',
],
'check_and_authorities' => [
'en' => 'Quality-check and Legal authorities block…',
'no' => 'Kvalitetskontroll og Rettskilder…',
'pl' => 'Kontrola jakości i blok źródeł prawnych…',
'uk' => 'Перевірка якості і блок джерел права…',
],
'file_read' => [
'en' => 'Read {name} ({chars} chars)',
'no' => 'Lest {name} ({chars} tegn)',
'pl' => 'Przeczytano {name} ({chars} znaków)',
'uk' => 'Прочитано {name} ({chars} символів)',
],
];
$lang = in_array($lang, ['en', 'no', 'pl', 'uk'], true) ? $lang : 'en';
$tmpl = $strings[$key][$lang] ?? $strings[$key]['en'] ?? $key;
foreach ($vars as $k => $v) {
$tmpl = str_replace('{' . $k . '}', (string)$v, $tmpl);
}
return $tmpl;
}
/** Localized jurisdiction labels for chrome (status messages). */
public static function jurisdictionChromeLabel(string $jurisdiction, string $lang): string
{
$map = [
'norwegian' => ['en' => 'Norwegian law', 'no' => 'norsk rett', 'pl' => 'prawo norweskie', 'uk' => 'норвезьке право'],
'echr' => ['en' => 'ECHR + HUDOC', 'no' => 'EMK og EMD-praksis', 'pl' => 'EKPC + HUDOC', 'uk' => 'ЄКПЛ + HUDOC'],
'both' => ['en' => 'NO + ECHR', 'no' => 'norsk rett + EMK/EMD', 'pl' => 'NO + EKPC', 'uk' => 'NO + ЄКПЛ'],
];
$lang = in_array($lang, ['en', 'no', 'pl', 'uk'], true) ? $lang : 'en';
return $map[$jurisdiction][$lang] ?? $map['norwegian']['en'];
}
/** /**
* Pass 1 — extract structured facts and identify missing info. * Pass 1 — extract structured facts and identify missing info.
* *
@@ -82,6 +161,8 @@ final class DbnKorrespondAgent
$body = $intake['recipient_body'] ?? 'other'; $body = $intake['recipient_body'] ?? 'other';
$mode = $intake['mode'] ?? 'initiate'; $mode = $intake['mode'] ?? 'initiate';
$bodyLabel = self::BODY_LABELS[$body] ?? 'mottaker'; $bodyLabel = self::BODY_LABELS[$body] ?? 'mottaker';
$userLang = dbnToolsNormalizeUiLanguage($intake['language'] ?? 'en');
$userLangName = dbnToolsLanguageName($userLang); // e.g. "English", "Norwegian", "Polish", "Ukrainian"
$context = $this->buildContextBlob($intake); $context = $this->buildContextBlob($intake);
$modeLabel = $mode === 'reply' ? 'svar på et brev/vedtak' : 'innledning av en sak'; $modeLabel = $mode === 'reply' ? 'svar på et brev/vedtak' : 'innledning av en sak';
@@ -98,8 +179,8 @@ Return JSON only:
"deadlines": ["YYYY-MM-DD", "or relative deadline as plain text"], "deadlines": ["YYYY-MM-DD", "or relative deadline as plain text"],
"applicable_acts": ["forvaltningsloven", "barnevernsloven", "NAV-loven", "opplæringslova", "barnehageloven", "EMK"], "applicable_acts": ["forvaltningsloven", "barnevernsloven", "NAV-loven", "opplæringslova", "barnehageloven", "EMK"],
"jurisdiction": "kommune/fylke if known, else null", "jurisdiction": "kommune/fylke if known, else null",
"missing_facts": [{"key":"deadline","question":"Norwegian bokmål question to user"}], "missing_facts": [{"key":"deadline","question":"<question in {$userLangName}>"}],
"suggested_goal": "One-line concrete goal for this letter, in Norwegian" "suggested_goal": "One-line concrete goal for this letter, in Norwegian bokmål"
} }
Rules: Rules:
@@ -108,7 +189,8 @@ Rules:
- missing_facts: include up to 4 items the drafter genuinely needs (date of decision, deadline, case - missing_facts: include up to 4 items the drafter genuinely needs (date of decision, deadline, case
number, specific decision being appealed, etc.). Leave EMPTY if intake is complete. number, specific decision being appealed, etc.). Leave EMPTY if intake is complete.
- For "reply" mode if no case reference is supplied, missing_facts SHOULD include one for it. - For "reply" mode if no case reference is supplied, missing_facts SHOULD include one for it.
- Write missing-fact questions in Norwegian bokmål, short and clear. - IMPORTANT: write each missing-fact "question" field in **{$userLangName}**, short and clear.
Do NOT write the question in Norwegian if the user's language is not Norwegian.
Intake: Intake:
{$context} {$context}
@@ -156,7 +238,7 @@ PROMPT;
$bodyLabel = self::BODY_LABELS[$body] ?? 'mottaker'; $bodyLabel = self::BODY_LABELS[$body] ?? 'mottaker';
// ── Retrieve law ──────────────────────────────────────────────────────── // ── Retrieve law ────────────────────────────────────────────────────────
if ($emit) { $emit('progress', ['detail' => 'Henter relevante lovkilder…']); } if ($emit) { $emit('progress', ['detail' => self::L('fetching_law', $userLang)]); }
$retrieval = $this->retrieveLaw($body, $classify['applicable_acts'] ?? []); $retrieval = $this->retrieveLaw($body, $classify['applicable_acts'] ?? []);
if ($emit) { if ($emit) {
$emit('retrieval', [ $emit('retrieval', [
@@ -166,19 +248,19 @@ PROMPT;
} }
// ── Draft in Norwegian bokmål ─────────────────────────────────────────── // ── Draft in Norwegian bokmål ───────────────────────────────────────────
if ($emit) { $emit('progress', ['detail' => 'Skriver utkast på bokmål…']); } if ($emit) { $emit('progress', ['detail' => self::L('drafting_no', $userLang)]); }
$draftNo = $this->draftNorwegian( $draftNo = $this->draftNorwegian(
$intake, $classify, $retrieval['sources'], $bodyLabel, $outputType, $tone, $goal $intake, $classify, $retrieval['sources'], $bodyLabel, $outputType, $tone, $goal
); );
// ── Self-check: verify citations, deadline, goal, tone ────────────────── // ── Self-check: verify citations, deadline, goal, tone ──────────────────
if ($emit) { $emit('progress', ['detail' => 'Kvalitetskontroll av utkastet…']); } if ($emit) { $emit('progress', ['detail' => self::L('quality_check', $userLang)]); }
$checked = $this->selfCheck($draftNo, $retrieval['sources'], $classify, $goal, $tone); $checked = $this->selfCheck($draftNo, $retrieval['sources'], $classify, $goal, $tone);
// ── Translate to user language (if not Norwegian) ─────────────────────── // ── Translate to user language (if not Norwegian) ───────────────────────
$draftUser = $checked['draft']; $draftUser = $checked['draft'];
if ($userLang !== 'no') { if ($userLang !== 'no') {
if ($emit) { $emit('progress', ['detail' => 'Oversetter til ' . dbnToolsLanguageName($userLang) . '…']); } if ($emit) { $emit('progress', ['detail' => self::L('translating_to', $userLang, ['lang' => dbnToolsLanguageName($userLang)])]); }
$draftUser = $this->translate($checked['draft'], $userLang, $outputType); $draftUser = $this->translate($checked['draft'], $userLang, $outputType);
} }
@@ -656,7 +738,7 @@ EOT,
$bodyLabel = self::BODY_LABELS[$body] ?? 'mottaker'; $bodyLabel = self::BODY_LABELS[$body] ?? 'mottaker';
$jurisdiction = in_array($jurisdiction, ['norwegian', 'echr', 'both'], true) ? $jurisdiction : 'norwegian'; $jurisdiction = in_array($jurisdiction, ['norwegian', 'echr', 'both'], true) ? $jurisdiction : 'norwegian';
if ($emit) { $emit('progress', ['detail' => 'Henter rettskilder for ' . $this->jurisdictionLabelNorsk($jurisdiction) . '…']); } if ($emit) { $emit('progress', ['detail' => self::L('fetching_for_jur', $userLang, ['jur' => self::jurisdictionChromeLabel($jurisdiction, $userLang)])]); }
$retrieval = $this->retrieveLawForJurisdiction($jurisdiction, $body, $classify); $retrieval = $this->retrieveLawForJurisdiction($jurisdiction, $body, $classify);
if ($emit) { if ($emit) {
$emit('retrieval', [ $emit('retrieval', [
@@ -666,17 +748,17 @@ EOT,
]); ]);
} }
if ($emit) { $emit('progress', ['detail' => 'Skriver om med formelle henvisninger…']); } if ($emit) { $emit('progress', ['detail' => self::L('rewriting_formal', $userLang)]); }
$refinedNo = $this->rewriteWithFormalCites( $refinedNo = $this->rewriteWithFormalCites(
$originalDraftNo, $retrieval['sources'], $bodyLabel, $outputType, $tone, $goal, $jurisdiction $originalDraftNo, $retrieval['sources'], $bodyLabel, $outputType, $tone, $goal, $jurisdiction
); );
if ($emit) { $emit('progress', ['detail' => 'Kvalitetskontroll og Rettskilder…']); } if ($emit) { $emit('progress', ['detail' => self::L('check_and_authorities', $userLang)]); }
$checked = $this->selfCheck($refinedNo, $retrieval['sources'], $classify, $goal, $tone); $checked = $this->selfCheck($refinedNo, $retrieval['sources'], $classify, $goal, $tone);
$draftUser = $checked['draft']; $draftUser = $checked['draft'];
if ($userLang !== 'no') { if ($userLang !== 'no') {
if ($emit) { $emit('progress', ['detail' => 'Oversetter til ' . dbnToolsLanguageName($userLang) . '…']); } if ($emit) { $emit('progress', ['detail' => self::L('translating_to', $userLang, ['lang' => dbnToolsLanguageName($userLang)])]); }
$draftUser = $this->translate($checked['draft'], $userLang, $outputType); $draftUser = $this->translate($checked['draft'], $userLang, $outputType);
} }
+6 -6
View File
@@ -118,19 +118,19 @@ require_once __DIR__ . '/includes/layout.php';
<!-- Clarify panel (shown if Pass 1 returns missing_facts) --> <!-- Clarify panel (shown if Pass 1 returns missing_facts) -->
<section id="korrClarifyPanel" class="korr-clarify-panel is-hidden" aria-labelledby="korrClarifyTitle"> <section id="korrClarifyPanel" class="korr-clarify-panel is-hidden" aria-labelledby="korrClarifyTitle">
<h3 id="korrClarifyTitle">Before we draft, clarify:</h3> <h3 id="korrClarifyTitle" data-i18n="clarify_title">Before we draft, clarify:</h3>
<p class="upload-hint">Answer what you can, then click <em>Continue draft</em>. Or click <em>Draft anyway</em> to proceed with what we have.</p> <p class="upload-hint" data-i18n="clarify_hint">Answer what you can, then click Continue draft. Or click Draft anyway to proceed with what we have.</p>
<div id="korrClarifyList" class="korr-clarify-list"></div> <div id="korrClarifyList" class="korr-clarify-list"></div>
<div class="korr-clarify-actions"> <div class="korr-clarify-actions">
<button type="button" id="korrClarifyContinue" class="primary-button">Continue draft</button> <button type="button" id="korrClarifyContinue" class="primary-button" data-i18n="clarify_continue">Continue draft</button>
<button type="button" id="korrClarifyForce" class="secondary-button">Draft anyway</button> <button type="button" id="korrClarifyForce" class="secondary-button" data-i18n="clarify_force">Draft anyway</button>
</div> </div>
</section> </section>
<section id="korrResults" class="results deep-research-results" aria-live="polite"> <section id="korrResults" class="results deep-research-results" aria-live="polite">
<div class="empty-state"> <div class="empty-state">
<h3>Ready</h3> <h3 data-i18n="ready_title">Ready</h3>
<p>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.</p> <p data-i18n="ready_desc">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.</p>
</div> </div>
</section> </section>