Azure mini finishes fastest. Azure full produces the most thorough advocate brief. Norwegian specialist uses a fine-tuned Norwegian law model for the synthesis — best for Barneloven, barnevern, and ECHR family law cases.
+
Azure mini finishes fastest. Azure full produces the most thorough advocate brief. Norwegian specialist v3 is a Qwen2.5 fine-tune trained on barnevernsloven, ECHR, and forvaltningsloven — highest precision for § 4-25, Strand Lobben, and procedural red flags.
Engine applies to the final advocacy synthesis only. Document classification, party extraction, and timeline are always fast (azure-mini).
+
Engine applies to the final advocacy synthesis only. Norwegian specialist v3 is the recommended choice for Barnevernet documents — it is fine-tuned on § 4-25, Strand Lobben, forvaltningsloven § 17/§ 41, and procedural red-flag detection. Classification, party extraction, and timeline always use azure-mini.
Azure mini is the default and finishes fastest. Azure full is the most thorough but can take 1-3 minutes. GPU keeps everything inside the BNL fleet. Live progress shown in the right-hand reasoning panel.
+
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.
Engine applies to the final synthesis only. Document classification, party extraction, timelines, and cross-referencing always use azure-mini.
+
Engine applies to the final synthesis only. Norwegian specialist v3 excels at identifying legally significant discrepancies in Barnevernet documents — procedural violations, threshold errors, and missing statutory justifications. Classification, party extraction, timelines, and cross-referencing always use azure-mini.
Corpus slices (used for legal significance context)
diff --git a/includes/AzureDocIntelligence.php b/includes/AzureDocIntelligence.php
new file mode 100644
index 0000000..2b29273
--- /dev/null
+++ b/includes/AzureDocIntelligence.php
@@ -0,0 +1,119 @@
+endpoint = rtrim($endpoint ?? ($cfg['endpoint'] ?? ''), '/');
+ $this->key = $key ?? ($cfg['key'] ?? '');
+ if ($this->endpoint === '' || $this->key === '') {
+ throw new RuntimeException('AzureDocIntelligence: endpoint or key not configured.');
+ }
+ }
+
+ private static function loadConfig(): array
+ {
+ $path = '/etc/bnl/azure.php';
+ if (is_readable($path)) {
+ $cfg = require $path;
+ return [
+ 'endpoint' => (string)($cfg['DOC_INTELLIGENCE_ENDPOINT'] ?? ''),
+ 'key' => (string)($cfg['DOC_INTELLIGENCE_KEY'] ?? ''),
+ ];
+ }
+ return [
+ 'endpoint' => (string)(getenv('AZURE_DOC_INTELLIGENCE_ENDPOINT') ?: ''),
+ 'key' => (string)(getenv('AZURE_DOC_INTELLIGENCE_KEY') ?: ''),
+ ];
+ }
+
+ /**
+ * OCR a local PDF file using the prebuilt-read model.
+ * Returns: ['content' => string, 'pages' => array, 'languages' => array]
+ */
+ public function readPdf(string $localPath, int $pollTimeoutSeconds = 120): array
+ {
+ if (!is_readable($localPath)) {
+ throw new InvalidArgumentException("Unreadable file: {$localPath}");
+ }
+ $url = $this->endpoint . '/documentintelligence/documentModels/prebuilt-read:analyze?api-version=2024-11-30';
+ $body = file_get_contents($localPath);
+
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $body,
+ CURLOPT_HTTPHEADER => [
+ 'Content-Type: application/pdf',
+ 'Ocp-Apim-Subscription-Key: ' . $this->key,
+ ],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HEADER => true,
+ CURLOPT_TIMEOUT => 60,
+ ]);
+ $response = curl_exec($ch);
+ $headerSize = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
+ $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ curl_close($ch);
+
+ if ($status !== 202 || !is_string($response)) {
+ throw new RuntimeException("DocIntelligence analyze failed: HTTP {$status}");
+ }
+ $headers = substr($response, 0, $headerSize);
+ if (!preg_match('/Operation-Location:\s*(.+?)\r?\n/i', $headers, $m)) {
+ throw new RuntimeException('DocIntelligence: missing Operation-Location header.');
+ }
+ $pollUrl = trim($m[1]);
+
+ $deadline = time() + $pollTimeoutSeconds;
+ while (time() < $deadline) {
+ usleep(1500_000);
+ $pollCh = curl_init();
+ curl_setopt_array($pollCh, [
+ CURLOPT_URL => $pollUrl,
+ CURLOPT_HTTPHEADER => ['Ocp-Apim-Subscription-Key: ' . $this->key],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 30,
+ ]);
+ $pollResp = curl_exec($pollCh);
+ $pollStatus = (int)curl_getinfo($pollCh, CURLINFO_RESPONSE_CODE);
+ curl_close($pollCh);
+ if ($pollStatus !== 200 || !is_string($pollResp)) {
+ throw new RuntimeException("DocIntelligence poll failed: HTTP {$pollStatus}");
+ }
+ $data = json_decode($pollResp, true);
+ $st = (string)($data['status'] ?? '');
+ if ($st === 'succeeded') {
+ $result = $data['analyzeResult'] ?? [];
+ return [
+ 'content' => (string)($result['content'] ?? ''),
+ 'pages' => $result['pages'] ?? [],
+ 'languages' => $result['languages'] ?? [],
+ 'page_count' => count($result['pages'] ?? []),
+ ];
+ }
+ if ($st === 'failed') {
+ $err = $data['error']['message'] ?? 'unknown';
+ throw new RuntimeException("DocIntelligence analysis failed: {$err}");
+ }
+ // 'running' or 'notStarted' — continue polling
+ }
+ throw new RuntimeException("DocIntelligence poll timeout after {$pollTimeoutSeconds}s.");
+ }
+}
diff --git a/includes/AzureSearchAdmin.php b/includes/AzureSearchAdmin.php
new file mode 100644
index 0000000..e617dac
--- /dev/null
+++ b/includes/AzureSearchAdmin.php
@@ -0,0 +1,204 @@
+endpoint = rtrim($endpoint ?? ($cfg['endpoint'] ?? ''), '/');
+ $this->adminKey = $adminKey ?? ($cfg['admin_key'] ?? '');
+ if ($this->endpoint === '' || $this->adminKey === '') {
+ throw new RuntimeException('AzureSearchAdmin: endpoint or admin key not configured.');
+ }
+ }
+
+ private static function loadConfig(): array
+ {
+ $path = '/etc/bnl/azure.php';
+ if (is_readable($path)) {
+ $cfg = require $path;
+ return [
+ 'endpoint' => (string)($cfg['SEARCH_ENDPOINT'] ?? 'https://bnl-legal-search.search.windows.net'),
+ 'admin_key' => (string)($cfg['SEARCH_ADMIN_KEY'] ?? ''),
+ ];
+ }
+ return [
+ 'endpoint' => (string)(getenv('AZURE_SEARCH_ENDPOINT') ?: 'https://bnl-legal-search.search.windows.net'),
+ 'admin_key' => (string)(getenv('AZURE_SEARCH_ADMIN_KEY') ?: ''),
+ ];
+ }
+
+ public static function indexName(int $userId): string
+ {
+ return 'case-' . $userId;
+ }
+
+ /** Create the per-user index if it does not exist. Idempotent. */
+ public function ensureUserIndex(int $userId): string
+ {
+ $name = self::indexName($userId);
+ if ($this->indexExists($name)) {
+ return $name;
+ }
+ $body = [
+ 'name' => $name,
+ 'fields' => [
+ ['name' => 'id', 'type' => 'Edm.String', 'key' => true, 'filterable' => true],
+ ['name' => 'doc_id', 'type' => 'Edm.Int32', 'filterable' => true, 'facetable' => true],
+ ['name' => 'user_id', 'type' => 'Edm.Int32', 'filterable' => true],
+ ['name' => 'filename', 'type' => 'Edm.String', 'filterable' => true, 'sortable' => true, 'searchable' => true, 'analyzer' => 'standard.lucene'],
+ ['name' => 'page', 'type' => 'Edm.Int32', 'filterable' => true, 'sortable' => true],
+ ['name' => 'chunk_text', 'type' => 'Edm.String', 'searchable' => true, 'analyzer' => 'nb.microsoft'],
+ ['name' => 'doc_type', 'type' => 'Edm.String', 'filterable' => true, 'facetable' => true],
+ ['name' => 'detected_date', 'type' => 'Edm.DateTimeOffset', 'filterable' => true, 'sortable' => true],
+ [
+ 'name' => 'vector',
+ 'type' => 'Collection(Edm.Single)',
+ 'searchable' => true,
+ 'dimensions' => 1536,
+ 'vectorSearchProfile' => 'caseVectorProfile',
+ ],
+ ],
+ 'vectorSearch' => [
+ 'algorithms' => [[
+ 'name' => 'caseHnsw',
+ 'kind' => 'hnsw',
+ 'hnswParameters' => ['m' => 4, 'efConstruction' => 400, 'efSearch' => 500, 'metric' => 'cosine'],
+ ]],
+ 'profiles' => [['name' => 'caseVectorProfile', 'algorithm' => 'caseHnsw']],
+ ],
+ 'semantic' => [
+ 'configurations' => [[
+ 'name' => 'caseSemantic',
+ 'prioritizedFields' => [
+ 'contentFields' => [['fieldName' => 'chunk_text']],
+ 'titleField' => ['fieldName' => 'filename'],
+ ],
+ ]],
+ ],
+ ];
+ $this->request('PUT', '/indexes/' . rawurlencode($name) . '?api-version=' . self::API_VERSION, $body);
+ return $name;
+ }
+
+ public function indexExists(string $name): bool
+ {
+ $code = $this->request('GET', '/indexes/' . rawurlencode($name) . '?api-version=' . self::API_VERSION, null, true);
+ return $code === 200;
+ }
+
+ /** Upsert a batch of documents (chunks) into the user's index. */
+ public function upsertChunks(int $userId, array $chunks): void
+ {
+ if (empty($chunks)) return;
+ $name = self::indexName($userId);
+ $body = [
+ 'value' => array_map(fn($c) => array_merge(['@search.action' => 'mergeOrUpload'], $c), $chunks),
+ ];
+ $this->request('POST', '/indexes/' . rawurlencode($name) . '/docs/index?api-version=' . self::API_VERSION, $body);
+ }
+
+ /** Delete all chunks for a given doc_id (used on document deletion). */
+ public function deleteDoc(int $userId, int $docId): void
+ {
+ $name = self::indexName($userId);
+ // First search to get all chunk ids for this doc
+ $resp = $this->request('POST', '/indexes/' . rawurlencode($name) . '/docs/search?api-version=' . self::API_VERSION, [
+ 'search' => '*',
+ 'filter' => 'doc_id eq ' . $docId,
+ 'select' => 'id',
+ 'top' => 1000,
+ ]);
+ $ids = array_map(fn($v) => $v['id'] ?? null, $resp['value'] ?? []);
+ $ids = array_filter($ids);
+ if (empty($ids)) return;
+
+ $body = [
+ 'value' => array_map(fn($id) => ['@search.action' => 'delete', 'id' => $id], array_values($ids)),
+ ];
+ $this->request('POST', '/indexes/' . rawurlencode($name) . '/docs/index?api-version=' . self::API_VERSION, $body);
+ }
+
+ /** Delete the entire index (account deletion / GDPR). */
+ public function deleteIndex(int $userId): void
+ {
+ $name = self::indexName($userId);
+ $this->request('DELETE', '/indexes/' . rawurlencode($name) . '?api-version=' . self::API_VERSION, null, true);
+ }
+
+ /**
+ * Hybrid search: BM25 (Norwegian analyzer) + vector + semantic ranker.
+ * Returns ['value' => [{id, doc_id, filename, page, chunk_text, @search.score, @search.rerankerScore}, ...]]
+ */
+ public function hybridSearch(int $userId, string $query, array $queryVector, int $k = 5): array
+ {
+ $name = self::indexName($userId);
+ $body = [
+ 'search' => $query,
+ 'queryType' => 'semantic',
+ 'semanticConfiguration' => 'caseSemantic',
+ 'searchFields' => 'chunk_text,filename',
+ 'select' => 'id,doc_id,filename,page,chunk_text,doc_type,detected_date',
+ 'top' => $k,
+ 'vectorQueries' => [[
+ 'kind' => 'vector',
+ 'vector' => $queryVector,
+ 'k' => $k,
+ 'fields' => 'vector',
+ ]],
+ ];
+ return $this->request('POST', '/indexes/' . rawurlencode($name) . '/docs/search?api-version=' . self::API_VERSION, $body);
+ }
+
+ /** Low-level HTTP. If $returnStatusOnly, returns http code instead of decoded body. */
+ private function request(string $method, string $path, ?array $body = null, bool $returnStatusOnly = false)
+ {
+ $url = $this->endpoint . $path;
+ $headers = [
+ 'api-key: ' . $this->adminKey,
+ 'Content-Type: application/json',
+ ];
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_CUSTOMREQUEST => strtoupper($method),
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_TIMEOUT => 30,
+ ]);
+ if ($body !== null) {
+ curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+ }
+ $raw = curl_exec($ch);
+ $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ $errno = curl_errno($ch);
+ curl_close($ch);
+
+ if ($returnStatusOnly) {
+ return $status;
+ }
+ if ($errno !== 0) {
+ throw new RuntimeException('AzureSearch curl error: ' . curl_strerror($errno));
+ }
+ if ($status >= 400) {
+ throw new RuntimeException("AzureSearch HTTP {$status}: " . substr((string)$raw, 0, 300));
+ }
+ $decoded = json_decode((string)$raw, true);
+ return is_array($decoded) ? $decoded : [];
+ }
+}
diff --git a/includes/BvjAnalyzerAgent.php b/includes/BvjAnalyzerAgent.php
index db707c1..c7fbd82 100644
--- a/includes/BvjAnalyzerAgent.php
+++ b/includes/BvjAnalyzerAgent.php
@@ -921,14 +921,18 @@ PROMPT;
$opts = ['json' => true, 'temperature' => $temperature, 'max_tokens' => 4500, 'timeout' => 240];
$deployLabel = match ($engine) {
- 'gpu' => 'GPU (cuttlefish)',
- 'azure_full' => 'gpt-4o',
- default => $this->azure->chatDeployment(),
+ 'gpu' => 'GPU (cuttlefish)',
+ 'dbn_legal_v3' => 'dbn-legal-agent-v3',
+ 'azure_full' => 'gpt-4o',
+ default => $this->azure->chatDeployment(),
};
$raw = '';
try {
- if ($engine === 'gpu') {
+ if ($engine === 'dbn_legal_v3') {
+ $response = dbnToolsCallGpuLlm($messages, array_merge($opts, ['model' => 'dbn-legal-agent-v3', 'timeout' => 180]));
+ $raw = (string)($response['choices'][0]['message']['content'] ?? '');
+ } elseif ($engine === 'gpu') {
$response = dbnToolsCallGpuLlm($messages, $opts);
$raw = (string)($response['choices'][0]['message']['content'] ?? '');
} elseif ($engine === 'azure_full') {
@@ -953,10 +957,9 @@ PROMPT;
];
}
- // Step 6b: dbn-legal-agent targeted legal Q&A check (azure engines only; silent on failure)
- // Asks one focused question about the document's statutory basis to surface domain knowledge
- // that Azure reliably misses (klar nødvendighet threshold, Strand Lobben, fvl §17/§41).
- if (in_array($engine, ['azure_mini', 'azure_full'], true)) {
+ // Step 6b: dbn-legal-agent targeted legal Q&A check (azure + gpu engines only;
+ // skipped when dbn_legal_v3 is the synthesis engine — it already IS the legal model).
+ if (in_array($engine, ['azure_mini', 'azure_full', 'gpu'], true)) {
$checkFindings = dbnToolsRunLegalCheck(
(string)($json['advocacy_brief'] ?? ''),
$docType
@@ -968,7 +971,7 @@ PROMPT;
foreach ($checkFindings as $cf) {
$json['procedural_red_flags'][] = $cf;
}
- $json['check_model'] = 'dbn-legal-agent-v2';
+ $json['check_model'] = 'dbn-legal-agent-v3';
}
}
diff --git a/includes/CaseStore.php b/includes/CaseStore.php
new file mode 100644
index 0000000..6a946bd
--- /dev/null
+++ b/includes/CaseStore.php
@@ -0,0 +1,285 @@
+prepare(
+ 'SELECT owner_user_id FROM case_seats
+ WHERE member_user_id = ? AND accepted_at IS NOT NULL AND revoked_at IS NULL
+ LIMIT 1'
+ );
+ $stmt->execute([$userId]);
+ $ownerId = (int)($stmt->fetchColumn() ?: 0);
+ return $ownerId > 0 ? $ownerId : $userId;
+ }
+
+ /** Ensure storage dir + Azure index exist for a user. Idempotent. */
+ public static function caseProvisionUser(int $userId): array
+ {
+ $rootDir = self::storageRoot() . '/case_' . $userId;
+ if (!is_dir($rootDir)) {
+ // 0750: owner rwx, group rx, world none
+ @mkdir($rootDir, 0750, true);
+ }
+ $indexName = '';
+ try {
+ $admin = new AzureSearchAdmin();
+ $indexName = $admin->ensureUserIndex($userId);
+ } catch (Throwable $e) {
+ error_log('[CaseStore::caseProvisionUser] index create failed: ' . $e->getMessage());
+ }
+ return ['storage_path' => $rootDir, 'index_name' => $indexName];
+ }
+
+ /**
+ * Register an uploaded file in DB and return the doc row.
+ * Enforces tier-based storage quota.
+ */
+ public static function registerUpload(int $userId, string $filename, string $tempPath, int $sizeBytes): array
+ {
+ // Quota check
+ $detail = FreeTier::balanceDetail($userId);
+ $quota = (int)$detail['storage_quota_bytes'];
+ $used = (int)$detail['storage_used_bytes'];
+ if ($quota === 0) {
+ throw new RuntimeException('Min Sak er ikke tilgjengelig på gratis-nivå. Oppgrader for å laste opp dokumenter.');
+ }
+ if ($used + $sizeBytes > $quota) {
+ $remainMb = max(0, ($quota - $used) / 1048576);
+ throw new RuntimeException(sprintf('Du har %.1f MB lagring igjen, men filen er %.1f MB.', $remainMb, $sizeBytes / 1048576));
+ }
+
+ // Provision (idempotent)
+ $bundle = self::caseProvisionUser($userId);
+ $dir = $bundle['storage_path'];
+
+ // Sanitize filename
+ $safeName = preg_replace('/[^A-Za-z0-9._\-]/', '_', $filename);
+ $safeName = mb_substr((string)$safeName, 0, 100);
+
+ $db = dbnmDb();
+ $db->prepare(
+ 'INSERT INTO case_documents
+ (user_id, filename, storage_path, size_bytes, ocr_status, qdrant_collection, azure_index_name, uploaded_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, NOW())'
+ )->execute([
+ $userId, $safeName, '', $sizeBytes, 'pending',
+ 'case_user_' . $userId,
+ AzureSearchAdmin::indexName($userId),
+ ]);
+ $docId = (int)$db->lastInsertId();
+
+ $finalPath = $dir . '/' . $docId . '.pdf';
+ if (!@rename($tempPath, $finalPath)) {
+ // Fallback: copy + unlink
+ if (!@copy($tempPath, $finalPath)) {
+ $db->prepare('DELETE FROM case_documents WHERE id = ?')->execute([$docId]);
+ throw new RuntimeException('Kunne ikke lagre filen på serveren.');
+ }
+ @unlink($tempPath);
+ }
+ @chmod($finalPath, 0640);
+
+ // Save final path + bump storage usage
+ $db->prepare('UPDATE case_documents SET storage_path = ? WHERE id = ?')
+ ->execute([$finalPath, $docId]);
+ $db->prepare('UPDATE user_tool_credits SET storage_used_bytes = storage_used_bytes + ? WHERE user_id = ?')
+ ->execute([$sizeBytes, $userId]);
+
+ return [
+ 'doc_id' => $docId,
+ 'filename' => $safeName,
+ 'storage_path' => $finalPath,
+ 'size_bytes' => $sizeBytes,
+ ];
+ }
+
+ /** Notify n8n that a new doc is ready for OCR + indexing. */
+ public static function caseEnqueueIngest(int $docId, int $userId): bool
+ {
+ $webhookUrl = getenv('N8N_CASE_INGEST_WEBHOOK') ?: '';
+ if ($webhookUrl === '') {
+ error_log('[CaseStore] N8N_CASE_INGEST_WEBHOOK not configured — leaving doc ' . $docId . ' as pending');
+ return false;
+ }
+ $payload = json_encode([
+ 'doc_id' => $docId,
+ 'user_id' => $userId,
+ 'callback_url' => 'https://tools.dobetternorge.no/api/case/ingest-callback.php',
+ ], JSON_UNESCAPED_UNICODE);
+
+ $ch = curl_init($webhookUrl);
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $payload,
+ CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 5,
+ ]);
+ curl_exec($ch);
+ $errno = curl_errno($ch);
+ $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ curl_close($ch);
+ return $errno === 0 && $status >= 200 && $status < 300;
+ }
+
+ /**
+ * Hybrid search across the user's case.
+ * Embeds the query via LiteLLM (azure-text-embedding-3-small) and hits the per-user Azure Search index.
+ *
+ * CRITICAL: $userId here must be the EFFECTIVE owner_user_id (resolved via caseResolveClientId).
+ * The Azure index is scoped to this user_id at the INDEX NAME level — cross-user leak is structurally
+ * impossible: index "case-100" cannot return rows from index "case-200".
+ */
+ public static function caseHybridSearch(int $effectiveOwnerUserId, string $query, int $k = 5): array
+ {
+ if ($effectiveOwnerUserId <= 0 || trim($query) === '') {
+ return [];
+ }
+ try {
+ $vector = self::embedQuery($query);
+ if (empty($vector)) {
+ return [];
+ }
+ $admin = new AzureSearchAdmin();
+ $resp = $admin->hybridSearch($effectiveOwnerUserId, $query, $vector, $k);
+ $hits = [];
+ foreach (($resp['value'] ?? []) as $hit) {
+ $hits[] = [
+ 'chunk_text' => (string)($hit['chunk_text'] ?? ''),
+ 'filename' => (string)($hit['filename'] ?? ''),
+ 'page' => (int)($hit['page'] ?? 0),
+ 'doc_id' => (int)($hit['doc_id'] ?? 0),
+ 'doc_type' => (string)($hit['doc_type'] ?? ''),
+ 'score' => (float)($hit['@search.score'] ?? 0),
+ 'reranker_score' => (float)($hit['@search.rerankerScore'] ?? 0),
+ ];
+ }
+ return $hits;
+ } catch (Throwable $e) {
+ error_log('[CaseStore::caseHybridSearch] failed: ' . $e->getMessage());
+ return [];
+ }
+ }
+
+ /** Embed a string via LiteLLM (azure-text-embedding-3-small). Returns float[] of dim 1536, or []. */
+ public static function embedQuery(string $text): array
+ {
+ $base = getenv('LITELLM_BASE_URL') ?: 'http://10.0.1.10:4000';
+ $key = getenv('LITELLM_API_KEY') ?: 'sk-bnl-litellm-26xR9mK4qvN3wL8sTj7pB2d';
+ $payload = json_encode([
+ 'model' => 'azure-text-embedding-3-small',
+ 'input' => mb_substr($text, 0, 8000),
+ ], JSON_UNESCAPED_UNICODE);
+
+ $ch = curl_init($base . '/v1/embeddings');
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $payload,
+ CURLOPT_HTTPHEADER => [
+ 'Authorization: Bearer ' . $key,
+ 'Content-Type: application/json',
+ ],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 15,
+ ]);
+ $raw = curl_exec($ch);
+ $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ curl_close($ch);
+ if ($status !== 200 || !is_string($raw)) {
+ return [];
+ }
+ $data = json_decode($raw, true);
+ $vec = $data['data'][0]['embedding'] ?? null;
+ return is_array($vec) ? array_map('floatval', $vec) : [];
+ }
+
+ /** Format chunks for injection into an agent's system prompt. */
+ public static function formatChunksForPrompt(array $chunks): string
+ {
+ if (empty($chunks)) return '';
+ $out = "\n\n## Brukerens egne dokumenter (private sak):\n";
+ foreach ($chunks as $i => $c) {
+ $out .= sprintf(
+ "\n[%d] %s · side %d%s\n%s\n",
+ $i + 1,
+ $c['filename'],
+ $c['page'],
+ $c['doc_type'] !== '' ? ' · ' . $c['doc_type'] : '',
+ mb_substr($c['chunk_text'], 0, 1500)
+ );
+ }
+ $out .= "\n— slutt på brukerens dokumenter —\n";
+ return $out;
+ }
+
+ /** Soft-delete a doc + remove vectors from Azure index. */
+ public static function deleteDocument(int $userId, int $docId): bool
+ {
+ $db = dbnmDb();
+ $stmt = $db->prepare('SELECT id, storage_path, size_bytes FROM case_documents WHERE id = ? AND user_id = ? AND deleted_at IS NULL LIMIT 1');
+ $stmt->execute([$docId, $userId]);
+ $doc = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!$doc) {
+ return false;
+ }
+ // Remove from Azure index
+ try {
+ $admin = new AzureSearchAdmin();
+ $admin->deleteDoc($userId, $docId);
+ } catch (Throwable $e) {
+ error_log('[CaseStore::deleteDocument] azure delete: ' . $e->getMessage());
+ }
+ // Mark deleted in DB
+ $db->prepare('UPDATE case_documents SET deleted_at = NOW() WHERE id = ?')->execute([$docId]);
+ // Refund storage
+ $db->prepare('UPDATE user_tool_credits SET storage_used_bytes = GREATEST(0, storage_used_bytes - ?) WHERE user_id = ?')
+ ->execute([(int)$doc['size_bytes'], $userId]);
+ // Remove file from disk
+ if (!empty($doc['storage_path']) && is_file($doc['storage_path'])) {
+ @unlink($doc['storage_path']);
+ }
+ return true;
+ }
+
+ /** Return all docs for a user (excluding deleted). */
+ public static function listDocs(int $userId): array
+ {
+ $db = dbnmDb();
+ $stmt = $db->prepare(
+ 'SELECT id, filename, size_bytes, page_count, doc_type, detected_date, ocr_status, ocr_error, uploaded_at, indexed_at
+ FROM case_documents WHERE user_id = ? AND deleted_at IS NULL ORDER BY uploaded_at DESC'
+ );
+ $stmt->execute([$userId]);
+ return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ }
+}
diff --git a/includes/DeepResearchAgent.php b/includes/DeepResearchAgent.php
index 094d9de..c3c3877 100644
--- a/includes/DeepResearchAgent.php
+++ b/includes/DeepResearchAgent.php
@@ -980,10 +980,11 @@ PROMPT;
'next_practical_step' => 'Try widening slice selection or rephrasing with more specific statutory or party terms.',
],
'deploy_label' => match($engine) {
- 'gpu' => 'GPU (cuttlefish)',
- 'dbn_legal' => 'dbn-legal-agent-v2',
- 'azure_full'=> 'gpt-4o',
- default => $this->azure->chatDeployment(),
+ 'gpu' => 'GPU (cuttlefish)',
+ 'dbn_legal' => 'dbn-legal-agent-v2',
+ 'dbn_legal_v3' => 'dbn-legal-agent-v3',
+ 'azure_full' => 'gpt-4o',
+ default => $this->azure->chatDeployment(),
},
];
}
@@ -1121,7 +1122,11 @@ PROMPT;
$opts = ['json' => true, 'temperature' => $synthTemp, 'max_tokens' => 4000, 'timeout' => 180];
try {
- if ($engine === 'dbn_legal') {
+ if ($engine === 'dbn_legal_v3') {
+ $response = dbnToolsCallGpuLlm($messages, array_merge($opts, ['model' => 'dbn-legal-agent-v3', 'timeout' => 180]));
+ $deployLabel = 'dbn-legal-agent-v3';
+ $raw = (string)($response['choices'][0]['message']['content'] ?? '');
+ } elseif ($engine === 'dbn_legal') {
$response = dbnToolsCallGpuLlm($messages, array_merge($opts, ['model' => 'dbn-legal-agent-v2', 'timeout' => 180]));
$deployLabel = 'dbn-legal-agent-v2';
$raw = (string)($response['choices'][0]['message']['content'] ?? '');
diff --git a/includes/DiscrepancyAgent.php b/includes/DiscrepancyAgent.php
index 92eaa97..089dd7f 100644
--- a/includes/DiscrepancyAgent.php
+++ b/includes/DiscrepancyAgent.php
@@ -754,9 +754,10 @@ PROMPT;
$locale = dbnToolsLanguageName($language);
$sourceCount = count($numberedSources);
$deployLabel = match ($engine) {
- 'gpu' => 'GPU (cuttlefish)',
- 'azure_full' => 'gpt-4o',
- default => $this->azure->chatDeployment(),
+ 'gpu' => 'GPU (cuttlefish)',
+ 'dbn_legal_v3' => 'dbn-legal-agent-v3',
+ 'azure_full' => 'gpt-4o',
+ default => $this->azure->chatDeployment(),
};
if (empty($numberedSources)) {
@@ -863,7 +864,10 @@ PROMPT;
$raw = '';
try {
- if ($engine === 'gpu') {
+ if ($engine === 'dbn_legal_v3') {
+ $response = dbnToolsCallGpuLlm($messages, array_merge($opts, ['model' => 'dbn-legal-agent-v3', 'timeout' => 180]));
+ $raw = (string)($response['choices'][0]['message']['content'] ?? '');
+ } elseif ($engine === 'gpu') {
$response = dbnToolsCallGpuLlm($messages, $opts);
$raw = (string)($response['choices'][0]['message']['content'] ?? '');
} elseif ($engine === 'azure_full') {
diff --git a/includes/FreeTier.php b/includes/FreeTier.php
index 9bf38a1..529348b 100644
--- a/includes/FreeTier.php
+++ b/includes/FreeTier.php
@@ -2,18 +2,22 @@
declare(strict_types=1);
/**
- * Credit system for free-tier (Google-authenticated) users of tools.dobetternorge.no.
+ * Credit + tier system for users of tools.dobetternorge.no.
*
- * Credits are stored in dobetternorge_maindb.user_tool_credits.
- * Usage is logged in user_tool_usage_log.
+ * Tables:
+ * user_tool_credits — balance (monthly, resets), bonus_balance (never expires), tier, Stripe links
+ * user_tool_usage_log — every tool call with credits_used
+ * user_subscriptions — Stripe subscription ledger
+ *
+ * Effective balance = balance + bonus_balance.
+ * Spend order: deduct from balance first, overflow to bonus_balance.
+ * pro_plus tier bypasses balance checks (still subject to hourly cap).
*
* CaveauAI client sessions (dbn_tools_user_id + client_id) bypass all checks.
- * Only SSO sessions with tier='free' are subject to limits.
+ * Only SSO sessions are subject to limits.
*/
final class FreeTier
{
- private const HOURLY_LIMIT = 10;
-
private const COSTS = [
'corpus-search' => 0,
'search' => 0,
@@ -24,8 +28,33 @@ final class FreeTier
'barnevernet' => 3,
'advocate' => 3,
'deep-research' => 5,
- 'transcribe' => 2, // flat rate; actual duration unknown upfront
- 'discrepancy' => 4, // 2 docs × 4 extraction steps + cross-ref + synthesis
+ 'transcribe' => 2,
+ 'discrepancy' => 4,
+ 'korrespond' => 3,
+ ];
+
+ /** Monthly credit allowance per tier. pro_plus is "effectively unlimited" but hourly-capped. */
+ private const MONTHLY_ALLOWANCE = [
+ 'free' => 30,
+ 'light' => 120,
+ 'pro' => 500,
+ 'pro_plus' => 999999,
+ ];
+
+ /** Hourly rate-limit per tier (number of paid tool calls per rolling hour). */
+ private const HOURLY_CAP = [
+ 'free' => 10,
+ 'light' => 15,
+ 'pro' => 30,
+ 'pro_plus' => 50,
+ ];
+
+ /** Per-user case-storage quota in bytes. */
+ private const STORAGE_QUOTA = [
+ 'free' => 0,
+ 'light' => 104857600, // 100 MB
+ 'pro' => 1073741824, // 1 GB
+ 'pro_plus' => 10737418240, // 10 GB
];
/** Credit cost for a given tool slug. Returns 1 for unknown tools. */
@@ -34,94 +63,269 @@ final class FreeTier
return self::COSTS[$tool] ?? 1;
}
+ public static function monthlyAllowance(string $tier): int
+ {
+ return self::MONTHLY_ALLOWANCE[$tier] ?? self::MONTHLY_ALLOWANCE['free'];
+ }
+
+ public static function hourlyCap(string $tier): int
+ {
+ return self::HOURLY_CAP[$tier] ?? self::HOURLY_CAP['free'];
+ }
+
+ public static function storageQuota(string $tier): int
+ {
+ return self::STORAGE_QUOTA[$tier] ?? 0;
+ }
+
+ /** Fetch a user's tier (defaults to 'free' if no row). */
+ public static function tier(int $userId): string
+ {
+ $row = self::row($userId);
+ return $row['tier'] ?? 'free';
+ }
+
+ /** Fetch the full credits row, applying lazy monthly reset. */
+ public static function row(int $userId): ?array
+ {
+ $db = dbnmDb();
+ // Auto-refill monthly balance based on tier-specific allowance, only if a new calendar month has begun.
+ $db->prepare(
+ "UPDATE user_tool_credits
+ SET balance = CASE tier
+ WHEN 'free' THEN " . self::MONTHLY_ALLOWANCE['free'] . "
+ WHEN 'light' THEN " . self::MONTHLY_ALLOWANCE['light'] . "
+ WHEN 'pro' THEN " . self::MONTHLY_ALLOWANCE['pro'] . "
+ WHEN 'pro_plus' THEN " . self::MONTHLY_ALLOWANCE['pro_plus'] . "
+ ELSE balance END,
+ last_reset = CURDATE()
+ WHERE user_id = ?
+ AND (YEAR(last_reset) < YEAR(CURDATE()) OR MONTH(last_reset) < MONTH(CURDATE()))"
+ )->execute([$userId]);
+
+ $stmt = $db->prepare('SELECT * FROM user_tool_credits WHERE user_id = ? LIMIT 1');
+ $stmt->execute([$userId]);
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ return is_array($row) ? $row : null;
+ }
+
/**
* Check whether the user may proceed with a tool call.
- * Handles monthly reset automatically.
*
- * Returns ['ok' => true, 'balance' => int]
- * or ['ok' => false, 'balance' => int, 'reason' => 'no_credits'|'rate_limit']
+ * Returns:
+ * ['ok' => true, 'balance' => int, 'bonus_balance' => int, 'tier' => string]
+ * ['ok' => false, 'balance' => int, 'bonus_balance' => int, 'tier' => string,
+ * 'reason' => 'no_credits'|'rate_limit']
*/
public static function check(int $userId, string $tool): array
{
$db = dbnmDb();
$cost = self::cost($tool);
+ $row = self::row($userId);
- // Auto-reset balance if a new month has begun since last reset
- $db->prepare(
- 'UPDATE user_tool_credits
- SET balance = allowance, last_reset = CURDATE()
- WHERE user_id = ?
- AND (YEAR(last_reset) < YEAR(CURDATE()) OR MONTH(last_reset) < MONTH(CURDATE()))'
- )->execute([$userId]);
-
- $row = $db->prepare(
- 'SELECT balance FROM user_tool_credits WHERE user_id = ? LIMIT 1'
- );
- $row->execute([$userId]);
- $credits = $row->fetch(PDO::FETCH_ASSOC);
-
- if ($credits === false) {
- // No credits row — treat as 0 balance (shouldn't happen after ensureFreeTierCredits)
- return ['ok' => false, 'balance' => 0, 'reason' => 'no_credits'];
+ if ($row === null) {
+ return [
+ 'ok' => false, 'balance' => 0, 'bonus_balance' => 0,
+ 'tier' => 'free', 'reason' => 'no_credits',
+ ];
}
- $balance = (int)$credits['balance'];
+ $balance = (int)$row['balance'];
+ $bonus = (int)$row['bonus_balance'];
+ $tier = (string)$row['tier'];
- // Free tools always pass
- if ($cost === 0) {
- return ['ok' => true, 'balance' => $balance];
- }
+ $base = [
+ 'balance' => $balance,
+ 'bonus_balance' => $bonus,
+ 'tier' => $tier,
+ ];
- if ($balance < $cost) {
- return ['ok' => false, 'balance' => $balance, 'reason' => 'no_credits'];
- }
-
- // Hourly rate limit check (counts any tool that costs > 0)
- $hourlyCount = $db->prepare(
+ // Hourly rate limit (always applies, even to pro_plus)
+ $stmt = $db->prepare(
'SELECT COUNT(*) FROM user_tool_usage_log
WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR) AND credits_used > 0'
);
- $hourlyCount->execute([$userId]);
- if ((int)$hourlyCount->fetchColumn() >= self::HOURLY_LIMIT) {
- return ['ok' => false, 'balance' => $balance, 'reason' => 'rate_limit'];
+ $stmt->execute([$userId]);
+ $hourly = (int)$stmt->fetchColumn();
+ if ($hourly >= self::hourlyCap($tier)) {
+ return $base + ['ok' => false, 'reason' => 'rate_limit'];
}
- return ['ok' => true, 'balance' => $balance];
+ // Free tool (cost=0) always passes credit check
+ if ($cost === 0) {
+ return $base + ['ok' => true];
+ }
+
+ // pro_plus bypasses credit check
+ if ($tier === 'pro_plus') {
+ return $base + ['ok' => true];
+ }
+
+ if (($balance + $bonus) < $cost) {
+ return $base + ['ok' => false, 'reason' => 'no_credits'];
+ }
+
+ return $base + ['ok' => true];
}
/**
* Deduct credits for a completed tool call and log the usage.
- * Safe to call even when cost is 0 (logs the call but deducts nothing).
+ * Spends from `balance` first, then `bonus_balance`.
+ * pro_plus tier logs the call but does not deduct.
+ *
+ * Returns the new effective balance (balance + bonus_balance).
*/
public static function deduct(int $userId, string $tool): int
{
$db = dbnmDb();
$cost = self::cost($tool);
+ $row = self::row($userId);
+ $tier = $row['tier'] ?? 'free';
+
+ if ($cost > 0 && $tier !== 'pro_plus' && $row !== null) {
+ $balance = (int)$row['balance'];
+ $bonus = (int)$row['bonus_balance'];
+
+ $fromBalance = min($cost, $balance);
+ $fromBonus = $cost - $fromBalance;
- if ($cost > 0) {
$db->prepare(
'UPDATE user_tool_credits
- SET balance = GREATEST(0, balance - ?)
+ SET balance = GREATEST(0, balance - ?),
+ bonus_balance = GREATEST(0, bonus_balance - ?)
WHERE user_id = ?'
- )->execute([$cost, $userId]);
+ )->execute([$fromBalance, $fromBonus, $userId]);
}
$db->prepare(
'INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)'
)->execute([$userId, $tool, $cost]);
- $row = $db->prepare('SELECT balance FROM user_tool_credits WHERE user_id = ? LIMIT 1');
- $row->execute([$userId]);
- $r = $row->fetch(PDO::FETCH_ASSOC);
- return $r ? (int)$r['balance'] : 0;
+ $latest = self::row($userId);
+ return $latest ? ((int)$latest['balance'] + (int)$latest['bonus_balance']) : 0;
+ }
+
+ /** Effective balance (monthly + bonus). */
+ public static function balance(int $userId): int
+ {
+ $row = self::row($userId);
+ return $row ? ((int)$row['balance'] + (int)$row['bonus_balance']) : 0;
+ }
+
+ /** Detailed balance breakdown for UI rendering. */
+ public static function balanceDetail(int $userId): array
+ {
+ $row = self::row($userId);
+ if (!$row) {
+ return ['balance' => 0, 'bonus_balance' => 0, 'tier' => 'free'];
+ }
+ return [
+ 'balance' => (int)$row['balance'],
+ 'bonus_balance' => (int)$row['bonus_balance'],
+ 'tier' => (string)$row['tier'],
+ 'storage_used_bytes' => (int)($row['storage_used_bytes'] ?? 0),
+ 'storage_quota_bytes' => self::storageQuota((string)$row['tier']),
+ 'survey_completed_at' => $row['survey_completed_at'] ?? null,
+ 'subscription_period_end' => $row['subscription_period_end'] ?? null,
+ ];
}
/**
- * Current balance for a user (after any pending monthly reset).
+ * Award one-time bonus credits (survey reward, Stripe topup, manual grant).
+ * Source is logged via user_tool_usage_log with a negative credits_used value.
*/
- public static function balance(int $userId): int
+ public static function awardBonus(int $userId, int $credits, string $source): int
{
- $result = self::check($userId, 'corpus-search'); // cost=0, triggers reset if needed
- return $result['balance'];
+ if ($credits <= 0) {
+ return self::balance($userId);
+ }
+ $db = dbnmDb();
+ self::ensureRow($userId);
+ $db->prepare('UPDATE user_tool_credits SET bonus_balance = bonus_balance + ? WHERE user_id = ?')
+ ->execute([$credits, $userId]);
+ $db->prepare('INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)')
+ ->execute([$userId, 'bonus:' . substr($source, 0, 40), -$credits]);
+ return self::balance($userId);
+ }
+
+ /**
+ * Set or upgrade a user's tier (called by Stripe subscription webhook).
+ * Refills monthly balance to the new tier's allowance.
+ */
+ public static function setTier(
+ int $userId,
+ string $tier,
+ ?string $stripeCustomerId,
+ ?string $subscriptionId,
+ ?string $periodEndIso
+ ): void {
+ $db = dbnmDb();
+ self::ensureRow($userId);
+ $allowance = self::monthlyAllowance($tier);
+ $db->prepare(
+ 'UPDATE user_tool_credits
+ SET tier = ?, balance = ?, allowance = ?,
+ stripe_customer_id = COALESCE(?, stripe_customer_id),
+ subscription_id = ?,
+ subscription_period_end = ?,
+ last_reset = CURDATE()
+ WHERE user_id = ?'
+ )->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $userId]);
+ }
+
+ /**
+ * Refill monthly balance at subscription renewal (invoice.paid).
+ * Does not touch bonus_balance.
+ */
+ public static function refillForRenewal(int $userId, string $tier, ?string $periodEndIso): void
+ {
+ $db = dbnmDb();
+ $allowance = self::monthlyAllowance($tier);
+ $db->prepare(
+ 'UPDATE user_tool_credits
+ SET balance = ?,
+ subscription_period_end = ?,
+ last_reset = CURDATE()
+ WHERE user_id = ?'
+ )->execute([$allowance, $periodEndIso, $userId]);
+ }
+
+ /**
+ * Revert a user to free tier (subscription canceled or fully ended).
+ * Preserves bonus_balance and case_documents (handled by 90-day cron).
+ */
+ public static function clearTier(int $userId): void
+ {
+ $db = dbnmDb();
+ $db->prepare(
+ 'UPDATE user_tool_credits
+ SET tier = ?, allowance = ?, subscription_id = NULL, subscription_period_end = NULL
+ WHERE user_id = ?'
+ )->execute(['free', self::monthlyAllowance('free'), $userId]);
+ }
+
+ /** Mark survey as completed so the bonus can only be claimed once per account. */
+ public static function markSurveyCompleted(int $userId): void
+ {
+ $db = dbnmDb();
+ self::ensureRow($userId);
+ $db->prepare('UPDATE user_tool_credits SET survey_completed_at = NOW() WHERE user_id = ?')
+ ->execute([$userId]);
+ }
+
+ public static function hasCompletedSurvey(int $userId): bool
+ {
+ $row = self::row($userId);
+ return !empty($row['survey_completed_at']);
+ }
+
+ /** Create the user_tool_credits row if missing (idempotent). */
+ public static function ensureRow(int $userId): void
+ {
+ $db = dbnmDb();
+ $db->prepare(
+ 'INSERT IGNORE INTO user_tool_credits (user_id, balance, allowance, tier, last_reset, created_at)
+ VALUES (?, ?, ?, ?, CURDATE(), NOW())'
+ )->execute([$userId, self::monthlyAllowance('free'), self::monthlyAllowance('free'), 'free']);
}
}
diff --git a/includes/KorrespondAgent.php b/includes/KorrespondAgent.php
index 9bd9026..f4fec25 100644
--- a/includes/KorrespondAgent.php
+++ b/includes/KorrespondAgent.php
@@ -330,6 +330,18 @@ PROMPT;
$parts[] = ' • ' . $key . ': ' . $value;
}
}
+
+ // Inject Min Sak (private case corpus) hits if user opted in and is on a paid tier.
+ if (!empty($intake['use_my_case'])) {
+ $caseQuery = trim((string)($intake['goal'] ?? '') . ' ' . (string)($intake['narrative'] ?? ''));
+ if ($caseQuery !== '') {
+ $caseBlock = dbnToolsCaseContext(true, $caseQuery, 5);
+ if ($caseBlock !== '') {
+ $parts[] = $caseBlock;
+ }
+ }
+ }
+
return mb_substr(implode("\n\n", $parts), 0, self::MAX_CONTEXT_CHARS, 'UTF-8');
}
diff --git a/includes/StripeClient.php b/includes/StripeClient.php
new file mode 100644
index 0000000..b51ece4
--- /dev/null
+++ b/includes/StripeClient.php
@@ -0,0 +1,234 @@
+secretKey = $secretKey ?? self::config('STRIPE_SECRET_KEY');
+ if ($this->secretKey === '') {
+ throw new RuntimeException('StripeClient: STRIPE_SECRET_KEY not configured.');
+ }
+ }
+
+ /** Load a Stripe config value from /etc/bnl/stripe.php OR env. */
+ public static function config(string $key): string
+ {
+ static $fileConfig = null;
+ if ($fileConfig === null) {
+ $path = '/etc/bnl/stripe.php';
+ $fileConfig = is_readable($path) ? (require $path) : [];
+ if (!is_array($fileConfig)) {
+ $fileConfig = [];
+ }
+ }
+
+ $envValue = getenv($key);
+ if ($envValue !== false && $envValue !== '') {
+ return $envValue;
+ }
+ return (string)($fileConfig[$key] ?? '');
+ }
+
+ /** Map an internal SKU to a Stripe price ID. */
+ public static function priceId(string $sku): string
+ {
+ static $map = null;
+ if ($map === null) {
+ $map = [
+ 'topup_s' => self::config('STRIPE_PRICE_TOPUP_S'),
+ 'topup_m' => self::config('STRIPE_PRICE_TOPUP_M'),
+ 'topup_l' => self::config('STRIPE_PRICE_TOPUP_L'),
+ 'light' => self::config('STRIPE_PRICE_LIGHT'),
+ 'pro' => self::config('STRIPE_PRICE_PRO'),
+ 'pro_plus' => self::config('STRIPE_PRICE_PRO_PLUS'),
+ ];
+ }
+ $id = $map[$sku] ?? '';
+ if ($id === '') {
+ throw new InvalidArgumentException("Unknown Stripe SKU: {$sku}");
+ }
+ return $id;
+ }
+
+ /** Topup credit grants — must match values shown on pricing.php. */
+ public static function topupCredits(string $sku): int
+ {
+ return match ($sku) {
+ 'topup_s' => 30,
+ 'topup_m' => 100,
+ 'topup_l' => 300,
+ default => 0,
+ };
+ }
+
+ /** Map a Stripe price ID back to the internal subscription tier (light/pro/pro_plus). */
+ public static function tierForPrice(string $priceId): ?string
+ {
+ foreach (['light', 'pro', 'pro_plus'] as $tier) {
+ if (self::config('STRIPE_PRICE_' . strtoupper($tier)) === $priceId) {
+ return $tier;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Create a Checkout Session.
+ *
+ * @param array $params Stripe parameters (flat form-encoded — see request() docs).
+ */
+ public function createCheckoutSession(array $params): array
+ {
+ return $this->request('POST', '/checkout/sessions', $params);
+ }
+
+ /** Create a Customer Portal session for self-serve subscription management. */
+ public function createPortalSession(string $customerId, string $returnUrl): array
+ {
+ return $this->request('POST', '/billing_portal/sessions', [
+ 'customer' => $customerId,
+ 'return_url' => $returnUrl,
+ ]);
+ }
+
+ /** Retrieve a subscription. */
+ public function getSubscription(string $subscriptionId): array
+ {
+ return $this->request('GET', '/subscriptions/' . urlencode($subscriptionId));
+ }
+
+ /** Find-or-create a Stripe customer for a given email. */
+ public function ensureCustomer(string $email, ?int $userId = null): string
+ {
+ $found = $this->request('GET', '/customers', ['email' => $email, 'limit' => 1]);
+ if (!empty($found['data'][0]['id'])) {
+ return (string)$found['data'][0]['id'];
+ }
+ $params = ['email' => $email];
+ if ($userId !== null) {
+ $params['metadata'] = ['user_id' => (string)$userId];
+ }
+ $created = $this->request('POST', '/customers', $params);
+ return (string)($created['id'] ?? '');
+ }
+
+ /**
+ * Verify a Stripe webhook signature.
+ * Stripe-Signature header format: t=,v1=[,v1=...]
+ */
+ public static function verifyWebhookSignature(string $payload, string $sigHeader, string $secret, int $toleranceSeconds = 300): bool
+ {
+ if ($secret === '' || $sigHeader === '') {
+ return false;
+ }
+ $parts = [];
+ foreach (explode(',', $sigHeader) as $pair) {
+ $kv = explode('=', $pair, 2);
+ if (count($kv) === 2) {
+ $parts[trim($kv[0])][] = trim($kv[1]);
+ }
+ }
+ $timestamp = isset($parts['t'][0]) ? (int)$parts['t'][0] : 0;
+ $sigs = $parts['v1'] ?? [];
+ if ($timestamp === 0 || empty($sigs)) {
+ return false;
+ }
+ if (abs(time() - $timestamp) > $toleranceSeconds) {
+ return false;
+ }
+ $signedPayload = $timestamp . '.' . $payload;
+ $expected = hash_hmac('sha256', $signedPayload, $secret);
+ foreach ($sigs as $sig) {
+ if (hash_equals($expected, $sig)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Low-level HTTP request to Stripe API. Returns decoded JSON or throws on error.
+ * Stripe uses form-encoded bodies even for nested params (foo[bar]=baz).
+ */
+ public function request(string $method, string $path, array $params = []): array
+ {
+ $url = self::API_BASE . $path;
+ $method = strtoupper($method);
+ $headers = [
+ 'Authorization: Bearer ' . $this->secretKey,
+ 'Stripe-Version: 2024-10-28.acacia',
+ ];
+
+ $ch = curl_init();
+ $body = '';
+ if ($method === 'GET' && !empty($params)) {
+ $url .= '?' . self::flattenFormParams($params);
+ } elseif (!empty($params)) {
+ $body = self::flattenFormParams($params);
+ $headers[] = 'Content-Type: application/x-www-form-urlencoded';
+ }
+
+ curl_setopt($ch, CURLOPT_URL, $url);
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+ if ($body !== '') {
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
+ }
+
+ $raw = curl_exec($ch);
+ $errno = curl_errno($ch);
+ $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ curl_close($ch);
+
+ if ($errno !== 0 || $raw === false) {
+ throw new RuntimeException('Stripe curl error: ' . curl_strerror($errno));
+ }
+
+ $decoded = json_decode((string)$raw, true);
+ if (!is_array($decoded)) {
+ throw new RuntimeException('Stripe response was not JSON: ' . substr((string)$raw, 0, 200));
+ }
+ if ($status >= 400) {
+ $msg = $decoded['error']['message'] ?? 'Unknown Stripe error';
+ $code = $decoded['error']['code'] ?? 'unknown';
+ throw new RuntimeException("Stripe API error ({$status}/{$code}): {$msg}");
+ }
+ return $decoded;
+ }
+
+ /** Flatten nested arrays into Stripe's form-encoding scheme (foo[bar]=baz). */
+ private static function flattenFormParams(array $params, string $prefix = ''): string
+ {
+ $pairs = [];
+ foreach ($params as $key => $value) {
+ $name = $prefix === '' ? (string)$key : $prefix . '[' . $key . ']';
+ if (is_array($value)) {
+ $pairs[] = self::flattenFormParams($value, $name);
+ } elseif (is_bool($value)) {
+ $pairs[] = rawurlencode($name) . '=' . ($value ? 'true' : 'false');
+ } elseif ($value !== null) {
+ $pairs[] = rawurlencode($name) . '=' . rawurlencode((string)$value);
+ }
+ }
+ return implode('&', $pairs);
+ }
+}
diff --git a/includes/bootstrap.php b/includes/bootstrap.php
index 1495329..e2869c6 100644
--- a/includes/bootstrap.php
+++ b/includes/bootstrap.php
@@ -420,18 +420,20 @@ function dbnmDb(): PDO
}
/**
- * True when the current session is a free-tier SSO user (Google login).
- * False for CaveauAI client sessions (always unlimited).
+ * True when the current session belongs to an SSO user (Google login).
+ * All SSO sessions go through the credit + tier system (free, light, pro, pro_plus).
+ * False for CaveauAI client sessions, which bypass all credit checks.
+ *
+ * Note: name is historical — paid SSO users are also subject to the credit gate.
*/
function dbnToolsIsFreeTier(): bool
{
return !empty($_SESSION['dbn_tools_authenticated'])
- && !empty($_SESSION['dbn_tools_sso_uid'])
- && ($_SESSION['dbn_tools_tier'] ?? '') === 'free';
+ && !empty($_SESSION['dbn_tools_sso_uid']);
}
/**
- * Enforce free-tier credit gate before a tool call.
+ * Enforce credit + tier gate before a tool call.
* Exits with JSON 402/429 if the user is over limit or out of credits.
* No-op for CaveauAI sessions.
*
@@ -449,16 +451,20 @@ function dbnToolsFreeTierCheck(string $tool): int
if (!$result['ok']) {
$isRateLimit = ($result['reason'] ?? '') === 'rate_limit';
+ $tier = (string)($result['tier'] ?? 'free');
+ $cap = FreeTier::hourlyCap($tier);
http_response_code($isRateLimit ? 429 : 402);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode([
'ok' => false,
'error' => ['code' => $result['reason'], 'message' => $isRateLimit
- ? 'Rate limit reached — you can make up to 10 requests per hour on the free tier.'
- : 'No credits remaining. Your 10 free credits reset on the 1st of next month.',
+ ? "Rate limit reached — your tier ({$tier}) allows {$cap} requests per hour."
+ : 'No credits remaining. See /pricing.php to top up or upgrade.',
],
'balance' => $result['balance'],
+ 'bonus_balance' => $result['bonus_balance'] ?? 0,
+ 'tier' => $tier,
], JSON_UNESCAPED_UNICODE);
exit;
}
@@ -466,6 +472,83 @@ function dbnToolsFreeTierCheck(string $tool): int
return $uid;
}
+/** Return the current SSO user's tier, or 'free' if not SSO / no row. */
+function dbnToolsCurrentTier(): string
+{
+ if (!dbnToolsIsFreeTier()) {
+ return 'caveau';
+ }
+ require_once __DIR__ . '/FreeTier.php';
+ return FreeTier::tier((int)$_SESSION['dbn_tools_sso_uid']);
+}
+
+/**
+ * Inject "Min Sak" context into an agent's system prompt.
+ *
+ * Returns a system-prompt fragment with the top-k hybrid-search hits from the user's
+ * private case corpus, or an empty string if the user is not paid / has no docs / opted out.
+ *
+ * CRITICAL: this resolves family-plan members to their OWNER's user_id so the search hits
+ * the shared OWNER's index. Index-level isolation makes cross-user leak structurally impossible.
+ *
+ * Pattern (for agents):
+ * $caseBlock = dbnToolsCaseContext($intake['use_my_case'] ?? false, $userQuery);
+ * if ($caseBlock !== '') { $systemPrompt .= $caseBlock; }
+ */
+function dbnToolsCaseContext(bool $useMyCase, string $query, int $k = 5): string
+{
+ if (!$useMyCase) return '';
+ if (!dbnToolsIsFreeTier()) return '';
+ $userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
+ if ($userId <= 0) return '';
+
+ require_once __DIR__ . '/FreeTier.php';
+ $tier = FreeTier::tier($userId);
+ if (!in_array($tier, ['light', 'pro', 'pro_plus'], true)) return '';
+
+ require_once __DIR__ . '/CaseStore.php';
+ $effective = CaseStore::caseResolveClientId($userId);
+ $chunks = CaseStore::caseHybridSearch($effective, $query, $k);
+
+ // Audit log: who ran what against whose case
+ try {
+ $db = dbnmDb();
+ $db->prepare(
+ 'INSERT INTO case_tool_runs (user_id, tool, used_my_case, case_chunks_retrieved, doc_ids, ip_hash, created_at)
+ VALUES (?, ?, 1, ?, ?, ?, NOW())'
+ )->execute([
+ $userId,
+ (string)(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function'] ?? 'unknown'),
+ count($chunks),
+ json_encode(array_values(array_unique(array_map(fn($c) => (int)$c['doc_id'], $chunks))), JSON_UNESCAPED_UNICODE),
+ hash('sha256', ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0') . '|case'),
+ ]);
+ } catch (Throwable $e) { /* audit log is non-fatal */ }
+
+ return CaseStore::formatChunksForPrompt($chunks);
+}
+
+/** Read /etc/bnl/intersite.php for the HMAC secret shared between dobetternorge.no and tools.dobetternorge.no. */
+function dbnToolsIntersiteSecret(): string
+{
+ static $secret = null;
+ if ($secret !== null) {
+ return $secret;
+ }
+ $envValue = (string)(dbnToolsEnv('INTERSITE_HMAC_SECRET') ?? '');
+ if ($envValue !== '') {
+ return $secret = $envValue;
+ }
+ $path = '/etc/bnl/intersite.php';
+ if (is_readable($path)) {
+ $cfg = require $path;
+ if (is_array($cfg) && !empty($cfg['INTERSITE_HMAC_SECRET'])) {
+ return $secret = (string)$cfg['INTERSITE_HMAC_SECRET'];
+ }
+ }
+ return $secret = '';
+}
+
/**
* Deduct credits after a successful tool call.
* The $uid returned from dbnToolsFreeTierCheck() must be passed in.
@@ -863,24 +946,43 @@ function dbnToolsRunLegalCheck(string $brief, string $docType): array
}
$opts = [
- 'model' => 'dbn-legal-agent-v2',
+ 'model' => 'dbn-legal-agent-v3',
'temperature' => 0.1,
'max_tokens' => 350,
'timeout' => 120,
// No 'json' key — plain narrative, no response_format flag
];
+ $sysMsg = 'Du er en ekspert på norsk barnevernsloven og EMD-praksis. Svar alltid på norsk med korrekt juridisk terminologi. Bruk terskler fra barnevernsloven 2021: § 4-25 krever «klar nødvendighet». Strand Lobben mot Norge (37283/13) setter krav om rehabiliteringsplan før adopsjon. Aldri oppfinn paragrafnumre, saksnumre eller dommernavn.';
+
+ $text = '';
try {
$response = dbnToolsCallGpuLlm(
[
- ['role' => 'system', 'content' => 'Du er en ekspert på norsk barnevernsloven og EMD-praksis. Svar alltid på norsk med korrekt juridisk terminologi. Bruk terskler fra barnevernsloven 2021: § 4-25 krever «klar nødvendighet». Strand Lobben mot Norge (37283/13) setter krav om rehabiliteringsplan før adopsjon. Aldri oppfinn paragrafnumre, saksnumre eller dommernavn.'],
+ ['role' => 'system', 'content' => $sysMsg],
['role' => 'user', 'content' => $question],
],
$opts
);
$text = trim((string)($response['choices'][0]['message']['content'] ?? ''));
} catch (Throwable $e) {
- return [];
+ // v3 unavailable — fall back to qwen2.5:7b as safety net
+ }
+
+ // Fallback: if v3 timed out or returned empty, retry with qwen2.5:7b
+ if (empty($text) || str_word_count($text) < 8) {
+ try {
+ $fallback = dbnToolsCallGpuLlm(
+ [
+ ['role' => 'system', 'content' => $sysMsg],
+ ['role' => 'user', 'content' => $question],
+ ],
+ array_merge($opts, ['model' => 'qwen2.5:7b', 'timeout' => 60])
+ );
+ $text = trim((string)($fallback['choices'][0]['message']['content'] ?? ''));
+ } catch (Throwable $e) {
+ return [];
+ }
}
if (empty($text) || str_word_count($text) < 15) {
@@ -898,7 +1000,7 @@ function dbnToolsRunLegalCheck(string $brief, string $docType): array
'legal_basis' => dbnToolsExtractCheckLegalBasis($clean),
'source_refs' => [],
'what_to_check'=> 'Verifiser med norsk familieretsadvokat',
- 'check_model' => 'dbn-legal-agent-v2',
+ 'check_model' => 'dbn-legal-agent-v3',
]];
}
diff --git a/min-sak.php b/min-sak.php
new file mode 100644
index 0000000..21ab60b
--- /dev/null
+++ b/min-sak.php
@@ -0,0 +1,228 @@
+
+
+ Min Sak — Do Better Norge
+
+
+
+
Min Sak — bygg din egen sak
+
Last opp dokumentene fra saken din én gang, og la alle verktøyene jobbe på din private korpus.
+
+ Hva er forskjellen mellom månedlige kreditter og bonuskreditter?
+
Månedlige kreditter (fra abonnement eller gratis tier) tilbakestilles første hver måned. Bonuskreditter (fra undersøkelsen eller topp-opp) utløper aldri og brukes etter de månedlige er oppbrukt.
+
+
+ Hva er Min Sak?
+
Min Sak er din private dokumentbank. Last opp PDF-er fra saken din, så blir de OCR-ert, analysert og lagret i din egen sikre korpus. Alle verktøyene kan deretter referere til dine egne dokumenter i stedet for bare generisk lov.
+
+
+ Hvor er dataene mine lagret?
+
Alt innenfor EU: servere i Falkenstein (Tyskland) og Helsinki (Finland), AI-tjenester i Vest-Europa og Norge Øst. Vi er hostet hos Hetzner og bruker Microsoft Azure for AI. Stripe behandler betalinger gjennom Irland.
+
+
+ Kan jeg dele en konto med advokaten min?
+
Ja — Pro+ Familie inkluderer 3 plasser. Du kan invitere advokat, samboer eller en annen familiemedlem. Alle ser de samme dokumentene, men hvem som gjorde hva blir logget.
+
+
+ Hva skjer hvis jeg sier opp?
+
Du faller tilbake til gratis-tier. Bonuskredittene dine beholdes. Dokumentene i Min Sak oppbevares i 90 dager før de slettes — så du har tid til å eksportere dem eller fornye.
+
+
+ Tilbyr dere refusjon?
+
Ja, full refusjon innen 7 dager hvis du ikke er fornøyd. Send oss en e-post.