420 lines
18 KiB
JavaScript
420 lines
18 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.
|
|
*
|
|
* UI strings come from window.DBN_LA_I18N (populated server-side from
|
|
* dbnToolsT('la_*')), so the same JS works in EN/NO/UK/PL.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ── i18n helper ───────────────────────────────────────────────────────────
|
|
var I18N = window.DBN_LA_I18N || {};
|
|
function t(key, vars) {
|
|
var s = (I18N && I18N[key]) || key;
|
|
if (vars) {
|
|
Object.keys(vars).forEach(function (k) {
|
|
s = s.split('{' + k + '}').join(String(vars[k]));
|
|
});
|
|
}
|
|
return s;
|
|
}
|
|
|
|
// ── 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 = window.DBN_LA_LANG || 'no';
|
|
var _lastPayload = null;
|
|
var _issueCards = {};
|
|
|
|
// ── Lang switcher ─────────────────────────────────────────────────────────
|
|
document.querySelectorAll('.la-lang-btn').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
// Switching UI language requires a page reload so all labels rerender.
|
|
var lang = btn.dataset.lang || 'no';
|
|
if (lang === _currentLang) return;
|
|
var url = new URL(window.location.href);
|
|
url.searchParams.set('lang', lang);
|
|
window.location.href = url.toString();
|
|
});
|
|
});
|
|
|
|
// ── File upload ───────────────────────────────────────────────────────────
|
|
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;
|
|
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(t('extractingFiles', { n: files.length }));
|
|
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(t('errorPrefix') + ' ' + 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(t('needInput'));
|
|
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(t('runButtonBusy'));
|
|
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(t('serverReturned') + ' ' + 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>' + esc(t('pass1')) + '</strong> — ' + esc(t('pass1Extracting')) + '</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">' + esc(t('waiting')) + '</div>';
|
|
list.appendChild(li);
|
|
_issueCards[issue.id] = li;
|
|
});
|
|
var pipeline = resultsEl.querySelector('.la-pipeline');
|
|
if (pipeline) {
|
|
pipeline.innerHTML = '<div class="la-step done"><strong>' + esc(t('pass1')) + '</strong> — ' + esc(t('pass1Found', { n: issues.length })) + '</div>'
|
|
+ '<div class="la-step running"><strong>' + esc(t('pass2')) + '</strong> — ' + esc(t('pass2Asking')) + '</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 = t('searchingCorpus');
|
|
card.classList.add('running');
|
|
} else if (step === 'issue_answering') {
|
|
status.textContent = t('askingFinetune');
|
|
}
|
|
}
|
|
|
|
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');
|
|
|
|
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>' + esc(t('answerHeader')) + '</h5><p>'
|
|
+ esc(issue.answer || '').replace(/\n/g, '<br>')
|
|
+ '</p></div>';
|
|
|
|
var basisHtml = '';
|
|
if (issue.legal_basis) {
|
|
basisHtml = '<div class="la-issue__basis"><strong>' + esc(t('legalBasis')) + '</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) {
|
|
var topHtml = '';
|
|
if (result.overall_assessment) {
|
|
topHtml = '<section class="la-synthesis result-section">'
|
|
+ '<h3>' + esc(t('overall')) + '</h3>'
|
|
+ '<p>' + esc(result.overall_assessment) + '</p>';
|
|
if (Array.isArray(result.next_steps) && result.next_steps.length) {
|
|
topHtml += '<h4>' + esc(t('nextSteps')) + '</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>';
|
|
}
|
|
if (Array.isArray(result.legal_check) && result.legal_check.length) {
|
|
topHtml += renderLegalCheck(result.legal_check);
|
|
}
|
|
topHtml += '</section>';
|
|
}
|
|
|
|
var pipeline = resultsEl.querySelector('.la-pipeline');
|
|
if (pipeline) {
|
|
pipeline.innerHTML =
|
|
'<div class="la-step done"><strong>' + esc(t('pass1')) + '</strong> — ' + esc(t('pass1Found', { n: (result.issues || []).length })) + '</div>'
|
|
+ '<div class="la-step done"><strong>' + esc(t('pass2')) + '</strong> — ' + esc(t('pass2Answered', { n: (result.issues || []).length })) + '</div>'
|
|
+ '<div class="la-step done"><strong>' + esc(t('pass3')) + '</strong> — ' + esc(t('pass3Synthesis')) + '</div>';
|
|
}
|
|
|
|
if (topHtml && pipeline) {
|
|
pipeline.insertAdjacentHTML('afterend', topHtml);
|
|
} else if (topHtml) {
|
|
resultsEl.insertAdjacentHTML('afterbegin', topHtml);
|
|
}
|
|
|
|
if ((!result.issues || !result.issues.length) && resultsEl.querySelector('#laIssueList')) {
|
|
resultsEl.querySelector('#laIssueList').innerHTML =
|
|
'<li class="la-issue answered"><p><em>' + esc(t('emptyIssues')) + '</em></p></li>';
|
|
}
|
|
}
|
|
|
|
function showError(msg) {
|
|
if (resultsEl) {
|
|
resultsEl.innerHTML = '<div class="empty-state"><h3>' + esc(t('errorPrefix')) + '</h3><p>' + esc(msg) + '</p></div>';
|
|
}
|
|
setStatus('');
|
|
}
|
|
|
|
function renderLegalCheck(findings) {
|
|
return '<div class="korr-legal-check">'
|
|
+ '<h4 class="korr-legal-check__title">Legal threshold check <small>(dbn-legal-agent-v3)</small></h4>'
|
|
+ findings.map(function (f) {
|
|
var severity = f.severity || 'low';
|
|
return '<div class="bvj-red-flag">'
|
|
+ '<div class="bvj-red-flag__head">'
|
|
+ '<div class="bvj-red-flag__desc">' + esc(f.description || '') + '</div>'
|
|
+ '<span class="bvj-severity bvj-severity-' + esc(severity) + '">' + esc(severity) + '</span>'
|
|
+ '</div>'
|
|
+ (f.legal_basis ? '<span class="bvj-red-flag__legal">' + esc(f.legal_basis) + '</span>' : '')
|
|
+ (f.what_to_check ? '<details class="bvj-red-flag__details"><summary>What to verify</summary><p class="bvj-red-flag__check">' + esc(f.what_to_check) + '</p></details>' : '')
|
|
+ '</div>';
|
|
}).join('')
|
|
+ '</div>';
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
function setBusy(on) {
|
|
if (runBtn) runBtn.disabled = on;
|
|
if (runBtn) runBtn.textContent = on ? t('runButtonBusy') : t('runButton');
|
|
}
|
|
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, ''');
|
|
}
|
|
}());
|