Files
dobetternorge-tools/includes/bootstrap.php
T
daveadmin a8b1bb87a6 feat(tools): converge two-tier Quick/Pro selector onto .no fork
Port the dobetterlegal-tools two-tier quality stack to dobetternorge.no:
QUALITY_TIERS registry + resolveTier (ToolModels), dbnToolsResolveToolRun
(bootstrap), tier read+charge in the 6 analytical endpoints, Quick/Pro
UI + payload.tier on the 6 tool pages/JS, and the bounded
corpusContextForSummarize RAG fix (per-passage trim + total budget +
reranker_enabled). Back-compat: requests without `tier` keep legacy
engine behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 12:23:46 +02:00

1727 lines
61 KiB
PHP

<?php
declare(strict_types=1);
define('DBN_TOOLS_ROOT', dirname(__DIR__));
define('DBN_TOOLS_VERSION', '0.1.0');
final class DbnToolsHttpException extends RuntimeException
{
public int $status;
public string $errorCode;
public array $extra;
public function __construct(string $message, int $status = 400, string $errorCode = 'bad_request', array $extra = [])
{
parent::__construct($message);
$this->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'] ?? ''),
];
}
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') {
return trim((string)(dbnToolsEnv('DBN_REVIEW_MODEL_FAMILY', 'dbn-legal-agent-v3') ?? 'dbn-legal-agent-v3'))
?: 'dbn-legal-agent-v3';
}
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']);
$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);
$engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? $legacyDefaultEngine));
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';