Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"/> | |
| <meta name="viewport" content="width=device-width,initial-scale=1.0"/> | |
| <title>ClinIQ — Clinical Document Assistant</title> | |
| <link rel="stylesheet" href="/style.css"/> | |
| <script type="module"> | |
| import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| let client = null; | |
| let docsReady = false; | |
| async function gc() { | |
| if (!client) client = await Client.connect(window.location.origin); | |
| return client; | |
| } | |
| function setStatus(msg, type = "info") { | |
| const el = document.getElementById("status"); | |
| el.innerHTML = msg; | |
| el.className = "status " + type; | |
| el.style.display = "block"; | |
| } | |
| function appendBubble(role, html, id) { | |
| const chat = document.getElementById("chat"); | |
| const wrap = document.createElement("div"); | |
| wrap.className = "bubble-wrap " + role; | |
| if (id) wrap.id = id; | |
| wrap.innerHTML = `<div class="bubble">${html}</div>`; | |
| chat.appendChild(wrap); | |
| chat.scrollTop = chat.scrollHeight; | |
| return wrap.querySelector(".bubble"); | |
| } | |
| function typewrite(el, text, speed = 10) { | |
| return new Promise(res => { | |
| let i = 0; | |
| el.textContent = ""; | |
| el.classList.add("typing"); | |
| const t = () => { | |
| if (i < text.length) { | |
| el.textContent += text[i++]; | |
| el.closest(".chat").scrollTop = el.closest(".chat").scrollHeight; | |
| setTimeout(t, speed); | |
| } else { el.classList.remove("typing"); res(); } | |
| }; | |
| t(); | |
| }); | |
| } | |
| function esc(t) { | |
| return String(t||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); | |
| } | |
| function markBold(t) { | |
| // Escape HTML first, then apply bold markdown | |
| return esc(t).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>"); | |
| } | |
| function renderTable(data) { | |
| if (!data) return ""; | |
| if (Array.isArray(data) && data.length) { | |
| const keys = Object.keys(data[0]); | |
| return `<div class="tbl-wrap"><table class="tbl"> | |
| <thead><tr>${keys.map(k=>`<th>${k}</th>`).join("")}</tr></thead> | |
| <tbody>${data.map(r=>`<tr>${keys.map(k=>`<td>${r[k]||"—"}</td>`).join("")}</tr>`).join("")}</tbody> | |
| </table></div>`; | |
| } | |
| if (typeof data === "object") { | |
| return `<div class="tbl-wrap"><table class="tbl"><tbody> | |
| ${Object.entries(data).map(([k,v])=>`<tr><th>${k}</th><td>${v||"—"}</td></tr>`).join("")} | |
| </tbody></table></div>`; | |
| } | |
| return ""; | |
| } | |
| // ── Safety panel ─────────────────────────────────────────────────────────── | |
| function showSafetyPanel(report) { | |
| const panel = document.getElementById("safety-panel"); | |
| const body = document.getElementById("safety-body"); | |
| if (report.error) { | |
| body.innerHTML = `<p class="safety-note">${report.error}</p>`; | |
| panel.className = "safety-panel review"; | |
| document.getElementById("safety-status-icon").textContent = "—"; | |
| document.getElementById("safety-status-label").textContent = "Not available"; | |
| panel.style.display = "block"; | |
| return; | |
| } | |
| const statusMap = { | |
| SAFE: { cls: "safe", label: "All Clear" }, | |
| REVIEW_NEEDED: { cls: "review", label: "Review Needed" }, | |
| URGENT: { cls: "danger", label: "Urgent — Action Required" }, | |
| }; | |
| const s = statusMap[report.overall_safety] || statusMap.REVIEW_NEEDED; | |
| const alertsHtml = (report.alerts || []).map(a => { | |
| const cls = a.level === "DANGER" ? "alert-danger" | |
| : a.level === "WARNING" ? "alert-warn" : "alert-info"; | |
| const tag = a.level === "DANGER" ? "DANGER" | |
| : a.level === "WARNING" ? "WARNING" : "INFO"; | |
| return `<div class="safety-alert ${cls}"> | |
| <div class="alert-title"><span class="alert-tag">${tag}</span> ${a.title}</div> | |
| <div class="alert-detail">${a.detail}</div> | |
| <div class="alert-rec"><strong>Action:</strong> ${a.recommendation}</div> | |
| </div>`; | |
| }).join(""); | |
| const medsHtml = report.medications?.length | |
| ? `<details class="extracted-details"><summary>${report.medications.length} Medication(s) extracted</summary> | |
| ${renderTable(report.medications)}</details>` : ""; | |
| const allergyHtml = report.allergies?.length | |
| ? `<details class="extracted-details"><summary>${report.allergies.length} Allergy/Allergies extracted</summary> | |
| ${renderTable(report.allergies)}</details>` : ""; | |
| body.innerHTML = alertsHtml + medsHtml + allergyHtml | |
| || `<p class="no-alerts">No safety concerns found.</p>`; | |
| panel.className = `safety-panel ${s.cls}`; | |
| document.getElementById("safety-status-icon").textContent = ""; | |
| document.getElementById("safety-status-label").textContent = s.label; | |
| panel.style.display = "block"; | |
| panel.scrollIntoView({ behavior: "smooth", block: "nearest" }); | |
| } | |
| // ── Agent trace ──────────────────────────────────────────────────────────── | |
| function clearTrace() { | |
| document.getElementById("trace-steps").innerHTML = ""; | |
| document.getElementById("trace-url-link").style.display = "none"; | |
| document.getElementById("excerpts-list").innerHTML = ""; | |
| } | |
| function addTraceStep(step) { | |
| const ol = document.getElementById("trace-steps"); | |
| const li = document.createElement("li"); | |
| li.className = "trace-step entering"; | |
| li.innerHTML = ` | |
| <div class="step-body"> | |
| <span class="step-name">${step.step}</span> | |
| <span class="step-detail">${markBold(step.detail)}</span> | |
| ${step.sub_queries ? `<ul class="sub-qs">${step.sub_queries.map(q=>`<li>"${esc(q)}"</li>`).join("")}</ul>` : ""} | |
| ${step.sources ? `<div class="step-srcs">${step.sources.map(s=>`<span class="src-chip">${esc(s)}</span>`).join("")}</div>` : ""} | |
| <span class="step-ms">${step.ms}ms</span> | |
| </div>`; | |
| ol.appendChild(li); | |
| requestAnimationFrame(() => li.classList.remove("entering")); | |
| } | |
| function setExcerpts(chunks) { | |
| document.getElementById("excerpts-list").innerHTML = (chunks || []).map(c => | |
| `<div class="excerpt-card"> | |
| <div class="excerpt-src">${c.source}</div> | |
| <div class="excerpt-txt">${c.excerpt}...</div> | |
| </div>`).join(""); | |
| } | |
| // ── Doc loading ──────────────────────────────────────────────────────────── | |
| async function runSafetyCheck() { | |
| document.getElementById("safety-panel").style.display = "none"; | |
| document.getElementById("safety-loading").style.display = "flex"; | |
| try { | |
| const c = await gc(); | |
| const res = await c.predict("/safety_check", {}); | |
| const report = JSON.parse(res.data[0]); | |
| showSafetyPanel(report); | |
| } catch(e) { | |
| showSafetyPanel({ error: "Safety check unavailable: " + e.message }); | |
| } finally { | |
| document.getElementById("safety-loading").style.display = "none"; | |
| } | |
| } | |
| function onDocsLoaded(names) { | |
| docsReady = true; | |
| document.getElementById("ask-input").disabled = false; | |
| document.getElementById("ask-btn").disabled = false; | |
| document.querySelectorAll(".tpl-btn").forEach(b => b.disabled = false); | |
| document.getElementById("docs-ready").style.display = "flex"; | |
| document.getElementById("doc-names").textContent = names.join(" · "); | |
| runSafetyCheck(); | |
| } | |
| document.getElementById("load-samples").addEventListener("click", async () => { | |
| setStatus("Loading demo documents...", "info"); | |
| try { | |
| const c = await gc(); | |
| const res = await c.predict("/load_samples", {}); | |
| const d = JSON.parse(res.data[0]); | |
| if (d.status === "ok") { | |
| setStatus(d.files + " document(s) loaded — " + d.chunks + " chunks ready", "success"); | |
| onDocsLoaded(d.names || ["demo docs"]); | |
| } else setStatus("Error: " + d.message, "error"); | |
| } catch(e) { setStatus("Error: " + e.message, "error"); } | |
| }); | |
| const dropzone = document.getElementById("dropzone"); | |
| const fileInput = document.getElementById("file-input"); | |
| dropzone.addEventListener("click", () => fileInput.click()); | |
| dropzone.addEventListener("dragover", e => { e.preventDefault(); dropzone.classList.add("drag"); }); | |
| dropzone.addEventListener("dragleave", ()=> dropzone.classList.remove("drag")); | |
| dropzone.addEventListener("drop", e => { e.preventDefault(); dropzone.classList.remove("drag"); handleFiles(e.dataTransfer.files); }); | |
| fileInput.addEventListener("change", () => handleFiles(fileInput.files)); | |
| async function handleFiles(files) { | |
| if (!files.length) return; | |
| setStatus("Indexing " + files.length + " file(s)...", "info"); | |
| const items = []; | |
| for (const f of files) { | |
| const buf = await f.arrayBuffer(); | |
| const b64 = btoa(String.fromCharCode(...new Uint8Array(buf))); | |
| items.push({ name: f.name, content_b64: b64 }); | |
| } | |
| try { | |
| const c = await gc(); | |
| const res = await c.predict("/ingest", { files_json: JSON.stringify(items) }); | |
| const d = JSON.parse(res.data[0]); | |
| if (d.status === "ok") { | |
| setStatus(d.files + " file(s) indexed — " + d.chunks + " chunks", "success"); | |
| onDocsLoaded(d.names || items.map(i => i.name)); | |
| } else setStatus("Error: " + (d.message || "Unknown error"), "error"); | |
| } catch(e) { setStatus("Error: " + e.message, "error"); } | |
| } | |
| // ── Ask ──────────────────────────────────────────────────────────────────── | |
| async function ask(question) { | |
| if (!question.trim() || !docsReady) return; | |
| const inp = document.getElementById("ask-input"); | |
| inp.value = ""; inp.disabled = true; | |
| document.getElementById("ask-btn").disabled = true; | |
| clearTrace(); | |
| appendBubble("user", question.replace(/</g,"<")); | |
| const thinkId = "think-" + Date.now(); | |
| appendBubble("bot", '<span class="dots"><span></span><span></span><span></span></span>', thinkId); | |
| let finalAnswer = "", structuredData = null, chunks = []; | |
| try { | |
| const c = await gc(); | |
| for await (const msg of c.submit("/ask_stream", { question })) { | |
| if (!msg.data) continue; | |
| const ev = JSON.parse(msg.data[0]); | |
| if (ev.type === "trace_step") addTraceStep(ev.step); | |
| else if (ev.type === "answer") { finalAnswer = ev.answer; structuredData = ev.structured_data; chunks = ev.chunks || []; } | |
| else if (ev.type === "done" && ev.trace_url) { | |
| const lnk = document.getElementById("trace-url-link"); | |
| lnk.href = ev.trace_url; lnk.style.display = "inline-flex"; | |
| } | |
| else if (ev.type === "error") finalAnswer = ev.message; | |
| } | |
| } catch(_) { | |
| try { | |
| const c2 = await gc(); | |
| const res = await c2.predict("/ask", { question }); | |
| const d = JSON.parse(res.data[0]); | |
| if (d.error) { finalAnswer = d.error; } | |
| else { finalAnswer = d.answer || ""; structuredData = d.structured_data; chunks = d.chunks || []; (d.trace||[]).forEach(addTraceStep); } | |
| } catch(e2) { finalAnswer = e2.message; } | |
| } | |
| const wrap = document.getElementById(thinkId); | |
| if (wrap) { | |
| const b = wrap.querySelector(".bubble"); | |
| const tbl = structuredData ? renderTable(structuredData) : ""; | |
| // If structured data rendered a table, suppress the raw JSON text answer | |
| const displayText = (tbl && structuredData) ? "" : finalAnswer; | |
| b.innerHTML = `<div class="answer-txt"></div>${tbl}`; | |
| if (displayText) await typewrite(b.querySelector(".answer-txt"), displayText); | |
| } | |
| setExcerpts(chunks); | |
| inp.disabled = false; | |
| document.getElementById("ask-btn").disabled = false; | |
| inp.focus(); | |
| } | |
| document.getElementById("ask-btn").addEventListener("click", | |
| () => ask(document.getElementById("ask-input").value.trim())); | |
| document.getElementById("ask-input").addEventListener("keydown", e => { | |
| if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(e.target.value.trim()); } | |
| }); | |
| document.querySelectorAll(".tpl-btn").forEach(btn => | |
| btn.addEventListener("click", () => { document.getElementById("ask-input").value = btn.dataset.q; ask(btn.dataset.q); })); | |
| document.getElementById("clear-btn").addEventListener("click", () => { | |
| document.getElementById("chat").innerHTML = | |
| `<div class="welcome"><div class="welcome-logo">🩺</div> | |
| <p>Load patient documents, then ask any question.<br/> | |
| <em>Try: "Do any medications interact with the patient's allergies?"</em></p></div>`; | |
| clearTrace(); | |
| }); | |
| document.getElementById("theme-btn").addEventListener("click", () => { | |
| document.documentElement.classList.toggle("dark"); | |
| document.getElementById("theme-btn").textContent = | |
| document.documentElement.classList.contains("dark") ? "Light" : "Dark"; | |
| }); | |
| </script> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="hdr"> | |
| <div class="logo"> | |
| <span class="logo-icon">🩺</span> | |
| <div> | |
| <h1>ClinIQ</h1> | |
| <p class="tagline">On-device clinical document assistant · Qwen2.5-3B · llama.cpp · Modal</p> | |
| </div> | |
| </div> | |
| <div class="hdr-right"> | |
| <div class="badges"> | |
| <span class="badge b-purple">Tiny Titan 3B</span> | |
| <span class="badge b-teal">llama.cpp</span> | |
| <span class="badge b-blue">Modal A10G</span> | |
| <span class="badge b-gold">Build Small 2026</span> | |
| </div> | |
| <button id="theme-btn" class="icon-btn">Dark</button> | |
| </div> | |
| </div> | |
| </header> | |
| <div class="privacy-bar"> | |
| Privacy guarantee: Documents are indexed locally. Only relevant excerpts — never full records — reach the GPU. | |
| </div> | |
| <main> | |
| <aside class="sidebar"> | |
| <div class="card"> | |
| <h2>Documents</h2> | |
| <div id="dropzone" class="dropzone"> | |
| <span class="drop-icon">📄</span> | |
| <span class="drop-txt">Drop PDFs, TXT, or Images here<br/><small>or click to browse</small></span> | |
| <input type="file" id="file-input" multiple accept=".pdf,.txt,.md,.jpg,.jpeg,.png,.tiff,.tif,.bmp,.webp" hidden/> | |
| </div> | |
| <button id="load-samples" class="btn secondary full">Load Demo Patient Docs</button> | |
| <div id="status" class="status info">Upload documents or load demo docs to begin.</div> | |
| <div id="docs-ready" class="docs-ok" style="display:none"> | |
| <span class="pulse-dot"></span> | |
| <span id="doc-names" class="doc-names-txt">Documents loaded</span> | |
| </div> | |
| </div> | |
| <div class="card safety-card-wrap"> | |
| <div class="safety-card-header"> | |
| <h2>Drug Safety Check</h2> | |
| <span class="safety-auto-badge">Auto on upload</span> | |
| </div> | |
| <p class="card-hint">Proactive drug-allergy and interaction alerts — no question needed.</p> | |
| <div id="safety-loading" class="safety-loading" style="display:none"> | |
| <div class="spinner"></div> | |
| <span>Analysing medications and allergies...</span> | |
| </div> | |
| <div id="safety-panel" class="safety-panel" style="display:none"> | |
| <div class="safety-status-bar"> | |
| <span id="safety-status-icon"></span> | |
| <span id="safety-status-label">All Clear</span> | |
| </div> | |
| <div id="safety-body"></div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2>Smart Templates</h2> | |
| <div class="templates"> | |
| <button class="tpl-btn" disabled data-q="List all medications with doses and frequency">Medication List</button> | |
| <button class="tpl-btn" disabled data-q="List all allergies and reactions">Allergy Summary</button> | |
| <button class="tpl-btn" disabled data-q="What are all the diagnoses?">Diagnoses</button> | |
| <button class="tpl-btn" disabled data-q="What follow-up appointments are scheduled?">Follow-up Plan</button> | |
| <button class="tpl-btn" disabled data-q="Do any medications interact with the patient's allergies or other medications?">Drug Interactions</button> | |
| <button class="tpl-btn" disabled data-q="Compare the medications listed in each document — did anything change?">Compare Across Visits</button> | |
| </div> | |
| </div> | |
| </aside> | |
| <div class="right-col"> | |
| <div class="card chat-card"> | |
| <div class="chat-hdr"> | |
| <h2>Clinical Q&A</h2> | |
| <button id="clear-btn" class="icon-btn sm">Clear</button> | |
| </div> | |
| <div id="chat" class="chat"> | |
| <div class="welcome"> | |
| <div class="welcome-logo">🩺</div> | |
| <p>Load patient documents, then ask any question.<br/> | |
| <em>Try: "Do any medications interact with the patient's allergies?"</em></p> | |
| </div> | |
| </div> | |
| <div class="input-row"> | |
| <textarea id="ask-input" rows="2" disabled | |
| placeholder="Ask a clinical question... (Enter to send)"></textarea> | |
| <button id="ask-btn" class="btn primary ask-btn" disabled>Ask</button> | |
| </div> | |
| </div> | |
| <div class="card trace-card"> | |
| <div class="trace-hdr"> | |
| <h2>Agent Trace</h2> | |
| <a id="trace-url-link" href="#" target="_blank" class="share-btn" style="display:none">View on HF Hub</a> | |
| </div> | |
| <ol id="trace-steps" class="trace-steps"></ol> | |
| <h3>Retrieved Excerpts</h3> | |
| <div id="excerpts-list"></div> | |
| </div> | |
| </div> | |
| </main> | |
| <footer> | |
| Built for <strong>Gradio Build Small Hackathon 2026</strong> by | |
| <a href="https://huggingface.co/karthikmulugu08" target="_blank">Karthik Mulugu</a> · | |
| <a href="https://huggingface.co/Qwen/Qwen2.5-3B-Instruct" target="_blank">Qwen2.5-3B-Instruct</a> | |
| via <a href="https://modal.com" target="_blank">Modal</a> (llama.cpp Q4_K_M) · | |
| <a href="https://huggingface.co/datasets/karthikmulugu08/cliniq-traces" target="_blank">Trace Dataset</a> | |
| </footer> | |
| </body> | |
| </html> | |