47aa35e946
Probe testing revealed the fine-tune loops when asked to check a brief directly (tool-planning architecture conflict) but answers focused legal Q&A reliably in ~55s. New step 6b asks one targeted question per document type (akuttvedtak → § 4-25 klar nødvendighet, adopsjon → Strand Lobben, undersøkelse → fvl § 17/§ 41) and merges the finding into procedural_red_flags with check_model provenance. Silent on timeout/error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
970 lines
32 KiB
PHP
970 lines
32 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();
|
|
}
|
|
|
|
/**
|
|
* 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'] ?? ''),
|
|
'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 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* True when the current session is a free-tier SSO user (Google login).
|
|
* False for CaveauAI client sessions (always unlimited).
|
|
*/
|
|
function dbnToolsIsFreeTier(): bool
|
|
{
|
|
return !empty($_SESSION['dbn_tools_authenticated'])
|
|
&& !empty($_SESSION['dbn_tools_sso_uid'])
|
|
&& ($_SESSION['dbn_tools_tier'] ?? '') === 'free';
|
|
}
|
|
|
|
/**
|
|
* Enforce free-tier credit 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';
|
|
http_response_code($isRateLimit ? 429 : 402);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
header('Cache-Control: no-store');
|
|
echo json_encode([
|
|
'ok' => false,
|
|
'error' => ['code' => $result['reason'], 'message' => $isRateLimit
|
|
? 'Rate limit reached — you can make up to 10 requests per hour on the free tier.'
|
|
: 'No credits remaining. Your 10 free credits reset on the 1st of next month.',
|
|
],
|
|
'balance' => $result['balance'],
|
|
], JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
return $uid;
|
|
}
|
|
|
|
/**
|
|
* 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 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('Do Better Norge 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_EXTRACT_ALLOWED_EXTS = ['txt', 'pdf', 'docx'];
|
|
|
|
function dbnToolsExtractUploadedFile(array $file): 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)) {
|
|
dbnToolsAbort('Unsupported file type. Upload a .pdf, .docx, or .txt file.', 422, 'unsupported_type');
|
|
}
|
|
|
|
$text = match ($ext) {
|
|
'txt' => dbnToolsExtractTxt($tmpPath),
|
|
'pdf' => dbnToolsExtractPdf($tmpPath),
|
|
'docx' => dbnToolsExtractDocx($tmpPath),
|
|
};
|
|
|
|
$text = trim($text);
|
|
if ($text === '') {
|
|
dbnToolsAbort('No text could be extracted from this file.', 422, 'no_text');
|
|
}
|
|
|
|
$truncated = false;
|
|
if (mb_strlen($text, 'UTF-8') > DBN_TOOLS_EXTRACT_TEXT_LIMIT) {
|
|
$text = mb_substr($text, 0, DBN_TOOLS_EXTRACT_TEXT_LIMIT, 'UTF-8');
|
|
$truncated = true;
|
|
}
|
|
|
|
return [
|
|
'ok' => true,
|
|
'text' => $text,
|
|
'filename' => $originalName,
|
|
'chars' => mb_strlen($text, 'UTF-8'),
|
|
'truncated' => $truncated,
|
|
];
|
|
}
|
|
|
|
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): array
|
|
{
|
|
$question = dbnToolsSelectCheckQuestion($docType, $brief);
|
|
if ($question === null) {
|
|
return [];
|
|
}
|
|
|
|
$opts = [
|
|
'model' => 'dbn-legal-agent',
|
|
'temperature' => 0.1,
|
|
'max_tokens' => 350,
|
|
'timeout' => 120,
|
|
// No 'json' key — plain narrative, no response_format flag
|
|
];
|
|
|
|
try {
|
|
$response = dbnToolsCallGpuLlm(
|
|
[['role' => 'user', 'content' => $question]],
|
|
$opts
|
|
);
|
|
$text = trim((string)($response['choices'][0]['message']['content'] ?? ''));
|
|
} catch (Throwable $e) {
|
|
return [];
|
|
}
|
|
|
|
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'=> 'Verifiser med norsk familieretsadvokat',
|
|
'check_model' => 'dbn-legal-agent',
|
|
]];
|
|
}
|
|
|
|
function dbnToolsSelectCheckQuestion(string $docType, string $brief): ?string
|
|
{
|
|
$t = mb_strtolower($docType);
|
|
$b = mb_strtolower($brief);
|
|
|
|
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);
|
|
}
|
|
|
|
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 '';
|
|
}
|