['family_core', 'bufdir_guidance'], 'school_1_10' => ['family_core', 'broader_legal', 'echr'], 'sfo' => ['family_core', 'bufdir_guidance'], 'nav' => ['broader_legal', 'family_core'], 'bufdir' => ['bufdir_guidance', 'family_core', 'echr'], 'barnevernet' => ['child_welfare', 'echr', 'bufdir_guidance', 'family_core'], 'kommune_other' => ['broader_legal', 'family_core'], 'statsforvalter' => ['child_welfare', 'broader_legal', 'family_core'], 'trygderetten' => ['broader_legal', 'echr'], 'tingrett' => ['family_core', 'echr', 'norwegian_courts'], 'other' => ['broader_legal', 'family_core'], ]; /** Human labels for the recipient body, in Norwegian (used in prompt). */ private const BODY_LABELS = [ 'barnehage' => 'barnehagen (kindergarten)', 'school_1_10' => 'skolen (grunnskolen 1.–10. trinn)', 'sfo' => 'SFO (skolefritidsordningen)', 'nav' => 'NAV', 'bufdir' => 'Bufdir (Barne-, ungdoms- og familiedirektoratet)', 'barnevernet' => 'barnevernstjenesten', 'kommune_other' => 'kommunen', 'statsforvalter' => 'Statsforvalteren', 'trygderetten' => 'Trygderetten', 'tingrett' => 'Tingretten', 'other' => 'mottaker', ]; private DbnAzureOpenAiGateway $azure; public function __construct(?DbnAzureOpenAiGateway $azure = null) { $this->azure = $azure ?: new DbnAzureOpenAiGateway(); } /** * Pass 1 — extract structured facts and identify missing info. * * @return array{ * summary:string, * parties:string[], * decision_or_action:string, * deadlines:string[], * applicable_acts:string[], * jurisdiction:?string, * missing_facts:array, * suggested_goal:?string * } */ public function classify(array $intake): array { $body = $intake['recipient_body'] ?? 'other'; $mode = $intake['mode'] ?? 'initiate'; $bodyLabel = self::BODY_LABELS[$body] ?? 'mottaker'; $context = $this->buildContextBlob($intake); $modeLabel = $mode === 'reply' ? 'svar på et brev/vedtak' : 'innledning av en sak'; $prompt = << '', 'parties' => [], 'decision_or_action' => '', 'deadlines' => [], 'applicable_acts' => ['forvaltningsloven'], 'jurisdiction' => null, 'missing_facts' => [], 'suggested_goal' => null, ]; try { $raw = $this->azure->withDeployment(self::CLASSIFY_DEPLOYMENT)->chatText([ ['role' => 'system', 'content' => 'You return valid JSON only. No markdown fences.'], ['role' => 'user', 'content' => $prompt], ], ['json' => true, 'temperature' => 0.1, 'max_tokens' => 800, 'timeout' => 30]); $json = $this->azure->decodeJsonObject($raw); if (!is_array($json)) { return $default; } return $this->normalizeClassify($json, $default); } catch (Throwable $e) { error_log('Korrespond classify failed: ' . $e->getMessage()); return $default; } } /** * Pass 2 — retrieve law, draft, self-check, translate. * * @return array Final result payload (matches NDJSON 'final' event shape). */ public function generate(array $intake, array $classify, ?callable $emit = null): array { $body = $intake['recipient_body'] ?? 'other'; $outputType = $intake['output_type'] ?? 'email'; $tone = $intake['tone'] ?? 'neutral'; $userLang = dbnToolsNormalizeUiLanguage($intake['language'] ?? 'en'); $goal = trim((string)($intake['goal'] ?? ($classify['suggested_goal'] ?? ''))); $bodyLabel = self::BODY_LABELS[$body] ?? 'mottaker'; // ── Retrieve law ──────────────────────────────────────────────────────── if ($emit) { $emit('progress', ['detail' => 'Henter relevante lovkilder…']); } $retrieval = $this->retrieveLaw($body, $classify['applicable_acts'] ?? []); if ($emit) { $emit('retrieval', [ 'sources_count' => count($retrieval['sources']), 'applicable_acts' => $classify['applicable_acts'] ?? [], ]); } // ── Draft in Norwegian bokmål ─────────────────────────────────────────── if ($emit) { $emit('progress', ['detail' => 'Skriver utkast på bokmål…']); } $draftNo = $this->draftNorwegian( $intake, $classify, $retrieval['sources'], $bodyLabel, $outputType, $tone, $goal ); // ── Self-check: verify citations, deadline, goal, tone ────────────────── if ($emit) { $emit('progress', ['detail' => 'Kvalitetskontroll av utkastet…']); } $checked = $this->selfCheck($draftNo, $retrieval['sources'], $classify, $goal, $tone); // ── Translate to user language (if not Norwegian) ─────────────────────── $draftUser = $checked['draft']; if ($userLang !== 'no') { if ($emit) { $emit('progress', ['detail' => 'Oversetter til ' . dbnToolsLanguageName($userLang) . '…']); } $draftUser = $this->translate($checked['draft'], $userLang, $outputType); } return [ 'tool' => 'korrespond', 'language' => $userLang, 'mode' => $intake['mode'] ?? 'initiate', 'recipient_body'=> $body, 'output_type' => $outputType, 'tone' => $tone, 'summary' => $classify['summary'] ?? '', 'goal' => $goal, 'draft_no' => $checked['draft'], 'draft_user' => $draftUser, 'draft_user_lang'=> $userLang, 'cited_law' => $checked['cited_sources'], 'self_check' => $checked['flags'], 'applicable_acts'=> $classify['applicable_acts'] ?? [], 'deadlines' => $classify['deadlines'] ?? [], 'parties' => $classify['parties'] ?? [], 'disclaimer' => dbnToolsDisclaimer($userLang), ]; } // ── Helpers ───────────────────────────────────────────────────────────────── private function buildContextBlob(array $intake): string { $parts = []; $parts[] = 'Mottaker (recipient body): ' . (self::BODY_LABELS[$intake['recipient_body'] ?? 'other'] ?? 'mottaker'); $parts[] = 'Modus: ' . (($intake['mode'] ?? 'initiate') === 'reply' ? 'svare på mottatt brev' : 'innlede sak'); if (!empty($intake['case_ref'])) { $parts[] = 'Saksnummer: ' . $intake['case_ref']; } if (!empty($intake['where'])) { $parts[] = 'Sted (kommune/fylke): ' . $intake['where']; } if (!empty($intake['parties_text'])) { $parts[] = 'Parter:' . "\n" . $intake['parties_text']; } if (!empty($intake['deadlines']) && is_array($intake['deadlines'])) { $parts[] = 'Datoer/frister: ' . implode(', ', array_map('strval', $intake['deadlines'])); } if (!empty($intake['goal'])) { $parts[] = 'Brukerens mål: ' . $intake['goal']; } if (!empty($intake['narrative'])) { $parts[] = 'Hva skjedde / kontekst:' . "\n" . $intake['narrative']; } if (!empty($intake['received_text'])) { $parts[] = 'Mottatt brev/vedtak:' . "\n" . mb_substr($intake['received_text'], 0, 8000, 'UTF-8'); } if (!empty($intake['attachments_text'])) { $parts[] = 'Vedlegg (utdrag):' . "\n" . mb_substr($intake['attachments_text'], 0, 6000, 'UTF-8'); } if (!empty($intake['clarifications']) && is_array($intake['clarifications'])) { $parts[] = 'Tilleggsopplysninger fra brukeren:'; foreach ($intake['clarifications'] as $key => $value) { if (trim((string)$value) === '') continue; $parts[] = ' • ' . $key . ': ' . $value; } } return mb_substr(implode("\n\n", $parts), 0, self::MAX_CONTEXT_CHARS, 'UTF-8'); } private function normalizeClassify(array $json, array $default): array { $out = $default; if (is_string($json['summary'] ?? null)) $out['summary'] = trim($json['summary']); if (is_string($json['decision_or_action'] ?? null)) $out['decision_or_action'] = trim($json['decision_or_action']); if (is_string($json['jurisdiction'] ?? null) && trim($json['jurisdiction']) !== '') { $out['jurisdiction'] = trim($json['jurisdiction']); } if (is_string($json['suggested_goal'] ?? null) && trim($json['suggested_goal']) !== '') { $out['suggested_goal'] = trim($json['suggested_goal']); } if (is_array($json['parties'] ?? null)) { $out['parties'] = array_values(array_filter(array_map( fn($p) => trim((string)$p), $json['parties'] ))); } if (is_array($json['deadlines'] ?? null)) { $out['deadlines'] = array_values(array_filter(array_map( fn($d) => trim((string)$d), $json['deadlines'] ))); } if (is_array($json['applicable_acts'] ?? null)) { $allowed = ['forvaltningsloven','barnevernsloven','NAV-loven','opplæringslova', 'barnehageloven','EMK','barneloven','sosialtjenesteloven']; $out['applicable_acts'] = array_values(array_intersect($allowed, array_map(fn($a) => trim((string)$a), $json['applicable_acts']))); if (empty($out['applicable_acts'])) { $out['applicable_acts'] = ['forvaltningsloven']; } } if (is_array($json['missing_facts'] ?? null)) { $mf = []; foreach ($json['missing_facts'] as $item) { if (!is_array($item)) continue; $k = trim((string)($item['key'] ?? '')); $q = trim((string)($item['question'] ?? '')); if ($k === '' || $q === '') continue; $mf[] = ['key' => $k, 'question' => $q]; if (count($mf) >= 4) break; } $out['missing_facts'] = $mf; } return $out; } /** * Hard-RAG retrieval: pull law passages via ClientRagPipeline, filtered * to slice presets for the recipient body. Numbered sources for citation tokens. * * @return array{sources:array, applied_slices:string[]} */ private function retrieveLaw(string $body, array $applicableActs): array { $client = dbnToolsRequireClient(); $package = dbnToolsFetchPackage(dbnToolsRequiredPackageSlug()); if (!$package) { return ['sources' => [], 'applied_slices' => []]; } dbnToolsBootCaveau(); $aiPortalRoot = dbnToolsAiPortalRoot(); $v6 = $aiPortalRoot . '/platform/includes/dbn_v6.php'; if (is_file($v6)) { require_once $v6; } // Slice preset for this body type $sliceIds = self::BODY_PRESETS[$body] ?? self::BODY_PRESETS['other']; $sliceSel = []; foreach ($sliceIds as $sid) { $sliceSel[$sid] = true; } $sliceSel = function_exists('dbnV6NormalizeSliceSelection') ? dbnV6NormalizeSliceSelection($sliceSel) : $sliceSel; $ragDb = dbnToolsRagDb(); $sharedDocIds = []; if (function_exists('dbnV6ResolveSelectedDocIds')) { try { $sharedDocIds = dbnV6ResolveSelectedDocIds($ragDb, $sliceSel); } catch (Throwable $e) { error_log('Korrespond slice resolve failed: ' . $e->getMessage()); $sharedDocIds = []; } } // Build 2-3 retrieval queries: one per applicable act $queries = []; foreach ($applicableActs as $act) { $queries[] = $this->queryForAct($act); } if (empty($queries)) { $queries = ['forvaltningsloven prosessuelle rettigheter saksbehandling']; } $queries = array_slice(array_unique($queries), 0, 3); // Run hybrid retrieval via ClientRagPipeline $pool = []; try { if (!class_exists('ClientRagPipeline')) { return ['sources' => [], 'applied_slices' => array_keys(array_filter($sliceSel))]; } $rag = new ClientRagPipeline((int)$client['id'], 'http://10.0.1.10:4000', 60); foreach ($queries as $q) { try { $chunks = $rag->searchAll($q, 5, null, [ 'search_private' => false, 'search_shared' => true, 'package_ids' => [(int)$package['id']], 'shared_doc_ids' => $sharedDocIds, 'chunk_limit' => 5, 'search_method' => 'hybrid', 'reranker_enabled' => true, 'include_beta_website' => false, 'include_primary_website' => false, ]); } catch (Throwable $e) { error_log('Korrespond rag search failed: ' . $e->getMessage()); $chunks = []; } foreach ($chunks as $c) { $pool[] = $c; } } } catch (Throwable $e) { error_log('Korrespond rag init failed: ' . $e->getMessage()); } // Dedupe by chunk_id, number sequentially $seen = []; $sources = []; $n = 1; foreach ($pool as $c) { $cid = (string)($c['chunk_id'] ?? $c['id'] ?? ''); if ($cid === '' || isset($seen[$cid])) continue; $seen[$cid] = true; $text = (string)($c['chunk_text'] ?? $c['content'] ?? $c['text'] ?? ''); $title = (string)($c['document_title'] ?? $c['title'] ?? 'Lovkilde'); $section = (string)($c['section_title'] ?? $c['section'] ?? ''); $sources[] = [ 'n' => $n++, 'chunk_id' => $cid, 'title' => $title, 'section' => $section, 'excerpt' => dbnToolsExcerpt($text, 500), 'full_text'=> mb_substr($text, 0, 1800, 'UTF-8'), 'source_url' => (string)($c['source_url'] ?? $c['url'] ?? ''), ]; if (count($sources) >= 8) break; } return [ 'sources' => $sources, 'applied_slices' => array_keys(array_filter($sliceSel)), ]; } private function queryForAct(string $act): string { return match (strtolower($act)) { 'forvaltningsloven' => 'forvaltningsloven §17 §18 §24 §25 §28 §32 saksbehandling kontradiksjon innsyn klage', 'barnevernsloven' => 'barnevernsloven omsorgsovertakelse akuttvedtak undersøkelse saksbehandling', 'nav-loven' => 'NAV-loven folketrygdloven klage anke vedtak saksbehandling', 'opplæringslova' => 'opplæringslova spesialundervisning enkeltvedtak klage skole', 'barnehageloven' => 'barnehageloven enkeltvedtak klage tilbud spesialpedagogisk', 'emk' => 'EMK artikkel 6 artikkel 8 familieliv rettferdig rettergang', 'barneloven' => 'barneloven samvær foreldreansvar fast bosted', 'sosialtjenesteloven'=> 'sosialtjenesteloven økonomisk stønad kvalifiseringsprogram klage', default => 'forvaltningsloven saksbehandling klage', }; } private function draftNorwegian( array $intake, array $classify, array $sources, string $bodyLabel, string $outputType, string $tone, string $goal ): string { $context = $this->buildContextBlob($intake); $toneLabel = $this->toneLabelNorsk($tone); $outputBlock = $this->outputInstructionsNorsk($outputType, $bodyLabel); $sourcesBlock = ''; if (!empty($sources)) { $rows = []; foreach ($sources as $s) { $rows[] = sprintf("[id=%d] %s%s\n%s", (int)$s['n'], $s['title'], $s['section'] !== '' ? ' — ' . $s['section'] : '', $s['excerpt'] ); } $sourcesBlock = "RETRIEVED LAW PASSAGES (you may ONLY cite these):\n" . implode("\n\n", $rows); } else { $sourcesBlock = 'RETRIEVED LAW PASSAGES: (none — write a plain-language draft without § references)'; } $goalLine = $goal !== '' ? ('Brukerens mål: ' . $goal) : 'Brukerens mål: ikke spesifisert — utled fra konteksten.'; $prompt = <<azure->withDeployment(self::DRAFT_DEPLOYMENT)->chatText([ ['role' => 'system', 'content' => 'Du er en erfaren norsk juridisk forfatter som skriver presist og faktabasert.'], ['role' => 'user', 'content' => $prompt], ], [ 'temperature' => 0.25, 'max_tokens' => self::MAX_DRAFT_TOKENS, 'timeout' => 120, ]); } catch (Throwable $e) { error_log('Korrespond draft failed: ' . $e->getMessage()); return '[Utkast kunne ikke genereres. Prøv igjen.]'; } } private function selfCheck(string $draft, array $sources, array $classify, string $goal, string $tone): array { // Strip any [CITE:N] tokens that don't map to a real source $validIds = array_map(fn($s) => (int)$s['n'], $sources); $cited = []; $cleanedDraft = preg_replace_callback('/\[CITE:(\d+)\]/', function ($m) use ($validIds, &$cited) { $id = (int)$m[1]; if (in_array($id, $validIds, true)) { $cited[$id] = true; return '[' . $id . ']'; } return ''; // strip unverified }, $draft) ?? $draft; // Strip orphan § references whose nearest CITE was removed: lightweight heuristic — // leave §-text intact but flag if there are § references with NO surviving [N] near them. $hasParagraph = (bool)preg_match('/§\s*\d|artikkel\s*\d/iu', $cleanedDraft); $hasCitation = !empty($cited); // Deadline check: if classify found deadlines, expect at least one mention in draft $deadlineMentioned = true; if (!empty($classify['deadlines'])) { $deadlineMentioned = false; foreach ($classify['deadlines'] as $d) { if ($d !== '' && mb_stripos($cleanedDraft, $d) !== false) { $deadlineMentioned = true; break; } } if (!$deadlineMentioned && preg_match('/frist|innen|dato|deadline/iu', $cleanedDraft)) { $deadlineMentioned = true; } } // Goal mention check — relax: just check the draft is non-trivial $goalAddressed = mb_strlen(trim($cleanedDraft), 'UTF-8') > 150; $flags = [ 'citations_verified' => $hasParagraph ? ($hasCitation ? 'ok' : 'warn') : 'ok', 'deadline_mentioned' => $deadlineMentioned ? 'ok' : 'warn', 'goal_addressed' => $goalAddressed ? 'ok' : 'warn', 'tone' => 'ok', // tone is set by prompt; trust it ]; // Map cited ids → source records $citedSources = []; foreach ($sources as $s) { if (isset($cited[(int)$s['n']])) { $citedSources[] = $s; } } return [ 'draft' => trim($cleanedDraft), 'cited_sources' => $citedSources, 'flags' => $flags, ]; } private function translate(string $norwegianDraft, string $userLang, string $outputType): string { $target = dbnToolsLanguageName($userLang); $format = $this->outputFormatHintEnglish($outputType); $prompt = <<azure->withDeployment(self::SELFCHECK_DEPLOYMENT)->chatText([ ['role' => 'system', 'content' => 'You are a precise legal translator.'], ['role' => 'user', 'content' => $prompt], ], [ 'temperature' => 0.1, 'max_tokens' => self::MAX_DRAFT_TOKENS, 'timeout' => 90, ]); } catch (Throwable $e) { error_log('Korrespond translate failed: ' . $e->getMessage()); return $norwegianDraft; // fallback: return NO unchanged } } private function toneLabelNorsk(string $tone): string { return match ($tone) { 'cooperative' => 'samarbeidsorientert, høflig, men presist', 'firm' => 'fast og bestemt, men korrekt og uten anklagende språk', 'adversarial' => 'tydelig konfronterende, varsler videre rettslige skritt', 'warm' => 'forsonende og varm, anerkjenner motpartens situasjon', default => 'nøytral og profesjonell', }; } private function outputInstructionsNorsk(string $outputType, string $bodyLabel): string { return match ($outputType) { 'email' => << << << << 'FORMAT: E-post (standard).', }; } private function outputFormatHintEnglish(string $outputType): string { return match ($outputType) { 'email' => 'email', 'formal' => 'formal letter', 'filing' => 'court/tribunal filing', 'call_prep' => 'phone-call preparation notes', default => 'letter', }; } }