d156f8cf6b
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>
191 lines
7.2 KiB
PHP
191 lines
7.2 KiB
PHP
<?php
|
|
/**
|
|
* POST /api/dashboard/chat-stream.php (SSE)
|
|
*
|
|
* Streams a RAG chat answer using the user's private corpus + the dobetter
|
|
* legal package. Each output token is delivered as an SSE event named "token".
|
|
* On completion, sources, chunks_used, model, and elapsed_ms are sent as a
|
|
* "done" event. Errors are sent as a "fail" event.
|
|
*
|
|
* Request body (JSON):
|
|
* {
|
|
* "question": "Hva sier barnevernloven § 4-12?",
|
|
* "history": [{role:"user"|"assistant", content:"..."}], // optional, capped at 8
|
|
* "category": "barnevern" (optional),
|
|
* "language": "no" | "en" (optional, default no)
|
|
* }
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
|
|
|
|
dbnToolsRequireMethod('POST');
|
|
dbnToolsRequireAuth();
|
|
|
|
try {
|
|
$tenant = dbnToolsEnsureDashboardTenant();
|
|
} catch (DbnToolsHttpException $e) {
|
|
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
|
|
}
|
|
$clientId = (int)$tenant['client_id'];
|
|
|
|
$input = dbnToolsJsonInput(80_000);
|
|
$question = trim((string)($input['question'] ?? ''));
|
|
if ($question === '') {
|
|
dbnToolsError('question is required.', 400, 'missing_question');
|
|
}
|
|
if (mb_strlen($question, 'UTF-8') > 4000) {
|
|
dbnToolsError('question is too long (max 4000 chars).', 422, 'question_too_long');
|
|
}
|
|
|
|
$history = is_array($input['history'] ?? null) ? $input['history'] : [];
|
|
$history = array_slice($history, -8);
|
|
$history = array_values(array_filter($history, fn($m) => is_array($m)
|
|
&& in_array($m['role'] ?? '', ['user', 'assistant'], true)
|
|
&& is_string($m['content'] ?? null)));
|
|
|
|
$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;
|
|
if ($folderScopeRaw !== null && $folderScopeRaw !== '' && $folderScopeRaw !== 'all') {
|
|
$folderScope = $folderScopeRaw === 'unassigned' ? 0 : (int)$folderScopeRaw;
|
|
}
|
|
$includeSubfolders = !empty($input['include_subfolders']);
|
|
$includeRelated = !empty($input['include_related']);
|
|
|
|
// SSE setup
|
|
header('Content-Type: text/event-stream');
|
|
header('Cache-Control: no-cache, no-transform');
|
|
header('X-Accel-Buffering: no');
|
|
@ini_set('output_buffering', 'off');
|
|
@ini_set('zlib.output_compression', '0');
|
|
while (ob_get_level() > 0) ob_end_flush();
|
|
ob_implicit_flush(true);
|
|
|
|
function sseEmit(string $event, array $data): void {
|
|
echo "event: {$event}\n";
|
|
echo 'data: ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n";
|
|
if (function_exists('flush')) @flush();
|
|
}
|
|
|
|
dbnToolsBootCaveau();
|
|
|
|
try {
|
|
$rag = new ClientRagPipeline($clientId);
|
|
|
|
$options = [
|
|
'conversation_history' => $history,
|
|
'language' => $language,
|
|
'user_id' => (int)($tenant['client_user_id'] ?? 0),
|
|
'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) {
|
|
// Unassigned only — currently not supported by allowed_folder_ids; pass empty array
|
|
// which the pipeline treats as "no folders allowed" → falls back to docs with NULL folder_id.
|
|
$options['allowed_folder_ids'] = [];
|
|
} else {
|
|
$ids = [$folderScope];
|
|
if ($includeSubfolders) {
|
|
try {
|
|
$db = dbnToolsDb();
|
|
$stack = [$folderScope];
|
|
$guard = 0;
|
|
while ($stack && $guard++ < 1000) {
|
|
$batch = $stack;
|
|
$stack = [];
|
|
$ph = implode(',', array_fill(0, count($batch), '?'));
|
|
$st = $db->prepare("SELECT id FROM client_folders WHERE client_id = ? AND parent_id IN ({$ph}) AND deleted_at IS NULL");
|
|
$st->execute(array_merge([$clientId], $batch));
|
|
foreach ($st->fetchAll() as $r) {
|
|
$cid = (int)$r['id'];
|
|
$ids[] = $cid;
|
|
$stack[] = $cid;
|
|
}
|
|
}
|
|
} catch (Throwable $e) { /* tolerated */ }
|
|
}
|
|
$options['allowed_folder_ids'] = $ids;
|
|
}
|
|
}
|
|
|
|
$result = $rag->askStreaming(
|
|
$question,
|
|
null, // model: let pipeline choose default
|
|
$category,
|
|
$options,
|
|
function (string $chunk): void {
|
|
if ($chunk !== '') sseEmit('token', ['t' => $chunk]);
|
|
}
|
|
);
|
|
|
|
$sources = [];
|
|
foreach (($result['fullChunks'] ?? $result['chunks'] ?? []) as $c) {
|
|
if (!is_array($c)) continue;
|
|
$sources[] = [
|
|
'document_id' => (int)($c['document_id'] ?? 0),
|
|
'title' => (string)($c['title'] ?? ''),
|
|
'section' => (string)($c['section_title'] ?? $c['section'] ?? ''),
|
|
'source_url' => (string)($c['source_url'] ?? ''),
|
|
'score' => isset($c['score']) ? (float)$c['score'] : null,
|
|
];
|
|
}
|
|
|
|
// Related documents from FalkorDB graph (co-citation), based on the top source doc.
|
|
$related = [];
|
|
if ($includeRelated && $sources && method_exists($rag, 'relatedDocumentsFromGraph')) {
|
|
$topDoc = (int)($sources[0]['document_id'] ?? 0);
|
|
if ($topDoc > 0) {
|
|
try {
|
|
$related = $rag->relatedDocumentsFromGraph($topDoc, 6);
|
|
} catch (Throwable $e) { /* tolerated */ }
|
|
}
|
|
}
|
|
|
|
sseEmit('done', [
|
|
'ok' => true,
|
|
'chunks_used' => (int)($result['chunks_used'] ?? count($sources)),
|
|
'model' => (string)($result['model'] ?? ''),
|
|
'response_time_ms'=> (int)($result['response_time_ms'] ?? 0),
|
|
'sources' => $sources,
|
|
'related_documents' => $related,
|
|
'scope' => [
|
|
'folder_id' => $folderScope,
|
|
'include_subfolders'=> $includeSubfolders,
|
|
],
|
|
]);
|
|
} catch (Throwable $e) {
|
|
sseEmit('fail', ['ok' => false, 'message' => $e->getMessage()]);
|
|
}
|
|
exit;
|