Spaces:
Running
Running
| /* ============================================ | |
| 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 = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/> | |
| </svg>`; | |
| 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 = '<span class="pipeline-complete-icon">✓</span> 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 = `<div class="msg-user-bubble">${escapeHTML(text)}</div>`; | |
| 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 = `<span>⚠️</span><span>${escapeHTML(text)}</span>`; | |
| 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 = '<span class="thinking-dot"></span><span class="thinking-dot"></span><span class="thinking-dot"></span>'; | |
| 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 = `<span class="step-icon">○</span> ${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 = ` | |
| <div class="verification-info"> | |
| <div class="verification-status ${isSafe ? 'safe' : 'flagged'}"> | |
| ${isSafe ? '✅' : '⚠️'} ${isSafe ? 'Safe' : 'Flagged'} — Risk: ${maxRisk.toFixed(4)} | |
| </div> | |
| <div class="verification-meta"> | |
| ${claims.length} claims verified • ${Math.min(evidence.length, 3)} sources cited • ${data.processing_time_seconds ? data.processing_time_seconds + 's' : ''} | |
| </div> | |
| ${data.processing_stats ? ` | |
| <div class="processing-stats" style="margin-top:8px; font-size:12px; color:var(--text-secondary, #888); line-height:1.8;"> | |
| <div>📊 <strong>Database:</strong> ${data.processing_stats.total_db_size.toLocaleString()} documents</div> | |
| <div>🔍 <strong>Queries Generated:</strong> ${data.processing_stats.queries_generated}</div> | |
| <div>📄 <strong>Passages Retrieved:</strong> ${data.processing_stats.passages_retrieved} → Top ${Math.min(data.processing_stats.passages_retrieved, 10)} after RRF</div> | |
| <div>✂️ <strong>Claims Decomposed:</strong> ${data.processing_stats.claims_verified}</div> | |
| <div>🔬 <strong>Total Evidence Evaluated:</strong> ${data.processing_stats.total_evidence_evaluated} (${data.processing_stats.claims_verified} claims × ${data.processing_stats.evidence_per_claim} docs)</div> | |
| ${data.processing_stats.token_usage ? ` | |
| <div>🪙 <strong>Tokens:</strong> Input: ${data.processing_stats.token_usage.prompt_tokens} • Output: ${data.processing_stats.token_usage.completion_tokens} • Total: ${data.processing_stats.token_usage.total_tokens}</div>` : ''} | |
| ${data.processing_stats.phase_times ? ` | |
| <div style="margin-top:4px;">⏱️ <strong>Phase Times:</strong> | |
| 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 | |
| </div>` : ''} | |
| </div>` : ''} | |
| </div> | |
| <div class="verification-toggle">View Details ▼</div> | |
| `; | |
| // Details | |
| const details = document.createElement('div'); | |
| details.className = 'verification-details'; | |
| let detailsHTML = '<div class="verification-details-inner">'; | |
| detailsHTML += '<div class="claims-title">Claims & Risk Scores</div>'; | |
| // 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 += ` | |
| <div class="claim-item"> | |
| <div class="claim-risk-bar-container"> | |
| <div class="claim-risk-bar"> | |
| <div class="claim-risk-bar-fill ${level}" style="width: ${pct}%"></div> | |
| </div> | |
| <span class="claim-risk-value">${risk.toFixed(4)}</span> | |
| </div> | |
| <div class="claim-text">${escapeHTML(c.claim || '')}</div> | |
| </div> | |
| `; | |
| }); | |
| // Evidence | |
| if (evidence.length > 0) { | |
| detailsHTML += '<div class="evidence-title">Retrieved Evidence</div>'; | |
| evidence.slice(0, 3).forEach((ev, i) => { | |
| const text = typeof ev === 'string' ? ev : (ev.text || JSON.stringify(ev)); | |
| detailsHTML += ` | |
| <div class="evidence-item"> | |
| <span class="evidence-icon">📄</span> | |
| <span class="evidence-text">Doc ${i + 1}: ${escapeHTML(text.slice(0, 150))}...</span> | |
| </div> | |
| `; | |
| }); | |
| } | |
| detailsHTML += '</div>'; | |
| 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 = `<p>${html.trim()}</p>`; | |
| // 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 `<span class="risk-sentence risk-${level}">` + | |
| `${escapeHTML(sentence)} ` + | |
| `<span class="risk-tooltip">` + | |
| `<span class="tooltip-risk ${level}">Risk: ${risk.toFixed(3)}</span><br>` + | |
| `${tooltipLabel}` + | |
| `${claimText ? '<br><em>' + claimText + '</em>' : ''}` + | |
| `</span>` + | |
| `</span> `; | |
| } | |
| 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 = `<div class="pipeline-header"><span class="pipeline-header-dot"></span>Pipeline</div>`; | |
| html += `<div class="pipeline-phases">`; | |
| html += `<div class="pipeline-phase"><div class="pipeline-steps">`; | |
| for (let i = 0; i < 4; i++) { | |
| html += `<div class="pipeline-step pending" data-step="${i}"> | |
| <span class="pipeline-step-icon">✓</span> | |
| <span class="step-tooltip">${stepNames[i]}</span> | |
| </div>`; | |
| } | |
| html += `</div></div>`; | |
| html += `<div class="pipeline-phase-sep"></div>`; | |
| html += `<div class="pipeline-phase"><div class="pipeline-steps">`; | |
| for (let i = 4; i < 9; i++) { | |
| html += `<div class="pipeline-step pending" data-step="${i}"> | |
| <span class="pipeline-step-icon">✓</span> | |
| <span class="step-tooltip">${stepNames[i]}</span> | |
| </div>`; | |
| } | |
| html += `</div></div>`; | |
| html += `</div>`; | |
| html += `<div class="pipeline-complete"></div>`; | |
| html += `<span class="pipeline-current-label" id="pipelineLabel"></span>`; | |
| 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 = '<span class="pipeline-complete-icon">✓</span> 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>${p.replace(/\n/g, '<br>')}</p>`).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)); | |
| } | |