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,4 @@
|
||||
.env
|
||||
*.log
|
||||
*.jsonl
|
||||
/support/
|
||||
@@ -0,0 +1,17 @@
|
||||
DirectoryIndex index.php
|
||||
Options -Indexes
|
||||
|
||||
<FilesMatch "^\.env">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set Referrer-Policy "same-origin"
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteRule ^includes/ - [F,L]
|
||||
</IfModule>
|
||||
@@ -0,0 +1,28 @@
|
||||
# Do Better Norge Legal Tools Hub
|
||||
|
||||
MVP docroot for `tools.dobetternorge.com`.
|
||||
|
||||
## Required environment
|
||||
|
||||
- `DBN_TOOLS_PASSCODE_HASH`
|
||||
- `DBN_AZURE_OPENAI_ENDPOINT`
|
||||
- `DBN_AZURE_OPENAI_API_KEY`
|
||||
- `DBN_AZURE_OPENAI_API_VERSION`
|
||||
- `DBN_AZURE_OPENAI_CHAT_DEPLOYMENT`
|
||||
- `DBN_AZURE_OPENAI_EMBEDDING_DEPLOYMENT`
|
||||
|
||||
Optional:
|
||||
|
||||
- `DBN_AI_PORTAL_ROOT` (defaults to sibling `ai-portal`)
|
||||
- `DBN_CAVEAU_CLIENT_SLUG` (defaults to `dave-jr-legal`)
|
||||
- `DBN_TOOLS_SUPPORT_DIR`
|
||||
- `DBN_TOOLS_METADATA_LOG`
|
||||
|
||||
Create the passcode hash with:
|
||||
|
||||
```bash
|
||||
php -r "echo password_hash('replace-this-passcode', PASSWORD_DEFAULT), PHP_EOL;"
|
||||
```
|
||||
|
||||
The APIs process pasted text in memory and write only metadata such as tool name,
|
||||
latency, language, source count, chunk count, deployment, and anonymous session id.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../includes/LegalTools.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
$input = dbnToolsJsonInput(25000);
|
||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
||||
|
||||
dbnToolsWithTelemetry('ask', $language, function () use ($input, $language): array {
|
||||
$question = dbnToolsString($input, 'question', 4000);
|
||||
return (new DbnLegalToolsService())->ask($question, $language);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/AzureOpenAiGateway.php';
|
||||
|
||||
dbnToolsRequireMethod('GET');
|
||||
dbnToolsRequireAuth();
|
||||
|
||||
$checks = [];
|
||||
$checks['passcode_hash'] = [
|
||||
'ok' => (bool)dbnToolsEnv('DBN_TOOLS_PASSCODE_HASH'),
|
||||
'detail' => dbnToolsEnv('DBN_TOOLS_PASSCODE_HASH') ? 'Configured' : 'Missing DBN_TOOLS_PASSCODE_HASH',
|
||||
];
|
||||
|
||||
$azure = new DbnAzureOpenAiGateway();
|
||||
$missingChat = $azure->missingChatConfig();
|
||||
$missingEmbedding = $azure->missingEmbeddingConfig();
|
||||
$checks['azure_chat_config'] = [
|
||||
'ok' => !$missingChat,
|
||||
'detail' => !$missingChat ? 'Configured' : 'Missing: ' . implode(', ', $missingChat),
|
||||
];
|
||||
$checks['azure_embedding_config'] = [
|
||||
'ok' => !$missingEmbedding,
|
||||
'detail' => !$missingEmbedding ? 'Configured' : 'Missing: ' . implode(', ', $missingEmbedding),
|
||||
];
|
||||
$checks['azure_reachability'] = [
|
||||
'ok' => false,
|
||||
'detail' => 'Not checked because chat config is incomplete',
|
||||
];
|
||||
if (!$missingChat) {
|
||||
$reachable = $azure->ping(8);
|
||||
$checks['azure_reachability'] = [
|
||||
'ok' => $reachable,
|
||||
'detail' => $reachable ? 'Azure chat deployment responded' : 'Azure chat deployment did not respond',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$db = dbnToolsDb();
|
||||
$db->query('SELECT 1');
|
||||
$checks['db_connectivity'] = ['ok' => true, 'detail' => 'CaveauAI admin DB reachable'];
|
||||
|
||||
$client = dbnToolsFetchClient($db);
|
||||
$checks['dave_jr_legal_client'] = [
|
||||
'ok' => (bool)$client,
|
||||
'detail' => $client ? 'Client id ' . $client['id'] . ' found' : 'Client slug ' . dbnToolsClientSlug() . ' not found',
|
||||
];
|
||||
|
||||
$package = dbnToolsFetchPackage('family-legal', $db);
|
||||
$checks['family_legal_package'] = [
|
||||
'ok' => (bool)$package,
|
||||
'detail' => $package ? 'Package id ' . $package['id'] . ' found' : 'family-legal package not found',
|
||||
];
|
||||
|
||||
$subOk = $client && $package && dbnToolsHasActiveSubscription((int)$client['id'], (int)$package['id'], $db);
|
||||
$checks['family_legal_subscription'] = [
|
||||
'ok' => (bool)$subOk,
|
||||
'detail' => $subOk ? 'Active subscription visible' : 'Active subscription not visible',
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
$checks['db_connectivity'] = ['ok' => false, 'detail' => $e->getMessage()];
|
||||
$checks['dave_jr_legal_client'] = ['ok' => false, 'detail' => 'Not checked'];
|
||||
$checks['family_legal_package'] = ['ok' => false, 'detail' => 'Not checked'];
|
||||
$checks['family_legal_subscription'] = ['ok' => false, 'detail' => 'Not checked'];
|
||||
}
|
||||
|
||||
$logPath = dbnToolsMetadataLogPath();
|
||||
$dir = dirname($logPath);
|
||||
$checks['metadata_log'] = [
|
||||
'ok' => is_dir($dir) && is_writable($dir),
|
||||
'detail' => is_dir($dir) && is_writable($dir) ? 'Metadata directory is writable' : 'Metadata directory is not writable',
|
||||
];
|
||||
|
||||
$ok = true;
|
||||
foreach ($checks as $check) {
|
||||
if (empty($check['ok'])) {
|
||||
$ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dbnToolsRespond([
|
||||
'ok' => $ok,
|
||||
'status' => $ok ? 'ok' : 'degraded',
|
||||
'version' => DBN_TOOLS_VERSION,
|
||||
'checks' => $checks,
|
||||
], $ok ? 200 : 503);
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../includes/LegalTools.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
$input = dbnToolsJsonInput(70000);
|
||||
|
||||
dbnToolsWithTelemetry('redact', '', function () use ($input): array {
|
||||
$text = dbnToolsString($input, 'text', 32000);
|
||||
$mode = (string)($input['mode'] ?? 'standard');
|
||||
return (new DbnLegalToolsService())->redact($text, $mode);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../includes/LegalTools.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
$input = dbnToolsJsonInput(12000);
|
||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
||||
|
||||
dbnToolsWithTelemetry('search', $language, function () use ($input, $language): array {
|
||||
$query = dbnToolsString($input, 'query', 2000);
|
||||
$limit = max(1, min(10, (int)($input['limit'] ?? 6)));
|
||||
return (new DbnLegalToolsService())->search($query, $language, $limit);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
$input = dbnToolsJsonInput(2048);
|
||||
|
||||
$email = strtolower(trim((string)($input['email'] ?? '')));
|
||||
$password = (string)($input['password'] ?? '');
|
||||
|
||||
if ($email === '') {
|
||||
dbnToolsError('Email is required.', 422, 'missing_email');
|
||||
}
|
||||
if ($password === '') {
|
||||
dbnToolsError('Password is required.', 422, 'missing_password');
|
||||
}
|
||||
|
||||
$configuredEmail = dbnToolsAuthEmail();
|
||||
$hash = dbnToolsAuthPasswordHash();
|
||||
|
||||
if ($configuredEmail === null || trim($configuredEmail) === '' || $hash === null || trim($hash) === '') {
|
||||
dbnToolsError('Authentication credentials are not configured.', 503, 'auth_not_configured');
|
||||
}
|
||||
|
||||
$emailMatch = hash_equals(strtolower(trim($configuredEmail)), $email);
|
||||
$passwordMatch = password_verify($password, $hash);
|
||||
|
||||
if (!$emailMatch || !$passwordMatch) {
|
||||
dbnToolsError('Email or password was not accepted.', 401, 'invalid_credentials');
|
||||
}
|
||||
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['dbn_tools_authenticated'] = true;
|
||||
$_SESSION['dbn_tools_authenticated_at'] = time();
|
||||
$_SESSION['dbn_tools_anon_id'] = $_SESSION['dbn_tools_anon_id'] ?? bin2hex(random_bytes(16));
|
||||
|
||||
dbnToolsRespond([
|
||||
'ok' => true,
|
||||
'authenticated' => true,
|
||||
'session' => dbnToolsAnonymousSessionId(),
|
||||
]);
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../includes/LegalTools.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
$input = dbnToolsJsonInput(70000);
|
||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
||||
|
||||
dbnToolsWithTelemetry('summarize', $language, function () use ($input, $language): array {
|
||||
$text = dbnToolsString($input, 'text', 32000);
|
||||
return (new DbnLegalToolsService())->summarize($text, $language);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../includes/LegalTools.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
$input = dbnToolsJsonInput(70000);
|
||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
||||
|
||||
dbnToolsWithTelemetry('timeline', $language, function () use ($input, $language): array {
|
||||
$text = dbnToolsString($input, 'text', 32000);
|
||||
return (new DbnLegalToolsService())->timeline($text, $language);
|
||||
});
|
||||
@@ -0,0 +1,513 @@
|
||||
:root {
|
||||
--bg: #f7f8fb;
|
||||
--panel: #ffffff;
|
||||
--ink: #1b2330;
|
||||
--muted: #667085;
|
||||
--line: #d8dde7;
|
||||
--teal: #0f766e;
|
||||
--teal-dark: #115e59;
|
||||
--coral: #c2410c;
|
||||
--amber: #b7791f;
|
||||
--soft-teal: #e7f5f2;
|
||||
--soft-coral: #fff0e8;
|
||||
--shadow: 0 18px 45px rgba(25, 35, 52, 0.10);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 3px solid rgba(15, 118, 110, 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.is-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.gate {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(15, 118, 110, 0.10), transparent 36%),
|
||||
linear-gradient(315deg, rgba(194, 65, 12, 0.10), transparent 34%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.gate-panel {
|
||||
width: min(460px, 100%);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: var(--teal);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.gate-panel h1,
|
||||
.topbar h1,
|
||||
.tool-heading h2,
|
||||
.reasoning-head h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.gate-copy {
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.passcode-form label,
|
||||
.input-label,
|
||||
.control-label {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.passcode-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.passcode-row input,
|
||||
.tool-form textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.passcode-row input {
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.passcode-row button,
|
||||
#runButton,
|
||||
.secondary-button {
|
||||
min-height: 44px;
|
||||
border-radius: 8px;
|
||||
padding: 0 18px;
|
||||
background: var(--teal);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: #263344;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
max-width: 1500px;
|
||||
margin: 0 auto 12px;
|
||||
padding: 10px 2px;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-pill,
|
||||
.tool-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--soft-teal);
|
||||
color: var(--teal-dark);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-pill.is-warning {
|
||||
background: var(--soft-coral);
|
||||
color: var(--coral);
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #f0d6bc;
|
||||
border-radius: 8px;
|
||||
background: #fff9f2;
|
||||
color: #68420d;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 190px minmax(0, 1fr) 370px;
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.tool-rail,
|
||||
.tool-panel,
|
||||
.reasoning-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tool-rail {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool-tab {
|
||||
width: 100%;
|
||||
min-height: 68px;
|
||||
border-radius: 8px;
|
||||
padding: 11px;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.tool-tab span {
|
||||
display: block;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tool-tab small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tool-tab.is-active {
|
||||
background: var(--soft-teal);
|
||||
border-color: rgba(15, 118, 110, 0.30);
|
||||
}
|
||||
|
||||
.tool-panel {
|
||||
min-height: 720px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.tool-heading,
|
||||
.reasoning-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tool-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.control-row label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tool-form textarea {
|
||||
resize: vertical;
|
||||
min-height: 220px;
|
||||
padding: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-status {
|
||||
min-height: 22px;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.results {
|
||||
margin-top: 18px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.result-section {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.empty-state p,
|
||||
.result-section p,
|
||||
.result-section li {
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.answer {
|
||||
color: var(--ink) !important;
|
||||
font-size: 1.03rem;
|
||||
}
|
||||
|
||||
.result-section h3 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.source-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.source-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.source-card h4,
|
||||
.detail-block h4 {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.source-meta {
|
||||
color: var(--teal-dark) !important;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-block {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.timeline-list li {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.timeline-list span {
|
||||
display: block;
|
||||
color: var(--teal-dark);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.timeline-list small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.redacted-output {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.pill-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pill-list li {
|
||||
border-radius: 999px;
|
||||
background: var(--soft-teal);
|
||||
color: var(--teal-dark);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.result-disclaimer {
|
||||
margin: 0;
|
||||
color: #68420d;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.reasoning-panel {
|
||||
min-height: 720px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.trace-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.trace-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 14px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.trace-list strong {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.trace-list p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.trace-status {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--teal);
|
||||
}
|
||||
|
||||
.trace-status.running {
|
||||
background: var(--amber);
|
||||
}
|
||||
|
||||
.trace-status.warning {
|
||||
background: var(--coral);
|
||||
}
|
||||
|
||||
.trace-status.waiting {
|
||||
background: #98a2b3;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.workspace {
|
||||
grid-template-columns: 170px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.reasoning-panel {
|
||||
grid-column: 1 / -1;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-shell {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.form-footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tool-rail {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tool-tab {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.tool-panel,
|
||||
.reasoning-panel {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.passcode-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
const state = {
|
||||
activeTool: 'ask',
|
||||
authenticated: Boolean(window.DBN_TOOLS_AUTHENTICATED),
|
||||
};
|
||||
|
||||
const tools = {
|
||||
ask: {
|
||||
kind: 'Source-grounded Legal Ask',
|
||||
title: 'Ask a legal question',
|
||||
label: 'Question',
|
||||
endpoint: 'api/ask.php',
|
||||
payloadKey: 'question',
|
||||
placeholder: 'Example: What evidence is needed before asking for changes in custody arrangements?',
|
||||
usesLanguage: true,
|
||||
badge: 'family-legal',
|
||||
},
|
||||
search: {
|
||||
kind: 'Legal Source Search',
|
||||
title: 'Search legal sources',
|
||||
label: 'Search query',
|
||||
endpoint: 'api/search.php',
|
||||
payloadKey: 'query',
|
||||
placeholder: 'Example: barnets beste samvær foreldreansvar',
|
||||
usesLanguage: true,
|
||||
badge: 'family-legal',
|
||||
},
|
||||
summarize: {
|
||||
kind: 'Document Summarizer',
|
||||
title: 'Summarize pasted text',
|
||||
label: 'Pasted text',
|
||||
endpoint: 'api/summarize.php',
|
||||
payloadKey: 'text',
|
||||
placeholder: 'Paste a case note, letter, or excerpt.',
|
||||
usesLanguage: true,
|
||||
badge: 'process-and-forget',
|
||||
},
|
||||
timeline: {
|
||||
kind: 'Timeline Builder',
|
||||
title: 'Build a timeline',
|
||||
label: 'Pasted text',
|
||||
endpoint: 'api/timeline.php',
|
||||
payloadKey: 'text',
|
||||
placeholder: 'Paste case notes with dates, actors, and events.',
|
||||
usesLanguage: true,
|
||||
badge: 'process-and-forget',
|
||||
},
|
||||
redact: {
|
||||
kind: 'Redaction Assistant',
|
||||
title: 'Redact sensitive details',
|
||||
label: 'Pasted text',
|
||||
endpoint: 'api/redact.php',
|
||||
payloadKey: 'text',
|
||||
placeholder: 'Paste text containing names, phone numbers, emails, addresses, or fødselsnummer-like values.',
|
||||
usesLanguage: false,
|
||||
badge: 'deterministic first',
|
||||
},
|
||||
};
|
||||
|
||||
const els = {};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
Object.assign(els, {
|
||||
gate: document.querySelector('#passcodeGate'),
|
||||
app: document.querySelector('#appShell'),
|
||||
passcodeForm: document.querySelector('#passcodeForm'),
|
||||
loginEmail: document.querySelector('#loginEmail'),
|
||||
loginPassword: document.querySelector('#loginPassword'),
|
||||
gateStatus: document.querySelector('#gateStatus'),
|
||||
tabs: Array.from(document.querySelectorAll('.tool-tab')),
|
||||
toolKind: document.querySelector('#toolKind'),
|
||||
toolTitle: document.querySelector('#toolTitle'),
|
||||
toolBadge: document.querySelector('#toolBadge'),
|
||||
form: document.querySelector('#toolForm'),
|
||||
inputLabel: document.querySelector('#inputLabel'),
|
||||
input: document.querySelector('#toolInput'),
|
||||
languageControl: document.querySelector('#languageControl'),
|
||||
redactionControl: document.querySelector('#redactionControl'),
|
||||
status: document.querySelector('#toolStatus'),
|
||||
results: document.querySelector('#results'),
|
||||
traceList: document.querySelector('#traceList'),
|
||||
healthButton: document.querySelector('#healthButton'),
|
||||
healthPill: document.querySelector('#healthPill'),
|
||||
});
|
||||
|
||||
els.tabs.forEach((button) => {
|
||||
button.addEventListener('click', () => setTool(button.dataset.tool));
|
||||
});
|
||||
els.form.addEventListener('submit', runTool);
|
||||
els.passcodeForm.addEventListener('submit', submitPasscode);
|
||||
els.healthButton.addEventListener('click', checkHealth);
|
||||
setTool(state.activeTool);
|
||||
|
||||
if (state.authenticated) {
|
||||
checkHealth();
|
||||
} else {
|
||||
els.loginEmail?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function setTool(toolName) {
|
||||
state.activeTool = toolName;
|
||||
const tool = tools[toolName];
|
||||
els.tabs.forEach((button) => {
|
||||
const active = button.dataset.tool === toolName;
|
||||
button.classList.toggle('is-active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
|
||||
els.toolKind.textContent = tool.kind;
|
||||
els.toolTitle.textContent = tool.title;
|
||||
els.toolBadge.textContent = tool.badge;
|
||||
els.inputLabel.textContent = tool.label;
|
||||
els.input.value = '';
|
||||
els.input.placeholder = tool.placeholder;
|
||||
els.languageControl.classList.toggle('is-hidden', !tool.usesLanguage);
|
||||
els.redactionControl.classList.toggle('is-hidden', toolName !== 'redact');
|
||||
els.status.textContent = '';
|
||||
renderTrace([]);
|
||||
}
|
||||
|
||||
async function submitPasscode(event) {
|
||||
event.preventDefault();
|
||||
els.gateStatus.textContent = 'Signing in…';
|
||||
try {
|
||||
const data = await postJson('api/session.php', {
|
||||
email: els.loginEmail.value.trim(),
|
||||
password: els.loginPassword.value,
|
||||
});
|
||||
if (!data.ok) {
|
||||
throw new Error(data.error?.message || 'Credentials were not accepted.');
|
||||
}
|
||||
state.authenticated = true;
|
||||
els.gate.classList.add('is-hidden');
|
||||
els.app.classList.remove('is-hidden');
|
||||
els.loginPassword.value = '';
|
||||
els.healthPill.textContent = 'Session active';
|
||||
checkHealth();
|
||||
els.input.focus();
|
||||
} catch (error) {
|
||||
els.gateStatus.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function runTool(event) {
|
||||
event.preventDefault();
|
||||
const tool = tools[state.activeTool];
|
||||
const text = els.input.value.trim();
|
||||
if (!text) {
|
||||
els.status.textContent = 'Add text before running the tool.';
|
||||
els.input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { [tool.payloadKey]: text };
|
||||
if (tool.usesLanguage) {
|
||||
payload.language = currentLanguage();
|
||||
}
|
||||
if (state.activeTool === 'search') {
|
||||
payload.limit = 7;
|
||||
}
|
||||
if (state.activeTool === 'redact') {
|
||||
payload.mode = currentRedactionMode();
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
renderTrace([
|
||||
{ label: 'Query interpretation', detail: 'Preparing request.', status: 'running' },
|
||||
]);
|
||||
|
||||
try {
|
||||
const data = await postJson(tool.endpoint, payload);
|
||||
if (!data.ok) {
|
||||
throw new Error(data.error?.message || 'Tool request failed.');
|
||||
}
|
||||
renderResults(data);
|
||||
renderTrace(data.trace || []);
|
||||
els.status.textContent = `Done in ${data.latency_ms || 0} ms.`;
|
||||
} catch (error) {
|
||||
els.status.textContent = error.message;
|
||||
renderTrace([
|
||||
{ label: 'Tool error', detail: error.message, status: 'warning' },
|
||||
]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
els.healthPill.textContent = 'Checking...';
|
||||
try {
|
||||
const response = await fetch('api/health.php', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const data = await response.json();
|
||||
els.healthPill.textContent = data.ok ? 'Healthy' : 'Needs config';
|
||||
els.healthPill.classList.toggle('is-warning', !data.ok);
|
||||
if (!data.ok && data.checks) {
|
||||
renderHealth(data);
|
||||
}
|
||||
} catch (error) {
|
||||
els.healthPill.textContent = 'Health failed';
|
||||
els.healthPill.classList.add('is-warning');
|
||||
}
|
||||
}
|
||||
|
||||
async function postJson(url, payload) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error?.message || `Request failed with HTTP ${response.status}.`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function setBusy(isBusy) {
|
||||
const button = document.querySelector('#runButton');
|
||||
button.disabled = isBusy;
|
||||
button.textContent = isBusy ? 'Running...' : 'Run Tool';
|
||||
}
|
||||
|
||||
function currentLanguage() {
|
||||
return document.querySelector('input[name="language"]:checked')?.value || 'en';
|
||||
}
|
||||
|
||||
function currentRedactionMode() {
|
||||
return document.querySelector('input[name="redactionMode"]:checked')?.value || 'standard';
|
||||
}
|
||||
|
||||
function renderResults(data) {
|
||||
const sections = [];
|
||||
sections.push(sectionHtml('What We Found', renderMainFinding(data)));
|
||||
sections.push(sectionHtml('Evidence Trail', renderEvidence(data)));
|
||||
sections.push(sectionHtml('What Remains Uncertain', renderListish(data.what_remains_uncertain)));
|
||||
sections.push(sectionHtml('Next Practical Step', `<p>${escapeHtml(data.next_practical_step || 'Review the evidence trail.')}</p>`));
|
||||
|
||||
if (data.disclaimer) {
|
||||
sections.push(`<p class="result-disclaimer">${escapeHtml(data.disclaimer)}</p>`);
|
||||
}
|
||||
|
||||
els.results.innerHTML = sections.join('');
|
||||
}
|
||||
|
||||
function renderMainFinding(data) {
|
||||
if (data.tool === 'ask') {
|
||||
return `<p class="answer">${escapeHtml(data.answer || data.what_we_found || '')}</p>`;
|
||||
}
|
||||
if (data.tool === 'redact') {
|
||||
return `<pre class="redacted-output">${escapeHtml(data.redacted_text || '')}</pre>${renderEntityCounts(data.entity_counts)}`;
|
||||
}
|
||||
if (data.tool === 'timeline') {
|
||||
return `<p>${escapeHtml(data.what_we_found || '')}</p>${renderTimeline(data.events || [])}`;
|
||||
}
|
||||
if (data.tool === 'summarize') {
|
||||
return [
|
||||
`<p>${escapeHtml(data.what_we_found || '')}</p>`,
|
||||
detailList('Key Facts', data.key_facts),
|
||||
detailList('Dates', data.dates),
|
||||
detailList('Parties', data.parties),
|
||||
detailList('Legal References Detected', data.legal_references_detected),
|
||||
].join('');
|
||||
}
|
||||
if (data.tool === 'search') {
|
||||
return `<p>${escapeHtml(data.what_we_found || '')}</p>`;
|
||||
}
|
||||
return `<p>${escapeHtml(data.what_we_found || '')}</p>`;
|
||||
}
|
||||
|
||||
function renderEvidence(data) {
|
||||
const items = data.evidence_trail || data.sources || data.hits || [];
|
||||
if (!items.length) {
|
||||
return '<p>No evidence trail was available for this request.</p>';
|
||||
}
|
||||
return `<div class="source-list">${items.map(renderEvidenceItem).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderEvidenceItem(item) {
|
||||
const title = item.title || item.citation || 'Source';
|
||||
const body = item.excerpt || item.why_it_matters || item.citation || '';
|
||||
const meta = [
|
||||
item.package_or_corpus,
|
||||
item.section,
|
||||
item.score !== undefined && item.score !== null ? `score ${item.score}` : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
return `
|
||||
<article class="source-card">
|
||||
<h4>${escapeHtml(title)}</h4>
|
||||
${meta ? `<p class="source-meta">${escapeHtml(meta)}</p>` : ''}
|
||||
<p>${escapeHtml(body)}</p>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTimeline(events) {
|
||||
if (!events.length) {
|
||||
return '<p>No events were identified.</p>';
|
||||
}
|
||||
return `<ol class="timeline-list">${events.map((event) => `
|
||||
<li>
|
||||
<strong>${escapeHtml(event.date || 'unknown')}</strong>
|
||||
<span>${escapeHtml(event.actor || 'unknown actor')}</span>
|
||||
<p>${escapeHtml(event.event || '')}</p>
|
||||
${event.source_excerpt ? `<small>${escapeHtml(event.source_excerpt)}</small>` : ''}
|
||||
</li>
|
||||
`).join('')}</ol>`;
|
||||
}
|
||||
|
||||
function renderEntityCounts(counts = {}) {
|
||||
const entries = Object.entries(counts).filter(([, count]) => Number(count) > 0);
|
||||
if (!entries.length) {
|
||||
return '<p class="muted">No deterministic sensitive categories detected.</p>';
|
||||
}
|
||||
return `<ul class="pill-list">${entries.map(([name, count]) => `<li>${escapeHtml(name)} <strong>${Number(count)}</strong></li>`).join('')}</ul>`;
|
||||
}
|
||||
|
||||
function detailList(title, values = []) {
|
||||
if (!Array.isArray(values) || !values.length) {
|
||||
return '';
|
||||
}
|
||||
return `<div class="detail-block"><h4>${escapeHtml(title)}</h4><ul>${values.map((item) => `<li>${escapeHtml(String(item))}</li>`).join('')}</ul></div>`;
|
||||
}
|
||||
|
||||
function renderListish(value) {
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.length) {
|
||||
return '<p>No uncertainty listed.</p>';
|
||||
}
|
||||
return `<ul>${value.map((item) => `<li>${escapeHtml(String(item))}</li>`).join('')}</ul>`;
|
||||
}
|
||||
return `<p>${escapeHtml(value || 'No uncertainty listed.')}</p>`;
|
||||
}
|
||||
|
||||
function sectionHtml(title, content) {
|
||||
return `<section class="result-section"><h3>${escapeHtml(title)}</h3>${content}</section>`;
|
||||
}
|
||||
|
||||
function renderTrace(trace) {
|
||||
if (!trace.length) {
|
||||
els.traceList.innerHTML = `
|
||||
<li>
|
||||
<span class="trace-status waiting"></span>
|
||||
<div><strong>Waiting</strong><p>Run a tool to see interpretation, retrieval, confidence, uncertainty, and next step.</p></div>
|
||||
</li>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
els.traceList.innerHTML = trace.map((item) => `
|
||||
<li>
|
||||
<span class="trace-status ${escapeHtml(item.status || 'complete')}"></span>
|
||||
<div>
|
||||
<strong>${escapeHtml(item.label || 'Step')}</strong>
|
||||
<p>${escapeHtml(item.detail || '')}</p>
|
||||
</div>
|
||||
</li>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderHealth(data) {
|
||||
const checks = Object.entries(data.checks || {}).map(([name, check]) => ({
|
||||
label: name.replaceAll('_', ' '),
|
||||
detail: check.detail || '',
|
||||
status: check.ok ? 'complete' : 'warning',
|
||||
}));
|
||||
renderTrace(checks);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
final class DbnAzureOpenAiGateway
|
||||
{
|
||||
private array $config;
|
||||
|
||||
public function __construct(?array $config = null)
|
||||
{
|
||||
$this->config = $config ?: [
|
||||
'endpoint' => rtrim((string)dbnToolsEnv('DBN_AZURE_OPENAI_ENDPOINT', ''), '/'),
|
||||
'api_key' => (string)dbnToolsEnv('DBN_AZURE_OPENAI_API_KEY', ''),
|
||||
'api_version' => (string)dbnToolsEnv('DBN_AZURE_OPENAI_API_VERSION', ''),
|
||||
'chat_deployment' => (string)dbnToolsEnv('DBN_AZURE_OPENAI_CHAT_DEPLOYMENT', ''),
|
||||
'embedding_deployment' => (string)dbnToolsEnv('DBN_AZURE_OPENAI_EMBEDDING_DEPLOYMENT', ''),
|
||||
];
|
||||
}
|
||||
|
||||
public function missingChatConfig(): array
|
||||
{
|
||||
$missing = [];
|
||||
foreach (['endpoint', 'api_key', 'api_version', 'chat_deployment'] as $key) {
|
||||
if (trim((string)($this->config[$key] ?? '')) === '') {
|
||||
$missing[] = $key;
|
||||
}
|
||||
}
|
||||
return $missing;
|
||||
}
|
||||
|
||||
public function missingEmbeddingConfig(): array
|
||||
{
|
||||
$missing = [];
|
||||
foreach (['endpoint', 'api_key', 'api_version', 'embedding_deployment'] as $key) {
|
||||
if (trim((string)($this->config[$key] ?? '')) === '') {
|
||||
$missing[] = $key;
|
||||
}
|
||||
}
|
||||
return $missing;
|
||||
}
|
||||
|
||||
public function chatDeployment(): string
|
||||
{
|
||||
return (string)$this->config['chat_deployment'];
|
||||
}
|
||||
|
||||
public function embeddingDeployment(): string
|
||||
{
|
||||
return (string)$this->config['embedding_deployment'];
|
||||
}
|
||||
|
||||
public function requireChat(): void
|
||||
{
|
||||
$missing = $this->missingChatConfig();
|
||||
if ($missing) {
|
||||
dbnToolsAbort(
|
||||
'Azure OpenAI chat gateway is missing configuration: ' . implode(', ', $missing) . '.',
|
||||
503,
|
||||
'azure_config_missing',
|
||||
['missing' => $missing]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function requireEmbedding(): void
|
||||
{
|
||||
$missing = $this->missingEmbeddingConfig();
|
||||
if ($missing) {
|
||||
dbnToolsAbort(
|
||||
'Azure OpenAI embedding gateway is missing configuration: ' . implode(', ', $missing) . '.',
|
||||
503,
|
||||
'azure_embedding_config_missing',
|
||||
['missing' => $missing]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function embeddings(array|string $input, array $options = []): array
|
||||
{
|
||||
$this->requireEmbedding();
|
||||
|
||||
$url = $this->config['endpoint']
|
||||
. '/openai/deployments/'
|
||||
. rawurlencode((string)$this->config['embedding_deployment'])
|
||||
. '/embeddings?api-version='
|
||||
. rawurlencode((string)$this->config['api_version']);
|
||||
|
||||
return $this->postJson($url, ['input' => $input], (int)($options['timeout'] ?? 30));
|
||||
}
|
||||
|
||||
public function chatText(array $messages, array $options = []): string
|
||||
{
|
||||
$response = $this->chat($messages, $options);
|
||||
$content = $response['choices'][0]['message']['content'] ?? '';
|
||||
if (!is_string($content) || trim($content) === '') {
|
||||
throw new RuntimeException('Azure OpenAI returned an empty chat response.');
|
||||
}
|
||||
return trim($content);
|
||||
}
|
||||
|
||||
public function chat(array $messages, array $options = []): array
|
||||
{
|
||||
$this->requireChat();
|
||||
|
||||
$payload = [
|
||||
'messages' => $messages,
|
||||
'temperature' => $options['temperature'] ?? 0.2,
|
||||
'max_tokens' => $options['max_tokens'] ?? 1200,
|
||||
];
|
||||
if (!empty($options['json'])) {
|
||||
$payload['response_format'] = ['type' => 'json_object'];
|
||||
}
|
||||
|
||||
$url = $this->config['endpoint']
|
||||
. '/openai/deployments/'
|
||||
. rawurlencode((string)$this->config['chat_deployment'])
|
||||
. '/chat/completions?api-version='
|
||||
. rawurlencode((string)$this->config['api_version']);
|
||||
|
||||
return $this->postJson($url, $payload, (int)($options['timeout'] ?? 45));
|
||||
}
|
||||
|
||||
public function ping(int $timeout = 8): bool
|
||||
{
|
||||
try {
|
||||
$text = $this->chatText([
|
||||
['role' => 'system', 'content' => 'Return one word only: ok'],
|
||||
['role' => 'user', 'content' => 'health'],
|
||||
], [
|
||||
'temperature' => 0,
|
||||
'max_tokens' => 5,
|
||||
'timeout' => $timeout,
|
||||
]);
|
||||
return trim($text) !== '';
|
||||
} catch (Throwable $e) {
|
||||
error_log('DBN Azure health check failed: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function decodeJsonObject(string $content): ?array
|
||||
{
|
||||
$content = trim($content);
|
||||
$decoded = json_decode($content, true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
if (preg_match('/\{(?:[^{}]|(?R))*\}/s', $content, $match)) {
|
||||
$decoded = json_decode($match[0], true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function postJson(string $url, array $payload, int $timeout): array
|
||||
{
|
||||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($body === false) {
|
||||
throw new RuntimeException('Unable to encode Azure OpenAI request.');
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'api-key: ' . $this->config['api_key'],
|
||||
];
|
||||
|
||||
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);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
throw new RuntimeException('Azure OpenAI request failed: ' . $error);
|
||||
}
|
||||
return $this->decodeResponse($response, $code);
|
||||
}
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => implode("\r\n", $headers),
|
||||
'content' => $body,
|
||||
'timeout' => $timeout,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
$response = @file_get_contents($url, false, $context);
|
||||
$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('Azure OpenAI request failed.');
|
||||
}
|
||||
return $this->decodeResponse($response, $code);
|
||||
}
|
||||
|
||||
private function decodeResponse(string $response, int $code): array
|
||||
{
|
||||
$decoded = json_decode($response, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new RuntimeException('Azure OpenAI returned non-JSON response.');
|
||||
}
|
||||
if ($code < 200 || $code >= 300) {
|
||||
$message = $decoded['error']['message'] ?? ('HTTP ' . $code);
|
||||
throw new RuntimeException('Azure OpenAI request failed: ' . $message);
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/AzureOpenAiGateway.php';
|
||||
|
||||
final class DbnLegalToolsService
|
||||
{
|
||||
private const MAX_PASTE_CHARS = 32000;
|
||||
|
||||
private DbnAzureOpenAiGateway $azure;
|
||||
|
||||
public function __construct(?DbnAzureOpenAiGateway $azure = null)
|
||||
{
|
||||
$this->azure = $azure ?: new DbnAzureOpenAiGateway();
|
||||
}
|
||||
|
||||
public function search(string $query, string $language = 'en', int $limit = 6): array
|
||||
{
|
||||
$query = trim($query);
|
||||
if (mb_strlen($query, 'UTF-8') < 3) {
|
||||
dbnToolsAbort('Search query must be at least 3 characters.', 422, 'query_too_short');
|
||||
}
|
||||
$limit = max(1, min(10, $limit));
|
||||
|
||||
$trace = [
|
||||
$this->trace('Query interpretation', 'Searching Dave Jr Legal private corpus plus the subscribed family-legal package.', 'complete'),
|
||||
$this->trace('Search tools used', 'ClientRagPipeline::searchAll with keyword mode, private corpus enabled, shared package filter set to family-legal.', 'running'),
|
||||
];
|
||||
|
||||
$client = dbnToolsRequireClient();
|
||||
$package = $this->requireFamilyPackage((int)$client['id']);
|
||||
|
||||
$chunks = [];
|
||||
$retrievalNote = 'ClientRagPipeline keyword retrieval';
|
||||
try {
|
||||
dbnToolsBootCaveau();
|
||||
$gatewayUrl = 'http://10.0.1.10:4000';
|
||||
try {
|
||||
$config = getConfig();
|
||||
$configured = trim((string)($config['ai_gateway']['url'] ?? ''));
|
||||
if ($configured !== '') {
|
||||
$gatewayUrl = $configured;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Retrieval still works in keyword mode without gateway config.
|
||||
}
|
||||
|
||||
$rag = new ClientRagPipeline((int)$client['id'], $gatewayUrl, 30);
|
||||
$chunks = $rag->searchAll($query, $limit, null, [
|
||||
'search_private' => true,
|
||||
'search_shared' => true,
|
||||
'package_ids' => [(int)$package['id']],
|
||||
'chunk_limit' => $limit,
|
||||
'search_method' => 'keyword',
|
||||
'min_private' => 0,
|
||||
'include_beta_website' => true,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$retrievalNote = 'SQL keyword fallback after ClientRagPipeline error';
|
||||
$trace[] = $this->trace('Search fallback', 'Pipeline retrieval failed; using direct SQL keyword fallback without storing the query.', 'warning');
|
||||
$chunks = $this->fallbackKeywordSearch((int)$client['id'], $package, $query, $limit);
|
||||
}
|
||||
|
||||
if (!$chunks) {
|
||||
$fallback = $this->fallbackKeywordSearch((int)$client['id'], $package, $query, $limit);
|
||||
if ($fallback) {
|
||||
$chunks = $fallback;
|
||||
$retrievalNote = 'SQL keyword fallback';
|
||||
}
|
||||
}
|
||||
|
||||
$hits = array_map(fn(array $chunk): array => $this->sourceFromChunk($chunk), array_slice($chunks, 0, $limit));
|
||||
$confidence = $this->citationConfidence($hits);
|
||||
|
||||
$trace[1] = $this->trace('Search tools used', $retrievalNote . '; returned ' . count($hits) . ' source hit(s).', 'complete');
|
||||
$trace[] = $this->trace('Evidence found', count($hits) ? 'Retrieved source excerpts for review.' : 'No matching source excerpts were found.', count($hits) ? 'complete' : 'warning');
|
||||
$trace[] = $this->trace('Citation confidence', ucfirst($confidence) . ' confidence based on source count and retrieval scores.', $confidence === 'low' ? 'warning' : 'complete');
|
||||
|
||||
return [
|
||||
'tool' => 'search',
|
||||
'language' => $language,
|
||||
'what_we_found' => count($hits) ? 'Found source excerpts from the legal corpus.' : 'No matching source excerpts were found.',
|
||||
'hits' => $hits,
|
||||
'evidence_trail' => $hits,
|
||||
'what_remains_uncertain' => count($hits) ? 'Search results still need human review for legal relevance and currentness.' : 'The corpus may not contain enough evidence for this query.',
|
||||
'next_practical_step' => count($hits) ? 'Open the strongest sources and confirm the cited sections before relying on them.' : 'Try a narrower query with statutory terms, party names, or dates.',
|
||||
'trace' => $trace,
|
||||
'trace_metadata' => [
|
||||
'chunk_count' => count($chunks),
|
||||
'source_count' => count($hits),
|
||||
'deployment' => null,
|
||||
'citation_confidence' => $confidence,
|
||||
],
|
||||
'disclaimer' => dbnToolsDisclaimer($language),
|
||||
];
|
||||
}
|
||||
|
||||
public function ask(string $question, string $language = 'en'): array
|
||||
{
|
||||
$search = $this->search($question, $language, 7);
|
||||
$hits = $search['hits'];
|
||||
$trace = $search['trace'];
|
||||
|
||||
if (!$hits) {
|
||||
$trace[] = $this->trace('Synthesis', 'Skipped answer synthesis because no evidence was found.', 'warning');
|
||||
return [
|
||||
'tool' => 'ask',
|
||||
'language' => $language,
|
||||
'answer' => $language === 'no'
|
||||
? 'Jeg fant ikke nok kildestøtte i familie-rettskorpuset til å svare sikkert.'
|
||||
: 'I did not find enough source support in the family-law corpus to answer safely.',
|
||||
'what_we_found' => $search['what_we_found'],
|
||||
'evidence_trail' => [],
|
||||
'what_remains_uncertain' => $search['what_remains_uncertain'],
|
||||
'next_practical_step' => $search['next_practical_step'],
|
||||
'trace' => $trace,
|
||||
'trace_metadata' => [
|
||||
'chunk_count' => 0,
|
||||
'source_count' => 0,
|
||||
'deployment' => null,
|
||||
'citation_confidence' => 'low',
|
||||
],
|
||||
'disclaimer' => dbnToolsDisclaimer($language),
|
||||
];
|
||||
}
|
||||
|
||||
$this->azure->requireChat();
|
||||
|
||||
$context = $this->buildEvidenceContext($hits);
|
||||
$locale = $language === 'no' ? 'Norwegian' : 'English';
|
||||
$prompt = <<<PROMPT
|
||||
Question:
|
||||
{$question}
|
||||
|
||||
Evidence excerpts:
|
||||
{$context}
|
||||
|
||||
Return JSON only with these keys:
|
||||
{
|
||||
"answer": "short direct answer in {$locale}",
|
||||
"what_we_found": "plain-language summary of the supported finding",
|
||||
"evidence_trail": [{"title":"source title","why_it_matters":"one sentence","citation":"visible source title or section"}],
|
||||
"what_remains_uncertain": ["specific gaps or caveats"],
|
||||
"next_practical_step": "one concrete next action"
|
||||
}
|
||||
PROMPT;
|
||||
|
||||
$system = $this->legalJsonSystemPrompt($language);
|
||||
$raw = $this->azure->chatText([
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $prompt],
|
||||
], [
|
||||
'json' => true,
|
||||
'temperature' => 0.15,
|
||||
'max_tokens' => 1300,
|
||||
]);
|
||||
|
||||
$json = $this->azure->decodeJsonObject($raw);
|
||||
if (!$json) {
|
||||
$json = [
|
||||
'answer' => $raw,
|
||||
'what_we_found' => 'Azure returned a plain-text answer based on the retrieved excerpts.',
|
||||
'evidence_trail' => [],
|
||||
'what_remains_uncertain' => ['The response format could not be validated as structured JSON.'],
|
||||
'next_practical_step' => 'Review the source excerpts manually before relying on the answer.',
|
||||
];
|
||||
}
|
||||
|
||||
$trace[] = $this->trace('Synthesis', 'Azure OpenAI generated an answer using only the retrieved source excerpts.', 'complete');
|
||||
$trace[] = $this->trace('Uncertainty / missing evidence', $this->uncertaintySummary($json['what_remains_uncertain'] ?? []), 'complete');
|
||||
$trace[] = $this->trace('Next practical step', (string)($json['next_practical_step'] ?? 'Review the evidence trail.'), 'complete');
|
||||
|
||||
return [
|
||||
'tool' => 'ask',
|
||||
'language' => $language,
|
||||
'answer' => (string)($json['answer'] ?? ''),
|
||||
'what_we_found' => (string)($json['what_we_found'] ?? ''),
|
||||
'evidence_trail' => $hits,
|
||||
'citation_notes' => $this->normalizeEvidenceTrail($json['evidence_trail'] ?? [], $hits),
|
||||
'sources' => $hits,
|
||||
'what_remains_uncertain' => $json['what_remains_uncertain'] ?? [],
|
||||
'next_practical_step' => (string)($json['next_practical_step'] ?? ''),
|
||||
'trace' => $trace,
|
||||
'trace_metadata' => [
|
||||
'chunk_count' => count($hits),
|
||||
'source_count' => count($hits),
|
||||
'deployment' => $this->azure->chatDeployment(),
|
||||
'citation_confidence' => $search['trace_metadata']['citation_confidence'] ?? 'medium',
|
||||
],
|
||||
'disclaimer' => dbnToolsDisclaimer($language),
|
||||
];
|
||||
}
|
||||
|
||||
public function summarize(string $text, string $language = 'en'): array
|
||||
{
|
||||
$text = $this->requirePasteText($text);
|
||||
$this->azure->requireChat();
|
||||
|
||||
$locale = $language === 'no' ? 'Norwegian' : 'English';
|
||||
$prompt = <<<PROMPT
|
||||
Summarize this pasted case-preparation text in {$locale}. Do not invent missing facts.
|
||||
|
||||
Pasted text:
|
||||
{$text}
|
||||
|
||||
Return JSON only:
|
||||
{
|
||||
"what_we_found": "plain-language summary",
|
||||
"key_facts": ["fact"],
|
||||
"dates": ["date or unknown"],
|
||||
"parties": ["party or role"],
|
||||
"legal_references_detected": ["reference"],
|
||||
"what_remains_uncertain": ["uncertainty"],
|
||||
"next_practical_step": "one concrete next action"
|
||||
}
|
||||
PROMPT;
|
||||
|
||||
$json = $this->runJsonTool($prompt, $language, 1300);
|
||||
$trace = [
|
||||
$this->trace('Query interpretation', 'Summarize pasted text without saving the text or output.', 'complete'),
|
||||
$this->trace('Search tools used', 'No external corpus search; source is the user-pasted text.', 'complete'),
|
||||
$this->trace('Evidence found', 'Evidence trail is limited to the pasted text supplied in this request.', 'complete'),
|
||||
$this->trace('Citation confidence', 'Medium confidence for factual extraction; no external legal source verification was performed.', 'warning'),
|
||||
$this->trace('Uncertainty / missing evidence', $this->uncertaintySummary($json['what_remains_uncertain'] ?? []), 'complete'),
|
||||
$this->trace('Next practical step', (string)($json['next_practical_step'] ?? 'Review the summary against the original text.'), 'complete'),
|
||||
];
|
||||
|
||||
return [
|
||||
'tool' => 'summarize',
|
||||
'language' => $language,
|
||||
'what_we_found' => (string)($json['what_we_found'] ?? ''),
|
||||
'key_facts' => $json['key_facts'] ?? [],
|
||||
'dates' => $json['dates'] ?? [],
|
||||
'parties' => $json['parties'] ?? [],
|
||||
'legal_references_detected' => $json['legal_references_detected'] ?? [],
|
||||
'evidence_trail' => [['title' => 'Pasted text', 'excerpt' => 'Processed in-memory only; not stored.']],
|
||||
'what_remains_uncertain' => $json['what_remains_uncertain'] ?? [],
|
||||
'next_practical_step' => (string)($json['next_practical_step'] ?? ''),
|
||||
'trace' => $trace,
|
||||
'trace_metadata' => [
|
||||
'chunk_count' => 1,
|
||||
'source_count' => 1,
|
||||
'deployment' => $this->azure->chatDeployment(),
|
||||
],
|
||||
'disclaimer' => dbnToolsDisclaimer($language),
|
||||
];
|
||||
}
|
||||
|
||||
public function timeline(string $text, string $language = 'en'): array
|
||||
{
|
||||
$text = $this->requirePasteText($text);
|
||||
$this->azure->requireChat();
|
||||
|
||||
$locale = $language === 'no' ? 'Norwegian' : 'English';
|
||||
$prompt = <<<PROMPT
|
||||
Build a chronological timeline from this pasted text in {$locale}. Keep uncertain dates explicit.
|
||||
|
||||
Pasted text:
|
||||
{$text}
|
||||
|
||||
Return JSON only:
|
||||
{
|
||||
"what_we_found": "short overview",
|
||||
"events": [{"date":"YYYY-MM-DD, month/year, or unknown","actor":"actor or unknown","event":"event","source_excerpt":"short excerpt","confidence":"high|medium|low"}],
|
||||
"evidence_trail": [{"title":"Pasted text","excerpt":"short relevant excerpt"}],
|
||||
"what_remains_uncertain": ["uncertainty"],
|
||||
"next_practical_step": "one concrete next action"
|
||||
}
|
||||
PROMPT;
|
||||
|
||||
$json = $this->runJsonTool($prompt, $language, 1600);
|
||||
$events = is_array($json['events'] ?? null) ? $json['events'] : [];
|
||||
$trace = [
|
||||
$this->trace('Query interpretation', 'Extract dated events from pasted text without saving the text or output.', 'complete'),
|
||||
$this->trace('Search tools used', 'No external corpus search; source is the user-pasted text.', 'complete'),
|
||||
$this->trace('Evidence found', count($events) . ' event(s) identified.', count($events) ? 'complete' : 'warning'),
|
||||
$this->trace('Citation confidence', 'Confidence is per event and based only on the pasted text.', 'complete'),
|
||||
$this->trace('Uncertainty / missing evidence', $this->uncertaintySummary($json['what_remains_uncertain'] ?? []), 'complete'),
|
||||
$this->trace('Next practical step', (string)($json['next_practical_step'] ?? 'Verify dates against original documents.'), 'complete'),
|
||||
];
|
||||
|
||||
return [
|
||||
'tool' => 'timeline',
|
||||
'language' => $language,
|
||||
'what_we_found' => (string)($json['what_we_found'] ?? ''),
|
||||
'events' => $events,
|
||||
'evidence_trail' => $json['evidence_trail'] ?? [['title' => 'Pasted text', 'excerpt' => 'Processed in-memory only; not stored.']],
|
||||
'what_remains_uncertain' => $json['what_remains_uncertain'] ?? [],
|
||||
'next_practical_step' => (string)($json['next_practical_step'] ?? ''),
|
||||
'trace' => $trace,
|
||||
'trace_metadata' => [
|
||||
'chunk_count' => count($events),
|
||||
'source_count' => 1,
|
||||
'deployment' => $this->azure->chatDeployment(),
|
||||
],
|
||||
'disclaimer' => dbnToolsDisclaimer($language),
|
||||
];
|
||||
}
|
||||
|
||||
public function redact(string $text, string $mode = 'standard'): array
|
||||
{
|
||||
$text = $this->requirePasteText($text);
|
||||
$mode = $mode === 'strict' ? 'strict' : 'standard';
|
||||
[$redacted, $entities] = $this->deterministicRedaction($text, $mode);
|
||||
|
||||
$categories = array_keys(array_filter($entities, fn(int $count): bool => $count > 0));
|
||||
$trace = [
|
||||
$this->trace('Query interpretation', 'Detect and redact sensitive identifiers from pasted text.', 'complete'),
|
||||
$this->trace('Search tools used', 'Deterministic Norwegian privacy patterns first; no text was stored.', 'complete'),
|
||||
$this->trace('Evidence found', count($categories) ? 'Detected categories: ' . implode(', ', $categories) . '.' : 'No deterministic sensitive categories were detected.', count($categories) ? 'complete' : 'warning'),
|
||||
$this->trace('Citation confidence', 'High for emails and fødselsnummer-like values; medium for addresses and names.', 'complete'),
|
||||
$this->trace('Uncertainty / missing evidence', 'Contextual names may need human review, especially in standard mode.', 'warning'),
|
||||
$this->trace('Next practical step', 'Review the redacted output before sharing it outside the case team.', 'complete'),
|
||||
];
|
||||
|
||||
return [
|
||||
'tool' => 'redact',
|
||||
'mode' => $mode,
|
||||
'what_we_found' => 'Redacted deterministic privacy patterns from the pasted text.',
|
||||
'redacted_text' => $redacted,
|
||||
'detected_entity_categories' => $categories,
|
||||
'entity_counts' => $entities,
|
||||
'evidence_trail' => [['title' => 'Pasted text', 'excerpt' => 'Processed in-memory only; not stored.']],
|
||||
'what_remains_uncertain' => ['Human review is still needed for names that depend on case context.'],
|
||||
'next_practical_step' => 'Review the output and rerun in strict mode if the text will be shared broadly.',
|
||||
'trace' => $trace,
|
||||
'trace_metadata' => [
|
||||
'chunk_count' => 1,
|
||||
'source_count' => 1,
|
||||
'deployment' => null,
|
||||
],
|
||||
'disclaimer' => 'Privacy support tool. Review before disclosure.',
|
||||
];
|
||||
}
|
||||
|
||||
private function requireFamilyPackage(int $clientId): array
|
||||
{
|
||||
$package = dbnToolsFetchPackage('family-legal');
|
||||
if (!$package || empty($package['is_active'])) {
|
||||
dbnToolsAbort('The family-legal corpus package is not active.', 503, 'package_unavailable');
|
||||
}
|
||||
if (!dbnToolsHasActiveSubscription($clientId, (int)$package['id'])) {
|
||||
dbnToolsAbort('Dave Jr Legal does not have an active family-legal subscription.', 503, 'subscription_missing');
|
||||
}
|
||||
return $package;
|
||||
}
|
||||
|
||||
private function runJsonTool(string $prompt, string $language, int $maxTokens): array
|
||||
{
|
||||
$raw = $this->azure->chatText([
|
||||
['role' => 'system', 'content' => $this->legalJsonSystemPrompt($language)],
|
||||
['role' => 'user', 'content' => $prompt],
|
||||
], [
|
||||
'json' => true,
|
||||
'temperature' => 0.1,
|
||||
'max_tokens' => $maxTokens,
|
||||
]);
|
||||
$json = $this->azure->decodeJsonObject($raw);
|
||||
if (!$json) {
|
||||
dbnToolsAbort('Azure OpenAI did not return valid structured JSON.', 502, 'azure_invalid_json');
|
||||
}
|
||||
return $json;
|
||||
}
|
||||
|
||||
private function legalJsonSystemPrompt(string $language): string
|
||||
{
|
||||
$locale = $language === 'no' ? 'Norwegian' : 'English';
|
||||
return <<<PROMPT
|
||||
You are Do Better Norge Legal Tools in a source-grounded legal preparation workflow.
|
||||
Use the DBN legal guardrails:
|
||||
- Answer only from provided source excerpts or pasted text.
|
||||
- Treat your role as legal information and issue-spotting, not final legal advice.
|
||||
- Never invent statutes, paragraph numbers, case names, citations, parties, dates, or sources.
|
||||
- If evidence is insufficient, say so plainly.
|
||||
- Respond in {$locale}.
|
||||
- Return valid JSON only. No markdown fences.
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
private function buildEvidenceContext(array $hits): string
|
||||
{
|
||||
$lines = [];
|
||||
foreach ($hits as $idx => $hit) {
|
||||
$n = $idx + 1;
|
||||
$lines[] = "[{$n}] Title: " . ($hit['title'] ?? 'Untitled');
|
||||
if (!empty($hit['section'])) {
|
||||
$lines[] = "Section: " . $hit['section'];
|
||||
}
|
||||
$lines[] = "Corpus/package: " . ($hit['package_or_corpus'] ?? 'unknown');
|
||||
$lines[] = "Excerpt: " . ($hit['excerpt'] ?? '');
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function normalizeEvidenceTrail(mixed $trail, array $hits): array
|
||||
{
|
||||
if (!is_array($trail) || !$trail) {
|
||||
return array_map(fn(array $hit): array => [
|
||||
'title' => $hit['title'],
|
||||
'citation' => $hit['title'],
|
||||
'why_it_matters' => dbnToolsExcerpt($hit['excerpt'], 180),
|
||||
], array_slice($hits, 0, 4));
|
||||
}
|
||||
return array_values(array_filter($trail, 'is_array'));
|
||||
}
|
||||
|
||||
private function sourceFromChunk(array $chunk): array
|
||||
{
|
||||
$title = (string)($chunk['document_title'] ?? $chunk['title'] ?? 'Untitled source');
|
||||
$score = isset($chunk['similarity']) ? round((float)$chunk['similarity'], 4) : null;
|
||||
return [
|
||||
'title' => $title,
|
||||
'excerpt' => dbnToolsExcerpt((string)($chunk['content'] ?? ''), 620),
|
||||
'package_or_corpus' => (string)($chunk['source_name'] ?? $chunk['source_type'] ?? 'Dave Jr Legal'),
|
||||
'score' => $score,
|
||||
'document_id' => isset($chunk['document_id']) ? (int)$chunk['document_id'] : null,
|
||||
'chunk_id' => isset($chunk['id']) ? (int)$chunk['id'] : null,
|
||||
'section' => $chunk['section_title'] ?? null,
|
||||
'authority_type' => $chunk['authority_type'] ?? null,
|
||||
'jurisdiction' => $chunk['jurisdiction'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function citationConfidence(array $hits): string
|
||||
{
|
||||
if (!$hits) {
|
||||
return 'low';
|
||||
}
|
||||
$scores = array_values(array_filter(array_map(fn(array $h) => $h['score'] ?? null, $hits), 'is_numeric'));
|
||||
$best = $scores ? max($scores) : 0;
|
||||
if (count($hits) >= 3 && $best >= 0.35) {
|
||||
return 'high';
|
||||
}
|
||||
if (count($hits) >= 1) {
|
||||
return 'medium';
|
||||
}
|
||||
return 'low';
|
||||
}
|
||||
|
||||
private function fallbackKeywordSearch(int $clientId, array $package, string $query, int $limit): array
|
||||
{
|
||||
$results = [];
|
||||
try {
|
||||
$results = array_merge($results, $this->fallbackPrivateSearch($clientId, $query, $limit));
|
||||
} catch (Throwable $e) {
|
||||
error_log('DBN tools private fallback failed: ' . $e->getMessage());
|
||||
}
|
||||
try {
|
||||
$remaining = max(1, $limit - count($results));
|
||||
$results = array_merge($results, $this->fallbackSharedSearch($package, $query, $remaining));
|
||||
} catch (Throwable $e) {
|
||||
error_log('DBN tools shared fallback failed: ' . $e->getMessage());
|
||||
}
|
||||
return array_slice($results, 0, $limit);
|
||||
}
|
||||
|
||||
private function fallbackPrivateSearch(int $clientId, string $query, int $limit): array
|
||||
{
|
||||
$db = dbnToolsDb();
|
||||
$terms = $this->searchTerms($query);
|
||||
if (!$terms) {
|
||||
return [];
|
||||
}
|
||||
$clauses = [];
|
||||
$params = [':client_id' => $clientId];
|
||||
foreach ($terms as $i => $term) {
|
||||
$key = ':term' . $i;
|
||||
$clauses[] = "(cc.content LIKE {$key} OR cd.title LIKE {$key})";
|
||||
$params[$key] = '%' . $term . '%';
|
||||
}
|
||||
$sql = 'SELECT cc.id, cc.document_id, cc.content, cd.title AS document_title, cd.category
|
||||
FROM client_chunks cc
|
||||
JOIN client_documents cd ON cc.document_id = cd.id
|
||||
WHERE cc.client_id = :client_id AND cd.status = "ready" AND (' . implode(' OR ', $clauses) . ')
|
||||
LIMIT ' . (int)$limit;
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as &$row) {
|
||||
$row['similarity'] = 0.25;
|
||||
$row['source_name'] = 'Dave Jr Legal private corpus';
|
||||
$row['source_type'] = 'private';
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function fallbackSharedSearch(array $package, string $query, int $limit): array
|
||||
{
|
||||
$ragDb = dbnToolsRagDb();
|
||||
$terms = $this->searchTerms($query);
|
||||
if (!$terms) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$where = ['d.status = "ready"'];
|
||||
$params = [];
|
||||
|
||||
if (!empty($package['corpus_id'])) {
|
||||
$where[] = 'd.corpus_id = ?';
|
||||
$params[] = (int)$package['corpus_id'];
|
||||
}
|
||||
|
||||
$cats = json_decode((string)($package['category_filter'] ?? '[]'), true) ?: [];
|
||||
if ($cats) {
|
||||
$where[] = 'd.category IN (' . implode(',', array_fill(0, count($cats), '?')) . ')';
|
||||
$params = array_merge($params, $cats);
|
||||
}
|
||||
|
||||
$langs = json_decode((string)($package['language_filter'] ?? '[]'), true) ?: [];
|
||||
if ($langs) {
|
||||
$where[] = 'd.language IN (' . implode(',', array_fill(0, count($langs), '?')) . ')';
|
||||
$params = array_merge($params, $langs);
|
||||
}
|
||||
|
||||
$termClauses = [];
|
||||
foreach ($terms as $term) {
|
||||
$termClauses[] = '(c.content LIKE ? OR d.title LIKE ?)';
|
||||
$params[] = '%' . $term . '%';
|
||||
$params[] = '%' . $term . '%';
|
||||
}
|
||||
$where[] = '(' . implode(' OR ', $termClauses) . ')';
|
||||
|
||||
$sql = 'SELECT c.id, c.document_id, c.content, c.section_title, d.title AS document_title,
|
||||
d.category, d.language
|
||||
FROM chunks c
|
||||
JOIN documents d ON c.document_id = d.id
|
||||
WHERE ' . implode(' AND ', $where) . '
|
||||
LIMIT ' . (int)$limit;
|
||||
$stmt = $ragDb->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as &$row) {
|
||||
$row['similarity'] = 0.2;
|
||||
$row['source_name'] = (string)($package['name'] ?? 'family-legal');
|
||||
$row['source_type'] = 'package';
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function searchTerms(string $query): array
|
||||
{
|
||||
$parts = preg_split('/[^\p{L}\p{N}]+/u', mb_strtolower($query, 'UTF-8')) ?: [];
|
||||
$stop = ['the', 'and', 'for', 'with', 'that', 'this', 'hva', 'har', 'kan', 'jeg', 'som', 'det', 'med', 'til', 'og'];
|
||||
$terms = [];
|
||||
foreach ($parts as $part) {
|
||||
if (mb_strlen($part, 'UTF-8') < 3 || in_array($part, $stop, true)) {
|
||||
continue;
|
||||
}
|
||||
$terms[] = $part;
|
||||
}
|
||||
return array_slice(array_values(array_unique($terms)), 0, 6);
|
||||
}
|
||||
|
||||
private function requirePasteText(string $text): string
|
||||
{
|
||||
$text = trim($text);
|
||||
if (mb_strlen($text, 'UTF-8') < 20) {
|
||||
dbnToolsAbort('Paste at least 20 characters of text.', 422, 'text_too_short');
|
||||
}
|
||||
if (mb_strlen($text, 'UTF-8') > self::MAX_PASTE_CHARS) {
|
||||
dbnToolsAbort('Pasted text is too long for the MVP limit.', 422, 'text_too_long');
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
private function deterministicRedaction(string $text, string $mode): array
|
||||
{
|
||||
$counts = [
|
||||
'email' => 0,
|
||||
'phone' => 0,
|
||||
'fødselsnummer' => 0,
|
||||
'address' => 0,
|
||||
'person_or_child_name' => 0,
|
||||
];
|
||||
|
||||
$replace = function (string $pattern, string $category, string $token) use (&$text, &$counts): void {
|
||||
$text = preg_replace_callback($pattern, function () use (&$counts, $category, $token): string {
|
||||
$counts[$category]++;
|
||||
return $token;
|
||||
}, $text) ?? $text;
|
||||
};
|
||||
|
||||
$replace('/\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b/i', 'email', '[EMAIL]');
|
||||
$replace('/(?<!\d)(?:\d{6}[\s\-]?\d{5}|\d{11})(?!\d)/u', 'fødselsnummer', '[FNR]');
|
||||
$replace('/(?<!\d)(?:\+47[\s.\-]?)?(?:\d[\s.\-]?){8}(?!\d)/u', 'phone', '[PHONE]');
|
||||
$replace('/\b[A-ZÆØÅ][\p{L}æøåÆØÅ\.\- ]{2,40}\s+(?:gate|gata|vei|veien|plass|street|road|avenue|ave)\s+\d+[A-Z]?\b/iu', 'address', '[ADDRESS]');
|
||||
|
||||
$text = preg_replace_callback(
|
||||
'/\b(Barn|Child|Navn|Name|Mor|Far|Mother|Father|Sønn|Datter)\s*:\s*([^\r\n,.;]+)/iu',
|
||||
function (array $m) use (&$counts): string {
|
||||
$counts['person_or_child_name']++;
|
||||
return $m[1] . ': [PERSON]';
|
||||
},
|
||||
$text
|
||||
) ?? $text;
|
||||
|
||||
$text = preg_replace_callback(
|
||||
'/\b(?:barnet|child|sønn|son|datter|daughter)\s+(?:heter|named|called)?\s*([A-ZÆØÅ][\p{L}æøåÆØÅ\-]{2,})\b/iu',
|
||||
function () use (&$counts): string {
|
||||
$counts['person_or_child_name']++;
|
||||
return '[CHILD_IDENTIFIER]';
|
||||
},
|
||||
$text
|
||||
) ?? $text;
|
||||
|
||||
if ($mode === 'strict') {
|
||||
$replace('/\b[A-ZÆØÅ][\p{L}æøåÆØÅ\-]{2,}\s+[A-ZÆØÅ][\p{L}æøåÆØÅ\-]{2,}\b/u', 'person_or_child_name', '[PERSON]');
|
||||
}
|
||||
|
||||
return [$text, $counts];
|
||||
}
|
||||
|
||||
private function uncertaintySummary(mixed $uncertainty): string
|
||||
{
|
||||
if (is_array($uncertainty)) {
|
||||
$uncertainty = implode(' ', array_map('strval', $uncertainty));
|
||||
}
|
||||
$uncertainty = trim((string)$uncertainty);
|
||||
return $uncertainty !== '' ? dbnToolsExcerpt($uncertainty, 220) : 'No additional uncertainty was supplied by the tool.';
|
||||
}
|
||||
|
||||
private function trace(string $label, string $detail, string $status = 'complete'): array
|
||||
{
|
||||
return [
|
||||
'label' => $label,
|
||||
'detail' => $detail,
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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')) . '…';
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/includes/bootstrap.php';
|
||||
$authenticated = dbnToolsIsAuthenticated();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Do Better Norge Legal Tools</title>
|
||||
<meta name="description" content="Do Better Norge legal preparation tools with source-grounded evidence trails.">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<link rel="canonical" href="https://tools.dobetternorge.com/">
|
||||
<meta property="og:title" content="Do Better Norge Legal Tools">
|
||||
<meta property="og:description" content="Legal preparation tools with visible evidence trails and uncertainty notes.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://tools.dobetternorge.com/">
|
||||
<meta name="theme-color" content="#f7f8fb">
|
||||
<link rel="stylesheet" href="assets/css/tools.css">
|
||||
</head>
|
||||
<body data-authenticated="<?= $authenticated ? 'true' : 'false' ?>">
|
||||
<section id="passcodeGate" class="gate<?= $authenticated ? ' is-hidden' : '' ?>" aria-labelledby="gateTitle">
|
||||
<div class="gate-panel">
|
||||
<p class="eyebrow">Do Better Norge</p>
|
||||
<h1 id="gateTitle">Legal Tools</h1>
|
||||
<p class="gate-copy">Legal information and preparation support, not final legal advice.</p>
|
||||
<form id="passcodeForm" class="passcode-form">
|
||||
<label for="loginEmail">Email</label>
|
||||
<input id="loginEmail" name="email" type="email" autocomplete="username email" required>
|
||||
<label for="loginPassword">Password</label>
|
||||
<div class="passcode-row">
|
||||
<input id="loginPassword" name="password" type="password" autocomplete="current-password" required>
|
||||
<button type="submit">Sign in</button>
|
||||
</div>
|
||||
<p id="gateStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main id="appShell" class="app-shell<?= $authenticated ? '' : ' is-hidden' ?>">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Do Better Norge</p>
|
||||
<h1>Legal Tools Hub</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<span id="healthPill" class="status-pill">Session active</span>
|
||||
<button id="healthButton" class="secondary-button" type="button">Health</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="disclaimer" role="note">
|
||||
Legal information and preparation support, not final legal advice. Pasted text is processed in memory by default.
|
||||
</div>
|
||||
|
||||
<section class="workspace" aria-label="Legal tools workspace">
|
||||
<nav class="tool-rail" aria-label="Tools">
|
||||
<button type="button" class="tool-tab is-active" data-tool="ask" aria-pressed="true">
|
||||
<span>Ask</span>
|
||||
<small>Source-grounded</small>
|
||||
</button>
|
||||
<button type="button" class="tool-tab" data-tool="search" aria-pressed="false">
|
||||
<span>Search</span>
|
||||
<small>Legal sources</small>
|
||||
</button>
|
||||
<button type="button" class="tool-tab" data-tool="summarize" aria-pressed="false">
|
||||
<span>Summarize</span>
|
||||
<small>Pasted text</small>
|
||||
</button>
|
||||
<button type="button" class="tool-tab" data-tool="timeline" aria-pressed="false">
|
||||
<span>Timeline</span>
|
||||
<small>Events</small>
|
||||
</button>
|
||||
<button type="button" class="tool-tab" data-tool="redact" aria-pressed="false">
|
||||
<span>Redact</span>
|
||||
<small>Privacy</small>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section class="tool-panel" aria-labelledby="toolTitle">
|
||||
<div class="tool-heading">
|
||||
<div>
|
||||
<p id="toolKind" class="eyebrow">Source-grounded Legal Ask</p>
|
||||
<h2 id="toolTitle">Ask a legal question</h2>
|
||||
</div>
|
||||
<span id="toolBadge" class="tool-badge">family-legal</span>
|
||||
</div>
|
||||
|
||||
<form id="toolForm" class="tool-form">
|
||||
<div class="control-row" id="languageControl">
|
||||
<span class="control-label">Language</span>
|
||||
<label><input type="radio" name="language" value="en" checked> English</label>
|
||||
<label><input type="radio" name="language" value="no"> Norsk</label>
|
||||
</div>
|
||||
|
||||
<div class="control-row is-hidden" id="redactionControl">
|
||||
<span class="control-label">Mode</span>
|
||||
<label><input type="radio" name="redactionMode" value="standard" checked> Standard</label>
|
||||
<label><input type="radio" name="redactionMode" value="strict"> Strict</label>
|
||||
</div>
|
||||
|
||||
<label class="input-label" for="toolInput" id="inputLabel">Question</label>
|
||||
<textarea id="toolInput" name="toolInput" rows="10" required></textarea>
|
||||
|
||||
<div class="form-footer">
|
||||
<p id="toolStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||
<button id="runButton" type="submit">Run Tool</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section id="results" class="results" aria-live="polite">
|
||||
<div class="empty-state">
|
||||
<h3>Ready</h3>
|
||||
<p>Choose a tool, run a request, and the answer will show the evidence trail beside it.</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="reasoning-panel" aria-labelledby="reasoningTitle">
|
||||
<div class="reasoning-head">
|
||||
<p class="eyebrow">Evidence trail</p>
|
||||
<h2 id="reasoningTitle">Reasoning</h2>
|
||||
</div>
|
||||
<ol id="traceList" class="trace-list">
|
||||
<li>
|
||||
<span class="trace-status waiting"></span>
|
||||
<div>
|
||||
<strong>Waiting</strong>
|
||||
<p>Run a tool to see interpretation, retrieval, confidence, uncertainty, and next step.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
window.DBN_TOOLS_AUTHENTICATED = <?= $authenticated ? 'true' : 'false' ?>;
|
||||
</script>
|
||||
<script src="assets/js/tools.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user