From 7e6463ed22d565f217becb4b56cd0511748b9802 Mon Sep 17 00:00:00 2001 From: davegilligan Date: Sun, 24 May 2026 04:21:01 +0200 Subject: [PATCH] =?UTF-8?q?Add=20Legal=20Analysis=20tool=20=E2=80=94=20two?= =?UTF-8?q?-pass=20DBN-legal=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- advocate.php | 3 +- api/legal-analysis.php | 165 ++++++++++++++ assets/css/tools.css | 130 +++++++++++ assets/js/legal-analysis.js | 377 ++++++++++++++++++++++++++++++++ assets/js/summarize.js | 7 + assets/js/tools.js | 188 ++++++++++++++++ barnevernet.php | 2 +- case-result.php | 48 ++++ deep-research.php | 2 +- discrepancy.php | 2 +- includes/CaseResults.php | 20 +- includes/LegalAnalysisAgent.php | 309 ++++++++++++++++++++++++++ includes/i18n.php | 29 ++- legal-analysis.php | 104 +++++++++ 14 files changed, 1361 insertions(+), 25 deletions(-) create mode 100644 api/legal-analysis.php create mode 100644 assets/js/legal-analysis.js create mode 100644 includes/LegalAnalysisAgent.php create mode 100644 legal-analysis.php diff --git a/advocate.php b/advocate.php index c29b3d7..44ef6d0 100644 --- a/advocate.php +++ b/advocate.php @@ -55,8 +55,7 @@ require_once __DIR__ . '/includes/layout.php'; - - +

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.

diff --git a/api/legal-analysis.php b/api/legal-analysis.php new file mode 100644 index 0000000..5e21df7 --- /dev/null +++ b/api/legal-analysis.php @@ -0,0 +1,165 @@ + 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.']); +} diff --git a/assets/css/tools.css b/assets/css/tools.css index 4e6b7c4..00553aa 100644 --- a/assets/css/tools.css +++ b/assets/css/tools.css @@ -9267,3 +9267,133 @@ body.lt-landing { font-size: 0.82rem; 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; } diff --git a/assets/js/legal-analysis.js b/assets/js/legal-analysis.js new file mode 100644 index 0000000..f1b0425 --- /dev/null +++ b/assets/js/legal-analysis.js @@ -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 '
  • ' + esc(f.name) + '' + + ' ' + f.chars.toLocaleString() + ' chars
  • '; + }).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 = + '
    ' + + '
    Pass 1 — Extracting legal issues from your document…
    ' + + '
    ' + + '
      '; + } + + 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 = + '
      ' + + '#' + issue.id + '' + + '' + + esc((issue.severity_hint || 'medium').toUpperCase()) + + '' + + '
      ' + + '

      ' + esc(issue.question) + '

      ' + + (issue.brief_context ? '

      ' + esc(issue.brief_context) + '

      ' : '') + + '
      Waiting…
      '; + list.appendChild(li); + _issueCards[issue.id] = li; + }); + // Mark Pass 1 complete + var pipeline = resultsEl.querySelector('.la-pipeline'); + if (pipeline) { + pipeline.innerHTML = '
      Pass 1 — Found ' + issues.length + ' legal issue(s)
      ' + + '
      Pass 2 — Asking dbn-legal-agent-v3 about each issue…
      '; + } + } + + 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 = '
      Svar

      ' + + esc(issue.answer || '').replace(/\n/g, '
      ') + + '

      '; + + var basisHtml = ''; + if (issue.legal_basis) { + basisHtml = '
      Lovgrunnlag: ' + + esc(issue.legal_basis) + '
      '; + } + var checkHtml = ''; + if (issue.what_to_check) { + checkHtml = '

      ' + esc(issue.what_to_check) + '

      '; + } + card.insertAdjacentHTML('beforeend', answerHtml + basisHtml + checkHtml); + } + + function renderFinal(result) { + // Add Pass 3 synthesis at the top + var topHtml = ''; + if (result.overall_assessment) { + topHtml = '
      ' + + '

      Overall assessment

      ' + + '

      ' + esc(result.overall_assessment) + '

      '; + if (Array.isArray(result.next_steps) && result.next_steps.length) { + topHtml += '

      Next steps

        ' + + result.next_steps.map(function (s) { return '
      • ' + esc(s) + '
      • '; }).join('') + + '
      '; + } + if (result.disclaimer) { + topHtml += '

      ' + esc(result.disclaimer) + '

      '; + } + topHtml += '
      '; + } + + // Mark Pass 2/3 complete in the pipeline strip + var pipeline = resultsEl.querySelector('.la-pipeline'); + if (pipeline) { + pipeline.innerHTML = + '
      Pass 1 — Issues extracted
      ' + + '
      Pass 2 — Specialist answered ' + (result.issues || []).length + ' issue(s)
      ' + + '
      Pass 3 — Synthesis complete
      '; + } + + // 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 = + '
    1. No discrete legal issues were identified.

    2. '; + } + } + + function showError(msg) { + if (resultsEl) { + resultsEl.innerHTML = '

      Error

      ' + esc(msg) + '

      '; + } + 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, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } +}()); diff --git a/assets/js/summarize.js b/assets/js/summarize.js index 9107543..c80df93 100644 --- a/assets/js/summarize.js +++ b/assets/js/summarize.js @@ -229,6 +229,13 @@ 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) { var credEl = document.getElementById('creditsRemaining'); if (credEl) credEl.textContent = data.balance; diff --git a/assets/js/tools.js b/assets/js/tools.js index 3ed5b40..468ea7c 100644 --- a/assets/js/tools.js +++ b/assets/js/tools.js @@ -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 = + '

      ⚖️🇳🇴 Deep Legal Analysis

      ' + + '
      ' + + '
      Pass 1 — Extracting legal issues…
      ' + + '
      ' + + '
        '; + 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 += '

        Error: ' + escapeHtml(err.message || String(err)) + '

        '; + }); +} + +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 = + '
        #' + issue.id + '' + + '' + escapeHtml(sev.toUpperCase()) + '
        ' + + '

        ' + escapeHtml(issue.question || '') + '

        ' + + (issue.brief_context ? '

        ' + escapeHtml(issue.brief_context) + '

        ' : '') + + '
        Waiting…
        '; + listEl.appendChild(li); + cards[issue.id] = li; + }); + pipelineEl.innerHTML = + '
        Pass 1 — Found ' + (data.issues || []).length + ' issue(s)
        ' + + '
        Pass 2 — Asking dbn-legal-agent-v3…
        '; + } 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 = '
        Svar

        ' + escapeHtml(iss.answer || '').replace(/\n/g, '
        ') + '

        '; + if (iss.legal_basis) html += '
        Lovgrunnlag: ' + escapeHtml(iss.legal_basis) + '
        '; + if (iss.what_to_check) html += '

        ' + escapeHtml(iss.what_to_check) + '

        '; + card.insertAdjacentHTML('beforeend', html); + } else if (data.event === 'final' && data.result) { + const res = data.result; + pipelineEl.innerHTML = + '
        Pass 1 — Issues extracted
        ' + + '
        Pass 2 — Specialist answered ' + (res.issues || []).length + '
        ' + + '
        Pass 3 — Synthesis complete
        '; + if (res.overall_assessment) { + let syn = '
        ' + + '

        Overall assessment

        ' + + '

        ' + escapeHtml(res.overall_assessment) + '

        '; + if (Array.isArray(res.next_steps) && res.next_steps.length) { + syn += '

        Next steps

          ' + + res.next_steps.map(function (s) { return '
        • ' + escapeHtml(s) + '
        • '; }).join('') + + '
        '; + } + if (res.disclaimer) { + syn += '

        ' + escapeHtml(res.disclaimer) + '

        '; + } + syn += '
        '; + 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 += '
        Error: ' + escapeHtml(data.message || data.error || 'unknown') + '
        '; + } +} + +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) { diff --git a/barnevernet.php b/barnevernet.php index 9aaf498..610d35f 100644 --- a/barnevernet.php +++ b/barnevernet.php @@ -41,7 +41,7 @@ require_once __DIR__ . '/includes/layout.php'; - +

        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.

        diff --git a/case-result.php b/case-result.php index 8b086f1..a974a5a 100644 --- a/case-result.php +++ b/case-result.php @@ -298,6 +298,53 @@ function crField(string $label, string $value): string { + + +
        +

        Overall assessment

        +

        +
        + + + + + +
        +

        Legal issues

        + +
        +
        + # + +
        +

        + +

        + +
        +
        Svar (dbn-legal-agent-v3)
        +

        +
        + +
        + Lovgrunnlag: +
        + + +

        + +
        + +
        + + +

        + +
        @@ -381,6 +428,7 @@ function crField(string $label, string $value): string { 'ask': '/ask.php', 'redact': '/redact.php', 'transcribe': '/transcribe.php', + 'legal-analysis':'/legal-analysis.php', }[tool] || '/dashboard.php'; window.location.href = path + '?rerun=' + ; }); diff --git a/deep-research.php b/deep-research.php index 76a2904..b264999 100644 --- a/deep-research.php +++ b/deep-research.php @@ -21,7 +21,7 @@ require_once __DIR__ . '/includes/layout.php'; - +

        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.

        diff --git a/discrepancy.php b/discrepancy.php index 203f498..81afca0 100644 --- a/discrepancy.php +++ b/discrepancy.php @@ -60,7 +60,7 @@ require_once __DIR__ . '/includes/layout.php'; - +

        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.

        diff --git a/includes/CaseResults.php b/includes/CaseResults.php index f4c2899..0af400b 100644 --- a/includes/CaseResults.php +++ b/includes/CaseResults.php @@ -33,6 +33,7 @@ final class CaseResults 'ask', 'redact', 'transcribe', + 'legal-analysis', ]; /** True when the user is on a tier that gets saved results (Plus, Pro, or active Plus trial). */ @@ -234,10 +235,11 @@ final class CaseResults 'deep-research' => 'Dyp analyse', 'discrepancy' => 'Motstrid', 'timeline' => 'Tidslinje', - 'summarize' => 'Sammendrag', - 'ask' => 'Spørsmål & svar', - 'redact' => 'Anonymisering', - 'transcribe' => 'Transkripsjon', + 'summarize' => 'Sammendrag', + 'ask' => 'Spørsmål & svar', + 'redact' => 'Anonymisering', + 'transcribe' => 'Transkripsjon', + 'legal-analysis' => 'Juridisk analyse', ][$tool] ?? ucfirst($tool); } @@ -251,10 +253,11 @@ final class CaseResults 'deep-research' => '🔬', 'discrepancy' => '🔍', 'timeline' => '📅', - 'summarize' => '📝', - 'ask' => '💬', - 'redact' => '🖊️', - 'transcribe' => '🎙️', + 'summarize' => '📝', + 'ask' => '💬', + 'redact' => '🖊️', + 'transcribe' => '🎙️', + 'legal-analysis' => '⚖️🇳🇴', ][$tool] ?? '📄'; } @@ -272,6 +275,7 @@ final class CaseResults 'ask' => [$input['question'] ?? null], 'redact' => [$input['text'] ?? null], 'transcribe' => [$input['filename'] ?? null], + 'legal-analysis' => [$input['doc_type'] ?? null, $input['text'] ?? null], default => [$input['title'] ?? null, $input['query'] ?? null, $input['text'] ?? null], }; foreach ($candidates as $c) { diff --git a/includes/LegalAnalysisAgent.php b/includes/LegalAnalysisAgent.php new file mode 100644 index 0000000..22a48fd --- /dev/null +++ b/includes/LegalAnalysisAgent.php @@ -0,0 +1,309 @@ +azureMini = (new DbnAzureOpenAiGateway())->withDeployment('gpt-4o-mini'); + $this->legalSvc = new DbnLegalToolsService(); + } + + /** + * Pass 1 — extract distinct legal issues. Azure-only. + * + * @return array + */ + 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 = <<", + "brief_context": "<≤2 sentences from the document that triggered this question>", + "doc_type": "", + "severity_hint": "" + } + ] +} + +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 = <<", + "next_steps": ["", "", ""], + "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, + ]; + } +} diff --git a/includes/i18n.php b/includes/i18n.php index d051d0e..b1af41b 100644 --- a/includes/i18n.php +++ b/includes/i18n.php @@ -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'], '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'], + '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'], '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'], @@ -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'], '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'], + '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'], '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'], @@ -1537,6 +1539,7 @@ function dbnToolsLaunchedTools(?string $language = null): array 'timeline' => ['Хронологія', 'Події та строки', 'Витягуйте дати, слухання, етапи Barnevernet і юридичні строки з нотаток або файлів.', 'Обробити і забути'], 'redact' => ['Редагування', 'Захист приватності', 'Видаляйте імена, ідентифікаційні номери, телефони та адреси перед поширенням документів.', 'Детермінований метод'], 'summarize' => ['Резюме', 'Резюме документа', 'Витягуйте ключові факти, дати, сторони та юридичні посилання — з можливістю збагачення корпусом.', 'Обробити і забути'], + 'legal-analysis' => ['Юридичний аналіз', 'Глибокий аналіз норвезького права', 'Витягніть юридичні питання з документа та отримайте відповіді від моделі dbn-legal-agent-v3 — з цитатами з barnevernsloven, ЄКПЛ і Verховного суду Норвегії.', 'Fine-tune · Norsk'], 'korrespond' => ['Korrespond', 'Листи і відповіді органам влади', 'Створюйте чернетки відповідей або нових листів до NAV, Barnevernet, школи, Bufdir та інших норвезьких органів — норвезькою + вашою мовою поряд, із перевіреними посиланнями на закон.', 'Hard-RAG · Norsk + EN/PL/UK'], 'barnevernet' => ['BVJ аналізатор', 'Документи Barnevernet', 'Аналізуйте документи захисту дітей з вашої позиції, з процесуальними ризиками та джерелами.', 'Документ + RAG'], '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'], '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'], + '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'], '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'], @@ -1561,19 +1565,20 @@ function dbnToolsLaunchedTools(?string $language = null): array ]; $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 = [ - 'transcribe' => 'TR', - 'timeline' => 'TL', - 'redact' => 'RX', - 'summarize' => 'SZ', - 'korrespond' => 'KOR', - 'barnevernet' => 'BVJ', - 'advocate' => 'ADV', - 'deep-research' => 'DR', - 'discrepancy' => 'DC', - 'corpus' => 'KB', - 'citations' => 'CIT', + 'transcribe' => 'TR', + 'timeline' => 'TL', + 'redact' => 'RX', + 'summarize' => 'SZ', + 'legal-analysis' => 'LA', + 'korrespond' => 'KOR', + 'barnevernet' => 'BVJ', + 'advocate' => 'ADV', + 'deep-research' => 'DR', + 'discrepancy' => 'DC', + 'corpus' => 'KB', + 'citations' => 'CIT', ]; $out = []; foreach ($order as $slug) { diff --git a/legal-analysis.php b/legal-analysis.php new file mode 100644 index 0000000..5fb6bf0 --- /dev/null +++ b/legal-analysis.php @@ -0,0 +1,104 @@ + +
        + +
        + + +
        + +
        + Document type + + + + + + + +
        + +

        + Engine: dbn-legal-agent-v3 (Norwegian legal fine-tune on GPU). Expect ~30-60 seconds per issue, up to 5 issues per run. +

        + +
        + +
        + +
        + +
        + +
        + +

        Drop up to 5 files here, or

        +

        PDF, DOCX, TXT — text extracted in memory, never stored

        +
        + +
        + + + + + +
        + +
        +
        +

        Ready

        +

        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.

        +

        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.

        +
        +
        + + + + + + + + + + + +