Add Summarize Document tool — engine selector, file upload, optional corpus enrichment

- summarize.php: full custom inline form (replaces tool_form.php wrapper) with
  lang switcher, azure_mini/azure_full/gpu engine selector, 8 corpus-slice
  toggles (all off by default), doc picker, file upload zone, and textarea
- api/summarize.php: rewritten to streaming NDJSON (matches barnevernet pattern);
  accepts JSON payload with text, language, engine, slices[], doc_ids[]
- includes/LegalTools.php: adds corpusContextForSummarize() (keyword search via
  ClientRagPipeline) and summarizeWithContext() (engine-aware LLM call with
  optional corpus prepend); returns structured JSON matching existing summarize format
- assets/js/summarize.js: self-contained IIFE handling file upload via
  api/extract.php, slice toggles, NDJSON stream reader, result rendering,
  and trace panel update
- includes/i18n.php: adds 'summarize' to nav in all 4 languages (EN/NO/UK/PL),
  inserted after 'redact' in the tool order with icon 'SZ'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 23:25:40 +02:00
parent 8587ec372f
commit e768662efe
5 changed files with 742 additions and 12 deletions
+74 -6
View File
@@ -2,13 +2,81 @@
declare(strict_types=1);
require_once __DIR__ . '/../includes/LegalTools.php';
require_once __DIR__ . '/../includes/ToolModels.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
$input = dbnToolsJsonInput(70000);
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
$ftUid = dbnToolsFreeTierCheck('summarize');
dbnToolsWithTelemetry('summarize', $language, function () use ($input, $language): array {
$text = dbnToolsString($input, 'text', 32000);
return (new DbnLegalToolsService())->summarize($text, $language);
});
$input = dbnToolsJsonInput(400000);
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
$engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? 'azure_mini'));
$slices = is_array($input['slices'] ?? null) ? array_values(array_filter($input['slices'])) : [];
// Streaming headers — flush each NDJSON line as it's written
header('Content-Type: application/x-ndjson; charset=utf-8');
header('X-Accel-Buffering: no');
header('Cache-Control: no-cache, no-store');
while (ob_get_level()) {
ob_end_clean();
}
$emit = static function (string $event, array $payload): void {
echo json_encode(['event' => $event] + $payload, JSON_UNESCAPED_UNICODE) . "\n";
flush();
};
try {
$text = dbnToolsInjectDocContent($input, dbnToolsString($input, 'text', 128000));
if (mb_strlen(trim($text), 'UTF-8') < 50) {
$emit('error', [
'error' => 'Paste text, upload a file, or select a document before running.',
'code' => 'empty_text',
]);
exit;
}
$emit('progress', [
'step' => 'text_ready',
'detail' => mb_strlen($text, 'UTF-8') . ' chars ready.',
]);
$corpusContext = '';
if (!empty($slices)) {
$sliceCount = count($slices);
$emit('progress', [
'step' => 'corpus_search',
'detail' => 'Searching legal corpus (' . $sliceCount . ' slice' . ($sliceCount === 1 ? '' : 's') . ')…',
]);
$svc = new DbnLegalToolsService();
$query = mb_substr(trim($text), 0, 600, 'UTF-8');
$corpusContext = $svc->corpusContextForSummarize($query, 8);
$emit('progress', [
'step' => 'corpus_done',
'detail' => $corpusContext !== ''
? 'Legal context retrieved.'
: 'No matching passages found; summarising without corpus.',
]);
}
$emit('progress', [
'step' => 'generating',
'detail' => 'Generating summary…',
]);
$result = (new DbnLegalToolsService())->summarizeWithContext($text, $language, $engine, $corpusContext);
if ($ftUid > 0) {
$balance = dbnToolsFreeTierDeduct($ftUid, 'summarize');
$result['balance'] = $balance;
}
$emit('final', $result);
} catch (DbnToolsHttpException $e) {
$emit('error', ['error' => $e->getMessage(), 'code' => (string)$e->getCode()]);
} catch (Throwable $e) {
error_log('summarize error: ' . $e->getMessage());
$emit('error', ['error' => 'An unexpected error occurred. Please try again.', 'code' => 'server_error']);
}
+357
View File
@@ -0,0 +1,357 @@
/**
* summarize.js — Custom handler for the Summarize Document tool.
*
* Handles file upload (via api/extract.php), corpus slice toggles,
* engine selection, JSON submission, and NDJSON streaming response.
*/
(function () {
'use strict';
// ── Element refs ──────────────────────────────────────────────────────────
var form = document.getElementById('sumForm');
var runBtn = document.getElementById('sumRunButton');
var statusEl = document.getElementById('sumStatus');
var resultsEl = document.getElementById('sumResults');
var traceList = document.getElementById('traceList');
var textarea = document.getElementById('sumInput');
var uploadZone = document.getElementById('sumUploadZone');
var uploadInput = document.getElementById('sumUploadInput');
var uploadPrompt = document.getElementById('sumUploadPrompt');
var uploadFileInfo = document.getElementById('sumUploadFileInfo');
var uploadFileList = document.getElementById('sumUploadFileList');
var uploadClear = document.getElementById('sumUploadClear');
// ── State ─────────────────────────────────────────────────────────────────
var _extractedFiles = []; // [{ name, text, chars }]
var _currentLang = 'en';
// ── Lang switcher ─────────────────────────────────────────────────────────
document.querySelectorAll('.sum-lang-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('.sum-lang-btn').forEach(function (b) {
b.classList.toggle('is-active', b === btn);
});
_currentLang = btn.dataset.lang || 'en';
});
});
// ── Corpus slice toggles ──────────────────────────────────────────────────
document.querySelectorAll('.sum-slice').forEach(function (btn) {
btn.addEventListener('click', function () {
var on = btn.classList.toggle('is-on');
btn.setAttribute('aria-pressed', String(on));
var badge = btn.querySelector('.dr-slice__badge');
if (badge) badge.textContent = on ? 'on' : 'off';
});
});
function activeSlices() {
var out = [];
document.querySelectorAll('.sum-slice.is-on').forEach(function (btn) {
if (btn.dataset.slice) out.push(btn.dataset.slice);
});
return out;
}
// ── 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);
}
});
uploadZone.addEventListener('click', function (e) {
if (e.target === uploadClear || (uploadClear && uploadClear.contains(e.target))) return;
if (e.target.tagName === 'LABEL') 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('Extracting text from ' + files.length + ' file(s)…');
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('Error: ' + 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();
runSummarize();
});
}
async function runSummarize() {
// Build text: extracted files + textarea
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');
// Doc picker ids
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('Paste text, upload a file, or select a document before running.');
return;
}
var engine = (document.querySelector('input[name="sumEngine"]:checked') || {}).value || 'azure_mini';
var slices = activeSlices();
var payload = {
text: combined,
language: _currentLang,
engine: engine,
slices: slices,
};
if (docIds.length) payload.doc_ids = docIds;
setBusy(true);
setStatus('Running…');
showProgress([]);
try {
var resp = await fetch('api/summarize.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok || !resp.body) {
throw new Error('Server returned ' + resp.status);
}
var reader = resp.body.getReader();
var decoder = new TextDecoder();
var buffer = '';
var steps = [];
while (true) {
var _ref = await reader.read();
var done = _ref.done;
var value = _ref.value;
if (done) break;
buffer += decoder.decode(value, { stream: true });
var lines = buffer.split('\n');
buffer = lines.pop(); // keep incomplete line
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; }
if (data.event === 'progress') {
steps.push(data);
showProgress(steps);
setStatus(data.detail || '');
} else if (data.event === 'final') {
renderFinal(data);
if (data.balance != null) {
var credEl = document.getElementById('creditsRemaining');
if (credEl) credEl.textContent = data.balance;
}
} else if (data.event === 'error') {
showError(data.error || 'An error occurred.');
}
}
}
} catch (err) {
showError(err.message || 'Request failed.');
} finally {
setBusy(false);
setStatus('');
}
}
// ── Result rendering ──────────────────────────────────────────────────────
function showProgress(steps) {
if (!resultsEl) return;
var items = steps.map(function (s) {
return '<li><span class="trace-status running"></span>'
+ '<div><strong>' + esc(stepLabel(s.step)) + '</strong>'
+ '<p>' + esc(s.detail || '') + '</p></div></li>';
}).join('');
resultsEl.innerHTML = '<ol class="trace-list">' + items
+ '<li><span class="trace-status running"></span><div><strong>Working…</strong></div></li></ol>';
}
function stepLabel(step) {
var labels = {
text_ready: 'Document prepared',
corpus_search: 'Searching legal corpus',
corpus_done: 'Corpus search done',
generating: 'Generating summary',
};
return labels[step] || step;
}
function renderFinal(data) {
if (!resultsEl) return;
var sections = [];
if (data.what_we_found) {
sections.push(
'<section class="result-section">'
+ '<h3>Summary</h3>'
+ '<p>' + esc(data.what_we_found) + '</p>'
+ '</section>'
);
}
if (Array.isArray(data.key_facts) && data.key_facts.length) {
sections.push(detailBlock('Key Facts', data.key_facts));
}
if (Array.isArray(data.dates) && data.dates.length) {
sections.push(detailBlock('Dates', data.dates));
}
if (Array.isArray(data.parties) && data.parties.length) {
sections.push(detailBlock('Parties', data.parties));
}
if (Array.isArray(data.legal_references_detected) && data.legal_references_detected.length) {
sections.push(detailBlock('Legal References Detected', data.legal_references_detected));
}
if (Array.isArray(data.what_remains_uncertain) && data.what_remains_uncertain.length) {
sections.push(detailBlock('What Remains Uncertain', data.what_remains_uncertain));
}
if (data.next_practical_step) {
sections.push(
'<section class="result-section">'
+ '<h3>Next Practical Step</h3>'
+ '<p>' + esc(data.next_practical_step) + '</p>'
+ '</section>'
);
}
if (data.corpus_used) {
sections.push(
'<p class="upload-hint" style="margin-top:.5rem">'
+ 'Summary enriched with relevant passages from the Do Better Norge legal corpus.'
+ '</p>'
);
}
if (data.disclaimer) {
sections.push('<p class="disclaimer-note">' + esc(data.disclaimer) + '</p>');
}
resultsEl.innerHTML = sections.join('');
// Update reasoning panel trace
if (traceList && Array.isArray(data.trace) && data.trace.length) {
traceList.innerHTML = data.trace.map(function (item) {
return '<li>'
+ '<span class="trace-status ' + esc(item.status || 'complete') + '"></span>'
+ '<div><strong>' + esc(item.label || '') + '</strong>'
+ '<p>' + esc(item.detail || '') + '</p></div>'
+ '</li>';
}).join('');
}
}
function showError(msg) {
if (resultsEl) {
resultsEl.innerHTML = '<div class="empty-state"><h3>Error</h3><p>' + esc(msg) + '</p></div>';
}
setStatus('');
}
function detailBlock(title, items) {
return '<div class="detail-block"><h4>' + esc(title) + '</h4>'
+ '<ul>' + items.map(function (v) { return '<li>' + esc(String(v)) + '</li>'; }).join('') + '</ul>'
+ '</div>';
}
// ── Helpers ───────────────────────────────────────────────────────────────
function setBusy(on) {
if (runBtn) runBtn.disabled = on;
if (runBtn) runBtn.textContent = on ? 'Running…' : 'Summarize';
}
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;');
}
}());
+147
View File
@@ -1211,6 +1211,153 @@ PROMPT;
return dbnToolsCallGpuLlm($messages, $options);
}
// ── Summarize: corpus context + engine-aware summary ─────────────────────
/**
* Search the shared legal corpus and return top-N passages as a formatted
* context string. Returns '' on failure so the caller can degrade gracefully.
*/
public function corpusContextForSummarize(string $query, int $limit = 8): string
{
try {
$client = dbnToolsRequireClient();
$package = $this->requireFamilyPackage((int)$client['id']);
dbnToolsBootCaveau();
$gatewayUrl = 'http://10.0.1.10:4000';
try {
$config = getConfig();
$u = trim((string)($config['ai_gateway']['url'] ?? ''));
if ($u !== '') $gatewayUrl = $u;
} catch (Throwable) {}
$rag = new ClientRagPipeline((int)$client['id'], $gatewayUrl, 20);
$chunks = $rag->searchAll($query, $limit, null, [
'search_private' => true,
'search_shared' => true,
'package_ids' => [(int)$package['id']],
'chunk_limit' => $limit,
'search_method' => 'keyword',
'min_private' => 0,
'include_beta_website' => true,
]);
$parts = [];
foreach ($chunks as $c) {
$title = (string)($c['title'] ?? ($c['source'] ?? 'Legal source'));
$content = (string)($c['content'] ?? ($c['text'] ?? ''));
if ($content !== '') {
$parts[] = "=== {$title} ===\n{$content}";
}
}
return implode("\n\n", $parts);
} catch (Throwable $e) {
error_log('summarize corpus search failed: ' . $e->getMessage());
return '';
}
}
/**
* Engine-aware structured summarization, optionally enriched with corpus context.
*/
public function summarizeWithContext(
string $text,
string $language = 'en',
string $engine = 'azure_mini',
string $corpusContext = ''
): array {
$text = $this->requirePasteText($text);
$engine = in_array($engine, ['azure_mini', 'azure_full', 'gpu'], true) ? $engine : 'azure_mini';
$locale = dbnToolsLanguageName($language);
$enriched = $text;
$corpusUsed = $corpusContext !== '';
if ($corpusUsed) {
$enriched = "[Relevant legal context from Do Better Norge corpus]\n"
. $corpusContext
. "\n\n---\n\nDocument to summarise:\n"
. $text;
}
$prompt = <<<PROMPT
Summarise the following document in {$locale}. Do not invent facts not present in the text.
Return JSON only — no extra text before or after the JSON object.
{$enriched}
Return this JSON structure:
{
"what_we_found": "plain-language summary (2-4 sentences)",
"key_facts": ["fact 1", "fact 2"],
"dates": ["date or event phrase"],
"parties": ["party or role"],
"legal_references_detected": ["statute, article, or case name"],
"what_remains_uncertain": ["uncertainty or gap"],
"next_practical_step": "one concrete next action"
}
PROMPT;
$system = $this->legalJsonSystemPrompt($language);
$messages = [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $prompt],
];
$maxTok = ($engine === 'azure_full') ? 8000 : 4000;
$chatOpts = ['json' => true, 'temperature' => 0.1, 'max_tokens' => $maxTok, 'timeout' => 120];
$deployLabel = $this->azure->chatDeployment();
try {
if ($engine === 'gpu') {
$response = $this->callGpuLlm($messages, $chatOpts);
$deployLabel = 'GPU (local)';
} elseif ($engine === 'azure_full') {
$response = $this->azure->withDeployment('gpt-4o')->chat($messages, $chatOpts);
$deployLabel = 'gpt-4o';
} else {
$response = $this->azure->withDeployment('gpt-4o-mini')->chat($messages, $chatOpts);
$deployLabel = 'gpt-4o-mini';
}
} catch (Throwable $e) {
dbnToolsAbort('LLM request failed: ' . $e->getMessage(), 502, 'llm_error');
}
$raw = (string)($response['choices'][0]['message']['content'] ?? '');
$json = $this->azure->decodeJsonObject($raw);
if (!$json) {
dbnToolsAbort('LLM returned unparseable JSON.', 502, 'llm_parse_error');
}
$corpusNote = $corpusUsed
? 'Summary enriched with ' . count(array_filter(explode('=== ', $corpusContext))) . ' passage(s) from the Do Better Norge legal corpus.'
: 'No corpus search performed; summarised from document text only.';
$trace = [
$this->trace('Document preparation', 'Text validated and prepared for summarisation.', 'complete'),
$this->trace('Corpus enrichment', $corpusNote, $corpusUsed ? 'complete' : 'complete'),
$this->trace('Summary generation', 'Structured summary generated via ' . $deployLabel . '.', 'complete'),
$this->trace('Uncertainty', $this->uncertaintySummary($json['what_remains_uncertain'] ?? []), 'complete'),
$this->trace('Next practical step', (string)($json['next_practical_step'] ?? 'Review the summary against the original document.'), 'complete'),
];
return [
'tool' => 'summarize',
'language' => $language,
'what_we_found' => (string)($json['what_we_found'] ?? ''),
'key_facts' => $json['key_facts'] ?? [],
'dates' => $json['dates'] ?? [],
'parties' => $json['parties'] ?? [],
'legal_references_detected' => $json['legal_references_detected'] ?? [],
'what_remains_uncertain' => $json['what_remains_uncertain'] ?? [],
'next_practical_step' => (string)($json['next_practical_step'] ?? ''),
'corpus_used' => $corpusUsed,
'trace' => $trace,
'trace_metadata' => [
'chunk_count' => 1,
'source_count' => 1,
'deployment' => $deployLabel,
],
'disclaimer' => dbnToolsDisclaimer($language),
];
}
private function applyGenericTags(string $text): string
{
// Collapse contextual role tags (e.g. [FATHER], [JUDGE: Andersen], [CHILD_1]) → [PERSON]
+6 -1
View File
@@ -1510,6 +1510,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
'transcribe' => ['Transcribe', 'Audio and meetings', 'Turn audio or video into text with speaker separation and legal vocabulary support.', 'Whisper / GPU'],
'timeline' => ['Timeline', 'Events and deadlines', 'Extract dates, hearings, Barnevernet milestones, and legal deadlines from notes or files.', 'Process-and-forget'],
'redact' => ['Redact', 'Privacy protection', 'Remove names, ID numbers, phone numbers, and addresses before sharing documents.', 'Deterministic first'],
'summarize' => ['Summarize', 'Document summary', 'Extract key facts, dates, parties, and legal references from any document — with optional legal corpus enrichment.', 'Process-and-forget'],
'korrespond' => ['Korrespond', 'Draft & reply to authorities', 'Draft replies or new correspondence to NAV, Barnevernet, schools, Bufdir and other Norwegian authorities — Norwegian + your language, side-by-side, citations verified against the legal corpus.', 'Hard-RAG · Norsk + EN/PL/UK'],
'barnevernet' => ['BVJ Analyzer', 'Barnevernet documents', 'Analyze child-welfare documents from your perspective with procedural red flags and citations.', 'Document + RAG'],
'advocate' => ['Advocate', 'Partisan brief', 'Choose who you represent and generate a source-grounded brief for that position.', 'ECHR + Lovdata'],
@@ -1522,6 +1523,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
'transcribe' => ['Transkriber', 'Lyd og møter', 'Gjør lyd eller video om til tekst med talerinndeling og juridisk ordforråd.', 'Whisper / GPU'],
'timeline' => ['Tidslinje', 'Hendelser og frister', 'Hent ut datoer, møter, barnevernsmilepæler og juridiske frister fra notater eller filer.', 'Behandles og glemmes'],
'redact' => ['Sladder', 'Personvern', 'Fjern navn, ID-numre, telefonnumre og adresser før du deler dokumenter.', 'Deterministisk først'],
'summarize' => ['Sammendrag', 'Dokumentsammendrag', 'Hent ut nøkkelfakta, datoer, parter og juridiske referanser fra et dokument — med valgfri korpusberikelse.', 'Behandles og glemmes'],
'korrespond' => ['Korrespond', 'Brev og svar til myndighetene', 'Skriv utkast til svar eller nytt brev til NAV, barnevernet, skolen, Bufdir og andre norske myndigheter — bokmål + ditt språk side om side, med verifiserte lovhenvisninger.', 'Hard-RAG · Norsk + EN/PL/UK'],
'barnevernet' => ['BVJ-analyse', 'Barnevernsdokumenter', 'Analyser barnevernsdokumenter fra ditt perspektiv med prosessuelle røde flagg og kilder.', 'Dokument + RAG'],
'advocate' => ['Advokatmodus', 'Partsinnlegg', 'Velg hvem du representerer og lag et kildebelagt innlegg for den posisjonen.', 'EMD + Lovdata'],
@@ -1534,6 +1536,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
'transcribe' => ['Транскрипція', 'Аудіо та зустрічі', 'Перетворюйте аудіо або відео на текст із розділенням мовців і юридичною лексикою.', 'Whisper / GPU'],
'timeline' => ['Хронологія', 'Події та строки', 'Витягуйте дати, слухання, етапи Barnevernet і юридичні строки з нотаток або файлів.', 'Обробити і забути'],
'redact' => ['Редагування', 'Захист приватності', 'Видаляйте імена, ідентифікаційні номери, телефони та адреси перед поширенням документів.', 'Детермінований метод'],
'summarize' => ['Резюме', 'Резюме документа', 'Витягуйте ключові факти, дати, сторони та юридичні посилання — з можливістю збагачення корпусом.', 'Обробити і забути'],
'korrespond' => ['Korrespond', 'Листи і відповіді органам влади', 'Створюйте чернетки відповідей або нових листів до NAV, Barnevernet, школи, Bufdir та інших норвезьких органів — норвезькою + вашою мовою поряд, із перевіреними посиланнями на закон.', 'Hard-RAG · Norsk + EN/PL/UK'],
'barnevernet' => ['BVJ аналізатор', 'Документи Barnevernet', 'Аналізуйте документи захисту дітей з вашої позиції, з процесуальними ризиками та джерелами.', 'Документ + RAG'],
'advocate' => ['Адвокат', 'Позиційний бриф', 'Оберіть, кого представляєте, і створіть бриф із джерелами на підтримку цієї позиції.', 'ЄСПЛ + Lovdata'],
@@ -1546,6 +1549,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
'transcribe' => ['Transkrypcja', 'Audio i spotkania', 'Zamień audio lub wideo na tekst z rozdzieleniem mówców i słownictwem prawnym.', 'Whisper / GPU'],
'timeline' => ['Oś czasu', 'Wydarzenia i terminy', 'Wyodrębniaj daty, rozprawy, etapy Barnevernet i terminy prawne z notatek lub plików.', 'Przetwórz i zapomnij'],
'redact' => ['Redakcja', 'Ochrona prywatności', 'Usuń imiona, numery identyfikacyjne, telefony i adresy przed udostępnieniem dokumentów.', 'Metoda deterministyczna'],
'summarize' => ['Streszczenie', 'Streszczenie dokumentu', 'Wyodrębniaj kluczowe fakty, daty, strony i odniesienia prawne — z opcjonalnym wzbogaceniem korpusem.', 'Przetwórz i zapomnij'],
'korrespond' => ['Korrespond', 'Pisma i odpowiedzi do urzędów', 'Twórz projekty odpowiedzi lub nowych pism do NAV, Barnevernet, szkoły, Bufdir i innych norweskich organów — norweski + Twój język obok siebie, ze zweryfikowanymi odniesieniami do ustaw.', 'Hard-RAG · Norsk + EN/PL/UK'],
'barnevernet' => ['Analizator BVJ', 'Dokumenty Barnevernet', 'Analizuj dokumenty opieki nad dziećmi z Twojej perspektywy, z ryzykami proceduralnymi i źródłami.', 'Dokument + RAG'],
'advocate' => ['Adwokat', 'Stronniczy brief', 'Wybierz, kogo reprezentujesz, i wygeneruj brief oparty na źródłach dla tej pozycji.', 'ETPC + Lovdata'],
@@ -1557,11 +1561,12 @@ function dbnToolsLaunchedTools(?string $language = null): array
];
$selected = $copy[$language] ?? $copy['en'];
$order = ['transcribe', 'timeline', 'redact', 'korrespond', 'barnevernet', 'advocate', 'deep-research', 'discrepancy', 'corpus', 'citations'];
$order = ['transcribe', 'timeline', 'redact', 'summarize', 'korrespond', 'barnevernet', 'advocate', 'deep-research', 'discrepancy', 'corpus', 'citations'];
$icons = [
'transcribe' => 'TR',
'timeline' => 'TL',
'redact' => 'RX',
'summarize' => 'SZ',
'korrespond' => 'KOR',
'barnevernet' => 'BVJ',
'advocate' => 'ADV',
+158 -5
View File
@@ -1,10 +1,163 @@
<?php
declare(strict_types=1);
$toolName = 'summarize';
$toolTitle = 'Summarize pasted text';
$toolKind = 'Document Summarizer';
$toolBadge = 'process-and-forget';
$toolName = 'summarize';
$toolTitle = 'Summarize a document';
$toolKind = 'Document Summarizer';
$toolBadge = 'Process-and-forget';
$extraScripts = ['assets/js/summarize.js'];
require_once __DIR__ . '/includes/layout.php';
?>
<?php require_once __DIR__ . '/includes/tool_form.php'; ?>
<form id="sumForm" class="tool-form" novalidate>
<div class="lang-switcher" id="sumLangSwitcher" role="group" aria-label="UI language">
<button type="button" class="lang-btn sum-lang-btn is-active" data-lang="en">&#127468;&#127463; EN</button>
<button type="button" class="lang-btn sum-lang-btn" data-lang="no">&#127475;&#127476; NO</button>
<button type="button" class="lang-btn sum-lang-btn" data-lang="uk">&#127482;&#127462; UK</button>
<button type="button" class="lang-btn sum-lang-btn" data-lang="pl">&#127477;&#127473; PL</button>
</div>
<div class="control-row" id="sumEngineControl">
<span class="control-label">Engine</span>
<label><input type="radio" name="sumEngine" value="azure_mini" checked> Azure gpt-4o-mini &#9733; <small class="control-hint">(fast)</small></label>
<label><input type="radio" name="sumEngine" value="azure_full"> Azure gpt-4o <small class="control-hint">(best)</small></label>
<label><input type="radio" name="sumEngine" value="gpu"> GPU (qwen local) <small class="control-hint">(local)</small></label>
</div>
<p class="upload-hint">Azure engines use your BNL Azure credits. GPU runs the local LiteLLM proxy on the GPU server.</p>
<details class="advanced-panel" id="sumSlicesPanel">
<summary class="advanced-toggle">Legal corpus enrichment <small class="control-hint">(optional)</small></summary>
<p class="upload-hint">When one or more slices are enabled, the tool searches the Do Better Norge legal corpus for relevant passages and prepends them to the prompt. All slices are off by default — enable only what applies to your document.</p>
<div class="dr-slice-grid">
<button type="button" class="adv-slice sum-slice" data-slice="child_welfare" aria-pressed="false">
<div class="dr-slice__head">
<span class="dr-slice__title">Child Welfare</span>
<span class="dr-slice__badge">off</span>
</div>
<p class="dr-slice__tagline">Barnevern, omsorgsovertakelse, foster care</p>
</button>
<button type="button" class="adv-slice sum-slice" data-slice="echr" aria-pressed="false">
<div class="dr-slice__head">
<span class="dr-slice__title">ECHR</span>
<span class="dr-slice__badge">off</span>
</div>
<p class="dr-slice__tagline">Art.&nbsp;8 family life, Art.&nbsp;9 religion, HUDOC vs Norway</p>
</button>
<button type="button" class="adv-slice sum-slice" data-slice="family_core" aria-pressed="false">
<div class="dr-slice__head">
<span class="dr-slice__title">Family Law Core</span>
<span class="dr-slice__badge">off</span>
</div>
<p class="dr-slice__tagline">Barneloven, custody, samvær, mediation</p>
</button>
<button type="button" class="adv-slice sum-slice" data-slice="bufdir_guidance" aria-pressed="false">
<div class="dr-slice__head">
<span class="dr-slice__title">Bufdir Guidance</span>
<span class="dr-slice__badge">off</span>
</div>
<p class="dr-slice__tagline">Bufdir, Barneombudet, Statsforvalteren guidance</p>
</button>
<button type="button" class="adv-slice sum-slice" data-slice="norwegian_courts" aria-pressed="false">
<div class="dr-slice__head">
<span class="dr-slice__title">Norwegian Courts</span>
<span class="dr-slice__badge">off</span>
</div>
<p class="dr-slice__tagline">Høyesterett + Lagmannsrett family decisions</p>
</button>
<button type="button" class="adv-slice sum-slice" data-slice="hague" aria-pressed="false">
<div class="dr-slice__head">
<span class="dr-slice__title">Hague Convention</span>
<span class="dr-slice__badge">off</span>
</div>
<p class="dr-slice__tagline">INCADAT, cross-border abduction, wrongful removal</p>
</button>
<button type="button" class="adv-slice sum-slice" data-slice="broader_legal" aria-pressed="false">
<div class="dr-slice__head">
<span class="dr-slice__title">Broader Legal Support</span>
<span class="dr-slice__badge">off</span>
</div>
<p class="dr-slice__tagline">Arbeidsmiljøloven, NOUer, statutes, government background</p>
</button>
<button type="button" class="adv-slice sum-slice" data-slice="dbn_resources" aria-pressed="false">
<div class="dr-slice__head">
<span class="dr-slice__title">DBN Resources</span>
<span class="dr-slice__badge">off</span>
</div>
<p class="dr-slice__tagline">Do Better Norge guides, flashcards, resource directory</p>
</button>
</div>
</details>
<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="sumUploadZone" role="region" aria-label="File upload">
<input type="file" id="sumUploadInput" multiple accept=".pdf,.docx,.txt" aria-label="Choose files to summarize">
<div id="sumUploadPrompt" class="upload-prompt">
<span class="upload-icon" aria-hidden="true">&#8679;</span>
<p>Drop up to 5 files here, or <label for="sumUploadInput" 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="sumUploadFileInfo" class="upload-file is-hidden">
<ul id="sumUploadFileList" class="upload-file-list"></ul>
<button type="button" id="sumUploadClear" class="upload-clear">&times; Clear files</button>
</div>
</div>
<label class="input-label" for="sumInput">Pasted text <small class="control-hint">(optional if file or doc selected)</small></label>
<textarea id="sumInput" name="text" rows="8" placeholder="Paste a case note, court decision, letter, or any document text. You can also upload a file or select from My Docs above — at least one source is required."></textarea>
<div class="form-footer">
<p id="sumStatus" class="form-status" role="status" aria-live="polite"></p>
<button id="sumRunButton" type="submit">Summarize</button>
</div>
</form>
<section id="sumResults" class="results" aria-live="polite">
<div class="empty-state">
<h3>Ready</h3>
<p>Upload a file, select from My Docs, or paste text — then click Summarize. Enable corpus slices to enrich the summary with relevant legal passages.</p>
</div>
</section>
<!-- 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" checked>
<input type="radio" name="language" value="no">
<input type="radio" name="language" value="uk">
<input type="radio" name="language" value="pl">
</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'; ?>