diff --git a/api/ask.php b/api/ask.php index 31f3d0f..b902a95 100644 --- a/api/ask.php +++ b/api/ask.php @@ -16,5 +16,8 @@ dbnToolsWithChargedTelemetry('ask', $language, $ftUid, function () use ($input, if (mb_strlen(trim($question), 'UTF-8') < 5) { dbnToolsAbort('Enter a question or select a document before running.', 422, 'empty_text'); } - return (new DbnLegalToolsService())->ask($question, $language, $engine); + $persona = (isset($input['profile']) && is_string($input['profile']) && trim($input['profile']) !== '') + ? trim($input['profile']) + : null; + return (new DbnLegalToolsService())->ask($question, $language, $engine, $persona); }); diff --git a/api/barnevernet.php b/api/barnevernet.php index 9a8316e..6677966 100644 --- a/api/barnevernet.php +++ b/api/barnevernet.php @@ -122,7 +122,13 @@ try { } } - $result = (new DbnBvjAnalyzerAgent())->run( + $personaSlug = (isset($input['profile']) && is_string($input['profile']) && trim($input['profile']) !== '') + ? trim($input['profile']) + : null; + + $result = (new DbnBvjAnalyzerAgent()) + ->withPersona($personaSlug) + ->run( $uploadedFiles, $advocateRole, $engine, diff --git a/api/corpus-search.php b/api/corpus-search.php index df7ada0..6d678ec 100644 --- a/api/corpus-search.php +++ b/api/corpus-search.php @@ -13,6 +13,9 @@ $mode = in_array($rawMode, ['hybrid', 'bm25', 'vector', 'azure'], true) ? $r $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); $limit = max(1, min(20, (int)($input['limit'] ?? 8))); $category = isset($input['category']) && $input['category'] !== '' ? trim((string)$input['category']) : null; +$persona = (isset($input['profile']) && is_string($input['profile']) && trim($input['profile']) !== '') + ? trim($input['profile']) + : null; const EXCLUDED_DOMAIN = 'dobetternorge.no'; @@ -23,7 +26,7 @@ if (mb_strlen($query, 'UTF-8') < 3) { try { // ── HYBRID: delegate to the existing RAG pipeline ────────────────────── if ($mode === 'hybrid') { - $result = (new DbnLegalToolsService())->search($query, $language, $limit, 'disabled', null); + $result = (new DbnLegalToolsService())->search($query, $language, $limit, 'disabled', null, 'both', $persona); $hits = array_map(fn($h) => [ 'title' => $h['title'] ?? '', 'category' => $h['category'] ?? '', diff --git a/api/legal-analysis.php b/api/legal-analysis.php index 6f5bc05..05b96e2 100644 --- a/api/legal-analysis.php +++ b/api/legal-analysis.php @@ -55,7 +55,16 @@ try { 'chars' => mb_strlen($text, 'UTF-8'), ]); - $agent = new DbnLegalAnalysisAgent(); + // Resolve the persona (default = child-welfare for this barnevern tool) so + // the targeted legal-check step uses the persona's system prompt + model. + $client = dbnToolsRequireClient(); + $personaSlug = (isset($input['profile']) && is_string($input['profile']) && trim($input['profile']) !== '') + ? trim($input['profile']) + : 'child-welfare'; + $personaResolved = dbnToolsResolvePersona((int)$client['id'], $personaSlug); + + $agent = (new DbnLegalAnalysisAgent()) + ->withPersonaPrompt($personaResolved['system_prompt'] ?? null); // Pass 1 — extract issues (Azure, fast); deduct credit AFTER this succeeds $emit('progress', ['step' => 'extracting_issues', 'detail' => 'Identifying distinct legal issues…']); @@ -101,7 +110,7 @@ try { 'issue_id' => $issue['id'], ]); $corpusQuery = $issue['question'] . "\n" . $issue['brief_context']; - $corpusContext = $svc->corpusContextForSummarize($corpusQuery, 3); + $corpusContext = $svc->corpusContextForSummarize($corpusQuery, 3, $personaResolved['slug'] ?? null); $emit('progress', [ 'step' => 'issue_answering', @@ -122,7 +131,8 @@ try { try { $legalCheck = dbnToolsRunLegalCheck( mb_strimwidth((string)($synth['overall_assessment'] ?? ''), 0, 800), - $docType + $docType, + $personaResolved['system_prompt'] ?? null ); } catch (Throwable) {} diff --git a/api/personas.php b/api/personas.php new file mode 100644 index 0000000..b202832 --- /dev/null +++ b/api/personas.php @@ -0,0 +1,16 @@ + true, + 'personas' => $personas, + 'default_persona' => dbnToolsDefaultPersonaSlug(), +]); diff --git a/api/search.php b/api/search.php index 13eb4ca..38a4b8d 100644 --- a/api/search.php +++ b/api/search.php @@ -20,5 +20,8 @@ dbnToolsWithTelemetry('search', $language, function () use ($input, $language): $scope = in_array($input['corpus_scope'] ?? '', ['shared', 'private', 'both'], true) ? $input['corpus_scope'] : 'both'; - return (new DbnLegalToolsService())->search($query, $language, $limit, $temporalMode, $asOfDate, $scope); + $persona = (isset($input['profile']) && is_string($input['profile']) && trim($input['profile']) !== '') + ? trim($input['profile']) + : null; + return (new DbnLegalToolsService())->search($query, $language, $limit, $temporalMode, $asOfDate, $scope, $persona); }); diff --git a/api/summarize.php b/api/summarize.php index 62220d8..d4d0886 100644 --- a/api/summarize.php +++ b/api/summarize.php @@ -51,7 +51,10 @@ try { ]); $svc = new DbnLegalToolsService(); $query = mb_substr(trim($text), 0, 600, 'UTF-8'); - $corpusContext = $svc->corpusContextForSummarize($query, 8); + $persona = (isset($input['profile']) && is_string($input['profile']) && trim($input['profile']) !== '') + ? trim($input['profile']) + : null; + $corpusContext = $svc->corpusContextForSummarize($query, 8, $persona); $emit('progress', [ 'step' => 'corpus_done', 'detail' => $corpusContext !== '' diff --git a/api/translate.php b/api/translate.php index 02dd7d1..b16d3d3 100644 --- a/api/translate.php +++ b/api/translate.php @@ -70,12 +70,22 @@ try { $sourceName = dbnToolsLanguageName($sourceLang); $targetName = dbnToolsLanguageName($targetLang); + // Persona-aware domain framing (default = Norwegian family law for back-compat). + $client = dbnToolsRequireClient(); + $personaSlug = (isset($input['profile']) && is_string($input['profile']) && trim($input['profile']) !== '') + ? trim($input['profile']) + : null; + $persona = dbnToolsResolvePersona((int)$client['id'], $personaSlug); + $domainLabel = (isset($persona['name']) && is_string($persona['name']) && trim($persona['name']) !== '' && ($persona['source'] ?? '') === 'chat_profile') + ? trim($persona['name']) + : 'Norwegian family law, ECHR, and child-welfare proceedings'; + $docTypeHint = $docType !== 'auto' - ? "The document is of type: {$docType}. Apply appropriate Norwegian family-law terminology for this context." + ? "The document is of type: {$docType}. Apply appropriate {$domainLabel} terminology for this context." : ''; $systemPrompt = << { uploadClear: document.querySelector('#uploadClear'), aliasSection: document.querySelector('#aliasSection'), corpusScopeControl: document.querySelector('#corpusScopeControl'), + personaControl: document.querySelector('#personaControl'), + personaSelect: document.querySelector('#personaSelect'), addAliasRow: document.querySelector('#addAliasRow'), aliasRows: document.querySelector('#aliasRows'), audioZone: document.querySelector('#audioZone'), @@ -1057,6 +1062,7 @@ document.addEventListener('DOMContentLoaded', () => { if (state.authenticated) { checkHealth(); + if (els.personaSelect) loadPersonas(); } else { els.loginEmail?.focus(); } @@ -1084,6 +1090,7 @@ function setTool(toolName) { els.input.placeholder = tool.placeholder; } els.languageControl.classList.toggle('is-hidden', !tool.usesLanguage); + els.personaControl?.classList.toggle('is-hidden', !tool.usesPersona || !personaState.options.length); els.corpusScopeControl?.classList.toggle('is-hidden', toolName !== 'search'); els.redactionControl.classList.toggle('is-hidden', toolName !== 'redact'); els.uploadZone.classList.toggle('is-hidden', toolName !== 'redact' && toolName !== 'timeline'); @@ -1154,6 +1161,10 @@ async function runTool(event) { if (tool.usesLanguage) { payload.language = currentLanguage(); } + if (tool.usesPersona) { + const profile = currentPersona(); + if (profile) payload.profile = profile; + } if (state.activeTool === 'search') { payload.limit = 7; payload.corpus_scope = currentCorpusScope(); @@ -1530,6 +1541,47 @@ function currentCorpusScope() { return document.querySelector('input[name="corpusScope"]:checked')?.value || 'both'; } +const personaState = { options: [], default: 'family' }; + +function currentPersona() { + return els.personaSelect?.value || personaState.default || ''; +} + +async function loadPersonas() { + if (!els.personaSelect) return; + try { + const response = await fetch('api/personas.php', { + method: 'GET', + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + }); + const data = await response.json().catch(() => ({})); + if (!response.ok || !data || data.ok !== true || !Array.isArray(data.personas) || !data.personas.length) return; + personaState.options = data.personas; + personaState.default = data.default_persona || 'family'; + const saved = sessionStorage.getItem('dbnPersona'); + els.personaSelect.innerHTML = ''; + for (const p of data.personas) { + const opt = document.createElement('option'); + opt.value = p.slug; + opt.textContent = p.name || p.slug; + els.personaSelect.appendChild(opt); + } + const initial = (saved && data.personas.some((p) => p.slug === saved)) + ? saved + : (data.personas.some((p) => p.slug === personaState.default) ? personaState.default : data.personas[0].slug); + els.personaSelect.value = initial; + els.personaSelect.addEventListener('change', () => { + sessionStorage.setItem('dbnPersona', els.personaSelect.value); + }); + if (tools[state.activeTool]?.usesPersona) { + els.personaControl?.classList.remove('is-hidden'); + } + } catch (_) { + // Personas are optional UI sugar; ignore failures (e.g. pre-seed environments). + } +} + function currentRedactionMode() { return document.querySelector('input[name="redactionMode"]:checked')?.value || 'standard'; } diff --git a/includes/BvjAnalyzerAgent.php b/includes/BvjAnalyzerAgent.php index 9357f62..0b260f1 100644 --- a/includes/BvjAnalyzerAgent.php +++ b/includes/BvjAnalyzerAgent.php @@ -34,11 +34,25 @@ final class DbnBvjAnalyzerAgent private array $uploadVecs = []; private array $stepTimings = []; + /** Persona slug driving package selection + the legal-check system prompt; default = child-welfare. */ + private string $personaSlug = 'child-welfare'; + /** Resolved persona system prompt (set in run()); null ⇒ built-in barnevern prompt. */ + private ?string $resolvedPersonaPrompt = null; + public function __construct(DbnAzureOpenAiGateway|DbnBedrockGateway|null $azure = null) { $this->azure = $azure ?: DbnGatewayFactory::makeForTool('barnevernet-analyze'); } + public function withPersona(?string $slug): self + { + $slug = is_string($slug) ? trim($slug) : ''; + if ($slug !== '') { + $this->personaSlug = $slug; + } + return $this; + } + /** * Main pipeline. At least 1 uploaded file is required. * @@ -71,7 +85,12 @@ final class DbnBvjAnalyzerAgent } $client = dbnToolsRequireClient(); - $package = $this->requireFamilyPackage((int)$client['id']); + $persona = dbnToolsResolvePersona((int)$client['id'], $this->personaSlug); + $package = $persona['package'] ?? $this->requireFamilyPackage((int)$client['id']); + $packageIds = $persona['package_ids'] ?: [(int)$package['id']]; + $this->resolvedPersonaPrompt = is_string($persona['system_prompt'] ?? null) && trim($persona['system_prompt']) !== '' + ? $persona['system_prompt'] + : null; dbnToolsBootCaveau(); $aiPortalRoot = dbnToolsAiPortalRoot(); @@ -265,7 +284,7 @@ final class DbnBvjAnalyzerAgent [ 'search_private' => false, 'search_shared' => true, - 'package_ids' => [(int)$package['id']], + 'package_ids' => $packageIds, 'shared_doc_ids' => $sharedDocIds, 'chunk_limit' => $controls['chunk_limit'], 'search_method' => 'hybrid', @@ -964,7 +983,8 @@ PROMPT; if ($engine !== 'dbn_legal_v3') { $checkFindings = dbnToolsRunLegalCheck( (string)($json['advocacy_brief'] ?? ''), - $docType + $docType, + $this->resolvedPersonaPrompt ); if (!empty($checkFindings)) { if (!is_array($json['procedural_red_flags'] ?? null)) { diff --git a/includes/DbnMcpRuntime.php b/includes/DbnMcpRuntime.php index 9028f6e..18ea0dc 100644 --- a/includes/DbnMcpRuntime.php +++ b/includes/DbnMcpRuntime.php @@ -12,31 +12,37 @@ final class DbnMcpRuntime $lang = self::langSchema(); $text = ['type' => 'string', 'description' => 'Text to process.']; $useCase = ['type' => 'boolean', 'description' => 'Use private My Case context. Defaults to false.']; + $persona = ['type' => 'string', 'description' => 'Legal persona/profile slug that scopes the corpus and answer style: family, child-welfare, immigration, labour, consumer-tenancy, or general. Defaults to family. Call dbn.list_personas for the live list.']; return [ - self::tool('dbn.search_legal', 'Search DBN legal corpus', 'Search the DBN Norwegian family-law corpus.', [ + self::tool('dbn.search_legal', 'Search DBN legal corpus', 'Search the DBN Norwegian legal corpus, scoped by the chosen legal persona (family by default).', [ 'query' => ['type' => 'string', 'minLength' => 3], 'language' => $lang, 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 10], 'corpus_scope' => ['type' => 'string', 'enum' => ['shared', 'private', 'both']], + 'profile' => $persona, ], ['query']), - self::tool('dbn.corpus_search', 'Advanced corpus search', 'Search the DBN legal corpus with a chosen retrieval mode (hybrid, bm25, vector, azure) and optional category filter.', [ + self::tool('dbn.corpus_search', 'Advanced corpus search', 'Search the DBN legal corpus with a chosen retrieval mode (hybrid, bm25, vector, azure) and optional category filter. The persona (profile) scopes the hybrid mode.', [ 'query' => ['type' => 'string', 'minLength' => 3], 'language' => $lang, 'mode' => ['type' => 'string', 'enum' => ['hybrid', 'bm25', 'vector', 'azure']], 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 20], 'category' => ['type' => 'string'], + 'profile' => $persona, ], ['query']), - self::tool('dbn.ask', 'Ask a legal question', 'Answer a legal preparation question with source-grounded DBN context.', [ + self::tool('dbn.ask', 'Ask a legal question', 'Answer a legal preparation question with source-grounded DBN context, using the chosen legal persona (model + corpus + style).', [ 'question' => ['type' => 'string', 'minLength' => 5], 'language' => $lang, 'use_case_context' => $useCase, + 'profile' => $persona, ], ['question']), + self::tool('dbn.list_personas', 'List legal personas', 'List the available DBN legal personas (saved caveauAI agent profiles). Pass a returned slug as the `profile` argument to search/ask tools.', []), self::tool('dbn.summarize', 'Summarize document', 'Summarize pasted case text with optional legal-corpus enrichment.', [ 'text' => $text, 'language' => $lang, 'use_legal_corpus' => ['type' => 'boolean'], 'use_case_context' => $useCase, + 'profile' => $persona, ], ['text']), self::tool('dbn.timeline', 'Extract timeline', 'Extract dates, hearings, milestones, and deadlines from case text.', [ 'text' => $text, @@ -162,6 +168,7 @@ final class DbnMcpRuntime 'language' => self::language($args['language'] ?? 'en'), 'limit' => (int)($args['limit'] ?? $args['top_k'] ?? 8), 'corpus_scope' => self::corpusScope($args['corpus_scope'] ?? 'both'), + 'profile' => self::persona($args['profile'] ?? null), ]), 'dbn.corpus_search' => self::callJson('api/corpus-search.php', [ 'query' => (string)($args['query'] ?? ''), @@ -169,17 +176,21 @@ final class DbnMcpRuntime 'mode' => in_array($args['mode'] ?? 'hybrid', ['hybrid', 'bm25', 'vector', 'azure'], true) ? (string)$args['mode'] : 'hybrid', 'limit' => (int)($args['limit'] ?? 8), 'category' => (string)($args['category'] ?? ''), + 'profile' => self::persona($args['profile'] ?? null), ]), 'dbn.ask' => self::callJson('api/ask.php', [ 'question' => (string)($args['question'] ?? ''), 'language' => self::language($args['language'] ?? 'en'), 'use_my_case' => !empty($args['use_case_context']), + 'profile' => self::persona($args['profile'] ?? null), ]), + 'dbn.list_personas' => self::callGet('api/personas.php', []), 'dbn.summarize' => self::callJson('api/summarize.php', [ 'text' => (string)($args['text'] ?? ''), 'language' => self::language($args['language'] ?? 'en'), 'slices' => !empty($args['use_legal_corpus']) ? ['family-legal'] : [], 'use_my_case' => !empty($args['use_case_context']), + 'profile' => self::persona($args['profile'] ?? null), ]), 'dbn.timeline' => self::callJson('api/timeline.php', [ 'text' => (string)($args['text'] ?? ''), @@ -682,6 +693,13 @@ final class DbnMcpRuntime return in_array($value, ['shared', 'private', 'both'], true) ? (string)$value : 'both'; } + /** Normalize a persona/profile slug ('' when absent → endpoint applies the default). */ + private static function persona(mixed $value): string + { + $slug = is_string($value) ? trim($value) : ''; + return preg_match('/^[a-z0-9-]{1,64}$/', $slug) === 1 ? $slug : ''; + } + private static function docType(mixed $value): string { $value = (string)$value; diff --git a/includes/LegalAnalysisAgent.php b/includes/LegalAnalysisAgent.php index bfff63a..76f909e 100644 --- a/includes/LegalAnalysisAgent.php +++ b/includes/LegalAnalysisAgent.php @@ -26,6 +26,16 @@ final class DbnLegalAnalysisAgent private DbnAzureOpenAiGateway|DbnBedrockGateway $azureMini; private DbnLegalToolsService $legalSvc; + /** Persona system prompt (from the resolved chat profile); null ⇒ use the built-in barnevern prompt. */ + private ?string $personaPrompt = null; + + public function withPersonaPrompt(?string $prompt): self + { + $prompt = is_string($prompt) ? trim($prompt) : ''; + $this->personaPrompt = $prompt !== '' ? $prompt : null; + return $this; + } + public function __construct() { // On Azure: gpt-4o-mini for extraction/synthesis. On Bedrock: factory picks Haiku/Sonnet. @@ -362,16 +372,19 @@ PROMPT; { $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. '; + // Base persona prompt comes from the resolved chat profile (default = + // Child-welfare/barnevern). 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 = ($this->personaPrompt + ?? '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.'; diff --git a/includes/LegalTools.php b/includes/LegalTools.php index 825f88a..b8deb40 100644 --- a/includes/LegalTools.php +++ b/includes/LegalTools.php @@ -23,7 +23,8 @@ final class DbnLegalToolsService int $limit = 6, string $temporalMode = 'disabled', ?string $asOfDate = null, - string $scope = 'both' + string $scope = 'both', + ?string $persona = null ): array { $query = trim($query); if (mb_strlen($query, 'UTF-8') < 3) { @@ -44,7 +45,11 @@ final class DbnLegalToolsService ]; $client = dbnToolsRequireClient(); - $package = $this->requireFamilyPackage((int)$client['id']); + $personaResolved = dbnToolsResolvePersona((int)$client['id'], $persona); + $package = $personaResolved['package'] ?? $this->requireFamilyPackage((int)$client['id']); + $packageIds = $personaResolved['package_ids'] ?: [(int)$package['id']]; + $personaRagOpts = is_array($personaResolved['rag_opts'] ?? null) ? $personaResolved['rag_opts'] : []; + $searchMethod = (string)($personaResolved['search_method'] ?? 'keyword') ?: 'keyword'; // Personal corpus client_id from session (may be 0 if user has no linked workspace) $personalClientId = (int)($_SESSION['dbn_tools_client_id'] ?? 0); @@ -68,50 +73,50 @@ final class DbnLegalToolsService // Search only the user's personal corpus if ($personalClientId > 0) { $rag = new ClientRagPipeline($personalClientId, $gatewayUrl, 30); - $chunks = $rag->searchAll($query, $limit, null, [ + $chunks = $rag->searchAll($query, $limit, null, array_merge($personaRagOpts, [ 'search_private' => true, 'search_shared' => false, 'chunk_limit' => $limit, - 'search_method' => 'keyword', + 'search_method' => $searchMethod, 'min_private' => 0, - ]); + ])); } } elseif ($scope === 'shared') { - // Search only the shared legal library + // Search only the shared legal library (persona-scoped packages) $rag = new ClientRagPipeline((int)$client['id'], $gatewayUrl, 30); - $chunks = $rag->searchAll($query, $limit, null, [ + $chunks = $rag->searchAll($query, $limit, null, array_merge($personaRagOpts, [ 'search_private' => true, 'search_shared' => true, - 'package_ids' => [(int)$package['id']], + 'package_ids' => $packageIds, 'chunk_limit' => $limit, - 'search_method' => 'keyword', + 'search_method' => $searchMethod, 'min_private' => 0, 'include_beta_website' => true, - ]); + ])); } else { // 'both': shared library + personal corpus merged and re-ranked by score $rag = new ClientRagPipeline((int)$client['id'], $gatewayUrl, 30); - $sharedChunks = $rag->searchAll($query, $limit, null, [ + $sharedChunks = $rag->searchAll($query, $limit, null, array_merge($personaRagOpts, [ 'search_private' => true, 'search_shared' => true, - 'package_ids' => [(int)$package['id']], + 'package_ids' => $packageIds, 'chunk_limit' => $limit, - 'search_method' => 'keyword', + 'search_method' => $searchMethod, 'min_private' => 0, 'include_beta_website' => true, - ]); + ])); $privateChunks = []; if ($personalClientId > 0) { try { $ragPrivate = new ClientRagPipeline($personalClientId, $gatewayUrl, 30); - $privateChunks = $ragPrivate->searchAll($query, $limit, null, [ + $privateChunks = $ragPrivate->searchAll($query, $limit, null, array_merge($personaRagOpts, [ 'search_private' => true, 'search_shared' => false, 'chunk_limit' => $limit, - 'search_method' => 'keyword', + 'search_method' => $searchMethod, 'min_private' => 0, - ]); + ])); } catch (Throwable $e) { error_log('[search] personal corpus query failed for client ' . $personalClientId . ': ' . $e->getMessage()); } @@ -183,15 +188,19 @@ final class DbnLegalToolsService 'source_count' => count($hits), 'deployment' => null, 'citation_confidence' => $confidence, + 'persona' => $personaResolved['slug'] ?? null, + 'persona_source' => $personaResolved['source'] ?? null, ], 'disclaimer' => dbnToolsDisclaimer($language), ]; } - public function ask(string $question, string $language = 'en', string $engine = 'azure_mini'): array + public function ask(string $question, string $language = 'en', string $engine = 'azure_mini', ?string $persona = null): array { $engine = in_array($engine, ['azure_mini', 'azure_full'], true) ? $engine : 'azure_mini'; - $search = $this->search($question, $language, 7); + $client = dbnToolsRequireClient(); + $personaResolved = dbnToolsResolvePersona((int)$client['id'], $persona); + $search = $this->search($question, $language, 7, 'disabled', null, 'both', $personaResolved['slug']); $hits = $search['hits']; $trace = $search['trace']; @@ -221,7 +230,8 @@ final class DbnLegalToolsService ]; } - $this->azure->requireChat(); + [$gateway, $personaModel] = $this->personaGateway($personaResolved, $engine); + $gateway->requireChat(); $context = $this->buildEvidenceContext($hits); $locale = dbnToolsLanguageName($language); @@ -242,9 +252,14 @@ Return JSON only with these keys: } PROMPT; + // Persona voice/domain prepended to the JSON-enforcing scaffold (keeps the + // structured-output contract while applying the persona's legal framing). $system = $this->legalJsonSystemPrompt($language); - $askDeployment = ($engine === 'azure_full') ? 'gpt-4o' : 'gpt-4o-mini'; - $raw = $this->azure->withDeployment($askDeployment)->chatText([ + if (!empty($personaResolved['system_prompt'])) { + $system = $personaResolved['system_prompt'] . "\n\n" . $system; + } + $askDeployment = $personaModel; + $raw = $gateway->withDeployment($askDeployment)->chatText([ ['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => $prompt], ], [ @@ -253,7 +268,7 @@ PROMPT; 'max_tokens' => 1300, ]); - $json = $this->azure->decodeJsonObject($raw); + $json = $gateway->decodeJsonObject($raw); if (!$json) { $json = [ 'answer' => $raw, @@ -1156,6 +1171,26 @@ PROMPT; return $package; } + /** + * Pick the synthesis gateway + model for a persona. + * - Persona pins a model (e.g. dbn-legal-agent-v3, gpt-4o) → route via LiteLLM + * so any model registered on the gateway is reachable. + * - No pinned model → existing Azure routing (gpt-4o / gpt-4o-mini by engine). + * @return array{0: DbnAzureOpenAiGateway|DbnBedrockGateway, 1: string} + */ + private function personaGateway(array $persona, string $engine): array + { + $model = trim((string)($persona['model'] ?? '')); + if ($model !== '') { + try { + return [new DbnBedrockGateway(['chat_model_name' => $model]), $model]; + } catch (Throwable $e) { + error_log('[dbn-persona] gateway init failed for model ' . $model . ': ' . $e->getMessage()); + } + } + return [$this->azure, ($engine === 'azure_full') ? 'gpt-4o' : 'gpt-4o-mini']; + } + private function runJsonTool(string $prompt, string $language, int $maxTokens): array { $raw = $this->azure->chatText([ @@ -1726,11 +1761,15 @@ PROMPT; * Search the shared legal corpus and return top-N passages as a formatted * context string. Returns '' on failure so the caller can degrade gracefully. */ - public function corpusContextForSummarize(string $query, int $limit = 8): string + public function corpusContextForSummarize(string $query, int $limit = 8, ?string $persona = null): string { try { - $client = dbnToolsRequireClient(); - $package = $this->requireFamilyPackage((int)$client['id']); + $client = dbnToolsRequireClient(); + $personaResolved = dbnToolsResolvePersona((int)$client['id'], $persona); + $package = $personaResolved['package'] ?? $this->requireFamilyPackage((int)$client['id']); + $packageIds = $personaResolved['package_ids'] ?: [(int)$package['id']]; + $searchMethod = (string)($personaResolved['search_method'] ?? 'keyword') ?: 'keyword'; + $personaRagOpts = is_array($personaResolved['rag_opts'] ?? null) ? $personaResolved['rag_opts'] : []; dbnToolsBootCaveau(); $gatewayUrl = 'http://10.0.1.10:4000'; try { @@ -1739,15 +1778,15 @@ PROMPT; if ($u !== '') $gatewayUrl = $u; } catch (Throwable) {} $rag = new ClientRagPipeline((int)$client['id'], $gatewayUrl, 20); - $chunks = $rag->searchAll($query, $limit, null, [ + $chunks = $rag->searchAll($query, $limit, null, array_merge($personaRagOpts, [ 'search_private' => true, 'search_shared' => true, - 'package_ids' => [(int)$package['id']], + 'package_ids' => $packageIds, 'chunk_limit' => $limit, - 'search_method' => 'keyword', + 'search_method' => $searchMethod, 'min_private' => 0, 'include_beta_website' => true, - ]); + ])); $parts = []; foreach ($chunks as $c) { $title = (string)($c['title'] ?? ($c['source'] ?? 'Legal source')); diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 2f7c12d..f011b3e 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -448,6 +448,144 @@ function dbnToolsBootCaveau(): void $booted = true; } +/** + * Load caveauAI's chat-profile resolver (the persona bridge). DBN reads client 57's + * saved chat profiles in-process to drive tools by persona instead of hardcoding + * family law. Returns false when the platform file or resolver functions are absent + * (callers then fall back to the family-legal package). + */ +function dbnToolsBootChatProfiles(): bool +{ + static $loaded = null; + if ($loaded !== null) { + return $loaded; + } + dbnToolsBootCaveau(); // ensures db.php + platform autoload context + $root = dbnToolsAiPortalRoot(); + $cpFile = $root . DIRECTORY_SEPARATOR . 'platform' . DIRECTORY_SEPARATOR . 'includes' . DIRECTORY_SEPARATOR . 'chat_profiles.php'; + if (!is_file($cpFile)) { + $loaded = false; + return false; + } + require_once $cpFile; + $loaded = function_exists('cpFetchProfileForUser') && function_exists('cpResolveRuntime'); + return $loaded; +} + +/** Default persona slug (back-compat: family). Override via .env DBN_DEFAULT_PERSONA. */ +function dbnToolsDefaultPersonaSlug(): string +{ + $v = trim((string)(dbnToolsEnv('DBN_DEFAULT_PERSONA', 'family') ?? 'family')); + return $v !== '' ? $v : 'family'; +} + +/** + * Resolve a DBN persona (a caveauAI chat profile for client 57) into a normalized + * runtime bundle the tools can act on: + * ['slug','name','package_ids','package','system_prompt','model','search_method', + * 'chunk_limit','rag_opts','source'] + * + * - source='chat_profile' when a seeded persona was found and resolved. + * - source='fallback' when no persona exists yet (pre-seed): the legacy family-legal + * package is used so behaviour is unchanged. + * model is null when the persona doesn't pin one (caller keeps its default routing). + */ +function dbnToolsResolvePersona(int $clientId, ?string $slug = null): array +{ + $slug = trim((string)($slug ?? '')) ?: dbnToolsDefaultPersonaSlug(); + $resolved = null; + + if (dbnToolsBootChatProfiles()) { + try { + $db = dbnToolsDb(); + $user = ['client_id' => $clientId]; + $row = cpFetchProfileForUser($db, $user, $slug); + if ($row) { + $runtime = cpResolveRuntime($db, $row, []); + $packageIds = array_values(array_filter( + array_map('intval', $runtime['package_ids'] ?? []), + static fn(int $id): bool => $id > 0 + )); + + $package = null; + if ($packageIds) { + $pkgStmt = $db->prepare('SELECT * FROM corpus_packages WHERE id = ? LIMIT 1'); + $pkgStmt->execute([$packageIds[0]]); + $package = $pkgStmt->fetch(PDO::FETCH_ASSOC) ?: null; + } + + $chunkLimit = (int)($runtime['chunk_limit'] ?? 8); + $ragOpts = (is_array($runtime['retrieval_tuning'] ?? null) && function_exists('cpRetrievalTuningToRagOptions')) + ? cpRetrievalTuningToRagOptions($runtime['retrieval_tuning'], $chunkLimit) + : []; + + $resolved = [ + 'slug' => $slug, + 'name' => (string)$row['name'], + 'package_ids' => $packageIds, + 'package' => $package, + 'system_prompt' => ($runtime['system_prompt'] ?? '') !== '' ? (string)$runtime['system_prompt'] : null, + 'model' => trim((string)($runtime['model'] ?? '')) ?: null, + 'search_method' => (string)($runtime['search_method'] ?? 'keyword'), + 'chunk_limit' => $chunkLimit, + 'rag_opts' => $ragOpts, + 'source' => 'chat_profile', + ]; + } + } catch (Throwable $e) { + error_log('[dbn-persona] resolve failed for slug ' . $slug . ': ' . $e->getMessage()); + } + } + + if ($resolved === null) { + // Pre-seed / missing persona → legacy family-legal package (unchanged behaviour). + $package = dbnToolsFetchPackage('family-legal'); + if (!$package || empty($package['is_active'])) { + dbnToolsAbort('No persona profile and the family-legal corpus package is not active.', 503, 'package_unavailable'); + } + if (!dbnToolsHasActiveSubscription($clientId, (int)$package['id'])) { + dbnToolsAbort('Do Better Norge does not have an active corpus subscription.', 503, 'subscription_missing'); + } + $resolved = [ + 'slug' => 'family', + 'name' => 'Family Law', + 'package_ids' => [(int)$package['id']], + 'package' => $package, + 'system_prompt' => null, + 'model' => null, + 'search_method' => 'keyword', + 'chunk_limit' => 8, + 'rag_opts' => [], + 'source' => 'fallback', + ]; + } + + return $resolved; +} + +/** List enabled DBN personas (client 57 chat profiles) for the persona picker / dbn.list_personas. */ +function dbnToolsListPersonas(int $clientId): array +{ + if (!dbnToolsBootChatProfiles()) { + return []; + } + try { + $db = dbnToolsDb(); + $rows = cpFetchProfilesForUser($db, ['client_id' => $clientId], false); + } catch (Throwable $e) { + error_log('[dbn-persona] list failed: ' . $e->getMessage()); + return []; + } + return array_map(static function (array $r): array { + return [ + 'slug' => (string)$r['slug'], + 'name' => (string)$r['name'], + 'description' => (string)($r['description'] ?? ''), + 'model' => (string)($r['model'] ?? ''), + ]; + }, $rows); +} + /** * Resolve (or lazily provision) the dashboard tenant for the current session. * @@ -1166,7 +1304,7 @@ function dbnToolsLiteLLMEmbedBatch(array $texts, string $model = 'nomic-embed-te // Strategy: ask ONE targeted question per document type, cap tokens at 350 to cut // off before the tool-planning loop kicks in, parse the answer for legal findings. -function dbnToolsRunLegalCheck(string $brief, string $docType): array +function dbnToolsRunLegalCheck(string $brief, string $docType, ?string $personaPrompt = null): array { $question = dbnToolsSelectCheckQuestion($docType, $brief); if ($question === null) { @@ -1181,7 +1319,11 @@ function dbnToolsRunLegalCheck(string $brief, string $docType): array // No 'json' key — plain narrative, no response_format flag ]; - $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.'; + // Base prompt from the resolved persona (default = Child-welfare/barnevern). + $personaPrompt = is_string($personaPrompt) ? trim($personaPrompt) : ''; + $sysMsg = $personaPrompt !== '' + ? $personaPrompt + : '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.'; // Prepend a short snippet of the actual synthesis text so v3 answers in context, // not just as a general law quiz. Strip HTML tags and cap at 350 chars. diff --git a/includes/tool_form.php b/includes/tool_form.php index a75a860..2a7ab1f 100644 --- a/includes/tool_form.php +++ b/includes/tool_form.php @@ -19,6 +19,13 @@ + +