Files
daveadmin bffc714541 Add My Docs picker to deep-research, advocate, barnevernet, korrespond, citations
- PHP: Add docPickerSection (button + chips + hidden input) to all 5 tool pages
- JS: Send doc_ids in payload for deep-research, advocate, barnevernet, korrespond
- Backend: Inject selected corpus doc content into paste_text/narrative/notes via dbnToolsInjectDocContent
- Citations: Add upload zone (file → api/extract.php → textarea) + paste textarea with live Norwegian legal reference extraction (regex) + ref chips → title search; doc picker populates titleInput via MutationObserver

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

1147 lines
47 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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.
/* advocate.js — page-scoped UI for /advocate.php */
(function () {
'use strict';
const els = {};
let lang = window.DBN_TOOLS_LANG || localStorage.getItem('dbn-ui-lang') || 'en';
let uploadFiles = [];
let lastResult = null;
let branchContext = null;
let customSubQuestions = null;
const CACHE_KEY = 'dbn-advocate-last';
const ROLE_FLIP = {
'Biological mother': 'Child welfare services (Barnevernet)',
'Biological father': 'Child welfare services (Barnevernet)',
'Both biological parents': 'Child welfare services (Barnevernet)',
'Foster carer / long-term placement': 'Biological mother',
'Adoptive parent': 'Biological mother',
'Child (via representative)': 'Child welfare services (Barnevernet)',
'Extended family (grandparent, sibling, aunt/uncle)': 'Child welfare services (Barnevernet)',
'Child welfare services (Barnevernet)': 'Both biological parents',
};
const PARENT_ROLES = new Set([
'Biological mother', 'Biological father', 'Both biological parents',
'Foster carer / long-term placement', 'Adoptive parent',
'Child (via representative)', 'Extended family (grandparent, sibling, aunt/uncle)',
]);
let sliceHintShown = false;
let synthTimer = null;
let synthStartMs = null;
const SLICE_DEFS = [
{ id: 'family_core', label: 'Family Law Core' },
{ id: 'child_welfare', label: 'Child Welfare' },
{ id: 'echr', label: 'ECHR' },
{ id: 'hague', label: 'Hague Convention' },
{ id: 'norwegian_courts', label: 'Norwegian Courts' },
{ id: 'bufdir_guidance', label: 'Bufdir Guidance' },
{ id: 'broader_legal', label: 'Broader Legal Support' },
{ id: 'dbn_resources', label: 'DBN Resources' },
];
const STEP_LABELS = [
'Query interpretation',
'Query expansion',
'Slice resolution',
'Upload indexing',
'Retrieval',
'Synthesis',
'Citation confidence',
];
document.addEventListener('DOMContentLoaded', () => {
if (!document.body.dataset.activeTool || document.body.dataset.activeTool !== 'advocate') return;
Object.assign(els, {
form: document.getElementById('advocateForm'),
input: document.getElementById('advInput'),
status: document.getElementById('advStatus'),
runButton: document.getElementById('advRunButton'),
results: document.getElementById('advResults'),
traceList: document.getElementById('traceList'),
roleSelect: document.getElementById('advRoleSelect'),
roleCustom: document.getElementById('advRoleCustom'),
slices: Array.from(document.querySelectorAll('.adv-slice')),
langButtons: Array.from(document.querySelectorAll('#advLangSwitcher .lang-btn')),
engineRadios: Array.from(document.querySelectorAll('input[name="advEngine"]')),
subQ: document.getElementById('advSubQ'),
subQVal: document.getElementById('advSubQValue'),
chunkLimit: document.getElementById('advChunkLimit'),
chunkLimitVal: document.getElementById('advChunkLimitValue'),
sim: document.getElementById('advSim'),
simVal: document.getElementById('advSimValue'),
topK: document.getElementById('advTopK'),
topKVal: document.getElementById('advTopKValue'),
temp: document.getElementById('advTemp'),
tempVal: document.getElementById('advTempValue'),
uploadZone: document.getElementById('advUploadZone'),
uploadInput: document.getElementById('advUploadInput'),
uploadPrompt: document.getElementById('advUploadPrompt'),
uploadFileInfo: document.getElementById('advUploadFileInfo'),
uploadFileList: document.getElementById('advUploadFileList'),
uploadClear: document.getElementById('advUploadClear'),
modal: document.getElementById('advSourceModal'),
modalClose: document.getElementById('advSourceModalClose'),
modalTitle: document.getElementById('advSourceModalTitle'),
modalEyebrow: document.getElementById('advSourceModalEyebrow'),
modalMeta: document.getElementById('advSourceModalMeta'),
modalText: document.getElementById('advSourceModalText'),
branchPanel: document.getElementById('advBranchPanel'),
branchClear: document.getElementById('advBranchClear'),
branchOrigin: document.getElementById('advBranchOrigin'),
branchSummary: document.getElementById('advBranchSummary'),
branchNotes: document.getElementById('advBranchNotes'),
inputCount: document.getElementById('advInputCount'),
previewAngles: document.getElementById('advPreviewAngles'),
subQPreview: document.getElementById('advSubQPreview'),
subQPreviewList: document.getElementById('advSubQPreviewList'),
runWithAngles: document.getElementById('advRunWithAngles'),
discardAngles: document.getElementById('advDiscardAngles'),
});
if (!els.form) return;
bindRole();
bindSlices();
bindLang();
bindRanges();
bindUpload();
bindModal();
bindBranch();
bindPreviewAngles();
els.form.addEventListener('submit', onSubmit);
els.results.addEventListener('click', (e) => {
const btn = e.target.closest('.dr-branch-btn, .dr-strength-branch-btn');
if (btn) branchFromSubQ(btn.dataset.question || '');
});
renderTrace(STEP_LABELS.map((label) => ({ label, detail: 'Waiting…', status: 'idle' })));
els.input.addEventListener('input', updateCharCount);
els.input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); els.form.requestSubmit(); }
});
updateCharCount();
const cached = loadFromCache();
if (cached) showRestoreBanner(cached);
});
function bindRole() {
if (!els.roleSelect) return;
els.roleSelect.addEventListener('change', () => {
const isOther = els.roleSelect.value === '__other__';
els.roleCustom.classList.toggle('is-hidden', !isOther);
if (isOther) els.roleCustom.focus();
if (!sliceHintShown && PARENT_ROLES.has(els.roleSelect.value)) {
const echrBtn = els.slices.find((b) => b.dataset.slice === 'echr');
const ncBtn = els.slices.find((b) => b.dataset.slice === 'norwegian_courts');
const echrOff = echrBtn && !echrBtn.classList.contains('is-on');
const ncOff = ncBtn && !ncBtn.classList.contains('is-on');
if (echrOff || ncOff) { showSliceHint(echrBtn, ncBtn, echrOff, ncOff); sliceHintShown = true; }
}
});
}
function getAdvocateRole() {
if (!els.roleSelect) return '';
if (els.roleSelect.value === '__other__') {
return (els.roleCustom ? els.roleCustom.value.trim() : '');
}
return els.roleSelect.value;
}
function bindSlices() {
els.slices.forEach((btn) => {
btn.addEventListener('click', () => {
const isOn = btn.classList.toggle('is-on');
btn.setAttribute('aria-pressed', isOn ? 'true' : 'false');
const badge = btn.querySelector('.dr-slice__badge');
if (badge) badge.textContent = isOn ? 'on' : 'off';
});
});
}
function bindLang() {
els.langButtons.forEach((b) => {
b.classList.toggle('is-active', b.dataset.lang === lang);
b.addEventListener('click', () => {
els.langButtons.forEach((x) => x.classList.remove('is-active'));
b.classList.add('is-active');
lang = b.dataset.lang || 'en';
localStorage.setItem('dbn-ui-lang', lang);
});
});
}
function bindRanges() {
const pairs = [
[els.subQ, els.subQVal, (v) => v],
[els.chunkLimit, els.chunkLimitVal, (v) => v],
[els.sim, els.simVal, (v) => Number(v).toFixed(2)],
[els.topK, els.topKVal, (v) => v],
[els.temp, els.tempVal, (v) => Number(v).toFixed(2)],
];
pairs.forEach(([range, label, fmt]) => {
if (!range || !label) return;
const sync = () => { label.textContent = fmt(range.value); };
range.addEventListener('input', sync);
sync();
});
}
function bindUpload() {
if (!els.uploadZone) return;
const onFiles = (fileList) => {
const files = Array.from(fileList || []).slice(0, 5);
if (uploadFiles.length + files.length > 5) {
setStatus('At most 5 files can be uploaded per request.', 'error');
return;
}
files.forEach((f) => {
if (f.size > 8 * 1024 * 1024) {
setStatus(`${f.name} exceeds the 8 MB limit.`, 'error');
return;
}
const ext = (f.name.split('.').pop() || '').toLowerCase();
if (!['pdf', 'docx', 'txt'].includes(ext)) {
setStatus(`${f.name} is not a supported file type.`, 'error');
return;
}
uploadFiles.push(f);
});
renderUploadList();
};
els.uploadInput.addEventListener('change', (e) => onFiles(e.target.files));
els.uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); els.uploadZone.classList.add('is-drop'); });
els.uploadZone.addEventListener('dragleave', () => els.uploadZone.classList.remove('is-drop'));
els.uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
els.uploadZone.classList.remove('is-drop');
onFiles(e.dataTransfer?.files);
});
els.uploadClear?.addEventListener('click', () => {
uploadFiles = [];
els.uploadInput.value = '';
renderUploadList();
});
}
function renderUploadList() {
if (!uploadFiles.length) {
els.uploadFileInfo.classList.add('is-hidden');
els.uploadPrompt.classList.remove('is-hidden');
return;
}
els.uploadPrompt.classList.add('is-hidden');
els.uploadFileInfo.classList.remove('is-hidden');
els.uploadFileList.innerHTML = uploadFiles.map((f) => {
const kb = (f.size / 1024).toFixed(0);
return `<li><span class="upload-filename">${escapeHtml(f.name)}</span><span class="upload-chars">${kb} KB</span></li>`;
}).join('');
}
function bindModal() {
els.modalClose?.addEventListener('click', closeModal);
els.modal?.addEventListener('click', (e) => {
if (e.target === els.modal) closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && els.modal && !els.modal.classList.contains('is-hidden')) closeModal();
});
}
function closeModal() {
els.modal?.classList.add('is-hidden');
}
function openModal(source) {
if (!source) return;
els.modalEyebrow.textContent = source.source_origin === 'upload' ? 'Uploaded file' : 'Corpus source';
els.modalTitle.textContent = source.title || 'Source';
const metaRows = [
['Number', `[${source.n}]`],
source.section ? ['Section', source.section] : null,
['Corpus / package', source.package_or_corpus || '—'],
source.authority_type ? ['Authority', source.authority_type] : null,
source.jurisdiction ? ['Jurisdiction', source.jurisdiction] : null,
source.similarity != null ? ['Similarity', String(source.similarity)] : null,
source.reranker_score != null ? ['Rerank score', String(source.reranker_score)] : null,
source.matched_sub_questions?.length ? ['Matched sub-Q', source.matched_sub_questions.join(', ')] : null,
].filter(Boolean);
els.modalMeta.innerHTML = '<dl>' + metaRows.map(([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(String(v))}</dd>`).join('') + '</dl>';
const summary = source.summary || '';
const chunkText = source.chunk_text || source.excerpt || '';
const isUpload = source.source_origin === 'upload';
const hasDocId = source.document_id != null;
let html = summary
? `<div class="dr-modal-summary">${escapeHtml(summary)}</div>`
: `<div class="dr-modal-summary dr-modal-summary--empty"><em>Summary not yet generated — showing raw chunk below.</em></div>`;
if (chunkText) {
html += `<button class="dr-modal-chunk-toggle" type="button">Show matching chunk ▼</button>`;
html += `<div class="dr-modal-chunk-text is-hidden">${escapeHtml(chunkText)}</div>`;
}
if (!isUpload && hasDocId) {
html += `<button class="dr-modal-all-chunks" type="button" data-document-id="${source.document_id}">View all document chunks →</button>`;
html += `<div class="dr-modal-chunks-list"></div>`;
}
els.modalText.innerHTML = html;
const chunkToggle = els.modalText.querySelector('.dr-modal-chunk-toggle');
const chunkDiv = els.modalText.querySelector('.dr-modal-chunk-text');
chunkToggle?.addEventListener('click', () => {
const isHidden = chunkDiv.classList.toggle('is-hidden');
chunkToggle.textContent = isHidden ? 'Show matching chunk ▼' : 'Hide matching chunk ▲';
});
const allChunksBtn = els.modalText.querySelector('.dr-modal-all-chunks');
const chunksListDiv = els.modalText.querySelector('.dr-modal-chunks-list');
if (allChunksBtn && chunksListDiv) {
allChunksBtn.addEventListener('click', async () => {
allChunksBtn.disabled = true;
allChunksBtn.textContent = 'Loading…';
try {
const res = await fetch(`api/document-chunks.php?document_id=${source.document_id}`, { credentials: 'same-origin' });
const data = await res.json();
if (data.ok && data.chunks) {
chunksListDiv.innerHTML =
`<div class="dr-modal-chunks-head">${escapeHtml(data.document?.title || '')} · ${data.chunks.length} chunks</div>` +
data.chunks.map((c) => `<div class="dr-modal-chunk-item">
<span class="dr-modal-chunk-idx">#${c.chunk_index + 1}${c.section_title ? ' · ' + escapeHtml(c.section_title) : ''}</span>
<p class="dr-modal-chunk-preview">${escapeHtml(truncate(c.content, 300))}</p>
</div>`).join('');
allChunksBtn.remove();
} else {
allChunksBtn.textContent = 'Could not load chunks.';
allChunksBtn.disabled = false;
}
} catch (_) {
allChunksBtn.textContent = 'Error loading chunks.';
allChunksBtn.disabled = false;
}
});
}
els.modal.classList.remove('is-hidden');
}
function getSelectedSlices() {
const out = {};
SLICE_DEFS.forEach((s) => {
const btn = els.slices.find((b) => b.dataset.slice === s.id);
out[s.id] = !!(btn && btn.classList.contains('is-on'));
});
return out;
}
function getEngine() {
const checked = els.engineRadios.find((r) => r.checked);
return checked ? checked.value : 'azure_mini';
}
function getControls() {
return {
sub_q_count: parseInt(els.subQ.value, 10),
chunk_limit: parseInt(els.chunkLimit.value, 10),
similarity_threshold: parseFloat(els.sim.value),
reranker_top_k: parseInt(els.topK.value, 10),
temperature: parseFloat(els.temp.value),
};
}
async function onSubmit(e) {
e.preventDefault();
const advocateRole = getAdvocateRole();
if (!advocateRole) {
setStatus('Select who you are representing before running.', 'error');
return;
}
const query = (els.input.value || '').trim();
if (!query && uploadFiles.length === 0) {
setStatus('Describe the case situation or upload a file before running.', 'error');
return;
}
const slices = getSelectedSlices();
if (!Object.values(slices).some(Boolean)) {
setStatus('Enable at least one corpus slice.', 'error');
return;
}
const engine = getEngine();
const expectedDuration = engine === 'azure_full'
? '60180 seconds with Azure gpt-4o'
: (engine === 'gpu' ? '3090 seconds on GPU' : '1545 seconds with Azure gpt-4o-mini');
setStatus(`Building advocate brief for ${advocateRole}… (${expectedDuration})`, 'busy');
els.runButton.disabled = true;
els.results.innerHTML = `<div class="empty-state"><h3>Researching your case…</h3><p>The agent is generating adversarial sub-questions and retrieving from the legal corpus on behalf of <strong>${escapeHtml(advocateRole)}</strong>. Live progress in the right-hand panel. Expect ${expectedDuration}.</p></div>`;
const stepState = STEP_LABELS.map((label) => ({ label, detail: 'Queued', status: 'idle' }));
renderTrace(stepState);
const payload = {
query,
paste_text: '',
slices,
engine,
language: lang,
controls: getControls(),
advocate_role: advocateRole,
use_my_case: (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false,
};
const _advDocIds = (document.getElementById('docPickerIds')?.value || '').split(',').map(Number).filter(Boolean);
if (_advDocIds.length) payload.doc_ids = _advDocIds;
if (branchContext) {
payload.prior_context = branchContext;
payload.branch_notes = (els.branchNotes ? els.branchNotes.value : '').trim();
}
if (customSubQuestions) {
payload.sub_questions_override = customSubQuestions;
customSubQuestions = null;
}
const stepKeyToIndex = {
interpretation: 0,
expansion: 1,
slice_resolution: 2,
upload_indexing: 3,
retrieval: 4,
synthesis: 5,
confidence: 6,
};
let response;
try {
if (uploadFiles.length > 0) {
const form = new FormData();
form.append('payload', JSON.stringify(payload));
uploadFiles.forEach((f) => form.append('files[]', f));
response = await fetch('api/deep-research.php', { method: 'POST', body: form, credentials: 'same-origin' });
} else {
response = await fetch('api/deep-research.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'same-origin',
});
}
} catch (err) {
setStatus(`Network error: ${err.message || err}`, 'error');
els.runButton.disabled = false;
stepState[0] = { ...stepState[0], status: 'error', detail: String(err) };
renderTrace(stepState);
return;
}
if (!response.ok || !response.body) {
setStatus(`Request failed (${response.status}).`, 'error');
els.runButton.disabled = false;
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let finalResult = null;
let errorEvent = null;
while (true) {
let chunk;
try {
chunk = await reader.read();
} catch (err) {
setStatus(`Stream error: ${err.message || err}`, 'error');
els.runButton.disabled = false;
return;
}
const { done, value } = chunk;
if (value) {
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let evt;
try { evt = JSON.parse(trimmed); } catch (_) { continue; }
handleStreamEvent(evt);
}
}
if (done) break;
}
if (errorEvent) {
setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error');
els.runButton.disabled = false;
const runningIdx = stepState.findIndex((s) => s.status === 'running');
if (runningIdx >= 0) {
stepState[runningIdx] = { ...stepState[runningIdx], status: 'error', detail: errorEvent.message };
renderTrace(stepState);
}
return;
}
if (!finalResult) {
setStatus('Stream ended without a final result.', 'error');
els.runButton.disabled = false;
return;
}
finalResult.query = query;
lastResult = finalResult;
const meta = finalResult.trace_metadata || {};
const rc = meta.retrieval_counts || {};
const countSummary = (rc.post_filter_corpus != null)
? `${rc.post_filter_corpus} corpus${rc.filtered_website ? ` (${rc.filtered_website} website filtered)` : ''}${rc.raw_upload ? ` + ${rc.raw_upload} upload` : ''}`
: `${meta.source_count || 0} sources`;
setStatus(
`Done in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${countSummary} · confidence ${meta.citation_confidence || '?'}`,
'ok'
);
els.runButton.disabled = false;
renderTrace(finalResult.trace || []);
renderResults(finalResult);
saveToCache(finalResult, { query, role: advocateRole, engine, slices, lang });
function handleStreamEvent(evt) {
if (!evt || !evt.event) return;
if (evt.event === 'progress') {
const d = evt.detail || '';
if (d) setStatus(d, 'busy');
return;
}
if (evt.event === 'start') {
setStatus(`Running… engine=${evt.engine}, uploads=${evt.upload_count || 0}`, 'busy');
return;
}
if (evt.event === 'step') {
const idx = stepKeyToIndex[evt.step];
if (idx === undefined) return;
if (evt.step === 'synthesis') {
if (evt.status === 'running') {
synthStartMs = Date.now();
synthTimer = setInterval(() => {
const elapsed = Math.round((Date.now() - synthStartMs) / 1000);
stepState[5] = { ...stepState[5], detail: `Synthesising… (${elapsed}s)` };
renderTrace(stepState);
}, 1000);
} else if (synthTimer) {
clearInterval(synthTimer);
synthTimer = null;
}
}
stepState[idx] = {
label: evt.label || stepState[idx].label,
detail: evt.detail || stepState[idx].detail,
status: evt.status || stepState[idx].status,
};
renderTrace(stepState);
return;
}
if (evt.event === 'subq') {
setStatus(`Retrieving sub-question ${evt.index}/${evt.total}: ${evt.question.slice(0, 80)}${evt.question.length > 80 ? '…' : ''}`, 'busy');
return;
}
if (evt.event === 'final') {
finalResult = evt.result;
return;
}
if (evt.event === 'error') {
errorEvent = evt;
return;
}
}
}
function setStatus(message, kind) {
els.status.textContent = message;
els.status.style.color = kind === 'error' ? '#b41e1e' : (kind === 'ok' ? 'var(--teal-dark)' : 'var(--muted)');
}
function renderTrace(steps) {
if (!els.traceList) return;
els.traceList.classList.add('is-rich');
els.traceList.innerHTML = steps.map((step, i) => {
const statusClass = step.status === 'running' ? 'is-running'
: step.status === 'complete' ? 'is-done'
: step.status === 'warning' ? 'is-warning'
: step.status === 'error' ? 'is-error'
: '';
const marker = step.status === 'complete' ? '✓'
: step.status === 'warning' ? '!'
: step.status === 'error' ? '×'
: (i + 1);
return `<li class="trace-step ${statusClass}">
<span class="trace-step__marker">${marker}</span>
<div>
<span class="trace-step__label">${escapeHtml(step.label || '')}</span>
<span class="trace-step__detail">${escapeHtml(step.detail || '')}</span>
</div>
</li>`;
}).join('');
}
function renderResults(data, restoreMode = false) {
const sources = data.sources || [];
const subs = data.sub_questions || [];
const role = data.advocate_role || '';
const strengths = Array.isArray(data.client_strengths) ? data.client_strengths : [];
const weaknesses = Array.isArray(data.opposing_weaknesses) ? data.opposing_weaknesses : [];
// 1. Advocate banner + flip bar
const flipRole = ROLE_FLIP[role] || '';
const bannerHtml = role ? `
<div class="adv-banner">
<span class="adv-banner__label">Representing</span>
<strong class="adv-banner__role">${escapeHtml(role)}</strong>
<span class="adv-banner__note">Brief argues for this party · grounded in Norwegian law and ECHR authorities</span>
</div>
${flipRole ? `<div class="adv-flip-bar">
<span class="adv-flip-bar__label">See the other side?</span>
<span class="adv-flip-bar__role">${escapeHtml(flipRole)}</span>
<button class="adv-flip-btn" type="button" data-flip-role="${escapeHtml(flipRole)}">Run counter-brief →</button>
</div>` : ''}` : '';
// 2. Client strengths — with ↗ deep-dive branch button per item
const strengthsHtml = strengths.length ? `
<div class="dr-result-block adv-strengths">
<h3 class="adv-strengths__head">Your strongest arguments</h3>
<ul class="adv-strengths__list">
${strengths.map((s) => `<li class="adv-strengths__item">
${renderInlineCitations(escapeHtml(String(s)), sources)}
<button class="dr-strength-branch-btn" type="button"
data-question="${escapeHtml('Deeper research: ' + String(s))}"
title="Branch research from this argument">↗</button>
</li>`).join('')}
</ul>
</div>` : '';
// 3. Brief
const briefHtml = renderBrief(data.brief_markdown || '', sources);
// 4. Opposing weaknesses
const weaknessesHtml = weaknesses.length ? `
<div class="dr-result-block adv-weaknesses">
<h3 class="adv-weaknesses__head">Gaps in the opposing position</h3>
<ul class="adv-weaknesses__list">
${weaknesses.map((w) => `<li class="adv-weaknesses__item">${renderInlineCitations(escapeHtml(String(w)), sources)}</li>`).join('')}
</ul>
</div>` : '';
// 5. Sub-Q report cards
const subQReportsHtml = subs.length ? `
<details class="dr-result-block dr-collapsible" ${restoreMode ? '' : 'open'}>
<summary class="dr-collapsible__head">
<h3>What each sub-question agent researched</h3>
<small>${subs.length} sub-question${subs.length === 1 ? '' : 's'} framed for ${escapeHtml(role || 'your client')}</small>
</summary>
<div class="dr-subq-list">
${subs.map((sq, i) => renderSubQReport(sq, i)).join('')}
</div>
</details>` : '';
// 6. Sources
const sourcesHtml = `
<details class="dr-result-block dr-collapsible" ${restoreMode ? '' : 'open'}>
<summary class="dr-collapsible__head">
<h3>All sources (${sources.length})</h3>
<small>Click a card to see the full chunk · external link opens the original article</small>
</summary>
<div class="dr-source-list">
${sources.map((s) => renderSourceCard(s)).join('')}
</div>
</details>`;
// 7. Uncertainty
const uncertHtml = (data.what_remains_uncertain || []).length ? `
<div class="dr-result-block">
<h3 style="margin:0 0 8px;font-size:0.95rem;color:var(--muted)">What remains uncertain</h3>
<ul style="padding-left:1.2em;margin:0;color:var(--muted);line-height:1.55">
${(data.what_remains_uncertain || []).map((u) => `<li>${escapeHtml(String(u))}</li>`).join('')}
</ul>
</div>` : '';
// 8. Next step
const nextHtml = data.next_practical_step ? `
<div class="dr-result-block">
<h3 style="margin:0 0 6px;font-size:0.95rem">Next practical step</h3>
<p style="margin:0;color:var(--ink);line-height:1.5">${escapeHtml(data.next_practical_step)}</p>
</div>` : '';
els.results.innerHTML = `
<div class="adv-result-actions">
<button id="advNewQuery" class="adv-new-query-btn" type="button">New query ↑</button>
<button class="adv-new-query-btn" type="button" onclick="window.print()">Print</button>
</div>
${bannerHtml}
${strengthsHtml}
<div class="dr-result-block">
<div class="dr-section-head">
<h3 style="margin:0;font-size:1rem">Advocate brief</h3>
<div class="adv-brief-actions">
<button class="dr-copy-btn" id="advCopyBrief" type="button">Copy brief</button>
<button class="dr-copy-btn" id="advCopyArgs" type="button">Copy arguments</button>
<a class="dr-copy-btn" id="advDownloadMd" style="cursor:pointer">&#8595; .md</a>
</div>
</div>
<div class="dr-brief">${briefHtml}</div>
</div>
${weaknessesHtml}
${subQReportsHtml}
${sourcesHtml}
${uncertHtml}
${nextHtml}
`;
// Bind new-query button
const nq = document.getElementById('advNewQuery');
if (nq) nq.addEventListener('click', () => {
els.input.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => els.input.focus(), 300);
});
// Bind copy-brief
const copyBtn = document.getElementById('advCopyBrief');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
copyToClipboard(data.brief_markdown || '', copyBtn, 'Copy brief');
});
}
// Bind copy-arguments
const copyArgsBtn = document.getElementById('advCopyArgs');
if (copyArgsBtn) {
copyArgsBtn.addEventListener('click', () => {
const text = (data.client_strengths || []).map((s) => `- ${s}`).join('\n');
copyToClipboard(text, copyArgsBtn, 'Copy arguments');
});
}
// Bind download .md
const dlLink = document.getElementById('advDownloadMd');
if (dlLink) {
const md = buildExportMarkdown(data);
const blob = new Blob([md], { type: 'text/markdown; charset=utf-8' });
dlLink.href = URL.createObjectURL(blob);
dlLink.setAttribute('download', `advocate-brief-${Date.now()}.md`);
}
// Bind flip-brief button
els.results.querySelector('.adv-flip-btn')?.addEventListener('click', (e) => {
const toRole = e.currentTarget.dataset.flipRole;
if (toRole) flipBrief(toRole);
});
// Bind source-card clicks
els.results.querySelectorAll('.dr-source-card[data-source-n]').forEach((node) => {
node.addEventListener('click', (e) => {
if (e.target.closest('a')) return;
const n = parseInt(node.dataset.sourceN, 10);
const src = sources.find((s) => s.n === n);
if (src) { openModal(src); flashSource(n); }
});
});
// Bind inline citation markers
els.results.querySelectorAll('.dr-cite[data-source-n]').forEach((node) => {
node.addEventListener('click', (e) => {
if (e.target.closest('a')) return;
flashSource(parseInt(node.dataset.sourceN, 10));
});
});
}
// Convert [n] markers already present in escaped HTML into clickable spans
function renderInlineCitations(escapedHtml, sources) {
const sourceSet = new Set((sources || []).map((s) => s.n));
return escapedHtml.replace(/\[(\d+(?:\s*[-,]\s*\d+)*)\]/g, (_, group) => {
const nums = expandCiteGroup(group);
return nums.map((n) => `<span class="dr-cite" data-source-n="${n}" role="button" tabindex="0">${n}</span>`).join('');
});
}
function renderSubQReport(sq, idx) {
const top = sq.top_sources || [];
const sourceItems = top.length
? top.map((s) => {
const link = s.deep_link || s.source_url;
const titleHtml = link
? `<a href="${escapeHtml(link)}" target="_blank" rel="noopener" class="dr-mini-source__title">${escapeHtml(s.title || 'Untitled')} <span class="dr-external-link" aria-hidden="true">↗</span></a>`
: `<span class="dr-mini-source__title">${escapeHtml(s.title || 'Untitled')}</span>`;
const meta = [];
if (s.section) meta.push(escapeHtml(s.section));
if (s.authority_label) meta.push(escapeHtml(s.authority_label));
if (s.source_origin === 'upload') meta.push('your upload');
return `<li class="dr-mini-source">
<span class="dr-mini-source__n">[${s.n ?? '?'}]</span>
<div class="dr-mini-source__body">
${titleHtml}
${meta.length ? `<div class="dr-mini-source__meta">${meta.join(' · ')}</div>` : ''}
<div class="dr-mini-source__excerpt">${escapeHtml(truncate(s.excerpt || '', 180))}</div>
</div>
</li>`;
}).join('')
: `<li class="dr-mini-source dr-mini-source--empty"><em>No sources retrieved for this sub-question.</em></li>`;
return `<div class="dr-subq-report">
<div class="dr-subq-report__head">
<span class="dr-subq-report__index">${escapeHtml(sq.id || ('q' + (idx + 1)))}</span>
<div class="dr-subq-report__body">
<div class="dr-subq-report__question">${escapeHtml(sq.question || '')}</div>
${sq.rationale ? `<div class="dr-subq-report__rationale">${escapeHtml(sq.rationale)}</div>` : ''}
</div>
<button class="dr-branch-btn" type="button" data-question="${escapeHtml(sq.question || '')}" aria-label="Branch research from this sub-question">Branch ↓</button>
</div>
<ul class="dr-mini-source-list">${sourceItems}</ul>
</div>`;
}
function flashSource(n) {
document.querySelectorAll('.dr-source-card.is-highlight').forEach((c) => c.classList.remove('is-highlight'));
const target = document.querySelector(`.dr-source-card[data-source-n="${n}"]`);
if (target) {
target.classList.add('is-highlight');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => target.classList.remove('is-highlight'), 1800);
}
}
function bindBranch() {
if (!els.branchClear) return;
els.branchClear.addEventListener('click', clearBranch);
}
function clearBranch() {
branchContext = null;
if (els.branchPanel) els.branchPanel.classList.add('is-hidden');
if (els.branchNotes) els.branchNotes.value = '';
}
function branchFromSubQ(question) {
if (!lastResult || !question) return;
branchContext = {
original_query: lastResult.query || '',
brief_summary: (lastResult.brief_markdown || '').slice(0, 600),
what_we_found: lastResult.what_we_found || '',
top_sources: (lastResult.sources || []).slice(0, 5).map((s) => ({
n: s.n, title: s.title, excerpt: (s.excerpt || '').slice(0, 200),
})),
};
els.input.value = question;
if (els.branchOrigin) els.branchOrigin.textContent = 'Original query: ' + branchContext.original_query;
if (els.branchSummary) els.branchSummary.textContent = branchContext.brief_summary;
if (els.branchPanel) els.branchPanel.classList.remove('is-hidden');
els.form.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function renderSourceCard(s) {
const score = s.reranker_score != null ? s.reranker_score : s.similarity;
const originTagClass = s.source_origin === 'upload' ? 'dr-source-tag dr-source-tag--upload' : 'dr-source-tag';
const originLabel = s.source_origin === 'upload' ? 'upload' : 'corpus';
const graphExpanded = s.graph_expanded === true;
const link = s.deep_link || s.source_url;
const titleHtml = link
? `<a href="${escapeHtml(link)}" target="_blank" rel="noopener" class="dr-source-title-link">${escapeHtml(s.title || 'Untitled')} <span class="dr-external-link" aria-hidden="true">↗</span></a>`
: `${escapeHtml(s.title || 'Untitled')}`;
return `<div class="dr-source-card" data-source-n="${s.n}" role="button" tabindex="0">
<span class="dr-source-number">${s.n}</span>
<div class="dr-source-body">
<div class="dr-source-title">${titleHtml}</div>
${s.section ? `<div class="dr-source-meta"><span class="dr-source-tag">${escapeHtml(s.section)}</span></div>` : ''}
<div class="dr-source-meta">
<span class="${originTagClass}">${originLabel}</span>
${graphExpanded ? `<span class="dr-source-tag dr-source-tag--graph">via citation graph</span>` : ''}
${s.authority_label ? `<span class="dr-source-tag">${escapeHtml(s.authority_label)}</span>` : ''}
<span class="dr-source-tag dr-source-tag--score">${escapeHtml(s.package_or_corpus || '—')}</span>
${(s.matched_sub_questions || []).map((q) => `<span class="dr-source-tag">${escapeHtml(q)}</span>`).join('')}
</div>
<p class="dr-source-excerpt">${escapeHtml(truncate(s.excerpt || '', 240))}</p>
</div>
<div class="dr-source-aside">
<span>score<br><b>${score != null ? Number(score).toFixed(2) : '—'}</b></span>
${s.reranker_score != null && s.similarity != null ? `<span>sim<br><b>${Number(s.similarity).toFixed(2)}</b></span>` : ''}
</div>
</div>`;
}
function renderBrief(markdown, sources) {
if (!markdown) return '<p><em>No brief was returned.</em></p>';
const sourceSet = new Set((sources || []).map((s) => s.n));
const escaped = escapeHtml(markdown);
const withCites = escaped.replace(/\[(\d+(?:\s*[-,]\s*\d+)*)\]/g, (_, group) => {
const nums = expandCiteGroup(group);
return nums.map((n) => `<span class="dr-cite" data-source-n="${n}" role="button" tabindex="0">${n}</span>`).join('');
});
const withBold = withCites
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, '$1<em>$2</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
const paragraphs = withBold.split(/\n{2,}/).map((p) => {
const t = p.trim();
if (!t) return '';
if (/^### /.test(t)) return `<h4 style="margin:14px 0 6px;color:var(--ink);font-size:1rem">${t.replace(/^### /, '')}</h4>`;
return `<p>${t.replace(/\n/g, '<br>')}</p>`;
}).join('');
return paragraphs;
}
function expandCiteGroup(group) {
const out = [];
group.split(',').forEach((part) => {
const range = part.trim().match(/^(\d+)\s*-\s*(\d+)$/);
if (range) {
const a = parseInt(range[1], 10);
const b = parseInt(range[2], 10);
for (let i = a; i <= b; i++) out.push(i);
} else {
const n = parseInt(part.trim(), 10);
if (!Number.isNaN(n)) out.push(n);
}
});
return Array.from(new Set(out));
}
function updateCharCount() {
if (!els.inputCount || !els.input) return;
const len = els.input.value.length;
els.inputCount.textContent = `${len.toLocaleString()} / 4,000`;
els.inputCount.classList.toggle('is-warn', len > 3500 && len <= 3900);
els.inputCount.classList.toggle('is-crit', len > 3900);
}
function saveToCache(result, formState) {
try { localStorage.setItem(CACHE_KEY, JSON.stringify({ result, formState, ts: Date.now() })); }
catch (e) { /* quota — silently ignore */ }
}
function loadFromCache() {
try {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) return null;
const obj = JSON.parse(raw);
if (!obj || !obj.result || !obj.ts) return null;
if (Date.now() - obj.ts > 86400000) { localStorage.removeItem(CACHE_KEY); return null; }
return obj;
} catch (e) { return null; }
}
function showRestoreBanner({ result, formState, ts }) {
const age = Math.round((Date.now() - ts) / 60000);
const ageStr = age < 60 ? `${age}m ago` : `${Math.round(age / 60)}h ago`;
const banner = document.createElement('div');
banner.id = 'advRestoreBanner';
banner.className = 'adv-restore-banner';
banner.innerHTML = `
<span class="adv-restore-banner__text">
Restore last session <em>(${ageStr})</em> — ${escapeHtml(formState.role || '?')} ·
&ldquo;${escapeHtml((formState.query || '').slice(0, 60))}${(formState.query || '').length > 60 ? '…' : ''}&rdquo;
</span>
<div class="adv-restore-banner__actions">
<button type="button" id="advRestoreYes">Restore</button>
<button type="button" id="advRestoreNo">Dismiss</button>
</div>`;
els.results.parentNode.insertBefore(banner, els.results);
document.getElementById('advRestoreYes').addEventListener('click', () => {
banner.remove();
restoreSession(result, formState);
});
document.getElementById('advRestoreNo').addEventListener('click', () => {
banner.remove();
localStorage.removeItem(CACHE_KEY);
});
}
function restoreSession(result, formState) {
els.input.value = formState.query || '';
updateCharCount();
if (formState.role) els.roleSelect.value = formState.role;
const radio = els.engineRadios.find((r) => r.value === formState.engine);
if (radio) radio.checked = true;
if (formState.slices) {
els.slices.forEach((btn) => {
const on = !!formState.slices[btn.dataset.slice];
btn.classList.toggle('is-on', on);
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
btn.querySelector('.dr-slice__badge').textContent = on ? 'on' : 'off';
});
}
lastResult = result;
result.query = formState.query || '';
renderResults(result, true);
const meta = result.trace_metadata || {};
setStatus(`Restored · confidence ${meta.citation_confidence || '?'}`, 'ok');
}
function showSliceHint(echrBtn, ncBtn, echrOff, ncOff) {
document.getElementById('advSliceHint')?.remove();
const names = [echrOff && 'ECHR', ncOff && 'Norwegian Courts'].filter(Boolean).join(' & ');
const hint = document.createElement('p');
hint.id = 'advSliceHint';
hint.className = 'adv-slice-hint';
hint.innerHTML = `${escapeHtml(names)} ${names.includes('&') ? 'are' : 'is'} often valuable for parent/family cases.
<button type="button" id="advSliceHintEnable">Enable ${escapeHtml(names)}</button>
<button type="button" id="advSliceHintDismiss">Dismiss</button>`;
document.querySelector('.dr-slice-section')?.after(hint);
document.getElementById('advSliceHintEnable').addEventListener('click', () => {
if (echrOff) activateSlice(echrBtn);
if (ncOff) activateSlice(ncBtn);
hint.remove();
});
document.getElementById('advSliceHintDismiss').addEventListener('click', () => hint.remove());
}
function activateSlice(btn) {
if (!btn) return;
btn.classList.add('is-on');
btn.setAttribute('aria-pressed', 'true');
btn.querySelector('.dr-slice__badge').textContent = 'on';
}
function copyToClipboard(text, btn, originalLabel) {
navigator.clipboard.writeText(text).then(() => {
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = originalLabel; }, 1500);
}).catch(() => {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = originalLabel; }, 1500);
});
}
function buildExportMarkdown(data) {
const role = data.advocate_role || 'Unknown party';
const date = new Date().toISOString().slice(0, 10);
const strengths = (data.client_strengths || []).map((s) => `- ${s}`).join('\n');
const weaknesses = (data.opposing_weaknesses || []).map((w) => `- ${w}`).join('\n');
const uncertainty = (data.what_remains_uncertain || []).map((u) => `- ${u}`).join('\n');
return [
`# Advocate Brief — ${role}`,
`**Generated:** ${date}`,
'',
strengths ? '## Strongest Arguments\n' + strengths : '',
'',
'## Brief',
data.brief_markdown || '',
weaknesses ? '\n## Gaps in Opposing Position\n' + weaknesses : '',
uncertainty ? '\n## What Remains Uncertain\n' + uncertainty : '',
data.next_practical_step ? `\n## Next Practical Step\n${data.next_practical_step}` : '',
'',
'---',
'_Generated by Do Better Norge — Case Advocate_',
].filter((s) => s !== null && s !== '').join('\n');
}
function flipBrief(toRole) {
const optionExists = Array.from(els.roleSelect.options).some((o) => o.value === toRole);
if (optionExists) {
els.roleSelect.value = toRole;
if (els.roleCustom) els.roleCustom.classList.add('is-hidden');
} else {
els.roleSelect.value = '__other__';
if (els.roleCustom) {
els.roleCustom.classList.remove('is-hidden');
els.roleCustom.value = toRole;
}
}
els.form.scrollIntoView({ behavior: 'smooth', block: 'start' });
setTimeout(() => els.form.requestSubmit(), 200);
}
function bindPreviewAngles() {
if (!els.previewAngles) return;
els.previewAngles.addEventListener('click', async () => {
const advocateRole = getAdvocateRole();
if (!advocateRole) { setStatus('Select who you are representing first.', 'error'); return; }
const query = (els.input.value || '').trim();
if (!query) { setStatus('Describe the case situation first.', 'error'); return; }
els.previewAngles.disabled = true;
els.previewAngles.textContent = 'Generating angles…';
setStatus('Generating research angles (steps 12 only, ~10s)…', 'busy');
try {
const res = await fetch('api/generate-subq.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
language: lang,
engine: getEngine(),
controls: getControls(),
advocate_role: advocateRole,
}),
credentials: 'same-origin',
});
const json = await res.json();
if (!res.ok || !json.ok) {
setStatus(json.message || `Error ${res.status}`, 'error');
return;
}
const sqs = json.sub_questions || [];
if (els.subQPreviewList) {
els.subQPreviewList.innerHTML = sqs.map((sq, i) => `
<div class="adv-subq-preview-item">
<label class="adv-subq-preview-label">Angle ${i + 1}</label>
<textarea class="adv-subq-edit" rows="3" data-sq-id="${escapeHtml(sq.id || ('q' + (i + 1)))}">${escapeHtml(sq.question || '')}</textarea>
${sq.rationale ? `<p class="adv-subq-rationale">${escapeHtml(sq.rationale)}</p>` : ''}
</div>`).join('');
}
if (els.subQPreview) {
els.subQPreview.classList.remove('is-hidden');
els.subQPreview.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
setStatus(`${sqs.length} research angles generated — review and edit, then run.`, 'ok');
} catch (err) {
setStatus(`Error generating angles: ${err.message || err}`, 'error');
} finally {
els.previewAngles.disabled = false;
els.previewAngles.textContent = 'Preview research angles first';
}
});
els.runWithAngles?.addEventListener('click', () => {
const textareas = els.subQPreviewList?.querySelectorAll('.adv-subq-edit') || [];
customSubQuestions = Array.from(textareas).map((ta, i) => ({
id: ta.dataset.sqId || `q${i + 1}`,
question: ta.value.trim(),
rationale: 'User-edited angle.',
})).filter((sq) => sq.question);
if (!customSubQuestions.length) {
setStatus('All angles are empty — edit at least one before running.', 'error');
customSubQuestions = null;
return;
}
if (els.subQPreview) els.subQPreview.classList.add('is-hidden');
els.form.requestSubmit();
});
els.discardAngles?.addEventListener('click', () => {
customSubQuestions = null;
if (els.subQPreview) els.subQPreview.classList.add('is-hidden');
});
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function truncate(s, n) {
if (!s) return '';
if (s.length <= n) return s;
return s.slice(0, n - 1) + '…';
}
})();