5589e891f4
The "browse" label's native for=input trigger AND the upload-zone click handler both called uploadInput.click(), so the picker opened twice when the user clicked the browse text. Stop propagation on the label and the input itself, plus tighten the zone handler to recognise any label-for descendant. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
390 lines
17 KiB
JavaScript
390 lines
17 KiB
JavaScript
/**
|
|
* legal-analysis.js — Custom handler for the Legal Analysis tool.
|
|
*
|
|
* Two-pass flow: extract distinct legal issues (Azure) → answer each issue
|
|
* with dbn-legal-agent-v3 fine-tune on ocelot → synthesise overall assessment.
|
|
* Streams NDJSON so the UI fills in as each issue completes.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ── Element refs ──────────────────────────────────────────────────────────
|
|
var form = document.getElementById('laForm');
|
|
var runBtn = document.getElementById('laRunButton');
|
|
var statusEl = document.getElementById('laStatus');
|
|
var resultsEl = document.getElementById('laResults');
|
|
var textarea = document.getElementById('laInput');
|
|
|
|
var uploadZone = document.getElementById('laUploadZone');
|
|
var uploadInput = document.getElementById('laUploadInput');
|
|
var uploadPrompt = document.getElementById('laUploadPrompt');
|
|
var uploadFileInfo = document.getElementById('laUploadFileInfo');
|
|
var uploadFileList = document.getElementById('laUploadFileList');
|
|
var uploadClear = document.getElementById('laUploadClear');
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────
|
|
var _extractedFiles = [];
|
|
var _currentLang = 'no';
|
|
var _lastPayload = null;
|
|
var _issueCards = {}; // id -> DOM element
|
|
|
|
// ── Lang switcher ─────────────────────────────────────────────────────────
|
|
document.querySelectorAll('.la-lang-btn').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
document.querySelectorAll('.la-lang-btn').forEach(function (b) {
|
|
b.classList.toggle('is-active', b === btn);
|
|
});
|
|
_currentLang = btn.dataset.lang || 'no';
|
|
});
|
|
});
|
|
|
|
// ── File upload (same pattern as summarize) ───────────────────────────────
|
|
if (uploadZone) {
|
|
uploadZone.addEventListener('dragover', function (e) {
|
|
e.preventDefault();
|
|
uploadZone.classList.add('is-drag-over');
|
|
});
|
|
uploadZone.addEventListener('dragleave', function (e) {
|
|
if (!uploadZone.contains(e.relatedTarget)) {
|
|
uploadZone.classList.remove('is-drag-over');
|
|
}
|
|
});
|
|
uploadZone.addEventListener('drop', function (e) {
|
|
e.preventDefault();
|
|
uploadZone.classList.remove('is-drag-over');
|
|
if (e.dataTransfer && e.dataTransfer.files.length) {
|
|
handleFiles(e.dataTransfer.files);
|
|
}
|
|
});
|
|
// Stop label-for and the input itself from bubbling into the zone click
|
|
// handler — otherwise the picker opens twice (native + programmatic).
|
|
var browseLabel = uploadZone.querySelector('label[for="' + (uploadInput && uploadInput.id) + '"]');
|
|
if (browseLabel) {
|
|
browseLabel.addEventListener('click', function (e) { e.stopPropagation(); });
|
|
}
|
|
if (uploadInput) {
|
|
uploadInput.addEventListener('click', function (e) { e.stopPropagation(); });
|
|
}
|
|
uploadZone.addEventListener('click', function (e) {
|
|
if (e.target === uploadClear || (uploadClear && uploadClear.contains(e.target))) return;
|
|
if (e.target === uploadInput) return;
|
|
// Any label or descendant of a label-for=uploadInput already triggered the input
|
|
var lbl = e.target.closest && e.target.closest('label');
|
|
if (lbl && lbl.getAttribute('for') === uploadInput.id) return;
|
|
if (uploadInput) uploadInput.click();
|
|
});
|
|
}
|
|
if (uploadInput) {
|
|
uploadInput.addEventListener('change', function () {
|
|
if (uploadInput.files && uploadInput.files.length) handleFiles(uploadInput.files);
|
|
});
|
|
}
|
|
if (uploadClear) {
|
|
uploadClear.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
resetUpload();
|
|
});
|
|
}
|
|
|
|
function resetUpload() {
|
|
_extractedFiles = [];
|
|
if (uploadInput) uploadInput.value = '';
|
|
if (uploadPrompt) uploadPrompt.classList.remove('is-hidden');
|
|
if (uploadFileInfo) uploadFileInfo.classList.add('is-hidden');
|
|
if (uploadFileList) uploadFileList.innerHTML = '';
|
|
}
|
|
|
|
function handleFiles(fileList) {
|
|
var files = Array.from(fileList).slice(0, 5);
|
|
if (!files.length) return;
|
|
|
|
setStatus('Extracting text from ' + files.length + ' file(s)…');
|
|
setBusy(true);
|
|
|
|
var promises = files.map(function (file) {
|
|
var fd = new FormData();
|
|
fd.append('file', file);
|
|
return fetch('api/extract.php', { method: 'POST', credentials: 'same-origin', body: fd })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (!data.ok) throw new Error(data.error || 'Extraction failed for ' + file.name);
|
|
return { name: file.name, text: data.text || '', chars: data.chars || 0 };
|
|
});
|
|
});
|
|
|
|
Promise.all(promises)
|
|
.then(function (results) {
|
|
_extractedFiles = results;
|
|
renderFileList(results);
|
|
setStatus('');
|
|
setBusy(false);
|
|
})
|
|
.catch(function (err) {
|
|
setStatus('Error: ' + err.message);
|
|
setBusy(false);
|
|
});
|
|
}
|
|
|
|
function renderFileList(files) {
|
|
if (!uploadFileList) return;
|
|
uploadFileList.innerHTML = files.map(function (f) {
|
|
return '<li><span class="upload-filename">' + esc(f.name) + '</span>'
|
|
+ ' <span class="upload-chars">' + f.chars.toLocaleString() + ' chars</span></li>';
|
|
}).join('');
|
|
if (uploadPrompt) uploadPrompt.classList.add('is-hidden');
|
|
if (uploadFileInfo) uploadFileInfo.classList.remove('is-hidden');
|
|
}
|
|
|
|
// ── Form submission ───────────────────────────────────────────────────────
|
|
if (form) {
|
|
form.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
runAnalysis();
|
|
});
|
|
}
|
|
|
|
async function runAnalysis() {
|
|
var pastedText = textarea ? textarea.value.trim() : '';
|
|
var fileText = _extractedFiles.map(function (f) { return f.text; }).join('\n\n---\n\n');
|
|
var combined = [fileText, pastedText].filter(Boolean).join('\n\n---\n\n');
|
|
|
|
var docIdsEl = document.getElementById('docPickerIds');
|
|
var rawDocIds = docIdsEl ? docIdsEl.value.trim() : '';
|
|
var docIds = rawDocIds ? rawDocIds.split(',').map(Number).filter(Boolean) : [];
|
|
|
|
if (!combined && !docIds.length) {
|
|
setStatus('Paste text, upload a file, or select a document before running.');
|
|
return;
|
|
}
|
|
|
|
var docType = (document.querySelector('input[name="laDocType"]:checked') || {}).value || 'auto';
|
|
|
|
var payload = {
|
|
text: combined,
|
|
language: _currentLang,
|
|
doc_type: docType,
|
|
};
|
|
if (docIds.length) payload.doc_ids = docIds;
|
|
_lastPayload = payload;
|
|
_issueCards = {};
|
|
|
|
setBusy(true);
|
|
setStatus('Running…');
|
|
renderInitial();
|
|
|
|
try {
|
|
var resp = await fetch('api/legal-analysis.php', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!resp.ok || !resp.body) {
|
|
throw new Error('Server returned ' + resp.status);
|
|
}
|
|
|
|
var reader = resp.body.getReader();
|
|
var decoder = new TextDecoder();
|
|
var buffer = '';
|
|
|
|
while (true) {
|
|
var _ref = await reader.read();
|
|
if (_ref.done) break;
|
|
buffer += decoder.decode(_ref.value, { stream: true });
|
|
var lines = buffer.split('\n');
|
|
buffer = lines.pop();
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var line = lines[i].trim();
|
|
if (!line) continue;
|
|
var data;
|
|
try { data = JSON.parse(line); } catch (_) { continue; }
|
|
handleEvent(data);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
showError(err.message || 'Request failed.');
|
|
} finally {
|
|
setBusy(false);
|
|
setStatus('');
|
|
}
|
|
}
|
|
|
|
function handleEvent(data) {
|
|
if (data.event === 'progress') {
|
|
setStatus(data.detail || '');
|
|
if (data.step === 'issue_searching_corpus' || data.step === 'issue_answering') {
|
|
markIssueRunning(data.issue_id, data.step);
|
|
}
|
|
} else if (data.event === 'issues_extracted') {
|
|
renderIssueList(data.issues || []);
|
|
} else if (data.event === 'issue_answered') {
|
|
fillIssueCard(data.issue);
|
|
} else if (data.event === 'final') {
|
|
renderFinal(data.result || {});
|
|
if (typeof window.dbnShowSaveResultButton === 'function') {
|
|
window.dbnShowSaveResultButton(
|
|
'legal-analysis',
|
|
_lastPayload || {},
|
|
data.result || {},
|
|
{
|
|
model: (data.result && data.result.model) || 'dbn-legal-agent-v3',
|
|
latency_ms: (data.result && data.result.latency_ms) || 0,
|
|
},
|
|
resultsEl
|
|
);
|
|
}
|
|
} else if (data.event === 'error') {
|
|
showError(data.message || data.error || 'An error occurred.');
|
|
}
|
|
}
|
|
|
|
// ── Result rendering ──────────────────────────────────────────────────────
|
|
function renderInitial() {
|
|
if (!resultsEl) return;
|
|
resultsEl.innerHTML =
|
|
'<div class="la-pipeline">'
|
|
+ '<div class="la-step running"><strong>Pass 1</strong> — Extracting legal issues from your document…</div>'
|
|
+ '</div>'
|
|
+ '<ol id="laIssueList" class="la-issues"></ol>';
|
|
}
|
|
|
|
function renderIssueList(issues) {
|
|
var list = document.getElementById('laIssueList');
|
|
if (!list) return;
|
|
list.innerHTML = '';
|
|
_issueCards = {};
|
|
issues.forEach(function (issue) {
|
|
var li = document.createElement('li');
|
|
li.className = 'la-issue pending';
|
|
li.dataset.issueId = String(issue.id);
|
|
li.innerHTML =
|
|
'<div class="la-issue__head">'
|
|
+ '<span class="la-issue__num">#' + issue.id + '</span>'
|
|
+ '<span class="la-severity la-severity-' + esc(issue.severity_hint || 'medium') + '">'
|
|
+ esc((issue.severity_hint || 'medium').toUpperCase())
|
|
+ '</span>'
|
|
+ '</div>'
|
|
+ '<h4 class="la-issue__q">' + esc(issue.question) + '</h4>'
|
|
+ (issue.brief_context ? '<p class="la-issue__ctx"><em>' + esc(issue.brief_context) + '</em></p>' : '')
|
|
+ '<div class="la-issue__status">Waiting…</div>';
|
|
list.appendChild(li);
|
|
_issueCards[issue.id] = li;
|
|
});
|
|
// Mark Pass 1 complete
|
|
var pipeline = resultsEl.querySelector('.la-pipeline');
|
|
if (pipeline) {
|
|
pipeline.innerHTML = '<div class="la-step done"><strong>Pass 1</strong> — Found ' + issues.length + ' legal issue(s)</div>'
|
|
+ '<div class="la-step running"><strong>Pass 2</strong> — Asking dbn-legal-agent-v3 about each issue…</div>';
|
|
}
|
|
}
|
|
|
|
function markIssueRunning(issueId, step) {
|
|
var card = _issueCards[issueId];
|
|
if (!card) return;
|
|
var status = card.querySelector('.la-issue__status');
|
|
if (!status) return;
|
|
if (step === 'issue_searching_corpus') {
|
|
status.textContent = 'Searching legal corpus…';
|
|
card.classList.add('running');
|
|
} else if (step === 'issue_answering') {
|
|
status.textContent = 'Asking dbn-legal-agent-v3…';
|
|
}
|
|
}
|
|
|
|
function fillIssueCard(issue) {
|
|
if (!issue || !issue.id) return;
|
|
var card = _issueCards[issue.id];
|
|
if (!card) return;
|
|
card.classList.remove('pending', 'running');
|
|
card.classList.add('answered');
|
|
|
|
// Refresh severity from real answer
|
|
var sevEl = card.querySelector('.la-severity');
|
|
if (sevEl) {
|
|
sevEl.className = 'la-severity la-severity-' + esc(issue.severity || 'medium');
|
|
sevEl.textContent = esc((issue.severity || 'medium').toUpperCase());
|
|
}
|
|
|
|
var statusBlock = card.querySelector('.la-issue__status');
|
|
if (statusBlock) statusBlock.remove();
|
|
|
|
var answerHtml = '<div class="la-issue__answer"><h5>Svar</h5><p>'
|
|
+ esc(issue.answer || '').replace(/\n/g, '<br>')
|
|
+ '</p></div>';
|
|
|
|
var basisHtml = '';
|
|
if (issue.legal_basis) {
|
|
basisHtml = '<div class="la-issue__basis"><strong>Lovgrunnlag:</strong> '
|
|
+ esc(issue.legal_basis) + '</div>';
|
|
}
|
|
var checkHtml = '';
|
|
if (issue.what_to_check) {
|
|
checkHtml = '<p class="la-issue__check"><em>' + esc(issue.what_to_check) + '</em></p>';
|
|
}
|
|
card.insertAdjacentHTML('beforeend', answerHtml + basisHtml + checkHtml);
|
|
}
|
|
|
|
function renderFinal(result) {
|
|
// Add Pass 3 synthesis at the top
|
|
var topHtml = '';
|
|
if (result.overall_assessment) {
|
|
topHtml = '<section class="la-synthesis result-section">'
|
|
+ '<h3>Overall assessment</h3>'
|
|
+ '<p>' + esc(result.overall_assessment) + '</p>';
|
|
if (Array.isArray(result.next_steps) && result.next_steps.length) {
|
|
topHtml += '<h4>Next steps</h4><ul>'
|
|
+ result.next_steps.map(function (s) { return '<li>' + esc(s) + '</li>'; }).join('')
|
|
+ '</ul>';
|
|
}
|
|
if (result.disclaimer) {
|
|
topHtml += '<p class="disclaimer-note">' + esc(result.disclaimer) + '</p>';
|
|
}
|
|
topHtml += '</section>';
|
|
}
|
|
|
|
// Mark Pass 2/3 complete in the pipeline strip
|
|
var pipeline = resultsEl.querySelector('.la-pipeline');
|
|
if (pipeline) {
|
|
pipeline.innerHTML =
|
|
'<div class="la-step done"><strong>Pass 1</strong> — Issues extracted</div>'
|
|
+ '<div class="la-step done"><strong>Pass 2</strong> — Specialist answered ' + (result.issues || []).length + ' issue(s)</div>'
|
|
+ '<div class="la-step done"><strong>Pass 3</strong> — Synthesis complete</div>';
|
|
}
|
|
|
|
// Insert synthesis at top of resultsEl, after pipeline
|
|
if (topHtml && pipeline) {
|
|
pipeline.insertAdjacentHTML('afterend', topHtml);
|
|
} else if (topHtml) {
|
|
resultsEl.insertAdjacentHTML('afterbegin', topHtml);
|
|
}
|
|
|
|
// If issues weren't already rendered (e.g. empty result), put the message in
|
|
if ((!result.issues || !result.issues.length) && resultsEl.querySelector('#laIssueList')) {
|
|
resultsEl.querySelector('#laIssueList').innerHTML =
|
|
'<li class="la-issue answered"><p><em>No discrete legal issues were identified.</em></p></li>';
|
|
}
|
|
}
|
|
|
|
function showError(msg) {
|
|
if (resultsEl) {
|
|
resultsEl.innerHTML = '<div class="empty-state"><h3>Error</h3><p>' + esc(msg) + '</p></div>';
|
|
}
|
|
setStatus('');
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
function setBusy(on) {
|
|
if (runBtn) runBtn.disabled = on;
|
|
if (runBtn) runBtn.textContent = on ? 'Running…' : 'Run legal analysis';
|
|
}
|
|
function setStatus(msg) {
|
|
if (statusEl) statusEl.textContent = msg;
|
|
}
|
|
function esc(s) {
|
|
return String(s == null ? '' : s)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
}());
|