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
+93 -11
View File
@@ -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":"<question in {$userLangName}>"}],
"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);
}