300 lines
11 KiB
JavaScript
300 lines
11 KiB
JavaScript
// Research orchestration UI
|
||
const RESEARCH_UI = {
|
||
async init() {
|
||
$("#run-research").addEventListener("click", () => this.runResearch());
|
||
$("#query-input").addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter" && e.ctrlKey) this.runResearch();
|
||
});
|
||
},
|
||
|
||
async runResearch() {
|
||
const query = $("#query-input").value.trim();
|
||
if (!query) return showToast("Enter a research query", "error");
|
||
|
||
// Get selected skills
|
||
const checked = [...document.querySelectorAll('.skill-toggle input:checked')].map(c => c.value);
|
||
|
||
const output = $("#research-output");
|
||
output.innerHTML = "";
|
||
showSpinner(output);
|
||
|
||
// Create session
|
||
let sessionId = STORE.currentSessionId;
|
||
if (!sessionId) {
|
||
try {
|
||
const session = await API.post("/api/research/session", { query });
|
||
STORE.currentSessionId = session.session_id;
|
||
sessionId = session.session_id;
|
||
} catch (e) {
|
||
showToast("Failed to create session", "error");
|
||
output.innerHTML = `<p class='typing'>Session error: ${e.message}</p>`;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Run research
|
||
try {
|
||
const results = await API.post("/api/research/run", {
|
||
query,
|
||
doc_id: STORE.currentDocId,
|
||
skills: checked.length ? checked : undefined,
|
||
});
|
||
|
||
output.innerHTML = "";
|
||
|
||
// Session info
|
||
const info = document.createElement("div");
|
||
info.className = "research-section";
|
||
info.innerHTML = `
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<h3 style="color:var(--accent-blue)">Research Results</h3>
|
||
<span style="font-size:11px;color:var(--text-muted)">Session: ${sessionId.substring(0, 8)}</span>
|
||
</div>
|
||
<p style="color:var(--text-secondary)">Query: ${query}</p>
|
||
<hr style="border:none;border-top:1px solid var(--border-color);margin:8px 0">`;
|
||
output.appendChild(info);
|
||
|
||
// Results from each skill
|
||
if (results.results) {
|
||
for (const [skill, response] of Object.entries(results.results)) {
|
||
const section = document.createElement("div");
|
||
section.className = "research-section";
|
||
section.id = `section-${skill}`;
|
||
section.innerHTML = `<h3>${skill.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}</h3>`;
|
||
|
||
const content = document.createElement("div");
|
||
content.innerHTML = renderContent(response);
|
||
section.appendChild(content);
|
||
output.appendChild(section);
|
||
}
|
||
}
|
||
|
||
STORE.currentSessionId = sessionId;
|
||
showToast("Research complete", "info");
|
||
|
||
} catch (e) {
|
||
output.innerHTML = `<p class='typing' style="color:var(--accent-red)">Error: ${e.message}</p>`;
|
||
showToast("Research failed", "error");
|
||
}
|
||
},
|
||
|
||
async runSemanticSearch(query, docId = null) {
|
||
const results = await API.post("/api/research/semantic", { query, doc_id: docId, limit: 20 });
|
||
return results.results || [];
|
||
},
|
||
|
||
async runTextSearch(query, docId = null) {
|
||
const results = await API.post("/api/research/text-search", { query, doc_id: docId, limit: 20 });
|
||
return results.results || [];
|
||
},
|
||
|
||
async getFindings(sessionId) {
|
||
if (!sessionId) return [];
|
||
try {
|
||
const res = await API.get(`/api/research/findings?session_id=${sessionId}`);
|
||
return res.findings || [];
|
||
} catch { return []; }
|
||
},
|
||
|
||
async saveMemory(sessionId, content, type = "fact", importance = 3) {
|
||
if (!sessionId) return;
|
||
try {
|
||
await API.post("/api/memories/save", {
|
||
session_id: sessionId,
|
||
content,
|
||
memory_type: type,
|
||
importance,
|
||
source_doc_id: STORE.currentDocId,
|
||
});
|
||
showToast("Memory saved", "info");
|
||
} catch (e) { showToast("Save failed: " + e.message, "error"); }
|
||
},
|
||
|
||
async runPipeline() {
|
||
const query = $("#query-input").value.trim();
|
||
if (!query) return showToast("Enter a research query", "error");
|
||
|
||
const mode = $("#pipeline-output-mode").value || "";
|
||
|
||
// Create session
|
||
let sessionId = STORE.currentSessionId;
|
||
if (!sessionId) {
|
||
try {
|
||
const session = await API.post("/api/research/session", { query });
|
||
sessionId = session.session_id;
|
||
STORE.currentSessionId = sessionId;
|
||
} catch (e) {
|
||
showToast("Failed to create session", "error");
|
||
return;
|
||
}
|
||
}
|
||
|
||
const pipelineEl = $("#pipeline-output");
|
||
pipelineEl.innerHTML = '<div class="spinner"></div> <span class="typing">Running: triage → evidence extraction → synthesis...</span>';
|
||
|
||
// Run the pipeline
|
||
try {
|
||
const results = await API.post("/api/research/pipeline", {
|
||
query,
|
||
doc_ids: STORE.currentDocId ? [STORE.currentDocId] : undefined,
|
||
output_mode: mode || undefined,
|
||
});
|
||
|
||
// Save session to the first doc or temp session
|
||
STORE.currentSessionId = sessionId;
|
||
|
||
// Render pipeline sections
|
||
// First render: get structured sections from results
|
||
const sections = RESEARCH_UI.renderPipelineSections(results);
|
||
pipelineEl.innerHTML = sections.map(s => RESEARCH_UI.renderSectionHtml(s)).join('');
|
||
|
||
// Wire up collapse toggles
|
||
pipelineEl.querySelectorAll(".pipeline-stage-header").forEach(header => {
|
||
header.addEventListener("click", () => {
|
||
const body = header.nextElementSibling;
|
||
body.classList.toggle("collapsed");
|
||
const arrow = header.querySelector(".stage-arrow");
|
||
if (arrow) arrow.textContent = body.classList.contains("collapsed") ? "▶" : "▼";
|
||
});
|
||
});
|
||
|
||
showToast("Pipeline complete", "info");
|
||
|
||
} catch (e) {
|
||
pipelineEl.innerHTML = `<p class='typing' style="color:var(--accent-red)">Error: ${e.message}</p>`;
|
||
showToast("Pipeline failed", "error");
|
||
}
|
||
},
|
||
|
||
renderPipelineSections(results) {
|
||
const sections = [];
|
||
|
||
// Triage stage
|
||
if (results.triage_state) {
|
||
const plan = results.triage_state;
|
||
sections.push({
|
||
stage: "triage",
|
||
title: "Stage 1: Source Triage",
|
||
status: "complete",
|
||
sections: [
|
||
{ label: "Objective", content: plan.OBJECTIVE || "" },
|
||
{ label: "Sub-questions", content: plan["SUB-QUESTIONS"] || "" },
|
||
{ label: "Classification", content: plan.CLASSIFICATION || "" },
|
||
{ label: "Reading Order", content: plan.READING_ORDER || "" },
|
||
{ label: "Extraction Criteria", content: plan["EXTRACTION CRITERIA"] || "" },
|
||
{ label: "Risks & Gaps", content: plan["RISKS and GAPS"] || plan.RISKS_AND_GAPS || "" },
|
||
],
|
||
});
|
||
}
|
||
|
||
// Evidence stage
|
||
if (results.evidence_rows && results.evidence_rows.length > 0) {
|
||
sections.push({
|
||
stage: "evidence",
|
||
title: `Stage 2: Extracted Evidence (${results.evidence_rows.length} rows)`,
|
||
status: "complete",
|
||
sections: [
|
||
{ label: "", content: "table", rows: results.evidence_rows },
|
||
],
|
||
});
|
||
}
|
||
|
||
// Synthesis stage
|
||
if (results.synthesis) {
|
||
sections.push({
|
||
stage: "synthesis",
|
||
title: `Stage 3: Synthesis (mode: ${results.output_mode || "auto"})`,
|
||
status: "complete",
|
||
sections: [
|
||
{ label: "Result", content: results.synthesis, mode: results.output_mode },
|
||
],
|
||
});
|
||
}
|
||
|
||
return sections;
|
||
},
|
||
|
||
renderSectionHtml(section) {
|
||
let cardsHtml = "";
|
||
section.sections.forEach(sec => {
|
||
if (sec.content === "table") {
|
||
const rows = sec.rows || [];
|
||
const hasComparison = rows[0] && rows[0].source_a !== undefined;
|
||
if (hasComparison) {
|
||
cardsHtml += `<div class="pipeline-section-label">Cross-Comparison</div>`;
|
||
cardsHtml += `<table class="matrix-table"><thead><tr><th>Topic</th><th>Source A</th><th>Source B</th><th>Agreement</th><th>Conflict</th><th>Notes</th></tr></thead><tbody>`;
|
||
rows.forEach(r => {
|
||
cardsHtml += `<tr>
|
||
<td>${r.topic || ""}</td>
|
||
<td>${(r.source_a || "").substring(0, 200)}</td>
|
||
<td>${(r.source_b || "").substring(0, 200)}</td>
|
||
<td>${r.agreement || ""}</td>
|
||
<td>${r.conflict || ""}</td>
|
||
<td>${r.notes || ""}</td>
|
||
</tr>`;
|
||
});
|
||
cardsHtml += `</tbody></table>`;
|
||
} else {
|
||
const cols = ["#", "Topic", "Evidence Type", "Description", "Doc Ref", "Evidence", "Analyst Note", "Confidence", "Review"];
|
||
cardsHtml += `<div class="pipeline-section-label">Extracted Evidence</div>`;
|
||
cardsHtml += `<table class="evidence-table"><thead><tr>${cols.map(c => `<th>${c}</th>`).join("")}</tr></thead><tbody>`;
|
||
rows.forEach((r, i) => {
|
||
const confClass = (r.confidence || "medium").toLowerCase().replace(" ", "-");
|
||
cardsHtml += `<tr>
|
||
<td>${i + 1}</td>
|
||
<td>${r.topic || ""}</td>
|
||
<td>${r.evidence_type || ""}</td>
|
||
<td>${(r.description || "").substring(0, 150)}</td>
|
||
<td>${(r.trace_ref || "").substring(0, 150)}</td>
|
||
<td>${(r.evidence || "").substring(0, 200)}</td>
|
||
<td>${(r.analyst_note || "").substring(0, 150)}</td>
|
||
<td class="confidence-${confClass}">${r.confidence || "Medium"}</td>
|
||
<td>${r.review_needed || "No"}</td>
|
||
</tr>`;
|
||
});
|
||
cardsHtml += `</tbody></table>`;
|
||
}
|
||
} else {
|
||
cardsHtml += `<div class="pipeline-section-label">${sec.label || ""}</div>`;
|
||
cardsHtml += `<div class="pipeline-section-content">${sec.content}</div>`;
|
||
}
|
||
});
|
||
|
||
return `
|
||
<div class="pipeline-stage-card">
|
||
<div class="pipeline-stage-header" data-stage="${section.stage}">
|
||
<span class="stage-title">${section.title}</span>
|
||
<span><span class="stage-arrow" style="margin-right:6px;">▼</span><span class="stage-status ${section.status}">Complete</span></span>
|
||
</div>
|
||
<div class="stage-body">${cardsHtml}</div>
|
||
</div>`;
|
||
},
|
||
|
||
async loadPipelineSession(sessionId) {
|
||
if (!sessionId) return;
|
||
const pipelineEl = $("#pipeline-output");
|
||
|
||
try {
|
||
const res = await API.get(`/api/research/pipeline/${sessionId}/render`);
|
||
const sections = res.sections || [];
|
||
|
||
pipelineEl.innerHTML = sections.length > 0
|
||
? sections.map(s => this.renderSectionHtml(s)).join('')
|
||
: "<div class='empty-state'><div class='empty-icon'>ℹ️</div><div class='empty-text'>No pipeline data for this session</div></div>";
|
||
|
||
// Wire up collapse toggles
|
||
pipelineEl.querySelectorAll(".pipeline-stage-header").forEach(header => {
|
||
header.addEventListener("click", () => {
|
||
const body = header.nextElementSibling;
|
||
body.classList.toggle("collapsed");
|
||
const arrow = header.querySelector(".stage-arrow");
|
||
if (arrow) arrow.textContent = body.classList.contains("collapsed") ? "▶" : "▼";
|
||
});
|
||
});
|
||
} catch (e) {
|
||
pipelineEl.innerHTML = `<p class='typing' style="color:var(--accent-red)">Error loading pipeline: ${e.message}</p>`;
|
||
}
|
||
},
|
||
};
|
||
|