feat(timeline): add live filter, actor chips, group headers, copy button, source toggle, count badge
- Live search/filter bar: filters events by keyword across event, actor, source_excerpt, date - Actor filter chips: click to filter by actor, multi-select, teal active state - Year/month group headers when sorted chronologically (── 2023 ──, Mar 2024 ──) - Per-event copy button (hover-revealed 📋): copies "date · actor · event" to clipboard - "Hide/show sources" toggle: collapses all source excerpts without re-rendering - Count badge: "23 events · 3 actors · 2022–2025" above the list - applyTimelineFilters() unifies sort + actor + text filters in one re-render pass - CSV export now includes end_date column - Reset all filter state on each new run Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+20
-3
@@ -36,6 +36,12 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
<p class="upload-hint">The agent will frame every sub-question, retrieval pass, and the final brief to argue <em>for</em> the selected party, identifying weaknesses in the opposing position and citing Lovdata statutes, ECHR judgments, and Bufdir guidance.</p>
|
<p class="upload-hint">The agent will frame every sub-question, retrieval pass, and the final brief to argue <em>for</em> the selected party, identifying weaknesses in the opposing position and citing Lovdata statutes, ECHR judgments, and Bufdir guidance.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label class="input-label" for="advInput">Describe the dispute or case situation</label>
|
||||||
|
<textarea id="advInput" name="advInput" rows="8" placeholder="Describe the facts of the case, the dispute, and what matters most to your client. The agent will generate adversarial sub-questions, retrieve from the legal corpus and your uploaded files, and synthesise a partisan brief with citations."></textarea>
|
||||||
|
<div class="adv-input-footer">
|
||||||
|
<small id="advInputCount" class="adv-char-count">0 / 4,000</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="control-row" id="advEngineControl">
|
<div class="control-row" id="advEngineControl">
|
||||||
<span class="control-label">Engine</span>
|
<span class="control-label">Engine</span>
|
||||||
<label><input type="radio" name="advEngine" value="azure_mini" checked> Azure gpt-4o-mini ★ <small class="control-hint">(~15-45s)</small></label>
|
<label><input type="radio" name="advEngine" value="azure_mini" checked> Azure gpt-4o-mini ★ <small class="control-hint">(~15-45s)</small></label>
|
||||||
@@ -166,12 +172,23 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
<textarea id="advBranchNotes" rows="3" placeholder="Optional: add observations, corrections, or additional context for this branch…"></textarea>
|
<textarea id="advBranchNotes" rows="3" placeholder="Optional: add observations, corrections, or additional context for this branch…"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="input-label" for="advInput">Describe the dispute or case situation</label>
|
<!-- Sub-question preview panel (hidden until "Preview angles" is clicked) -->
|
||||||
<textarea id="advInput" name="advInput" rows="8" placeholder="Describe the facts of the case, the dispute, and what matters most to your client. The agent will generate adversarial sub-questions, retrieve from the legal corpus and your uploaded files, and synthesise a partisan brief with citations."></textarea>
|
<div id="advSubQPreview" class="adv-subq-preview is-hidden" aria-live="polite">
|
||||||
|
<h4 class="adv-subq-preview__head">Generated research angles — review & edit</h4>
|
||||||
|
<p class="upload-hint">The agent will research these angles against the corpus. Edit any question before running to steer retrieval.</p>
|
||||||
|
<div id="advSubQPreviewList"></div>
|
||||||
|
<div class="adv-subq-preview__actions">
|
||||||
|
<button type="button" id="advRunWithAngles">Run with these angles</button>
|
||||||
|
<button type="button" id="advDiscardAngles" class="adv-discard-btn">Discard & run fresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
<p id="advStatus" class="form-status" role="status" aria-live="polite"></p>
|
<p id="advStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||||
<button id="advRunButton" type="submit">Research my case</button>
|
<div class="form-footer__btns">
|
||||||
|
<button id="advRunButton" type="submit">Research my case</button>
|
||||||
|
<button type="button" id="advPreviewAngles" class="adv-preview-btn">Preview research angles first</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -65,8 +65,9 @@ try {
|
|||||||
if (mb_strlen($advocateRole, 'UTF-8') > 200) {
|
if (mb_strlen($advocateRole, 'UTF-8') > 200) {
|
||||||
throw new DbnToolsHttpException('advocate_role is too long.', 422, 'advocate_role_too_long');
|
throw new DbnToolsHttpException('advocate_role is too long.', 422, 'advocate_role_too_long');
|
||||||
}
|
}
|
||||||
$priorContext = is_array($input['prior_context'] ?? null) ? $input['prior_context'] : null;
|
$priorContext = is_array($input['prior_context'] ?? null) ? $input['prior_context'] : null;
|
||||||
$branchNotes = mb_substr(trim((string)($input['branch_notes'] ?? '')), 0, 1000, 'UTF-8');
|
$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) {
|
if (mb_strlen($seedQuery, 'UTF-8') > 4000) {
|
||||||
throw new DbnToolsHttpException('Query is too long.', 422, 'query_too_long');
|
throw new DbnToolsHttpException('Query is too long.', 422, 'query_too_long');
|
||||||
@@ -125,7 +126,8 @@ try {
|
|||||||
$emit,
|
$emit,
|
||||||
$advocateRole,
|
$advocateRole,
|
||||||
$priorContext,
|
$priorContext,
|
||||||
$branchNotes
|
$branchNotes,
|
||||||
|
$subQsOverride
|
||||||
);
|
);
|
||||||
|
|
||||||
$result['ok'] = true;
|
$result['ok'] = true;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../includes/DeepResearchAgent.php';
|
||||||
|
|
||||||
|
dbnToolsRequireMethod('POST');
|
||||||
|
dbnToolsRequireAuth();
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
if ($raw === false || strlen($raw) > 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.']);
|
||||||
|
}
|
||||||
@@ -1737,6 +1737,118 @@ p {
|
|||||||
color: var(--ink);
|
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 {
|
.date-type-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 0.68rem;
|
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; }
|
.adv-slice-hint button { font-size:.8rem; padding:2px 10px; border-radius:4px; cursor:pointer; }
|
||||||
#advSliceHintEnable { background:var(--teal); color:#fff; border:none; }
|
#advSliceHintEnable { background:var(--teal); color:#fff; border:none; }
|
||||||
#advSliceHintDismiss { background:transparent; border:1px solid var(--line); color:var(--muted); }
|
#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; }
|
||||||
|
}
|
||||||
|
|||||||
+357
-15
@@ -7,6 +7,27 @@
|
|||||||
let uploadFiles = [];
|
let uploadFiles = [];
|
||||||
let lastResult = null;
|
let lastResult = null;
|
||||||
let branchContext = 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 = [
|
const SLICE_DEFS = [
|
||||||
{ id: 'family_core', label: 'Family Law Core' },
|
{ id: 'family_core', label: 'Family Law Core' },
|
||||||
@@ -71,6 +92,12 @@
|
|||||||
branchOrigin: document.getElementById('advBranchOrigin'),
|
branchOrigin: document.getElementById('advBranchOrigin'),
|
||||||
branchSummary: document.getElementById('advBranchSummary'),
|
branchSummary: document.getElementById('advBranchSummary'),
|
||||||
branchNotes: document.getElementById('advBranchNotes'),
|
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;
|
if (!els.form) return;
|
||||||
@@ -82,13 +109,21 @@
|
|||||||
bindUpload();
|
bindUpload();
|
||||||
bindModal();
|
bindModal();
|
||||||
bindBranch();
|
bindBranch();
|
||||||
|
bindPreviewAngles();
|
||||||
els.form.addEventListener('submit', onSubmit);
|
els.form.addEventListener('submit', onSubmit);
|
||||||
els.results.addEventListener('click', (e) => {
|
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 || '');
|
if (btn) branchFromSubQ(btn.dataset.question || '');
|
||||||
});
|
});
|
||||||
|
|
||||||
renderTrace(STEP_LABELS.map((label) => ({ label, detail: 'Waiting…', status: 'idle' })));
|
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() {
|
function bindRole() {
|
||||||
@@ -97,6 +132,13 @@
|
|||||||
const isOther = els.roleSelect.value === '__other__';
|
const isOther = els.roleSelect.value === '__other__';
|
||||||
els.roleCustom.classList.toggle('is-hidden', !isOther);
|
els.roleCustom.classList.toggle('is-hidden', !isOther);
|
||||||
if (isOther) els.roleCustom.focus();
|
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.prior_context = branchContext;
|
||||||
payload.branch_notes = (els.branchNotes ? els.branchNotes.value : '').trim();
|
payload.branch_notes = (els.branchNotes ? els.branchNotes.value : '').trim();
|
||||||
}
|
}
|
||||||
|
if (customSubQuestions) {
|
||||||
|
payload.sub_questions_override = customSubQuestions;
|
||||||
|
customSubQuestions = null;
|
||||||
|
}
|
||||||
|
|
||||||
const stepKeyToIndex = {
|
const stepKeyToIndex = {
|
||||||
interpretation: 0,
|
interpretation: 0,
|
||||||
@@ -456,6 +502,7 @@
|
|||||||
els.runButton.disabled = false;
|
els.runButton.disabled = false;
|
||||||
renderTrace(finalResult.trace || []);
|
renderTrace(finalResult.trace || []);
|
||||||
renderResults(finalResult);
|
renderResults(finalResult);
|
||||||
|
saveToCache(finalResult, { query, role: advocateRole, engine, slices, lang });
|
||||||
|
|
||||||
function handleStreamEvent(evt) {
|
function handleStreamEvent(evt) {
|
||||||
if (!evt || !evt.event) return;
|
if (!evt || !evt.event) return;
|
||||||
@@ -471,6 +518,19 @@
|
|||||||
if (evt.event === 'step') {
|
if (evt.event === 'step') {
|
||||||
const idx = stepKeyToIndex[evt.step];
|
const idx = stepKeyToIndex[evt.step];
|
||||||
if (idx === undefined) return;
|
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] = {
|
stepState[idx] = {
|
||||||
label: evt.label || stepState[idx].label,
|
label: evt.label || stepState[idx].label,
|
||||||
detail: evt.detail || stepState[idx].detail,
|
detail: evt.detail || stepState[idx].detail,
|
||||||
@@ -522,27 +582,38 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderResults(data) {
|
function renderResults(data, restoreMode = false) {
|
||||||
const sources = data.sources || [];
|
const sources = data.sources || [];
|
||||||
const subs = data.sub_questions || [];
|
const subs = data.sub_questions || [];
|
||||||
const role = data.advocate_role || '';
|
const role = data.advocate_role || '';
|
||||||
const strengths = Array.isArray(data.client_strengths) ? data.client_strengths : [];
|
const strengths = Array.isArray(data.client_strengths) ? data.client_strengths : [];
|
||||||
const weaknesses = Array.isArray(data.opposing_weaknesses) ? data.opposing_weaknesses : [];
|
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 ? `
|
const bannerHtml = role ? `
|
||||||
<div class="adv-banner">
|
<div class="adv-banner">
|
||||||
<span class="adv-banner__label">Representing</span>
|
<span class="adv-banner__label">Representing</span>
|
||||||
<strong class="adv-banner__role">${escapeHtml(role)}</strong>
|
<strong class="adv-banner__role">${escapeHtml(role)}</strong>
|
||||||
<span class="adv-banner__note">Brief argues for this party · grounded in Norwegian law and ECHR authorities</span>
|
<span class="adv-banner__note">Brief argues for this party · grounded in Norwegian law and ECHR authorities</span>
|
||||||
</div>` : '';
|
</div>
|
||||||
|
${flipRole ? `<div class="adv-flip-bar">
|
||||||
|
<span class="adv-flip-bar__label">See the other side?</span>
|
||||||
|
<span class="adv-flip-bar__role">${escapeHtml(flipRole)}</span>
|
||||||
|
<button class="adv-flip-btn" type="button" data-flip-role="${escapeHtml(flipRole)}">Run counter-brief →</button>
|
||||||
|
</div>` : ''}` : '';
|
||||||
|
|
||||||
// 2. Client strengths
|
// 2. Client strengths — with ↗ deep-dive branch button per item
|
||||||
const strengthsHtml = strengths.length ? `
|
const strengthsHtml = strengths.length ? `
|
||||||
<div class="dr-result-block adv-strengths">
|
<div class="dr-result-block adv-strengths">
|
||||||
<h3 class="adv-strengths__head">Your strongest arguments</h3>
|
<h3 class="adv-strengths__head">Your strongest arguments</h3>
|
||||||
<ul class="adv-strengths__list">
|
<ul class="adv-strengths__list">
|
||||||
${strengths.map((s) => `<li class="adv-strengths__item">${renderInlineCitations(escapeHtml(String(s)), sources)}</li>`).join('')}
|
${strengths.map((s) => `<li class="adv-strengths__item">
|
||||||
|
${renderInlineCitations(escapeHtml(String(s)), sources)}
|
||||||
|
<button class="dr-strength-branch-btn" type="button"
|
||||||
|
data-question="${escapeHtml('Deeper research: ' + String(s))}"
|
||||||
|
title="Branch research from this argument">↗</button>
|
||||||
|
</li>`).join('')}
|
||||||
</ul>
|
</ul>
|
||||||
</div>` : '';
|
</div>` : '';
|
||||||
|
|
||||||
@@ -560,27 +631,27 @@
|
|||||||
|
|
||||||
// 5. Sub-Q report cards
|
// 5. Sub-Q report cards
|
||||||
const subQReportsHtml = subs.length ? `
|
const subQReportsHtml = subs.length ? `
|
||||||
<div class="dr-result-block">
|
<details class="dr-result-block dr-collapsible" ${restoreMode ? '' : 'open'}>
|
||||||
<div class="dr-sources-head">
|
<summary class="dr-collapsible__head">
|
||||||
<h3>What each sub-question agent researched</h3>
|
<h3>What each sub-question agent researched</h3>
|
||||||
<small>${subs.length} sub-question${subs.length === 1 ? '' : 's'} framed for ${escapeHtml(role || 'your client')}</small>
|
<small>${subs.length} sub-question${subs.length === 1 ? '' : 's'} framed for ${escapeHtml(role || 'your client')}</small>
|
||||||
</div>
|
</summary>
|
||||||
<div class="dr-subq-list">
|
<div class="dr-subq-list">
|
||||||
${subs.map((sq, i) => renderSubQReport(sq, i)).join('')}
|
${subs.map((sq, i) => renderSubQReport(sq, i)).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>` : '';
|
</details>` : '';
|
||||||
|
|
||||||
// 6. Sources
|
// 6. Sources
|
||||||
const sourcesHtml = `
|
const sourcesHtml = `
|
||||||
<div class="dr-result-block">
|
<details class="dr-result-block dr-collapsible" ${restoreMode ? '' : 'open'}>
|
||||||
<div class="dr-sources-head">
|
<summary class="dr-collapsible__head">
|
||||||
<h3>All sources (${sources.length})</h3>
|
<h3>All sources (${sources.length})</h3>
|
||||||
<small>Click a card to see the full chunk · external link opens the original article</small>
|
<small>Click a card to see the full chunk · external link opens the original article</small>
|
||||||
</div>
|
</summary>
|
||||||
<div class="dr-source-list">
|
<div class="dr-source-list">
|
||||||
${sources.map((s) => renderSourceCard(s)).join('')}
|
${sources.map((s) => renderSourceCard(s)).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</details>`;
|
||||||
|
|
||||||
// 7. Uncertainty
|
// 7. Uncertainty
|
||||||
const uncertHtml = (data.what_remains_uncertain || []).length ? `
|
const uncertHtml = (data.what_remains_uncertain || []).length ? `
|
||||||
@@ -599,10 +670,21 @@
|
|||||||
</div>` : '';
|
</div>` : '';
|
||||||
|
|
||||||
els.results.innerHTML = `
|
els.results.innerHTML = `
|
||||||
|
<div class="adv-result-actions">
|
||||||
|
<button id="advNewQuery" class="adv-new-query-btn" type="button">New query ↑</button>
|
||||||
|
<button class="adv-new-query-btn" type="button" onclick="window.print()">Print</button>
|
||||||
|
</div>
|
||||||
${bannerHtml}
|
${bannerHtml}
|
||||||
${strengthsHtml}
|
${strengthsHtml}
|
||||||
<div class="dr-result-block">
|
<div class="dr-result-block">
|
||||||
<h3 style="margin:0 0 10px;font-size:1rem">Advocate brief</h3>
|
<div class="dr-section-head">
|
||||||
|
<h3 style="margin:0;font-size:1rem">Advocate brief</h3>
|
||||||
|
<div class="adv-brief-actions">
|
||||||
|
<button class="dr-copy-btn" id="advCopyBrief" type="button">Copy brief</button>
|
||||||
|
<button class="dr-copy-btn" id="advCopyArgs" type="button">Copy arguments</button>
|
||||||
|
<a class="dr-copy-btn" id="advDownloadMd" style="cursor:pointer">↓ .md</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="dr-brief">${briefHtml}</div>
|
<div class="dr-brief">${briefHtml}</div>
|
||||||
</div>
|
</div>
|
||||||
${weaknessesHtml}
|
${weaknessesHtml}
|
||||||
@@ -612,6 +694,40 @@
|
|||||||
${nextHtml}
|
${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
|
// Bind source-card clicks
|
||||||
els.results.querySelectorAll('.dr-source-card[data-source-n]').forEach((node) => {
|
els.results.querySelectorAll('.dr-source-card[data-source-n]').forEach((node) => {
|
||||||
node.addEventListener('click', (e) => {
|
node.addEventListener('click', (e) => {
|
||||||
@@ -782,6 +898,232 @@
|
|||||||
return Array.from(new Set(out));
|
return Array.from(new Set(out));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateCharCount() {
|
||||||
|
if (!els.inputCount || !els.input) return;
|
||||||
|
const len = els.input.value.length;
|
||||||
|
els.inputCount.textContent = `${len.toLocaleString()} / 4,000`;
|
||||||
|
els.inputCount.classList.toggle('is-warn', len > 3500 && len <= 3900);
|
||||||
|
els.inputCount.classList.toggle('is-crit', len > 3900);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToCache(result, formState) {
|
||||||
|
try { localStorage.setItem(CACHE_KEY, JSON.stringify({ result, formState, ts: Date.now() })); }
|
||||||
|
catch (e) { /* quota — silently ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFromCache() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const obj = JSON.parse(raw);
|
||||||
|
if (!obj || !obj.result || !obj.ts) return null;
|
||||||
|
if (Date.now() - obj.ts > 86400000) { localStorage.removeItem(CACHE_KEY); return null; }
|
||||||
|
return obj;
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRestoreBanner({ result, formState, ts }) {
|
||||||
|
const age = Math.round((Date.now() - ts) / 60000);
|
||||||
|
const ageStr = age < 60 ? `${age}m ago` : `${Math.round(age / 60)}h ago`;
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.id = 'advRestoreBanner';
|
||||||
|
banner.className = 'adv-restore-banner';
|
||||||
|
banner.innerHTML = `
|
||||||
|
<span class="adv-restore-banner__text">
|
||||||
|
Restore last session <em>(${ageStr})</em> — ${escapeHtml(formState.role || '?')} ·
|
||||||
|
“${escapeHtml((formState.query || '').slice(0, 60))}${(formState.query || '').length > 60 ? '…' : ''}”
|
||||||
|
</span>
|
||||||
|
<div class="adv-restore-banner__actions">
|
||||||
|
<button type="button" id="advRestoreYes">Restore</button>
|
||||||
|
<button type="button" id="advRestoreNo">Dismiss</button>
|
||||||
|
</div>`;
|
||||||
|
els.results.parentNode.insertBefore(banner, els.results);
|
||||||
|
document.getElementById('advRestoreYes').addEventListener('click', () => {
|
||||||
|
banner.remove();
|
||||||
|
restoreSession(result, formState);
|
||||||
|
});
|
||||||
|
document.getElementById('advRestoreNo').addEventListener('click', () => {
|
||||||
|
banner.remove();
|
||||||
|
localStorage.removeItem(CACHE_KEY);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreSession(result, formState) {
|
||||||
|
els.input.value = formState.query || '';
|
||||||
|
updateCharCount();
|
||||||
|
if (formState.role) els.roleSelect.value = formState.role;
|
||||||
|
const radio = els.engineRadios.find((r) => r.value === formState.engine);
|
||||||
|
if (radio) radio.checked = true;
|
||||||
|
if (formState.slices) {
|
||||||
|
els.slices.forEach((btn) => {
|
||||||
|
const on = !!formState.slices[btn.dataset.slice];
|
||||||
|
btn.classList.toggle('is-on', on);
|
||||||
|
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||||
|
btn.querySelector('.dr-slice__badge').textContent = on ? 'on' : 'off';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lastResult = result;
|
||||||
|
result.query = formState.query || '';
|
||||||
|
renderResults(result, true);
|
||||||
|
const meta = result.trace_metadata || {};
|
||||||
|
setStatus(`Restored · confidence ${meta.citation_confidence || '?'}`, 'ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSliceHint(echrBtn, ncBtn, echrOff, ncOff) {
|
||||||
|
document.getElementById('advSliceHint')?.remove();
|
||||||
|
const names = [echrOff && 'ECHR', ncOff && 'Norwegian Courts'].filter(Boolean).join(' & ');
|
||||||
|
const hint = document.createElement('p');
|
||||||
|
hint.id = 'advSliceHint';
|
||||||
|
hint.className = 'adv-slice-hint';
|
||||||
|
hint.innerHTML = `${escapeHtml(names)} ${names.includes('&') ? 'are' : 'is'} often valuable for parent/family cases.
|
||||||
|
<button type="button" id="advSliceHintEnable">Enable ${escapeHtml(names)}</button>
|
||||||
|
<button type="button" id="advSliceHintDismiss">Dismiss</button>`;
|
||||||
|
document.querySelector('.dr-slice-section')?.after(hint);
|
||||||
|
document.getElementById('advSliceHintEnable').addEventListener('click', () => {
|
||||||
|
if (echrOff) activateSlice(echrBtn);
|
||||||
|
if (ncOff) activateSlice(ncBtn);
|
||||||
|
hint.remove();
|
||||||
|
});
|
||||||
|
document.getElementById('advSliceHintDismiss').addEventListener('click', () => hint.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateSlice(btn) {
|
||||||
|
if (!btn) return;
|
||||||
|
btn.classList.add('is-on');
|
||||||
|
btn.setAttribute('aria-pressed', 'true');
|
||||||
|
btn.querySelector('.dr-slice__badge').textContent = 'on';
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(text, btn, originalLabel) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => { btn.textContent = originalLabel; }, 1500);
|
||||||
|
}).catch(() => {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => { btn.textContent = originalLabel; }, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExportMarkdown(data) {
|
||||||
|
const role = data.advocate_role || 'Unknown party';
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
const strengths = (data.client_strengths || []).map((s) => `- ${s}`).join('\n');
|
||||||
|
const weaknesses = (data.opposing_weaknesses || []).map((w) => `- ${w}`).join('\n');
|
||||||
|
const uncertainty = (data.what_remains_uncertain || []).map((u) => `- ${u}`).join('\n');
|
||||||
|
return [
|
||||||
|
`# Advocate Brief — ${role}`,
|
||||||
|
`**Generated:** ${date}`,
|
||||||
|
'',
|
||||||
|
strengths ? '## Strongest Arguments\n' + strengths : '',
|
||||||
|
'',
|
||||||
|
'## Brief',
|
||||||
|
data.brief_markdown || '',
|
||||||
|
weaknesses ? '\n## Gaps in Opposing Position\n' + weaknesses : '',
|
||||||
|
uncertainty ? '\n## What Remains Uncertain\n' + uncertainty : '',
|
||||||
|
data.next_practical_step ? `\n## Next Practical Step\n${data.next_practical_step}` : '',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'_Generated by Do Better Norge — Case Advocate_',
|
||||||
|
].filter((s) => s !== null && s !== '').join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function flipBrief(toRole) {
|
||||||
|
const optionExists = Array.from(els.roleSelect.options).some((o) => o.value === toRole);
|
||||||
|
if (optionExists) {
|
||||||
|
els.roleSelect.value = toRole;
|
||||||
|
if (els.roleCustom) els.roleCustom.classList.add('is-hidden');
|
||||||
|
} else {
|
||||||
|
els.roleSelect.value = '__other__';
|
||||||
|
if (els.roleCustom) {
|
||||||
|
els.roleCustom.classList.remove('is-hidden');
|
||||||
|
els.roleCustom.value = toRole;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
els.form.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
setTimeout(() => els.form.requestSubmit(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindPreviewAngles() {
|
||||||
|
if (!els.previewAngles) return;
|
||||||
|
els.previewAngles.addEventListener('click', async () => {
|
||||||
|
const advocateRole = getAdvocateRole();
|
||||||
|
if (!advocateRole) { setStatus('Select who you are representing first.', 'error'); return; }
|
||||||
|
const query = (els.input.value || '').trim();
|
||||||
|
if (!query) { setStatus('Describe the case situation first.', 'error'); return; }
|
||||||
|
|
||||||
|
els.previewAngles.disabled = true;
|
||||||
|
els.previewAngles.textContent = 'Generating angles…';
|
||||||
|
setStatus('Generating research angles (steps 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) => `
|
||||||
|
<div class="adv-subq-preview-item">
|
||||||
|
<label class="adv-subq-preview-label">Angle ${i + 1}</label>
|
||||||
|
<textarea class="adv-subq-edit" rows="3" data-sq-id="${escapeHtml(sq.id || ('q' + (i + 1)))}">${escapeHtml(sq.question || '')}</textarea>
|
||||||
|
${sq.rationale ? `<p class="adv-subq-rationale">${escapeHtml(sq.rationale)}</p>` : ''}
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
if (els.subQPreview) {
|
||||||
|
els.subQPreview.classList.remove('is-hidden');
|
||||||
|
els.subQPreview.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
setStatus(`${sqs.length} research angles generated — review and edit, then run.`, 'ok');
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Error generating angles: ${err.message || err}`, 'error');
|
||||||
|
} finally {
|
||||||
|
els.previewAngles.disabled = false;
|
||||||
|
els.previewAngles.textContent = 'Preview research angles first';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
els.runWithAngles?.addEventListener('click', () => {
|
||||||
|
const textareas = els.subQPreviewList?.querySelectorAll('.adv-subq-edit') || [];
|
||||||
|
customSubQuestions = Array.from(textareas).map((ta, i) => ({
|
||||||
|
id: ta.dataset.sqId || `q${i + 1}`,
|
||||||
|
question: ta.value.trim(),
|
||||||
|
rationale: 'User-edited angle.',
|
||||||
|
})).filter((sq) => sq.question);
|
||||||
|
if (!customSubQuestions.length) {
|
||||||
|
setStatus('All angles are empty — edit at least one before running.', 'error');
|
||||||
|
customSubQuestions = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (els.subQPreview) els.subQPreview.classList.add('is-hidden');
|
||||||
|
els.form.requestSubmit();
|
||||||
|
});
|
||||||
|
|
||||||
|
els.discardAngles?.addEventListener('click', () => {
|
||||||
|
customSubQuestions = null;
|
||||||
|
if (els.subQPreview) els.subQPreview.classList.add('is-hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s)
|
return String(s)
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
|
|||||||
+137
-17
@@ -379,6 +379,10 @@ const TIMELINE_I18N = {
|
|||||||
|
|
||||||
let lastTimelineEvents = [];
|
let lastTimelineEvents = [];
|
||||||
let lastTimelineEventsOriginal = [];
|
let lastTimelineEventsOriginal = [];
|
||||||
|
let activeActorFilters = new Set();
|
||||||
|
let timelineSearchTerm = '';
|
||||||
|
let showSources = true;
|
||||||
|
let timelineSortMode = 'doc';
|
||||||
let audioQueue = []; // [{file, status: 'pending'|'processing'|'done'|'error', result}]
|
let audioQueue = []; // [{file, status: 'pending'|'processing'|'done'|'error', result}]
|
||||||
let lastTranscriptData = null;
|
let lastTranscriptData = null;
|
||||||
let lastRedactedText = null;
|
let lastRedactedText = null;
|
||||||
@@ -942,6 +946,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (e.target.closest('#rdlCopy')) copyRedactedText();
|
if (e.target.closest('#rdlCopy')) copyRedactedText();
|
||||||
if (e.target.closest('#rdlTxt')) downloadRedactedTxt();
|
if (e.target.closest('#rdlTxt')) downloadRedactedTxt();
|
||||||
if (e.target.closest('#rdlDocx')) downloadRedactedDocx();
|
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;
|
const activeTool = document.body.dataset.activeTool || state.activeTool;
|
||||||
if (els.form && tools[activeTool]) {
|
if (els.form && tools[activeTool]) {
|
||||||
@@ -1323,16 +1347,33 @@ function renderResults(data) {
|
|||||||
const sortChr = document.getElementById('sortChronological');
|
const sortChr = document.getElementById('sortChronological');
|
||||||
if (sortDoc && sortChr) {
|
if (sortDoc && sortChr) {
|
||||||
sortDoc.addEventListener('click', () => {
|
sortDoc.addEventListener('click', () => {
|
||||||
lastTimelineEvents = [...lastTimelineEventsOriginal];
|
timelineSortMode = 'doc';
|
||||||
document.getElementById('timelineListContainer').innerHTML = renderTimeline(lastTimelineEvents);
|
|
||||||
sortDoc.classList.add('is-active');
|
sortDoc.classList.add('is-active');
|
||||||
sortChr.classList.remove('is-active');
|
sortChr.classList.remove('is-active');
|
||||||
|
applyTimelineFilters();
|
||||||
});
|
});
|
||||||
sortChr.addEventListener('click', () => {
|
sortChr.addEventListener('click', () => {
|
||||||
lastTimelineEvents = sortChronological(lastTimelineEventsOriginal);
|
timelineSortMode = 'chrono';
|
||||||
document.getElementById('timelineListContainer').innerHTML = renderTimeline(lastTimelineEvents);
|
|
||||||
sortChr.classList.add('is-active');
|
sortChr.classList.add('is-active');
|
||||||
sortDoc.classList.remove('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') {
|
if (data.tool === 'timeline') {
|
||||||
lastTimelineEventsOriginal = data.events || [];
|
lastTimelineEventsOriginal = data.events || [];
|
||||||
lastTimelineEvents = [...lastTimelineEventsOriginal];
|
lastTimelineEvents = [...lastTimelineEventsOriginal];
|
||||||
|
activeActorFilters = new Set();
|
||||||
|
timelineSearchTerm = '';
|
||||||
|
showSources = true;
|
||||||
|
timelineSortMode = 'doc';
|
||||||
const csvLabel = currentTimelineT('timelineExportCsv') || 'Download CSV';
|
const csvLabel = currentTimelineT('timelineExportCsv') || 'Download CSV';
|
||||||
const csvBtn = lastTimelineEvents.length
|
const csvBtn = lastTimelineEventsOriginal.length
|
||||||
? `<div class="timeline-export"><button type="button" id="exportCsvBtn" class="export-csv-btn">${escapeHtml(csvLabel)}</button></div>`
|
? `<div class="timeline-export"><button type="button" id="exportCsvBtn" class="export-csv-btn">${escapeHtml(csvLabel)}</button></div>`
|
||||||
: '';
|
: '';
|
||||||
const sortBar = lastTimelineEvents.length > 1 ? `
|
const countBadge = buildTimelineCountBadge(lastTimelineEventsOriginal);
|
||||||
|
const actorChips = buildActorChips(lastTimelineEventsOriginal);
|
||||||
|
const toolbar = buildTimelineToolbar();
|
||||||
|
const sortBar = lastTimelineEventsOriginal.length > 1 ? `
|
||||||
<div class="timeline-sort-bar">
|
<div class="timeline-sort-bar">
|
||||||
<span class="sort-label">Sort:</span>
|
<span class="sort-label">Sort:</span>
|
||||||
<button type="button" class="sort-btn is-active" id="sortDocOrder">${escapeHtml(currentTimelineT('sortDocOrder') || 'Document order')}</button>
|
<button type="button" class="sort-btn is-active" id="sortDocOrder">${escapeHtml(currentTimelineT('sortDocOrder') || 'Document order')}</button>
|
||||||
<button type="button" class="sort-btn" id="sortChronological">${escapeHtml(currentTimelineT('sortChronological') || 'Chronological')}</button>
|
<button type="button" class="sort-btn" id="sortChronological">${escapeHtml(currentTimelineT('sortChronological') || 'Chronological')}</button>
|
||||||
</div>` : '';
|
</div>` : '';
|
||||||
return `<p>${escapeHtml(data.what_we_found || '')}</p>${sortBar}<div id="timelineListContainer">${renderTimeline(lastTimelineEvents)}</div>${csvBtn}`;
|
return `<p>${escapeHtml(data.what_we_found || '')}</p>${countBadge}${actorChips}${toolbar}${sortBar}<div id="timelineListContainer">${renderTimeline(lastTimelineEvents, false)}</div>${csvBtn}`;
|
||||||
}
|
}
|
||||||
if (data.tool === 'summarize') {
|
if (data.tool === 'summarize') {
|
||||||
return [
|
return [
|
||||||
@@ -1533,24 +1581,96 @@ function renderEvidenceItem(item) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTimeline(events) {
|
function buildTimelineCountBadge(events) {
|
||||||
if (!events.length) {
|
if (!events.length) return '';
|
||||||
return '<p>No events were identified.</p>';
|
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 `<ol class="timeline-list">${events.map((ev) => {
|
const ac = actors.size;
|
||||||
|
return `<p class="timeline-count-badge">${events.length} event${events.length !== 1 ? 's' : ''}${ac ? ` · ${ac} actor${ac !== 1 ? 's' : ''}` : ''}${rangeStr}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) =>
|
||||||
|
`<button type="button" class="timeline-actor-chip" data-actor="${escapeHtml(a)}">${escapeHtml(a)}</button>`
|
||||||
|
).join('');
|
||||||
|
return `<div class="timeline-actor-chips" id="actorChips">${chips}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTimelineToolbar() {
|
||||||
|
return `<div class="timeline-toolbar">
|
||||||
|
<input type="search" id="timelineSearch" class="timeline-search" placeholder="Search events…" aria-label="Filter events by keyword">
|
||||||
|
<button type="button" id="sourceToggle" class="source-toggle-btn">Hide sources</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 '<p class="timeline-empty">No matching events.</p>';
|
||||||
|
}
|
||||||
|
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';
|
const conf = ev.confidence || 'medium';
|
||||||
return `
|
let groupHeader = '';
|
||||||
<li class="timeline-item confidence-${escapeHtml(conf)}">
|
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 = `<li class="timeline-group-header" role="presentation"><span>${escapeHtml(label)}</span></li>`;
|
||||||
|
lastGroupKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const excerptHtml = ev.source_excerpt
|
||||||
|
? `<small class="timeline-excerpt${showSources ? '' : ' is-hidden'}">${escapeHtml(ev.source_excerpt)}</small>`
|
||||||
|
: '';
|
||||||
|
const copyText = [ev.date, ev.actor, ev.event].filter(Boolean).join(' · ');
|
||||||
|
return `${groupHeader}<li class="timeline-item confidence-${escapeHtml(conf)}">
|
||||||
<div class="timeline-header">
|
<div class="timeline-header">
|
||||||
<strong class="timeline-date">${escapeHtml(ev.date || 'unknown')}${ev.time ? `<span class="timeline-time"> ${escapeHtml(ev.time)}</span>` : ''}</strong>
|
<strong class="timeline-date">${escapeHtml(ev.date || 'unknown')}${ev.time ? `<span class="timeline-time"> ${escapeHtml(ev.time)}</span>` : ''}</strong>
|
||||||
${ev.date_type ? `<span class="date-type-badge">${escapeHtml(ev.date_type)}</span>` : ''}
|
${ev.date_type ? `<span class="date-type-badge">${escapeHtml(ev.date_type)}</span>` : ''}
|
||||||
<span class="confidence-badge confidence-badge--${escapeHtml(conf)}">${escapeHtml(conf)}</span>
|
<span class="confidence-badge confidence-badge--${escapeHtml(conf)}">${escapeHtml(conf)}</span>
|
||||||
|
<button type="button" class="timeline-copy-btn" data-copy="${escapeHtml(copyText)}" title="Copy event" aria-label="Copy event to clipboard">📋</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="timeline-actor">${escapeHtml(ev.actor || 'unknown actor')}</span>
|
<span class="timeline-actor">${escapeHtml(ev.actor || 'unknown actor')}</span>
|
||||||
<p class="timeline-event">${escapeHtml(ev.event || '')}</p>
|
<p class="timeline-event">${escapeHtml(ev.event || '')}</p>
|
||||||
${ev.source_excerpt ? `<small class="timeline-excerpt">${escapeHtml(ev.source_excerpt)}</small>` : ''}
|
${excerptHtml}
|
||||||
</li>`;
|
</li>`;
|
||||||
}).join('')}</ol>`;
|
}).join('');
|
||||||
|
return `<ol class="timeline-list">${items}</ol>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFeedbackWidget() {
|
function renderFeedbackWidget() {
|
||||||
@@ -1620,9 +1740,9 @@ function setupFeedbackWidget(tool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function exportTimelineCSV(events) {
|
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) => [
|
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 || '',
|
ev.event || '', ev.source_excerpt || '', ev.confidence || '',
|
||||||
]);
|
]);
|
||||||
const csv = [header, ...rows]
|
const csv = [header, ...rows]
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ final class DbnDeepResearchAgent
|
|||||||
?callable $emit = null,
|
?callable $emit = null,
|
||||||
string $advocateRole = '',
|
string $advocateRole = '',
|
||||||
?array $priorContext = null,
|
?array $priorContext = null,
|
||||||
string $branchNotes = ''
|
string $branchNotes = '',
|
||||||
|
array $subQuestionsOverride = []
|
||||||
): array {
|
): array {
|
||||||
$seedQuery = trim($seedQuery);
|
$seedQuery = trim($seedQuery);
|
||||||
$pastedText = trim($pastedText);
|
$pastedText = trim($pastedText);
|
||||||
@@ -88,17 +89,26 @@ final class DbnDeepResearchAgent
|
|||||||
$this->stepTimings['interpretation'] = $this->elapsedMs($stepStart);
|
$this->stepTimings['interpretation'] = $this->elapsedMs($stepStart);
|
||||||
$emitStep('interpretation', 'Query interpretation', $interpretation['detail'], 'complete');
|
$emitStep('interpretation', 'Query interpretation', $interpretation['detail'], 'complete');
|
||||||
|
|
||||||
// STEP 2: Query expansion
|
// STEP 2: Query expansion (or use caller-supplied override)
|
||||||
$emitRunning('expansion', 'Query expansion', 'Generating sub-questions…');
|
|
||||||
$stepStart = microtime(true);
|
$stepStart = microtime(true);
|
||||||
$expansion = $this->expandQueries($seedDescription, $interpretation['brief'], $interpretation['key_signals'], $controls['sub_q_count'], $language, $advocateRole);
|
if (!empty($subQuestionsOverride)) {
|
||||||
$this->stepTimings['expansion'] = $this->elapsedMs($stepStart);
|
$subQuestions = array_values(array_filter($subQuestionsOverride, fn($sq) =>
|
||||||
$subQuestions = $expansion['questions'];
|
is_array($sq) && !empty(trim((string)($sq['question'] ?? '')))
|
||||||
$expansionStatus = $expansion['fallback'] ? 'warning' : 'complete';
|
));
|
||||||
$expansionDetail = $expansion['fallback']
|
$this->stepTimings['expansion'] = $this->elapsedMs($stepStart);
|
||||||
? 'Could not parse sub-questions; falling back to retrieving on the seed query alone.'
|
$emitStep('expansion', 'Query expansion',
|
||||||
: sprintf('Generated %d sub-questions to research the corpus from multiple angles.', count($subQuestions));
|
sprintf('Using %d custom sub-question(s) supplied by the user.', count($subQuestions)), 'complete');
|
||||||
$emitStep('expansion', 'Query expansion', $expansionDetail, $expansionStatus);
|
} 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
|
// STEP 3: Slice resolution
|
||||||
$emitRunning('slice_resolution', 'Slice resolution', 'Resolving slice toggles to document IDs…');
|
$emitRunning('slice_resolution', 'Slice resolution', 'Resolving slice toggles to document IDs…');
|
||||||
@@ -1164,6 +1174,50 @@ PROMPT;
|
|||||||
return 'low';
|
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
|
private function trace(string $label, string $detail, string $status = 'complete'): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
Reference in New Issue
Block a user