From 06d01a3bceca5be254aa74f4667e021f0cdfb0a4 Mon Sep 17 00:00:00 2001 From: davegilligan Date: Sat, 23 May 2026 17:15:40 +0200 Subject: [PATCH] feat(dashboard): add corpus dashboard at /dashboard/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/dashboard/chat-stream.php | 110 ++++++++++++ api/dashboard/corpus-init.php | 38 ++++ api/dashboard/documents.php | 249 +++++++++++++++++++++++++++ api/dashboard/graph.php | 81 +++++++++ api/dashboard/ingest-status.php | 53 ++++++ api/dashboard/save-from-tool.php | 136 +++++++++++++++ api/dashboard/upload.php | 241 ++++++++++++++++++++++++++ assets/css/dashboard.css | 173 +++++++++++++++++++ assets/js/corpus-save.js | 112 +++++++++--- dashboard/chat.php | 233 +++++++++++++++++++++++++ dashboard/document.php | 204 ++++++++++++++++++++++ dashboard/documents.php | 189 ++++++++++++++++++++ dashboard/index.php | 128 ++++++++++++++ dashboard/settings.php | 65 +++++++ dashboard/upload.php | 221 ++++++++++++++++++++++++ includes/CorpusProvision.php | 249 +++++++++++++++++++++++++++ includes/bootstrap.php | 44 +++++ includes/layout.php | 1 + includes/layout_dashboard.php | 102 +++++++++++ includes/layout_dashboard_footer.php | 31 ++++ 20 files changed, 2632 insertions(+), 28 deletions(-) create mode 100644 api/dashboard/chat-stream.php create mode 100644 api/dashboard/corpus-init.php create mode 100644 api/dashboard/documents.php create mode 100644 api/dashboard/graph.php create mode 100644 api/dashboard/ingest-status.php create mode 100644 api/dashboard/save-from-tool.php create mode 100644 api/dashboard/upload.php create mode 100644 assets/css/dashboard.css create mode 100644 dashboard/chat.php create mode 100644 dashboard/document.php create mode 100644 dashboard/documents.php create mode 100644 dashboard/index.php create mode 100644 dashboard/settings.php create mode 100644 dashboard/upload.php create mode 100644 includes/CorpusProvision.php create mode 100644 includes/layout_dashboard.php create mode 100644 includes/layout_dashboard_footer.php diff --git a/api/dashboard/chat-stream.php b/api/dashboard/chat-stream.php new file mode 100644 index 0000000..23b1516 --- /dev/null +++ b/api/dashboard/chat-stream.php @@ -0,0 +1,110 @@ +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'; + +// 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', + ]; + + $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, + ]; + } + + 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, + ]); +} catch (Throwable $e) { + sseEmit('fail', ['ok' => false, 'message' => $e->getMessage()]); +} +exit; diff --git a/api/dashboard/corpus-init.php b/api/dashboard/corpus-init.php new file mode 100644 index 0000000..77f80e7 --- /dev/null +++ b/api/dashboard/corpus-init.php @@ -0,0 +1,38 @@ +getMessage(), $e->status, $e->errorCode, $e->extra); +} + +dbnToolsRespond([ + 'ok' => true, + 'client_id' => (int)$tenant['client_id'], + 'client_user_id' => (int)$tenant['client_user_id'], + 'corpus_id' => (int)$tenant['corpus_id'], + 'created' => (bool)($tenant['created'] ?? false), +]); diff --git a/api/dashboard/documents.php b/api/dashboard/documents.php new file mode 100644 index 0000000..0613b4e --- /dev/null +++ b/api/dashboard/documents.php @@ -0,0 +1,249 @@ +getMessage(), $e->status, $e->errorCode); +} +$clientId = (int)$tenant['client_id']; + +$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')); +$action = (string)($_GET['action'] ?? ($method === 'POST' ? '' : 'list')); + +$db = dbnToolsDb(); + +switch ($action) { + case 'list': + dbnToolsRequireMethod('GET'); + respondList($db, $clientId); + break; + case 'get': + dbnToolsRequireMethod('GET'); + respondGet($db, $clientId); + break; + case 'update': + dbnToolsRequireMethod('POST'); + respondUpdate($db, $clientId); + break; + case 'delete': + dbnToolsRequireMethod('POST'); + respondDelete($db, $clientId); + break; + default: + dbnToolsError('Unknown action.', 400, 'unknown_action'); +} + +function respondList(PDO $db, int $clientId): void +{ + $offset = max(0, (int)($_GET['offset'] ?? 0)); + $limit = max(1, min(100, (int)($_GET['limit'] ?? 20))); + $q = trim((string)($_GET['q'] ?? '')); + $status = trim((string)($_GET['status'] ?? '')); + $category = trim((string)($_GET['category'] ?? '')); + + $where = ['client_id = ?']; + $params = [$clientId]; + + if ($q !== '') { + $where[] = '(title LIKE ? OR tags LIKE ?)'; + $like = '%' . str_replace(['%', '_'], ['\%', '\_'], $q) . '%'; + $params[] = $like; + $params[] = $like; + } + $allowedStatus = ['pending', 'processing', 'ready', 'error']; + if ($status !== '' && in_array($status, $allowedStatus, true)) { + $where[] = 'status = ?'; + $params[] = $status; + } + if ($category !== '') { + $where[] = 'category = ?'; + $params[] = $category; + } + + $whereSql = 'WHERE ' . implode(' AND ', $where); + + $countStmt = $db->prepare("SELECT COUNT(*) FROM client_documents {$whereSql}"); + $countStmt->execute($params); + $total = (int)$countStmt->fetchColumn(); + + $sql = "SELECT id, title, source_type, language, category, tags, author, + source_tool, import_method, status, word_count, chunk_count, + file_size_bytes, source_url, error_message, + created_at, updated_at + FROM client_documents + {$whereSql} + ORDER BY id DESC + LIMIT {$limit} OFFSET {$offset}"; + $stmt = $db->prepare($sql); + $stmt->execute($params); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + dbnToolsRespond([ + 'ok' => true, + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'documents' => array_map('shapeDoc', $rows), + ]); +} + +function respondGet(PDO $db, int $clientId): void +{ + $id = (int)($_GET['id'] ?? 0); + if ($id <= 0) { + dbnToolsError('id is required.', 400, 'missing_id'); + } + $stmt = $db->prepare( + 'SELECT * FROM client_documents WHERE id = ? AND client_id = ? LIMIT 1' + ); + $stmt->execute([$id, $clientId]); + $doc = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$doc) { + dbnToolsError('Document not found.', 404, 'not_found'); + } + + $chunks = $db->prepare( + 'SELECT id, content, section_title + FROM client_chunks + WHERE client_id = ? AND document_id = ? + ORDER BY id ASC + LIMIT 200' + ); + try { + $chunks->execute([$clientId, $id]); + $chunkRows = $chunks->fetchAll(PDO::FETCH_ASSOC); + } catch (Throwable $e) { + $chunkRows = []; + } + + dbnToolsRespond([ + 'ok' => true, + 'document' => shapeDoc($doc) + ['content' => (string)$doc['content']], + 'chunks' => $chunkRows, + ]); +} + +function respondUpdate(PDO $db, int $clientId): void +{ + $input = dbnToolsJsonInput(20_000); + $id = (int)($input['id'] ?? 0); + if ($id <= 0) { + dbnToolsError('id is required.', 400, 'missing_id'); + } + + $fields = []; + $params = []; + $allowed = [ + 'title' => ['VARCHAR', 500], + 'category' => ['VARCHAR', 50], + 'tags' => ['VARCHAR', 500], + 'language' => ['VARCHAR', 10], + 'author' => ['VARCHAR', 200], + ]; + foreach ($allowed as $col => [$kind, $max]) { + if (!array_key_exists($col, $input)) { + continue; + } + $val = trim((string)$input[$col]); + if (mb_strlen($val, 'UTF-8') > $max) { + dbnToolsError("Field {$col} exceeds {$max} chars.", 422, 'field_too_long'); + } + $fields[] = "{$col} = ?"; + $params[] = $val !== '' ? $val : null; + } + if (!$fields) { + dbnToolsError('No editable fields supplied.', 400, 'no_fields'); + } + $params[] = $id; + $params[] = $clientId; + + $stmt = $db->prepare( + 'UPDATE client_documents SET ' . implode(', ', $fields) + . ', updated_at = NOW() WHERE id = ? AND client_id = ?' + ); + $stmt->execute($params); + + $stmt = $db->prepare('SELECT * FROM client_documents WHERE id = ? AND client_id = ? LIMIT 1'); + $stmt->execute([$id, $clientId]); + $doc = $stmt->fetch(PDO::FETCH_ASSOC); + + dbnToolsRespond(['ok' => true, 'document' => shapeDoc($doc ?: [])]); +} + +function respondDelete(PDO $db, int $clientId): void +{ + $input = dbnToolsJsonInput(50_000); + $ids = $input['ids'] ?? []; + if (!is_array($ids) || !$ids) { + dbnToolsError('ids array is required.', 400, 'missing_ids'); + } + $ids = array_values(array_unique(array_map('intval', $ids))); + $ids = array_filter($ids, fn($v) => $v > 0); + if (!$ids) { + dbnToolsError('No valid ids.', 400, 'invalid_ids'); + } + if (count($ids) > 200) { + dbnToolsError('Cannot delete more than 200 documents at once.', 422, 'too_many'); + } + + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $stmt = $db->prepare( + "DELETE FROM client_documents + WHERE client_id = ? AND id IN ({$placeholders})" + ); + $stmt->execute(array_merge([$clientId], $ids)); + + try { + $chunks = $db->prepare( + "DELETE FROM client_chunks WHERE client_id = ? AND document_id IN ({$placeholders})" + ); + $chunks->execute(array_merge([$clientId], $ids)); + } catch (Throwable $e) { + // table may be filtered to client_id only; non-fatal + } + + dbnToolsRespond(['ok' => true, 'deleted' => $stmt->rowCount()]); +} + +function shapeDoc(array $row): array +{ + return [ + 'id' => (int)($row['id'] ?? 0), + 'title' => (string)($row['title'] ?? ''), + 'source_type' => (string)($row['source_type'] ?? ''), + 'language' => (string)($row['language'] ?? ''), + 'category' => (string)($row['category'] ?? ''), + 'tags' => (string)($row['tags'] ?? ''), + 'author' => $row['author'] ?? null, + 'source_url' => $row['source_url'] ?? null, + 'source_tool' => $row['source_tool'] ?? null, + 'import_method' => (string)($row['import_method'] ?? ''), + 'status' => (string)($row['status'] ?? ''), + 'word_count' => (int)($row['word_count'] ?? 0), + 'chunk_count' => (int)($row['chunk_count'] ?? 0), + 'file_size_bytes'=> (int)($row['file_size_bytes'] ?? 0), + 'error_message' => $row['error_message'] ?? null, + 'created_at' => (string)($row['created_at'] ?? ''), + 'updated_at' => (string)($row['updated_at'] ?? ''), + ]; +} diff --git a/api/dashboard/graph.php b/api/dashboard/graph.php new file mode 100644 index 0000000..7a3719f --- /dev/null +++ b/api/dashboard/graph.php @@ -0,0 +1,81 @@ + $validActions] + ); +} +if ($docId <= 0) { + dbnToolsError('doc_id must be a positive integer.', 400, 'missing_doc_id'); +} + +$root = dbnToolsAiPortalRoot(); +$graphFile = $root . '/lib/ai/GraphClient.php'; +$agentFile = $root . '/lib/ai/LegalGraphAgent.php'; + +if (!is_file($graphFile) || !is_file($agentFile)) { + dbnToolsError('Graph backend not installed.', 503, 'graph_unavailable'); +} +require_once $graphFile; +require_once $agentFile; + +try { + $config = file_exists('/etc/bnl/config.php') ? include '/etc/bnl/config.php' : []; + $host = (string)($config['falkordb']['host'] ?? dbnToolsEnv('DBN_FALKORDB_HOST', '10.0.2.10')); + $port = (int) ($config['falkordb']['port'] ?? (int)dbnToolsEnv('DBN_FALKORDB_PORT', '6379')); + $pass = (string)($config['falkordb']['password'] ?? dbnToolsEnv('DBN_FALKORDB_PASSWORD', '')); + + $client = new GraphClient($host, $port, $pass); + $agent = new LegalGraphAgent($client); + + $results = match ($action) { + 'cites' => $agent->cites($docId, $limit), + 'cited_by' => $agent->citedBy($docId, $limit), + 'implements' => $agent->implements($docId, $limit), + 'chain' => $agent->chain($docId, $depth), + }; +} catch (Throwable $e) { + dbnToolsRespond([ + 'ok' => true, + 'action' => $action, + 'doc_id' => $docId, + 'count' => 0, + 'results' => [], + 'warning' => 'Graph backend unavailable: ' . $e->getMessage(), + ]); +} + +dbnToolsRespond([ + 'ok' => true, + 'action' => $action, + 'doc_id' => $docId, + 'count' => count($results), + 'results' => $results, +]); diff --git a/api/dashboard/ingest-status.php b/api/dashboard/ingest-status.php new file mode 100644 index 0000000..4caa7f2 --- /dev/null +++ b/api/dashboard/ingest-status.php @@ -0,0 +1,53 @@ +getMessage(), $e->status, $e->errorCode); +} +$clientId = (int)$tenant['client_id']; + +$raw = (string)($_GET['ids'] ?? ''); +$ids = array_values(array_filter( + array_map('intval', explode(',', $raw)), + fn($v) => $v > 0 +)); +if (!$ids) { + dbnToolsRespond(['ok' => true, 'statuses' => []]); +} +$ids = array_slice($ids, 0, 100); + +$db = dbnToolsDb(); +$placeholders = implode(',', array_fill(0, count($ids), '?')); +$sql = "SELECT id, status, chunk_count, error_message + FROM client_documents + WHERE client_id = ? AND id IN ({$placeholders})"; +$stmt = $db->prepare($sql); +$stmt->execute(array_merge([$clientId], $ids)); +$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + +dbnToolsRespond([ + 'ok' => true, + 'statuses' => array_map(fn($r) => [ + 'id' => (int)$r['id'], + 'status' => (string)$r['status'], + 'chunk_count' => (int)$r['chunk_count'], + 'error_message' => $r['error_message'] ?? null, + ], $rows), +]); diff --git a/api/dashboard/save-from-tool.php b/api/dashboard/save-from-tool.php new file mode 100644 index 0000000..38f6efb --- /dev/null +++ b/api/dashboard/save-from-tool.php @@ -0,0 +1,136 @@ +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'); +} diff --git a/api/dashboard/upload.php b/api/dashboard/upload.php new file mode 100644 index 0000000..d96521a --- /dev/null +++ b/api/dashboard/upload.php @@ -0,0 +1,241 @@ +getMessage(), $e->status, $e->errorCode); +} +$clientId = (int)$tenant['client_id']; +$corpusId = (int)$tenant['corpus_id']; + +dbnToolsBootCaveau(); +$db = getDb(); + +$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? ''); +$isMultipart = stripos($contentType, 'multipart/form-data') === 0; + +try { + if ($isMultipart) { + $result = handleFileUpload($db, $clientId, $corpusId); + } else { + $input = dbnToolsJsonInput(2_500_000); + $kind = (string)($input['kind'] ?? 'text'); + $result = match ($kind) { + 'text' => handleTextPaste($db, $clientId, $corpusId, $input), + 'url' => handleUrlImport($db, $clientId, $corpusId, $input), + default => dbnToolsError('Unknown kind: ' . $kind, 400, 'unknown_kind'), + }; + } +} catch (DbnToolsHttpException $e) { + dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra); +} catch (Throwable $e) { + dbnToolsError('Upload failed: ' . $e->getMessage(), 500, 'upload_failed'); +} + +dbnToolsRespond($result, 201); + + +function handleFileUpload(PDO $db, int $clientId, int $corpusId): array +{ + if (empty($_FILES['file'])) { + dbnToolsError('No file uploaded.', 400, 'missing_file'); + } + + $extract = dbnToolsExtractUploadedFile($_FILES['file']); + $text = (string)$extract['text']; + $filename = (string)$extract['filename']; + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + $sourceType = match ($ext) { + 'pdf' => 'pdf', + 'docx' => 'docx', + default => 'text', + }; + + if (mb_strlen($text, 'UTF-8') < 200 && $ext === 'pdf') { + $ocrText = tryOcrPdf((string)($_FILES['file']['tmp_name'] ?? '')); + if ($ocrText !== null && mb_strlen($ocrText, 'UTF-8') > mb_strlen($text, 'UTF-8')) { + $text = $ocrText; + $importMethod = 'ocr_scan'; + } + } + $importMethod = $importMethod ?? 'dbn_upload'; + + $title = trim((string)($_POST['title'] ?? '')) ?: pathinfo($filename, PATHINFO_FILENAME); + $category = sanitizeCategory((string)($_POST['category'] ?? 'uncategorized')); + $tags = sanitizeTagsCsv((string)($_POST['tags'] ?? '')); + $author = trim((string)($_POST['author'] ?? '')) ?: null; + $language = trim((string)($_POST['language'] ?? 'no')) ?: 'no'; + + return persistAndIngest($db, $clientId, $corpusId, [ + 'title' => $title, + 'source_type' => $sourceType, + 'content' => $text, + 'category' => $category, + 'tags' => $tags, + 'author' => $author, + 'language' => $language, + 'import_method' => $importMethod, + 'original_filename' => $filename, + 'file_size_bytes' => (int)($_FILES['file']['size'] ?? 0), + 'source_tool' => 'dashboard-upload', + ]); +} + +function handleTextPaste(PDO $db, int $clientId, int $corpusId, array $input): array +{ + $title = trim((string)($input['title'] ?? '')); + $content = trim((string)($input['content'] ?? '')); + if ($title === '') dbnToolsError('title is required.', 400, 'missing_title'); + if (mb_strlen($content, 'UTF-8') < 30) dbnToolsError('content too short (min 30 chars).', 400, 'content_too_short'); + if (mb_strlen($content, 'UTF-8') > 2_000_000) dbnToolsError('content exceeds 2 MB.', 400, 'content_too_large'); + + return persistAndIngest($db, $clientId, $corpusId, [ + 'title' => $title, + 'source_type' => 'text', + 'content' => $content, + 'category' => sanitizeCategory((string)($input['category'] ?? 'uncategorized')), + 'tags' => sanitizeTagsCsv((string)($input['tags'] ?? '')), + 'author' => trim((string)($input['author'] ?? '')) ?: null, + 'language' => trim((string)($input['language'] ?? 'no')) ?: 'no', + 'import_method' => 'manual', + 'source_tool' => 'dashboard-paste', + ]); +} + +function handleUrlImport(PDO $db, int $clientId, int $corpusId, array $input): array +{ + $url = trim((string)($input['url'] ?? '')); + $title = trim((string)($input['title'] ?? '')); + if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL, FILTER_FLAG_SCHEME_REQUIRED)) { + dbnToolsError('Valid URL is required.', 400, 'invalid_url'); + } + $scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME)); + if (!in_array($scheme, ['http', 'https'], true)) { + dbnToolsError('URL must use http or https.', 400, 'invalid_scheme'); + } + if ($title === '') $title = $url; + + $stmt = $db->prepare(" + INSERT INTO client_documents + (client_id, corpus_id, title, source_type, source_url, content, + category, tags, language, import_method, source_tool, status) + VALUES (?, ?, ?, 'url', ?, '', ?, ?, ?, 'url', 'dashboard-url', 'pending') + "); + $stmt->execute([ + $clientId, $corpusId, $title, $url, + sanitizeCategory((string)($input['category'] ?? 'uncategorized')), + sanitizeTagsCsv((string)($input['tags'] ?? '')), + trim((string)($input['language'] ?? 'no')) ?: 'no', + ]); + + return [ + 'ok' => true, + 'document_id' => (int)$db->lastInsertId(), + 'status' => 'pending', + 'chunks' => 0, + 'note' => 'URL queued for background ingest.', + ]; +} + +function persistAndIngest(PDO $db, int $clientId, int $corpusId, array $doc): array +{ + $wordCount = str_word_count($doc['content']); + + $stmt = $db->prepare(" + INSERT INTO client_documents + (client_id, corpus_id, title, source_type, original_filename, file_size_bytes, + content, category, tags, author, language, + import_method, source_tool, word_count, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending') + "); + $stmt->execute([ + $clientId, + $corpusId, + $doc['title'], + $doc['source_type'], + $doc['original_filename'] ?? null, + $doc['file_size_bytes'] ?? 0, + $doc['content'], + $doc['category'], + $doc['tags'], + $doc['author'] ?? null, + $doc['language'], + $doc['import_method'], + $doc['source_tool'], + $wordCount, + ]); + $docId = (int)$db->lastInsertId(); + + try { + $rag = new ClientRagPipeline($clientId); + $chunks = $rag->ingestDocument($docId); + return [ + 'ok' => true, + 'document_id' => $docId, + 'chunks' => (int)$chunks, + 'status' => 'ready', + 'word_count' => $wordCount, + ]; + } catch (Throwable $e) { + $db->prepare("UPDATE client_documents SET status='error', error_message=? WHERE id=?") + ->execute([substr($e->getMessage(), 0, 1000), $docId]); + return [ + 'ok' => false, + 'document_id' => $docId, + 'status' => 'error', + 'error' => ['code' => 'index_failed', 'message' => 'Saved, but indexing failed: ' . $e->getMessage()], + ]; + } +} + +function sanitizeCategory(string $cat): string +{ + $cat = strtolower(trim($cat)); + $cat = preg_replace('/[^a-z0-9\-_]/', '', $cat) ?: 'uncategorized'; + return substr($cat, 0, 50); +} + +function sanitizeTagsCsv(string $raw): string +{ + $tags = array_filter(array_map('trim', explode(',', $raw))); + $tags = array_values(array_slice(array_map(fn($t) => substr($t, 0, 32), $tags), 0, 20)); + return implode(',', $tags); +} + +function tryOcrPdf(string $tmpPath): ?string +{ + if ($tmpPath === '' || !is_readable($tmpPath)) return null; + if (!function_exists('shell_exec')) return null; + + $check = @shell_exec('command -v tesseract 2>/dev/null'); + if (!$check) return null; + + $out = trim((string)@shell_exec( + 'pdftoppm -r 200 ' . escapeshellarg($tmpPath) . ' - -png 2>/dev/null | ' + . 'tesseract -l nor+eng stdin stdout 2>/dev/null' + )); + return $out !== '' ? $out : null; +} diff --git a/assets/css/dashboard.css b/assets/css/dashboard.css new file mode 100644 index 0000000..f7c10c2 --- /dev/null +++ b/assets/css/dashboard.css @@ -0,0 +1,173 @@ +/* dashboard.css — Norwegian-legal twin of CaveauAI for tools.dobetternorge.no/dashboard/ + * + * Pairs with tools.css design tokens (--dbn-paper, --dbn-blue, --dbn-red, --dbn-line). + * Keeps brand: paper + navy + burgundy. Adds gold accent for graph edges. + */ + +:root { + --dash-gold: #b88a2c; + --dash-radius: 12px; + --dash-radius-sm: 8px; + --dash-shadow: 0 1px 0 rgba(22, 19, 15, 0.04), 0 12px 32px -22px rgba(0, 32, 91, 0.18); + --dash-row-hover: rgba(0, 32, 91, 0.05); +} + +body[data-dashboard-page] { + background: var(--dbn-paper, #f6f2ea); + color: var(--dbn-ink, #16130f); + font-family: "IBM Plex Sans", "Inter", system-ui, sans-serif; + margin: 0; + min-height: 100vh; +} + +.dash-shell { max-width: 1320px; margin: 0 auto; padding: 1.25rem clamp(1rem, 4vw, 2.25rem) 4rem; } + +/* ── Topbar ───────────────────────────────────────────────────────────── */ +.dash-topbar { + display: flex; align-items: center; justify-content: space-between; + padding: 0.75rem 0 1.25rem; + border-bottom: 1px solid var(--dbn-line, rgba(22, 19, 15, 0.16)); +} +.dash-brand { display: flex; gap: 0.75rem; align-items: center; text-decoration: none; color: inherit; } +.dash-brand__mark { + width: 38px; height: 38px; border-radius: 10px; + background: var(--dbn-blue, #00205b); color: #fff; + display: grid; place-items: center; font-size: 1.1rem; +} +.dash-brand__text strong { font-family: "Crimson Pro", "Georgia", serif; font-size: 1.15rem; display: block; } +.dash-brand__text small { color: rgba(22, 19, 15, 0.55); font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; } +.dash-topbar__link { color: var(--dbn-blue, #00205b); text-decoration: none; font-size: 0.9rem; } +.dash-topbar__link:hover { text-decoration: underline; } + +/* ── Layout (sidebar + main) ──────────────────────────────────────────── */ +.dash-layout { display: grid; grid-template-columns: 220px 1fr; gap: 1.75rem; padding-top: 1.5rem; } +@media (max-width: 880px) { .dash-layout { grid-template-columns: 1fr; } } + +.dash-sidebar { display: flex; flex-direction: column; gap: 0.25rem; } +.dash-sidebar__item { + display: block; padding: 0.7rem 0.85rem; border-radius: var(--dash-radius-sm); + text-decoration: none; color: var(--dbn-ink); transition: background 120ms; + border: 1px solid transparent; +} +.dash-sidebar__item:hover { background: var(--dash-row-hover); } +.dash-sidebar__item.is-active { + background: rgba(186, 12, 47, 0.08); + border-color: rgba(186, 12, 47, 0.18); +} +.dash-sidebar__item strong { display: block; font-size: 0.95rem; font-weight: 600; } +.dash-sidebar__item small { display: block; color: rgba(22, 19, 15, 0.55); font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; } + +/* ── Main column ──────────────────────────────────────────────────────── */ +.dash-main { min-width: 0; } +.dash-main__head { margin-bottom: 1.5rem; } +.dash-main__head h1 { + font-family: "Crimson Pro", "Georgia", serif; + font-size: clamp(1.6rem, 2.4vw, 2.1rem); + margin: 0 0 0.35rem; + color: var(--dbn-blue, #00205b); +} +.dash-main__lead { color: rgba(22, 19, 15, 0.7); margin: 0; max-width: 64ch; } + +/* ── KPI tiles ────────────────────────────────────────────────────────── */ +.dash-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 2rem; } +.dash-kpi { + background: #fff; padding: 1rem 1.1rem; border-radius: var(--dash-radius); + border: 1px solid var(--dbn-line); + box-shadow: var(--dash-shadow); +} +.dash-kpi__label { font-size: 0.72rem; color: rgba(22, 19, 15, 0.55); text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 0.4rem; } +.dash-kpi__value { font-family: "Crimson Pro", "Georgia", serif; font-size: 1.85rem; font-weight: 600; color: var(--dbn-blue); margin: 0; line-height: 1; } +.dash-kpi__hint { font-size: 0.78rem; color: rgba(22, 19, 15, 0.55); margin: 0.3rem 0 0; } + +/* ── Card ────────────────────────────────────────────────────────────── */ +.dash-card { + background: #fff; border-radius: var(--dash-radius); border: 1px solid var(--dbn-line); + box-shadow: var(--dash-shadow); padding: 1.25rem 1.4rem; margin-bottom: 1.5rem; +} +.dash-card__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1rem; gap: 1rem; } +.dash-card__head h2 { font-family: "Crimson Pro", serif; font-size: 1.2rem; margin: 0; color: var(--dbn-blue); } +.dash-card__actions { display: flex; gap: 0.5rem; } + +/* ── Buttons ─────────────────────────────────────────────────────────── */ +.dash-btn { + display: inline-flex; align-items: center; gap: 0.4rem; + padding: 0.55rem 0.95rem; border-radius: var(--dash-radius-sm); + border: 1px solid var(--dbn-line); background: #fff; color: var(--dbn-ink); + font: inherit; cursor: pointer; text-decoration: none; + transition: border-color 120ms, background 120ms; +} +.dash-btn:hover { border-color: var(--dbn-blue); } +.dash-btn--primary { background: var(--dbn-blue); color: #fff; border-color: var(--dbn-blue); } +.dash-btn--primary:hover { background: #001543; } +.dash-btn--danger { background: #fff; color: var(--dbn-red, #ba0c2f); border-color: rgba(186, 12, 47, 0.4); } +.dash-btn--danger:hover { background: rgba(186, 12, 47, 0.07); } + +/* ── Documents table ─────────────────────────────────────────────────── */ +.dash-doctable { width: 100%; border-collapse: collapse; font-size: 0.93rem; } +.dash-doctable th, .dash-doctable td { padding: 0.7rem 0.85rem; text-align: left; border-bottom: 1px solid var(--dbn-line); } +.dash-doctable th { font-weight: 600; color: rgba(22, 19, 15, 0.6); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; } +.dash-doctable tbody tr { cursor: pointer; transition: background 100ms; } +.dash-doctable tbody tr:hover { background: var(--dash-row-hover); } +.dash-doctable__title { font-weight: 600; color: var(--dbn-blue); } +.dash-doctable__meta { color: rgba(22, 19, 15, 0.55); font-size: 0.78rem; } + +/* ── Status pills ────────────────────────────────────────────────────── */ +.dash-status { display: inline-block; padding: 0.18rem 0.55rem; border-radius: 999px; font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; } +.dash-status--ready { background: rgba(15, 118, 110, 0.12); color: #0f766e; } +.dash-status--pending { background: rgba(184, 138, 44, 0.16); color: #8b6d18; } +.dash-status--processing { background: rgba(0, 32, 91, 0.10); color: var(--dbn-blue); } +.dash-status--error { background: rgba(186, 12, 47, 0.12); color: var(--dbn-red); } + +/* ── Filters bar ─────────────────────────────────────────────────────── */ +.dash-filters { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; } +.dash-filters input, .dash-filters select { + padding: 0.5rem 0.7rem; border: 1px solid var(--dbn-line); border-radius: var(--dash-radius-sm); + font: inherit; background: #fff; +} +.dash-filters input { min-width: 280px; } + +/* ── Empty / loading states ──────────────────────────────────────────── */ +.dash-empty { text-align: center; padding: 3rem 1rem; color: rgba(22, 19, 15, 0.55); } +.dash-empty__icon { font-size: 2.5rem; opacity: 0.4; display: block; margin-bottom: 0.5rem; } +.dash-loading { padding: 2rem 1rem; text-align: center; color: rgba(22, 19, 15, 0.5); font-style: italic; } +.dash-error { padding: 1rem; border-radius: var(--dash-radius-sm); background: rgba(186, 12, 47, 0.07); color: var(--dbn-red); border: 1px solid rgba(186, 12, 47, 0.2); } + +/* ── Document view ───────────────────────────────────────────────────── */ +.dash-doc-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1.5rem; margin-bottom: 1rem; } +.dash-doc-head h2 { font-family: "Crimson Pro", serif; font-size: 1.5rem; margin: 0; color: var(--dbn-blue); } +.dash-doc-meta { color: rgba(22, 19, 15, 0.55); font-size: 0.85rem; margin: 0.3rem 0 0; } +.dash-tabs { display: flex; gap: 0.25rem; border-bottom: 1px solid var(--dbn-line); margin-bottom: 1rem; } +.dash-tab { + padding: 0.6rem 1rem; background: none; border: none; cursor: pointer; + color: rgba(22, 19, 15, 0.6); border-bottom: 2px solid transparent; margin-bottom: -1px; + font: inherit; +} +.dash-tab:hover { color: var(--dbn-blue); } +.dash-tab.is-active { color: var(--dbn-blue); border-bottom-color: var(--dbn-red); } +.dash-tab-panel { display: none; } +.dash-tab-panel.is-active { display: block; } + +.dash-preview { + background: #fcfaf5; padding: 1.25rem 1.5rem; border-radius: var(--dash-radius-sm); + font-family: "Crimson Pro", serif; line-height: 1.65; white-space: pre-wrap; word-wrap: break-word; + max-height: 60vh; overflow-y: auto; +} +.dash-chunk { + background: #fff; padding: 0.85rem 1rem; border-radius: var(--dash-radius-sm); + border: 1px solid var(--dbn-line); margin-bottom: 0.75rem; font-size: 0.9rem; +} +.dash-chunk__section { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(22, 19, 15, 0.5); margin: 0 0 0.4rem; } + +/* ── Related (graph edges) ───────────────────────────────────────────── */ +.dash-related { display: flex; flex-direction: column; gap: 0.5rem; } +.dash-related__edge { + display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.85rem; + border-radius: var(--dash-radius-sm); border: 1px solid var(--dbn-line); + border-left: 3px solid var(--dash-gold); +} +.dash-related__rel { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--dash-gold); font-weight: 600; min-width: 90px; } +.dash-related__title { flex: 1; color: var(--dbn-blue); font-weight: 500; } + +/* ── Pagination ──────────────────────────────────────────────────────── */ +.dash-pager { display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; color: rgba(22, 19, 15, 0.55); font-size: 0.85rem; } +.dash-pager__actions { display: flex; gap: 0.5rem; } diff --git a/assets/js/corpus-save.js b/assets/js/corpus-save.js index 4c186f4..92a9c52 100644 --- a/assets/js/corpus-save.js +++ b/assets/js/corpus-save.js @@ -1,31 +1,64 @@ /** - * corpus-save.js — "Save to corpus" shared handler for all DBN tool pages. + * corpus-save.js — Shared "Save to corpus" handler. * - * Buttons that trigger a save must have: - * class="js-save-corpus" - * data-content-id="" - * data-tool="" - * data-suggested-title="" (optional) + * Two trigger paths: + * + * 1. Tool pages — buttons with class="js-save-corpus": + * data-content-id="" + * data-tool="" + * data-suggested-title="" (optional) + * + * 2. Dashboard chat — page code calls: + * dialog.dataset.pendingContent = "..."; + * dialog.dataset.pendingTool = "dashboard-chat"; (optional) + * dialog.showModal(); + * Title and tags are populated by the caller before showModal(). + * + * Endpoint resolution: if window.DBN_DASHBOARD is present (dashboard pages), + * POST to /api/dashboard/save-from-tool.php; otherwise fall back to the + * legacy /api/save-to-corpus.php so existing tools keep working. */ (function () { 'use strict'; - const dlg = document.getElementById('save-corpus-dialog'); - const form = document.getElementById('save-corpus-form'); - const titleIn = document.getElementById('save-corpus-title'); - const tagsIn = document.getElementById('save-corpus-tags'); + const dlg = document.getElementById('save-corpus-dialog'); + const form = document.getElementById('save-corpus-form'); + const titleIn = document.getElementById('save-corpus-title'); + const tagsIn = document.getElementById('save-corpus-tags'); const cancelBtn = document.getElementById('save-corpus-cancel'); - if (!dlg || !form) return; // dialog not present (e.g. not logged in) + if (!dlg || !form) return; cancelBtn?.addEventListener('click', () => dlg.close()); - let _pendingBtn = null; - let _pendingContent = ''; - let _pendingTool = ''; + let _pendingBtn = null; - // Delegated click — catches buttons added dynamically by tool JS + function endpoint() { + return window.DBN_DASHBOARD + ? '/api/dashboard/save-from-tool.php' + : '/api/save-to-corpus.php'; + } + + function bodyFor(kind, payload) { + if (window.DBN_DASHBOARD) { + return JSON.stringify({ + title: payload.title, + content: payload.content, + source_tool: payload.tool || 'dashboard-save', + tags: payload.tags, + kind, + }); + } + return JSON.stringify({ + title: payload.title, + content: payload.content, + source_tool: payload.tool || '', + tags: payload.tags, + }); + } + + // ── Path 1: legacy tool buttons (.js-save-corpus) ───────────────────── document.addEventListener('click', (e) => { const btn = e.target.closest('.js-save-corpus'); if (!btn) return; @@ -40,27 +73,29 @@ return; } - _pendingBtn = btn; - _pendingContent = content; - _pendingTool = btn.dataset.tool ?? ''; + _pendingBtn = btn; + dlg.dataset.pendingContent = content; + dlg.dataset.pendingTool = btn.dataset.tool || ''; + dlg.dataset.pendingKind = 'tool_output'; - titleIn.value = btn.dataset.suggestedTitle ?? ''; + titleIn.value = btn.dataset.suggestedTitle || ''; tagsIn.value = ''; dlg.showModal(); titleIn.focus(); titleIn.select(); }); - // Form submit inside dialog + // ── Submit dialog (both paths) ──────────────────────────────────────── form.addEventListener('submit', async (e) => { e.preventDefault(); dlg.close(); const btn = _pendingBtn; - const content = _pendingContent; + const content = dlg.dataset.pendingContent || ''; + const tool = dlg.dataset.pendingTool || ''; + const kind = dlg.dataset.pendingKind || 'tool_output'; const title = titleIn.value.trim(); const tags = tagsIn.value.trim(); - const tool = _pendingTool; if (!title || !content) return; @@ -70,25 +105,30 @@ } try { - const resp = await fetch('api/save-to-corpus.php', { + const resp = await fetch(endpoint(), { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, content, source_tool: tool, tags }), + body: bodyFor(kind, { title, content, tool, tags }), }); - const data = await resp.json().catch(() => ({})); if (resp.ok && data.ok) { if (btn) { btn.textContent = '✓ Saved to corpus'; btn.classList.add('js-save-corpus--saved'); + } else { + // Path 2 (no button): show a fleeting toast + showToast('Lagret i korpus — ' + (data.chunks || 0) + ' passasjer'); } } else { - const msg = data.error ?? `Error ${resp.status}`; + const msg = (data.error && data.error.message) || data.error || ('Error ' + resp.status); if (btn) { btn.textContent = 'Save failed'; btn.disabled = false; btn.title = msg; + } else { + showToast('Lagring feilet: ' + msg, true); } console.error('[corpus-save] Save failed:', msg); } @@ -96,11 +136,27 @@ if (btn) { btn.textContent = 'Network error'; btn.disabled = false; + } else { + showToast('Nettverksfeil', true); } console.error('[corpus-save] Network error:', err); } - _pendingBtn = null; - _pendingContent = ''; + _pendingBtn = null; + delete dlg.dataset.pendingContent; + delete dlg.dataset.pendingTool; + delete dlg.dataset.pendingKind; }); + + function showToast(msg, isError) { + const t = document.createElement('div'); + t.textContent = msg; + t.style.cssText = + 'position:fixed;bottom:1.5rem;left:50%;transform:translateX(-50%);' + + 'padding:0.65rem 1rem;border-radius:10px;font:inherit;font-size:0.9rem;' + + 'z-index:99999;color:#fff;background:' + (isError ? '#ba0c2f' : '#00205b') + ';' + + 'box-shadow:0 8px 24px rgba(0,0,0,0.25);'; + document.body.appendChild(t); + setTimeout(() => t.remove(), 3500); + } }()); diff --git a/dashboard/chat.php b/dashboard/chat.php new file mode 100644 index 0000000..f69d5dd --- /dev/null +++ b/dashboard/chat.php @@ -0,0 +1,233 @@ + +
+ ⚖ Juridisk informasjon og forberedelsesstøtte — ikke endelig juridisk rådgivning. Bekreft alltid med advokat. +
+ +
+
+
Still ditt første spørsmål. Det kan handle om barnerett, barnevern, EMD, arbeidsrett — alt som finnes i ditt korpus.
+
+ +
+ + +
+
+ + + + + + diff --git a/dashboard/document.php b/dashboard/document.php new file mode 100644 index 0000000..69fa806 --- /dev/null +++ b/dashboard/document.php @@ -0,0 +1,204 @@ + +
+

Laster dokument…

+
+ + + + diff --git a/dashboard/documents.php b/dashboard/documents.php new file mode 100644 index 0000000..97f12bf --- /dev/null +++ b/dashboard/documents.php @@ -0,0 +1,189 @@ + +
+
+ + + + + Last opp +
+ +

Laster dokumenter…

+ + +
+ + + + diff --git a/dashboard/index.php b/dashboard/index.php new file mode 100644 index 0000000..f2adf60 --- /dev/null +++ b/dashboard/index.php @@ -0,0 +1,128 @@ + +
+
+

Dokumenter

+

+

av kvote

+
+
+

Passasjer indeksert

+

+

søkbare biter

+
+
+

Klare

+

+

av totalt

+
+
+

Siste opplasting

+

+

dato

+
+
+ +
+
+

Kom i gang

+
+ +
+ +
+
+

Nylig aktivitet

+ +
+
Laster…
+
+ + + + diff --git a/dashboard/settings.php b/dashboard/settings.php new file mode 100644 index 0000000..b4cbd4a --- /dev/null +++ b/dashboard/settings.php @@ -0,0 +1,65 @@ + +
+
+

Konto

+
+
+
Klient-ID
+
+
Korpus-ID
+
+
Bruker-ID
+
+
+
+ +
+
+

RAG-pipeline

+
+
+
Chunking
+
600 ord pr. passasje, 75 ords overlapp, heading-aware
+
Embedding-modell
+
nomic-embed-text (768-dim) via LiteLLM på Colin
+
Vector DB
+
bnl_client_chunks i Qdrant (Colin Docker)
+
Søkemetode
+
Hybrid (vector + keyword), reciprocal rank fusion, private boost 1.5×
+
Graf-database
+
bnl_legal i FalkorDB (Colin) — siterings-edges
+
+
+ +
+
+

Personvern

+
+

+ Alt du laster opp eller lagrer her holdes til din konto. Andre brukere kan ikke se eller søke i dine dokumenter. + Felles-pakken family-legal (~220K passasjer av norsk lovverk og rettspraksis) er delt og brukes + for å berike svar med autoritative kilder, men du eier alt du selv legger inn. +

+

+ Slett enkelt-dokumenter fra Dokumenter. Trenger du å slette hele + kontoen, kontakt support. +

+
+ + + + diff --git a/dashboard/upload.php b/dashboard/upload.php new file mode 100644 index 0000000..8d3dcb9 --- /dev/null +++ b/dashboard/upload.php @@ -0,0 +1,221 @@ + +
+
+

Velg kilde

+
+ + + +
+
+ +
+ +
+ + + + +
+ +
+ + + + + + +
+ + + + + + diff --git a/includes/CorpusProvision.php b/includes/CorpusProvision.php new file mode 100644 index 0000000..8f6b1a2 --- /dev/null +++ b/includes/CorpusProvision.php @@ -0,0 +1,249 @@ +`, plan `sandbox`, + * dbn_sso_uid = sso_uid, fresh api_key hash) + * - one row in `client_users` (role owner, random password hash — login is + * always via SSO bridge, never password) + * - one row in `client_corpora` (slug `default`, is_default=1) + * + * CaveauAI sessions (which already have client_id + client_user_id set on the + * tools session) are returned as-is without touching the DB. + * + * Output shape: + * [ + * 'client_id' => int, + * 'client_user_id' => int, + * 'corpus_id' => int, + * 'created' => bool, // true the first time, false on every call after + * ] + */ +final class CorpusProvision +{ + public static function ensureForSsoUser(int $ssoUid, string $email, string $displayName): array + { + if ($ssoUid <= 0) { + throw new DbnToolsHttpException('SSO user id is required.', 400, 'missing_sso_uid'); + } + if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new DbnToolsHttpException('A valid email is required.', 400, 'invalid_email'); + } + + dbnToolsBootCaveau(); + $db = getDb(); + + $existing = self::lookupBySso($db, $ssoUid); + if ($existing !== null) { + $corpusId = self::ensureDefaultCorpus($db, $existing['client_id'], $email); + return [ + 'client_id' => $existing['client_id'], + 'client_user_id' => $existing['client_user_id'], + 'corpus_id' => $corpusId, + 'created' => false, + ]; + } + + $db->beginTransaction(); + try { + $clientId = self::createClient($db, $ssoUid, $email, $displayName); + $clientUserId = self::createOwnerUser($db, $clientId, $email, $displayName); + $corpusId = self::ensureDefaultCorpus($db, $clientId, $email); + self::subscribeIncludedPackages($db, $clientId); + $db->commit(); + } catch (Throwable $e) { + $db->rollBack(); + throw new DbnToolsHttpException( + 'Could not provision dashboard tenant: ' . $e->getMessage(), + 500, + 'provision_failed' + ); + } + + return [ + 'client_id' => $clientId, + 'client_user_id' => $clientUserId, + 'corpus_id' => $corpusId, + 'created' => true, + ]; + } + + public static function ensureForCaveauSession(int $clientId, string $email): array + { + if ($clientId <= 0) { + throw new DbnToolsHttpException('Caveau client_id is required.', 400, 'missing_client_id'); + } + dbnToolsBootCaveau(); + $db = getDb(); + $corpusId = self::ensureDefaultCorpus($db, $clientId, $email); + return [ + 'client_id' => $clientId, + 'client_user_id' => 0, + 'corpus_id' => $corpusId, + 'created' => false, + ]; + } + + private static function lookupBySso(PDO $db, int $ssoUid): ?array + { + $stmt = $db->prepare('SELECT id FROM clients WHERE dbn_sso_uid = ? LIMIT 1'); + $stmt->execute([$ssoUid]); + $clientId = (int)($stmt->fetchColumn() ?: 0); + if ($clientId === 0) { + return null; + } + $stmt = $db->prepare( + "SELECT id FROM client_users + WHERE client_id = ? AND role = 'owner' AND is_active = 1 + ORDER BY id ASC LIMIT 1" + ); + $stmt->execute([$clientId]); + $userId = (int)($stmt->fetchColumn() ?: 0); + return ['client_id' => $clientId, 'client_user_id' => $userId]; + } + + private static function createClient(PDO $db, int $ssoUid, string $email, string $displayName): int + { + require_once dbnToolsAiPortalRoot() . '/platform/includes/client_auth.php'; + $apiKey = generateApiKey(); + + $slug = self::uniqueSlug($db, 'dbn-user-' . $ssoUid); + $name = $displayName !== '' ? $displayName : ('Dashboard ' . $email); + + $stmt = $db->prepare(" + INSERT INTO clients ( + dbn_sso_uid, slug, name, contact_email, country, timezone, + plan, embedding_tier, + api_key_hash, api_key_prefix, + monthly_query_limit, monthly_document_limit, monthly_user_limit, + is_active, plan_source, subscription_status + ) VALUES ( + ?, ?, ?, ?, 'NO', 'Europe/Oslo', + 'sandbox', 'standard', + ?, ?, + 500, 50, 1, + 1, 'signup', 'none' + ) + "); + $stmt->execute([ + $ssoUid, + $slug, + $name, + $email, + $apiKey['hash'], + $apiKey['prefix'], + ]); + return (int)$db->lastInsertId(); + } + + private static function createOwnerUser(PDO $db, int $clientId, string $email, string $displayName): int + { + $stmt = $db->prepare('SELECT id, client_id FROM client_users WHERE email = ? LIMIT 1'); + $stmt->execute([$email]); + $existing = $stmt->fetch(PDO::FETCH_ASSOC); + if ($existing && (int)$existing['client_id'] === $clientId) { + return (int)$existing['id']; + } + if ($existing) { + throw new RuntimeException("Email {$email} already belongs to another workspace."); + } + + $usernameBase = preg_replace('/[^a-z0-9._-]+/', '', strtolower(strstr($email, '@', true) ?: 'owner')); + $usernameBase = $usernameBase !== '' ? $usernameBase : 'owner'; + $username = self::uniqueUsername($db, $usernameBase); + + $stmt = $db->prepare(" + INSERT INTO client_users + (client_id, username, email, display_name, password_hash, role, is_active) + VALUES (?, ?, ?, ?, ?, 'owner', 1) + "); + $stmt->execute([ + $clientId, + $username, + $email, + $displayName !== '' ? $displayName : null, + password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT), + ]); + return (int)$db->lastInsertId(); + } + + private static function ensureDefaultCorpus(PDO $db, int $clientId, string $email): int + { + $stmt = $db->prepare( + 'SELECT id FROM client_corpora + WHERE client_id = ? AND is_default = 1 + ORDER BY id ASC LIMIT 1' + ); + $stmt->execute([$clientId]); + $id = (int)($stmt->fetchColumn() ?: 0); + if ($id > 0) { + return $id; + } + + $stmt = $db->prepare(" + INSERT INTO client_corpora (client_id, name, slug, description, is_default) + VALUES (?, ?, 'default', ?, 1) + "); + $stmt->execute([ + $clientId, + 'Min korpus', + 'Default personal corpus for ' . $email, + ]); + return (int)$db->lastInsertId(); + } + + 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) { + return; + } + $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]); + } + + private static function uniqueSlug(PDO $db, string $base): string + { + $base = preg_replace('/[^a-z0-9-]+/', '-', strtolower($base)) ?: 'dbn-user'; + $base = trim($base, '-'); + $stmt = $db->prepare('SELECT id FROM clients WHERE slug = ? LIMIT 1'); + $slug = $base; + $suffix = 2; + while (true) { + $stmt->execute([$slug]); + if (!$stmt->fetch(PDO::FETCH_ASSOC)) { + return $slug; + } + $slug = $base . '-' . $suffix; + $suffix++; + } + } + + private static function uniqueUsername(PDO $db, string $base): string + { + $stmt = $db->prepare('SELECT id FROM client_users WHERE username = ? LIMIT 1'); + $username = $base; + $suffix = 2; + while (true) { + $stmt->execute([$username]); + if (!$stmt->fetch(PDO::FETCH_ASSOC)) { + return $username; + } + $username = $base . $suffix; + $suffix++; + } + } +} diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 05652d6..3fbcc45 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -365,6 +365,50 @@ function dbnToolsBootCaveau(): void $booted = true; } +/** + * Resolve (or lazily provision) the dashboard tenant for the current session. + * + * - CaveauAI sessions return the existing client_id/client_user_id and ensure a + * default corpus exists. + * - SSO sessions promote the user to their own CaveauAI client tenant on first + * call (via CorpusProvision), then cache the result on the session. + * + * Returns ['client_id', 'client_user_id', 'corpus_id', 'created']. + * Throws DbnToolsHttpException on auth/provisioning failure. + */ +function dbnToolsEnsureDashboardTenant(): array +{ + if (!dbnToolsIsAuthenticated()) { + throw new DbnToolsHttpException('Dashboard requires an authenticated session.', 401, 'session_required'); + } + + $cached = $_SESSION['dbn_tools_dashboard_tenant'] ?? null; + if (is_array($cached) && !empty($cached['client_id']) && !empty($cached['corpus_id'])) { + return $cached + ['created' => false]; + } + + require_once __DIR__ . '/CorpusProvision.php'; + + if (dbnToolsIsFreeTier()) { + $ssoUid = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0); + $email = (string)($_SESSION['dbn_tools_sso_email'] ?? $_SESSION['dbn_tools_user_email'] ?? ''); + $displayName = (string)($_SESSION['dbn_tools_sso_name'] ?? $_SESSION['dbn_tools_user_name'] ?? ''); + $tenant = CorpusProvision::ensureForSsoUser($ssoUid, $email, $displayName); + } else { + $clientId = (int)($_SESSION['dbn_tools_client_id'] ?? 0); + $email = (string)($_SESSION['dbn_tools_user_email'] ?? ''); + $tenant = CorpusProvision::ensureForCaveauSession($clientId, $email); + $tenant['client_user_id'] = (int)($_SESSION['dbn_tools_user_id'] ?? $tenant['client_user_id']); + } + + $_SESSION['dbn_tools_dashboard_tenant'] = [ + 'client_id' => (int)$tenant['client_id'], + 'client_user_id' => (int)$tenant['client_user_id'], + 'corpus_id' => (int)$tenant['corpus_id'], + ]; + return $tenant; +} + function dbnToolsDb(): PDO { dbnToolsBootCaveau(); diff --git a/includes/layout.php b/includes/layout.php index 686d00a..fae6a1f 100644 --- a/includes/layout.php +++ b/includes/layout.php @@ -67,6 +67,7 @@ window.DBN_FREE_TIER_BALANCE = ; + 📚 Min korpus diff --git a/includes/layout_dashboard.php b/includes/layout_dashboard.php new file mode 100644 index 0000000..d7bf331 --- /dev/null +++ b/includes/layout_dashboard.php @@ -0,0 +1,102 @@ +status); + echo 'Dashboard unavailable' + . '

' + . htmlspecialchars($e->getMessage()) + . ' Try again

'; + exit; +} + +$uiLang = dbnToolsCurrentLanguage(); +$dashboardPage = $dashboardPage ?? 'index'; +$dashboardTitle = $dashboardTitle ?? 'Dashboard'; +$dashboardLead = $dashboardLead ?? ''; +$langPath = strtok((string)($_SERVER['REQUEST_URI'] ?? '/dashboard/'), '?') ?: '/dashboard/'; + +$dashboardNav = [ + 'index' => ['url' => '/dashboard/', 'label' => 'Oversikt', 'sub' => 'Overview'], + 'documents' => ['url' => '/dashboard/documents.php', 'label' => 'Dokumenter', 'sub' => 'Documents'], + 'upload' => ['url' => '/dashboard/upload.php', 'label' => 'Last opp', 'sub' => 'Upload'], + 'chat' => ['url' => '/dashboard/chat.php', 'label' => 'Spør', 'sub' => 'Ask'], + 'settings' => ['url' => '/dashboard/settings.php', 'label' => 'Innstillinger', 'sub' => 'Settings'], +]; +?> + + + + + + <?= htmlspecialchars($dashboardTitle) ?> · Min korpus · Do Better Norge + + + + + + +
+ + +
+ + +
+
+

+ +

+ +
+
diff --git a/includes/layout_dashboard_footer.php b/includes/layout_dashboard_footer.php new file mode 100644 index 0000000..139e0b2 --- /dev/null +++ b/includes/layout_dashboard_footer.php @@ -0,0 +1,31 @@ +
+
+
+
+ + + + + + + + +
+

Save to corpus

+

This will be indexed and searchable in your private corpus.

+ + + + + + +
+
+ +