feat(tools): persona-driven multi-domain corpus + model routing
Generalize the family-locked legal tools into caveauAI persona profiles (client 57 chat profiles, resolved in-process via the chat_profiles bridge). Each tool accepts an optional `profile` slug that scopes the corpus package(s), search method, system prompt and synthesis model; omitting it falls back to the family-legal package so existing behaviour is unchanged. - dbnToolsResolvePersona / dbnToolsListPersonas / dbnToolsBootChatProfiles in bootstrap.php; new api/personas.php + dbn.list_personas MCP tool. - LegalTools search/ask/corpusContextForSummarize and the BvjAnalyzer / LegalAnalysis / translate paths take the persona's packages + prompt + model. - Persona <select> on ask/search/summarize (populated from api/personas.php). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -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);
|
||||
});
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
@@ -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'] ?? '',
|
||||
|
||||
+13
-3
@@ -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) {}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../includes/LegalTools.php';
|
||||
|
||||
dbnToolsRequireMethod('GET');
|
||||
dbnToolsRequireAuth();
|
||||
|
||||
$client = dbnToolsRequireClient();
|
||||
$personas = dbnToolsListPersonas((int)$client['id']);
|
||||
|
||||
dbnToolsRespond([
|
||||
'ok' => true,
|
||||
'personas' => $personas,
|
||||
'default_persona' => dbnToolsDefaultPersonaSlug(),
|
||||
]);
|
||||
+4
-1
@@ -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);
|
||||
});
|
||||
|
||||
+4
-1
@@ -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 !== ''
|
||||
|
||||
+12
-2
@@ -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 = <<<PROMPT
|
||||
You are a professional legal translator specialising in Norwegian family law, ECHR, and child-welfare proceedings.
|
||||
You are a professional legal translator specialising in {$domainLabel}.
|
||||
|
||||
Task: Translate the provided text from {$sourceName} into {$targetName}.
|
||||
|
||||
|
||||
@@ -894,6 +894,7 @@ const tools = {
|
||||
payloadKey: 'question',
|
||||
placeholder: 'Example: What evidence is needed before asking for changes in custody arrangements?',
|
||||
usesLanguage: true,
|
||||
usesPersona: true,
|
||||
badge: 'family-legal',
|
||||
},
|
||||
search: {
|
||||
@@ -904,6 +905,7 @@ const tools = {
|
||||
payloadKey: 'query',
|
||||
placeholder: 'Example: barnets beste samvær foreldreansvar',
|
||||
usesLanguage: true,
|
||||
usesPersona: true,
|
||||
badge: 'family-legal',
|
||||
},
|
||||
summarize: {
|
||||
@@ -914,6 +916,7 @@ const tools = {
|
||||
payloadKey: 'text',
|
||||
placeholder: 'Paste a case note, letter, or excerpt.',
|
||||
usesLanguage: true,
|
||||
usesPersona: true,
|
||||
badge: 'process-and-forget',
|
||||
},
|
||||
timeline: {
|
||||
@@ -980,6 +983,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,15 +372,18 @@ 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. '
|
||||
// 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. '
|
||||
. 'Aldri oppfinn paragrafnumre, saksnumre eller dommernavn.')
|
||||
. ' Avslutt med en «Kilder:»-seksjon som lister lovparagrafer og dommer du har sitert. ';
|
||||
|
||||
if ($language === 'no') {
|
||||
|
||||
+68
-29
@@ -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']);
|
||||
$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'));
|
||||
|
||||
+144
-2
@@ -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.
|
||||
|
||||
@@ -19,6 +19,13 @@
|
||||
<input type="number" id="numSpeakersInput" name="num_speakers" min="2" max="10" placeholder="auto" class="num-speakers-input" aria-label="Expected speaker count">
|
||||
</div>
|
||||
|
||||
<div class="control-row is-hidden" id="personaControl">
|
||||
<span class="control-label">Domain</span>
|
||||
<label class="persona-select-label" for="personaSelect">
|
||||
<select id="personaSelect" name="profile" aria-label="Legal domain persona"></select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="control-row is-hidden" id="corpusScopeControl">
|
||||
<span class="control-label">Search</span>
|
||||
<label><input type="radio" name="corpusScope" value="both" checked> Legal Library + My Docs</label>
|
||||
|
||||
Reference in New Issue
Block a user