8a11001bff
Routes AI tools across three tiers based on task complexity: - Azure GPT-4o-mini always: redact, translate, timeline-basic, search-legal (mechanical tasks) - Claude Haiku 4.5 (Bedrock): ask, summarize, timeline-deep, citations (Norwegian nuance) - Claude Sonnet 4.6 (Bedrock): korrespond, legal-analysis, deep-research, barnevernet-analyze, discrepancy-find, advocate (public-facing legal output) No AWS credentials in app — credentials live in LiteLLM on Colin (same as nova-lite). Rollback: DBN_BEDROCK_ENABLED=false in .env, no code push needed. Includes extended thinking support for Pro deep-research via chatWithThinking(). Claude Opus 4.7 constant added for future premium tier (needs litellm_config.yaml entry). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
377 lines
17 KiB
PHP
377 lines
17 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/bootstrap.php';
|
|
require_once __DIR__ . '/AzureOpenAiGateway.php';
|
|
require_once __DIR__ . '/DbnGatewayFactory.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|DbnBedrockGateway $azureMini;
|
|
private DbnLegalToolsService $legalSvc;
|
|
|
|
public function __construct()
|
|
{
|
|
// On Azure: gpt-4o-mini for extraction/synthesis. On Bedrock: factory picks Haiku/Sonnet.
|
|
$this->azureMini = DbnGatewayFactory::bedrockEnabled()
|
|
? DbnGatewayFactory::makeForTool('legal-analysis')
|
|
: (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}
|
|
User's response language: {$locale}
|
|
|
|
Return JSON only — every human-readable string ("question", "brief_context") MUST be
|
|
written in {$locale}. Keep Norwegian statute references (barnevernsloven § 4-25,
|
|
forvaltningsloven § 17, EMK Art. 8) and Norwegian/EMD case names (Strand Lobben mot
|
|
Norge 37283/13) in their original form even when the surrounding text is in {$locale}.
|
|
{
|
|
"issues": [
|
|
{
|
|
"id": 1,
|
|
"question": "<short legal question in {$locale}, single issue, statute refs kept in original Norwegian/Latin>",
|
|
"brief_context": "<≤2 sentences in {$locale} summarising what in the document triggered this question — paraphrase, do not quote in Norwegian unless quoting a statute>",
|
|
"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": []}.
|
|
- The source document may be in Norwegian — that is fine; still write your output in {$locale}.
|
|
|
|
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
|
|
{
|
|
$locale = dbnToolsLanguageName($language);
|
|
|
|
// The fine-tune was trained primarily in Norwegian; the Norwegian system
|
|
// prompt keeps its precision on barnevernsloven / EMD. We then add a
|
|
// language-coercion line so the prose comes back in the user's chosen
|
|
// language. Statute and case names stay in their original Norwegian form.
|
|
$sysMsg = 'Du er en ekspert på norsk barnevernsloven og EMD-praksis. '
|
|
. 'Bruk 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. ';
|
|
|
|
if ($language === 'no') {
|
|
$sysMsg .= 'Svar på norsk.';
|
|
} else {
|
|
$sysMsg .= 'IMPORTANT: Write your answer in ' . $locale
|
|
. '. Keep all Norwegian statute references (e.g. "barnevernsloven § 4-25", '
|
|
. '"forvaltningsloven § 17", "EMK Art. 8") and case names (e.g. "Strand Lobben '
|
|
. 'mot Norge 37283/13") in their original Norwegian/Latin form. The "Kilder:" '
|
|
. 'section heading stays as "Kilder:" but its contents (the cited authorities) '
|
|
. 'are listed in their original Norwegian form.';
|
|
}
|
|
|
|
$userMsg = $issue['question'];
|
|
if ($issue['brief_context'] !== '') {
|
|
$ctxLabel = match ($language) {
|
|
'no' => 'Kontekst fra saken',
|
|
'pl' => 'Kontekst sprawy',
|
|
'uk' => 'Контекст справи',
|
|
default => 'Case context',
|
|
};
|
|
$userMsg .= "\n\n" . $ctxLabel . ': ' . $issue['brief_context'];
|
|
}
|
|
if ($corpusContext !== '') {
|
|
$srcLabel = match ($language) {
|
|
'no' => 'Relevante kilder fra Do Better Norge-korpuset',
|
|
'pl' => 'Istotne źródła z korpusu Do Better Norge',
|
|
'uk' => 'Релевантні джерела з корпусу Do Better Norge',
|
|
default => 'Relevant sources from the Do Better Norge corpus',
|
|
};
|
|
$userMsg .= "\n\n" . $srcLabel . ":\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);
|
|
|
|
$whatToCheck = match ($language) {
|
|
'no' => 'Verifiser med norsk familieretsadvokat før handling.',
|
|
'pl' => 'Zweryfikuj z norweskim adwokatem ds. rodzinnych przed podjęciem działań.',
|
|
'uk' => 'Перевірте з норвезьким адвокатом із сімейного права перед діями.',
|
|
default => 'Verify with a qualified Norwegian family-law lawyer before acting.',
|
|
};
|
|
|
|
return [
|
|
'id' => $issue['id'],
|
|
'question' => $issue['question'],
|
|
'brief_context' => $issue['brief_context'],
|
|
'answer' => $clean,
|
|
'severity' => $severity,
|
|
'legal_basis' => $legalBasis,
|
|
'citations_from_corpus' => [],
|
|
'what_to_check' => $whatToCheck,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
$disclaimerText = match ($language) {
|
|
'no' => 'Dette er automatisert juridisk analyse, ikke juridisk rådgivning. Verifiser med en kvalifisert norsk advokat før du handler.',
|
|
'pl' => 'To jest zautomatyzowana analiza prawna, a nie porada prawna. Zweryfikuj z wykwalifikowanym norweskim prawnikiem przed podjęciem działań.',
|
|
'uk' => 'Це автоматизований юридичний аналіз, а не юридична консультація. Перевірте з кваліфікованим норвезьким юристом перед діями.',
|
|
default => 'This is automated legal analysis, not legal advice. Verify with a qualified Norwegian lawyer before acting.',
|
|
};
|
|
|
|
$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.
|
|
|
|
Output language: {$locale}. Every human-readable string ("overall_assessment",
|
|
"next_steps[]", "disclaimer") MUST be written in {$locale}. Keep Norwegian statute
|
|
references and case names in their original form.
|
|
|
|
ISSUES + ANSWERS:
|
|
{$issuesBlock}
|
|
|
|
Return JSON only:
|
|
{
|
|
"overall_assessment": "<3-5 sentences in {$locale} summarising the legal picture across all issues>",
|
|
"next_steps": ["<concrete action 1 in {$locale}>", "<concrete action 2>", "<concrete action 3>"],
|
|
"disclaimer": "{$disclaimerText}"
|
|
}
|
|
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)) {
|
|
$emptyAssessment = match ($language) {
|
|
'no' => 'Ingen distinkte juridiske spørsmål identifisert i dette dokumentet.',
|
|
'pl' => 'Nie zidentyfikowano odrębnych kwestii prawnych w tym dokumencie.',
|
|
'uk' => 'У цьому документі не виявлено окремих юридичних питань.',
|
|
default => 'No discrete legal issues identified in this document.',
|
|
};
|
|
$emptyDisclaimer = match ($language) {
|
|
'no' => 'Automatisert analyse — ikke juridisk rådgivning.',
|
|
'pl' => 'Analiza zautomatyzowana — nie stanowi porady prawnej.',
|
|
'uk' => 'Автоматизований аналіз — не є юридичною консультацією.',
|
|
default => 'Automated analysis — not legal advice.',
|
|
};
|
|
return [
|
|
'ok' => true,
|
|
'issues' => [],
|
|
'overall_assessment' => $emptyAssessment,
|
|
'next_steps' => [],
|
|
'disclaimer' => $emptyDisclaimer,
|
|
'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,
|
|
];
|
|
}
|
|
}
|