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
+188
View File
@@ -1132,6 +1132,16 @@ async function runTool(event) {
latency_ms: data.latency_ms || 0,
});
}
// Offer "Run deep legal analysis" on ask/redact results
if (['ask', 'redact'].includes(state.activeTool)) {
const askText = state.activeTool === 'ask'
? ((lastToolPayload && lastToolPayload.question) || (data.answer || data.what_we_found || ''))
: (lastToolPayload && lastToolPayload.text) || (data.redacted_text || '');
const resEl = els.results || document.getElementById('results');
if (askText && resEl) {
dbnInjectLegalAnalysisButton(askText, (lastToolPayload && lastToolPayload.language) || 'no', state.activeTool, resEl);
}
}
} catch (error) {
els.status.textContent = error.message;
renderTrace([
@@ -2552,6 +2562,184 @@ function showSaveResultButton(tool, inputPayload, outputPayload, meta, container
window.dbnShowSaveResultButton = showSaveResultButton;
// ── Legal Analysis add-on (Run deep legal analysis on any text/result) ──────
// Injects a button that, when clicked, streams the two-pass legal-analysis flow
// (extract issues → ask dbn-legal-agent-v3 → synthesise) into a sub-section
// below the host container.
function dbnRunLegalAnalysisAddon(text, lang, sourceTool, containerEl) {
if (!containerEl) containerEl = document.getElementById('results');
if (!containerEl || !text || text.length < 80) return;
// Remove any prior legal-analysis section
const prior = containerEl.parentNode.querySelector('.la-addon-section');
if (prior) prior.remove();
const section = document.createElement('section');
section.className = 'la-addon-section result-section';
section.style.marginTop = '1.2rem';
section.style.padding = '1rem 1.2rem';
section.style.border = '1px dashed var(--dbn-teal, #0f766e)';
section.style.borderRadius = '8px';
section.style.background = '#f0fdfa';
section.innerHTML =
'<h3 style="margin-top:0;color:#0e7490;">⚖️🇳🇴 Deep Legal Analysis</h3>'
+ '<div class="la-pipeline" style="margin-bottom:1rem;">'
+ '<div class="la-step running"><strong>Pass 1</strong> — Extracting legal issues…</div>'
+ '</div>'
+ '<ol id="laAddonIssues" class="la-issues" style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:1rem;"></ol>';
containerEl.parentNode.appendChild(section);
const issueListEl = section.querySelector('#laAddonIssues');
const pipelineEl = section.querySelector('.la-pipeline');
const issueCards = {};
const payload = {
text: text,
language: lang || 'no',
doc_type: 'auto',
source_tool: sourceTool || 'addon',
};
fetch('api/legal-analysis.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async function (resp) {
if (!resp.ok || !resp.body) throw new Error('Server returned ' + resp.status);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const _r = await reader.read();
if (_r.done) break;
buffer += decoder.decode(_r.value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let data;
try { data = JSON.parse(trimmed); } catch (_) { continue; }
laAddonEvent(data, issueListEl, pipelineEl, issueCards);
}
}
}).catch(function (err) {
section.innerHTML += '<p style="color:#b91c1c;margin-top:0.5rem;">Error: ' + escapeHtml(err.message || String(err)) + '</p>';
});
}
function laAddonEvent(data, listEl, pipelineEl, cards) {
if (data.event === 'issues_extracted') {
listEl.innerHTML = '';
(data.issues || []).forEach(function (issue) {
const li = document.createElement('li');
li.className = 'la-issue pending';
li.dataset.issueId = String(issue.id);
const sev = issue.severity_hint || 'medium';
li.innerHTML =
'<div class="la-issue__head"><span class="la-issue__num">#' + issue.id + '</span>'
+ '<span class="la-severity la-severity-' + escapeHtml(sev) + '">' + escapeHtml(sev.toUpperCase()) + '</span></div>'
+ '<h4 class="la-issue__q">' + escapeHtml(issue.question || '') + '</h4>'
+ (issue.brief_context ? '<p class="la-issue__ctx"><em>' + escapeHtml(issue.brief_context) + '</em></p>' : '')
+ '<div class="la-issue__status">Waiting…</div>';
listEl.appendChild(li);
cards[issue.id] = li;
});
pipelineEl.innerHTML =
'<div class="la-step done"><strong>Pass 1</strong> — Found ' + (data.issues || []).length + ' issue(s)</div>'
+ '<div class="la-step running"><strong>Pass 2</strong> — Asking dbn-legal-agent-v3…</div>';
} else if (data.event === 'progress') {
const card = cards[data.issue_id];
if (card) {
const status = card.querySelector('.la-issue__status');
if (status) {
status.textContent = data.step === 'issue_searching_corpus'
? 'Searching legal corpus…'
: 'Asking dbn-legal-agent-v3…';
}
card.classList.add('running');
}
} else if (data.event === 'issue_answered' && data.issue) {
const iss = data.issue;
const card = cards[iss.id];
if (!card) return;
card.classList.remove('pending', 'running');
card.classList.add('answered');
const sev = iss.severity || 'medium';
const sevEl = card.querySelector('.la-severity');
if (sevEl) {
sevEl.className = 'la-severity la-severity-' + escapeHtml(sev);
sevEl.textContent = escapeHtml(sev.toUpperCase());
}
const statusEl = card.querySelector('.la-issue__status');
if (statusEl) statusEl.remove();
let html = '<div class="la-issue__answer"><h5>Svar</h5><p>' + escapeHtml(iss.answer || '').replace(/\n/g, '<br>') + '</p></div>';
if (iss.legal_basis) html += '<div class="la-issue__basis"><strong>Lovgrunnlag:</strong> ' + escapeHtml(iss.legal_basis) + '</div>';
if (iss.what_to_check) html += '<p class="la-issue__check"><em>' + escapeHtml(iss.what_to_check) + '</em></p>';
card.insertAdjacentHTML('beforeend', html);
} else if (data.event === 'final' && data.result) {
const res = data.result;
pipelineEl.innerHTML =
'<div class="la-step done"><strong>Pass 1</strong> — Issues extracted</div>'
+ '<div class="la-step done"><strong>Pass 2</strong> — Specialist answered ' + (res.issues || []).length + '</div>'
+ '<div class="la-step done"><strong>Pass 3</strong> — Synthesis complete</div>';
if (res.overall_assessment) {
let syn = '<section class="la-synthesis" style="margin-bottom:1rem;padding:0.85rem 1.1rem;border-left:4px solid #0f766e;background:#ecfeff;border-radius:6px;">'
+ '<h3 style="margin-top:0;color:#0e7490;">Overall assessment</h3>'
+ '<p>' + escapeHtml(res.overall_assessment) + '</p>';
if (Array.isArray(res.next_steps) && res.next_steps.length) {
syn += '<h4 style="margin:0.7rem 0 0.3rem;color:#155e75;">Next steps</h4><ul>'
+ res.next_steps.map(function (s) { return '<li>' + escapeHtml(s) + '</li>'; }).join('')
+ '</ul>';
}
if (res.disclaimer) {
syn += '<p style="font-size:0.85rem;color:#64748b;margin-top:0.5rem;"><em>' + escapeHtml(res.disclaimer) + '</em></p>';
}
syn += '</section>';
pipelineEl.insertAdjacentHTML('afterend', syn);
}
// Save button (legal-analysis result is itself a saveable run)
if (typeof window.dbnShowSaveResultButton === 'function') {
const containerSection = pipelineEl.parentNode;
window.dbnShowSaveResultButton(
'legal-analysis',
{ text: '', language: 'no', doc_type: 'auto', source_tool: 'addon' }, // input was the prior tool's text
res,
{ model: res.model || 'dbn-legal-agent-v3', latency_ms: res.latency_ms || 0 },
containerSection
);
}
} else if (data.event === 'error') {
pipelineEl.innerHTML += '<div style="color:#b91c1c;margin-top:0.4rem;">Error: ' + escapeHtml(data.message || data.error || 'unknown') + '</div>';
}
}
function dbnInjectLegalAnalysisButton(text, lang, sourceTool, containerEl, options) {
if (!containerEl || !text || text.length < 80) return;
// Avoid duplicate buttons
if (containerEl.querySelector('.la-addon-btn')) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'la-addon-btn';
btn.textContent = (options && options.label) || '⚖️🇳🇴 Run deep legal analysis on this text';
btn.style.cssText = 'display:block;margin:1rem auto 0;padding:0.6rem 1.2rem;font-size:0.92rem;'
+ 'background:#0f766e;color:#fff;border:none;border-radius:6px;cursor:pointer;'
+ 'font-weight:600;letter-spacing:0.02em;';
btn.addEventListener('mouseenter', function () { btn.style.background = '#0d5e57'; });
btn.addEventListener('mouseleave', function () { btn.style.background = '#0f766e'; });
btn.addEventListener('click', function () {
btn.disabled = true;
btn.textContent = 'Running deep legal analysis…';
btn.style.background = '#94a3b8';
dbnRunLegalAnalysisAddon(text, lang, sourceTool, containerEl);
});
containerEl.appendChild(btn);
}
window.dbnRunLegalAnalysisAddon = dbnRunLegalAnalysisAddon;
window.dbnInjectLegalAnalysisButton = dbnInjectLegalAnalysisButton;
let _freeTierBalance = (typeof window.DBN_FREE_TIER_BALANCE === 'number') ? window.DBN_FREE_TIER_BALANCE : -1;
function dbnUpdateCredits(balance) {