Initial release: Do Better Norge Legal Tools Hub
Five MVP tools (Ask, Search, Summarize, Timeline, Redact) with email+password auth, Azure OpenAI gateway, evidence trail panel, and process-and-forget privacy default. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
<?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();
|
||||
|
||||
function dbnToolsIsAuthenticated(): bool
|
||||
{
|
||||
return !empty($_SESSION['dbn_tools_authenticated']);
|
||||
}
|
||||
|
||||
function dbnToolsAuthEmail(): ?string
|
||||
{
|
||||
return dbnToolsEnv('DBN_TOOLS_AUTH_EMAIL');
|
||||
}
|
||||
|
||||
function dbnToolsAuthPasswordHash(): ?string
|
||||
{
|
||||
return dbnToolsEnv('DBN_TOOLS_AUTH_PASSWORD_HASH');
|
||||
}
|
||||
|
||||
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('Passcode 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
|
||||
{
|
||||
$language = strtolower(trim((string)$value));
|
||||
return in_array($language, ['no', 'en'], true) ? $language : 'en';
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
function dbnToolsClientSlug(): string
|
||||
{
|
||||
return dbnToolsEnv('DBN_CAVEAU_CLIENT_SLUG') ?: 'dave-jr-legal';
|
||||
}
|
||||
|
||||
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('Dave Jr Legal client tenant is not active or was not found.', 503, 'client_unavailable');
|
||||
}
|
||||
return $client;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
if ($language === 'no') {
|
||||
return 'Juridisk informasjon og forberedelsesstøtte, ikke endelig juridisk rådgivning.';
|
||||
}
|
||||
return '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')) . '…';
|
||||
}
|
||||
Reference in New Issue
Block a user