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:
2026-06-01 20:49:58 +02:00
parent 5a0ef89dca
commit 662fbf7d6d
16 changed files with 404 additions and 58 deletions
+144 -2
View File
@@ -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.