Legal Analysis: full language follow-through (UI + LLM)

The tool now respects the chosen UI language end-to-end — even if the
source document is Norwegian, a user on EN/UK/PL gets the analysis in
their language. Norwegian statute references (barnevernsloven § 4-25,
EMK Art. 8) and case names (Strand Lobben mot Norge 37283/13) are kept
verbatim because they are proper nouns.

LLM (LegalAnalysisAgent.php):
- extractIssues: prompt asks for question + brief_context in user's
  language; statute refs preserved
- answerIssue: Norwegian core system prompt (keeps fine-tune precision)
  + language-coercion line for non-NO; localised context/source labels
- synthesise: overall_assessment, next_steps, disclaimer in user's
  language; explicit per-language disclaimer text
- runFullAnalysis empty-case fallback also localised
- what_to_check translated per language

UI:
- 40 new la_* translation keys in i18n.php × 4 languages (NO/EN/UK/PL)
- legal-analysis.php: 4-way lang switcher, dbnToolsT() for every label,
  emits window.DBN_LA_I18N for runtime JS strings
- legal-analysis.js: t() helper reads from window.DBN_LA_I18N
- layout_footer.php: emits window.DBN_CURRENT_LANG +
  window.DBN_ADDON_I18N so the legal-analysis add-on button works in
  the page's language no matter which tool it's invoked from
- tools.js add-on: reads from DBN_ADDON_I18N, passes DBN_CURRENT_LANG
  to /api/legal-analysis.php so server responds in same language

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 08:43:15 +02:00
parent 2509a596c1
commit 21c092e0d0
6 changed files with 397 additions and 89 deletions
+45 -35
View File
@@ -4,10 +4,25 @@
* Two-pass flow: extract distinct legal issues (Azure) → answer each issue
* with dbn-legal-agent-v3 fine-tune on ocelot → synthesise overall assessment.
* Streams NDJSON so the UI fills in as each issue completes.
*
* UI strings come from window.DBN_LA_I18N (populated server-side from
* dbnToolsT('la_*')), so the same JS works in EN/NO/UK/PL.
*/
(function () {
'use strict';
// ── i18n helper ───────────────────────────────────────────────────────────
var I18N = window.DBN_LA_I18N || {};
function t(key, vars) {
var s = (I18N && I18N[key]) || key;
if (vars) {
Object.keys(vars).forEach(function (k) {
s = s.split('{' + k + '}').join(String(vars[k]));
});
}
return s;
}
// ── Element refs ──────────────────────────────────────────────────────────
var form = document.getElementById('laForm');
var runBtn = document.getElementById('laRunButton');
@@ -24,21 +39,23 @@
// ── State ─────────────────────────────────────────────────────────────────
var _extractedFiles = [];
var _currentLang = 'no';
var _currentLang = window.DBN_LA_LANG || 'no';
var _lastPayload = null;
var _issueCards = {}; // id -> DOM element
var _issueCards = {};
// ── Lang switcher ─────────────────────────────────────────────────────────
document.querySelectorAll('.la-lang-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('.la-lang-btn').forEach(function (b) {
b.classList.toggle('is-active', b === btn);
});
_currentLang = btn.dataset.lang || 'no';
// Switching UI language requires a page reload so all labels rerender.
var lang = btn.dataset.lang || 'no';
if (lang === _currentLang) return;
var url = new URL(window.location.href);
url.searchParams.set('lang', lang);
window.location.href = url.toString();
});
});
// ── File upload (same pattern as summarize) ───────────────────────────────
// ── File upload ───────────────────────────────────────────────────────────
if (uploadZone) {
uploadZone.addEventListener('dragover', function (e) {
e.preventDefault();
@@ -68,7 +85,6 @@
uploadZone.addEventListener('click', function (e) {
if (e.target === uploadClear || (uploadClear && uploadClear.contains(e.target))) return;
if (e.target === uploadInput) return;
// Any label or descendant of a label-for=uploadInput already triggered the input
var lbl = e.target.closest && e.target.closest('label');
if (lbl && lbl.getAttribute('for') === uploadInput.id) return;
if (uploadInput) uploadInput.click();
@@ -98,7 +114,7 @@
var files = Array.from(fileList).slice(0, 5);
if (!files.length) return;
setStatus('Extracting text from ' + files.length + ' file(s)…');
setStatus(t('extractingFiles', { n: files.length }));
setBusy(true);
var promises = files.map(function (file) {
@@ -120,7 +136,7 @@
setBusy(false);
})
.catch(function (err) {
setStatus('Error: ' + err.message);
setStatus(t('errorPrefix') + ' ' + err.message);
setBusy(false);
});
}
@@ -153,7 +169,7 @@
var docIds = rawDocIds ? rawDocIds.split(',').map(Number).filter(Boolean) : [];
if (!combined && !docIds.length) {
setStatus('Paste text, upload a file, or select a document before running.');
setStatus(t('needInput'));
return;
}
@@ -169,7 +185,7 @@
_issueCards = {};
setBusy(true);
setStatus('Running…');
setStatus(t('runButtonBusy'));
renderInitial();
try {
@@ -181,7 +197,7 @@
});
if (!resp.ok || !resp.body) {
throw new Error('Server returned ' + resp.status);
throw new Error(t('serverReturned') + ' ' + resp.status);
}
var reader = resp.body.getReader();
@@ -244,7 +260,7 @@
if (!resultsEl) return;
resultsEl.innerHTML =
'<div class="la-pipeline">'
+ '<div class="la-step running"><strong>Pass 1</strong> — Extracting legal issues from your document…</div>'
+ '<div class="la-step running"><strong>' + esc(t('pass1')) + '</strong> — ' + esc(t('pass1Extracting')) + '</div>'
+ '</div>'
+ '<ol id="laIssueList" class="la-issues"></ol>';
}
@@ -267,15 +283,14 @@
+ '</div>'
+ '<h4 class="la-issue__q">' + esc(issue.question) + '</h4>'
+ (issue.brief_context ? '<p class="la-issue__ctx"><em>' + esc(issue.brief_context) + '</em></p>' : '')
+ '<div class="la-issue__status">Waiting…</div>';
+ '<div class="la-issue__status">' + esc(t('waiting')) + '</div>';
list.appendChild(li);
_issueCards[issue.id] = li;
});
// Mark Pass 1 complete
var pipeline = resultsEl.querySelector('.la-pipeline');
if (pipeline) {
pipeline.innerHTML = '<div class="la-step done"><strong>Pass 1</strong> — Found ' + issues.length + ' legal issue(s)</div>'
+ '<div class="la-step running"><strong>Pass 2</strong> — Asking dbn-legal-agent-v3 about each issue…</div>';
pipeline.innerHTML = '<div class="la-step done"><strong>' + esc(t('pass1')) + '</strong> — ' + esc(t('pass1Found', { n: issues.length })) + '</div>'
+ '<div class="la-step running"><strong>' + esc(t('pass2')) + '</strong> — ' + esc(t('pass2Asking')) + '</div>';
}
}
@@ -285,10 +300,10 @@
var status = card.querySelector('.la-issue__status');
if (!status) return;
if (step === 'issue_searching_corpus') {
status.textContent = 'Searching legal corpus';
status.textContent = t('searchingCorpus');
card.classList.add('running');
} else if (step === 'issue_answering') {
status.textContent = 'Asking dbn-legal-agent-v3…';
status.textContent = t('askingFinetune');
}
}
@@ -299,7 +314,6 @@
card.classList.remove('pending', 'running');
card.classList.add('answered');
// Refresh severity from real answer
var sevEl = card.querySelector('.la-severity');
if (sevEl) {
sevEl.className = 'la-severity la-severity-' + esc(issue.severity || 'medium');
@@ -309,13 +323,13 @@
var statusBlock = card.querySelector('.la-issue__status');
if (statusBlock) statusBlock.remove();
var answerHtml = '<div class="la-issue__answer"><h5>Svar</h5><p>'
var answerHtml = '<div class="la-issue__answer"><h5>' + esc(t('answerHeader')) + '</h5><p>'
+ esc(issue.answer || '').replace(/\n/g, '<br>')
+ '</p></div>';
var basisHtml = '';
if (issue.legal_basis) {
basisHtml = '<div class="la-issue__basis"><strong>Lovgrunnlag:</strong> '
basisHtml = '<div class="la-issue__basis"><strong>' + esc(t('legalBasis')) + '</strong> '
+ esc(issue.legal_basis) + '</div>';
}
var checkHtml = '';
@@ -326,14 +340,13 @@
}
function renderFinal(result) {
// Add Pass 3 synthesis at the top
var topHtml = '';
if (result.overall_assessment) {
topHtml = '<section class="la-synthesis result-section">'
+ '<h3>Overall assessment</h3>'
+ '<h3>' + esc(t('overall')) + '</h3>'
+ '<p>' + esc(result.overall_assessment) + '</p>';
if (Array.isArray(result.next_steps) && result.next_steps.length) {
topHtml += '<h4>Next steps</h4><ul>'
topHtml += '<h4>' + esc(t('nextSteps')) + '</h4><ul>'
+ result.next_steps.map(function (s) { return '<li>' + esc(s) + '</li>'; }).join('')
+ '</ul>';
}
@@ -343,32 +356,29 @@
topHtml += '</section>';
}
// Mark Pass 2/3 complete in the pipeline strip
var pipeline = resultsEl.querySelector('.la-pipeline');
if (pipeline) {
pipeline.innerHTML =
'<div class="la-step done"><strong>Pass 1</strong> — Issues extracted</div>'
+ '<div class="la-step done"><strong>Pass 2</strong> — Specialist answered ' + (result.issues || []).length + ' issue(s)</div>'
+ '<div class="la-step done"><strong>Pass 3</strong> — Synthesis complete</div>';
'<div class="la-step done"><strong>' + esc(t('pass1')) + '</strong> — ' + esc(t('pass1Found', { n: (result.issues || []).length })) + '</div>'
+ '<div class="la-step done"><strong>' + esc(t('pass2')) + '</strong> — ' + esc(t('pass2Answered', { n: (result.issues || []).length })) + '</div>'
+ '<div class="la-step done"><strong>' + esc(t('pass3')) + '</strong> — ' + esc(t('pass3Synthesis')) + '</div>';
}
// Insert synthesis at top of resultsEl, after pipeline
if (topHtml && pipeline) {
pipeline.insertAdjacentHTML('afterend', topHtml);
} else if (topHtml) {
resultsEl.insertAdjacentHTML('afterbegin', topHtml);
}
// If issues weren't already rendered (e.g. empty result), put the message in
if ((!result.issues || !result.issues.length) && resultsEl.querySelector('#laIssueList')) {
resultsEl.querySelector('#laIssueList').innerHTML =
'<li class="la-issue answered"><p><em>No discrete legal issues were identified.</em></p></li>';
'<li class="la-issue answered"><p><em>' + esc(t('emptyIssues')) + '</em></p></li>';
}
}
function showError(msg) {
if (resultsEl) {
resultsEl.innerHTML = '<div class="empty-state"><h3>Error</h3><p>' + esc(msg) + '</p></div>';
resultsEl.innerHTML = '<div class="empty-state"><h3>' + esc(t('errorPrefix')) + '</h3><p>' + esc(msg) + '</p></div>';
}
setStatus('');
}
@@ -376,7 +386,7 @@
// ── Helpers ───────────────────────────────────────────────────────────────
function setBusy(on) {
if (runBtn) runBtn.disabled = on;
if (runBtn) runBtn.textContent = on ? 'Running…' : 'Run legal analysis';
if (runBtn) runBtn.textContent = on ? t('runButtonBusy') : t('runButton');
}
function setStatus(msg) {
if (statusEl) statusEl.textContent = msg;