feat: add Legal Translation tool (translate.php)

New dedicated tool for translating Norwegian legal documents (Barnevernet
letters, court decisions, correspondence) into the user's chosen language
with legal-terminology annotations.

- translate.php: new tool page with source/target language selectors,
  4-way UI lang switcher, file upload, doc picker, streaming results
- api/translate.php: NDJSON streaming endpoint; Azure GPT-4o-mini with
  legal-aware prompt that preserves Norwegian statute refs verbatim and
  annotates terms with no target-language equivalent; 2-credit cost
- assets/js/translate.js: form handler, NDJSON stream reader, copy button
- assets/css/tools.css: .lt-* styles for translation result + annotations
- includes/i18n.php: 22 lt_* keys × 4 languages; translate entry in nav
- includes/FreeTier.php: translate → 2 credits
- includes/CaseResults.php + case-result.php: translate in eligible tools,
  toolLabel, toolIcon, deriveTitle, rendering block, rerun map

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 09:59:06 +02:00
parent 21c092e0d0
commit effd3289b4
8 changed files with 842 additions and 1 deletions
+175
View File
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
@ini_set('output_buffering', '0');
@ini_set('zlib.output_compression', '0');
@ini_set('implicit_flush', '1');
while (ob_get_level() > 0) { @ob_end_clean(); }
ob_implicit_flush(true);
header('Content-Type: application/x-ndjson; charset=utf-8');
header('Cache-Control: no-store');
header('X-Accel-Buffering: no');
$startTime = microtime(true);
$language = 'en';
$emit = function (string $event, array $payload = []) use ($startTime): void {
$payload['event'] = $event;
$payload['t_ms'] = (int)round((microtime(true) - $startTime) * 1000);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
@flush();
};
try {
$input = dbnToolsJsonInput(400000);
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
$sourceLang = dbnToolsNormalizeLanguage($input['source_lang'] ?? 'no');
$targetLang = dbnToolsNormalizeLanguage($input['target_lang'] ?? 'en');
$allowedDocTypes = ['auto','barnevernet','adopsjon','emergency','samvær','fylkesnemnd','other'];
$docType = (string)($input['doc_type'] ?? 'auto');
if (!in_array($docType, $allowedDocTypes, true)) {
$docType = 'auto';
}
if ($sourceLang === $targetLang) {
throw new DbnToolsHttpException(
'Source and target languages must be different.',
422, 'same_language'
);
}
$text = dbnToolsInjectDocContent($input, dbnToolsString($input, 'text', 200000, false));
if (mb_strlen(trim($text), 'UTF-8') < 10) {
throw new DbnToolsHttpException(
'Please paste text or upload a file to translate.',
422, 'empty_text'
);
}
$ftUid = dbnToolsFreeTierCheck('translate');
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'translate');
if ($ftRemaining >= 0) {
header('X-Credits-Remaining: ' . $ftRemaining);
}
$emit('start', [
'mode' => 'translate',
'language' => $language,
'source_lang' => $sourceLang,
'target_lang' => $targetLang,
'doc_type' => $docType,
'chars' => mb_strlen($text, 'UTF-8'),
]);
$emit('progress', ['step' => 'translating', 'detail' => 'Translating…']);
$sourceName = dbnToolsLanguageName($sourceLang);
$targetName = dbnToolsLanguageName($targetLang);
$docTypeHint = $docType !== 'auto'
? "The document is of type: {$docType}. Apply appropriate Norwegian family-law terminology for this context."
: '';
$systemPrompt = <<<PROMPT
You are a professional legal translator specialising in Norwegian family law, ECHR, and child-welfare proceedings.
Task: Translate the provided text from {$sourceName} into {$targetName}.
Rules:
1. Preserve ALL Norwegian statute references verbatim as proper nouns (barnevernsloven, bvl., BRL, EMK, barnekonvensjonen, § numbers, Høyesterett, Fylkesnemnda, Barnevernet, Statsforvalteren, Bufdir, NAV, etc.).
2. Maintain formal legal register throughout.
3. Translate the complete text faithfully — do NOT summarise, add commentary, or omit any content.
4. If a Norwegian legal term has no natural equivalent in {$targetName}, translate it as closely as possible and add a brief translator's note in square brackets, e.g. [barnevernstjenesten = the Child Welfare Service, local authority].
5. After translating, list any such terms requiring explanation in the annotations array.
{$docTypeHint}
Respond with a valid JSON object:
{
"translated_text": "<full translation>",
"annotations": [
{"term": "<Norwegian term>", "explanation": "<plain-language explanation in {$targetName}>"}
],
"disclaimer": "<one-sentence AI disclaimer in {$targetName}>"
}
If no terms require annotation, return an empty array for "annotations".
PROMPT;
$azure = (new DbnAzureOpenAiGateway())->withDeployment('gpt-4o-mini');
$chars = mb_strlen($text, 'UTF-8');
$maxTokens = min(8000, max(1500, (int)($chars * 1.4)));
$response = $azure->chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $text],
], [
'json' => true,
'temperature' => 0.05,
'max_tokens' => $maxTokens,
'timeout' => 120,
]);
$rawContent = $response['choices'][0]['message']['content'] ?? '';
$decoded = $azure->decodeJsonObject($rawContent);
if ($decoded === null || empty($decoded['translated_text'])) {
throw new DbnToolsHttpException(
'Translation model returned an unexpected response. Please try again.',
502, 'bad_response'
);
}
$result = [
'ok' => true,
'translated_text' => trim((string)($decoded['translated_text'] ?? '')),
'annotations' => is_array($decoded['annotations'] ?? null) ? $decoded['annotations'] : [],
'disclaimer' => (string)($decoded['disclaimer'] ?? ''),
'source_lang' => $sourceLang,
'target_lang' => $targetLang,
'doc_type' => $docType,
'model' => 'gpt-4o-mini',
'latency_ms' => (int)round((microtime(true) - $startTime) * 1000),
];
dbnToolsLogMetadata([
'tool' => 'translate',
'language' => $language,
'ok' => true,
'latency_ms' => $result['latency_ms'],
'source_lang' => $sourceLang,
'target_lang' => $targetLang,
'deployment' => 'gpt-4o-mini',
]);
$emit('final', ['result' => $result]);
} catch (DbnToolsHttpException $e) {
$latency = (int)round((microtime(true) - $startTime) * 1000);
dbnToolsLogMetadata([
'tool' => 'translate',
'language' => $language,
'ok' => false,
'latency_ms' => $latency,
'error_code' => $e->errorCode,
]);
$emit('error', ['code' => $e->errorCode, 'message' => $e->getMessage(), 'status' => $e->status]);
} catch (Throwable $e) {
error_log('translate fatal: ' . $e->getMessage());
$latency = (int)round((microtime(true) - $startTime) * 1000);
dbnToolsLogMetadata([
'tool' => 'translate',
'language' => $language,
'ok' => false,
'latency_ms' => $latency,
'error_code' => 'internal_error',
]);
$emit('error', ['code' => 'internal_error', 'message' => 'Translation could not complete this request.']);
}
+54
View File
@@ -9397,3 +9397,57 @@ body.lt-landing {
}
.la-synthesis h3 { margin-top: 0; color: #0e7490; }
.la-synthesis h4 { margin: 0.9rem 0 0.4rem; font-size: 0.95rem; color: #155e75; }
/* ── Legal Translation ─────────────────────────────────────────────────── */
.lt-source-target-row { display: flex; gap: 1.5rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.75rem; }
.lt-source-target-row .control-label { min-width: 8rem; font-weight: 600; }
.lt-result { margin-bottom: 1.4rem; }
.lt-result__head {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.lt-result__head h3 { margin: 0; }
.lt-lang-pair {
font-size: 0.8rem;
font-family: monospace;
background: var(--surface-2, #f1f5f9);
border: 1px solid var(--border, #cbd5e1);
border-radius: 4px;
padding: 0.2rem 0.5rem;
color: #475569;
}
.lt-translated-text {
white-space: pre-wrap;
font-size: 0.95rem;
line-height: 1.75;
border: 1px solid var(--border, #cbd5e1);
border-radius: 8px;
padding: 1.25rem 1.5rem;
background: var(--surface-2, #f8fafc);
color: #1e293b;
max-height: 60vh;
overflow-y: auto;
}
.lt-copy-btn {
margin-left: auto;
font-size: 0.83rem;
padding: 0.3rem 0.75rem;
}
.lt-annotations { margin-top: 1.5rem; }
.lt-annotations h4 { margin: 0 0 0.6rem; font-size: 0.9rem; color: #475569; }
.lt-annotation {
background: var(--surface-2, #f8fafc);
border-left: 3px solid var(--accent, #0f766e);
padding: 0.45rem 0.75rem;
margin-bottom: 0.45rem;
border-radius: 0 6px 6px 0;
font-size: 0.87rem;
line-height: 1.5;
color: #334155;
}
.lt-annotation strong { font-family: monospace; color: #0e7490; }
.lt-busy { padding: 2rem; text-align: center; color: #64748b; animation: pulse 1.5s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
+336
View File
@@ -0,0 +1,336 @@
/**
* translate.js Legal Translation tool handler.
*
* Single-pass: POST text + language pair Azure GPT-4o-mini translated text
* with optional legal-term annotations. Streams NDJSON.
*
* UI strings from window.DBN_LT_I18N (populated by translate.php inline script).
*/
(function () {
'use strict';
// ── i18n helper ───────────────────────────────────────────────────────────
var I18N = window.DBN_LT_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('ltForm');
var runBtn = document.getElementById('ltRunButton');
var statusEl = document.getElementById('ltStatus');
var resultsEl = document.getElementById('ltResults');
var textarea = document.getElementById('ltInput');
var uploadZone = document.getElementById('ltUploadZone');
var uploadInput = document.getElementById('ltUploadInput');
var uploadPrompt = document.getElementById('ltUploadPrompt');
var uploadFileInfo = document.getElementById('ltUploadFileInfo');
var uploadFileList = document.getElementById('ltUploadFileList');
var uploadClear = document.getElementById('ltUploadClear');
// ── State ─────────────────────────────────────────────────────────────────
var _extractedFiles = [];
var _currentLang = window.DBN_LT_LANG || window.DBN_CURRENT_LANG || 'no';
var _lastResult = null;
// ── Lang switcher ─────────────────────────────────────────────────────────
document.querySelectorAll('.lt-lang-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
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 ───────────────────────────────────────────────────────────
if (uploadZone) {
uploadZone.addEventListener('dragover', function (e) {
e.preventDefault();
uploadZone.classList.add('is-drag-over');
});
uploadZone.addEventListener('dragleave', function (e) {
if (!uploadZone.contains(e.relatedTarget)) {
uploadZone.classList.remove('is-drag-over');
}
});
uploadZone.addEventListener('drop', function (e) {
e.preventDefault();
uploadZone.classList.remove('is-drag-over');
if (e.dataTransfer && e.dataTransfer.files.length) {
handleFiles(e.dataTransfer.files);
}
});
var browseLabel = uploadZone.querySelector('label[for="' + (uploadInput && uploadInput.id) + '"]');
if (browseLabel) {
browseLabel.addEventListener('click', function (e) { e.stopPropagation(); });
}
if (uploadInput) {
uploadInput.addEventListener('click', function (e) { e.stopPropagation(); });
}
uploadZone.addEventListener('click', function (e) {
if (e.target === uploadClear || (uploadClear && uploadClear.contains(e.target))) return;
if (e.target === uploadInput) return;
var lbl = e.target.closest && e.target.closest('label');
if (lbl && lbl.getAttribute('for') === (uploadInput && uploadInput.id)) return;
if (uploadInput) uploadInput.click();
});
}
if (uploadInput) {
uploadInput.addEventListener('change', function () {
if (uploadInput.files && uploadInput.files.length) handleFiles(uploadInput.files);
});
}
if (uploadClear) {
uploadClear.addEventListener('click', function (e) {
e.stopPropagation();
resetUpload();
});
}
function resetUpload() {
_extractedFiles = [];
if (uploadInput) uploadInput.value = '';
if (uploadPrompt) uploadPrompt.classList.remove('is-hidden');
if (uploadFileInfo) uploadFileInfo.classList.add('is-hidden');
if (uploadFileList) uploadFileList.innerHTML = '';
}
function handleFiles(fileList) {
var files = Array.from(fileList).slice(0, 5);
if (!files.length) return;
setStatus(t('extractingFiles', { n: files.length }));
setBusy(true);
var promises = files.map(function (file) {
var fd = new FormData();
fd.append('file', file);
return fetch('api/extract.php', { method: 'POST', credentials: 'same-origin', body: fd })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.ok) throw new Error(data.error || 'Extraction failed for ' + file.name);
return { name: file.name, text: data.text || '', chars: data.chars || 0 };
});
});
Promise.all(promises)
.then(function (results) {
_extractedFiles = results;
renderFileList(results);
setStatus('');
setBusy(false);
})
.catch(function (err) {
setStatus(t('errorPrefix') + ': ' + err.message);
setBusy(false);
});
}
function renderFileList(files) {
if (!uploadFileList) return;
uploadFileList.innerHTML = files.map(function (f) {
return '<li><span class="upload-filename">' + esc(f.name) + '</span>'
+ ' <span class="upload-chars">' + f.chars.toLocaleString() + ' chars</span></li>';
}).join('');
if (uploadPrompt) uploadPrompt.classList.add('is-hidden');
if (uploadFileInfo) uploadFileInfo.classList.remove('is-hidden');
}
// ── Form submission ───────────────────────────────────────────────────────
if (form) {
form.addEventListener('submit', function (e) {
e.preventDefault();
runTranslation();
});
}
async function runTranslation() {
var pastedText = textarea ? textarea.value.trim() : '';
var fileText = _extractedFiles.map(function (f) { return f.text; }).join('\n\n---\n\n');
var combined = [fileText, pastedText].filter(Boolean).join('\n\n---\n\n');
var docIdsEl = document.getElementById('docPickerIds');
var rawDocIds = docIdsEl ? docIdsEl.value.trim() : '';
var docIds = rawDocIds ? rawDocIds.split(',').map(Number).filter(Boolean) : [];
if (!combined && !docIds.length) {
setStatus(t('needInput'));
return;
}
var sourceLang = (document.querySelector('input[name="ltSourceLang"]:checked') || {}).value || 'no';
var targetLang = (document.querySelector('input[name="ltTargetLang"]:checked') || {}).value || 'en';
if (sourceLang === targetLang) {
setStatus(t('sameLangError'));
return;
}
var docType = (document.querySelector('input[name="ltDocType"]:checked') || {}).value || 'auto';
var payload = {
text: combined,
language: _currentLang,
source_lang: sourceLang,
target_lang: targetLang,
doc_type: docType,
};
if (docIds.length) payload.doc_ids = docIds;
_lastResult = null;
setBusy(true);
setStatus(t('translatingStatus'));
showBusy();
try {
var resp = await fetch('api/translate.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok || !resp.body) {
throw new Error(t('serverReturned') + ' ' + resp.status);
}
var reader = resp.body.getReader();
var decoder = new TextDecoder();
var buffer = '';
while (true) {
var _ref = await reader.read();
if (_ref.done) break;
buffer += decoder.decode(_ref.value, { stream: true });
var lines = buffer.split('\n');
buffer = lines.pop();
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var data;
try { data = JSON.parse(line); } catch (_) { continue; }
handleEvent(data, payload);
}
}
} catch (err) {
showError(err.message || 'Request failed.');
} finally {
setBusy(false);
setStatus('');
}
}
function handleEvent(data, payload) {
if (data.event === 'progress') {
setStatus(data.detail || '');
} else if (data.event === 'final') {
_lastResult = data.result || {};
renderResult(_lastResult, payload);
if (typeof window.dbnShowSaveResultButton === 'function') {
window.dbnShowSaveResultButton(
'translate',
payload || {},
_lastResult,
{
model: (_lastResult && _lastResult.model) || 'gpt-4o-mini',
latency_ms: (_lastResult && _lastResult.latency_ms) || 0,
},
resultsEl
);
}
} else if (data.event === 'error') {
showError(data.message || data.error || 'An error occurred.');
}
}
// ── Result rendering ──────────────────────────────────────────────────────
function showBusy() {
if (!resultsEl) return;
resultsEl.innerHTML = '<div class="lt-busy"><p>' + esc(t('translatingStatus')) + '</p></div>';
}
function renderResult(result, payload) {
if (!resultsEl) return;
var translatedText = (result.translated_text || '').replace(/\n/g, '<br>');
var annotations = Array.isArray(result.annotations) ? result.annotations : [];
var disclaimer = result.disclaimer || t('disclaimer');
var sourceLang = (payload && payload.source_lang) || '';
var targetLang = (payload && payload.target_lang) || '';
var copyId = 'ltCopyBtn-' + Date.now();
var html = '<section class="lt-result result-section">'
+ '<div class="lt-result__head">'
+ '<h3>' + esc(t('resultTitle')) + '</h3>'
+ (sourceLang && targetLang
? '<span class="lt-lang-pair">' + esc(sourceLang.toUpperCase()) + ' → ' + esc(targetLang.toUpperCase()) + '</span>'
: '')
+ '<button type="button" id="' + copyId + '" class="lt-copy-btn secondary-btn">'
+ esc(t('copyButton')) + '</button>'
+ '</div>'
+ '<div class="lt-translated-text">' + translatedText + '</div>';
if (annotations.length) {
html += '<div class="lt-annotations"><h4>' + esc(t('annotationsTitle')) + '</h4>';
annotations.forEach(function (ann) {
if (!ann || !ann.term) return;
html += '<div class="lt-annotation">'
+ '<strong>' + esc(ann.term) + '</strong>'
+ (ann.explanation ? ' — ' + esc(ann.explanation) : '')
+ '</div>';
});
html += '</div>';
}
if (disclaimer) {
html += '<p class="disclaimer-note">' + esc(disclaimer) + '</p>';
}
html += '</section>';
resultsEl.innerHTML = html;
var copyBtn = document.getElementById(copyId);
if (copyBtn) {
copyBtn.addEventListener('click', function () {
var plain = (result.translated_text || '').replace(/<br\s*\/?>/gi, '\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(plain).then(function () {
copyBtn.textContent = t('copyDone');
setTimeout(function () { copyBtn.textContent = t('copyButton'); }, 2000);
});
}
});
}
}
function showError(msg) {
if (resultsEl) {
resultsEl.innerHTML = '<div class="empty-state"><h3>' + esc(t('errorPrefix')) + '</h3><p>' + esc(msg) + '</p></div>';
}
setStatus('');
}
// ── Helpers ───────────────────────────────────────────────────────────────
function setBusy(on) {
if (runBtn) runBtn.disabled = on;
if (runBtn) runBtn.textContent = on ? t('runButtonBusy') : t('runButton');
}
function setStatus(msg) {
if (statusEl) statusEl.textContent = msg;
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
}());
+28
View File
@@ -298,6 +298,33 @@ function crField(string $label, string $value): string {
<?php endif; ?>
<?= crListBlock('What Remains Uncertain', (array)($output['what_remains_uncertain'] ?? [])) ?>
<?php elseif ($toolSlug === 'translate'): ?>
<?php if (!empty($output['translated_text'])): ?>
<div class="cr-block">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.6rem;">
<h3 style="margin:0">Translation</h3>
<?php if (!empty($output['source_lang']) && !empty($output['target_lang'])): ?>
<span style="font-size:0.8rem;font-family:monospace;background:#f1f5f9;border:1px solid #cbd5e1;border-radius:4px;padding:0.2rem 0.5rem;color:#475569;"><?= htmlspecialchars(strtoupper((string)$output['source_lang'])) ?> → <?= htmlspecialchars(strtoupper((string)$output['target_lang'])) ?></span>
<?php endif; ?>
</div>
<pre style="white-space:pre-wrap;font-size:0.93rem;line-height:1.75;border:1px solid #cbd5e1;border-radius:8px;padding:1.1rem 1.4rem;background:#f8fafc;color:#1e293b;max-height:60vh;overflow-y:auto;"><?= htmlspecialchars((string)$output['translated_text']) ?></pre>
</div>
<?php endif; ?>
<?php if (!empty($output['annotations']) && is_array($output['annotations'])): ?>
<div class="cr-block">
<h3>Legal term notes</h3>
<?php foreach ($output['annotations'] as $ann): if (!is_array($ann)) continue; ?>
<div style="background:#f8fafc;border-left:3px solid #0f766e;padding:0.45rem 0.75rem;margin-bottom:0.45rem;border-radius:0 6px 6px 0;font-size:0.87rem;">
<strong style="font-family:monospace;color:#0e7490;"><?= htmlspecialchars((string)($ann['term'] ?? '')) ?></strong>
<?php if (!empty($ann['explanation'])): ?> — <?= htmlspecialchars((string)$ann['explanation']) ?><?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($output['disclaimer'])): ?>
<p style="font-size:0.85rem;color:#64748b;margin-top:1rem;"><em><?= htmlspecialchars((string)$output['disclaimer']) ?></em></p>
<?php endif; ?>
<?php elseif ($toolSlug === 'legal-analysis'): ?>
<?php if (!empty($output['overall_assessment'])): ?>
<div class="cr-block">
@@ -429,6 +456,7 @@ function crField(string $label, string $value): string {
'redact': '/redact.php',
'transcribe': '/transcribe.php',
'legal-analysis':'/legal-analysis.php',
'translate': '/translate.php',
}[tool] || '/dashboard.php';
window.location.href = path + '?rerun=' + <?= (int)$result['id'] ?>;
});
+4
View File
@@ -34,6 +34,7 @@ final class CaseResults
'redact',
'transcribe',
'legal-analysis',
'translate',
];
/** True when the user is on a tier that gets saved results (Plus, Pro, or active Plus trial). */
@@ -240,6 +241,7 @@ final class CaseResults
'redact' => 'Anonymisering',
'transcribe' => 'Transkripsjon',
'legal-analysis' => 'Juridisk analyse',
'translate' => 'Oversettelse',
][$tool] ?? ucfirst($tool);
}
@@ -258,6 +260,7 @@ final class CaseResults
'redact' => '🖊️',
'transcribe' => '🎙️',
'legal-analysis' => '⚖️🇳🇴',
'translate' => '🌐',
][$tool] ?? '📄';
}
@@ -276,6 +279,7 @@ final class CaseResults
'redact' => [$input['text'] ?? null],
'transcribe' => [$input['filename'] ?? null],
'legal-analysis' => [$input['doc_type'] ?? null, $input['text'] ?? null],
'translate' => [$input['source_lang'] ?? null, $input['target_lang'] ?? null, $input['text'] ?? null],
default => [$input['title'] ?? null, $input['query'] ?? null, $input['text'] ?? null],
};
foreach ($candidates as $c) {
+1
View File
@@ -38,6 +38,7 @@ final class FreeTier
'transcribe' => 2,
'discrepancy' => 4,
'korrespond' => 3,
'translate' => 2,
];
/** Monthly credit allowance per tier. */
+98 -1
View File
@@ -435,6 +435,29 @@ function dbnToolsTranslations(): array
'la_addon_button' => '⚖️🇳🇴 Run deep legal analysis on this text',
'la_addon_button_busy' => 'Running deep legal analysis…',
'la_addon_section' => 'Deep Legal Analysis',
'lt_source_label' => 'Source language',
'lt_target_label' => 'Translate to',
'lt_doc_type_label' => 'Document type',
'lt_run_button' => 'Translate document',
'lt_run_button_busy' => 'Translating…',
'lt_input_label' => 'Paste text to translate',
'lt_input_hint' => '(optional if uploading files)',
'lt_input_placeholder' => 'Paste Norwegian legal text here…',
'lt_translating_status' => 'Translating…',
'lt_ready_title' => 'Ready to translate',
'lt_ready_intro' => 'Upload a PDF, DOCX or TXT, or paste text below.',
'lt_result_title' => 'Translation',
'lt_annotations_title' => 'Legal term notes',
'lt_copy_button' => 'Copy translation',
'lt_copy_done' => 'Copied!',
'lt_need_input' => 'Please paste text or upload a file.',
'lt_error_prefix' => 'Error',
'lt_server_returned' => 'Server returned',
'lt_extracting_files' => 'Extracting text from {n} file(s)…',
'lt_engine_hint' => 'Engine: Azure GPT-4o · Legal documents are processed in memory and never stored.',
'lt_same_lang_error' => 'Source and target languages must be different.',
'lt_disclaimer' => 'This is an AI-assisted translation. Always verify with a qualified legal interpreter for official use.',
],
'no' => [
'meta_title' => 'Do Better Norge - juridiske AI-verktøy',
@@ -802,6 +825,29 @@ function dbnToolsTranslations(): array
'la_addon_button' => '⚖️🇳🇴 Kjør dyp juridisk analyse på denne teksten',
'la_addon_button_busy' => 'Kjører dyp juridisk analyse…',
'la_addon_section' => 'Dyp juridisk analyse',
'lt_source_label' => 'Kildespråk',
'lt_target_label' => 'Oversett til',
'lt_doc_type_label' => 'Dokumenttype',
'lt_run_button' => 'Oversett dokument',
'lt_run_button_busy' => 'Oversetter…',
'lt_input_label' => 'Lim inn tekst som skal oversettes',
'lt_input_hint' => '(valgfritt ved filopplasting)',
'lt_input_placeholder' => 'Lim inn norsk juridisk tekst her…',
'lt_translating_status' => 'Oversetter…',
'lt_ready_title' => 'Klar til å oversette',
'lt_ready_intro' => 'Last opp PDF, DOCX eller TXT, eller lim inn tekst nedenfor.',
'lt_result_title' => 'Oversettelse',
'lt_annotations_title' => 'Juridiske termer',
'lt_copy_button' => 'Kopier oversettelse',
'lt_copy_done' => 'Kopiert!',
'lt_need_input' => 'Lim inn tekst eller last opp en fil.',
'lt_error_prefix' => 'Feil',
'lt_server_returned' => 'Serveren svarte',
'lt_extracting_files' => 'Henter tekst fra {n} fil(er)…',
'lt_engine_hint' => 'Motor: Azure GPT-4o · Juridiske dokumenter behandles i minnet og lagres aldri.',
'lt_same_lang_error' => 'Kilde- og målspråk må være forskjellige.',
'lt_disclaimer' => 'Dette er en AI-assistert oversettelse. Verifiser alltid med en kvalifisert juridisk tolk til offisielt bruk.',
],
'uk' => [
'meta_title' => 'Do Better Norge - юридичні AI інструменти',
@@ -1169,6 +1215,29 @@ function dbnToolsTranslations(): array
'la_addon_button' => '⚖️🇳🇴 Запустити глибокий юридичний аналіз цього тексту',
'la_addon_button_busy' => 'Виконується глибокий юридичний аналіз…',
'la_addon_section' => 'Глибокий юридичний аналіз',
'lt_source_label' => 'Мова оригіналу',
'lt_target_label' => 'Перекласти на',
'lt_doc_type_label' => 'Тип документу',
'lt_run_button' => 'Перекласти документ',
'lt_run_button_busy' => 'Перекладаю…',
'lt_input_label' => 'Вставте текст для перекладу',
'lt_input_hint' => '(необов\'язково при завантаженні)',
'lt_input_placeholder' => 'Вставте норвезький юридичний текст тут…',
'lt_translating_status' => 'Перекладаю…',
'lt_ready_title' => 'Готовий до перекладу',
'lt_ready_intro' => 'Завантажте PDF, DOCX або TXT, або вставте текст нижче.',
'lt_result_title' => 'Переклад',
'lt_annotations_title' => 'Юридичні терміни',
'lt_copy_button' => 'Копіювати переклад',
'lt_copy_done' => 'Скопійовано!',
'lt_need_input' => 'Будь ласка, вставте текст або завантажте файл.',
'lt_error_prefix' => 'Помилка',
'lt_server_returned' => 'Сервер повернув',
'lt_extracting_files' => 'Витягую текст з {n} файл(ів)…',
'lt_engine_hint' => 'Механізм: Azure GPT-4o · Юридичні документи обробляються в пам\'яті та не зберігаються.',
'lt_same_lang_error' => 'Мова оригіналу та мова перекладу повинні бути різними.',
'lt_disclaimer' => 'Це переклад за допомогою штучного інтелекту. Завжди перевіряйте з кваліфікованим юридичним перекладачем для офіційного використання.',
],
'pl' => [
'meta_title' => 'Do Better Norge - prawne narzędzia AI',
@@ -1536,6 +1605,29 @@ function dbnToolsTranslations(): array
'la_addon_button' => '⚖️🇳🇴 Uruchom głęboką analizę prawną tego tekstu',
'la_addon_button_busy' => 'Trwa głęboka analiza prawna…',
'la_addon_section' => 'Głęboka analiza prawna',
'lt_source_label' => 'Język źródłowy',
'lt_target_label' => 'Przetłumacz na',
'lt_doc_type_label' => 'Typ dokumentu',
'lt_run_button' => 'Przetłumacz dokument',
'lt_run_button_busy' => 'Tłumaczę…',
'lt_input_label' => 'Wklej tekst do tłumaczenia',
'lt_input_hint' => '(opcjonalne przy wgrywaniu pliku)',
'lt_input_placeholder' => 'Wklej tu norweski tekst prawny…',
'lt_translating_status' => 'Tłumaczę…',
'lt_ready_title' => 'Gotowy do tłumaczenia',
'lt_ready_intro' => 'Prześlij PDF, DOCX lub TXT, lub wklej tekst poniżej.',
'lt_result_title' => 'Tłumaczenie',
'lt_annotations_title' => 'Terminy prawne',
'lt_copy_button' => 'Skopiuj tłumaczenie',
'lt_copy_done' => 'Skopiowano!',
'lt_need_input' => 'Wklej tekst lub prześlij plik.',
'lt_error_prefix' => 'Błąd',
'lt_server_returned' => 'Serwer zwrócił',
'lt_extracting_files' => 'Wyodrębniam tekst z {n} plik(ów)…',
'lt_engine_hint' => 'Silnik: Azure GPT-4o · Dokumenty prawne są przetwarzane w pamięci i nigdy nie zapisywane.',
'lt_same_lang_error' => 'Języki źródłowy i docelowy muszą być różne.',
'lt_disclaimer' => 'To jest tłumaczenie wspomagane AI. Zawsze weryfikuj z wykwalifikowanym tłumaczem prawnym do oficjalnego użytku.',
],
];
}
@@ -1679,6 +1771,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
'discrepancy' => ['Discrepancy Finder', 'Document comparison', 'Upload two versions of a Barnevernet document and find contradictions, deleted facts, and new allegations.', 'Cross-document AI'],
'corpus' => ['Corpus', 'Legal knowledge base', 'Inspect indexed sources, corpus health, legal categories, and retrieval behavior.', '~220 K passages'],
'citations' => ['Citations', 'Citation graph', 'Browse the legal citation graph — what a statute cites, what cites it, and what implements or amends it.', 'Graph topology'],
'translate' => ['Translate', 'Legal translation', 'Translate Barnevernet letters and legal documents into your language with legal-terminology annotations.', 'Azure · GPT-4o'],
],
'no' => [
'transcribe' => ['Transkriber', 'Lyd og møter', 'Gjør lyd eller video om til tekst med talerinndeling og juridisk ordforråd.', 'Whisper / GPU'],
@@ -1693,6 +1786,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
'discrepancy' => ['Avviksfinner', 'Dokumentsammenligning', 'Last opp to versjoner av et barneverndokument og finn motsigelser, slettede fakta og nye påstander.', 'Kryssdokument AI'],
'corpus' => ['Korpus', 'Juridisk kunnskapsbase', 'Se indekserte kilder, korpushelse, juridiske kategorier og søkeoppsett.', '~220 K utdrag'],
'citations' => ['Siteringer', 'Siteringsgraf', 'Utforsk siteringsgrafen — hva et dokument siterer, hva som siterer det, og hva som implementerer det.', 'Grafstruktur'],
'translate' => ['Oversett', 'Juridisk oversettelse', 'Oversett Barnevernet-brev og juridiske dokumenter til ditt språk med juridisk terminologi.', 'Azure · GPT-4o'],
],
'uk' => [
'transcribe' => ['Транскрипція', 'Аудіо та зустрічі', 'Перетворюйте аудіо або відео на текст із розділенням мовців і юридичною лексикою.', 'Whisper / GPU'],
@@ -1707,6 +1801,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
'discrepancy' => ['Пошук розбіжностей', 'Порівняння документів', 'Завантажте дві версії документа Barnevernet і знайдіть суперечності, видалені факти та нові твердження.', 'Міждокументний AI'],
'corpus' => ['Корпус', 'Юридична база знань', 'Переглядайте індексовані джерела, стан корпусу, категорії та поведінку пошуку.', '~220 тис. уривків'],
'citations' => ['Граф цитувань', 'Мережа посилань', 'Граф правових посилань — що цитує документ, хто цитує його, що його реалізує.', 'Граф-топологія'],
'translate' => ['Перекласти', 'Юридичний переклад', 'Перекладайте листи Barnevernet та юридичні документи на свою мову з юридичними термінами.', 'Azure · GPT-4o'],
],
'pl' => [
'transcribe' => ['Transkrypcja', 'Audio i spotkania', 'Zamień audio lub wideo na tekst z rozdzieleniem mówców i słownictwem prawnym.', 'Whisper / GPU'],
@@ -1721,11 +1816,12 @@ function dbnToolsLaunchedTools(?string $language = null): array
'discrepancy' => ['Wyszukiwacz rozbieżności', 'Porównanie dokumentów', 'Prześlij dwie wersje dokumentu Barnevernet i znajdź sprzeczności, usunięte fakty i nowe zarzuty.', 'AI Między-dokumentowe'],
'corpus' => ['Korpus', 'Prawna baza wiedzy', 'Sprawdzaj indeksowane źródła, stan korpusu, kategorie prawne i działanie wyszukiwania.', '~220 tys. fragmentów'],
'citations' => ['Graf cytowań', 'Sieć cytowań', 'Przeglądaj sieć cytowań — co cytuje dokument, kto go cytuje i co go implementuje.', 'Topologia grafu'],
'translate' => ['Tłumacz', 'Tłumaczenie prawne', 'Tłumacz listy Barnevernet i dokumenty prawne na swój język z adnotacjami terminologicznymi.', 'Azure · GPT-4o'],
],
];
$selected = $copy[$language] ?? $copy['en'];
$order = ['transcribe', 'timeline', 'redact', 'summarize', 'legal-analysis', 'korrespond', 'barnevernet', 'advocate', 'deep-research', 'discrepancy', 'corpus', 'citations'];
$order = ['transcribe', 'timeline', 'redact', 'summarize', 'legal-analysis', 'korrespond', 'barnevernet', 'advocate', 'deep-research', 'discrepancy', 'corpus', 'citations', 'translate'];
$icons = [
'transcribe' => 'TR',
'timeline' => 'TL',
@@ -1739,6 +1835,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
'discrepancy' => 'DC',
'corpus' => 'KB',
'citations' => 'CIT',
'translate' => 'TX',
];
$out = [];
foreach ($order as $slug) {
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
$toolName = 'translate';
$toolTitle = 'Legal Translation';
$toolKind = 'Legal Translation';
$toolBadge = 'Azure · GPT-4o';
$extraScripts = ['assets/js/translate.js'];
require_once __DIR__ . '/includes/layout.php';
$ltLang = dbnToolsCurrentLanguage();
$ltT = static fn(string $k): string => dbnToolsT($k, $ltLang);
// Default source = Norwegian; target = UI language, but if UI=no, default to English
$ltDefaultTarget = ($ltLang === 'no') ? 'en' : $ltLang;
$ltI18n = [
'runButton' => $ltT('lt_run_button'),
'runButtonBusy' => $ltT('lt_run_button_busy'),
'extractingFiles' => $ltT('lt_extracting_files'),
'translatingStatus' => $ltT('lt_translating_status'),
'needInput' => $ltT('lt_need_input'),
'errorPrefix' => $ltT('lt_error_prefix'),
'serverReturned' => $ltT('lt_server_returned'),
'resultTitle' => $ltT('lt_result_title'),
'annotationsTitle' => $ltT('lt_annotations_title'),
'copyButton' => $ltT('lt_copy_button'),
'copyDone' => $ltT('lt_copy_done'),
'sameLangError' => $ltT('lt_same_lang_error'),
'disclaimer' => $ltT('lt_disclaimer'),
];
?>
<form id="ltForm" class="tool-form" novalidate>
<div class="lang-switcher" id="ltLangSwitcher" role="group" aria-label="UI language">
<button type="button" class="lang-btn lt-lang-btn <?= $ltLang === 'en' ? 'is-active' : '' ?>" data-lang="en">&#127468;&#127463; EN</button>
<button type="button" class="lang-btn lt-lang-btn <?= $ltLang === 'no' ? 'is-active' : '' ?>" data-lang="no">&#127475;&#127476; NO</button>
<button type="button" class="lang-btn lt-lang-btn <?= $ltLang === 'uk' ? 'is-active' : '' ?>" data-lang="uk">&#127482;&#127462; UK</button>
<button type="button" class="lang-btn lt-lang-btn <?= $ltLang === 'pl' ? 'is-active' : '' ?>" data-lang="pl">&#127477;&#127473; PL</button>
</div>
<div class="lt-source-target-row control-row">
<span class="control-label"><?= htmlspecialchars($ltT('lt_source_label')) ?></span>
<label><input type="radio" name="ltSourceLang" value="no" checked> &#127475;&#127476; NO</label>
<label><input type="radio" name="ltSourceLang" value="en"> &#127468;&#127463; EN</label>
<label><input type="radio" name="ltSourceLang" value="uk"> &#127482;&#127462; UK</label>
<label><input type="radio" name="ltSourceLang" value="pl"> &#127477;&#127473; PL</label>
</div>
<div class="lt-source-target-row control-row" id="ltTargetRow">
<span class="control-label"><?= htmlspecialchars($ltT('lt_target_label')) ?></span>
<label><input type="radio" name="ltTargetLang" value="en" <?= $ltDefaultTarget === 'en' ? 'checked' : '' ?>> &#127468;&#127463; EN</label>
<label><input type="radio" name="ltTargetLang" value="no" <?= $ltDefaultTarget === 'no' ? 'checked' : '' ?>> &#127475;&#127476; NO</label>
<label><input type="radio" name="ltTargetLang" value="uk" <?= $ltDefaultTarget === 'uk' ? 'checked' : '' ?>> &#127482;&#127462; UK</label>
<label><input type="radio" name="ltTargetLang" value="pl" <?= $ltDefaultTarget === 'pl' ? 'checked' : '' ?>> &#127477;&#127473; PL</label>
</div>
<div class="control-row" id="ltDocTypeControl">
<span class="control-label"><?= htmlspecialchars($ltT('lt_doc_type_label')) ?></span>
<label><input type="radio" name="ltDocType" value="auto" checked> <?= htmlspecialchars($ltT('la_doc_type_auto')) ?></label>
<label><input type="radio" name="ltDocType" value="barnevernet"> Barnevernet</label>
<label><input type="radio" name="ltDocType" value="adopsjon"> Adopsjon</label>
<label><input type="radio" name="ltDocType" value="emergency"> Akutt-plassering</label>
<label><input type="radio" name="ltDocType" value="samvær"> Samvær</label>
<label><input type="radio" name="ltDocType" value="fylkesnemnd"> Fylkesnemnd</label>
<label><input type="radio" name="ltDocType" value="other"> <?= htmlspecialchars($ltT('la_doc_type_other')) ?></label>
</div>
<p class="upload-hint"><?= htmlspecialchars($ltT('lt_engine_hint')) ?></p>
<div id="docPickerSection" class="doc-picker-section">
<button type="button" id="docPickerBtn" class="doc-picker-btn" aria-haspopup="dialog">
<svg class="doc-picker-btn__icon" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><rect x="2" y="1" width="9" height="12" rx="1.5" stroke="currentColor" stroke-width="1.4"/><path d="M5 5h5M5 8h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><rect x="7" y="9" width="6" height="5" rx="1" fill="white" stroke="currentColor" stroke-width="1.3"/><path d="M9 11h2M9 12.5h1" stroke="currentColor" stroke-width="1" stroke-linecap="round"/></svg>
<span>Select from My Docs</span>
</button>
<div id="docPickerChips" class="doc-picker-chips" aria-label="Selected documents"></div>
<input type="hidden" id="docPickerIds" name="doc_ids" value="">
</div>
<div class="upload-zone" id="ltUploadZone" role="region" aria-label="File upload">
<input type="file" id="ltUploadInput" multiple accept=".pdf,.docx,.txt" aria-label="Choose files to translate">
<div id="ltUploadPrompt" class="upload-prompt">
<span class="upload-icon" aria-hidden="true">&#8679;</span>
<p>Drop up to 5 files here, or <label for="ltUploadInput" class="upload-browse">browse</label></p>
<p class="upload-hint"><strong>PDF</strong>, <strong>DOCX</strong>, <strong>TXT</strong> &mdash; text extracted in memory, never stored</p>
</div>
<div id="ltUploadFileInfo" class="upload-file is-hidden">
<ul id="ltUploadFileList" class="upload-file-list"></ul>
<button type="button" id="ltUploadClear" class="upload-clear">&times; Clear files</button>
</div>
</div>
<label class="input-label" for="ltInput"><?= htmlspecialchars($ltT('lt_input_label')) ?> <small class="control-hint"><?= htmlspecialchars($ltT('lt_input_hint')) ?></small></label>
<textarea id="ltInput" name="text" rows="8" placeholder="<?= htmlspecialchars($ltT('lt_input_placeholder')) ?>"></textarea>
<div class="form-footer">
<p id="ltStatus" class="form-status" role="status" aria-live="polite"></p>
<button id="ltRunButton" type="submit"><?= htmlspecialchars($ltT('lt_run_button')) ?></button>
</div>
</form>
<section id="ltResults" class="results" aria-live="polite">
<div class="empty-state">
<h3><?= htmlspecialchars($ltT('lt_ready_title')) ?></h3>
<p><?= htmlspecialchars($ltT('lt_ready_intro')) ?></p>
</div>
</section>
<script>
window.DBN_LT_I18N = <?= json_encode($ltI18n, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>;
window.DBN_LT_LANG = <?= json_encode($ltLang) ?>;
</script>
<!-- Hidden stubs so tools.js element refs don't crash on this page -->
<div class="is-hidden" id="languageControl" aria-hidden="true">
<input type="radio" name="language" value="en" <?= $ltLang === 'en' ? 'checked' : '' ?>>
<input type="radio" name="language" value="no" <?= $ltLang === 'no' ? 'checked' : '' ?>>
<input type="radio" name="language" value="uk" <?= $ltLang === 'uk' ? 'checked' : '' ?>>
<input type="radio" name="language" value="pl" <?= $ltLang === 'pl' ? 'checked' : '' ?>>
</div>
<div class="is-hidden" id="redactionControl" aria-hidden="true"></div>
<div class="is-hidden" id="audioZone" aria-hidden="true">
<input type="file" id="audioInput" style="display:none">
<div id="audioPrompt"></div>
<div id="audioFileInfo"><ol id="audioQueueList"></ol><button type="button" id="audioClear"></button></div>
</div>
<div class="is-hidden" id="diarizeControl" aria-hidden="true">
<input type="checkbox" id="diarizeCheck">
<input type="number" id="numSpeakersInput">
</div>
<div class="is-hidden" id="transcribeLangControl" aria-hidden="true"><input type="radio" name="transcribeLang" value="no" checked></div>
<div class="is-hidden" id="vocabControl" aria-hidden="true">
<div id="vocabPresets"></div>
<textarea id="initPromptInput"></textarea>
</div>
<div class="is-hidden" id="aliasSection" aria-hidden="true">
<button type="button" id="addAliasRow"></button>
<div id="aliasRows"></div>
</div>
<div class="is-hidden" id="exemptSection" aria-hidden="true">
<button type="button" id="addExemptRow"></button>
<div id="exemptRows"></div>
</div>
<div class="is-hidden" id="uploadZone" aria-hidden="true">
<input type="file" id="uploadInput">
<div id="uploadPrompt"></div>
<div id="uploadFileInfo"><ul id="uploadFileList"></ul><button type="button" id="uploadClear"></button></div>
</div>
<?php require_once __DIR__ . '/includes/layout_footer.php'; ?>