Add Legal Analysis tool — two-pass DBN-legal pipeline

Restores the dbn-legal-agent-v3 fine-tune on ocelot (was silently aliased
to plain qwen2.5:14b in LiteLLM since the viper retirement) and ships a
new tool that uses it via a two-pass flow:

  Pass 1 (Azure 4o-mini)  → extract up to 5 distinct legal issues
  Pass 2 (ocelot v3 only) → answer each issue, ≤350 tokens, with corpus
  Pass 3 (Azure 4o-mini)  → synthesise overall assessment + next steps

The 12GB-VRAM constraint motivates the split: dbn-legal-agent-v3 stays
hot in VRAM through the 5 sequential per-issue calls because issue
extraction and synthesis run on Azure, not on ocelot.

New surface:
  - includes/LegalAnalysisAgent.php
  - api/legal-analysis.php           (NDJSON streaming endpoint)
  - legal-analysis.php               (dedicated tool page)
  - assets/js/legal-analysis.js      (streamed UI with per-issue cards)
  - Save-result + case-result.php rendering for legal-analysis output
  - Nav registration in all four UI languages

Add-on integration: a "⚖️🇳🇴 Run deep legal analysis on this text"
button now appears on Summarize, Ask, and Redact result pages and
streams the same pipeline inline below the existing result.

Existing tools relabelled: the misleading "🇳🇴 Norwegian specialist v3 "
option on advocate/deep-research/discrepancy/barnevernet is now honestly
"DBN Legal Agent" — now that the real fine-tune is actually deployed,
the label finally matches reality. The advocate.php v2 option was
removed since the v2 GGUF is retired.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 04:21:01 +02:00
parent 2013648ee0
commit 7e6463ed22
14 changed files with 1361 additions and 25 deletions
+377
View File
@@ -0,0 +1,377 @@
/**
* legal-analysis.js — Custom handler for the Legal Analysis tool.
*
* 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.
*/
(function () {
'use strict';
// ── Element refs ──────────────────────────────────────────────────────────
var form = document.getElementById('laForm');
var runBtn = document.getElementById('laRunButton');
var statusEl = document.getElementById('laStatus');
var resultsEl = document.getElementById('laResults');
var textarea = document.getElementById('laInput');
var uploadZone = document.getElementById('laUploadZone');
var uploadInput = document.getElementById('laUploadInput');
var uploadPrompt = document.getElementById('laUploadPrompt');
var uploadFileInfo = document.getElementById('laUploadFileInfo');
var uploadFileList = document.getElementById('laUploadFileList');
var uploadClear = document.getElementById('laUploadClear');
// ── State ─────────────────────────────────────────────────────────────────
var _extractedFiles = [];
var _currentLang = 'no';
var _lastPayload = null;
var _issueCards = {}; // id -> DOM element
// ── 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';
});
});
// ── File upload (same pattern as summarize) ───────────────────────────────
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();
runAnalysis();
});
}
async function runAnalysis() {
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('Paste text, upload a file, or select a document before running.');
return;
}
var docType = (document.querySelector('input[name="laDocType"]:checked') || {}).value || 'auto';
var payload = {
text: combined,
language: _currentLang,
doc_type: docType,
};
if (docIds.length) payload.doc_ids = docIds;
_lastPayload = payload;
_issueCards = {};
setBusy(true);
setStatus('Running…');
renderInitial();
try {
var resp = await fetch('api/legal-analysis.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 = '';
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);
}
}
} catch (err) {
showError(err.message || 'Request failed.');
} finally {
setBusy(false);
setStatus('');
}
}
function handleEvent(data) {
if (data.event === 'progress') {
setStatus(data.detail || '');
if (data.step === 'issue_searching_corpus' || data.step === 'issue_answering') {
markIssueRunning(data.issue_id, data.step);
}
} else if (data.event === 'issues_extracted') {
renderIssueList(data.issues || []);
} else if (data.event === 'issue_answered') {
fillIssueCard(data.issue);
} else if (data.event === 'final') {
renderFinal(data.result || {});
if (typeof window.dbnShowSaveResultButton === 'function') {
window.dbnShowSaveResultButton(
'legal-analysis',
_lastPayload || {},
data.result || {},
{
model: (data.result && data.result.model) || 'dbn-legal-agent-v3',
latency_ms: (data.result && data.result.latency_ms) || 0,
},
resultsEl
);
}
} else if (data.event === 'error') {
showError(data.message || data.error || 'An error occurred.');
}
}
// ── Result rendering ──────────────────────────────────────────────────────
function renderInitial() {
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>'
+ '<ol id="laIssueList" class="la-issues"></ol>';
}
function renderIssueList(issues) {
var list = document.getElementById('laIssueList');
if (!list) return;
list.innerHTML = '';
_issueCards = {};
issues.forEach(function (issue) {
var li = document.createElement('li');
li.className = 'la-issue pending';
li.dataset.issueId = String(issue.id);
li.innerHTML =
'<div class="la-issue__head">'
+ '<span class="la-issue__num">#' + issue.id + '</span>'
+ '<span class="la-severity la-severity-' + esc(issue.severity_hint || 'medium') + '">'
+ esc((issue.severity_hint || 'medium').toUpperCase())
+ '</span>'
+ '</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>';
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>';
}
}
function markIssueRunning(issueId, step) {
var card = _issueCards[issueId];
if (!card) return;
var status = card.querySelector('.la-issue__status');
if (!status) return;
if (step === 'issue_searching_corpus') {
status.textContent = 'Searching legal corpus…';
card.classList.add('running');
} else if (step === 'issue_answering') {
status.textContent = 'Asking dbn-legal-agent-v3…';
}
}
function fillIssueCard(issue) {
if (!issue || !issue.id) return;
var card = _issueCards[issue.id];
if (!card) return;
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');
sevEl.textContent = esc((issue.severity || 'medium').toUpperCase());
}
var statusBlock = card.querySelector('.la-issue__status');
if (statusBlock) statusBlock.remove();
var answerHtml = '<div class="la-issue__answer"><h5>Svar</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> '
+ esc(issue.legal_basis) + '</div>';
}
var checkHtml = '';
if (issue.what_to_check) {
checkHtml = '<p class="la-issue__check"><em>' + esc(issue.what_to_check) + '</em></p>';
}
card.insertAdjacentHTML('beforeend', answerHtml + basisHtml + checkHtml);
}
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>'
+ '<p>' + esc(result.overall_assessment) + '</p>';
if (Array.isArray(result.next_steps) && result.next_steps.length) {
topHtml += '<h4>Next steps</h4><ul>'
+ result.next_steps.map(function (s) { return '<li>' + esc(s) + '</li>'; }).join('')
+ '</ul>';
}
if (result.disclaimer) {
topHtml += '<p class="disclaimer-note">' + esc(result.disclaimer) + '</p>';
}
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>';
}
// 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>';
}
}
function showError(msg) {
if (resultsEl) {
resultsEl.innerHTML = '<div class="empty-state"><h3>Error</h3><p>' + esc(msg) + '</p></div>';
}
setStatus('');
}
// ── Helpers ───────────────────────────────────────────────────────────────
function setBusy(on) {
if (runBtn) runBtn.disabled = on;
if (runBtn) runBtn.textContent = on ? 'Running…' : 'Run legal analysis';
}
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;');
}
}());