/* ============================================ Bio-RAG — Application Logic ============================================ */ // --- DOM Elements --- const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); const DOM = { sidebar: $('#sidebar'), sidebarToggle: $('#sidebarToggle'), sidebarHistory: $('#sidebarHistory'), menuBtn: $('#menuBtn'), newChatBtn: $('#newChatBtn'), headerNewChat: $('#headerNewChat'), headerDeleteChat: $('#headerDeleteChat'), chatArea: $('#chatArea'), messages: $('#messages'), welcomeScreen: $('#welcomeScreen'), questionInput: $('#questionInput'), sendBtn: $('#sendBtn'), deleteModal: $('#deleteModal'), deleteCancelBtn: $('#deleteCancelBtn'), deleteConfirmBtn: $('#deleteConfirmBtn'), }; // --- State --- const state = { isProcessing: false, conversations: JSON.parse(localStorage.getItem('biorag_history') || '[]'), currentMessages: [], currentChatId: null, chatToDelete: null, }; // ============================================ // INITIALIZATION // ============================================ document.addEventListener('DOMContentLoaded', () => { initEventListeners(); renderHistory(); autoResizeTextarea(); }); function initEventListeners() { // Send DOM.sendBtn.addEventListener('click', handleSend); DOM.questionInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }); // Input state DOM.questionInput.addEventListener('input', () => { autoResizeTextarea(); DOM.sendBtn.disabled = !DOM.questionInput.value.trim(); }); // Sidebar DOM.sidebarToggle.addEventListener('click', () => toggleSidebar(false)); DOM.menuBtn.addEventListener('click', () => toggleSidebar(true)); // New chat DOM.newChatBtn.addEventListener('click', newChat); DOM.headerNewChat.addEventListener('click', newChat); // Delete chat DOM.headerDeleteChat.addEventListener('click', () => { if (state.currentChatId) { showDeleteModal(state.currentChatId); } }); DOM.deleteCancelBtn.addEventListener('click', hideDeleteModal); DOM.deleteConfirmBtn.addEventListener('click', confirmDelete); // Close modal on background click DOM.deleteModal.addEventListener('click', (e) => { if (e.target === DOM.deleteModal) { hideDeleteModal(); } }); // Suggestion cards $$('.suggestion-card').forEach(card => { card.addEventListener('click', () => { const question = card.dataset.question; DOM.questionInput.value = question; DOM.sendBtn.disabled = false; handleSend(); }); }); } // ============================================ // SIDEBAR // ============================================ function toggleSidebar(open) { if (open) { DOM.sidebar.classList.remove('collapsed'); DOM.sidebar.classList.add('open'); } else { DOM.sidebar.classList.add('collapsed'); DOM.sidebar.classList.remove('open'); } } function renderHistory() { DOM.sidebarHistory.innerHTML = ''; if (state.conversations.length === 0) return; const now = new Date(); const today = []; const yesterday = []; const older = []; state.conversations.forEach(conv => { const d = new Date(conv.timestamp); const diffDays = Math.floor((now - d) / 86400000); if (diffDays === 0) today.push(conv); else if (diffDays === 1) yesterday.push(conv); else older.push(conv); }); if (today.length) addHistorySection('Today', today); if (yesterday.length) addHistorySection('Yesterday', yesterday); if (older.length) addHistorySection('Previous', older); } function addHistorySection(title, items) { const h = document.createElement('div'); h.className = 'history-section-title'; h.textContent = title; DOM.sidebarHistory.appendChild(h); items.forEach(conv => { const wrapper = document.createElement('div'); wrapper.className = 'history-item-wrapper'; const btn = document.createElement('button'); btn.className = 'history-item'; btn.textContent = conv.title; btn.addEventListener('click', () => loadConversation(conv)); const deleteBtn = document.createElement('button'); deleteBtn.className = 'delete-chat-btn'; deleteBtn.title = 'Delete chat'; deleteBtn.innerHTML = ` `; deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); showDeleteModal(conv.id); }); wrapper.appendChild(btn); wrapper.appendChild(deleteBtn); DOM.sidebarHistory.appendChild(wrapper); }); } function loadConversation(conv) { state.currentChatId = conv.id; state.currentMessages = conv.messages || []; DOM.messages.innerHTML = ''; DOM.welcomeScreen.style.display = 'none'; DOM.headerDeleteChat.style.display = 'flex'; state.currentMessages.forEach(msg => { if (msg.role === 'user') { addUserMessageToDOM(msg.content); } else { addBotMessageToDOM(msg.content, msg.resultData, false); } }); scrollToBottom(); } // ============================================ // NEW CHAT // ============================================ function newChat() { saveCurrentConversation(); state.currentChatId = null; state.currentMessages = []; DOM.messages.innerHTML = ''; DOM.welcomeScreen.style.display = ''; DOM.questionInput.value = ''; DOM.sendBtn.disabled = true; DOM.headerDeleteChat.style.display = 'none'; scrollToBottom(); } function saveCurrentConversation() { if (state.currentMessages.length === 0) return; const firstUserMsg = state.currentMessages.find(m => m.role === 'user'); const title = firstUserMsg ? firstUserMsg.content.slice(0, 50) + (firstUserMsg.content.length > 50 ? '...' : '') : 'Untitled'; const conv = { id: Date.now(), title, timestamp: new Date().toISOString(), messages: state.currentMessages, }; state.conversations.unshift(conv); if (state.conversations.length > 30) state.conversations.pop(); localStorage.setItem('biorag_history', JSON.stringify(state.conversations)); renderHistory(); } // ============================================ // DELETE CHAT // ============================================ function showDeleteModal(chatId) { state.chatToDelete = chatId; DOM.deleteModal.classList.add('show'); } function hideDeleteModal() { state.chatToDelete = null; DOM.deleteModal.classList.remove('show'); } function confirmDelete() { if (!state.chatToDelete) return; // Remove from conversations state.conversations = state.conversations.filter(c => c.id !== state.chatToDelete); localStorage.setItem('biorag_history', JSON.stringify(state.conversations)); // If deleting current chat, start new chat if (state.currentChatId === state.chatToDelete) { state.currentChatId = null; state.currentMessages = []; DOM.messages.innerHTML = ''; DOM.welcomeScreen.style.display = ''; DOM.headerDeleteChat.style.display = 'none'; } // Update UI renderHistory(); hideDeleteModal(); } // ============================================ // SEND & RECEIVE // ============================================ async function handleSend() { const question = DOM.questionInput.value.trim(); if (!question || state.isProcessing) return; state.isProcessing = true; DOM.sendBtn.disabled = true; DOM.questionInput.value = ''; autoResizeTextarea(); DOM.welcomeScreen.style.display = 'none'; addUserMessageToDOM(question); state.currentMessages.push({ role: 'user', content: question }); scrollToBottom(); // Create bot message wrapper const botWrapper = document.createElement('div'); botWrapper.className = 'msg-bot'; const avatar = document.createElement('div'); avatar.className = 'msg-bot-avatar'; avatar.textContent = '🧬'; const botContent = document.createElement('div'); botContent.className = 'msg-bot-content'; const progressEl = createPipelineProgress(); botContent.appendChild(progressEl); const textEl = document.createElement('div'); textEl.className = 'msg-bot-text'; textEl.style.display = 'none'; botContent.appendChild(textEl); botWrapper.appendChild(avatar); botWrapper.appendChild(botContent); DOM.messages.appendChild(botWrapper); animatePipelineStep(progressEl, 0); scrollToBottom(); try { const response = await fetch('/api/ask-stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question }), }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let finalResult = null; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.startsWith('data: ')) continue; try { const event = JSON.parse(line.slice(6).trim()); if (event.step !== undefined) { if (event.status === 'active') animatePipelineStep(progressEl, event.step); else if (event.status === 'done') completePipelineStep(progressEl, event.step); scrollToBottom(); } if (event.answer_ready) { textEl.style.display = ''; typewriter(textEl, event.answer); } if (event.complete) finalResult = event.result; if (event.error) throw new Error(event.error); } catch (e) { if (e.message && !e.message.includes('JSON')) throw e; } } } // Pipeline complete const dot = progressEl.querySelector('.pipeline-header-dot'); if (dot) dot.classList.add('done'); const label = progressEl.querySelector('.pipeline-current-label'); if (label) label.textContent = ''; const comp = progressEl.querySelector('.pipeline-complete'); if (comp) { comp.className = 'pipeline-complete show safe'; comp.innerHTML = ' Done'; } if (finalResult) { const answerText = finalResult.final_answer || finalResult.rejection_message || 'No response.'; const isRejection = !!finalResult.rejection_message && (!finalResult.claim_checks || finalResult.claim_checks.length === 0); if (isRejection) { botWrapper.remove(); addRejectionToDOM(answerText); } else { textEl.innerHTML = formatText(answerText); setTimeout(() => highlightRisksInText(textEl, finalResult), 500); const panel = buildVerificationPanel(finalResult); botContent.appendChild(panel); } state.currentMessages.push({ role: 'assistant', content: answerText, resultData: finalResult }); } scrollToBottom(); } catch (err) { botWrapper.remove(); addErrorToDOM(err.message || 'Connection failed.'); } state.isProcessing = false; DOM.sendBtn.disabled = !DOM.questionInput.value.trim(); } // ============================================ // DOM BUILDERS // ============================================ function addUserMessageToDOM(text) { const div = document.createElement('div'); div.className = 'msg-user'; div.innerHTML = `
${escapeHTML(text)}
`; DOM.messages.appendChild(div); } async function addBotMessageToDOM(text, resultData, animate) { const wrapper = document.createElement('div'); wrapper.className = 'msg-bot'; const avatar = document.createElement('div'); avatar.className = 'msg-bot-avatar'; avatar.textContent = '🧬'; const content = document.createElement('div'); content.className = 'msg-bot-content'; const textEl = document.createElement('div'); textEl.className = 'msg-bot-text'; content.appendChild(textEl); wrapper.appendChild(avatar); wrapper.appendChild(content); DOM.messages.appendChild(wrapper); // Typewriter or instant if (animate) { await typewriter(textEl, text); } else { textEl.innerHTML = formatText(text); } // Verification panel if (resultData && resultData.claim_checks && resultData.claim_checks.length > 0) { // Apply inline risk highlighting after text is rendered setTimeout(() => { highlightRisksInText(textEl, resultData); }, animate ? 300 : 0); const panel = buildVerificationPanel(resultData); content.appendChild(panel); } scrollToBottom(); } function addRejectionToDOM(text) { const wrapper = document.createElement('div'); wrapper.className = 'msg-bot'; const avatar = document.createElement('div'); avatar.className = 'msg-bot-avatar'; avatar.textContent = '🧬'; const content = document.createElement('div'); content.className = 'msg-bot-content'; const rejection = document.createElement('div'); rejection.className = 'msg-rejection'; rejection.innerHTML = `⚠️${escapeHTML(text)}`; content.appendChild(rejection); wrapper.appendChild(avatar); wrapper.appendChild(content); DOM.messages.appendChild(wrapper); } function addErrorToDOM(text) { const wrapper = document.createElement('div'); wrapper.className = 'msg-error'; const avatar = document.createElement('div'); avatar.className = 'msg-bot-avatar'; avatar.textContent = '🧬'; const content = document.createElement('div'); content.className = 'msg-error-content'; content.textContent = `Error: ${text}`; wrapper.appendChild(avatar); wrapper.appendChild(content); DOM.messages.appendChild(wrapper); } // ============================================ // THINKING INDICATOR // ============================================ function showThinking() { const wrapper = document.createElement('div'); wrapper.className = 'thinking'; const avatar = document.createElement('div'); avatar.className = 'msg-bot-avatar'; avatar.textContent = '🧬'; const content = document.createElement('div'); content.className = 'thinking-content'; const dots = document.createElement('div'); dots.className = 'thinking-dots'; dots.innerHTML = ''; const steps = document.createElement('div'); steps.className = 'thinking-steps'; content.appendChild(dots); content.appendChild(steps); wrapper.appendChild(avatar); wrapper.appendChild(content); DOM.messages.appendChild(wrapper); return wrapper; } function updateThinkingStep(el, index, text) { const stepsContainer = el.querySelector('.thinking-steps'); if (!stepsContainer) return; // Mark previous as done const prevSteps = stepsContainer.querySelectorAll('.thinking-step'); prevSteps.forEach(s => { s.classList.remove('active'); s.classList.add('done'); const icon = s.querySelector('.step-icon'); if (icon) icon.textContent = '✓'; }); // Add new step const step = document.createElement('div'); step.className = 'thinking-step active'; step.innerHTML = ` ${escapeHTML(text)}`; stepsContainer.appendChild(step); scrollToBottom(); } // ============================================ // VERIFICATION PANEL // ============================================ function buildVerificationPanel(data) { const claims = data.claim_checks || []; const maxRisk = data.max_risk_score || 0; const isSafe = data.safe !== false && maxRisk < 0.7; const evidence = data.evidence || []; const panel = document.createElement('div'); panel.className = `verification-panel ${isSafe ? 'safe' : 'flagged'}`; // Summary const summary = document.createElement('div'); summary.className = 'verification-summary'; summary.innerHTML = `
${isSafe ? '✅' : '⚠️'} ${isSafe ? 'Safe' : 'Flagged'} — Risk: ${maxRisk.toFixed(4)}
${claims.length} claims verified • ${Math.min(evidence.length, 3)} sources cited • ${data.processing_time_seconds ? data.processing_time_seconds + 's' : ''}
${data.processing_stats ? `
📊 Database: ${data.processing_stats.total_db_size.toLocaleString()} documents
🔍 Queries Generated: ${data.processing_stats.queries_generated}
📄 Passages Retrieved: ${data.processing_stats.passages_retrieved} → Top ${Math.min(data.processing_stats.passages_retrieved, 10)} after RRF
✂️ Claims Decomposed: ${data.processing_stats.claims_verified}
🔬 Total Evidence Evaluated: ${data.processing_stats.total_evidence_evaluated} (${data.processing_stats.claims_verified} claims × ${data.processing_stats.evidence_per_claim} docs)
${data.processing_stats.token_usage ? `
🪙 Tokens: Input: ${data.processing_stats.token_usage.prompt_tokens} • Output: ${data.processing_stats.token_usage.completion_tokens} • Total: ${data.processing_stats.token_usage.total_tokens}
` : ''} ${data.processing_stats.phase_times ? `
⏱️ Phase Times: Query Expansion: ${data.processing_stats.phase_times.query_expansion || 0}s • Retrieval: ${data.processing_stats.phase_times.retrieval || 0}s • Generation: ${data.processing_stats.phase_times.generation || 0}s • Decomposition: ${data.processing_stats.phase_times.decomposition || 0}s • Verification: ${data.processing_stats.phase_times.verification || 0}s
` : ''}
` : ''}
View Details ▼
`; // Details const details = document.createElement('div'); details.className = 'verification-details'; let detailsHTML = '
'; detailsHTML += '
Claims & Risk Scores
'; // Sort claims by risk (highest first) const sortedClaims = [...claims].sort((a, b) => (b.risk_score || 0) - (a.risk_score || 0)); sortedClaims.forEach(c => { const risk = c.risk_score || 0; const pct = Math.min(risk * 100, 100); const level = risk >= 0.7 ? 'high' : risk >= 0.3 ? 'medium' : 'low'; detailsHTML += `
${risk.toFixed(4)}
${escapeHTML(c.claim || '')}
`; }); // Evidence if (evidence.length > 0) { detailsHTML += '
Retrieved Evidence
'; evidence.slice(0, 3).forEach((ev, i) => { const text = typeof ev === 'string' ? ev : (ev.text || JSON.stringify(ev)); detailsHTML += `
📄 Doc ${i + 1}: ${escapeHTML(text.slice(0, 150))}...
`; }); } detailsHTML += '
'; details.innerHTML = detailsHTML; // Toggle summary.addEventListener('click', () => { const isOpen = details.classList.toggle('open'); summary.querySelector('.verification-toggle').textContent = isOpen ? 'Hide Details ▲' : 'View Details ▼'; }); panel.appendChild(summary); panel.appendChild(details); return panel; } // ============================================ // TYPEWRITER EFFECT // ============================================ async function typewriter(element, text) { const words = text.split(' '); const cursor = document.createElement('span'); cursor.className = 'cursor'; let currentHTML = ''; element.appendChild(cursor); for (let i = 0; i < words.length; i++) { currentHTML += (i > 0 ? ' ' : '') + escapeHTML(words[i]); element.innerHTML = formatText(currentHTML); element.appendChild(cursor); scrollToBottom(); await delay(25); } cursor.remove(); element.innerHTML = formatText(text); } // ============================================ // INLINE RISK HIGHLIGHTING // ============================================ function highlightRisksInText(textElement, resultData) { if (!resultData || !resultData.claim_checks || resultData.claim_checks.length === 0) return; const originalText = resultData.original_answer || textElement.textContent; const sentences = splitIntoSentences(originalText); const claims = resultData.claim_checks; // Map each sentence to its highest risk score const sentenceRisks = sentences.map(sentence => { const matchingClaims = findMatchingClaims(sentence, claims); const maxRisk = matchingClaims.length > 0 ? Math.max(...matchingClaims.map(c => c.risk_score || 0)) : 0; const topClaim = matchingClaims.sort((a, b) => (b.risk_score || 0) - (a.risk_score || 0))[0]; return { sentence, maxRisk, topClaim }; }); // Build highlighted HTML let html = ''; sentenceRisks.forEach(({ sentence, maxRisk, topClaim }) => { if (maxRisk >= 0.7) { html += buildHighlightedSentence(sentence, maxRisk, topClaim, 'danger'); } else if (maxRisk >= 0.15) { html += buildHighlightedSentence(sentence, maxRisk, topClaim, 'caution'); } else { html += escapeHTML(sentence) + ' '; } }); // Apply with animation textElement.innerHTML = `

${html.trim()}

`; // Trigger animation setTimeout(() => { textElement.querySelectorAll('.risk-sentence').forEach(el => { el.classList.add('animate-in'); }); }, 100); } function buildHighlightedSentence(sentence, risk, claim, level) { const tooltipLabel = level === 'danger' ? 'Unverified or contradicted' : 'Low confidence'; const claimText = claim ? escapeHTML(claim.claim || '').slice(0, 60) + '...' : ''; return `` + `${escapeHTML(sentence)} ` + `` + `Risk: ${risk.toFixed(3)}
` + `${tooltipLabel}` + `${claimText ? '
' + claimText + '' : ''}` + `
` + `
`; } function splitIntoSentences(text) { // Split on sentence boundaries but keep the delimiter const raw = text.split(/(?<=[.!?])\s+/); return raw.filter(s => s.trim().length > 5); } function findMatchingClaims(sentence, claims) { const sentenceClean = sentence.toLowerCase().replace(/[^\w\s]/g, ''); const sentenceWords = new Set( sentenceClean.split(/\s+/).filter(w => w.length > 3) ); if (sentenceWords.size === 0) return []; const results = []; claims.forEach(claim => { const claimText = (claim.claim || '').toLowerCase().replace(/[^\w\s]/g, ''); const claimWords = claimText.split(/\s+/).filter(w => w.length > 3); if (claimWords.length === 0) return; // Count matches in both directions const claimInSentence = claimWords.filter(w => sentenceWords.has(w)).length; const sentenceInClaim = [...sentenceWords].filter(w => claimWords.includes(w)).length; const claimMatchRatio = claimInSentence / claimWords.length; const sentenceMatchRatio = sentenceInClaim / sentenceWords.size; // Both directions must match at least 50% // This prevents a short claim from matching many long sentences if (claimMatchRatio >= 0.5 && sentenceMatchRatio >= 0.3) { results.push(claim); } }); return results; } // ============================================ // PIPELINE PROGRESS INDICATOR // ============================================ function createPipelineProgress() { const div = document.createElement('div'); div.className = 'pipeline-progress'; const stepNames = [ 'Domain Check', 'Query Expansion', 'Retrieval', 'Generation', 'Decomposition', 'Evidence', 'NLI', 'Risk Score', 'Decision' ]; let html = `
Pipeline
`; html += `
`; html += `
`; for (let i = 0; i < 4; i++) { html += `
${stepNames[i]}
`; } html += `
`; html += `
`; html += `
`; for (let i = 4; i < 9; i++) { html += `
${stepNames[i]}
`; } html += `
`; html += `
`; html += `
`; html += ``; div.innerHTML = html; return div; } async function animatePipelineStep(progressEl, stepIndex) { const step = progressEl.querySelector(`[data-step="${stepIndex}"]`); if (!step) return; const stepNames = [ 'Domain Check...', 'Expanding Query...', 'Retrieving Evidence...', 'Generating Answer...', 'Decomposing Claims...', 'Retrieving Per-Claim...', 'NLI Evaluation...', 'Risk Scoring...', 'Final Decision...' ]; for (let i = 0; i < stepIndex; i++) { const prev = progressEl.querySelector(`[data-step="${i}"]`); if (prev && !prev.classList.contains('done')) { prev.classList.remove('pending', 'active'); prev.classList.add('done'); } } step.classList.remove('pending'); step.classList.add('active'); // Update label const label = progressEl.querySelector('.pipeline-current-label'); if (label) label.textContent = stepNames[stepIndex] || ''; scrollToBottom(); } function completePipelineStep(progressEl, stepIndex) { const step = progressEl.querySelector(`[data-step="${stepIndex}"]`); if (!step) return; step.classList.remove('pending', 'active'); step.classList.add('done'); } function showPipelineComplete(progressEl) { // Mark all steps as done progressEl.querySelectorAll('.pipeline-step').forEach(s => { s.classList.remove('pending', 'active'); s.classList.add('done'); }); // Update header dot const dot = progressEl.querySelector('.pipeline-header-dot'); if (dot) dot.classList.add('done'); // Show simple completion const complete = progressEl.querySelector('.pipeline-complete'); if (complete) { complete.className = 'pipeline-complete show safe'; complete.innerHTML = ' Pipeline Complete'; } scrollToBottom(); // Collapse after 2 seconds setTimeout(() => { progressEl.classList.add('collapsed'); }, 2000); } // ============================================ // UTILITIES // ============================================ function escapeHTML(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function formatText(text) { // Convert line breaks to paragraphs return text.split(/\n\n+/).map(p => `

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

`).join(''); } function scrollToBottom() { DOM.chatArea.scrollTop = DOM.chatArea.scrollHeight; } function autoResizeTextarea() { const el = DOM.questionInput; el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 120) + 'px'; } function delay(ms) { return new Promise(r => setTimeout(r, ms)); }