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
+1 -2
View File
@@ -55,8 +55,7 @@ require_once __DIR__ . '/includes/layout.php';
<label><input type="radio" name="advEngine" value="azure_mini" checked> Azure gpt-4o-mini &#9733; <small class="control-hint">(~15-45s)</small></label> <label><input type="radio" name="advEngine" value="azure_mini" checked> Azure gpt-4o-mini &#9733; <small class="control-hint">(~15-45s)</small></label>
<label><input type="radio" name="advEngine" value="azure_full"> Azure gpt-4o <small class="control-hint">(best · ~60-180s)</small></label> <label><input type="radio" name="advEngine" value="azure_full"> Azure gpt-4o <small class="control-hint">(best · ~60-180s)</small></label>
<label><input type="radio" name="advEngine" value="gpu"> GPU (cuttlefish) <small class="control-hint">(local · ~30-90s)</small></label> <label><input type="radio" name="advEngine" value="gpu"> GPU (cuttlefish) <small class="control-hint">(local · ~30-90s)</small></label>
<label><input type="radio" name="advEngine" value="dbn_legal"> &#x1F1F3;&#x1F1F4; Norwegian specialist v2 <small class="control-hint">(dbn-legal-agent-v2 · ~40-90s)</small></label> <label><input type="radio" name="advEngine" value="dbn_legal_v3"> &#x1F1F3;&#x1F1F4;&#9876;&#65039; DBN Legal Agent &#9733; <small class="control-hint">(dbn-legal-agent-v3 fine-tune · ~20-60s)</small></label>
<label><input type="radio" name="advEngine" value="dbn_legal_v3"> &#x1F1F3;&#x1F1F4; Norwegian specialist v3 &#9733; <small class="control-hint">(dbn-legal-agent-v3 · ~20-60s)</small></label>
</div> </div>
<p class="upload-hint">Azure mini finishes fastest. Azure full produces the most thorough advocate brief. Norwegian specialist v3 is a Qwen2.5 fine-tune trained on barnevernsloven, ECHR, and forvaltningsloven — highest precision for § 4-25, Strand Lobben, and procedural red flags.</p> <p class="upload-hint">Azure mini finishes fastest. Azure full produces the most thorough advocate brief. Norwegian specialist v3 is a Qwen2.5 fine-tune trained on barnevernsloven, ECHR, and forvaltningsloven — highest precision for § 4-25, Strand Lobben, and procedural red flags.</p>
+165
View File
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/LegalAnalysisAgent.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';
$creditDeducted = false;
$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');
$docType = (string)($input['doc_type'] ?? 'other');
$allowedDocTypes = ['auto','barnevernet','adopsjon','emergency','samvær','fylkesnemnd','other'];
if (!in_array($docType, $allowedDocTypes, true)) {
$docType = 'other';
}
$text = dbnToolsInjectDocContent($input, dbnToolsString($input, 'text', 128000, false));
if (mb_strlen(trim($text), 'UTF-8') < 80) {
throw new DbnToolsHttpException(
'Paste at least 80 characters of text, upload a file, or select a document.',
422, 'empty_text'
);
}
$emit('start', [
'mode' => 'legal-analysis',
'language' => $language,
'doc_type' => $docType,
'chars' => mb_strlen($text, 'UTF-8'),
]);
$agent = new DbnLegalAnalysisAgent();
// Pass 1 — extract issues (Azure, fast); deduct credit AFTER this succeeds
$emit('progress', ['step' => 'extracting_issues', 'detail' => 'Identifying distinct legal issues…']);
$issues = $agent->extractIssues($text, $language, $docType);
if (empty($issues)) {
$emit('final', [
'result' => [
'ok' => true,
'issues' => [],
'overall_assessment' => 'No discrete legal issues were identified in this document.',
'next_steps' => [],
'disclaimer' => 'Automated analysis — not legal advice.',
'model' => 'dbn-legal-agent-v3',
'doc_type' => $docType,
'latency_ms' => (int)round((microtime(true) - $startTime) * 1000),
],
]);
exit;
}
// Deduct credit (gated until extract succeeds and at least one issue exists)
$ftUid = dbnToolsFreeTierCheck('legal-analysis');
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'legal-analysis');
$creditDeducted = true;
if ($ftRemaining >= 0) {
header('X-Credits-Remaining: ' . $ftRemaining);
}
$emit('issues_extracted', [
'count' => count($issues),
'issues' => array_map(fn($i) => [
'id' => $i['id'],
'question' => $i['question'],
'brief_context' => $i['brief_context'],
'severity_hint' => $i['severity_hint'],
], $issues),
]);
// Pass 2 — answer each issue sequentially on ocelot (keeps fine-tune hot)
$svc = new DbnLegalToolsService();
$answered = [];
foreach ($issues as $issue) {
$emit('progress', [
'step' => 'issue_searching_corpus',
'detail' => sprintf('Issue %d: searching legal corpus…', $issue['id']),
'issue_id' => $issue['id'],
]);
$corpusQuery = $issue['question'] . "\n" . $issue['brief_context'];
$corpusContext = $svc->corpusContextForSummarize($corpusQuery, 3);
$emit('progress', [
'step' => 'issue_answering',
'detail' => sprintf('Issue %d: asking dbn-legal-agent-v3…', $issue['id']),
'issue_id' => $issue['id'],
]);
$answer = $agent->answerIssue($issue, $corpusContext, $language);
$answered[] = $answer;
$emit('issue_answered', ['issue' => $answer]);
}
// Pass 3 — synthesise (Azure)
$emit('progress', ['step' => 'synthesising', 'detail' => 'Synthesising overall assessment…']);
$synth = $agent->synthesise($answered, $language, $docType);
$result = [
'ok' => true,
'issues' => $answered,
'overall_assessment' => $synth['overall_assessment'],
'next_steps' => $synth['next_steps'],
'disclaimer' => $synth['disclaimer'],
'doc_type' => $docType,
'model' => 'dbn-legal-agent-v3',
'latency_ms' => (int)round((microtime(true) - $startTime) * 1000),
];
dbnToolsLogMetadata([
'tool' => 'legal-analysis',
'language' => $language,
'ok' => true,
'latency_ms' => $result['latency_ms'],
'issue_count' => count($answered),
'deployment' => 'dbn-legal-agent-v3',
]);
$emit('final', ['result' => $result]);
} catch (DbnToolsHttpException $e) {
$latency = (int)round((microtime(true) - $startTime) * 1000);
dbnToolsLogMetadata([
'tool' => 'legal-analysis',
'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('legal-analysis fatal: ' . $e->getMessage());
$latency = (int)round((microtime(true) - $startTime) * 1000);
dbnToolsLogMetadata([
'tool' => 'legal-analysis',
'language' => $language,
'ok' => false,
'latency_ms' => $latency,
'error_code' => 'internal_error',
]);
$emit('error', ['code' => 'internal_error', 'message' => 'Legal analysis could not complete this request.']);
}
+130
View File
@@ -9267,3 +9267,133 @@ body.lt-landing {
font-size: 0.82rem; font-size: 0.82rem;
width: 100%; width: 100%;
} }
/* ── Legal Analysis tool ─────────────────────────────────────────────────── */
.la-pipeline {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.2rem;
padding: 0.6rem 0.75rem;
background: #f5f7fb;
border-radius: 8px;
border: 1px solid #e2e7ef;
font-size: 0.85rem;
}
.la-step {
padding: 0.3rem 0.7rem;
border-radius: 5px;
background: #fff;
border: 1px solid #d8dde7;
}
.la-step.running {
border-color: #f59e0b;
background: #fffbeb;
animation: la-pulse 1.4s ease-in-out infinite;
}
.la-step.done {
border-color: #16a34a;
background: #f0fdf4;
color: #15803d;
}
@keyframes la-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.55; } }
.la-issues {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.la-issue {
border: 1px solid #d8dde7;
border-radius: 8px;
padding: 0.9rem 1rem;
background: #fff;
transition: border-color 0.2s, box-shadow 0.2s;
}
.la-issue.pending { opacity: 0.7; border-style: dashed; }
.la-issue.running {
border-color: #f59e0b;
box-shadow: 0 0 0 2px rgba(245,158,11,0.15);
}
.la-issue.answered {
border-color: #cbd5e1;
}
.la-issue__head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.la-issue__num {
display: inline-block;
min-width: 1.8rem;
font-weight: 700;
color: #64748b;
font-size: 0.85rem;
}
.la-severity {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
}
.la-severity-high { background: #fee2e2; color: #b91c1c; }
.la-severity-medium { background: #fef3c7; color: #92400e; }
.la-severity-low { background: #dcfce7; color: #166534; }
.la-issue__q {
margin: 0.2rem 0 0.4rem;
font-size: 1.02rem;
color: #1e293b;
}
.la-issue__ctx {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: #64748b;
}
.la-issue__status {
font-size: 0.85rem;
color: #f59e0b;
font-style: italic;
}
.la-issue__answer {
margin-top: 0.6rem;
padding-top: 0.6rem;
border-top: 1px solid #eef2f7;
}
.la-issue__answer h5 {
margin: 0 0 0.4rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
}
.la-issue__answer p { margin: 0; line-height: 1.55; color: #1e293b; }
.la-issue__basis {
margin-top: 0.5rem;
font-size: 0.85rem;
color: #475569;
background: #f8fafc;
padding: 0.3rem 0.55rem;
border-radius: 5px;
display: inline-block;
}
.la-issue__check {
margin: 0.55rem 0 0;
font-size: 0.8rem;
color: #94a3b8;
}
.la-synthesis {
margin-bottom: 1.4rem;
padding: 1rem 1.2rem;
border-left: 4px solid var(--dbn-teal, #0f766e);
background: #ecfeff;
border-radius: 6px;
}
.la-synthesis h3 { margin-top: 0; color: #0e7490; }
.la-synthesis h4 { margin: 0.9rem 0 0.4rem; font-size: 0.95rem; color: #155e75; }
+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;');
}
}());
+7
View File
@@ -229,6 +229,13 @@
resultsEl resultsEl
); );
} }
// Offer deep legal analysis on the summarised text
if (typeof window.dbnInjectLegalAnalysisButton === 'function') {
var sourceText = combined || (_lastPayload && _lastPayload.text) || '';
if (sourceText && sourceText.length >= 80) {
window.dbnInjectLegalAnalysisButton(sourceText, _currentLang, 'summarize', resultsEl);
}
}
if (data.balance != null) { if (data.balance != null) {
var credEl = document.getElementById('creditsRemaining'); var credEl = document.getElementById('creditsRemaining');
if (credEl) credEl.textContent = data.balance; if (credEl) credEl.textContent = data.balance;
+188
View File
@@ -1132,6 +1132,16 @@ async function runTool(event) {
latency_ms: data.latency_ms || 0, 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) { } catch (error) {
els.status.textContent = error.message; els.status.textContent = error.message;
renderTrace([ renderTrace([
@@ -2552,6 +2562,184 @@ function showSaveResultButton(tool, inputPayload, outputPayload, meta, container
window.dbnShowSaveResultButton = showSaveResultButton; 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; let _freeTierBalance = (typeof window.DBN_FREE_TIER_BALANCE === 'number') ? window.DBN_FREE_TIER_BALANCE : -1;
function dbnUpdateCredits(balance) { function dbnUpdateCredits(balance) {
+1 -1
View File
@@ -41,7 +41,7 @@ require_once __DIR__ . '/includes/layout.php';
<label><input type="radio" name="bvjEngine" value="azure_mini" checked> Azure gpt-4o-mini &#9733; <small class="control-hint">(~30-60s)</small></label> <label><input type="radio" name="bvjEngine" value="azure_mini" checked> Azure gpt-4o-mini &#9733; <small class="control-hint">(~30-60s)</small></label>
<label><input type="radio" name="bvjEngine" value="azure_full"> Azure gpt-4o <small class="control-hint">(best · ~90-180s)</small></label> <label><input type="radio" name="bvjEngine" value="azure_full"> Azure gpt-4o <small class="control-hint">(best · ~90-180s)</small></label>
<label><input type="radio" name="bvjEngine" value="gpu"> GPU qwen2.5:14b <small class="control-hint">(local · ~45-90s)</small></label> <label><input type="radio" name="bvjEngine" value="gpu"> GPU qwen2.5:14b <small class="control-hint">(local · ~45-90s)</small></label>
<label><input type="radio" name="bvjEngine" value="dbn_legal_v3"> &#x1F1F3;&#x1F1F4; Norwegian specialist v3 &#9733; <small class="control-hint">(dbn-legal-agent-v3 · ~20-60s)</small></label> <label><input type="radio" name="bvjEngine" value="dbn_legal_v3"> &#x1F1F3;&#x1F1F4;&#9876;&#65039; DBN Legal Agent &#9733; <small class="control-hint">(dbn-legal-agent-v3 fine-tune · ~20-60s)</small></label>
</div> </div>
<p class="upload-hint">Engine applies to the final advocacy synthesis only. Norwegian specialist v3 is the recommended choice for Barnevernet documents it is fine-tuned on § 4-25, Strand Lobben, forvaltningsloven § 17/§ 41, and procedural red-flag detection. Classification, party extraction, and timeline always use azure-mini.</p> <p class="upload-hint">Engine applies to the final advocacy synthesis only. Norwegian specialist v3 is the recommended choice for Barnevernet documents it is fine-tuned on § 4-25, Strand Lobben, forvaltningsloven § 17/§ 41, and procedural red-flag detection. Classification, party extraction, and timeline always use azure-mini.</p>
+48
View File
@@ -298,6 +298,53 @@ function crField(string $label, string $value): string {
<?php endif; ?> <?php endif; ?>
<?= crListBlock('What Remains Uncertain', (array)($output['what_remains_uncertain'] ?? [])) ?> <?= crListBlock('What Remains Uncertain', (array)($output['what_remains_uncertain'] ?? [])) ?>
<?php elseif ($toolSlug === 'legal-analysis'): ?>
<?php if (!empty($output['overall_assessment'])): ?>
<div class="cr-block">
<h3>Overall assessment</h3>
<p style="line-height:1.6"><?= htmlspecialchars((string)$output['overall_assessment']) ?></p>
</div>
<?php endif; ?>
<?php if (!empty($output['next_steps']) && is_array($output['next_steps'])): ?>
<?= crListBlock('Next steps', $output['next_steps']) ?>
<?php endif; ?>
<?php if (!empty($output['issues']) && is_array($output['issues'])): ?>
<div class="cr-block">
<h3>Legal issues</h3>
<?php foreach ($output['issues'] as $iss): if (!is_array($iss)) continue;
$sev = (string)($iss['severity'] ?? 'medium');
$sevColor = $sev === 'high' ? '#fee2e2' : ($sev === 'low' ? '#dcfce7' : '#fef3c7');
$sevText = $sev === 'high' ? '#b91c1c' : ($sev === 'low' ? '#166534' : '#92400e');
?>
<article style="border:1px solid #d8dde7;border-radius:8px;padding:0.85rem 1rem;margin-bottom:0.75rem;">
<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:0.4rem;">
<span style="font-weight:700;color:#64748b;font-size:0.85rem;">#<?= (int)($iss['id'] ?? 0) ?></span>
<span style="font-size:0.7rem;font-weight:700;letter-spacing:0.04em;padding:0.15rem 0.5rem;border-radius:999px;background:<?= $sevColor ?>;color:<?= $sevText ?>;"><?= htmlspecialchars(strtoupper($sev)) ?></span>
</div>
<h4 style="margin:0.2rem 0 0.4rem;font-size:1.02rem;"><?= htmlspecialchars((string)($iss['question'] ?? '')) ?></h4>
<?php if (!empty($iss['brief_context'])): ?>
<p style="font-size:0.85rem;color:#64748b;margin:0 0 0.5rem"><em><?= htmlspecialchars((string)$iss['brief_context']) ?></em></p>
<?php endif; ?>
<div style="margin-top:0.5rem;padding-top:0.5rem;border-top:1px solid #eef2f7;">
<h5 style="margin:0 0 0.3rem;font-size:0.78rem;text-transform:uppercase;letter-spacing:0.06em;color:#64748b;">Svar (dbn-legal-agent-v3)</h5>
<p style="margin:0;line-height:1.55;white-space:pre-wrap;"><?= htmlspecialchars((string)($iss['answer'] ?? '')) ?></p>
</div>
<?php if (!empty($iss['legal_basis'])): ?>
<div style="margin-top:0.5rem;font-size:0.85rem;color:#475569;background:#f8fafc;padding:0.3rem 0.55rem;border-radius:5px;display:inline-block;">
<strong>Lovgrunnlag:</strong> <?= htmlspecialchars((string)$iss['legal_basis']) ?>
</div>
<?php endif; ?>
<?php if (!empty($iss['what_to_check'])): ?>
<p style="margin:0.55rem 0 0;font-size:0.8rem;color:#94a3b8;"><em><?= htmlspecialchars((string)$iss['what_to_check']) ?></em></p>
<?php endif; ?>
</article>
<?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 else: ?> <?php else: ?>
<?php if ($primaryOutput !== ''): ?> <?php if ($primaryOutput !== ''): ?>
<div class="cr-output"><?= htmlspecialchars($primaryOutput) ?></div> <div class="cr-output"><?= htmlspecialchars($primaryOutput) ?></div>
@@ -381,6 +428,7 @@ function crField(string $label, string $value): string {
'ask': '/ask.php', 'ask': '/ask.php',
'redact': '/redact.php', 'redact': '/redact.php',
'transcribe': '/transcribe.php', 'transcribe': '/transcribe.php',
'legal-analysis':'/legal-analysis.php',
}[tool] || '/dashboard.php'; }[tool] || '/dashboard.php';
window.location.href = path + '?rerun=' + <?= (int)$result['id'] ?>; window.location.href = path + '?rerun=' + <?= (int)$result['id'] ?>;
}); });
+1 -1
View File
@@ -21,7 +21,7 @@ require_once __DIR__ . '/includes/layout.php';
<label><input type="radio" name="drEngine" value="azure_mini" checked> Azure gpt-4o-mini &#9733; <small class="control-hint">(~15-45s)</small></label> <label><input type="radio" name="drEngine" value="azure_mini" checked> Azure gpt-4o-mini &#9733; <small class="control-hint">(~15-45s)</small></label>
<label><input type="radio" name="drEngine" value="azure_full"> Azure gpt-4o <small class="control-hint">(best · ~60-180s)</small></label> <label><input type="radio" name="drEngine" value="azure_full"> Azure gpt-4o <small class="control-hint">(best · ~60-180s)</small></label>
<label><input type="radio" name="drEngine" value="gpu"> GPU (cuttlefish) <small class="control-hint">(local · ~30-90s)</small></label> <label><input type="radio" name="drEngine" value="gpu"> GPU (cuttlefish) <small class="control-hint">(local · ~30-90s)</small></label>
<label><input type="radio" name="drEngine" value="dbn_legal_v3"> &#x1F1F3;&#x1F1F4; Norwegian specialist v3 &#9733; <small class="control-hint">(dbn-legal-agent-v3 · ~20-60s)</small></label> <label><input type="radio" name="drEngine" value="dbn_legal_v3"> &#x1F1F3;&#x1F1F4;&#9876;&#65039; DBN Legal Agent &#9733; <small class="control-hint">(dbn-legal-agent-v3 fine-tune · ~20-60s)</small></label>
</div> </div>
<p class="upload-hint">Azure mini is the default and finishes fastest. Azure full is the most thorough. Norwegian specialist v3 is a Qwen2.5 fine-tune optimised for barnevernsloven, ECHR, and forvaltningsloven best for cases involving § 4-25, Strand Lobben, or procedural challenges.</p> <p class="upload-hint">Azure mini is the default and finishes fastest. Azure full is the most thorough. Norwegian specialist v3 is a Qwen2.5 fine-tune optimised for barnevernsloven, ECHR, and forvaltningsloven best for cases involving § 4-25, Strand Lobben, or procedural challenges.</p>
+1 -1
View File
@@ -60,7 +60,7 @@ require_once __DIR__ . '/includes/layout.php';
<label><input type="radio" name="dcEngine" value="azure_mini" checked> Azure gpt-4o-mini &#9733; <small class="control-hint">(~60-90s)</small></label> <label><input type="radio" name="dcEngine" value="azure_mini" checked> Azure gpt-4o-mini &#9733; <small class="control-hint">(~60-90s)</small></label>
<label><input type="radio" name="dcEngine" value="azure_full"> Azure gpt-4o <small class="control-hint">(best · ~2-3 min)</small></label> <label><input type="radio" name="dcEngine" value="azure_full"> Azure gpt-4o <small class="control-hint">(best · ~2-3 min)</small></label>
<label><input type="radio" name="dcEngine" value="gpu"> GPU qwen2.5:14b <small class="control-hint">(local · ~90s)</small></label> <label><input type="radio" name="dcEngine" value="gpu"> GPU qwen2.5:14b <small class="control-hint">(local · ~90s)</small></label>
<label><input type="radio" name="dcEngine" value="dbn_legal_v3"> &#x1F1F3;&#x1F1F4; Norwegian specialist v3 &#9733; <small class="control-hint">(dbn-legal-agent-v3 · ~30-60s)</small></label> <label><input type="radio" name="dcEngine" value="dbn_legal_v3"> &#x1F1F3;&#x1F1F4;&#9876;&#65039; DBN Legal Agent &#9733; <small class="control-hint">(dbn-legal-agent-v3 fine-tune · ~30-60s)</small></label>
</div> </div>
<p class="upload-hint">Engine applies to the final synthesis only. Norwegian specialist v3 excels at identifying legally significant discrepancies in Barnevernet documents procedural violations, threshold errors, and missing statutory justifications. Classification, party extraction, timelines, and cross-referencing always use azure-mini.</p> <p class="upload-hint">Engine applies to the final synthesis only. Norwegian specialist v3 excels at identifying legally significant discrepancies in Barnevernet documents procedural violations, threshold errors, and missing statutory justifications. Classification, party extraction, timelines, and cross-referencing always use azure-mini.</p>
+4
View File
@@ -33,6 +33,7 @@ final class CaseResults
'ask', 'ask',
'redact', 'redact',
'transcribe', 'transcribe',
'legal-analysis',
]; ];
/** True when the user is on a tier that gets saved results (Plus, Pro, or active Plus trial). */ /** True when the user is on a tier that gets saved results (Plus, Pro, or active Plus trial). */
@@ -238,6 +239,7 @@ final class CaseResults
'ask' => 'Spørsmål & svar', 'ask' => 'Spørsmål & svar',
'redact' => 'Anonymisering', 'redact' => 'Anonymisering',
'transcribe' => 'Transkripsjon', 'transcribe' => 'Transkripsjon',
'legal-analysis' => 'Juridisk analyse',
][$tool] ?? ucfirst($tool); ][$tool] ?? ucfirst($tool);
} }
@@ -255,6 +257,7 @@ final class CaseResults
'ask' => '💬', 'ask' => '💬',
'redact' => '🖊️', 'redact' => '🖊️',
'transcribe' => '🎙️', 'transcribe' => '🎙️',
'legal-analysis' => '⚖️🇳🇴',
][$tool] ?? '📄'; ][$tool] ?? '📄';
} }
@@ -272,6 +275,7 @@ final class CaseResults
'ask' => [$input['question'] ?? null], 'ask' => [$input['question'] ?? null],
'redact' => [$input['text'] ?? null], 'redact' => [$input['text'] ?? null],
'transcribe' => [$input['filename'] ?? null], 'transcribe' => [$input['filename'] ?? null],
'legal-analysis' => [$input['doc_type'] ?? null, $input['text'] ?? null],
default => [$input['title'] ?? null, $input['query'] ?? null, $input['text'] ?? null], default => [$input['title'] ?? null, $input['query'] ?? null, $input['text'] ?? null],
}; };
foreach ($candidates as $c) { foreach ($candidates as $c) {
+309
View File
@@ -0,0 +1,309 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/AzureOpenAiGateway.php';
require_once __DIR__ . '/LegalTools.php';
/**
* Two-pass legal analysis:
* 1. Extract distinct legal issues from a document (Azure GPT-4o-mini)
* 2. For each issue: retrieve corpus passages, ask dbn-legal-agent-v3 a single
* targeted question (cap 350 tokens to avoid the documented loop bug)
* 3. Synthesise overall assessment + next steps (Azure GPT-4o-mini)
*
* Only step 2 touches the GPU. Steps 1 and 3 use Azure so dbn-legal-agent-v3
* stays hot in the 12GB RTX 3060 VRAM across all per-issue calls.
*/
final class DbnLegalAnalysisAgent
{
private const MAX_ISSUES = 5;
private const LEGAL_MAX_TOKENS = 350;
private const LEGAL_TIMEOUT = 60;
private const LEGAL_MODEL = 'dbn-legal-agent-v3';
private DbnAzureOpenAiGateway $azureMini;
private DbnLegalToolsService $legalSvc;
public function __construct()
{
$this->azureMini = (new DbnAzureOpenAiGateway())->withDeployment('gpt-4o-mini');
$this->legalSvc = new DbnLegalToolsService();
}
/**
* Pass 1 extract distinct legal issues. Azure-only.
*
* @return array<int,array{id:int,question:string,brief_context:string,doc_type:string,severity_hint:string}>
*/
public function extractIssues(string $text, string $language, string $docType): array
{
$locale = dbnToolsLanguageName($language);
$text = mb_substr($text, 0, 24000, 'UTF-8'); // keep prompt within 4o-mini context
$prompt = <<<PROMPT
You analyse the document below and extract up to 5 DISTINCT legal issues that warrant
expert Norwegian-law review (barnevernsloven, EMK/ECHR, Hague Convention, family law,
process law). Each issue must be answerable as a SINGLE focused legal question
( 25 words), not a multi-part essay.
Document type hint: {$docType}
Document language: {$locale}
Return JSON only:
{
"issues": [
{
"id": 1,
"question": "<short Norwegian legal question, single issue>",
"brief_context": "<≤2 sentences from the document that triggered this question>",
"doc_type": "<barnevernet|adopsjon|emergency|samvær|other>",
"severity_hint": "<high|medium|low>"
}
]
}
Rules:
- Skip non-legal observations (logistics, social commentary, opinions).
- Each question should be answerable with citations to barnevernsloven, EMK Art. X,
named Høyesterett/EMD cases NOT general advice.
- If the document has fewer than 5 real legal issues, return fewer entries.
- If NO real legal issue exists, return {"issues": []}.
DOCUMENT:
---
{$text}
---
PROMPT;
$raw = $this->azureMini->chatText(
[
['role' => 'system', 'content' => 'You return valid JSON only. No prose, no fences.'],
['role' => 'user', 'content' => $prompt],
],
['json' => true, 'temperature' => 0.1, 'max_tokens' => 1500, 'timeout' => 90]
);
$decoded = $this->azureMini->decodeJsonObject($raw);
$issues = is_array($decoded['issues'] ?? null) ? $decoded['issues'] : [];
$clean = [];
$id = 1;
foreach ($issues as $issue) {
$question = trim((string)($issue['question'] ?? ''));
if ($question === '' || mb_strlen($question, 'UTF-8') < 10) {
continue;
}
$clean[] = [
'id' => $id++,
'question' => mb_substr($question, 0, 280, 'UTF-8'),
'brief_context' => mb_substr(trim((string)($issue['brief_context'] ?? '')), 0, 400, 'UTF-8'),
'doc_type' => (string)($issue['doc_type'] ?? $docType),
'severity_hint' => in_array($issue['severity_hint'] ?? '', ['high','medium','low'], true)
? $issue['severity_hint']
: 'medium',
];
if (count($clean) >= self::MAX_ISSUES) {
break;
}
}
return $clean;
}
/**
* Pass 2 single targeted question to dbn-legal-agent-v3 with corpus context.
* Ocelot-only. Capped at 350 tokens / 60s to avoid the documented loop bug.
*
* @param array{id:int,question:string,brief_context:string,doc_type:string,severity_hint:string} $issue
* @return array{id:int,question:string,answer:string,severity:string,legal_basis:string,citations_from_corpus:array,what_to_check:string,brief_context:string}
*/
public function answerIssue(array $issue, string $corpusContext, string $language): array
{
$sysMsg = 'Du er en ekspert på norsk barnevernsloven og EMD-praksis. '
. 'Svar alltid på norsk med korrekt juridisk terminologi. '
. 'Bruk terskler fra barnevernsloven 2021: § 4-25 krever «klar nødvendighet». '
. 'Strand Lobben mot Norge (37283/13) setter krav om rehabiliteringsplan før adopsjon. '
. 'Aldri oppfinn paragrafnumre, saksnumre eller dommernavn. '
. 'Avslutt med en «Kilder:»-seksjon som lister lovparagrafer og dommer du har sitert.';
$userMsg = $issue['question'];
if ($issue['brief_context'] !== '') {
$userMsg .= "\n\nKontekst fra saken: " . $issue['brief_context'];
}
if ($corpusContext !== '') {
$userMsg .= "\n\nRelevante kilder fra Do Better Norge-korpuset:\n" . $corpusContext;
}
$answer = '';
$error = null;
try {
$response = dbnToolsCallGpuLlm(
[
['role' => 'system', 'content' => $sysMsg],
['role' => 'user', 'content' => $userMsg],
],
[
'model' => self::LEGAL_MODEL,
'temperature' => 0.1,
'max_tokens' => self::LEGAL_MAX_TOKENS,
'timeout' => self::LEGAL_TIMEOUT,
]
);
$answer = trim((string)($response['choices'][0]['message']['content'] ?? ''));
} catch (Throwable $e) {
$error = $e->getMessage();
}
$clean = dbnToolsExtractCleanAnswer($answer);
if (mb_strlen($clean, 'UTF-8') < 30) {
$clean = $answer !== ''
? $answer
: ($error !== null ? "[Modellfeil: $error]" : '[Modellen returnerte ingen brukbar tekst.]');
}
$severity = $clean !== '' ? dbnToolsInferCheckSeverity($clean) : $issue['severity_hint'];
$legalBasis = dbnToolsExtractCheckLegalBasis($clean);
return [
'id' => $issue['id'],
'question' => $issue['question'],
'brief_context' => $issue['brief_context'],
'answer' => $clean,
'severity' => $severity,
'legal_basis' => $legalBasis,
'citations_from_corpus' => [], // populated by orchestrator if it kept the chunks
'what_to_check' => 'Verifiser med norsk familieretsadvokat før handling.',
];
}
/**
* Pass 3 synthesise overall assessment. Azure-only.
*/
public function synthesise(array $issues, string $language, string $docType): array
{
$locale = dbnToolsLanguageName($language);
$bullets = [];
foreach ($issues as $i) {
$bullets[] = sprintf(
"- [%s] %s\n Svar: %s",
strtoupper((string)$i['severity']),
$i['question'],
mb_substr((string)$i['answer'], 0, 600, 'UTF-8')
);
}
$issuesBlock = implode("\n", $bullets);
$prompt = <<<PROMPT
Below are 1-5 legal questions raised about a {$docType} document, each with an answer
from a Norwegian-law specialist model. Write a concise overall assessment in {$locale}.
ISSUES + ANSWERS:
{$issuesBlock}
Return JSON only:
{
"overall_assessment": "<3-5 sentences summarising the legal picture across all issues>",
"next_steps": ["<concrete action 1>", "<concrete action 2>", "<concrete action 3>"],
"disclaimer": "This is automated legal analysis, not legal advice. Verify with a qualified Norwegian lawyer before acting."
}
PROMPT;
try {
$raw = $this->azureMini->chatText(
[
['role' => 'system', 'content' => 'You return valid JSON only. No prose, no fences.'],
['role' => 'user', 'content' => $prompt],
],
['json' => true, 'temperature' => 0.2, 'max_tokens' => 700, 'timeout' => 60]
);
$decoded = $this->azureMini->decodeJsonObject($raw);
if (is_array($decoded) && !empty($decoded['overall_assessment'])) {
return [
'overall_assessment' => (string)$decoded['overall_assessment'],
'next_steps' => is_array($decoded['next_steps'] ?? null) ? array_slice($decoded['next_steps'], 0, 5) : [],
'disclaimer' => (string)($decoded['disclaimer'] ?? 'Automated analysis — not legal advice.'),
];
}
} catch (Throwable $e) {
error_log('legal-analysis synthesis failed: ' . $e->getMessage());
}
return [
'overall_assessment' => 'Synthesis step did not return structured output. See individual issue answers below.',
'next_steps' => [],
'disclaimer' => 'Automated analysis — not legal advice. Verify with a qualified Norwegian lawyer.',
];
}
/**
* Full orchestrated run. Emits progress events via the $emit callable.
*
* @param callable $emit (string $event, array $payload): void
*/
public function runFullAnalysis(string $text, string $language, string $docType, callable $emit): array
{
$startMs = (int)round(microtime(true) * 1000);
// Pass 1
$emit('progress', ['step' => 'extracting_issues', 'detail' => 'Identifying distinct legal issues…']);
$issues = $this->extractIssues($text, $language, $docType);
if (empty($issues)) {
return [
'ok' => true,
'issues' => [],
'overall_assessment' => 'No discrete legal issues identified in this document.',
'next_steps' => [],
'disclaimer' => 'Automated analysis — not legal advice.',
'model' => self::LEGAL_MODEL,
'latency_ms' => (int)round(microtime(true) * 1000) - $startMs,
];
}
$emit('progress', [
'step' => 'issues_extracted',
'detail' => sprintf('Found %d legal issue(s); asking specialist…', count($issues)),
'issues' => array_map(fn($i) => ['id' => $i['id'], 'question' => $i['question'], 'severity_hint' => $i['severity_hint']], $issues),
]);
// Pass 2 — one issue at a time
$answered = [];
foreach ($issues as $issue) {
$emit('progress', [
'step' => 'issue_searching_corpus',
'detail' => sprintf('Issue %d: searching legal corpus…', $issue['id']),
'issue_id' => $issue['id'],
]);
$corpusQuery = $issue['question'] . "\n" . $issue['brief_context'];
$corpusContext = $this->legalSvc->corpusContextForSummarize($corpusQuery, 3);
$emit('progress', [
'step' => 'issue_answering',
'detail' => sprintf('Issue %d: asking dbn-legal-agent-v3…', $issue['id']),
'issue_id' => $issue['id'],
]);
$answer = $this->answerIssue($issue, $corpusContext, $language);
$answered[] = $answer;
$emit('issue_answered', ['issue' => $answer]);
}
// Pass 3
$emit('progress', ['step' => 'synthesising', 'detail' => 'Synthesising overall assessment…']);
$synth = $this->synthesise($answered, $language, $docType);
return [
'ok' => true,
'issues' => $answered,
'overall_assessment' => $synth['overall_assessment'],
'next_steps' => $synth['next_steps'],
'disclaimer' => $synth['disclaimer'],
'doc_type' => $docType,
'model' => self::LEGAL_MODEL,
'latency_ms' => (int)round(microtime(true) * 1000) - $startMs,
];
}
}
+6 -1
View File
@@ -1511,6 +1511,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
'timeline' => ['Timeline', 'Events and deadlines', 'Extract dates, hearings, Barnevernet milestones, and legal deadlines from notes or files.', 'Process-and-forget'], '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'], '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'], 'summarize' => ['Summarize', 'Document summary', 'Extract key facts, dates, parties, and legal references from any document — with optional legal corpus enrichment.', 'Process-and-forget'],
'legal-analysis' => ['Legal Analysis', 'Deep Norwegian-law Q&A', 'Extract distinct legal issues from a document and answer each with the dbn-legal-agent-v3 fine-tune — citations from barnevernsloven, EMK, and Høyesterett.', 'Fine-tune · Norsk'],
'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'], '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'], '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'], 'advocate' => ['Advocate', 'Partisan brief', 'Choose who you represent and generate a source-grounded brief for that position.', 'ECHR + Lovdata'],
@@ -1524,6 +1525,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
'timeline' => ['Tidslinje', 'Hendelser og frister', 'Hent ut datoer, møter, barnevernsmilepæler og juridiske frister fra notater eller filer.', 'Behandles og glemmes'], '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'], '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'], 'summarize' => ['Sammendrag', 'Dokumentsammendrag', 'Hent ut nøkkelfakta, datoer, parter og juridiske referanser fra et dokument — med valgfri korpusberikelse.', 'Behandles og glemmes'],
'legal-analysis' => ['Juridisk analyse', 'Dyp norsk-rett Q&A', 'Hent ut distinkte juridiske spørsmål fra et dokument og få svar fra dbn-legal-agent-v3 fine-tunen — kilder fra barnevernsloven, EMK og Høyesterett.', 'Fine-tune · Norsk'],
'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'], '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'], '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'], 'advocate' => ['Advokatmodus', 'Partsinnlegg', 'Velg hvem du representerer og lag et kildebelagt innlegg for den posisjonen.', 'EMD + Lovdata'],
@@ -1537,6 +1539,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
'timeline' => ['Хронологія', 'Події та строки', 'Витягуйте дати, слухання, етапи Barnevernet і юридичні строки з нотаток або файлів.', 'Обробити і забути'], 'timeline' => ['Хронологія', 'Події та строки', 'Витягуйте дати, слухання, етапи Barnevernet і юридичні строки з нотаток або файлів.', 'Обробити і забути'],
'redact' => ['Редагування', 'Захист приватності', 'Видаляйте імена, ідентифікаційні номери, телефони та адреси перед поширенням документів.', 'Детермінований метод'], 'redact' => ['Редагування', 'Захист приватності', 'Видаляйте імена, ідентифікаційні номери, телефони та адреси перед поширенням документів.', 'Детермінований метод'],
'summarize' => ['Резюме', 'Резюме документа', 'Витягуйте ключові факти, дати, сторони та юридичні посилання — з можливістю збагачення корпусом.', 'Обробити і забути'], 'summarize' => ['Резюме', 'Резюме документа', 'Витягуйте ключові факти, дати, сторони та юридичні посилання — з можливістю збагачення корпусом.', 'Обробити і забути'],
'legal-analysis' => ['Юридичний аналіз', 'Глибокий аналіз норвезького права', 'Витягніть юридичні питання з документа та отримайте відповіді від моделі dbn-legal-agent-v3 — з цитатами з barnevernsloven, ЄКПЛ і Verховного суду Норвегії.', 'Fine-tune · Norsk'],
'korrespond' => ['Korrespond', 'Листи і відповіді органам влади', 'Створюйте чернетки відповідей або нових листів до NAV, Barnevernet, школи, Bufdir та інших норвезьких органів — норвезькою + вашою мовою поряд, із перевіреними посиланнями на закон.', 'Hard-RAG · Norsk + EN/PL/UK'], 'korrespond' => ['Korrespond', 'Листи і відповіді органам влади', 'Створюйте чернетки відповідей або нових листів до NAV, Barnevernet, школи, Bufdir та інших норвезьких органів — норвезькою + вашою мовою поряд, із перевіреними посиланнями на закон.', 'Hard-RAG · Norsk + EN/PL/UK'],
'barnevernet' => ['BVJ аналізатор', 'Документи Barnevernet', 'Аналізуйте документи захисту дітей з вашої позиції, з процесуальними ризиками та джерелами.', 'Документ + RAG'], 'barnevernet' => ['BVJ аналізатор', 'Документи Barnevernet', 'Аналізуйте документи захисту дітей з вашої позиції, з процесуальними ризиками та джерелами.', 'Документ + RAG'],
'advocate' => ['Адвокат', 'Позиційний бриф', 'Оберіть, кого представляєте, і створіть бриф із джерелами на підтримку цієї позиції.', 'ЄСПЛ + Lovdata'], 'advocate' => ['Адвокат', 'Позиційний бриф', 'Оберіть, кого представляєте, і створіть бриф із джерелами на підтримку цієї позиції.', 'ЄСПЛ + Lovdata'],
@@ -1550,6 +1553,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
'timeline' => ['Oś czasu', 'Wydarzenia i terminy', 'Wyodrębniaj daty, rozprawy, etapy Barnevernet i terminy prawne z notatek lub plików.', 'Przetwórz i zapomnij'], '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'], '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'], 'summarize' => ['Streszczenie', 'Streszczenie dokumentu', 'Wyodrębniaj kluczowe fakty, daty, strony i odniesienia prawne — z opcjonalnym wzbogaceniem korpusem.', 'Przetwórz i zapomnij'],
'legal-analysis' => ['Analiza prawna', 'Głęboka analiza prawa norweskiego', 'Wyodrębnij odrębne kwestie prawne z dokumentu i uzyskaj odpowiedzi od modelu dbn-legal-agent-v3 — z cytatami z barnevernsloven, EKPC i norweskiego Sądu Najwyższego.', 'Fine-tune · Norsk'],
'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'], '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'], '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'], 'advocate' => ['Adwokat', 'Stronniczy brief', 'Wybierz, kogo reprezentujesz, i wygeneruj brief oparty na źródłach dla tej pozycji.', 'ETPC + Lovdata'],
@@ -1561,12 +1565,13 @@ function dbnToolsLaunchedTools(?string $language = null): array
]; ];
$selected = $copy[$language] ?? $copy['en']; $selected = $copy[$language] ?? $copy['en'];
$order = ['transcribe', 'timeline', 'redact', 'summarize', 'korrespond', 'barnevernet', 'advocate', 'deep-research', 'discrepancy', 'corpus', 'citations']; $order = ['transcribe', 'timeline', 'redact', 'summarize', 'legal-analysis', 'korrespond', 'barnevernet', 'advocate', 'deep-research', 'discrepancy', 'corpus', 'citations'];
$icons = [ $icons = [
'transcribe' => 'TR', 'transcribe' => 'TR',
'timeline' => 'TL', 'timeline' => 'TL',
'redact' => 'RX', 'redact' => 'RX',
'summarize' => 'SZ', 'summarize' => 'SZ',
'legal-analysis' => 'LA',
'korrespond' => 'KOR', 'korrespond' => 'KOR',
'barnevernet' => 'BVJ', 'barnevernet' => 'BVJ',
'advocate' => 'ADV', 'advocate' => 'ADV',
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
$toolName = 'legal-analysis';
$toolTitle = 'Legal Analysis';
$toolKind = 'Deep Legal Q&A';
$toolBadge = 'Two-pass';
$extraScripts = ['assets/js/legal-analysis.js'];
require_once __DIR__ . '/includes/layout.php';
?>
<form id="laForm" class="tool-form" novalidate>
<div class="lang-switcher" id="laLangSwitcher" role="group" aria-label="UI language">
<button type="button" class="lang-btn la-lang-btn is-active" data-lang="no">&#127475;&#127476; NO</button>
<button type="button" class="lang-btn la-lang-btn" data-lang="en">&#127468;&#127463; EN</button>
</div>
<div class="control-row" id="laDocTypeControl">
<span class="control-label">Document type</span>
<label><input type="radio" name="laDocType" value="auto" checked> Auto-detect</label>
<label><input type="radio" name="laDocType" value="barnevernet"> Barnevernet</label>
<label><input type="radio" name="laDocType" value="adopsjon"> Adopsjon</label>
<label><input type="radio" name="laDocType" value="emergency"> Akutt-plassering</label>
<label><input type="radio" name="laDocType" value="samvær"> Samvær</label>
<label><input type="radio" name="laDocType" value="fylkesnemnd"> Fylkesnemnd</label>
<label><input type="radio" name="laDocType" value="other"> Annet</label>
</div>
<p class="upload-hint">
Engine: <strong>dbn-legal-agent-v3</strong> (Norwegian legal fine-tune on GPU). Expect ~30-60 seconds per issue, up to 5 issues per run.
</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="laUploadZone" role="region" aria-label="File upload">
<input type="file" id="laUploadInput" multiple accept=".pdf,.docx,.txt" aria-label="Choose files to analyse">
<div id="laUploadPrompt" class="upload-prompt">
<span class="upload-icon" aria-hidden="true">&#8679;</span>
<p>Drop up to 5 files here, or <label for="laUploadInput" 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="laUploadFileInfo" class="upload-file is-hidden">
<ul id="laUploadFileList" class="upload-file-list"></ul>
<button type="button" id="laUploadClear" class="upload-clear">&times; Clear files</button>
</div>
</div>
<label class="input-label" for="laInput">Pasted text <small class="control-hint">(optional if file or doc selected)</small></label>
<textarea id="laInput" name="text" rows="8" placeholder="Paste a case note, court decision, vedtak, brev, or any legal 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="laStatus" class="form-status" role="status" aria-live="polite"></p>
<button id="laRunButton" type="submit">Run legal analysis</button>
</div>
</form>
<section id="laResults" class="results" aria-live="polite">
<div class="empty-state">
<h3>Ready</h3>
<p>Upload a document or paste text the tool will extract up to 5 distinct legal issues, then ask the Norwegian-law fine-tune to answer each one with citations.</p>
<p class="upload-hint">Pass 1 uses Azure GPT-4o-mini to spot issues. Pass 2 calls the dbn-legal-agent-v3 fine-tune on ocelot for each one. Pass 3 synthesises the overall picture. A typical run takes 2-5 minutes.</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">
<input type="radio" name="language" value="no" 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'; ?>