diff --git a/api/dashboard/chat-stream.php b/api/dashboard/chat-stream.php index c4e6749..4894843 100644 --- a/api/dashboard/chat-stream.php +++ b/api/dashboard/chat-stream.php @@ -48,6 +48,26 @@ $history = array_values(array_filter($history, fn($m) => is_array($m) $category = trim((string)($input['category'] ?? '')) ?: null; $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. $folderScopeRaw = $input['folder_id'] ?? null; $folderScope = null; @@ -84,6 +104,10 @@ try { 'user_role' => 'owner', ]; + if ($personaPackageIds) { + $options['package_ids'] = $personaPackageIds; + } + // Apply folder scoping via allowed_folder_ids (supported by ClientRagPipeline). if ($folderScope !== null) { if ($folderScope === 0) { diff --git a/api/deep-research.php b/api/deep-research.php index 001dfaa..0dfee86 100644 --- a/api/deep-research.php +++ b/api/deep-research.php @@ -70,6 +70,9 @@ try { $priorContext = is_array($input['prior_context'] ?? null) ? $input['prior_context'] : null; $branchNotes = mb_substr(trim((string)($input['branch_notes'] ?? '')), 0, 1000, 'UTF-8'); $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) { throw new DbnToolsHttpException('Query is too long.', 422, 'query_too_long'); @@ -139,7 +142,8 @@ try { $advocateRole, $priorContext, $branchNotes, - $subQsOverride + $subQsOverride, + $personaSlug ); $result['ok'] = true; diff --git a/api/korrespond.php b/api/korrespond.php index 4a8069b..fd9bf0f 100644 --- a/api/korrespond.php +++ b/api/korrespond.php @@ -174,8 +174,12 @@ try { $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond'); $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 ────────────────── - $result = $agent->generate($intake, $classify, $emit, $engine); + $result = $agent->generate($intake, $classify, $emit, $engine, $personaSlug); $result['ok'] = true; $result['latency_ms'] = (int)round((microtime(true) - $startTime) * 1000); if ($ftRemaining >= 0) { diff --git a/assets/css/tools.css b/assets/css/tools.css index b6271fb..ddfee02 100644 --- a/assets/css/tools.css +++ b/assets/css/tools.css @@ -3726,6 +3726,18 @@ a.passage-card__title:hover { color: var(--ink); } } .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-empty, .drill-error { diff --git a/assets/js/deep-research.js b/assets/js/deep-research.js index dce5e4c..eb5a97b 100644 --- a/assets/js/deep-research.js +++ b/assets/js/deep-research.js @@ -7,6 +7,7 @@ let uploadFiles = []; let lastResult = null; let branchContext = null; + let persona = ''; const SLICE_DEFS = [ { id: 'family_core', label: 'Family Law Core' }, @@ -42,6 +43,8 @@ slices: Array.from(document.querySelectorAll('.dr-slice')), langButtons: Array.from(document.querySelectorAll('#drLangSwitcher .lang-btn')), engineRadios: Array.from(document.querySelectorAll('input[name="drEngine"]')), + personaControl: document.getElementById('drPersonaControl'), + personaSelect: document.getElementById('drPersonaSelect'), subQ: document.getElementById('drSubQ'), subQVal: document.getElementById('drSubQValue'), chunkLimit: document.getElementById('drChunkLimit'), @@ -79,6 +82,7 @@ bindUpload(); bindModal(); bindBranch(); + loadPersonas(); els.form.addEventListener('submit', onSubmit); els.results.addEventListener('click', (e) => { const btn = e.target.closest('.dr-branch-btn'); @@ -325,6 +329,7 @@ language: lang, controls: getControls(), }; + if (persona) payload.profile = persona; const _drDocIds = (document.getElementById('docPickerIds')?.value || '').split(',').map(Number).filter(Boolean); if (_drDocIds.length) payload.doc_ids = _drDocIds; if (branchContext) { @@ -644,6 +649,33 @@ `; } + 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() { if (!els.branchClear) return; els.branchClear.addEventListener('click', clearBranch); diff --git a/assets/js/korrespond.js b/assets/js/korrespond.js index e706aaf..0893455 100644 --- a/assets/js/korrespond.js +++ b/assets/js/korrespond.js @@ -12,6 +12,7 @@ let lastClassify = null; let lastFinal = null; let pendingClarifications = {}; + let persona = ''; const LANG_LABELS = { en: 'English', no: 'Norsk', uk: 'Українська', pl: 'Polski' }; @@ -116,6 +117,8 @@ clarifyList: document.getElementById('korrClarifyList'), clarifyContinue:document.getElementById('korrClarifyContinue'), clarifyForce: document.getElementById('korrClarifyForce'), + personaControl: document.getElementById('korrPersonaControl'), + personaSelect: document.getElementById('korrPersonaSelect'), }); if (!els.form) return; @@ -124,6 +127,7 @@ bindGoalChips(); bindUpload(); bindClarify(); + loadPersonas(); applyStaticI18n(); els.form.addEventListener('submit', (e) => { e.preventDefault(); runRequest(false); }); }); @@ -279,9 +283,37 @@ engine: (document.querySelector('[name="korrEngine"]:checked')?.value ?? 'azure_mini'), }; if (korrDocIds.length) payload.doc_ids = korrDocIds; + if (persona) payload.profile = persona; 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) { const payload = buildPayload(forceDraft); if (!payload.recipient_body) { diff --git a/corpus.php b/corpus.php index 8bc8352..03090cd 100644 --- a/corpus.php +++ b/corpus.php @@ -77,6 +77,10 @@ require_once __DIR__ . '/includes/layout.php'; +
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.
+Corpus slices
Three core legal slices are on by default. Enable Hague Convention, Norwegian Courts, Bufdir guidance, or DBN Resources for more targeted research.
diff --git a/includes/CorpusProvision.php b/includes/CorpusProvision.php index 41baf19..5a39f64 100644 --- a/includes/CorpusProvision.php +++ b/includes/CorpusProvision.php @@ -208,20 +208,40 @@ final class CorpusProvision 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 { - $packageSlug = dbnToolsRequiredPackageSlug(); - $stmt = $db->prepare('SELECT id FROM corpus_packages WHERE slug = ? AND is_active = 1 LIMIT 1'); - $stmt->execute([$packageSlug]); - $packageId = (int)($stmt->fetchColumn() ?: 0); - if ($packageId === 0) { + $placeholders = implode(',', array_fill(0, count(self::DBN_PACKAGE_SLUGS), '?')); + $stmt = $db->prepare( + "SELECT id FROM corpus_packages WHERE slug IN ($placeholders) AND is_active = 1" + ); + $stmt->execute(self::DBN_PACKAGE_SLUGS); + $packageIds = array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN)); + if (!$packageIds) { return; } - $db->prepare( + $insert = $db->prepare( "INSERT IGNORE INTO client_corpus_subscriptions (client_id, package_id, is_active, source, subscribed_at) 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 diff --git a/includes/DeepResearchAgent.php b/includes/DeepResearchAgent.php index 45a2236..300a4d0 100644 --- a/includes/DeepResearchAgent.php +++ b/includes/DeepResearchAgent.php @@ -36,7 +36,8 @@ final class DbnDeepResearchAgent string $advocateRole = '', ?array $priorContext = null, string $branchNotes = '', - array $subQuestionsOverride = [] + array $subQuestionsOverride = [], + ?string $persona = null ): array { $seedQuery = trim($seedQuery); $pastedText = trim($pastedText); @@ -50,7 +51,15 @@ final class DbnDeepResearchAgent } $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(); $aiPortalRoot = dbnToolsAiPortalRoot(); @@ -230,7 +239,7 @@ final class DbnDeepResearchAgent [ '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', diff --git a/includes/KorrespondAgent.php b/includes/KorrespondAgent.php index 34a5efd..8c32ee9 100644 --- a/includes/KorrespondAgent.php +++ b/includes/KorrespondAgent.php @@ -243,7 +243,7 @@ PROMPT; * * @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) ? (($engine === 'claude_sonnet' || $engine === 'azure_full') @@ -259,7 +259,7 @@ PROMPT; // ── Retrieve law ──────────────────────────────────────────────────────── 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) { $emit('retrieval', [ 'sources_count' => count($retrieval['sources']), @@ -420,12 +420,20 @@ PROMPT; * * @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(); - $package = dbnToolsFetchPackage(dbnToolsRequiredPackageSlug()); - if (!$package) { - return ['sources' => [], 'applied_slices' => []]; + $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()); + if (!$package) { + return ['sources' => [], 'applied_slices' => []]; + } + $packageIds = [(int)$package['id']]; } dbnToolsBootCaveau(); @@ -476,7 +484,7 @@ PROMPT; $chunks = $rag->searchAll($q, 5, null, [ 'search_private' => false, 'search_shared' => true, - 'package_ids' => [(int)$package['id']], + 'package_ids' => $packageIds, 'shared_doc_ids' => $sharedDocIds, 'chunk_limit' => 5, 'search_method' => 'hybrid', diff --git a/korrespond.php b/korrespond.php index 66907cf..b2fbe3a 100644 --- a/korrespond.php +++ b/korrespond.php @@ -78,6 +78,13 @@ require_once __DIR__ . '/includes/layout.php';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.
+ +