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:
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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 => ({ '&':'&','<':'<','>':'>','"':'"' }[c])); }
|
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[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;
|
||||||
|
|||||||
@@ -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 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 Resources for more targeted research.</p>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,13 +420,21 @@ 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();
|
||||||
|
$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) {
|
||||||
$package = dbnToolsFetchPackage(dbnToolsRequiredPackageSlug());
|
$package = dbnToolsFetchPackage(dbnToolsRequiredPackageSlug());
|
||||||
if (!$package) {
|
if (!$package) {
|
||||||
return ['sources' => [], 'applied_slices' => []];
|
return ['sources' => [], 'applied_slices' => []];
|
||||||
}
|
}
|
||||||
|
$packageIds = [(int)$package['id']];
|
||||||
|
}
|
||||||
|
|
||||||
dbnToolsBootCaveau();
|
dbnToolsBootCaveau();
|
||||||
$aiPortalRoot = dbnToolsAiPortalRoot();
|
$aiPortalRoot = dbnToolsAiPortalRoot();
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user