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 */ public function extractIssues(string $text, string $language, string $docType): array { $locale = dbnToolsLanguageName($language); $text = mb_substr($text, 0, 24000, 'UTF-8'); // keep prompt within 4o-mini context $prompt = <<", "brief_context": "<≤2 sentences in {$locale} summarising what in the document triggered this question — paraphrase, do not quote in Norwegian unless quoting a statute>", "doc_type": "", "severity_hint": "" } ] } Rules: - Skip non-legal observations (logistics, social commentary, opinions). - Each question should be answerable with citations to barnevernsloven, EMK Art. X, named Høyesterett/EMD cases — NOT general advice. - If the document has fewer than 5 real legal issues, return fewer entries. - If NO real legal issue exists, return {"issues": []}. - 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 = <<", "next_steps": ["", "", ""], "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, ]; } }