From 640778454ffb052ccd11213f060849809f6c9a74 Mon Sep 17 00:00:00 2001 From: davegilligan Date: Fri, 15 May 2026 12:26:05 +0200 Subject: [PATCH] =?UTF-8?q?Add=20Case=20Advocate=20tab=20=E2=80=94=20parti?= =?UTF-8?q?san=20brief=20grounded=20in=20Norwegian=20law?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /advocate.php tab: user selects who they represent (biological father, mother, foster carer, CWS, etc.) and the agent takes their side entirely. Adversarial sub-questions target supporting Lovdata statutes + ECHR precedents; synthesis returns client_strengths[] and opposing_weaknesses[] alongside the advocate brief. - DeepResearchAgent: add advocateRole param to run(), interpretSeed(), expandQueries(), synthesise(). Neutral path unchanged (empty string). - api/deep-research.php: extract + validate advocate_role from payload; telemetry logs tool='advocate' vs 'deep_research'. - advocate.php: new page with role dropdown (presets + custom), same corpus slices/engine/controls/upload zone as deep research. - assets/js/advocate.js: page-scoped JS; renders advocate banner, client strengths card (teal), advocate brief, opposing weaknesses card (amber), sub-Q cards, sources, uncertainty, next step. - assets/css/tools.css: append .adv-* rules (~120 lines). - includes/layout.php: add Advocate nav tab between Deep research and Summarize. - index.php: add Advocate cap-card tile. Co-Authored-By: Claude Sonnet 4.6 --- advocate.php | 182 +++++++++ api/deep-research.php | 34 +- assets/css/tools.css | 161 ++++++++ assets/js/advocate.js | 693 +++++++++++++++++++++++++++++++++ includes/DeepResearchAgent.php | 102 ++++- includes/layout.php | 1 + index.php | 7 +- 7 files changed, 1154 insertions(+), 26 deletions(-) create mode 100644 advocate.php create mode 100644 assets/js/advocate.js diff --git a/advocate.php b/advocate.php new file mode 100644 index 0000000..2d8cded --- /dev/null +++ b/advocate.php @@ -0,0 +1,182 @@ + +
+ +
+ + +
+ + +
+ + + +

The agent will frame every sub-question, retrieval pass, and the final brief to argue for the selected party, identifying weaknesses in the opposing position and citing Lovdata statutes, ECHR judgments, and Bufdir guidance.

+
+ +
+ Engine + + + +
+

Azure mini finishes fastest. Azure full produces the most thorough advocate brief. GPU keeps everything inside the BNL fleet.

+ +
+

Corpus slices

+

Select which slices the agent searches when building your case. All three legal slices are on by default.

+
+ + + + +
+
+ +
+ Advanced controls +
+
+ + + How many angles the agent generates (each framed to strengthen your case). +
+
+ + + Corpus chunks retrieved per sub-question. +
+
+ + + Minimum similarity for uploaded-doc chunks to be included. +
+
+ + + Top sources kept after dedupe + rerank to feed synthesis. +
+
+ + + Keep low for grounded legal briefs. +
+
+
+ +
+ +
+ +

Drop case files here, or

+

PDF, DOCX, TXT — up to 5 files — chunked + embedded in memory only, never stored.

+
+ +
+ + + + + +
+ +
+
+

Ready

+

Select who you are representing, describe the dispute, and optionally upload case documents. The agent will argue your side — identifying supporting statutes, ECHR judgments, and weaknesses in the opposing position.

+
+
+ + + + + + + + + + + + + + diff --git a/api/deep-research.php b/api/deep-research.php index 99bdba3..edbb255 100644 --- a/api/deep-research.php +++ b/api/deep-research.php @@ -52,12 +52,16 @@ try { } } - $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); - $seedQuery = trim((string)($input['query'] ?? '')); - $pastedText = trim((string)($input['paste_text'] ?? '')); - $sliceInput = $input['slices'] ?? []; - $engine = (string)($input['engine'] ?? 'azure_mini'); - $controls = is_array($input['controls'] ?? null) ? $input['controls'] : []; + $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); + $seedQuery = trim((string)($input['query'] ?? '')); + $pastedText = trim((string)($input['paste_text'] ?? '')); + $sliceInput = $input['slices'] ?? []; + $engine = (string)($input['engine'] ?? 'azure_mini'); + $controls = is_array($input['controls'] ?? null) ? $input['controls'] : []; + $advocateRole = trim((string)($input['advocate_role'] ?? '')); + if (mb_strlen($advocateRole, 'UTF-8') > 200) { + throw new DbnToolsHttpException('advocate_role is too long.', 422, 'advocate_role_too_long'); + } if (mb_strlen($seedQuery, 'UTF-8') > 4000) { throw new DbnToolsHttpException('Query is too long.', 422, 'query_too_long'); @@ -113,20 +117,22 @@ try { $engine, $language, $controls, - $emit + $emit, + $advocateRole ); $result['ok'] = true; $result['latency_ms'] = (int)round((microtime(true) - $startTime) * 1000); dbnToolsLogMetadata([ - 'tool' => 'deep_research', - 'language' => $language, - 'ok' => true, - 'latency_ms' => $result['latency_ms'], - 'chunk_count' => (int)($result['trace_metadata']['chunk_count'] ?? 0), - 'source_count' => (int)($result['trace_metadata']['source_count'] ?? 0), - 'deployment' => $result['trace_metadata']['deployment'] ?? null, + 'tool' => $advocateRole !== '' ? 'advocate' : 'deep_research', + 'language' => $language, + 'ok' => true, + 'latency_ms' => $result['latency_ms'], + 'chunk_count' => (int)($result['trace_metadata']['chunk_count'] ?? 0), + 'source_count' => (int)($result['trace_metadata']['source_count'] ?? 0), + 'deployment' => $result['trace_metadata']['deployment'] ?? null, + 'advocate_role' => $advocateRole !== '' ? $advocateRole : null, ]); $emit('final', ['result' => $result]); diff --git a/assets/css/tools.css b/assets/css/tools.css index b9cd6bb..21bb4d8 100644 --- a/assets/css/tools.css +++ b/assets/css/tools.css @@ -3052,3 +3052,164 @@ a.dr-source-title-link:hover { .source-expand-grid { grid-template-columns: 1fr; } .corpus-search-controls { flex-direction: column; align-items: flex-start; } } + +/* ===================================================================== + Advocate tool (.adv-*) + ===================================================================== */ + +/* Role selector row */ +.adv-role-row { + display: flex; + flex-direction: column; + gap: 8px; + padding: 18px 20px; + background: var(--soft-teal); + border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--teal) 20%, transparent); + margin-bottom: 18px; +} +.adv-role-row .control-label { + font-size: 0.82rem; + font-weight: 600; + color: var(--teal-dark, var(--teal)); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0; +} +.adv-role-select { + appearance: none; + background: var(--panel) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M0 0l6 8 6-8z' fill='%23666'/%3E%3C/svg%3E") no-repeat right 14px center; + border: 1px solid var(--line); + border-radius: 6px; + padding: 10px 38px 10px 14px; + font-size: 0.95rem; + color: var(--ink); + cursor: pointer; + width: 100%; + max-width: 480px; +} +.adv-role-select:focus { outline: 2px solid var(--teal); outline-offset: 2px; } +.adv-role-custom { + border: 1px solid var(--line); + border-radius: 6px; + padding: 9px 14px; + font-size: 0.93rem; + color: var(--ink); + width: 100%; + max-width: 480px; + background: var(--panel); +} +.adv-role-custom:focus { outline: 2px solid var(--teal); outline-offset: 2px; } + +/* Advocate result banner */ +.adv-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 14px; + padding: 14px 20px; + background: var(--soft-teal); + border-radius: 8px; + border-left: 4px solid var(--teal); + margin-bottom: 16px; +} +.adv-banner__label { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--teal-dark, var(--teal)); + flex-shrink: 0; +} +.adv-banner__role { + font-size: 1.05rem; + color: var(--ink); +} +.adv-banner__note { + font-size: 0.8rem; + color: var(--muted); + flex-basis: 100%; +} + +/* Client strengths card */ +.adv-strengths { + background: var(--soft-teal); + border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--teal) 20%, transparent); + padding: 16px 20px; + margin-bottom: 14px; +} +.adv-strengths__head { + font-size: 0.9rem; + font-weight: 700; + color: var(--teal-dark, var(--teal)); + margin: 0 0 10px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.adv-strengths__list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 7px; +} +.adv-strengths__item { + padding-left: 24px; + position: relative; + font-size: 0.88rem; + color: var(--ink); + line-height: 1.5; +} +.adv-strengths__item::before { + content: '✓'; + position: absolute; + left: 0; + color: var(--teal); + font-weight: 700; +} + +/* Opposing weaknesses card */ +.adv-weaknesses { + background: color-mix(in srgb, var(--amber, #c87f00) 10%, transparent); + border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--amber, #c87f00) 25%, transparent); + padding: 16px 20px; + margin-bottom: 14px; +} +.adv-weaknesses__head { + font-size: 0.9rem; + font-weight: 700; + color: var(--amber-dark, #8a5700); + margin: 0 0 10px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.adv-weaknesses__list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 7px; +} +.adv-weaknesses__item { + padding-left: 24px; + position: relative; + font-size: 0.88rem; + color: var(--ink); + line-height: 1.5; +} +.adv-weaknesses__item::before { + content: '⚠'; + position: absolute; + left: 0; + font-size: 0.85em; + color: var(--amber, #c87f00); +} + +@media (max-width: 760px) { + .adv-role-select, .adv-role-custom { max-width: 100%; } + .adv-banner { flex-direction: column; align-items: flex-start; } +} diff --git a/assets/js/advocate.js b/assets/js/advocate.js new file mode 100644 index 0000000..e0877c2 --- /dev/null +++ b/assets/js/advocate.js @@ -0,0 +1,693 @@ +/* advocate.js — page-scoped UI for /advocate.php */ +(function () { + 'use strict'; + + const els = {}; + let lang = 'en'; + let uploadFiles = []; + let lastResult = null; + + const SLICE_DEFS = [ + { id: 'family_core', label: 'Family Law Core' }, + { id: 'child_welfare', label: 'Child Welfare' }, + { id: 'echr_hague', label: 'ECHR and Hague' }, + { id: 'broader_legal', label: 'Broader Legal Support' }, + ]; + + 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'), + }); + + if (!els.form) return; + + bindRole(); + bindSlices(); + bindLang(); + bindRanges(); + bindUpload(); + bindModal(); + els.form.addEventListener('submit', onSubmit); + + renderTrace(STEP_LABELS.map((label) => ({ label, detail: 'Waiting…', status: 'idle' }))); + }); + + 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(); + }); + } + + 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.addEventListener('click', () => { + els.langButtons.forEach((x) => x.classList.remove('is-active')); + b.classList.add('is-active'); + lang = b.dataset.lang || 'en'; + }); + }); + } + + 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 > 4 * 1024 * 1024) { + setStatus(`${f.name} exceeds the 4 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 `
  • ${escapeHtml(f.name)}${kb} KB
  • `; + }).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 = '
    ' + metaRows.map(([k, v]) => `
    ${escapeHtml(k)}
    ${escapeHtml(String(v))}
    `).join('') + '
    '; + els.modalText.textContent = source.chunk_text || source.excerpt || ''; + 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' + ? '60–180 seconds with Azure gpt-4o' + : (engine === 'gpu' ? '30–90 seconds on GPU' : '15–45 seconds with Azure gpt-4o-mini'); + + setStatus(`Building advocate brief for ${advocateRole}… (${expectedDuration})`, 'busy'); + els.runButton.disabled = true; + els.results.innerHTML = `

    Researching your case…

    The agent is generating adversarial sub-questions and retrieving from the legal corpus on behalf of ${escapeHtml(advocateRole)}. Live progress in the right-hand panel. Expect ${expectedDuration}.

    `; + + 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, + }; + + 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; + } + + 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); + + 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; + 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 `
  • + ${marker} +
    + ${escapeHtml(step.label || '')} + ${escapeHtml(step.detail || '')} +
    +
  • `; + }).join(''); + } + + function renderResults(data) { + 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 + const bannerHtml = role ? ` +
    + Representing + ${escapeHtml(role)} + Brief argues for this party · grounded in Norwegian law and ECHR authorities +
    ` : ''; + + // 2. Client strengths + const strengthsHtml = strengths.length ? ` +
    +

    Your strongest arguments

    +
      + ${strengths.map((s) => `
    • ${renderInlineCitations(escapeHtml(String(s)), sources)}
    • `).join('')} +
    +
    ` : ''; + + // 3. Brief + const briefHtml = renderBrief(data.brief_markdown || '', sources); + + // 4. Opposing weaknesses + const weaknessesHtml = weaknesses.length ? ` +
    +

    Gaps in the opposing position

    +
      + ${weaknesses.map((w) => `
    • ${renderInlineCitations(escapeHtml(String(w)), sources)}
    • `).join('')} +
    +
    ` : ''; + + // 5. Sub-Q report cards + const subQReportsHtml = subs.length ? ` +
    +
    +

    What each sub-question agent researched

    + ${subs.length} sub-question${subs.length === 1 ? '' : 's'} framed for ${escapeHtml(role || 'your client')} +
    +
    + ${subs.map((sq, i) => renderSubQReport(sq, i)).join('')} +
    +
    ` : ''; + + // 6. Sources + const sourcesHtml = ` +
    +
    +

    All sources (${sources.length})

    + Click a card to see the full chunk · external link opens the original article +
    +
    + ${sources.map((s) => renderSourceCard(s)).join('')} +
    +
    `; + + // 7. Uncertainty + const uncertHtml = (data.what_remains_uncertain || []).length ? ` +
    +

    What remains uncertain

    +
      + ${(data.what_remains_uncertain || []).map((u) => `
    • ${escapeHtml(String(u))}
    • `).join('')} +
    +
    ` : ''; + + // 8. Next step + const nextHtml = data.next_practical_step ? ` +
    +

    Next practical step

    +

    ${escapeHtml(data.next_practical_step)}

    +
    ` : ''; + + els.results.innerHTML = ` + ${bannerHtml} + ${strengthsHtml} +
    +

    Advocate brief

    +
    ${briefHtml}
    +
    + ${weaknessesHtml} + ${subQReportsHtml} + ${sourcesHtml} + ${uncertHtml} + ${nextHtml} + `; + + // 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) => `${n}`).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 + ? `${escapeHtml(s.title || 'Untitled')} ` + : `${escapeHtml(s.title || 'Untitled')}`; + 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 `
  • + [${s.n ?? '?'}] +
    + ${titleHtml} + ${meta.length ? `
    ${meta.join(' · ')}
    ` : ''} +
    ${escapeHtml(truncate(s.excerpt || '', 180))}
    +
    +
  • `; + }).join('') + : `
  • No sources retrieved for this sub-question.
  • `; + + return `
    +
    + ${escapeHtml(sq.id || ('q' + (idx + 1)))} +
    +
    ${escapeHtml(sq.question || '')}
    + ${sq.rationale ? `
    ${escapeHtml(sq.rationale)}
    ` : ''} +
    +
    +
      ${sourceItems}
    +
    `; + } + + 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 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 link = s.deep_link || s.source_url; + const titleHtml = link + ? `${escapeHtml(s.title || 'Untitled')} ` + : `${escapeHtml(s.title || 'Untitled')}`; + return `
    + ${s.n} +
    +
    ${titleHtml}
    + ${s.section ? `
    ${escapeHtml(s.section)}
    ` : ''} +
    + ${originLabel} + ${s.authority_label ? `${escapeHtml(s.authority_label)}` : ''} + ${escapeHtml(s.package_or_corpus || '—')} + ${(s.matched_sub_questions || []).map((q) => `${escapeHtml(q)}`).join('')} +
    +

    ${escapeHtml(truncate(s.excerpt || '', 240))}

    +
    +
    + score
    ${score != null ? Number(score).toFixed(2) : '—'}
    + ${s.reranker_score != null && s.similarity != null ? `sim
    ${Number(s.similarity).toFixed(2)}
    ` : ''} +
    +
    `; + } + + function renderBrief(markdown, sources) { + if (!markdown) return '

    No brief was returned.

    '; + 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) => `${n}`).join(''); + }); + + const withBold = withCites + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, '$1$2') + .replace(/`([^`]+)`/g, '$1'); + + const paragraphs = withBold.split(/\n{2,}/).map((p) => { + const t = p.trim(); + if (!t) return ''; + if (/^### /.test(t)) return `

    ${t.replace(/^### /, '')}

    `; + return `

    ${t.replace(/\n/g, '
    ')}

    `; + }).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 escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function truncate(s, n) { + if (!s) return ''; + if (s.length <= n) return s; + return s.slice(0, n - 1) + '…'; + } +})(); diff --git a/includes/DeepResearchAgent.php b/includes/DeepResearchAgent.php index 5a96a49..f4d1f61 100644 --- a/includes/DeepResearchAgent.php +++ b/includes/DeepResearchAgent.php @@ -30,7 +30,8 @@ final class DbnDeepResearchAgent string $engine, string $language, array $controls, - ?callable $emit = null + ?callable $emit = null, + string $advocateRole = '' ): array { $seedQuery = trim($seedQuery); $pastedText = trim($pastedText); @@ -81,14 +82,14 @@ final class DbnDeepResearchAgent // STEP 1: Query interpretation $emitRunning('interpretation', 'Query interpretation', 'Summarising the seed input…'); $stepStart = microtime(true); - $interpretation = $this->interpretSeed($seedDescription, $language); + $interpretation = $this->interpretSeed($seedDescription, $language, $advocateRole); $this->stepTimings['interpretation'] = $this->elapsedMs($stepStart); $emitStep('interpretation', 'Query interpretation', $interpretation['detail'], 'complete'); // STEP 2: Query expansion $emitRunning('expansion', 'Query expansion', 'Generating sub-questions…'); $stepStart = microtime(true); - $expansion = $this->expandQueries($seedDescription, $interpretation['brief'], $controls['sub_q_count'], $language); + $expansion = $this->expandQueries($seedDescription, $interpretation['brief'], $controls['sub_q_count'], $language, $advocateRole); $this->stepTimings['expansion'] = $this->elapsedMs($stepStart); $subQuestions = $expansion['questions']; $expansionStatus = $expansion['fallback'] ? 'warning' : 'complete'; @@ -290,7 +291,8 @@ final class DbnDeepResearchAgent $numberedSources, $engine, $language, - $controls['temperature'] + $controls['temperature'], + $advocateRole ); $this->stepTimings['synthesis'] = $this->elapsedMs($stepStart); $emitStep( @@ -335,10 +337,14 @@ final class DbnDeepResearchAgent ]; } + $isAdvocate = $advocateRole !== ''; return [ - 'tool' => 'deep_research', + 'tool' => $isAdvocate ? 'advocate' : 'deep_research', 'language' => $language, + 'advocate_role' => $isAdvocate ? $advocateRole : null, 'brief_markdown' => (string)($synthesis['json']['brief_markdown'] ?? $synthesis['json']['answer'] ?? ''), + 'client_strengths' => $isAdvocate ? ($synthesis['json']['client_strengths'] ?? []) : null, + 'opposing_weaknesses' => $isAdvocate ? ($synthesis['json']['opposing_weaknesses'] ?? []) : null, 'sub_questions' => $subQOut, 'sources' => $numberedSources, 'what_we_found' => (string)($synthesis['json']['what_we_found'] ?? ''), @@ -405,11 +411,14 @@ final class DbnDeepResearchAgent return implode("\n\n", $parts); } - private function interpretSeed(string $seedDescription, string $language): array + private function interpretSeed(string $seedDescription, string $language, string $advocateRole = ''): array { $locale = $language === 'no' ? 'Norwegian' : 'English'; + $rolePrefix = $advocateRole !== '' + ? "You are preparing a case-research brief for: {$advocateRole}. Frame your interpretation to identify the strongest legal angles for this party.\n\n" + : ''; $prompt = <<azure->chatText([ @@ -790,7 +831,8 @@ PROMPT; array $numberedSources, string $engine, string $language, - float $temperature + float $temperature, + string $advocateRole = '' ): array { $locale = $language === 'no' ? 'Norwegian' : 'English'; @@ -839,7 +881,44 @@ PROMPT; ? '400-900 words, minimum 4 paragraphs, with clear paragraph breaks. Cover EACH sub-question above in its own paragraph.' : '250-450 words, 2-3 short paragraphs. Note when evidence is thin.'; - $prompt = << 'system', 'content' => 'You return valid JSON only. No markdown fences.'], diff --git a/includes/layout.php b/includes/layout.php index 632d386..a4207a0 100644 --- a/includes/layout.php +++ b/includes/layout.php @@ -12,6 +12,7 @@ $navItems = [ 'ask' => ['Ask', 'Source-grounded'], 'search' => ['Search', 'Legal sources'], 'deep-research' => ['Deep research', 'Agent + RAG'], + 'advocate' => ['Advocate', 'Take a side'], 'summarize' => ['Summarize', 'Pasted text'], 'timeline' => ['Timeline', 'Events'], 'redact' => ['Redact', 'Privacy'], diff --git a/index.php b/index.php index e86af41..985cff6 100644 --- a/index.php +++ b/index.php @@ -91,7 +91,7 @@ if (dbnToolsIsAuthenticated()) {
    -

    Seven tools, one suite

    +

    Eight tools, one suite

    Ask @@ -108,6 +108,11 @@ if (dbnToolsIsAuthenticated()) {

    Deep research

    Upload a case file or paste a question. An agent expands it into 3–5 angles, runs hybrid rank/rerank RAG across the corpus + your upload, and returns a cited brief.

    +
    Summarize

    Summarize