95685862ab
- 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>
558 lines
18 KiB
JavaScript
558 lines
18 KiB
JavaScript
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 128 000 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('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|