feat(dashboard): add corpus dashboard at /dashboard/
Full private corpus dashboard for tools.dobetternorge.no users — each SSO
account gets an auto-provisioned CaveauAI tenant (clients row, corpus) on
first visit. Includes upload (file/paste/URL), RAG chat with SSE streaming
and citation chips, document CRUD, FalkorDB graph relations tab, and
improved save-from-tool flow with tag/preview support.
- dashboard/{index,documents,document,upload,chat,settings}.php
- api/dashboard/{corpus-init,documents,upload,ingest-status,chat-stream,
save-from-tool,graph}.php
- includes/{CorpusProvision,layout_dashboard,layout_dashboard_footer}.php
- assets/css/dashboard.css assets/js/corpus-save.js (routing upgrade)
- includes/{bootstrap,layout}.php extended for dashboard provisioning
Migration 141 (clients.dbn_sso_uid + import_method enum) applied on chloe.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/dashboard/save-from-tool.php
|
||||
*
|
||||
* Improved successor to /api/save-to-corpus.php — adds:
|
||||
* - tags as either CSV string or array
|
||||
* - source_tool slug recorded as import provenance
|
||||
* - chat-answer kind (records import_method='chat_answer')
|
||||
* - preview flag: if true, returns the proposed chunks WITHOUT persisting (dry-run)
|
||||
*
|
||||
* Request body (JSON, max 2 MB):
|
||||
* title: string (required)
|
||||
* content: string (required, min 30 chars)
|
||||
* source_tool: string (optional slug; default 'dashboard-save')
|
||||
* tags: string[] | string CSV (optional, max 20 tags, 32 chars each)
|
||||
* category: string (optional; default 'tool-output')
|
||||
* language: string (optional; default 'no')
|
||||
* author: string (optional)
|
||||
* kind: 'tool_output'|'chat_answer'|'manual' (default 'tool_output')
|
||||
* preview: bool (optional; if true, return chunk preview without saving)
|
||||
*
|
||||
* Response (saved):
|
||||
* { ok, document_id, chunks, status }
|
||||
* Response (preview):
|
||||
* { ok, preview:true, chunks: [...], word_count }
|
||||
*/
|
||||
|
||||
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'];
|
||||
$corpusId = (int)$tenant['corpus_id'];
|
||||
|
||||
$input = dbnToolsJsonInput(2_000_000);
|
||||
|
||||
$title = trim((string)($input['title'] ?? ''));
|
||||
if ($title === '') dbnToolsError('title is required.', 400, 'missing_title');
|
||||
if (mb_strlen($title, 'UTF-8') > 500) dbnToolsError('title too long (max 500).', 422, 'title_too_long');
|
||||
|
||||
$content = trim((string)($input['content'] ?? ''));
|
||||
if (mb_strlen($content, 'UTF-8') < 30) dbnToolsError('content too short (min 30 chars).', 400, 'content_too_short');
|
||||
if (mb_strlen($content, 'UTF-8') > 1_900_000) dbnToolsError('content exceeds 2 MB.', 422, 'content_too_large');
|
||||
|
||||
$sourceTool = trim((string)($input['source_tool'] ?? 'dashboard-save'));
|
||||
$sourceTool = substr(preg_replace('/[^a-z0-9\-_]/', '', strtolower($sourceTool)) ?: 'dashboard-save', 0, 64);
|
||||
|
||||
$rawTags = $input['tags'] ?? '';
|
||||
$tagList = is_array($rawTags)
|
||||
? array_map('strval', $rawTags)
|
||||
: array_map('trim', explode(',', (string)$rawTags));
|
||||
$tagList = array_values(array_filter(array_map(fn($t) => substr(trim($t), 0, 32), $tagList)));
|
||||
$tagList = array_slice($tagList, 0, 20);
|
||||
$tagsCsv = implode(',', $tagList);
|
||||
|
||||
$category = strtolower(trim((string)($input['category'] ?? 'tool-output')));
|
||||
$category = substr(preg_replace('/[^a-z0-9\-_]/', '', $category) ?: 'tool-output', 0, 50);
|
||||
|
||||
$language = trim((string)($input['language'] ?? 'no')) ?: 'no';
|
||||
$author = trim((string)($input['author'] ?? '')) ?: null;
|
||||
|
||||
$kind = (string)($input['kind'] ?? 'tool_output');
|
||||
$importMethod = match ($kind) {
|
||||
'chat_answer' => 'chat_answer',
|
||||
'manual' => 'manual',
|
||||
default => 'tool_output',
|
||||
};
|
||||
|
||||
$preview = !empty($input['preview']);
|
||||
$wordCount = str_word_count($content);
|
||||
|
||||
dbnToolsBootCaveau();
|
||||
|
||||
try {
|
||||
if ($preview) {
|
||||
require_once dbnToolsAiPortalRoot() . '/lib/ai/TextChunker.php';
|
||||
$chunker = new TextChunker();
|
||||
$chunks = $chunker->chunk($content);
|
||||
$sample = array_slice($chunks, 0, 8);
|
||||
dbnToolsRespond([
|
||||
'ok' => true,
|
||||
'preview' => true,
|
||||
'word_count' => $wordCount,
|
||||
'chunks' => array_map(fn($c) => [
|
||||
'section_title' => (string)($c['section_title'] ?? ''),
|
||||
'word_count' => (int)str_word_count((string)($c['content'] ?? '')),
|
||||
'snippet' => mb_substr((string)($c['content'] ?? ''), 0, 240, 'UTF-8'),
|
||||
], $sample),
|
||||
'total_chunks' => count($chunks),
|
||||
]);
|
||||
}
|
||||
|
||||
$db = getDb();
|
||||
$ins = $db->prepare("
|
||||
INSERT INTO client_documents
|
||||
(client_id, corpus_id, title, source_type, content, category, language,
|
||||
tags, author, import_method, source_tool, word_count, status)
|
||||
VALUES (?, ?, ?, 'text', ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
|
||||
");
|
||||
$ins->execute([
|
||||
$clientId, $corpusId, $title, $content, $category, $language,
|
||||
$tagsCsv, $author, $importMethod, $sourceTool, $wordCount,
|
||||
]);
|
||||
$docId = (int)$db->lastInsertId();
|
||||
|
||||
$rag = new ClientRagPipeline($clientId);
|
||||
$chunks = $rag->ingestDocument($docId);
|
||||
|
||||
dbnToolsRespond([
|
||||
'ok' => true,
|
||||
'document_id' => $docId,
|
||||
'chunks' => (int)$chunks,
|
||||
'status' => 'ready',
|
||||
], 201);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($docId)) {
|
||||
try {
|
||||
$db->prepare("UPDATE client_documents SET status='error', error_message=? WHERE id=?")
|
||||
->execute([substr($e->getMessage(), 0, 1000), $docId]);
|
||||
} catch (Throwable $ignored) { /* non-fatal */ }
|
||||
dbnToolsError(
|
||||
'Saved to corpus but indexing failed: ' . $e->getMessage(),
|
||||
500, 'index_failed',
|
||||
['document_id' => $docId]
|
||||
);
|
||||
}
|
||||
dbnToolsError('Save failed: ' . $e->getMessage(), 500, 'save_failed');
|
||||
}
|
||||
Reference in New Issue
Block a user