diff --git a/api/korrespond.php b/api/korrespond.php index a65b714..1d3a81f 100644 --- a/api/korrespond.php +++ b/api/korrespond.php @@ -108,7 +108,10 @@ try { $attachmentsText .= "\n\n--- " . $extracted['filename'] . " ---\n\n" . $extracted['text']; } $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 ──────────────────────────────────────────── - $emit('progress', ['detail' => 'Analyserer situasjonen…']); + $emit('progress', ['detail' => DbnKorrespondAgent::L('analyzing', $language)]); $agent = new DbnKorrespondAgent(); $classify = $agent->classify($intake); $emit('classify', ['result' => [ diff --git a/assets/js/korrespond.js b/assets/js/korrespond.js index 0d99965..7ad13c7 100644 --- a/assets/js/korrespond.js +++ b/assets/js/korrespond.js @@ -15,6 +15,77 @@ 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; @@ -53,9 +124,18 @@ 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) => { @@ -65,6 +145,7 @@ b.classList.add('is-active'); lang = b.dataset.lang || 'en'; localStorage.setItem('dbn-ui-lang', lang); + applyStaticI18n(); }); }); } @@ -199,21 +280,21 @@ async function runRequest(forceDraft) { const payload = buildPayload(forceDraft); if (!payload.recipient_body) { - setStatus('Pick a recipient body before drafting.', 'error'); + setStatus(t('pick_recipient'), 'error'); return; } if (payload.mode === 'initiate' && !payload.narrative) { - setStatus('Describe the situation in "What happened" first.', 'error'); + setStatus(t('initiate_narrative'), 'error'); return; } 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; } - setStatus('Analyserer…', 'busy'); + setStatus(t('analyzing'), 'busy'); els.runButton.disabled = true; - els.results.innerHTML = `

Working…

Pass 1 extracts facts. If anything is missing we'll ask for clarification. Otherwise Pass 2 runs hard-RAG retrieval + draft + self-check + translate.

`; + els.results.innerHTML = `

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

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

`; const form = new FormData(); form.append('payload', JSON.stringify(payload)); @@ -223,7 +304,7 @@ try { response = await fetch('api/korrespond.php', { method: 'POST', body: form, credentials: 'same-origin' }); } catch (err) { - setStatus(`Network error: ${err.message || err}`, 'error'); + setStatus(t('network_error', { msg: err.message || err }), 'error'); els.runButton.disabled = false; return; } @@ -233,7 +314,7 @@ const d = await response.json().catch(() => ({})); if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d); } else { - setStatus(`Request failed (${response.status}).`, 'error'); + setStatus(t('request_failed', { status: response.status }), 'error'); } els.runButton.disabled = false; return; @@ -254,7 +335,7 @@ let chunk; try { chunk = await reader.read(); } catch (err) { - setStatus(`Stream error: ${err.message || err}`, 'error'); + setStatus(t('stream_error', { msg: err.message || err }), 'error'); els.runButton.disabled = false; return; } @@ -268,10 +349,10 @@ if (!trimmed) continue; let evt; try { evt = JSON.parse(trimmed); } catch (_) { continue; } if (!evt || !evt.event) continue; - if (evt.event === 'progress') { setStatus(evt.detail || 'Working…', 'busy'); continue; } - if (evt.event === 'start') { setStatus(`Started — ${evt.body} / ${evt.output_type}`, 'busy'); continue; } + if (evt.event === '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(`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 === 'final') { finalResult = evt.result; continue; } if (evt.event === 'error') { errorEvent = evt; continue; } @@ -287,16 +368,16 @@ return; } if (clarifyEvent) { - setStatus('Need clarification before drafting.', 'busy'); + setStatus(t('need_clarify'), 'busy'); showClarify(clarifyEvent.questions); return; } if (!finalResult) { - setStatus('Stream ended without a draft.', 'error'); + setStatus(t('stream_no_draft'), 'error'); 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; renderFinal(finalResult); pendingClarifications = {}; // reset for next run @@ -305,12 +386,15 @@ // ── Pass 3: refine with jurisdiction-scoped formal citations ──────────────── async function runRefine(jurisdiction) { if (!lastFinal || !lastClassify) { - setStatus('No draft to refine. Run a draft first.', 'error'); + 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 = 'Refining…'; } - setStatus(`Refining draft with ${jurisdiction} authorities…`, 'busy'); + if (refineBtn) { refineBtn.disabled = true; refineBtn.textContent = t('refine_btn_busy'); } + setStatus(t('refining_status', { jur: jurLabel }), 'busy'); const payload = { jurisdiction, @@ -333,8 +417,8 @@ body: JSON.stringify(payload), }); } catch (err) { - setStatus(`Network error: ${err.message || err}`, 'error'); - if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; } + setStatus(t('network_error', { msg: err.message || err }), 'error'); + if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); } return; } @@ -343,9 +427,9 @@ const d = await response.json().catch(() => ({})); if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d); } 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; } const creditsRemaining = response.headers.get('X-Credits-Remaining'); @@ -363,8 +447,8 @@ let chunk; try { chunk = await reader.read(); } catch (err) { - setStatus(`Stream error: ${err.message || err}`, 'error'); - if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; } + setStatus(t('stream_error', { msg: err.message || err }), 'error'); + if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); } return; } const { done, value } = chunk; @@ -377,9 +461,9 @@ if (!trimmed) continue; let evt; try { evt = JSON.parse(trimmed); } catch (_) { continue; } if (!evt || !evt.event) continue; - if (evt.event === 'progress') { setStatus(evt.detail || 'Refining…', 'busy'); continue; } - if (evt.event === 'start') { setStatus(`Refining (${evt.jurisdiction})…`, 'busy'); continue; } - if (evt.event === 'retrieval') { setStatus(`Hentet ${evt.sources_count} rettskilder for ${evt.jurisdiction}…`, 'busy'); continue; } + if (evt.event === '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; } } @@ -387,18 +471,18 @@ if (done) break; } - if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; } + if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); } if (errorEvent) { setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error'); return; } if (!finalResult) { - setStatus('Refine stream ended without a result.', 'error'); + setStatus(t('refine_no_result'), 'error'); 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); } @@ -414,10 +498,10 @@ els.results.appendChild(block); } block.innerHTML = ` -

Sammendrag (Pass 1)

+

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

${esc(c.summary)}

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

Antatt rettslig grunnlag: ${c.applicable_acts.map(esc).join(', ')}

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

Frister: ${c.deadlines.map(esc).join(', ')}

` : ''} + ${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(', ')}

` : ''} `; } @@ -442,20 +526,20 @@
${esc(data.recipient_body || '')} · ${esc(data.output_type || '')} · ${esc(data.tone || '')}
- ${flagBadge('citations_verified', 'Citations verified')} - ${flagBadge('deadline_mentioned', 'Deadline')} - ${flagBadge('goal_addressed', 'Goal addressed')} - ${flagBadge('tone', 'Tone')} + ${flagBadge('citations_verified', t('flag_cites'))} + ${flagBadge('deadline_mentioned', t('flag_deadline'))} + ${flagBadge('goal_addressed', t('flag_goal'))} + ${flagBadge('tone', t('flag_tone'))}
-

Norsk (bokmål) — kanonisk

+

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

- - + +
${esc(draftNo)}
@@ -463,10 +547,10 @@ ${isSameLang ? '' : `
-

${esc(userLangLabel)} — reference

+

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

- - + +
${esc(draftUser)}
@@ -475,29 +559,29 @@ ${cited.length ? `
- Cited law (${cited.length}) — each reference traces to a real corpus passage + ${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 ? `View source` : ''} + ${s.source_url ? `${esc(t('view_source'))}` : ''}
`).join('')}
- ` : '

No cited law sources — draft is plain-language (no § references available from corpus).

'} + ` : `

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

`} ${data.disclaimer ? `

${esc(data.disclaimer)}

` : ''}
-

Refine with formal citations (+1 credit)

-

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

+

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

+

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

- - - - + + + +
@@ -508,7 +592,7 @@ btn.addEventListener('click', () => { const target = btn.dataset.copy === 'no' ? draftNo : draftUser; navigator.clipboard?.writeText(target).then( - () => { btn.textContent = 'Copied ✓'; setTimeout(() => btn.textContent = 'Copy', 1500); }, + () => { btn.textContent = t('copied'); setTimeout(() => btn.textContent = t('copy'), 1500); }, () => { btn.textContent = 'Failed'; } ); }); @@ -539,9 +623,9 @@ const isSameLang = userLang === 'no'; const draftNo = data.draft_no || ''; const draftUser = data.draft_user || ''; - const jurLabel = data.jurisdiction === 'echr' ? 'ECHR (EMK + HUDOC)' - : data.jurisdiction === 'both' ? 'Norwegian + ECHR' - : 'Norwegian law'; + const 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'; @@ -553,21 +637,21 @@ slot.innerHTML = `
- Refined · ${esc(jurLabel)} + ${esc(t('refined_label'))} · ${esc(jurLabel)}
- ${flagBadge('citations_verified', 'Citations verified')} - ${flagBadge('deadline_mentioned', 'Deadline')} - ${flagBadge('goal_addressed', 'Goal addressed')} + ${flagBadge('citations_verified', t('flag_cites'))} + ${flagBadge('deadline_mentioned', t('flag_deadline'))} + ${flagBadge('goal_addressed', t('flag_goal'))}
-

Norsk (bokmål) — refined

+

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

- - + +
${esc(draftNo)}
@@ -575,10 +659,10 @@ ${isSameLang ? '' : `
-

${esc(userLangLabel)} — refined

+

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

- - + +
${esc(draftUser)}
@@ -587,13 +671,13 @@ ${cited.length ? `
- Cited authorities (${cited.length}) — ${esc(jurLabel)} + ${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 ? `View source` : ''} + ${s.source_url ? `${esc(t('view_source'))}` : ''}
`).join('')}
@@ -605,7 +689,7 @@ btn.addEventListener('click', () => { const target = btn.dataset.rcopy === 'no' ? draftNo : draftUser; navigator.clipboard?.writeText(target).then( - () => { btn.textContent = 'Copied ✓'; setTimeout(() => btn.textContent = 'Copy', 1500); }, + () => { btn.textContent = t('copied'); setTimeout(() => btn.textContent = t('copy'), 1500); }, () => { btn.textContent = 'Failed'; } ); }); diff --git a/includes/KorrespondAgent.php b/includes/KorrespondAgent.php index 08ac036..73f0223 100644 --- a/includes/KorrespondAgent.php +++ b/includes/KorrespondAgent.php @@ -63,6 +63,85 @@ final class DbnKorrespondAgent $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. * @@ -82,6 +161,8 @@ final class DbnKorrespondAgent $body = $intake['recipient_body'] ?? 'other'; $mode = $intake['mode'] ?? 'initiate'; $bodyLabel = self::BODY_LABELS[$body] ?? 'mottaker'; + $userLang = dbnToolsNormalizeUiLanguage($intake['language'] ?? 'en'); + $userLangName = dbnToolsLanguageName($userLang); // e.g. "English", "Norwegian", "Polish", "Ukrainian" $context = $this->buildContextBlob($intake); $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"], "applicable_acts": ["forvaltningsloven", "barnevernsloven", "NAV-loven", "opplæringslova", "barnehageloven", "EMK"], "jurisdiction": "kommune/fylke if known, else null", - "missing_facts": [{"key":"deadline","question":"Norwegian bokmål question to user"}], - "suggested_goal": "One-line concrete goal for this letter, in Norwegian" + "missing_facts": [{"key":"deadline","question":""}], + "suggested_goal": "One-line concrete goal for this letter, in Norwegian bokmål" } Rules: @@ -108,7 +189,8 @@ Rules: - 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. - 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: {$context} @@ -156,7 +238,7 @@ PROMPT; $bodyLabel = self::BODY_LABELS[$body] ?? 'mottaker'; // ── 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'] ?? []); if ($emit) { $emit('retrieval', [ @@ -166,19 +248,19 @@ PROMPT; } // ── 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( $intake, $classify, $retrieval['sources'], $bodyLabel, $outputType, $tone, $goal ); // ── 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); // ── Translate to user language (if not Norwegian) ─────────────────────── $draftUser = $checked['draft']; 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); } @@ -656,7 +738,7 @@ EOT, $bodyLabel = self::BODY_LABELS[$body] ?? 'mottaker'; $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); if ($emit) { $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( $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); $draftUser = $checked['draft']; 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); } diff --git a/korrespond.php b/korrespond.php index daf4db5..902e96d 100644 --- a/korrespond.php +++ b/korrespond.php @@ -118,19 +118,19 @@ require_once __DIR__ . '/includes/layout.php';
-

Ready

-

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.

+

Ready

+

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.