diff --git a/advocate.php b/advocate.php index 0870fb3..7361fbc 100644 --- a/advocate.php +++ b/advocate.php @@ -36,6 +36,12 @@ require_once __DIR__ . '/includes/layout.php';

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 @@ -166,12 +172,23 @@ require_once __DIR__ . '/includes/layout.php';
- - + + diff --git a/api/deep-research.php b/api/deep-research.php index 4347d4b..f56b34f 100644 --- a/api/deep-research.php +++ b/api/deep-research.php @@ -65,8 +65,9 @@ try { if (mb_strlen($advocateRole, 'UTF-8') > 200) { throw new DbnToolsHttpException('advocate_role is too long.', 422, 'advocate_role_too_long'); } - $priorContext = is_array($input['prior_context'] ?? null) ? $input['prior_context'] : null; - $branchNotes = mb_substr(trim((string)($input['branch_notes'] ?? '')), 0, 1000, 'UTF-8'); + $priorContext = is_array($input['prior_context'] ?? null) ? $input['prior_context'] : null; + $branchNotes = mb_substr(trim((string)($input['branch_notes'] ?? '')), 0, 1000, 'UTF-8'); + $subQsOverride = is_array($input['sub_questions_override'] ?? null) ? $input['sub_questions_override'] : []; if (mb_strlen($seedQuery, 'UTF-8') > 4000) { throw new DbnToolsHttpException('Query is too long.', 422, 'query_too_long'); @@ -125,7 +126,8 @@ try { $emit, $advocateRole, $priorContext, - $branchNotes + $branchNotes, + $subQsOverride ); $result['ok'] = true; diff --git a/api/generate-subq.php b/api/generate-subq.php new file mode 100644 index 0000000..3bab510 --- /dev/null +++ b/api/generate-subq.php @@ -0,0 +1,56 @@ + 20000) { + throw new DbnToolsHttpException('Request body unreadable or too large.', 413, 'body_too_large'); + } + $input = json_decode((string)$raw, true); + if (!is_array($input)) { + throw new DbnToolsHttpException('Request body must be valid JSON.', 400, 'invalid_json'); + } + + $seedQuery = mb_substr(trim((string)($input['query'] ?? '')), 0, 4000, 'UTF-8'); + $pastedText = mb_substr(trim((string)($input['paste_text'] ?? '')), 0, 64000, 'UTF-8'); + $engine = (string)($input['engine'] ?? 'azure_mini'); + $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); + $controls = is_array($input['controls'] ?? null) ? $input['controls'] : []; + $advocateRole = mb_substr(trim((string)($input['advocate_role'] ?? '')), 0, 200, 'UTF-8'); + $priorContext = is_array($input['prior_context'] ?? null) ? $input['prior_context'] : null; + $branchNotes = mb_substr(trim((string)($input['branch_notes'] ?? '')), 0, 1000, 'UTF-8'); + + if ($seedQuery === '' && $pastedText === '') { + throw new DbnToolsHttpException('Provide a query or pasted text.', 422, 'missing_seed'); + } + + $result = (new DbnDeepResearchAgent())->generateSubQPreview( + $seedQuery, + $pastedText, + $engine, + $language, + $controls, + $advocateRole, + $priorContext, + $branchNotes + ); + + echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + +} catch (DbnToolsHttpException $e) { + http_response_code($e->getCode() ?: 500); + echo json_encode(['ok' => false, 'code' => $e->getSlug(), 'message' => $e->getMessage()]); +} catch (Throwable $e) { + error_log('DBN generate-subq error: ' . $e->getMessage()); + http_response_code(500); + echo json_encode(['ok' => false, 'code' => 'internal_error', 'message' => 'Could not generate sub-questions.']); +} diff --git a/assets/css/tools.css b/assets/css/tools.css index 01fb4b9..b28a261 100644 --- a/assets/css/tools.css +++ b/assets/css/tools.css @@ -1737,6 +1737,118 @@ p { color: var(--ink); } +/* Timeline count badge */ +.timeline-count-badge { + font-size: 0.8rem; + color: var(--muted); + margin: 0 0 0.6rem; + font-variant-numeric: tabular-nums; +} + +/* Actor filter chips */ +.timeline-actor-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-bottom: 0.75rem; +} +.timeline-actor-chip { + font-size: 0.75rem; + font-weight: 500; + padding: 0.2rem 0.65rem; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--bg); + color: var(--muted); + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; + white-space: nowrap; +} +.timeline-actor-chip:hover { background: var(--soft-teal); color: var(--teal-dark); border-color: var(--teal); } +.timeline-actor-chip.is-active { background: var(--teal); color: #fff; border-color: var(--teal); } + +/* Search + source toggle toolbar */ +.timeline-toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} +.timeline-search { + flex: 1; + min-width: 0; + font-size: 0.83rem; + padding: 0.28rem 0.65rem; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg); + color: var(--ink); + outline-offset: 2px; +} +.timeline-search:focus { border-color: var(--teal); outline: 2px solid color-mix(in srgb, var(--teal) 25%, transparent); } +.source-toggle-btn { + flex-shrink: 0; + font-size: 0.75rem; + font-weight: 500; + padding: 0.28rem 0.7rem; + border-radius: 6px; + border: 1px solid var(--line); + background: transparent; + color: var(--muted); + cursor: pointer; + white-space: nowrap; + transition: background 0.12s, color 0.12s; +} +.source-toggle-btn:hover { background: var(--bg); color: var(--ink); } + +/* Year/month group headers */ +.timeline-group-header { + list-style: none; + display: flex; + align-items: center; + gap: 0.6rem; + margin: 0.9rem 0 0.4rem; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); +} +.timeline-group-header:first-child { margin-top: 0; } +.timeline-group-header::before, +.timeline-group-header::after { + content: ''; + flex: 1; + height: 1px; + background: var(--line); +} + +/* Per-event copy button */ +.timeline-copy-btn { + margin-left: auto; + flex-shrink: 0; + font-size: 0.8rem; + line-height: 1; + padding: 0.15rem 0.35rem; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + border-radius: 4px; + opacity: 0; + transition: opacity 0.1s, background 0.1s; +} +.timeline-item:hover .timeline-copy-btn { opacity: 1; } +.timeline-copy-btn:hover { background: var(--soft-teal); color: var(--teal-dark); } +.timeline-copy-btn:focus-visible { opacity: 1; outline: 2px solid var(--teal); outline-offset: 1px; } + +/* Empty state when filters yield nothing */ +.timeline-empty { + color: var(--muted); + font-style: italic; + padding: 0.5rem 0; +} + .date-type-badge { display: inline-block; font-size: 0.68rem; @@ -5891,3 +6003,58 @@ body.lt-landing { .adv-slice-hint button { font-size:.8rem; padding:2px 10px; border-radius:4px; cursor:pointer; } #advSliceHintEnable { background:var(--teal); color:#fff; border:none; } #advSliceHintDismiss { background:transparent; border:1px solid var(--line); color:var(--muted); } + +/* === Advocate UX additions — F1/F3/F5/F6/F7 === */ +/* Brief action buttons */ +.adv-brief-actions { display:flex; align-items:center; gap:6px; flex-wrap:wrap; } +.adv-result-actions { display:flex; align-items:center; gap:8px; justify-content:flex-end; margin-bottom:16px; } + +/* Strength branch button */ +.adv-strengths__item { display:flex; align-items:flex-start; gap:6px; } +.dr-strength-branch-btn { flex-shrink:0; font-size:.75rem; padding:1px 6px; border:1px solid var(--line); border-radius:3px; background:var(--bg); color:var(--teal); cursor:pointer; margin-top:1px; } +.dr-strength-branch-btn:hover { background:var(--soft-teal); } + +/* Flip-brief bar */ +.adv-flip-bar { display:flex; align-items:center; gap:10px; padding:8px 14px; margin-bottom:12px; background:var(--bg); border:1px solid var(--line); border-radius:6px; font-size:.85rem; flex-wrap:wrap; } +.adv-flip-bar__label { color:var(--muted); } +.adv-flip-bar__role { font-weight:600; color:var(--ink); } +.adv-flip-btn { margin-left:auto; font-size:.82rem; padding:3px 12px; border:1px solid var(--teal); border-radius:4px; background:var(--soft-teal); color:var(--teal-dark); cursor:pointer; } +.adv-flip-btn:hover { background:var(--teal); color:#fff; } + +/* Sub-Q preview panel */ +.adv-subq-preview { background:var(--bg); border:1px solid var(--line); border-radius:8px; padding:16px; margin:12px 0; } +.adv-subq-preview__head { margin:0 0 4px; font-size:.95rem; } +.adv-subq-preview-item { margin-bottom:14px; } +.adv-subq-preview-label { display:block; font-size:.8rem; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; margin-bottom:4px; } +.adv-subq-edit { width:100%; font-size:.88rem; padding:8px 10px; border:1px solid var(--line); border-radius:5px; background:var(--panel); color:var(--ink); resize:vertical; font-family:inherit; box-sizing:border-box; } +.adv-subq-edit:focus { outline:2px solid var(--teal); border-color:var(--teal); } +.adv-subq-rationale { font-size:.8rem; color:var(--muted); margin:3px 0 0; font-style:italic; } +.adv-subq-preview__actions { display:flex; gap:10px; margin-top:12px; flex-wrap:wrap; } +.adv-subq-preview__actions button { font-size:.85rem; padding:5px 16px; border-radius:5px; cursor:pointer; } +#advRunWithAngles { background:var(--teal); color:#fff; border:none; } +#advRunWithAngles:hover { background:var(--teal-dark); } +.adv-discard-btn { background:transparent; border:1px solid var(--line); color:var(--muted); } + +/* Form footer with two buttons */ +.form-footer__btns { display:flex; gap:10px; align-items:center; flex-wrap:wrap; } +.adv-preview-btn { font-size:.82rem; padding:5px 14px; border:1px solid var(--line); border-radius:5px; background:var(--bg); color:var(--muted); cursor:pointer; } +.adv-preview-btn:hover { border-color:var(--teal); color:var(--teal); background:var(--soft-teal); } + +/* Print styles */ +@media print { + .tool-rail, .reasoning-panel, .topbar, .tool-form, + .adv-result-actions, .adv-flip-bar, .adv-brief-actions, + .dr-branch-btn, .dr-strength-branch-btn, + .adv-restore-banner, .adv-subq-preview, + .dr-source-modal { display:none !important; } + .workspace { display:block !important; } + .tool-panel { max-width:100%; border:none; box-shadow:none; padding:0; } + .results { padding:0; } + .adv-banner { border:1px solid #000; padding:8px 12px; margin-bottom:12px; } + details { display:block !important; } + details > * { display:block !important; } + .dr-result-block { border:1px solid #ccc; margin-bottom:12px; padding:12px; break-inside:avoid; } + .dr-source-card { break-inside:avoid; } + .dr-brief { line-height:1.75; } + summary { display:none; } +} diff --git a/assets/js/advocate.js b/assets/js/advocate.js index be683ba..6282594 100644 --- a/assets/js/advocate.js +++ b/assets/js/advocate.js @@ -7,6 +7,27 @@ 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' }, @@ -71,6 +92,12 @@ 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; @@ -82,13 +109,21 @@ bindUpload(); bindModal(); bindBranch(); + bindPreviewAngles(); els.form.addEventListener('submit', onSubmit); els.results.addEventListener('click', (e) => { - const btn = e.target.closest('.dr-branch-btn'); + 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() { @@ -97,6 +132,13 @@ 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; } + } }); } @@ -354,6 +396,10 @@ 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, @@ -456,6 +502,7 @@ 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; @@ -471,6 +518,19 @@ 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, @@ -522,27 +582,38 @@ }).join(''); } - function renderResults(data) { + 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 + // 1. Advocate banner + flip bar + const flipRole = ROLE_FLIP[role] || ''; const bannerHtml = role ? `
Representing ${escapeHtml(role)} Brief argues for this party · grounded in Norwegian law and ECHR authorities -
` : ''; + + ${flipRole ? `
+ See the other side? + ${escapeHtml(flipRole)} + +
` : ''}` : ''; - // 2. Client strengths + // 2. Client strengths — with ↗ deep-dive branch button per item const strengthsHtml = strengths.length ? `

Your strongest arguments

` : ''; @@ -560,27 +631,27 @@ // 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 ? ` @@ -599,10 +670,21 @@ ` : ''; els.results.innerHTML = ` +
+ + +
${bannerHtml} ${strengthsHtml}
-

Advocate brief

+
+

Advocate brief

+
+ + + ↓ .md +
+
${briefHtml}
${weaknessesHtml} @@ -612,6 +694,40 @@ ${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) => { @@ -782,6 +898,232 @@ 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 = ` + + Restore last session (${ageStr}) — ${escapeHtml(formState.role || '?')} · + “${escapeHtml((formState.query || '').slice(0, 60))}${(formState.query || '').length > 60 ? '…' : ''}” + +
+ + +
`; + 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. + + `; + 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 1–2 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) => ` +
+ + + ${sq.rationale ? `

${escapeHtml(sq.rationale)}

` : ''} +
`).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, '&') diff --git a/assets/js/tools.js b/assets/js/tools.js index d0744af..823447f 100644 --- a/assets/js/tools.js +++ b/assets/js/tools.js @@ -379,6 +379,10 @@ const TIMELINE_I18N = { let lastTimelineEvents = []; let lastTimelineEventsOriginal = []; +let activeActorFilters = new Set(); +let timelineSearchTerm = ''; +let showSources = true; +let timelineSortMode = 'doc'; let audioQueue = []; // [{file, status: 'pending'|'processing'|'done'|'error', result}] let lastTranscriptData = null; let lastRedactedText = null; @@ -942,6 +946,26 @@ document.addEventListener('DOMContentLoaded', () => { if (e.target.closest('#rdlCopy')) copyRedactedText(); if (e.target.closest('#rdlTxt')) downloadRedactedTxt(); if (e.target.closest('#rdlDocx')) downloadRedactedDocx(); + const copyBtn = e.target.closest('.timeline-copy-btn'); + if (copyBtn) { + navigator.clipboard.writeText(copyBtn.dataset.copy || '').then(() => { + const orig = copyBtn.innerHTML; + copyBtn.innerHTML = '✓'; + setTimeout(() => { copyBtn.innerHTML = orig; }, 1200); + }).catch(() => {}); + } + const chip = e.target.closest('.timeline-actor-chip'); + if (chip) { + const actor = chip.dataset.actor; + if (activeActorFilters.has(actor)) { + activeActorFilters.delete(actor); + chip.classList.remove('is-active'); + } else { + activeActorFilters.add(actor); + chip.classList.add('is-active'); + } + applyTimelineFilters(); + } }); const activeTool = document.body.dataset.activeTool || state.activeTool; if (els.form && tools[activeTool]) { @@ -1323,16 +1347,33 @@ function renderResults(data) { const sortChr = document.getElementById('sortChronological'); if (sortDoc && sortChr) { sortDoc.addEventListener('click', () => { - lastTimelineEvents = [...lastTimelineEventsOriginal]; - document.getElementById('timelineListContainer').innerHTML = renderTimeline(lastTimelineEvents); + timelineSortMode = 'doc'; sortDoc.classList.add('is-active'); sortChr.classList.remove('is-active'); + applyTimelineFilters(); }); sortChr.addEventListener('click', () => { - lastTimelineEvents = sortChronological(lastTimelineEventsOriginal); - document.getElementById('timelineListContainer').innerHTML = renderTimeline(lastTimelineEvents); + timelineSortMode = 'chrono'; sortChr.classList.add('is-active'); sortDoc.classList.remove('is-active'); + applyTimelineFilters(); + }); + } + const searchInput = document.getElementById('timelineSearch'); + if (searchInput) { + searchInput.addEventListener('input', () => { + timelineSearchTerm = searchInput.value.trim().toLowerCase(); + applyTimelineFilters(); + }); + } + const sourceToggle = document.getElementById('sourceToggle'); + if (sourceToggle) { + sourceToggle.addEventListener('click', () => { + showSources = !showSources; + sourceToggle.textContent = showSources ? 'Hide sources' : 'Show sources'; + document.querySelectorAll('.timeline-excerpt').forEach((el) => { + el.classList.toggle('is-hidden', !showSources); + }); }); } } @@ -1467,17 +1508,24 @@ function renderMainFinding(data) { if (data.tool === 'timeline') { lastTimelineEventsOriginal = data.events || []; lastTimelineEvents = [...lastTimelineEventsOriginal]; + activeActorFilters = new Set(); + timelineSearchTerm = ''; + showSources = true; + timelineSortMode = 'doc'; const csvLabel = currentTimelineT('timelineExportCsv') || 'Download CSV'; - const csvBtn = lastTimelineEvents.length + const csvBtn = lastTimelineEventsOriginal.length ? `
` : ''; - const sortBar = lastTimelineEvents.length > 1 ? ` + const countBadge = buildTimelineCountBadge(lastTimelineEventsOriginal); + const actorChips = buildActorChips(lastTimelineEventsOriginal); + const toolbar = buildTimelineToolbar(); + const sortBar = lastTimelineEventsOriginal.length > 1 ? `
Sort:
` : ''; - return `

${escapeHtml(data.what_we_found || '')}

${sortBar}
${renderTimeline(lastTimelineEvents)}
${csvBtn}`; + return `

${escapeHtml(data.what_we_found || '')}

${countBadge}${actorChips}${toolbar}${sortBar}
${renderTimeline(lastTimelineEvents, false)}
${csvBtn}`; } if (data.tool === 'summarize') { return [ @@ -1533,24 +1581,96 @@ function renderEvidenceItem(item) { `; } -function renderTimeline(events) { - if (!events.length) { - return '

No events were identified.

'; +function buildTimelineCountBadge(events) { + if (!events.length) return ''; + const actors = new Set(events.map((e) => e.actor).filter((a) => a && a !== 'unknown')); + const isoDates = events.map((e) => e.date || '').filter((d) => /^\d{4}/.test(d)).sort(); + let rangeStr = ''; + if (isoDates.length) { + const y0 = isoDates[0].slice(0, 4); + const y1 = isoDates[isoDates.length - 1].slice(0, 4); + rangeStr = y0 === y1 ? ` · ${y0}` : ` · ${y0}–${y1}`; } - return `
    ${events.map((ev) => { + const ac = actors.size; + return `

    ${events.length} event${events.length !== 1 ? 's' : ''}${ac ? ` · ${ac} actor${ac !== 1 ? 's' : ''}` : ''}${rangeStr}

    `; +} + +function buildActorChips(events) { + const actors = [...new Set(events.map((e) => e.actor).filter((a) => a && a !== 'unknown'))].sort(); + if (actors.length < 2) return ''; + const chips = actors.map((a) => + `` + ).join(''); + return `
    ${chips}
    `; +} + +function buildTimelineToolbar() { + return `
    + + +
    `; +} + +function applyTimelineFilters() { + let events = timelineSortMode === 'chrono' + ? sortChronological([...lastTimelineEventsOriginal]) + : [...lastTimelineEventsOriginal]; + if (activeActorFilters.size > 0) { + events = events.filter((e) => activeActorFilters.has(e.actor)); + } + if (timelineSearchTerm) { + const q = timelineSearchTerm; + events = events.filter((e) => + (e.event || '').toLowerCase().includes(q) || + (e.actor || '').toLowerCase().includes(q) || + (e.source_excerpt || '').toLowerCase().includes(q) || + (e.date || '').toLowerCase().includes(q) + ); + } + lastTimelineEvents = events; + const container = document.getElementById('timelineListContainer'); + if (container) container.innerHTML = renderTimeline(events, timelineSortMode === 'chrono'); +} + +function renderTimeline(events, grouped = false) { + if (!events.length) { + return '

    No matching events.

    '; + } + const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + let lastGroupKey = null; + const items = events.map((ev) => { const conf = ev.confidence || 'medium'; - return ` -
  1. + let groupHeader = ''; + if (grouped && /^\d{4}-\d{2}/.test(ev.date || '')) { + const year = ev.date.slice(0, 4); + const mon = ev.date.slice(5, 7); + const key = `${year}-${mon}`; + if (key !== lastGroupKey) { + const prevYear = lastGroupKey ? lastGroupKey.slice(0, 4) : null; + const label = (year !== prevYear) + ? year + : `${MONTH_NAMES[parseInt(mon, 10) - 1] || mon} ${year}`; + groupHeader = `
  2. `; + lastGroupKey = key; + } + } + const excerptHtml = ev.source_excerpt + ? `${escapeHtml(ev.source_excerpt)}` + : ''; + const copyText = [ev.date, ev.actor, ev.event].filter(Boolean).join(' · '); + return `${groupHeader}
  3. ${escapeHtml(ev.date || 'unknown')}${ev.time ? ` ${escapeHtml(ev.time)}` : ''} ${ev.date_type ? `${escapeHtml(ev.date_type)}` : ''} ${escapeHtml(conf)} +
    ${escapeHtml(ev.actor || 'unknown actor')}

    ${escapeHtml(ev.event || '')}

    - ${ev.source_excerpt ? `${escapeHtml(ev.source_excerpt)}` : ''} + ${excerptHtml}
  4. `; - }).join('')}
`; + }).join(''); + return `
    ${items}
`; } function renderFeedbackWidget() { @@ -1620,9 +1740,9 @@ function setupFeedbackWidget(tool) { } function exportTimelineCSV(events) { - const header = ['Date', 'Time', 'Date Type', 'Actor', 'Event', 'Source Excerpt', 'Confidence']; + const header = ['Date', 'End Date', 'Time', 'Date Type', 'Actor', 'Event', 'Source Excerpt', 'Confidence']; const rows = events.map((ev) => [ - ev.date || '', ev.time || '', ev.date_type || '', ev.actor || '', + ev.date || '', ev.end_date || '', ev.time || '', ev.date_type || '', ev.actor || '', ev.event || '', ev.source_excerpt || '', ev.confidence || '', ]); const csv = [header, ...rows] diff --git a/includes/DeepResearchAgent.php b/includes/DeepResearchAgent.php index 55572dc..24966c2 100644 --- a/includes/DeepResearchAgent.php +++ b/includes/DeepResearchAgent.php @@ -33,7 +33,8 @@ final class DbnDeepResearchAgent ?callable $emit = null, string $advocateRole = '', ?array $priorContext = null, - string $branchNotes = '' + string $branchNotes = '', + array $subQuestionsOverride = [] ): array { $seedQuery = trim($seedQuery); $pastedText = trim($pastedText); @@ -88,17 +89,26 @@ final class DbnDeepResearchAgent $this->stepTimings['interpretation'] = $this->elapsedMs($stepStart); $emitStep('interpretation', 'Query interpretation', $interpretation['detail'], 'complete'); - // STEP 2: Query expansion - $emitRunning('expansion', 'Query expansion', 'Generating sub-questions…'); + // STEP 2: Query expansion (or use caller-supplied override) $stepStart = microtime(true); - $expansion = $this->expandQueries($seedDescription, $interpretation['brief'], $interpretation['key_signals'], $controls['sub_q_count'], $language, $advocateRole); - $this->stepTimings['expansion'] = $this->elapsedMs($stepStart); - $subQuestions = $expansion['questions']; - $expansionStatus = $expansion['fallback'] ? 'warning' : 'complete'; - $expansionDetail = $expansion['fallback'] - ? 'Could not parse sub-questions; falling back to retrieving on the seed query alone.' - : sprintf('Generated %d sub-questions to research the corpus from multiple angles.', count($subQuestions)); - $emitStep('expansion', 'Query expansion', $expansionDetail, $expansionStatus); + if (!empty($subQuestionsOverride)) { + $subQuestions = array_values(array_filter($subQuestionsOverride, fn($sq) => + is_array($sq) && !empty(trim((string)($sq['question'] ?? ''))) + )); + $this->stepTimings['expansion'] = $this->elapsedMs($stepStart); + $emitStep('expansion', 'Query expansion', + sprintf('Using %d custom sub-question(s) supplied by the user.', count($subQuestions)), 'complete'); + } else { + $emitRunning('expansion', 'Query expansion', 'Generating sub-questions…'); + $expansion = $this->expandQueries($seedDescription, $interpretation['brief'], $interpretation['key_signals'], $controls['sub_q_count'], $language, $advocateRole); + $this->stepTimings['expansion'] = $this->elapsedMs($stepStart); + $subQuestions = $expansion['questions']; + $expansionStatus = $expansion['fallback'] ? 'warning' : 'complete'; + $expansionDetail = $expansion['fallback'] + ? 'Could not parse sub-questions; falling back to retrieving on the seed query alone.' + : sprintf('Generated %d sub-questions to research the corpus from multiple angles.', count($subQuestions)); + $emitStep('expansion', 'Query expansion', $expansionDetail, $expansionStatus); + } // STEP 3: Slice resolution $emitRunning('slice_resolution', 'Slice resolution', 'Resolving slice toggles to document IDs…'); @@ -1164,6 +1174,50 @@ PROMPT; return 'low'; } + public function generateSubQPreview( + string $seedQuery, + string $pastedText, + string $engine, + string $language, + array $controls, + string $advocateRole = '', + ?array $priorContext = null, + string $branchNotes = '' + ): array { + $seedQuery = trim($seedQuery); + $pastedText = trim($pastedText); + $engine = in_array($engine, ['azure_mini', 'azure_full', 'gpu'], true) ? $engine : 'azure_mini'; + $language = dbnToolsNormalizeUiLanguage($language); + $controls = $this->normalizeControls($controls); + + if ($seedQuery === '' && $pastedText === '') { + dbnToolsAbort('Provide a question or pasted text.', 422, 'missing_seed'); + } + + dbnToolsRequireClient(); + dbnToolsBootCaveau(); + $aiPortalRoot = dbnToolsAiPortalRoot(); + require_once $aiPortalRoot . '/platform/includes/dbn_v6.php'; + + $seedDescription = $this->buildSeedDescription($seedQuery, $pastedText, []); + $interpretation = $this->interpretSeed($seedDescription, $language, $advocateRole, $priorContext, $branchNotes); + $expansion = $this->expandQueries( + $seedDescription, + $interpretation['brief'], + $interpretation['key_signals'], + $controls['sub_q_count'], + $language, + $advocateRole + ); + + return [ + 'ok' => true, + 'interpretation' => $interpretation, + 'sub_questions' => $expansion['questions'], + 'fallback' => $expansion['fallback'] ?? false, + ]; + } + private function trace(string $label, string $detail, string $status = 'complete'): array { return [