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.
+ Describe the dispute or case situation
+
+
+
Engine
Azure gpt-4o-mini ★ (~15-45s)
@@ -166,12 +172,23 @@ require_once __DIR__ . '/includes/layout.php';
- Describe the dispute or case situation
-
+
+
+
Generated research angles — review & edit
+
The agent will research these angles against the corpus. Edit any question before running to steer retrieval.
+
+
+ Run with these angles
+ Discard & run fresh
+
+
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)}
+ Run counter-brief →
+
` : ''}` : '';
- // 2. Client strengths
+ // 2. Client strengths — with ↗ deep-dive branch button per item
const strengthsHtml = strengths.length ? `
Your strongest arguments
- ${strengths.map((s) => `${renderInlineCitations(escapeHtml(String(s)), sources)} `).join('')}
+ ${strengths.map((s) => `
+ ${renderInlineCitations(escapeHtml(String(s)), sources)}
+ ↗
+ `).join('')}
` : '';
@@ -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 = `
+
+ New query ↑
+ Print
+
${bannerHtml}
${strengthsHtml}
-
Advocate brief
+
+
Advocate brief
+
+
Copy brief
+
Copy arguments
+
↓ .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 ? '…' : ''}”
+
+
+ Restore
+ Dismiss
+
`;
+ 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.
+ Enable ${escapeHtml(names)}
+ Dismiss `;
+ 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) => `
+
+
Angle ${i + 1}
+
+ ${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
? `${escapeHtml(csvLabel)}
`
: '';
- const sortBar = lastTimelineEvents.length > 1 ? `
+ const countBadge = buildTimelineCountBadge(lastTimelineEventsOriginal);
+ const actorChips = buildActorChips(lastTimelineEventsOriginal);
+ const toolbar = buildTimelineToolbar();
+ const sortBar = lastTimelineEventsOriginal.length > 1 ? `
Sort:
${escapeHtml(currentTimelineT('sortDocOrder') || 'Document order')}
${escapeHtml(currentTimelineT('sortChronological') || 'Chronological')}
` : '';
- 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) =>
+ `${escapeHtml(a)} `
+ ).join('');
+ return `${chips}
`;
+}
+
+function buildTimelineToolbar() {
+ return `
+
+ Hide sources
+
`;
+}
+
+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 `
-
+ 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 = ` `;
+ lastGroupKey = key;
+ }
+ }
+ const excerptHtml = ev.source_excerpt
+ ? `${escapeHtml(ev.source_excerpt)} `
+ : '';
+ const copyText = [ev.date, ev.actor, ev.event].filter(Boolean).join(' · ');
+ return `${groupHeader}
${escapeHtml(ev.actor || 'unknown actor')}
${escapeHtml(ev.event || '')}
- ${ev.source_excerpt ? `${escapeHtml(ev.source_excerpt)} ` : ''}
+ ${excerptHtml}
`;
- }).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 [