azureMini = (new DbnAzureOpenAiGateway())->withDeployment('gpt-4o-mini'); $this->legalSvc = new DbnLegalToolsService(); } /** * Pass 1 — extract distinct legal issues. Azure-only. * * @return array */ public function extractIssues(string $text, string $language, string $docType): array { $locale = dbnToolsLanguageName($language); $text = mb_substr($text, 0, 24000, 'UTF-8'); // keep prompt within 4o-mini context $prompt = <<", "brief_context": "<≤2 sentences from the document that triggered this question>", "doc_type": "", "severity_hint": "" } ] } Rules: - Skip non-legal observations (logistics, social commentary, opinions). - Each question should be answerable with citations to barnevernsloven, EMK Art. X, named Høyesterett/EMD cases — NOT general advice. - If the document has fewer than 5 real legal issues, return fewer entries. - If NO real legal issue exists, return {"issues": []}. DOCUMENT: --- {$text} --- PROMPT; $raw = $this->azureMini->chatText( [ ['role' => 'system', 'content' => 'You return valid JSON only. No prose, no fences.'], ['role' => 'user', 'content' => $prompt], ], ['json' => true, 'temperature' => 0.1, 'max_tokens' => 1500, 'timeout' => 90] ); $decoded = $this->azureMini->decodeJsonObject($raw); $issues = is_array($decoded['issues'] ?? null) ? $decoded['issues'] : []; $clean = []; $id = 1; foreach ($issues as $issue) { $question = trim((string)($issue['question'] ?? '')); if ($question === '' || mb_strlen($question, 'UTF-8') < 10) { continue; } $clean[] = [ 'id' => $id++, 'question' => mb_substr($question, 0, 280, 'UTF-8'), 'brief_context' => mb_substr(trim((string)($issue['brief_context'] ?? '')), 0, 400, 'UTF-8'), 'doc_type' => (string)($issue['doc_type'] ?? $docType), 'severity_hint' => in_array($issue['severity_hint'] ?? '', ['high','medium','low'], true) ? $issue['severity_hint'] : 'medium', ]; if (count($clean) >= self::MAX_ISSUES) { break; } } return $clean; } /** * Pass 2 — single targeted question to dbn-legal-agent-v3 with corpus context. * Ocelot-only. Capped at 350 tokens / 60s to avoid the documented loop bug. * * @param array{id:int,question:string,brief_context:string,doc_type:string,severity_hint:string} $issue * @return array{id:int,question:string,answer:string,severity:string,legal_basis:string,citations_from_corpus:array,what_to_check:string,brief_context:string} */ public function answerIssue(array $issue, string $corpusContext, string $language): array { $sysMsg = 'Du er en ekspert på norsk barnevernsloven og EMD-praksis. ' . 'Svar alltid på norsk med korrekt juridisk terminologi. ' . 'Bruk terskler fra barnevernsloven 2021: § 4-25 krever «klar nødvendighet». ' . 'Strand Lobben mot Norge (37283/13) setter krav om rehabiliteringsplan før adopsjon. ' . 'Aldri oppfinn paragrafnumre, saksnumre eller dommernavn. ' . 'Avslutt med en «Kilder:»-seksjon som lister lovparagrafer og dommer du har sitert.'; $userMsg = $issue['question']; if ($issue['brief_context'] !== '') { $userMsg .= "\n\nKontekst fra saken: " . $issue['brief_context']; } if ($corpusContext !== '') { $userMsg .= "\n\nRelevante kilder fra Do Better Norge-korpuset:\n" . $corpusContext; } $answer = ''; $error = null; try { $response = dbnToolsCallGpuLlm( [ ['role' => 'system', 'content' => $sysMsg], ['role' => 'user', 'content' => $userMsg], ], [ 'model' => self::LEGAL_MODEL, 'temperature' => 0.1, 'max_tokens' => self::LEGAL_MAX_TOKENS, 'timeout' => self::LEGAL_TIMEOUT, ] ); $answer = trim((string)($response['choices'][0]['message']['content'] ?? '')); } catch (Throwable $e) { $error = $e->getMessage(); } $clean = dbnToolsExtractCleanAnswer($answer); if (mb_strlen($clean, 'UTF-8') < 30) { $clean = $answer !== '' ? $answer : ($error !== null ? "[Modellfeil: $error]" : '[Modellen returnerte ingen brukbar tekst.]'); } $severity = $clean !== '' ? dbnToolsInferCheckSeverity($clean) : $issue['severity_hint']; $legalBasis = dbnToolsExtractCheckLegalBasis($clean); return [ 'id' => $issue['id'], 'question' => $issue['question'], 'brief_context' => $issue['brief_context'], 'answer' => $clean, 'severity' => $severity, 'legal_basis' => $legalBasis, 'citations_from_corpus' => [], // populated by orchestrator if it kept the chunks 'what_to_check' => 'Verifiser med norsk familieretsadvokat før handling.', ]; } /** * Pass 3 — synthesise overall assessment. Azure-only. */ public function synthesise(array $issues, string $language, string $docType): array { $locale = dbnToolsLanguageName($language); $bullets = []; foreach ($issues as $i) { $bullets[] = sprintf( "- [%s] %s\n Svar: %s", strtoupper((string)$i['severity']), $i['question'], mb_substr((string)$i['answer'], 0, 600, 'UTF-8') ); } $issuesBlock = implode("\n", $bullets); $prompt = <<", "next_steps": ["", "", ""], "disclaimer": "This is automated legal analysis, not legal advice. Verify with a qualified Norwegian lawyer before acting." } PROMPT; try { $raw = $this->azureMini->chatText( [ ['role' => 'system', 'content' => 'You return valid JSON only. No prose, no fences.'], ['role' => 'user', 'content' => $prompt], ], ['json' => true, 'temperature' => 0.2, 'max_tokens' => 700, 'timeout' => 60] ); $decoded = $this->azureMini->decodeJsonObject($raw); if (is_array($decoded) && !empty($decoded['overall_assessment'])) { return [ 'overall_assessment' => (string)$decoded['overall_assessment'], 'next_steps' => is_array($decoded['next_steps'] ?? null) ? array_slice($decoded['next_steps'], 0, 5) : [], 'disclaimer' => (string)($decoded['disclaimer'] ?? 'Automated analysis — not legal advice.'), ]; } } catch (Throwable $e) { error_log('legal-analysis synthesis failed: ' . $e->getMessage()); } return [ 'overall_assessment' => 'Synthesis step did not return structured output. See individual issue answers below.', 'next_steps' => [], 'disclaimer' => 'Automated analysis — not legal advice. Verify with a qualified Norwegian lawyer.', ]; } /** * Full orchestrated run. Emits progress events via the $emit callable. * * @param callable $emit (string $event, array $payload): void */ public function runFullAnalysis(string $text, string $language, string $docType, callable $emit): array { $startMs = (int)round(microtime(true) * 1000); // Pass 1 $emit('progress', ['step' => 'extracting_issues', 'detail' => 'Identifying distinct legal issues…']); $issues = $this->extractIssues($text, $language, $docType); if (empty($issues)) { return [ 'ok' => true, 'issues' => [], 'overall_assessment' => 'No discrete legal issues identified in this document.', 'next_steps' => [], 'disclaimer' => 'Automated analysis — not legal advice.', 'model' => self::LEGAL_MODEL, 'latency_ms' => (int)round(microtime(true) * 1000) - $startMs, ]; } $emit('progress', [ 'step' => 'issues_extracted', 'detail' => sprintf('Found %d legal issue(s); asking specialist…', count($issues)), 'issues' => array_map(fn($i) => ['id' => $i['id'], 'question' => $i['question'], 'severity_hint' => $i['severity_hint']], $issues), ]); // Pass 2 — one issue at a time $answered = []; foreach ($issues as $issue) { $emit('progress', [ 'step' => 'issue_searching_corpus', 'detail' => sprintf('Issue %d: searching legal corpus…', $issue['id']), 'issue_id' => $issue['id'], ]); $corpusQuery = $issue['question'] . "\n" . $issue['brief_context']; $corpusContext = $this->legalSvc->corpusContextForSummarize($corpusQuery, 3); $emit('progress', [ 'step' => 'issue_answering', 'detail' => sprintf('Issue %d: asking dbn-legal-agent-v3…', $issue['id']), 'issue_id' => $issue['id'], ]); $answer = $this->answerIssue($issue, $corpusContext, $language); $answered[] = $answer; $emit('issue_answered', ['issue' => $answer]); } // Pass 3 $emit('progress', ['step' => 'synthesising', 'detail' => 'Synthesising overall assessment…']); $synth = $this->synthesise($answered, $language, $docType); return [ 'ok' => true, 'issues' => $answered, 'overall_assessment' => $synth['overall_assessment'], 'next_steps' => $synth['next_steps'], 'disclaimer' => $synth['disclaimer'], 'doc_type' => $docType, 'model' => self::LEGAL_MODEL, 'latency_ms' => (int)round(microtime(true) * 1000) - $startMs, ]; } }