Files
dobetternorge-tools/assets/js/tools.js
T
daveadmin bbe5307c03 Add document upload to Redact tool
api/extract.php — new endpoint accepting .pdf/.docx/.txt up to 4 MB;
pdftotext for PDFs, ZipArchive+DOMXPath for DOCX, mb_convert_encoding
for TXT; truncates to 32 000 chars to stay within redact limit.

index.php — drop/browse upload zone above the textarea, visible only
in Redact mode.

tools.js — setupUpload(), handleFileUpload(), resetUpload(); drag-and-drop
and file picker both call the extract endpoint then populate the textarea.

tools.css — upload zone, drag-over, file-info, clear button styles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 06:52:14 +02:00

495 lines
16 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('#publicLanding'),
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'),
uploadZone: document.querySelector('#uploadZone'),
uploadInput: document.querySelector('#uploadInput'),
uploadPrompt: document.querySelector('#uploadPrompt'),
uploadFileInfo: document.querySelector('#uploadFileInfo'),
uploadFileName: document.querySelector('#uploadFileName'),
uploadClear: document.querySelector('#uploadClear'),
});
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);
setupUpload();
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.uploadZone.classList.toggle('is-hidden', toolName !== 'redact');
resetUpload();
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();
payload.region = currentRedactionRegion();
}
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);
}
}
function resetUpload() {
if (!els.uploadInput) return;
els.uploadInput.value = '';
els.uploadPrompt.classList.remove('is-hidden');
els.uploadFileInfo.classList.add('is-hidden');
els.uploadFileName.textContent = '';
els.uploadZone.classList.remove('is-drag-over');
}
function setupUpload() {
els.uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
els.uploadZone.classList.add('is-drag-over');
});
els.uploadZone.addEventListener('dragleave', (e) => {
if (!els.uploadZone.contains(e.relatedTarget)) {
els.uploadZone.classList.remove('is-drag-over');
}
});
els.uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
els.uploadZone.classList.remove('is-drag-over');
const file = e.dataTransfer?.files?.[0];
if (file) handleFileUpload(file);
});
els.uploadZone.addEventListener('click', (e) => {
if (e.target === els.uploadClear || els.uploadClear?.contains(e.target)) return;
if (e.target.tagName === 'LABEL') return;
els.uploadInput.click();
});
els.uploadInput.addEventListener('change', () => {
const file = els.uploadInput.files?.[0];
if (file) handleFileUpload(file);
});
els.uploadClear.addEventListener('click', () => {
resetUpload();
els.status.textContent = '';
});
}
async function handleFileUpload(file) {
const allowed = ['pdf', 'docx', 'txt'];
const ext = file.name.split('.').pop().toLowerCase();
if (!allowed.includes(ext)) {
els.status.textContent = 'Unsupported file type. Use .pdf, .docx, or .txt.';
return;
}
els.status.textContent = `Extracting ${file.name}…`;
setBusy(true);
try {
const formData = new FormData();
formData.append('file', file);
const resp = await fetch('api/extract.php', {
method: 'POST',
credentials: 'same-origin',
body: formData,
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.ok) {
throw new Error(data.error?.message || `Extraction failed (HTTP ${resp.status}).`);
}
els.input.value = data.text;
els.uploadFileName.textContent = file.name;
els.uploadPrompt.classList.add('is-hidden');
els.uploadFileInfo.classList.remove('is-hidden');
const note = data.truncated ? ' (truncated to 32000 chars)' : '';
els.status.textContent = `Extracted ${data.chars.toLocaleString()} chars from ${file.name}${note}.`;
} catch (err) {
els.status.textContent = err.message;
resetUpload();
} 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 currentRedactionRegion() {
return document.querySelector('input[name="redactionRegion"]:checked')?.value || 'nordic';
}
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 chunkText = item.chunk_text || '';
const meta = [
item.package_or_corpus,
item.section,
item.score !== undefined && item.score !== null ? `score ${item.score}` : '',
].filter(Boolean).join(' · ');
const chunkToggle = (chunkText && chunkText !== body) ? `
<details class="chunk-details">
<summary class="chunk-toggle">View chunk</summary>
<pre class="chunk-text">${escapeHtml(chunkText)}</pre>
</details>
` : '';
return `
<article class="source-card">
<h4>${escapeHtml(title)}</h4>
${meta ? `<p class="source-meta">${escapeHtml(meta)}</p>` : ''}
<p>${escapeHtml(body)}</p>
${chunkToggle}
</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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}