Files
dobetternorge-tools/assets/js/tools.js
T
daveadmin 95685862ab Redact: multi-doc upload, contextual person naming, aliases
- Extract limit raised from 32K to 128K chars per file (long legal docs now fit)
- Redact API body/text limits raised (400KB / 128K chars) to match
- Upload zone accepts multiple files (up to 5); extracted text concatenated with
  doc separator and combined before redaction; shows per-file char counts
- LLM redact pass now infers contextual person roles (FATHER, MOTHER, CHILD,
  ATTORNEY, JUDGE, etc.) instead of generic [PERSON] for all names; same
  individual gets consistent tag throughout the document
- Tag validation widened to allow any [A-Za-z0-9_- ] pattern (not just the
  five hardcoded tags), supporting contextual and alias tags
- Alias UI added to Redact mode: user maps real names to bracketed aliases
  (e.g. "David Jr" -> [Junior]); aliases injected into LLM system prompt as
  override instructions; max 20 aliases, 100 chars each
- max_tokens raised from 2000 to 4000; timeout from 60s to 90s for larger docs

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

558 lines
18 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.
This file contains Unicode characters that might be confused with other characters. 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'),
uploadFileList: document.querySelector('#uploadFileList'),
uploadClear: document.querySelector('#uploadClear'),
aliasSection: document.querySelector('#aliasSection'),
addAliasRow: document.querySelector('#addAliasRow'),
aliasRows: document.querySelector('#aliasRows'),
});
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();
setupAliases();
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');
els.aliasSection.classList.toggle('is-hidden', toolName !== 'redact');
resetUpload();
resetAliases();
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();
payload.aliases = getAliases();
}
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.uploadFileList.innerHTML = '';
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');
if (e.dataTransfer?.files?.length) handleFiles(e.dataTransfer.files);
});
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', () => {
if (els.uploadInput.files?.length) handleFiles(els.uploadInput.files);
});
els.uploadClear.addEventListener('click', () => {
resetUpload();
els.input.value = '';
els.status.textContent = '';
});
}
async function handleFiles(fileList) {
const allowed = ['pdf', 'docx', 'txt'];
const files = Array.from(fileList).slice(0, 5);
for (const file of files) {
const ext = file.name.split('.').pop().toLowerCase();
if (!allowed.includes(ext)) {
els.status.textContent = `Skipped ${file.name}: unsupported type. Use .pdf, .docx, or .txt.`;
return;
}
}
els.status.textContent = files.length === 1 ? `Extracting ${files[0].name}…` : `Extracting ${files.length} files…`;
setBusy(true);
const parts = [];
let totalChars = 0;
let anyTruncated = false;
try {
for (const file of files) {
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 for ${file.name} (HTTP ${resp.status}).`);
}
parts.push({ filename: file.name, chars: data.chars, truncated: data.truncated, text: data.text });
totalChars += data.chars;
if (data.truncated) anyTruncated = true;
}
const combined = parts.length === 1
? parts[0].text
: parts.map((p) => `--- Document: ${p.filename} ---\n\n${p.text}`).join('\n\n');
const MAX_COMBINED = 128000;
const combinedTruncated = combined.length > MAX_COMBINED;
els.input.value = combinedTruncated ? combined.slice(0, MAX_COMBINED) : combined;
els.uploadFileList.innerHTML = parts
.map((p) => `<li><span class="upload-filename">${escapeHtml(p.filename)}</span><span class="upload-chars">${p.chars.toLocaleString()} chars${p.truncated ? ' • per-file limit reached' : ''}</span></li>`)
.join('');
els.uploadPrompt.classList.add('is-hidden');
els.uploadFileInfo.classList.remove('is-hidden');
const truncNote = (anyTruncated || combinedTruncated) ? ' — truncated to 128000 char limit' : '';
els.status.textContent = parts.length === 1
? `Extracted ${totalChars.toLocaleString()} chars from ${parts[0].filename}${truncNote}.`
: `Extracted ${totalChars.toLocaleString()} chars total from ${parts.length} files${truncNote}.`;
} catch (err) {
els.status.textContent = err.message;
resetUpload();
} finally {
setBusy(false);
}
}
function setupAliases() {
els.addAliasRow.addEventListener('click', () => {
const row = document.createElement('div');
row.className = 'alias-row';
row.innerHTML = [
'<input type="text" class="alias-original" placeholder="Real name" maxlength="100">',
'<span class="alias-arrow" aria-hidden="true">→</span>',
'<input type="text" class="alias-label" placeholder="Alias (without brackets)" maxlength="100">',
'<button type="button" class="alias-remove" aria-label="Remove alias">×</button>',
].join('');
els.aliasRows.appendChild(row);
row.querySelector('.alias-original').focus();
});
els.aliasRows.addEventListener('click', (e) => {
const btn = e.target.closest('.alias-remove');
if (btn) btn.closest('.alias-row').remove();
});
}
function getAliases() {
return Array.from(els.aliasRows.querySelectorAll('.alias-row')).flatMap((row) => {
const original = row.querySelector('.alias-original')?.value.trim() ?? '';
const alias = row.querySelector('.alias-label')?.value.trim() ?? '';
return original && alias ? [{ original, alias }] : [];
});
}
function resetAliases() {
if (els.aliasRows) els.aliasRows.innerHTML = '';
}
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;');
}