status = $status; $this->errorCode = $errorCode; $this->extra = $extra; } } function dbnToolsLoadEnv(string $path): void { if (!is_file($path) || !is_readable($path)) { return; } $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); if ($lines === false) { return; } foreach ($lines as $line) { $line = trim($line); if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) { continue; } [$key, $value] = explode('=', $line, 2); $key = trim($key); $value = trim($value); if ($key === '') { continue; } if ((str_starts_with($value, '"') && str_ends_with($value, '"')) || (str_starts_with($value, "'") && str_ends_with($value, "'"))) { $value = substr($value, 1, -1); } if (getenv($key) === false) { putenv($key . '=' . $value); $_ENV[$key] = $value; } } } dbnToolsLoadEnv(DBN_TOOLS_ROOT . '/.env'); function dbnToolsEnv(string $key, ?string $default = null): ?string { $fileKey = $key . '_FILE'; $filePath = getenv($fileKey); if ($filePath !== false && $filePath !== '') { $value = @file_get_contents($filePath); if ($value === false) { throw new RuntimeException("Unable to read secret file for {$fileKey}"); } return rtrim($value, "\r\n"); } $value = getenv($key); if ($value === false || $value === '') { return $default; } return $value; } function dbnToolsIsHttps(): bool { if (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off') { return true; } return isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower((string)$_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https'; } function dbnToolsStartSession(): void { if (session_status() === PHP_SESSION_ACTIVE) { return; } session_name('dbn_tools_session'); session_set_cookie_params([ 'lifetime' => 0, 'path' => '/', 'secure' => dbnToolsIsHttps(), 'httponly' => true, 'samesite' => 'Lax', ]); session_start(); if (empty($_SESSION['dbn_tools_anon_id'])) { $_SESSION['dbn_tools_anon_id'] = bin2hex(random_bytes(16)); } } dbnToolsStartSession(); require_once __DIR__ . '/i18n.php'; function dbnToolsIsAuthenticated(): bool { // SSO session established via dobetternorge.no signed token if (!empty($_SESSION['dbn_tools_authenticated']) && !empty($_SESSION['dbn_tools_sso_uid'])) { return true; } // Regular Caveau session return !empty($_SESSION['dbn_tools_authenticated']) && !empty($_SESSION['dbn_tools_user_id']) && !empty($_SESSION['dbn_tools_client_id']) && (string)($_SESSION['dbn_tools_client_slug'] ?? '') === dbnToolsClientSlug(); } function dbnToolsSafeReturnPath(mixed $value, string $default = '/dashboard.php'): string { $return = trim((string)$value); if ($return === '' || !str_starts_with($return, '/') || str_starts_with($return, '//')) { return $default; } if (preg_match('/[\r\n]/', $return)) { return $default; } return $return; } function dbnToolsMainLoginUrl(?string $returnPath = null): string { $return = dbnToolsSafeReturnPath($returnPath ?? ($_SERVER['REQUEST_URI'] ?? '/dashboard.php'), '/dashboard.php'); return 'https://dobetternorge.no/tools-login.php?return=' . urlencode($return); } function dbnToolsRequirePageAuth(?string $returnPath = null): void { if (dbnToolsIsAuthenticated()) { return; } header('Location: ' . dbnToolsMainLoginUrl($returnPath)); exit; } /** * Validates a signed SSO token from dobetternorge.no. * Returns the decoded payload array or null on failure. */ function dbnToolsValidateSsoToken(string $token, string $secret): ?array { $parts = explode('.', $token, 2); if (count($parts) !== 2) return null; [$payload, $sig] = $parts; if (!hash_equals(hash_hmac('sha256', $payload, $secret), $sig)) return null; $data = json_decode(base64_decode(strtr($payload, '-_', '+/')), true); if (!is_array($data) || ($data['exp'] ?? 0) < time()) return null; if (empty($data['tools_approved'])) return null; return $data; } function dbnToolsAuthenticatedUser(): ?array { if (!dbnToolsIsAuthenticated()) { return null; } return [ 'user_id' => isset($_SESSION['dbn_tools_user_id']) ? (int)$_SESSION['dbn_tools_user_id'] : null, 'client_id' => isset($_SESSION['dbn_tools_client_id']) ? (int)$_SESSION['dbn_tools_client_id'] : null, 'email' => (string)($_SESSION['dbn_tools_user_email'] ?? ''), 'name' => (string)($_SESSION['dbn_tools_user_name'] ?? ''), 'role' => (string)($_SESSION['dbn_tools_user_role'] ?? ''), ]; } /** * Operator gate for the LLM-engine admin. True when the authenticated session is the * tenant owner, or when their email is listed in the DBN_ADMIN_EMAILS allowlist (so an * SSO operator can self-authorise without an 'owner' client_users row). */ function dbnToolsIsOwner(): bool { if (!dbnToolsIsAuthenticated()) { return false; } if (strtolower((string)($_SESSION['dbn_tools_user_role'] ?? '')) === 'owner') { return true; } $email = strtolower(trim((string)($_SESSION['dbn_tools_user_email'] ?? $_SESSION['dbn_tools_sso_email'] ?? ''))); if ($email === '') { return false; } $allow = array_filter(array_map('trim', explode(',', strtolower((string)(dbnToolsEnv('DBN_ADMIN_EMAILS', '') ?? ''))))); return in_array($email, $allow, true); } /** * Look up an operator engine override for a tool + scope from dbn_tool_engine_config * (dobetternorge_maindb). Prefers a tool-specific row, then a '*' all-tools row. * Returns null when no enabled override exists. Statically cached per request; safe * (returns null) when the table is absent, so behaviour is unchanged pre-migration. * * scope: tier_quick | tier_pro | legacy | persona_family | persona_general */ function dbnToolsEngineOverride(string $tool, string $scope): ?string { static $cache = null; if ($cache === null) { $cache = []; try { $rows = dbnmDb()->query('SELECT tool_slug, scope, engine FROM dbn_tool_engine_config WHERE enabled = 1')->fetchAll(); foreach ($rows as $r) { $cache[$r['tool_slug'] . '|' . $r['scope']] = (string)$r['engine']; } } catch (Throwable $e) { $cache = []; // table missing / DB down → no overrides } } return $cache[$tool . '|' . $scope] ?? $cache['*|' . $scope] ?? null; } /** Reviewer/persona model ids an operator override may select (validated against this). */ function dbnToolsReviewerModelKeys(): array { return ['gpt-4o', 'gpt-4o-mini', 'dbn-legal-agent-v3', 'dbn-legal-agent', 'dobetter-norge-v4', 'qwen2.5:14b']; } /** True if $model is an accepted reviewer/persona model id. */ function dbnToolsIsValidReviewerModel(string $model): bool { return in_array($model, dbnToolsReviewerModelKeys(), true); } function dbnToolsRequiredPackageSlug(): string { return dbnToolsEnv('DBN_CAVEAU_PACKAGE_SLUG') ?: 'family-legal'; } function dbnToolsAnonymousSessionId(): string { $id = (string)($_SESSION['dbn_tools_anon_id'] ?? ''); if ($id === '') { $id = bin2hex(random_bytes(16)); $_SESSION['dbn_tools_anon_id'] = $id; } return substr(hash('sha256', $id), 0, 18); } function dbnToolsRespond(array $payload, int $status = 200): void { http_response_code($status); header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store'); echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); exit; } function dbnToolsError(string $message, int $status = 400, string $code = 'bad_request', array $extra = []): void { dbnToolsRespond(array_merge([ 'ok' => false, 'error' => [ 'code' => $code, 'message' => $message, ], ], $extra), $status); } function dbnToolsAbort(string $message, int $status = 400, string $code = 'bad_request', array $extra = []): void { throw new DbnToolsHttpException($message, $status, $code, $extra); } function dbnToolsRequireMethod(string $method): void { if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== strtoupper($method)) { dbnToolsError('Method not allowed.', 405, 'method_not_allowed'); } } function dbnToolsRequireAuth(): void { if (!dbnToolsIsAuthenticated()) { dbnToolsError('Caveau session required.', 401, 'session_required'); } } function dbnToolsJsonInput(int $maxBytes = 50000): array { $raw = file_get_contents('php://input'); if ($raw === false) { dbnToolsError('Unable to read request body.', 400, 'body_unreadable'); } if (strlen($raw) > $maxBytes) { dbnToolsError('Request body is too large for this tool.', 413, 'body_too_large'); } $data = json_decode($raw, true); if (!is_array($data)) { dbnToolsError('Request body must be valid JSON.', 400, 'invalid_json'); } return $data; } function dbnToolsNormalizeLanguage(mixed $value): string { return dbnToolsNormalizeUiLanguage($value); } function dbnToolsNormalizeRegion(mixed $value): string { $region = strtolower(trim((string)$value)); return in_array($region, ['nordic', 'european', 'echr', 'global'], true) ? $region : 'nordic'; } function dbnToolsString(array $input, string $key, int $maxChars, bool $required = true): string { $value = trim((string)($input[$key] ?? '')); if ($required && $value === '') { dbnToolsAbort("Missing required field: {$key}.", 422, 'missing_field'); } if (mb_strlen($value, 'UTF-8') > $maxChars) { dbnToolsAbort("Field {$key} is too long.", 422, 'field_too_long'); } return $value; } function dbnToolsSupportDir(): string { $dir = dbnToolsEnv('DBN_TOOLS_SUPPORT_DIR'); if ($dir === null || trim($dir) === '') { $dir = rtrim(sys_get_temp_dir(), "\\/") . DIRECTORY_SEPARATOR . 'dbn-tools'; } if (!is_dir($dir)) { @mkdir($dir, 0770, true); } return $dir; } function dbnToolsMetadataLogPath(): string { return dbnToolsEnv('DBN_TOOLS_METADATA_LOG') ?: dbnToolsSupportDir() . DIRECTORY_SEPARATOR . 'metadata.jsonl'; } function dbnToolsLogMetadata(array $entry): void { $path = dbnToolsMetadataLogPath(); $safe = [ 'timestamp' => gmdate('c'), 'session' => dbnToolsAnonymousSessionId(), 'tool' => (string)($entry['tool'] ?? 'unknown'), 'latency_ms' => (int)($entry['latency_ms'] ?? 0), 'language' => (string)($entry['language'] ?? ''), 'ok' => (bool)($entry['ok'] ?? false), 'error_code' => $entry['error_code'] ?? null, 'chunk_count' => (int)($entry['chunk_count'] ?? 0), 'source_count' => (int)($entry['source_count'] ?? 0), 'deployment' => $entry['deployment'] ?? dbnToolsEnv('DBN_AZURE_OPENAI_CHAT_DEPLOYMENT'), ]; @file_put_contents( $path, json_encode($safe, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND | LOCK_EX ); } function dbnToolsWithTelemetry(string $tool, string $language, callable $handler): void { $start = microtime(true); try { $payload = $handler(); $latency = (int)round((microtime(true) - $start) * 1000); $payload['ok'] = $payload['ok'] ?? true; $payload['latency_ms'] = $latency; dbnToolsLogMetadata([ 'tool' => $tool, 'language' => $language, 'ok' => true, 'latency_ms' => $latency, 'chunk_count' => (int)($payload['trace_metadata']['chunk_count'] ?? 0), 'source_count' => (int)($payload['trace_metadata']['source_count'] ?? 0), 'deployment' => $payload['trace_metadata']['deployment'] ?? null, ]); dbnToolsRespond($payload); } catch (DbnToolsHttpException $e) { $latency = (int)round((microtime(true) - $start) * 1000); dbnToolsLogMetadata([ 'tool' => $tool, 'language' => $language, 'ok' => false, 'latency_ms' => $latency, 'error_code' => $e->errorCode, ]); dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra); } catch (Throwable $e) { $latency = (int)round((microtime(true) - $start) * 1000); dbnToolsLogMetadata([ 'tool' => $tool, 'language' => $language, 'ok' => false, 'latency_ms' => $latency, 'error_code' => 'internal_error', ]); error_log('DBN tools error: ' . $e->getMessage()); dbnToolsError('The tool could not complete this request.', 500, 'internal_error'); } } function dbnToolsWithChargedTelemetry(string $tool, string $language, int $creditUserId, callable $handler, ?int $credits = null, array $creditMetadata = []): void { $start = microtime(true); try { $payload = $handler(); if ($creditUserId > 0) { $balance = $credits === null ? dbnToolsFreeTierDeduct($creditUserId, $tool) : dbnToolsFreeTierDeductAmount($creditUserId, $tool, $credits, $creditMetadata); if ($balance >= 0 && !headers_sent()) { header('X-Credits-Remaining: ' . $balance); } $payload['balance'] = $balance; } $latency = (int)round((microtime(true) - $start) * 1000); $payload['ok'] = $payload['ok'] ?? true; $payload['latency_ms'] = $latency; dbnToolsLogMetadata([ 'tool' => $tool, 'language' => $language, 'ok' => true, 'latency_ms' => $latency, 'chunk_count' => (int)($payload['trace_metadata']['chunk_count'] ?? 0), 'source_count' => (int)($payload['trace_metadata']['source_count'] ?? 0), 'deployment' => $payload['trace_metadata']['deployment'] ?? null, ]); dbnToolsRespond($payload); } catch (DbnToolsHttpException $e) { $latency = (int)round((microtime(true) - $start) * 1000); dbnToolsLogMetadata([ 'tool' => $tool, 'language' => $language, 'ok' => false, 'latency_ms' => $latency, 'error_code' => $e->errorCode, ]); dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra); } catch (Throwable $e) { $latency = (int)round((microtime(true) - $start) * 1000); dbnToolsLogMetadata([ 'tool' => $tool, 'language' => $language, 'ok' => false, 'latency_ms' => $latency, 'error_code' => 'internal_error', ]); error_log('DBN tools error: ' . $e->getMessage()); dbnToolsError('The tool could not complete this request.', 500, 'internal_error'); } } function dbnToolsAiPortalRoot(): string { $root = dbnToolsEnv('DBN_AI_PORTAL_ROOT'); if ($root !== null && trim($root) !== '') { return rtrim($root, "\\/"); } return dirname(DBN_TOOLS_ROOT) . DIRECTORY_SEPARATOR . 'ai-portal'; } function dbnToolsBootCaveau(): void { static $booted = false; if ($booted) { return; } $root = dbnToolsAiPortalRoot(); $dbFile = $root . DIRECTORY_SEPARATOR . 'admin' . DIRECTORY_SEPARATOR . 'includes' . DIRECTORY_SEPARATOR . 'db.php'; $ragFile = $root . DIRECTORY_SEPARATOR . 'platform' . DIRECTORY_SEPARATOR . 'includes' . DIRECTORY_SEPARATOR . 'client_rag.php'; $agentFile = $root . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'ai' . DIRECTORY_SEPARATOR . 'DbnLegalAgent.php'; if (!is_file($dbFile) || !is_file($ragFile)) { dbnToolsAbort('CaveauAI platform files are not available. Check DBN_AI_PORTAL_ROOT.', 503, 'caveau_unavailable'); } require_once $dbFile; require_once $ragFile; if (is_file($agentFile)) { require_once $agentFile; } $booted = true; } /** * Load caveauAI's chat-profile resolver (the persona bridge). DBN reads client 57's * saved chat profiles in-process to drive tools by persona instead of hardcoding * family law. Returns false when the platform file or resolver functions are absent * (callers then fall back to the family-legal package). */ function dbnToolsBootChatProfiles(): bool { static $loaded = null; if ($loaded !== null) { return $loaded; } dbnToolsBootCaveau(); // ensures db.php + platform autoload context $root = dbnToolsAiPortalRoot(); $cpFile = $root . DIRECTORY_SEPARATOR . 'platform' . DIRECTORY_SEPARATOR . 'includes' . DIRECTORY_SEPARATOR . 'chat_profiles.php'; if (!is_file($cpFile)) { $loaded = false; return false; } require_once $cpFile; $loaded = function_exists('cpFetchProfileForUser') && function_exists('cpResolveRuntime'); return $loaded; } /** Default persona slug (back-compat: family). Override via .env DBN_DEFAULT_PERSONA. */ function dbnToolsDefaultPersonaSlug(): string { $v = trim((string)(dbnToolsEnv('DBN_DEFAULT_PERSONA', 'family') ?? 'family')); return $v !== '' ? $v : 'family'; } /** Product display name for tool-facing copy. Override via .env DBN_PRODUCT_NAME. */ function dbnToolsProductName(): string { $v = trim((string)(dbnToolsEnv('DBN_PRODUCT_NAME', 'Do Better Legal') ?? 'Do Better Legal')); return $v !== '' ? $v : 'Do Better Legal'; } /** * Engine track for a persona slug: * 'family' → family + child-welfare, served/verified by the fine-tuned Qwen. * 'general' → all other Norwegian-law personas, served/verified by the interim engine. */ function dbnToolsPersonaTrack(?string $slug): string { $slug = strtolower(trim((string)$slug)); return in_array($slug, ['family', 'child-welfare'], true) ? 'family' : 'general'; } /** * Reviewer/verification model for a persona's track. * Family track → fine-tuned Qwen (dbn-legal-agent-v3). * General track → interim gpt-4o; swap to the 2nd fine-tune later via .env * DBN_REVIEW_MODEL_GENERAL with no code change. */ function dbnToolsReviewerModel(?string $slug = null): string { if (dbnToolsPersonaTrack($slug) === 'family') { $override = dbnToolsEngineOverride('*', 'persona_family'); if ($override !== null && dbnToolsIsValidReviewerModel($override)) { return $override; } return trim((string)(dbnToolsEnv('DBN_REVIEW_MODEL_FAMILY', 'dbn-legal-agent-v3') ?? 'dbn-legal-agent-v3')) ?: 'dbn-legal-agent-v3'; } $override = dbnToolsEngineOverride('*', 'persona_general'); if ($override !== null && dbnToolsIsValidReviewerModel($override)) { return $override; } return trim((string)(dbnToolsEnv('DBN_REVIEW_MODEL_GENERAL', 'gpt-4o') ?? 'gpt-4o')) ?: 'gpt-4o'; } /** * Resolve a DBN persona (a caveauAI chat profile for client 57) into a normalized * runtime bundle the tools can act on: * ['slug','name','package_ids','package','system_prompt','model','search_method', * 'chunk_limit','rag_opts','source'] * * - source='chat_profile' when a seeded persona was found and resolved. * - source='fallback' when no persona exists yet (pre-seed): the legacy family-legal * package is used so behaviour is unchanged. * model is null when the persona doesn't pin one (caller keeps its default routing). */ function dbnToolsResolvePersona(int $clientId, ?string $slug = null): array { $slug = trim((string)($slug ?? '')) ?: dbnToolsDefaultPersonaSlug(); $resolved = null; if (dbnToolsBootChatProfiles()) { try { $db = dbnToolsDb(); $user = ['client_id' => $clientId]; $row = cpFetchProfileForUser($db, $user, $slug); if ($row) { $runtime = cpResolveRuntime($db, $row, []); $packageIds = array_values(array_filter( array_map('intval', $runtime['package_ids'] ?? []), static fn(int $id): bool => $id > 0 )); $package = null; if ($packageIds) { $pkgStmt = $db->prepare('SELECT * FROM corpus_packages WHERE id = ? LIMIT 1'); $pkgStmt->execute([$packageIds[0]]); $package = $pkgStmt->fetch(PDO::FETCH_ASSOC) ?: null; } $chunkLimit = (int)($runtime['chunk_limit'] ?? 8); $ragOpts = (is_array($runtime['retrieval_tuning'] ?? null) && function_exists('cpRetrievalTuningToRagOptions')) ? cpRetrievalTuningToRagOptions($runtime['retrieval_tuning'], $chunkLimit) : []; $resolved = [ 'slug' => $slug, 'name' => (string)$row['name'], 'package_ids' => $packageIds, 'package' => $package, 'system_prompt' => ($runtime['system_prompt'] ?? '') !== '' ? (string)$runtime['system_prompt'] : null, 'model' => trim((string)($runtime['model'] ?? '')) ?: null, 'search_method' => (string)($runtime['search_method'] ?? 'keyword'), 'chunk_limit' => $chunkLimit, 'rag_opts' => $ragOpts, 'source' => 'chat_profile', ]; } } catch (Throwable $e) { error_log('[dbn-persona] resolve failed for slug ' . $slug . ': ' . $e->getMessage()); } } if ($resolved === null) { // Pre-seed / missing persona → legacy family-legal package (unchanged behaviour). $package = dbnToolsFetchPackage('family-legal'); if (!$package || empty($package['is_active'])) { dbnToolsAbort('No persona profile and the family-legal corpus package is not active.', 503, 'package_unavailable'); } if (!dbnToolsHasActiveSubscription($clientId, (int)$package['id'])) { dbnToolsAbort(dbnToolsProductName() . ' does not have an active corpus subscription.', 503, 'subscription_missing'); } $resolved = [ 'slug' => 'family', 'name' => 'Family Law', 'package_ids' => [(int)$package['id']], 'package' => $package, 'system_prompt' => null, 'model' => null, 'search_method' => 'keyword', 'chunk_limit' => 8, 'rag_opts' => [], 'source' => 'fallback', ]; } return $resolved; } /** List enabled DBN personas (client 57 chat profiles) for the persona picker / dbn.list_personas. */ function dbnToolsListPersonas(int $clientId): array { if (!dbnToolsBootChatProfiles()) { return []; } try { $db = dbnToolsDb(); $rows = cpFetchProfilesForUser($db, ['client_id' => $clientId], false); } catch (Throwable $e) { error_log('[dbn-persona] list failed: ' . $e->getMessage()); return []; } return array_map(static function (array $r): array { return [ 'slug' => (string)$r['slug'], 'name' => (string)$r['name'], 'description' => (string)($r['description'] ?? ''), 'model' => (string)($r['model'] ?? ''), ]; }, $rows); } /** * 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(); try { return getDb(); } catch (Throwable $e) { throw new DbnToolsHttpException('CaveauAI database is not reachable.', 503, 'db_unavailable'); } } function dbnToolsRagDb(): PDO { dbnToolsBootCaveau(); try { return getRagDb(); } catch (Throwable $e) { throw new DbnToolsHttpException('CaveauAI corpus database is not reachable.', 503, 'rag_db_unavailable'); } } /** * PDO connection to dobetternorge_maindb (user accounts + credits). * Credentials come from DBNM_DB_* env vars. */ function dbnmDb(): PDO { static $pdo = null; if ($pdo !== null) { return $pdo; } $host = dbnToolsEnv('DBNM_DB_HOST', 'localhost'); $name = dbnToolsEnv('DBNM_DB_NAME', 'dobetternorge_maindb'); $user = dbnToolsEnv('DBNM_DB_USER', 'root'); $pass = dbnToolsEnv('DBNM_DB_PASS', ''); try { $pdo = new PDO( "mysql:host={$host};dbname={$name};charset=utf8mb4", $user, $pass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ] ); } catch (PDOException $e) { throw new DbnToolsHttpException('Credit database is not reachable.', 503, 'credit_db_unavailable'); } return $pdo; } function dbnToolsMainUserProfile(int $userId): ?array { if ($userId <= 0) { return null; } try { $stmt = dbnmDb()->prepare( 'SELECT id, username, display_name, email, phone, address_line1, address_line2, postal_code, city, address_region, country, preferred_language, profile_prompt_dismissed_at FROM users WHERE id = ? LIMIT 1' ); $stmt->execute([$userId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return is_array($row) ? $row : null; } catch (Throwable $e) { return null; } } function dbnToolsProfileNeedsPrompt(?array $profile): bool { if (!$profile || !empty($profile['profile_prompt_dismissed_at'])) { return false; } foreach (['display_name', 'phone', 'address_line1', 'postal_code', 'city', 'country'] as $field) { if (trim((string)($profile[$field] ?? '')) === '') { return true; } } return false; } /** * True when the current session belongs to an SSO user (Google login). * All SSO sessions go through the credit + tier system (free, plus, pro). * 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']); } /** * Current free-tier SSO user id, or 0 for CaveauAI / non-SSO sessions. * Lets an endpoint resolve a quality tier (subscription gate) BEFORE charging, * without reaching into the session directly. Mirrors the uid the check/deduct * helpers use. */ function dbnToolsFreeTierUid(): int { return dbnToolsIsFreeTier() ? (int)$_SESSION['dbn_tools_sso_uid'] : 0; } /** * Resolve the model + credit gate for a tool run. When the request carries a `tier` * param, honour the Quick/Pro quality tier (subscription-gated, server-priced); otherwise * fall back to the legacy engine-selector behaviour. Runs the credit gate (exits 402/429 * if over limit) and returns the context an endpoint needs to run + deduct. * * @return array{tier:string,engine:string,credits:?int,ftUid:int,metadata:array} */ function dbnToolsResolveToolRun(string $tool, array $input, string $legacyDefaultEngine = 'azure_mini'): array { if (isset($input['tier'])) { $res = ToolModels::resolveTier(dbnToolsFreeTierUid(), $tool, (string)$input['tier']); $scope = $res['tier'] === 'pro' ? 'tier_pro' : 'tier_quick'; $override = dbnToolsEngineOverride($tool, $scope); if ($override !== null && ToolModels::isValidTierEngine($override)) { $res['engine'] = $override; // credits stay tied to the tier; only the engine swaps } $ftUid = dbnToolsFreeTierCheckAmount($tool, $res['credits']); return [ 'tier' => $res['tier'], 'engine' => $res['engine'], 'credits' => $res['credits'], 'ftUid' => $ftUid, 'metadata' => ['tier' => $res['tier'], 'engine' => $res['engine']], ]; } $ftUid = dbnToolsFreeTierCheck($tool); $requested = (string)($input['engine'] ?? $legacyDefaultEngine); $override = dbnToolsEngineOverride($tool, 'legacy'); if ($override !== null && ToolModels::isValidTierEngine($override)) { $requested = $override; // still passes through engineForUser tier gating below } $engine = ToolModels::engineForUser($ftUid, $requested); return [ 'tier' => '', 'engine' => $engine, 'credits' => null, 'ftUid' => $ftUid, 'metadata' => [], ]; } /** * 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. * * @return int The user_id for use in dbnToolsFreeTierDeduct() */ function dbnToolsFreeTierCheck(string $tool): int { if (!dbnToolsIsFreeTier()) { return 0; } require_once __DIR__ . '/FreeTier.php'; $uid = (int)$_SESSION['dbn_tools_sso_uid']; $result = FreeTier::check($uid, $tool); 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 — 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; } return $uid; } function dbnToolsFreeTierCheckAmount(string $tool, int $credits): int { if (!dbnToolsIsFreeTier()) { return 0; } require_once __DIR__ . '/FreeTier.php'; $uid = (int)$_SESSION['dbn_tools_sso_uid']; $result = FreeTier::checkAmount($uid, $tool, max(0, $credits)); 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 - 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, 'cost' => $result['cost'] ?? $credits, ], JSON_UNESCAPED_UNICODE); exit; } 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) { $GLOBALS['dbn_last_case_doc_ids'] = []; return ''; } if (!dbnToolsIsFreeTier()) { $GLOBALS['dbn_last_case_doc_ids'] = []; return ''; } $userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0); if ($userId <= 0) { $GLOBALS['dbn_last_case_doc_ids'] = []; return ''; } require_once __DIR__ . '/FreeTier.php'; $tier = FreeTier::tier($userId); if (!FreeTier::isPaidTier($tier)) { $GLOBALS['dbn_last_case_doc_ids'] = []; return ''; } require_once __DIR__ . '/CaseStore.php'; $effective = CaseStore::caseResolveClientId($userId); $chunks = CaseStore::caseHybridSearch($effective, $query, $k); $GLOBALS['dbn_last_case_doc_ids'] = array_values(array_unique(array_map( static fn($c) => (int)($c['doc_id'] ?? 0), $chunks ))); // 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); } /** Returns the case_document ids retrieved by the most recent dbnToolsCaseContext() call in this request. */ function dbnToolsLastCaseDocIds(): array { return is_array($GLOBALS['dbn_last_case_doc_ids'] ?? null) ? $GLOBALS['dbn_last_case_doc_ids'] : []; } /** 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. * No-op when uid === 0 (non-free-tier session). * * @return int Remaining balance (for response header) */ function dbnToolsFreeTierDeduct(int $uid, string $tool): int { if ($uid === 0) { return -1; } require_once __DIR__ . '/FreeTier.php'; return FreeTier::deduct($uid, $tool); } function dbnToolsFreeTierDeductAmount(int $uid, string $tool, int $credits, array $metadata = []): int { if ($uid === 0) { return -1; } require_once __DIR__ . '/FreeTier.php'; return FreeTier::deductAmount($uid, $tool, $credits, $metadata); } function dbnToolsClientSlug(): string { return dbnToolsEnv('DBN_CAVEAU_CLIENT_SLUG') ?: 'dobetter'; } function dbnToolsFetchClient(?PDO $db = null): ?array { $db = $db ?: dbnToolsDb(); $stmt = $db->prepare('SELECT * FROM clients WHERE slug = ? LIMIT 1'); $stmt->execute([dbnToolsClientSlug()]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ?: null; } function dbnToolsRequireClient(): array { $client = dbnToolsFetchClient(); if (!$client || empty($client['is_active'])) { dbnToolsAbort(dbnToolsProductName() . ' client tenant is not active or was not found.', 503, 'client_unavailable'); } return $client; } function dbnToolsFetchActiveClientUser(string $email, int $clientId, ?PDO $db = null): ?array { $db = $db ?: dbnToolsDb(); $stmt = $db->prepare( 'SELECT id, client_id, username, email, display_name, password_hash, role, is_active FROM client_users WHERE client_id = ? AND email = ? AND is_active = 1 LIMIT 1' ); $stmt->execute([$clientId, $email]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ?: null; } function dbnToolsCanUsePackage(int $clientId, string $packageSlug, ?PDO $db = null): array { $db = $db ?: dbnToolsDb(); $package = dbnToolsFetchPackage($packageSlug, $db); if (!$package || empty($package['is_active'])) { return [ 'ok' => false, 'status' => 503, 'code' => 'package_unavailable', 'message' => "The {$packageSlug} corpus package is not active.", ]; } if (!dbnToolsHasActiveSubscription($clientId, (int)$package['id'], $db)) { return [ 'ok' => false, 'status' => 403, 'code' => 'subscription_missing', 'message' => 'This Caveau workspace does not have access to the required corpus package.', ]; } return [ 'ok' => true, 'package' => $package, ]; } function dbnToolsFetchPackage(string $slug = 'family-legal', ?PDO $db = null): ?array { $db = $db ?: dbnToolsDb(); $stmt = $db->prepare('SELECT * FROM corpus_packages WHERE slug = ? LIMIT 1'); $stmt->execute([$slug]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ?: null; } function dbnToolsHasActiveSubscription(int $clientId, int $packageId, ?PDO $db = null): bool { $db = $db ?: dbnToolsDb(); $stmt = $db->prepare( 'SELECT COUNT(*) FROM client_corpus_subscriptions WHERE client_id = ? AND package_id = ? AND is_active = 1' ); $stmt->execute([$clientId, $packageId]); return (int)$stmt->fetchColumn() > 0; } function dbnToolsDisclaimer(string $language): string { $language = dbnToolsNormalizeUiLanguage($language); return match ($language) { 'no' => 'Juridisk informasjon og forberedelsesstøtte, ikke endelig juridisk rådgivning.', 'uk' => 'Юридична інформація та підтримка підготовки, не остаточна юридична порада.', 'pl' => 'Informacje prawne i wsparcie przygotowania, nie ostateczna porada prawna.', default => 'Legal information and preparation support, not final legal advice.', }; } function dbnToolsExcerpt(string $text, int $limit = 520): string { $text = preg_replace('/\s+/u', ' ', strip_tags($text)) ?? ''; $text = trim($text); if (mb_strlen($text, 'UTF-8') <= $limit) { return $text; } return rtrim(mb_substr($text, 0, $limit - 1, 'UTF-8')) . '…'; } const DBN_TOOLS_EXTRACT_MAX_BYTES = 8 * 1024 * 1024; const DBN_TOOLS_EXTRACT_TEXT_LIMIT = 128000; const DBN_TOOLS_TIMELINE_EXTRACT_TEXT_LIMIT = 600000; const DBN_TOOLS_EXTRACT_ALLOWED_EXTS = ['txt', 'pdf', 'docx', 'xlsx', 'pptx', 'html', 'htm', 'csv', 'md', 'json']; const DBN_TOOLS_EXTRACT_AUDIO_EXTS = ['mp3', 'wav', 'm4a', 'ogg', 'flac', 'webm']; const DBN_TOOLS_EXTRACT_IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'webp']; function dbnToolsExtractUploadedFile(array $file, int $textLimit = DBN_TOOLS_EXTRACT_TEXT_LIMIT): array { $errCode = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE); if ($errCode !== UPLOAD_ERR_OK) { $msg = match ($errCode) { UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The file exceeds the allowed size limit.', UPLOAD_ERR_NO_TMP_DIR => 'No temporary directory is available.', UPLOAD_ERR_CANT_WRITE => 'Unable to save the uploaded file.', default => 'File upload failed.', }; dbnToolsAbort($msg, 422, 'upload_error'); } $originalName = basename((string)($file['name'] ?? '')); $tmpPath = (string)($file['tmp_name'] ?? ''); $size = (int)($file['size'] ?? 0); if (!is_uploaded_file($tmpPath)) { dbnToolsAbort('Invalid file upload.', 400, 'invalid_upload'); } if ($size === 0) { dbnToolsAbort('The uploaded file is empty.', 422, 'file_empty'); } if ($size > DBN_TOOLS_EXTRACT_MAX_BYTES) { dbnToolsAbort('File exceeds the 8 MB limit.', 413, 'file_too_large'); } $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); if (!in_array($ext, DBN_TOOLS_EXTRACT_ALLOWED_EXTS, true)) { $allowed = strtoupper(implode(', .', DBN_TOOLS_EXTRACT_ALLOWED_EXTS)); dbnToolsAbort("Unsupported file type. Allowed: .{$allowed}.", 422, 'unsupported_type'); } $text = match ($ext) { 'txt', 'md', 'json' => dbnToolsExtractTxt($tmpPath), 'pdf' => dbnToolsExtractPdf($tmpPath), 'docx' => dbnToolsExtractDocx($tmpPath), 'html', 'htm' => dbnDmsExtractHtml($tmpPath), 'csv' => dbnDmsExtractCsv($tmpPath), 'xlsx' => dbnDmsExtractXlsx($tmpPath), 'pptx' => dbnDmsExtractPptx($tmpPath), default => dbnToolsExtractTxt($tmpPath), }; $text = trim($text); if ($text === '') { dbnToolsAbort('No text could be extracted from this file.', 422, 'no_text'); } $truncated = false; $textLimit = max(1000, min($textLimit, DBN_TOOLS_TIMELINE_EXTRACT_TEXT_LIMIT)); if (mb_strlen($text, 'UTF-8') > $textLimit) { $text = mb_substr($text, 0, $textLimit, 'UTF-8'); $truncated = true; } return [ 'ok' => true, 'text' => $text, 'filename' => $originalName, 'chars' => mb_strlen($text, 'UTF-8'), 'truncated' => $truncated, 'limit' => $textLimit, ]; } function dbnToolsExtractTxt(string $path): string { $content = file_get_contents($path); if ($content === false) { throw new DbnToolsHttpException('Unable to read the file.', 500, 'read_error'); } return mb_convert_encoding($content, 'UTF-8', 'UTF-8, ISO-8859-1, Windows-1252'); } function dbnToolsExtractPdf(string $path): string { $cmd = 'pdftotext ' . escapeshellarg($path) . ' - 2>/dev/null'; $output = shell_exec($cmd); if ($output === null || $output === false || trim($output) === '') { throw new DbnToolsHttpException( 'PDF text extraction failed. The file may be image-only or encrypted.', 422, 'pdf_extract_failed' ); } return $output; } function dbnToolsExtractDocx(string $path): string { $zip = new ZipArchive(); $result = $zip->open($path); if ($result !== true) { throw new DbnToolsHttpException('Unable to open the .docx file.', 422, 'docx_open_failed'); } $xml = $zip->getFromName('word/document.xml'); $zip->close(); if ($xml === false) { throw new DbnToolsHttpException('No document content found in this .docx file.', 422, 'docx_no_content'); } $doc = new DOMDocument(); libxml_use_internal_errors(true); $doc->loadXML($xml); libxml_clear_errors(); $xpath = new DOMXPath($doc); $xpath->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'); $paragraphs = []; foreach ($xpath->query('//w:p') as $para) { $runs = []; foreach ($xpath->query('.//w:t', $para) as $t) { $runs[] = $t->textContent; } $paragraphs[] = implode('', $runs); } return implode("\n", $paragraphs); } function dbnToolsCallGpuLlm(array $messages, array $options = []): array { $url = 'http://10.0.1.10:4000/v1/chat/completions'; $apiKey = (string)(dbnToolsEnv('LITELLM_MASTER_KEY') ?: 'sk-bnl-litellm-26xR9mK4qvN3wL8sTj7pB2d'); $model = (string)($options['model'] ?? 'qwen2.5:14b'); $timeout = (int)($options['timeout'] ?? 90); $payload = [ 'model' => $model, 'messages' => $messages, 'temperature' => $options['temperature'] ?? 0.1, 'max_tokens' => $options['max_tokens'] ?? 8000, ]; if (!empty($options['json'])) { $payload['response_format'] = ['type' => 'json_object']; } $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $headers = [ 'Content-Type: application/json', 'Authorization: Bearer ' . $apiKey, ]; if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => $timeout, ]); $response = curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $err = curl_error($ch); curl_close($ch); if ($response === false) { throw new RuntimeException('GPU LiteLLM request failed: ' . $err); } } else { $ctx = stream_context_create(['http' => [ 'method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => $body, 'timeout' => $timeout, 'ignore_errors' => true, ]]); $response = @file_get_contents($url, false, $ctx); $code = 0; if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) { $code = (int)$m[1]; } if ($response === false) { throw new RuntimeException('GPU LiteLLM request failed.'); } } $decoded = json_decode($response, true); if (!is_array($decoded)) { throw new RuntimeException('GPU LiteLLM returned non-JSON response.'); } if ($code < 200 || $code >= 300) { $msg = $decoded['error']['message'] ?? ('HTTP ' . $code); throw new RuntimeException('GPU LiteLLM error: ' . $msg); } return $decoded; } /** * Batch-embed texts via LiteLLM /v1/embeddings using cURL. * Returns an array of float[] indexed by input position. */ function dbnToolsLiteLLMEmbedBatch(array $texts, string $model = 'nomic-embed-text', int $timeout = 60): array { if (empty($texts)) { return []; } $url = 'http://10.0.1.10:4000/v1/embeddings'; $apiKey = (string)(dbnToolsEnv('LITELLM_MASTER_KEY') ?: 'sk-bnl-litellm-26xR9mK4qvN3wL8sTj7pB2d'); $payload = json_encode(['model' => $model, 'input' => array_values($texts)], JSON_UNESCAPED_UNICODE); $headers = [ 'Content-Type: application/json', 'Authorization: Bearer ' . $apiKey, ]; if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => $timeout, ]); $response = curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $err = curl_error($ch); curl_close($ch); if ($response === false) { throw new RuntimeException('LiteLLM embed request failed: ' . $err); } } else { $ctx = stream_context_create(['http' => [ 'method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => $payload, 'timeout' => $timeout, 'ignore_errors' => true, ]]); $response = @file_get_contents($url, false, $ctx); $code = 0; if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) { $code = (int)$m[1]; } if ($response === false) { throw new RuntimeException('LiteLLM embed request failed.'); } } $decoded = json_decode($response, true); if (!is_array($decoded)) { throw new RuntimeException('LiteLLM embed returned non-JSON.'); } if ($code < 200 || $code >= 300) { $msg = $decoded['error']['message'] ?? ('HTTP ' . $code); throw new RuntimeException('LiteLLM embed error: ' . $msg); } // OpenAI /v1/embeddings returns data[].embedding ordered by index $data = $decoded['data'] ?? []; usort($data, fn($a, $b) => ($a['index'] ?? 0) <=> ($b['index'] ?? 0)); return array_map(fn($d) => $d['embedding'], $data); } // ── dbn-legal-agent targeted check step ────────────────────────────────────── // // dbn-legal-agent is a QLoRA fine-tune trained for single-turn Q&A with a baked-in // "Kilder:" citation scaffold. It loops when asked to check a full brief (no trained // behavior for that task) but answers focused legal questions reliably in ~55s. // // Strategy: ask ONE targeted question per document type, cap tokens at 350 to cut // off before the tool-planning loop kicks in, parse the answer for legal findings. function dbnToolsRunLegalCheck(string $brief, string $docType, ?string $personaPrompt = null, ?string $personaSlug = null): array { $track = dbnToolsPersonaTrack($personaSlug); $model = dbnToolsReviewerModel($personaSlug); $question = dbnToolsSelectCheckQuestion($docType, $brief, $track); if ($question === null) { return []; } $opts = [ 'model' => $model, 'temperature' => 0.1, 'max_tokens' => 350, 'timeout' => 45, // No 'json' key — plain narrative, no response_format flag ]; // Base prompt: resolved persona prompt wins; otherwise a track-appropriate default. $personaPrompt = is_string($personaPrompt) ? trim($personaPrompt) : ''; if ($personaPrompt !== '') { $sysMsg = $personaPrompt; } elseif ($track === 'family') { $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.'; } else { $sysMsg = 'Du er en erfaren norsk jurist med bred kompetanse i norsk rett (forvaltningsrett, utlendingsrett, arbeidsrett, husleie- og forbrukerrett). Svar alltid på norsk med korrekt juridisk terminologi. Aldri oppfinn paragrafnumre, saksnumre, dommernavn eller kilder. Hvis du er usikker, si det tydelig.'; } // Prepend a short snippet of the actual synthesis text so v3 answers in context, // not just as a general law quiz. Strip HTML tags and cap at 350 chars. $snippet = mb_strimwidth(strip_tags($brief), 0, 350, '…'); $userMsg = $snippet !== '' ? "Tekst fra dokumentet:\n{$snippet}\n\n{$question}" : $question; $text = ''; try { $response = dbnToolsCallGpuLlm( [ ['role' => 'system', 'content' => $sysMsg], ['role' => 'user', 'content' => $userMsg], ], $opts ); $text = trim((string)($response['choices'][0]['message']['content'] ?? '')); } catch (Throwable $e) { return []; // v3 timed out or unavailable — skip legal check (non-critical) } if (empty($text) || str_word_count($text) < 15) { return []; } $clean = dbnToolsExtractCleanAnswer($text); if (mb_strlen($clean) < 40) { return []; } return [[ 'description' => mb_substr($clean, 0, 280), 'severity' => dbnToolsInferCheckSeverity($clean), 'legal_basis' => dbnToolsExtractCheckLegalBasis($clean), 'source_refs' => [], 'what_to_check'=> $track === 'family' ? 'Verifiser med norsk familieretsadvokat' : 'Verifiser med norsk advokat', 'check_model' => $model, ]]; } function dbnToolsSelectCheckQuestion(string $docType, string $brief, string $track = 'family'): ?string { $t = mb_strtolower($docType); $b = mb_strtolower($brief); // Track 2 (other Norwegian law): neutral verification question, always fires. if ($track !== 'family') { return 'Vurder teksten ovenfor opp mot gjeldende norsk rett: er de rettslige påstandene korrekte, ' . 'er det vist til riktige lover og paragrafer, og mangler det viktige rettslige vilkår, frister ' . 'eller prosessuelle krav som burde vært nevnt?'; } if (str_contains($t, 'akutt') || str_contains($t, 'emergency')) { return 'Hva er den korrekte rettslige terskelen for midlertidig plassering utenfor hjemmet ' . 'etter barnevernsloven § 4-25 (2021-loven)? Er det forskjell mellom § 4-6 (1992-loven) ' . 'og § 4-25 (2021-loven), og er «klar nødvendighet» det riktige kravet?'; } if (str_contains($t, 'adopsjon') || str_contains($t, 'adoption') || str_contains($b, '§ 5-8') || str_contains($b, '§ 4-20')) { return 'Hva krever Høyesterett og EMD (særlig Strand Lobben mot Norge, saksnr. 37283/13, 2019) ' . 'for at fratakelse av foreldreansvar og adopsjon uten samtykke skal være lovlig? ' . 'Hvilken rolle spiller dokumentert rehabiliteringsplan og biologiske familiebånd?'; } if (str_contains($t, 'undersøkelse') || str_contains($t, 'investigation') || (str_contains($b, 'varsel') && str_contains($b, 'hjemmebesøk'))) { return 'Hva er konsekvensene etter forvaltningsloven § 17 (kontradiksjonsprinsippet) og ' . '§ 41 (ugyldighet) dersom barnevernstjenesten gjennomfører hjemmebesøk uten å ha ' . 'sendt varsel om undersøkelse i en ikke-akutt situasjon?'; } // Generic procedural check for other document types return 'Hva er de viktigste prosessuelle kravene barnevernstjenesten må oppfylle ved ' . 'inngripende vedtak etter barnevernsloven 2021, særlig knyttet til varsel, ' . 'begrunnelse, forholdsmessighet og EMD-forpliktelser?'; } function dbnToolsExtractCleanAnswer(string $text): string { // Strip loop artifacts: tool-planning stubs and "Final:" repetition blocks $cutPatterns = ['Tool calls:', 'Tool_calls:', '"tool_calls"', 'Final:', 'Final answer:']; foreach ($cutPatterns as $marker) { $pos = mb_stripos($text, $marker); if ($pos !== false && $pos > 40) { $text = mb_substr($text, 0, $pos); } } // Preserve Kilder: section for legal citations but cap its length $srcPos = mb_strpos($text, 'Kilder:'); if ($srcPos !== false && $srcPos > 60) { $text = mb_substr($text, 0, $srcPos + 180); } return trim($text); } /** * Robustly extract a JSON object from a model reply, tolerating the artifacts the * fine-tuned models leak: ```fences```, markdown-escaped underscores/asterisks * (`\_`, `\*` — never valid JSON escapes), and prose wrapped around a real JSON * blob. Returns the decoded array, or null if nothing parses. Shared by both * gateways' decodeJsonObject(), so every JSON tool benefits. */ function dbnToolsRepairJsonText(string $content): ?array { $content = trim($content); $content = (string)preg_replace('/^```(?:json)?\s*\n?/i', '', $content); $content = (string)preg_replace('/\n?```\s*$/', '', $content); // Drop only invalid markdown escapes; leave legitimate \n \" \\ \/ \t intact. $content = (string)preg_replace('/\\\\([_*])/', '$1', $content); $content = trim($content); $decoded = json_decode($content, true); if (is_array($decoded)) { return $decoded; } // Collect every balanced top-level {...} block (ignoring braces inside JSON // strings), then try the longest first — handles "prose then appended JSON". $candidates = []; $depth = 0; $start = -1; $inStr = false; $escaped = false; $len = strlen($content); for ($i = 0; $i < $len; $i++) { $ch = $content[$i]; if ($inStr) { if ($escaped) { $escaped = false; } elseif ($ch === '\\') { $escaped = true; } elseif ($ch === '"') { $inStr = false; } continue; } if ($ch === '"') { $inStr = true; } elseif ($ch === '{') { if ($depth === 0) { $start = $i; } $depth++; } elseif ($ch === '}') { if ($depth > 0) { $depth--; if ($depth === 0 && $start >= 0) { $candidates[] = substr($content, $start, $i - $start + 1); $start = -1; } } } } usort($candidates, static fn(string $a, string $b): int => strlen($b) <=> strlen($a)); foreach ($candidates as $candidate) { $decoded = json_decode($candidate, true); if (is_array($decoded)) { return $decoded; } } return null; } /** * Parse a labelled-prose reply (`answer: ...`, `what_we_found: ...`) into an assoc * array keyed by $keys, for fine-tunes that ignore the JSON contract. Tolerates * markdown-escaped key names (`what\_we\_found`). Each value runs until the next * known key label or a trailing { JSON blob (discarded). Returns only found keys. */ function dbnToolsParseLabeledFields(string $text, array $keys): array { $text = (string)preg_replace('/\\\\([_*])/', '$1', trim($text)); if ($text === '' || empty($keys)) { return []; } // Find each "key:" label position (line start, case-insensitive). $labels = []; foreach ($keys as $key) { if (preg_match('/^\s*' . preg_quote($key, '/') . '\s*:/im', $text, $m, PREG_OFFSET_CAPTURE)) { $labelStart = $m[0][1]; $valueStart = $labelStart + strlen($m[0][0]); $labels[] = ['key' => $key, 'start' => $labelStart, 'value_start' => $valueStart]; } } if (!$labels) { return []; } usort($labels, static fn(array $a, array $b): int => $a['start'] <=> $b['start']); $out = []; $count = count($labels); for ($i = 0; $i < $count; $i++) { $end = ($i + 1 < $count) ? $labels[$i + 1]['start'] : strlen($text); $value = substr($text, $labels[$i]['value_start'], $end - $labels[$i]['value_start']); // Drop a trailing appended JSON blob from the last field's value. $brace = strpos($value, '{'); if ($brace !== false && $i + 1 === $count) { $value = substr($value, 0, $brace); } // Collapse a duplicated "key:" prefix the model sometimes repeats inside the value. $value = (string)preg_replace('/^\s*' . preg_quote($labels[$i]['key'], '/') . '\s*:\s*/i', '', trim($value)); $out[$labels[$i]['key']] = trim($value); } return $out; } function dbnToolsInferCheckSeverity(string $text): string { if (preg_match('/ugyldig|§\s*41|kontradiksjon|klar nødvendighet|strand lobben|biologiske bånd/i', $text)) { return 'high'; } if (preg_match('/prosessuell|varsel|terskel|paragrafnummer|forholdsmessig|rehabiliter/i', $text)) { return 'medium'; } return 'low'; } function dbnToolsExtractCheckLegalBasis(string $text): string { preg_match_all('/§\s*\d[\d\-a-zA-Z]*/u', $text, $m); if (!empty($m[0])) { return implode(', ', array_unique(array_slice($m[0], 0, 3))); } return ''; } // ── Document picker helpers ─────────────────────────────────────────────────── /** Fetch text content of selected client documents as labelled blocks. */ function dbnToolsFetchDocChunks(array $docIds, int $clientId): string { if (empty($docIds) || $clientId <= 0) { return ''; } $db = dbnToolsDb(); $placeholders = implode(',', array_fill(0, count($docIds), '?')); $stmt = $db->prepare( "SELECT c.content, d.title AS doc_title, c.document_id FROM client_chunks c JOIN client_documents d ON d.id = c.document_id WHERE c.client_id = ? AND c.document_id IN ($placeholders) AND d.source_type != 'audio' ORDER BY c.document_id, c.id ASC LIMIT 500" ); $stmt->execute(array_merge([$clientId], $docIds)); $byDoc = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $id = (int)$row['document_id']; $byDoc[$id] ??= ['title' => (string)$row['doc_title'], 'chunks' => []]; $byDoc[$id]['chunks'][] = (string)$row['content']; } // Docs saved-from-tool or case-uploaded store content directly in client_documents // with no rows in client_chunks — fall back for those. $missingIds = array_values(array_diff($docIds, array_keys($byDoc))); if (!empty($missingIds)) { $mp = implode(',', array_fill(0, count($missingIds), '?')); $fb = $db->prepare( "SELECT id, title, content FROM client_documents WHERE client_id = ? AND id IN ($mp) AND source_type != 'audio' AND content IS NOT NULL AND content != '' LIMIT 50" ); $fb->execute(array_merge([$clientId], $missingIds)); foreach ($fb->fetchAll(PDO::FETCH_ASSOC) as $row) { $id = (int)$row['id']; $byDoc[$id] = ['title' => (string)$row['title'], 'chunks' => [(string)$row['content']]]; } } $parts = []; foreach ($byDoc as $doc) { $parts[] = '=== ' . $doc['title'] . " ===\n" . implode("\n\n", $doc['chunks']); } return implode("\n\n---\n\n", $parts); } /** Resolve client_id for the current CaveauAI session; returns 0 for SSO/free-tier users. */ function dbnToolsClientIdFromSession(): int { try { $tenant = dbnToolsEnsureDashboardTenant(); return (int)($tenant['client_id'] ?? 0); } catch (Throwable) { return 0; } } /** * Inject selected corpus document content into $text if doc_ids are in the request input. * No-ops silently for free-tier (SSO) users who have no client_documents. */ function dbnToolsInjectDocContent(array $input, string $text): string { $raw = $input['doc_ids'] ?? []; $ids = array_values(array_filter(array_map('intval', is_array($raw) ? $raw : explode(',', (string)$raw)))); if (empty($ids)) { return $text; } $clientId = dbnToolsClientIdFromSession(); if ($clientId <= 0) { return $text; } $docText = dbnToolsFetchDocChunks($ids, $clientId); if ($docText === '') { return $text; } return $docText . ($text !== '' ? "\n\n---\n\n" . $text : ''); } require_once __DIR__ . '/dms_helpers.php';