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:
+1
-2
@@ -55,8 +55,7 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
<label><input type="radio" name="advEngine" value="azure_mini" checked> Azure gpt-4o-mini ★ <small class="control-hint">(~15-45s)</small></label>
|
<label><input type="radio" name="advEngine" value="azure_mini" checked> Azure gpt-4o-mini ★ <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"> 🇳🇴 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"> 🇳🇴⚔️ DBN Legal Agent ★ <small class="control-hint">(dbn-legal-agent-v3 fine-tune · ~20-60s)</small></label>
|
||||||
<label><input type="radio" name="advEngine" value="dbn_legal_v3"> 🇳🇴 Norwegian specialist v3 ★ <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>
|
||||||
|
|
||||||
|
|||||||
@@ -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.']);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
}());
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -41,7 +41,7 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
<label><input type="radio" name="bvjEngine" value="azure_mini" checked> Azure gpt-4o-mini ★ <small class="control-hint">(~30-60s)</small></label>
|
<label><input type="radio" name="bvjEngine" value="azure_mini" checked> Azure gpt-4o-mini ★ <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"> 🇳🇴 Norwegian specialist v3 ★ <small class="control-hint">(dbn-legal-agent-v3 · ~20-60s)</small></label>
|
<label><input type="radio" name="bvjEngine" value="dbn_legal_v3"> 🇳🇴⚔️ DBN Legal Agent ★ <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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -21,7 +21,7 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
<label><input type="radio" name="drEngine" value="azure_mini" checked> Azure gpt-4o-mini ★ <small class="control-hint">(~15-45s)</small></label>
|
<label><input type="radio" name="drEngine" value="azure_mini" checked> Azure gpt-4o-mini ★ <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"> 🇳🇴 Norwegian specialist v3 ★ <small class="control-hint">(dbn-legal-agent-v3 · ~20-60s)</small></label>
|
<label><input type="radio" name="drEngine" value="dbn_legal_v3"> 🇳🇴⚔️ DBN Legal Agent ★ <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
@@ -60,7 +60,7 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
<label><input type="radio" name="dcEngine" value="azure_mini" checked> Azure gpt-4o-mini ★ <small class="control-hint">(~60-90s)</small></label>
|
<label><input type="radio" name="dcEngine" value="azure_mini" checked> Azure gpt-4o-mini ★ <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"> 🇳🇴 Norwegian specialist v3 ★ <small class="control-hint">(dbn-legal-agent-v3 · ~30-60s)</small></label>
|
<label><input type="radio" name="dcEngine" value="dbn_legal_v3"> 🇳🇴⚔️ DBN Legal Agent ★ <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>
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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">🇳🇴 NO</button>
|
||||||
|
<button type="button" class="lang-btn la-lang-btn" data-lang="en">🇬🇧 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">⇧</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> — 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">× 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'; ?>
|
||||||
Reference in New Issue
Block a user