commit 2d8d1c7409545c0e6576b16c174867ab4d00864a Author: davegilligan Date: Thu May 7 00:01:07 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91d40b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +*.log +*.jsonl +/support/ diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..4b52483 --- /dev/null +++ b/.htaccess @@ -0,0 +1,17 @@ +DirectoryIndex index.php +Options -Indexes + + + Require all denied + + + + Header always set X-Content-Type-Options "nosniff" + Header always set Referrer-Policy "same-origin" + Header always set X-Frame-Options "SAMEORIGIN" + + + + RewriteEngine On + RewriteRule ^includes/ - [F,L] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..8035150 --- /dev/null +++ b/README.md @@ -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. diff --git a/api/ask.php b/api/ask.php new file mode 100644 index 0000000..4b94722 --- /dev/null +++ b/api/ask.php @@ -0,0 +1,14 @@ +ask($question, $language); +}); diff --git a/api/health.php b/api/health.php new file mode 100644 index 0000000..3b13206 --- /dev/null +++ b/api/health.php @@ -0,0 +1,88 @@ + (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); diff --git a/api/redact.php b/api/redact.php new file mode 100644 index 0000000..1709d03 --- /dev/null +++ b/api/redact.php @@ -0,0 +1,14 @@ +redact($text, $mode); +}); diff --git a/api/search.php b/api/search.php new file mode 100644 index 0000000..882b8be --- /dev/null +++ b/api/search.php @@ -0,0 +1,15 @@ +search($query, $language, $limit); +}); diff --git a/api/session.php b/api/session.php new file mode 100644 index 0000000..b46e16b --- /dev/null +++ b/api/session.php @@ -0,0 +1,42 @@ + true, + 'authenticated' => true, + 'session' => dbnToolsAnonymousSessionId(), +]); diff --git a/api/summarize.php b/api/summarize.php new file mode 100644 index 0000000..621c48f --- /dev/null +++ b/api/summarize.php @@ -0,0 +1,14 @@ +summarize($text, $language); +}); diff --git a/api/timeline.php b/api/timeline.php new file mode 100644 index 0000000..f5f888f --- /dev/null +++ b/api/timeline.php @@ -0,0 +1,14 @@ +timeline($text, $language); +}); diff --git a/assets/css/tools.css b/assets/css/tools.css new file mode 100644 index 0000000..71bf298 --- /dev/null +++ b/assets/css/tools.css @@ -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; + } +} diff --git a/assets/js/tools.js b/assets/js/tools.js new file mode 100644 index 0000000..8f0912b --- /dev/null +++ b/assets/js/tools.js @@ -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', `

${escapeHtml(data.next_practical_step || 'Review the evidence trail.')}

`)); + + if (data.disclaimer) { + sections.push(`

${escapeHtml(data.disclaimer)}

`); + } + + els.results.innerHTML = sections.join(''); +} + +function renderMainFinding(data) { + if (data.tool === 'ask') { + return `

${escapeHtml(data.answer || data.what_we_found || '')}

`; + } + if (data.tool === 'redact') { + return `
${escapeHtml(data.redacted_text || '')}
${renderEntityCounts(data.entity_counts)}`; + } + if (data.tool === 'timeline') { + return `

${escapeHtml(data.what_we_found || '')}

${renderTimeline(data.events || [])}`; + } + if (data.tool === 'summarize') { + return [ + `

${escapeHtml(data.what_we_found || '')}

`, + 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 `

${escapeHtml(data.what_we_found || '')}

`; + } + return `

${escapeHtml(data.what_we_found || '')}

`; +} + +function renderEvidence(data) { + const items = data.evidence_trail || data.sources || data.hits || []; + if (!items.length) { + return '

No evidence trail was available for this request.

'; + } + return `
${items.map(renderEvidenceItem).join('')}
`; +} + +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 ` +
+

${escapeHtml(title)}

+ ${meta ? `

${escapeHtml(meta)}

` : ''} +

${escapeHtml(body)}

+
+ `; +} + +function renderTimeline(events) { + if (!events.length) { + return '

No events were identified.

'; + } + return `
    ${events.map((event) => ` +
  1. + ${escapeHtml(event.date || 'unknown')} + ${escapeHtml(event.actor || 'unknown actor')} +

    ${escapeHtml(event.event || '')}

    + ${event.source_excerpt ? `${escapeHtml(event.source_excerpt)}` : ''} +
  2. + `).join('')}
`; +} + +function renderEntityCounts(counts = {}) { + const entries = Object.entries(counts).filter(([, count]) => Number(count) > 0); + if (!entries.length) { + return '

No deterministic sensitive categories detected.

'; + } + return ``; +} + +function detailList(title, values = []) { + if (!Array.isArray(values) || !values.length) { + return ''; + } + return `

${escapeHtml(title)}

    ${values.map((item) => `
  • ${escapeHtml(String(item))}
  • `).join('')}
`; +} + +function renderListish(value) { + if (Array.isArray(value)) { + if (!value.length) { + return '

No uncertainty listed.

'; + } + return ``; + } + return `

${escapeHtml(value || 'No uncertainty listed.')}

`; +} + +function sectionHtml(title, content) { + return `

${escapeHtml(title)}

${content}
`; +} + +function renderTrace(trace) { + if (!trace.length) { + els.traceList.innerHTML = ` +
  • + +
    Waiting

    Run a tool to see interpretation, retrieval, confidence, uncertainty, and next step.

    +
  • + `; + return; + } + + els.traceList.innerHTML = trace.map((item) => ` +
  • + +
    + ${escapeHtml(item.label || 'Step')} +

    ${escapeHtml(item.detail || '')}

    +
    +
  • + `).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("'", '''); +} diff --git a/includes/AzureOpenAiGateway.php b/includes/AzureOpenAiGateway.php new file mode 100644 index 0000000..f718e96 --- /dev/null +++ b/includes/AzureOpenAiGateway.php @@ -0,0 +1,223 @@ +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; + } +} diff --git a/includes/LegalTools.php b/includes/LegalTools.php new file mode 100644 index 0000000..23c24c9 --- /dev/null +++ b/includes/LegalTools.php @@ -0,0 +1,631 @@ +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 = <<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 = <<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 = <<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 << $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('/(? $label, + 'detail' => $detail, + 'status' => $status, + ]; + } +} diff --git a/includes/bootstrap.php b/includes/bootstrap.php new file mode 100644 index 0000000..ec3db9e --- /dev/null +++ b/includes/bootstrap.php @@ -0,0 +1,408 @@ +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')) . '…'; +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..5fc1705 --- /dev/null +++ b/index.php @@ -0,0 +1,144 @@ + + + + + + + Do Better Norge Legal Tools + + + + + + + + + + + +
    +
    +

    Do Better Norge

    +

    Legal Tools

    +

    Legal information and preparation support, not final legal advice.

    +
    + + + +
    + + +
    +

    +
    +
    +
    + +
    +
    +
    +

    Do Better Norge

    +

    Legal Tools Hub

    +
    +
    + Session active + +
    +
    + +
    + Legal information and preparation support, not final legal advice. Pasted text is processed in memory by default. +
    + +
    + + +
    +
    +
    +

    Source-grounded Legal Ask

    +

    Ask a legal question

    +
    + family-legal +
    + +
    +
    + Language + + +
    + + + + + + + +
    + +
    +
    +

    Ready

    +

    Choose a tool, run a request, and the answer will show the evidence trail beside it.

    +
    +
    +
    + + +
    +
    + + + + +