feat(tools): persona selector across standalone tools + dashboard chat

Wire the legal-domain persona picker into corpus, deep-research, korrespond and
the dashboard chat. Each endpoint reads the chosen profile, resolves its packages
against client 57, and scopes retrieval via package_ids (falling back to family
when omitted). New dashboard tenants now subscribe to all DBN domain packages so
persona switching survives the subscription intersection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 23:03:31 +02:00
parent 662fbf7d6d
commit d156f8cf6b
13 changed files with 251 additions and 20 deletions
+24
View File
@@ -48,6 +48,26 @@ $history = array_values(array_filter($history, fn($m) => is_array($m)
$category = trim((string)($input['category'] ?? '')) ?: null; $category = trim((string)($input['category'] ?? '')) ?: null;
$language = in_array($input['language'] ?? 'no', ['no', 'en'], true) ? $input['language'] : 'no'; $language = in_array($input['language'] ?? 'no', ['no', 'en'], true) ? $input['language'] : 'no';
// Persona (legal-domain) scope: resolve the chosen persona's packages against the
// DBN client (57, the package owner). The dashboard tenant is subscribed to the
// DBN package set (provisioning + migration 177), so these package_ids survive the
// subscription intersection in ClientRagPipeline and scope shared-law retrieval.
$personaSlug = (isset($input['profile']) && is_string($input['profile']) && trim($input['profile']) !== '')
? trim($input['profile']) : null;
$personaPackageIds = [];
if ($personaSlug !== null) {
try {
$dbnClient = dbnToolsFetchClient();
if ($dbnClient) {
$persona = dbnToolsResolvePersona((int)$dbnClient['id'], $personaSlug);
$personaPackageIds = array_values(array_filter(
array_map('intval', $persona['package_ids'] ?? []),
static fn(int $id): bool => $id > 0
));
}
} catch (Throwable $e) { /* tolerated — fall back to default package scope */ }
}
// Folder scope: limit retrieval to a folder subtree, ACL-checked. // Folder scope: limit retrieval to a folder subtree, ACL-checked.
$folderScopeRaw = $input['folder_id'] ?? null; $folderScopeRaw = $input['folder_id'] ?? null;
$folderScope = null; $folderScope = null;
@@ -84,6 +104,10 @@ try {
'user_role' => 'owner', 'user_role' => 'owner',
]; ];
if ($personaPackageIds) {
$options['package_ids'] = $personaPackageIds;
}
// Apply folder scoping via allowed_folder_ids (supported by ClientRagPipeline). // Apply folder scoping via allowed_folder_ids (supported by ClientRagPipeline).
if ($folderScope !== null) { if ($folderScope !== null) {
if ($folderScope === 0) { if ($folderScope === 0) {
+5 -1
View File
@@ -70,6 +70,9 @@ try {
$priorContext = is_array($input['prior_context'] ?? null) ? $input['prior_context'] : null; $priorContext = is_array($input['prior_context'] ?? null) ? $input['prior_context'] : null;
$branchNotes = mb_substr(trim((string)($input['branch_notes'] ?? '')), 0, 1000, 'UTF-8'); $branchNotes = mb_substr(trim((string)($input['branch_notes'] ?? '')), 0, 1000, 'UTF-8');
$subQsOverride = is_array($input['sub_questions_override'] ?? null) ? $input['sub_questions_override'] : []; $subQsOverride = is_array($input['sub_questions_override'] ?? null) ? $input['sub_questions_override'] : [];
$personaSlug = (isset($input['profile']) && is_string($input['profile']) && trim($input['profile']) !== '')
? trim($input['profile'])
: null;
if (mb_strlen($seedQuery, 'UTF-8') > 4000) { if (mb_strlen($seedQuery, 'UTF-8') > 4000) {
throw new DbnToolsHttpException('Query is too long.', 422, 'query_too_long'); throw new DbnToolsHttpException('Query is too long.', 422, 'query_too_long');
@@ -139,7 +142,8 @@ try {
$advocateRole, $advocateRole,
$priorContext, $priorContext,
$branchNotes, $branchNotes,
$subQsOverride $subQsOverride,
$personaSlug
); );
$result['ok'] = true; $result['ok'] = true;
+5 -1
View File
@@ -174,8 +174,12 @@ try {
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond'); $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond');
$creditDeducted = true; $creditDeducted = true;
$personaSlug = (isset($input['profile']) && is_string($input['profile']) && trim($input['profile']) !== '')
? trim($input['profile'])
: null;
// ── Pass 2: retrieve law → draft → self-check → translate ────────────────── // ── Pass 2: retrieve law → draft → self-check → translate ──────────────────
$result = $agent->generate($intake, $classify, $emit, $engine); $result = $agent->generate($intake, $classify, $emit, $engine, $personaSlug);
$result['ok'] = true; $result['ok'] = true;
$result['latency_ms'] = (int)round((microtime(true) - $startTime) * 1000); $result['latency_ms'] = (int)round((microtime(true) - $startTime) * 1000);
if ($ftRemaining >= 0) { if ($ftRemaining >= 0) {
+12
View File
@@ -3726,6 +3726,18 @@ a.passage-card__title:hover { color: var(--ink); }
} }
.drill-sort-select:focus { border-color: var(--teal); } .drill-sort-select:focus { border-color: var(--teal); }
.corpus-persona {
display: inline-flex;
align-items: center;
gap: 6px;
}
.corpus-persona__label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.drill-loading, .drill-loading,
.drill-empty, .drill-empty,
.drill-error { .drill-error {
+32
View File
@@ -7,6 +7,7 @@
let uploadFiles = []; let uploadFiles = [];
let lastResult = null; let lastResult = null;
let branchContext = null; let branchContext = null;
let persona = '';
const SLICE_DEFS = [ const SLICE_DEFS = [
{ id: 'family_core', label: 'Family Law Core' }, { id: 'family_core', label: 'Family Law Core' },
@@ -42,6 +43,8 @@
slices: Array.from(document.querySelectorAll('.dr-slice')), slices: Array.from(document.querySelectorAll('.dr-slice')),
langButtons: Array.from(document.querySelectorAll('#drLangSwitcher .lang-btn')), langButtons: Array.from(document.querySelectorAll('#drLangSwitcher .lang-btn')),
engineRadios: Array.from(document.querySelectorAll('input[name="drEngine"]')), engineRadios: Array.from(document.querySelectorAll('input[name="drEngine"]')),
personaControl: document.getElementById('drPersonaControl'),
personaSelect: document.getElementById('drPersonaSelect'),
subQ: document.getElementById('drSubQ'), subQ: document.getElementById('drSubQ'),
subQVal: document.getElementById('drSubQValue'), subQVal: document.getElementById('drSubQValue'),
chunkLimit: document.getElementById('drChunkLimit'), chunkLimit: document.getElementById('drChunkLimit'),
@@ -79,6 +82,7 @@
bindUpload(); bindUpload();
bindModal(); bindModal();
bindBranch(); bindBranch();
loadPersonas();
els.form.addEventListener('submit', onSubmit); els.form.addEventListener('submit', onSubmit);
els.results.addEventListener('click', (e) => { els.results.addEventListener('click', (e) => {
const btn = e.target.closest('.dr-branch-btn'); const btn = e.target.closest('.dr-branch-btn');
@@ -325,6 +329,7 @@
language: lang, language: lang,
controls: getControls(), controls: getControls(),
}; };
if (persona) payload.profile = persona;
const _drDocIds = (document.getElementById('docPickerIds')?.value || '').split(',').map(Number).filter(Boolean); const _drDocIds = (document.getElementById('docPickerIds')?.value || '').split(',').map(Number).filter(Boolean);
if (_drDocIds.length) payload.doc_ids = _drDocIds; if (_drDocIds.length) payload.doc_ids = _drDocIds;
if (branchContext) { if (branchContext) {
@@ -644,6 +649,33 @@
</div>`; </div>`;
} }
async function loadPersonas() {
if (!els.personaSelect) return;
try {
const r = await fetch('api/personas.php', { credentials: 'same-origin', headers: { Accept: 'application/json' } });
const data = await r.json().catch(() => ({}));
if (!r.ok || data.ok !== true || !Array.isArray(data.personas) || !data.personas.length) return;
const fallback = data.default_persona || 'family';
els.personaSelect.innerHTML = '';
data.personas.forEach((p) => {
const opt = document.createElement('option');
opt.value = p.slug;
opt.textContent = p.name || p.slug;
els.personaSelect.appendChild(opt);
});
const saved = sessionStorage.getItem('dbnPersona');
const initial = (saved && data.personas.some((p) => p.slug === saved)) ? saved
: (data.personas.some((p) => p.slug === fallback) ? fallback : data.personas[0].slug);
persona = initial;
els.personaSelect.value = initial;
els.personaSelect.addEventListener('change', () => {
persona = els.personaSelect.value;
sessionStorage.setItem('dbnPersona', persona);
});
els.personaControl?.classList.remove('is-hidden');
} catch (_) { /* personas are optional UI sugar; ignore failures */ }
}
function bindBranch() { function bindBranch() {
if (!els.branchClear) return; if (!els.branchClear) return;
els.branchClear.addEventListener('click', clearBranch); els.branchClear.addEventListener('click', clearBranch);
+32
View File
@@ -12,6 +12,7 @@
let lastClassify = null; let lastClassify = null;
let lastFinal = null; let lastFinal = null;
let pendingClarifications = {}; let pendingClarifications = {};
let persona = '';
const LANG_LABELS = { en: 'English', no: 'Norsk', uk: 'Українська', pl: 'Polski' }; const LANG_LABELS = { en: 'English', no: 'Norsk', uk: 'Українська', pl: 'Polski' };
@@ -116,6 +117,8 @@
clarifyList: document.getElementById('korrClarifyList'), clarifyList: document.getElementById('korrClarifyList'),
clarifyContinue:document.getElementById('korrClarifyContinue'), clarifyContinue:document.getElementById('korrClarifyContinue'),
clarifyForce: document.getElementById('korrClarifyForce'), clarifyForce: document.getElementById('korrClarifyForce'),
personaControl: document.getElementById('korrPersonaControl'),
personaSelect: document.getElementById('korrPersonaSelect'),
}); });
if (!els.form) return; if (!els.form) return;
@@ -124,6 +127,7 @@
bindGoalChips(); bindGoalChips();
bindUpload(); bindUpload();
bindClarify(); bindClarify();
loadPersonas();
applyStaticI18n(); applyStaticI18n();
els.form.addEventListener('submit', (e) => { e.preventDefault(); runRequest(false); }); els.form.addEventListener('submit', (e) => { e.preventDefault(); runRequest(false); });
}); });
@@ -279,9 +283,37 @@
engine: (document.querySelector('[name="korrEngine"]:checked')?.value ?? 'azure_mini'), engine: (document.querySelector('[name="korrEngine"]:checked')?.value ?? 'azure_mini'),
}; };
if (korrDocIds.length) payload.doc_ids = korrDocIds; if (korrDocIds.length) payload.doc_ids = korrDocIds;
if (persona) payload.profile = persona;
return payload; return payload;
} }
async function loadPersonas() {
if (!els.personaSelect) return;
try {
const r = await fetch('api/personas.php', { credentials: 'same-origin', headers: { Accept: 'application/json' } });
const data = await r.json().catch(() => ({}));
if (!r.ok || data.ok !== true || !Array.isArray(data.personas) || !data.personas.length) return;
const fallback = data.default_persona || 'family';
els.personaSelect.innerHTML = '';
data.personas.forEach((p) => {
const opt = document.createElement('option');
opt.value = p.slug;
opt.textContent = p.name || p.slug;
els.personaSelect.appendChild(opt);
});
const saved = sessionStorage.getItem('dbnPersona');
const initial = (saved && data.personas.some((p) => p.slug === saved)) ? saved
: (data.personas.some((p) => p.slug === fallback) ? fallback : data.personas[0].slug);
persona = initial;
els.personaSelect.value = initial;
els.personaSelect.addEventListener('change', () => {
persona = els.personaSelect.value;
sessionStorage.setItem('dbnPersona', persona);
});
els.personaControl?.classList.remove('is-hidden');
} catch (_) { /* personas are optional UI sugar; ignore failures */ }
}
async function runRequest(forceDraft) { async function runRequest(forceDraft) {
const payload = buildPayload(forceDraft); const payload = buildPayload(forceDraft);
if (!payload.recipient_body) { if (!payload.recipient_body) {
+45 -1
View File
@@ -77,6 +77,10 @@ require_once __DIR__ . '/includes/layout.php';
<button class="mode-pill" data-lang="uk" type="button">UK</button> <button class="mode-pill" data-lang="uk" type="button">UK</button>
<button class="mode-pill" data-lang="pl" type="button">PL</button> <button class="mode-pill" data-lang="pl" type="button">PL</button>
</div> </div>
<label class="corpus-persona is-hidden" id="corpusPersonaControl" for="corpusPersonaSelect" title="Legal domain — scopes Hybrid search">
<span class="corpus-persona__label">Domain</span>
<select id="corpusPersonaSelect" class="drill-sort-select" aria-label="Legal domain persona"></select>
</label>
</div> </div>
<div class="search-cats" role="group" aria-label="Category filter" id="searchCatPills"> <div class="search-cats" role="group" aria-label="Category filter" id="searchCatPills">
<button class="mode-pill is-active" data-cat="" type="button">All</button> <button class="mode-pill is-active" data-cat="" type="button">All</button>
@@ -743,6 +747,7 @@ require_once __DIR__ . '/includes/layout.php';
let searchMode = 'hybrid'; let searchMode = 'hybrid';
let searchLang = 'en'; let searchLang = 'en';
let searchCat = ''; let searchCat = '';
let searchPersona = '';
document.querySelectorAll('.search-modes .mode-pill').forEach(btn => { document.querySelectorAll('.search-modes .mode-pill').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@@ -775,6 +780,40 @@ require_once __DIR__ . '/includes/layout.php';
const searchBtn = document.getElementById('corpusSearchBtn'); const searchBtn = document.getElementById('corpusSearchBtn');
const searchResults = document.getElementById('corpusSearchResults'); const searchResults = document.getElementById('corpusSearchResults');
// ── Persona (legal domain) selector — scopes Hybrid search ──────────────────
const personaControl = document.getElementById('corpusPersonaControl');
const personaSelect = document.getElementById('corpusPersonaSelect');
async function loadPersonas() {
if (!personaSelect) return;
try {
const r = await fetch('/api/personas.php', { credentials: 'same-origin', headers: { Accept: 'application/json' } });
const data = await r.json().catch(() => ({}));
if (!r.ok || data.ok !== true || !Array.isArray(data.personas) || !data.personas.length) return;
const fallback = data.default_persona || 'family';
personaSelect.innerHTML = '';
for (const p of data.personas) {
const opt = document.createElement('option');
opt.value = p.slug;
opt.textContent = p.name || p.slug;
personaSelect.appendChild(opt);
}
const hashPersona = new URLSearchParams(location.hash.slice(1)).get('persona');
const saved = hashPersona || sessionStorage.getItem('dbnPersona');
const initial = (saved && data.personas.some(p => p.slug === saved)) ? saved
: (data.personas.some(p => p.slug === fallback) ? fallback : data.personas[0].slug);
searchPersona = initial;
personaSelect.value = initial;
personaSelect.addEventListener('change', () => {
searchPersona = personaSelect.value;
sessionStorage.setItem('dbnPersona', searchPersona);
pushHash();
});
personaControl?.classList.remove('is-hidden');
} catch (_) { /* personas are optional UI sugar; ignore failures */ }
}
loadPersonas();
function runSearch() { function runSearch() {
const q = searchInput.value.trim(); const q = searchInput.value.trim();
if (q.length < 3) { if (q.length < 3) {
@@ -792,7 +831,7 @@ require_once __DIR__ . '/includes/layout.php';
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: q, mode: searchMode, language: searchLang, limit: 8, category: searchCat || null }), body: JSON.stringify({ query: q, mode: searchMode, language: searchLang, limit: 8, category: searchCat || null, profile: searchPersona || null }),
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
@@ -872,6 +911,7 @@ require_once __DIR__ . '/includes/layout.php';
if (searchMode !== 'hybrid') p.set('mode', searchMode); if (searchMode !== 'hybrid') p.set('mode', searchMode);
if (searchLang !== 'en') p.set('lang', searchLang); if (searchLang !== 'en') p.set('lang', searchLang);
if (searchCat) p.set('cat', searchCat); if (searchCat) p.set('cat', searchCat);
if (searchPersona && searchPersona !== 'family') p.set('persona', searchPersona);
if (drillPanel && !drillPanel.hidden) { if (drillPanel && !drillPanel.hidden) {
if (drillState.category) p.set('drill', drillState.category); if (drillState.category) p.set('drill', drillState.category);
if (drillState.sourceName) p.set('drillsrc', drillState.sourceName); if (drillState.sourceName) p.set('drillsrc', drillState.sourceName);
@@ -901,6 +941,10 @@ require_once __DIR__ . '/includes/layout.php';
searchCat = p.get('cat'); searchCat = p.get('cat');
activatePill('#searchCatPills .mode-pill', 'cat', searchCat); activatePill('#searchCatPills .mode-pill', 'cat', searchCat);
} }
if (p.has('persona')) {
searchPersona = p.get('persona');
if (personaSelect) personaSelect.value = searchPersona;
}
if (p.has('drill')) openDrillByCategory(p.get('drill')); if (p.has('drill')) openDrillByCategory(p.get('drill'));
if (p.has('drillsrc')) openDrillBySource(p.get('drillsrc')); if (p.has('drillsrc')) openDrillBySource(p.get('drillsrc'));
if (p.has('q') && searchInput) { if (p.has('q') && searchInput) {
+29
View File
@@ -12,6 +12,12 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
<section class="dash-card" style="display:flex; flex-direction:column; min-height:60vh;"> <section class="dash-card" style="display:flex; flex-direction:column; min-height:60vh;">
<div class="dms-filters" style="margin-bottom:8px;"> <div class="dms-filters" style="margin-bottom:8px;">
<label style="font-size:13px;display:inline-flex;gap:6px;align-items:center;">
⚖️ <span>Domain:</span>
<select id="chatPersona" aria-label="Legal domain persona">
<option value="">Default (family law)</option>
</select>
</label>
<label style="font-size:13px;display:inline-flex;gap:6px;align-items:center;"> <label style="font-size:13px;display:inline-flex;gap:6px;align-items:center;">
📁 <span>Scope:</span> 📁 <span>Scope:</span>
<select id="chatFolderScope"> <select id="chatFolderScope">
@@ -86,6 +92,27 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c])); } function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c])); }
// Populate the legal-domain persona picker (personas.php lives at the site root).
(async function loadPersonas() {
const sel = document.getElementById('chatPersona');
if (!sel) return;
try {
const resp = await fetch('/api/personas.php', { credentials: 'same-origin' });
if (!resp.ok) return;
const data = await resp.json();
const personas = (data && data.personas) || [];
if (!personas.length) return;
sel.innerHTML = '';
personas.forEach(p => {
const opt = document.createElement('option');
opt.value = p.slug;
opt.textContent = p.name;
sel.appendChild(opt);
});
if (data.default_persona) sel.value = data.default_persona;
} catch (_) { /* keep the default option */ }
})();
function clearEmpty() { function clearEmpty() {
const empty = $log.querySelector('.chat-empty'); const empty = $log.querySelector('.chat-empty');
if (empty) empty.remove(); if (empty) empty.remove();
@@ -132,7 +159,9 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
const folderScope = document.getElementById('chatFolderScope'); const folderScope = document.getElementById('chatFolderScope');
const includeSub = document.getElementById('chatIncludeSub'); const includeSub = document.getElementById('chatIncludeSub');
const includeRel = document.getElementById('chatIncludeRelated'); const includeRel = document.getElementById('chatIncludeRelated');
const persona = document.getElementById('chatPersona');
const body = { question, history: history.slice(0, -1) }; const body = { question, history: history.slice(0, -1) };
if (persona && persona.value) body.profile = persona.value;
if (folderScope && folderScope.value && folderScope.value !== 'all') { if (folderScope && folderScope.value && folderScope.value !== 'all') {
body.folder_id = folderScope.value; body.folder_id = folderScope.value;
body.include_subfolders = includeSub && includeSub.checked; body.include_subfolders = includeSub && includeSub.checked;
+6
View File
@@ -25,6 +25,12 @@ require_once __DIR__ . '/includes/layout.php';
</div> </div>
<p class="upload-hint">Azure mini is the default and finishes fastest. Azure full is the most thorough. Norwegian specialist v3 is a Qwen2.5 fine-tune optimised for barnevernsloven, ECHR, and forvaltningsloven best for cases involving § 4-25, Strand Lobben, or procedural challenges.</p> <p class="upload-hint">Azure mini is the default and finishes fastest. Azure full is the most thorough. Norwegian specialist v3 is a Qwen2.5 fine-tune optimised for barnevernsloven, ECHR, and forvaltningsloven best for cases involving § 4-25, Strand Lobben, or procedural challenges.</p>
<div class="control-row corpus-persona is-hidden" id="drPersonaControl">
<span class="control-label">Domain</span>
<select id="drPersonaSelect" class="drill-sort-select" aria-label="Legal domain persona"></select>
<small class="control-hint">Scopes the corpus to a legal domain persona.</small>
</div>
<div class="dr-slice-section"> <div class="dr-slice-section">
<p class="control-label">Corpus slices</p> <p class="control-label">Corpus slices</p>
<p class="upload-hint">Three core legal slices are on by default. Enable Hague Convention, Norwegian Courts, Bufdir guidance, or DBN&nbsp;Resources for more targeted research.</p> <p class="upload-hint">Three core legal slices are on by default. Enable Hague Convention, Norwegian Courts, Bufdir guidance, or DBN&nbsp;Resources for more targeted research.</p>
+27 -7
View File
@@ -208,20 +208,40 @@ final class CorpusProvision
return (int)$db->lastInsertId(); return (int)$db->lastInsertId();
} }
/**
* Slugs every DBN dashboard tenant is subscribed to, so persona-scoped
* retrieval (which intersects requested package_ids with the tenant's
* subscriptions) resolves for any domain persona. family-legal stays the
* default; the dbn-* packages back the other personas.
*/
public const DBN_PACKAGE_SLUGS = [
'family-legal',
'dbn-child-welfare',
'dbn-immigration',
'dbn-labour',
'dbn-consumer-tenancy',
'dbn-general',
];
private static function subscribeIncludedPackages(PDO $db, int $clientId): void private static function subscribeIncludedPackages(PDO $db, int $clientId): void
{ {
$packageSlug = dbnToolsRequiredPackageSlug(); $placeholders = implode(',', array_fill(0, count(self::DBN_PACKAGE_SLUGS), '?'));
$stmt = $db->prepare('SELECT id FROM corpus_packages WHERE slug = ? AND is_active = 1 LIMIT 1'); $stmt = $db->prepare(
$stmt->execute([$packageSlug]); "SELECT id FROM corpus_packages WHERE slug IN ($placeholders) AND is_active = 1"
$packageId = (int)($stmt->fetchColumn() ?: 0); );
if ($packageId === 0) { $stmt->execute(self::DBN_PACKAGE_SLUGS);
$packageIds = array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN));
if (!$packageIds) {
return; return;
} }
$db->prepare( $insert = $db->prepare(
"INSERT IGNORE INTO client_corpus_subscriptions "INSERT IGNORE INTO client_corpus_subscriptions
(client_id, package_id, is_active, source, subscribed_at) (client_id, package_id, is_active, source, subscribed_at)
VALUES (?, ?, 1, 'dbn_dashboard', NOW())" VALUES (?, ?, 1, 'dbn_dashboard', NOW())"
)->execute([$clientId, $packageId]); );
foreach ($packageIds as $packageId) {
$insert->execute([$clientId, $packageId]);
}
} }
private static function uniqueSlug(PDO $db, string $base): string private static function uniqueSlug(PDO $db, string $base): string
+12 -3
View File
@@ -36,7 +36,8 @@ final class DbnDeepResearchAgent
string $advocateRole = '', string $advocateRole = '',
?array $priorContext = null, ?array $priorContext = null,
string $branchNotes = '', string $branchNotes = '',
array $subQuestionsOverride = [] array $subQuestionsOverride = [],
?string $persona = null
): array { ): array {
$seedQuery = trim($seedQuery); $seedQuery = trim($seedQuery);
$pastedText = trim($pastedText); $pastedText = trim($pastedText);
@@ -50,7 +51,15 @@ final class DbnDeepResearchAgent
} }
$client = dbnToolsRequireClient(); $client = dbnToolsRequireClient();
$package = $this->requireFamilyPackage((int)$client['id']); $personaResolved = dbnToolsResolvePersona((int)$client['id'], $persona);
$packageIds = array_values(array_filter(
array_map('intval', $personaResolved['package_ids'] ?? []),
static fn(int $id): bool => $id > 0
));
if (!$packageIds) {
// Persona resolved without a package → fall back to the legacy family package.
$packageIds = [(int)$this->requireFamilyPackage((int)$client['id'])['id']];
}
dbnToolsBootCaveau(); dbnToolsBootCaveau();
$aiPortalRoot = dbnToolsAiPortalRoot(); $aiPortalRoot = dbnToolsAiPortalRoot();
@@ -230,7 +239,7 @@ final class DbnDeepResearchAgent
[ [
'search_private' => false, 'search_private' => false,
'search_shared' => true, 'search_shared' => true,
'package_ids' => [(int)$package['id']], 'package_ids' => $packageIds,
'shared_doc_ids' => $sharedDocIds, 'shared_doc_ids' => $sharedDocIds,
'chunk_limit' => $controls['chunk_limit'], 'chunk_limit' => $controls['chunk_limit'],
'search_method' => 'hybrid', 'search_method' => 'hybrid',
+15 -7
View File
@@ -243,7 +243,7 @@ PROMPT;
* *
* @return array Final result payload (matches NDJSON 'final' event shape). * @return array Final result payload (matches NDJSON 'final' event shape).
*/ */
public function generate(array $intake, array $classify, ?callable $emit = null, string $engine = 'azure_mini'): array public function generate(array $intake, array $classify, ?callable $emit = null, string $engine = 'azure_mini', ?string $persona = null): array
{ {
$draftDeployment = ($this->azure instanceof DbnBedrockGateway) $draftDeployment = ($this->azure instanceof DbnBedrockGateway)
? (($engine === 'claude_sonnet' || $engine === 'azure_full') ? (($engine === 'claude_sonnet' || $engine === 'azure_full')
@@ -259,7 +259,7 @@ PROMPT;
// ── Retrieve law ──────────────────────────────────────────────────────── // ── Retrieve law ────────────────────────────────────────────────────────
if ($emit) { $emit('progress', ['detail' => self::L('fetching_law', $userLang)]); } if ($emit) { $emit('progress', ['detail' => self::L('fetching_law', $userLang)]); }
$retrieval = $this->retrieveLaw($body, $classify['applicable_acts'] ?? []); $retrieval = $this->retrieveLaw($body, $classify['applicable_acts'] ?? [], $persona);
if ($emit) { if ($emit) {
$emit('retrieval', [ $emit('retrieval', [
'sources_count' => count($retrieval['sources']), 'sources_count' => count($retrieval['sources']),
@@ -420,12 +420,20 @@ PROMPT;
* *
* @return array{sources:array, applied_slices:string[]} * @return array{sources:array, applied_slices:string[]}
*/ */
private function retrieveLaw(string $body, array $applicableActs): array private function retrieveLaw(string $body, array $applicableActs, ?string $persona = null): array
{ {
$client = dbnToolsRequireClient(); $client = dbnToolsRequireClient();
$package = dbnToolsFetchPackage(dbnToolsRequiredPackageSlug()); $personaResolved = dbnToolsResolvePersona((int)$client['id'], $persona);
if (!$package) { $packageIds = array_values(array_filter(
return ['sources' => [], 'applied_slices' => []]; array_map('intval', $personaResolved['package_ids'] ?? []),
static fn(int $id): bool => $id > 0
));
if (!$packageIds) {
$package = dbnToolsFetchPackage(dbnToolsRequiredPackageSlug());
if (!$package) {
return ['sources' => [], 'applied_slices' => []];
}
$packageIds = [(int)$package['id']];
} }
dbnToolsBootCaveau(); dbnToolsBootCaveau();
@@ -476,7 +484,7 @@ PROMPT;
$chunks = $rag->searchAll($q, 5, null, [ $chunks = $rag->searchAll($q, 5, null, [
'search_private' => false, 'search_private' => false,
'search_shared' => true, 'search_shared' => true,
'package_ids' => [(int)$package['id']], 'package_ids' => $packageIds,
'shared_doc_ids' => $sharedDocIds, 'shared_doc_ids' => $sharedDocIds,
'chunk_limit' => 5, 'chunk_limit' => 5,
'search_method' => 'hybrid', 'search_method' => 'hybrid',
+7
View File
@@ -78,6 +78,13 @@ require_once __DIR__ . '/includes/layout.php';
</div> </div>
<p class="upload-hint">Quick uses Claude Haiku 4.5 for drafting fast and solid for standard correspondence. Thorough uses Claude Sonnet 4.6 better at multi-statute cases, complex appeal grounds, and ECHR framing.</p> <p class="upload-hint">Quick uses Claude Haiku 4.5 for drafting fast and solid for standard correspondence. Thorough uses Claude Sonnet 4.6 better at multi-statute cases, complex appeal grounds, and ECHR framing.</p>
<!-- Domain persona -->
<div class="control-row corpus-persona is-hidden" id="korrPersonaControl">
<span class="control-label">Domain</span>
<select id="korrPersonaSelect" class="adv-role-select" aria-label="Legal domain persona"></select>
<small class="control-hint">Scopes the hard-RAG law retrieval to a legal domain.</small>
</div>
<!-- Case context fields --> <!-- Case context fields -->
<div class="dr-control-grid"> <div class="dr-control-grid">
<div class="dr-control-card"> <div class="dr-control-card">