clinical-intelligence-rag / src /streamlit_app.py
Samarth-Health-AI's picture
Upload streamlit_app.py
bb806ac verified
Raw
History Blame Contribute Delete
25.4 kB
import streamlit as st
import os, shutil, re
from rag_pipeline import *
import streamlit.components.v1 as components
from audiorecorder import audiorecorder # NEW
from audio_recorder_streamlit import audio_recorder
from pydub import AudioSegment
AudioSegment.ffmpeg = shutil.which("ffmpeg") or "ffmpeg"
AudioSegment.ffprobe = shutil.which("ffprobe") or "ffprobe"
# --- 0. PAGE CONFIG ---
st.set_page_config(
page_title="Clinical Intelligence Portal",
layout="wide",
initial_sidebar_state="expanded"
)
# Accept either a Groq key (preferred free provider) or an OpenRouter key.
# Having any one of these set enables the app; the LLM client (get_openrouter_llm)
# picks Groq automatically when GROQ_API_KEY is present.
API_KEY = os.environ.get("GROQ_API_KEY") or os.environ.get("OPENROUTER_API_KEY")
if not API_KEY:
try:
API_KEY = st.secrets["OPENROUTER_API_KEY"]
except Exception:
API_KEY = None # no secret configured; user can enter the key in the sidebar
# --- 1. JAVASCRIPT ERROR SUPPRESSOR (audio widget noise) ---
components.html(
"""
<script>
const hideAudioErrors = () => {
const selectors = [
'[data-testid="stNotification"]',
'[role="alert"]',
'[data-baseweb="notification"]',
'.stNotification',
'div[class*="Notification"]',
'div[class*="Toast"]'
];
selectors.forEach(selector => {
document.querySelectorAll(selector).forEach(el => {
const text = el.textContent || el.innerText || '';
if (text.includes('error has occurred') || text.includes('please try again') || text.includes('An error')) {
el.style.display = 'none';
setTimeout(() => el.remove(), 50);
}
});
});
};
hideAudioErrors();
setInterval(hideAudioErrors, 50);
const observer = new MutationObserver(hideAudioErrors);
observer.observe(document.body, { childList: true, subtree: true });
window.addEventListener('message', hideAudioErrors);
</script>
""",
height=0,
)
# --- 2. THEME-ADAPTIVE CSS (follows Chrome light/dark automatically) ---
st.markdown(
"""
<style>
:root {
--bg: #f4f6f9;
--ink: #1f2933;
--ink-soft: #5b6b7b;
--line: #e3e8ef;
--card: #ffffff;
--surface: #f7f9fc;
--brand: #1f6feb;
--brand-deep: #13447a;
--teal: #0f9d8f;
--amber: #c47f12;
--dx: #185fa5;
--dx-bg: #e6f1fb;
--px: #0f6e56;
--px-bg: #e1f5ee;
--shadow: 0 1px 3px rgba(16,24,40,.08), 0 1px 2px rgba(16,24,40,.05);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0e141b;
--ink: #e6edf3;
--ink-soft: #9aa7b5;
--line: #283341;
--card: #161d26;
--surface: #1d2630;
--dx-bg: rgba(31,111,235,.18);
--px-bg: rgba(15,157,143,.18);
--shadow: 0 1px 3px rgba(0,0,0,.5), 0 1px 2px rgba(0,0,0,.4);
}
}
.stApp { background: var(--bg); color: var(--ink); font-family: 'Inter','Segoe UI',sans-serif; }
/* keep the audio widget's transient errors hidden */
[data-testid="stAudioInput"] [data-testid="stNotification"],
[data-testid="stAudioInput"] [role="alert"],
[data-testid="stAudioInput"] div[class*="error"],
[data-testid="stAudioInput"] div[class*="Error"],
[data-testid="stAudioInput"] div[data-baseweb="notification"],
[data-testid="stAudioInput"] .stNotification,
div[data-baseweb="toast"],
div[class*="stToast"] {
display: none !important; visibility: hidden !important; height: 0 !important;
overflow: hidden !important; position: absolute !important; left: -9999px !important;
}
/* Sidebar: dark clinical gradient (works in both themes) */
[data-testid="stSidebar"] {
background: linear-gradient(180deg, #13447a 0%, #1c5aa8 100%);
min-width: 360px; max-width: 360px; padding: 22px 18px;
}
[data-testid="stSidebar"] * { color: #ffffff; }
.sb-card {
background: rgba(255,255,255,0.10); border: 1px solid rgba(255,255,255,0.16);
border-radius: 10px; padding: 14px; margin-bottom: 14px;
}
.sb-title { font-size: .82rem; font-weight: 700; letter-spacing:.4px; text-transform: uppercase;
display:flex; align-items:center; gap:8px; margin-bottom: 10px; color:#fff; }
.sb-num { width:20px; height:20px; border-radius:6px; background:rgba(255,255,255,.22);
display:inline-grid; place-items:center; font-size:.72rem; font-weight:800; }
.sb-note { font-size:.74rem; opacity:.85; }
/* file uploader / inputs stay readable (white box) */
[data-testid="stSidebar"] [data-testid="stFileUploaderDropzone"] {
background:#fff !important; border-radius:10px !important; border:1px solid #cbd5e0 !important;
}
[data-testid="stSidebar"] [data-testid="stFileUploaderDropzone"] * { color:#111827 !important; }
/* "Browse files" button: force a high-contrast style in EVERY theme
(otherwise it renders dark-on-dark and disappears in dark mode) */
[data-testid="stSidebar"] [data-testid="stFileUploaderDropzone"] button {
background:#1c5aa8 !important; color:#ffffff !important;
border:1px solid #1c5aa8 !important; font-weight:600 !important;
opacity:1 !important;
}
[data-testid="stSidebar"] [data-testid="stFileUploaderDropzone"] button:hover {
background:#13447a !important; border-color:#13447a !important;
}
[data-testid="stSidebar"] input, [data-testid="stSidebar"] textarea { color:#1f2933 !important; }
[data-testid="stSidebar"] .stCheckbox label { color:#ffffff !important; }
/* Banner */
.banner-container {
background: linear-gradient(120deg, #13447a 0%, #1f6feb 100%);
border-radius: 14px; padding: 26px 30px; margin-bottom: 22px; box-shadow: var(--shadow);
}
.banner-container h1 { color:#fff; font-size:1.6rem; font-weight:700; margin:0; }
.banner-container p { color:#e8f0fe; font-size:.92rem; margin:4px 0 0; }
.banner-warn { color:#ffd86b !important; font-size:.8rem !important; margin-top:8px !important; }
/* Cards */
.clinical-card {
background: var(--card); border:1px solid var(--line); border-radius:12px;
padding:18px 20px; box-shadow:var(--shadow); margin-bottom:16px;
}
.section-title { color:var(--ink); font-weight:700; font-size:1.05rem; margin:0 0 4px; }
/* Vitals strip */
.vitals { display:flex; flex-wrap:wrap; gap:0; align-items:center;
background:var(--card); border:1px solid var(--line); border-radius:12px;
padding:10px 6px; margin-bottom:16px; box-shadow:var(--shadow); }
.vital { display:flex; flex-direction:column; padding:2px 16px; border-left:1px solid var(--line); }
.vital:first-child { border-left:none; }
.vital .lbl { font-size:.62rem; letter-spacing:.5px; text-transform:uppercase; color:var(--ink-soft); }
.vital .val { font-size:.95rem; font-weight:700; color:var(--ink); }
/* SOAP cards */
.soap-card { background:var(--card); border:1px solid var(--line); border-radius:12px;
box-shadow:var(--shadow); overflow:hidden; margin-bottom:14px; }
.soap-head { display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--line); }
.soap-tag { width:26px; height:26px; border-radius:7px; display:grid; place-items:center;
color:#fff; font-weight:800; font-size:.85rem; flex:none; }
.soap-ttl { font-weight:700; font-size:.8rem; letter-spacing:.6px; text-transform:uppercase; color:var(--ink); }
.soap-ttl small { display:block; font-weight:500; text-transform:none; letter-spacing:0; color:var(--ink-soft); font-size:.72rem; }
.soap-list { margin:0; padding:12px 16px 14px 30px; }
.soap-list li { margin-bottom:7px; font-size:.9rem; color:var(--ink); line-height:1.5; }
.soap-empty { color:var(--ink-soft); font-style:italic; padding:12px 16px; font-size:.86rem; margin:0; }
/* Coding / superbill */
.grouphdr { display:flex; align-items:center; gap:8px; font-size:.74rem; font-weight:700;
letter-spacing:.6px; text-transform:uppercase; color:var(--ink-soft); margin:6px 0 6px; }
.grouphdr .dot { width:9px; height:9px; border-radius:50%; }
.code-row { display:flex; align-items:flex-start; gap:12px; padding:10px 0; border-top:1px solid var(--line); }
.code-finding { flex:1; font-size:.9rem; font-weight:600; color:var(--ink); }
.code-finding small { display:block; font-weight:400; color:var(--ink-soft); font-size:.76rem; margin-top:2px; }
.code-pill { font-family:Consolas,monospace; font-weight:700; font-size:.82rem; padding:4px 10px; border-radius:7px; white-space:nowrap; }
.code-pill.dx { background:var(--dx-bg); color:var(--dx); }
.code-pill.px { background:var(--px-bg); color:var(--px); }
.code-alt { font-size:.74rem; color:var(--ink-soft); margin-top:3px; }
.code-none { font-size:.74rem; color:var(--amber); font-weight:600; }
.srcbadge { font-size:.66rem; font-weight:700; padding:3px 9px; border-radius:999px;
background:var(--surface); color:var(--ink-soft); border:1px solid var(--line); }
/* Quality metrics */
.metric-card { padding:14px; border-radius:10px; font-weight:600; text-align:center;
margin-bottom:6px; color:white; font-size:1rem; }
.metric-good { background: var(--teal); }
.metric-bad { background: #c0392b; }
.metric-reason { color:var(--ink-soft); font-size:.84rem; line-height:1.5; margin-bottom:14px;
padding:10px; background:var(--surface); border-radius:6px; border:1px solid var(--line); }
/* Disclaimer */
.disc { font-size:.76rem; color:#7a5b00; background:#fff8ec; border:1px solid #f1e2c4;
border-radius:8px; padding:10px 14px; margin-top:6px; }
.empty-state { text-align:center; padding:60px 40px; background:var(--card);
border:2px dashed var(--line); border-radius:14px; margin-top:20px; box-shadow:var(--shadow); }
.empty-state h3 { color:var(--ink); font-size:1.6rem; margin-bottom:10px; }
.empty-state p { color:var(--ink-soft); font-size:1rem; }
.step-grid { display:flex; flex-wrap:wrap; gap:12px; justify-content:center; margin-top:18px; }
.step { background:var(--surface); border:1px solid var(--line); border-radius:10px;
padding:16px 14px; width:150px; text-align:center; }
.step .circle { width:36px; height:36px; border-radius:50%; background:var(--brand); color:#fff;
font-weight:800; display:grid; place-items:center; margin:0 auto 8px; }
.step h4 { margin:0 0 4px; font-size:.9rem; color:var(--ink); }
.step p { margin:0; font-size:.76rem; color:var(--ink-soft); line-height:1.4; }
</style>
""",
unsafe_allow_html=True,
)
# --- Helper: render SOAP content as a clean bullet list (fixes alignment) ---
def bullets_to_html(content: str) -> str:
if not content:
return '<p class="soap-empty">Not documented in transcript.</p>'
lines = [l.strip() for l in content.splitlines() if l.strip()]
items = []
for l in lines:
l = re.sub(r'^[\-\*β€’β€’]\s*', '', l)
l = re.sub(r'^\d+[\.\)]\s*', '', l)
if l:
items.append(l)
if not items:
return '<p class="soap-empty">Not documented in transcript.</p>'
if len(items) == 1 and "not documented" in items[0].lower():
return f'<p class="soap-empty">{items[0]}</p>'
return '<ul class="soap-list">' + "".join(f"<li>{i}</li>" for i in items) + "</ul>"
# --- 3. HEADER BANNER ---
st.markdown(
"""
<div class="banner-container">
<h1>πŸ₯ Clinical Intelligence Portal</h1>
<p>AI-assisted SOAP note generation, coding suggestions &amp; quality assurance</p>
<p class="banner-warn">⚠️ Cost-efficient AI prototype backed by a strict Human-in-the-Loop (HITL) clinical validation framework.</p>
</div>
""",
unsafe_allow_html=True,
)
# --- 4. SIDEBAR WORKFLOW ---
with st.sidebar:
st.markdown(
'<h2 style="color:#fff; font-size:1.4rem; text-align:center; margin-bottom:14px;">Workflow</h2>',
unsafe_allow_html=True,
)
# 1 β€” Access
st.markdown('<div class="sb-card">', unsafe_allow_html=True)
st.markdown('<div class="sb-title"><span class="sb-num">1</span>πŸ” Access</div>', unsafe_allow_html=True)
if API_KEY:
st.success("βœ“ API key auto detected (prototype)")
else:
API_KEY = st.text_input("Enter API Key", type="password", help="OpenRouter or similar API key")
st.markdown('</div>', unsafe_allow_html=True)
# 2 β€” Voice
st.markdown('<div class="sb-card">', unsafe_allow_html=True)
st.markdown('<div class="sb-title"><span class="sb-num">2</span>🎀 Record voice <span style="margin-left:auto;font-weight:400;text-transform:none;opacity:.7">optional</span></div>', unsafe_allow_html=True)
audio_bytes = audio_recorder(
text="",
recording_color="#e8453c",
neutral_color="#6aa36f",
icon_name="microphone",
icon_size="3x",
energy_threshold=(-1.0, 1.0),
pause_threshold=300.0,
key="voice_recorder_main",
)
audio_data = None
if audio_bytes:
audio_data = audio_bytes
st.audio(audio_data, format="audio/wav")
if API_KEY:
status_placeholder = st.empty()
if st.button("πŸ“ Transcribe Voice", use_container_width=True, key="btn_transcribe_voice"):
if audio_data is None:
status_placeholder.warning("⚠️ Record audio first.")
else:
with st.spinner("πŸ”„ Transcribing..."):
try:
st.session_state.voice_transcript = transcribe_and_tag(audio_data, API_KEY)
status_placeholder.success("βœ“ Done!")
except Exception as e:
status_placeholder.error(f"❌ {str(e)}")
if "voice_transcript" in st.session_state:
st.text_area("πŸ“‹ Preview", st.session_state.voice_transcript, height=100, disabled=True)
st.download_button("πŸ’Ύ Download", st.session_state.voice_transcript, "transcript.txt", use_container_width=True)
st.checkbox("βœ“ Use Voice for Analysis", value=True, key="use_voice")
st.markdown('</div>', unsafe_allow_html=True)
# 3 β€” Documents
st.markdown('<div class="sb-card">', unsafe_allow_html=True)
st.markdown('<div class="sb-title"><span class="sb-num">3</span>πŸ“‚ Add records</div>', unsafe_allow_html=True)
st.markdown('<div class="sb-note" style="margin-bottom:8px;">Meeting transcripts / clinical documents / any supporting artifact (PDF, DOCX, TXT)</div>', unsafe_allow_html=True)
files = st.file_uploader(
"Upload records",
accept_multiple_files=True,
type=["pdf", "docx", "txt"],
label_visibility="collapsed",
)
sample_path = "src/sample_transcript.pdf"
if os.path.exists(sample_path):
with open(sample_path, "rb") as f:
st.download_button("πŸ“₯ Download Sample Transcript", f, "sample_template.pdf", use_container_width=True)
st.checkbox("πŸ“„ Use built-in sample transcript", value=False, key="use_sample")
st.markdown('</div>', unsafe_allow_html=True)
# 4 β€” Generate
st.markdown('<div class="sb-card">', unsafe_allow_html=True)
st.markdown('<div class="sb-title"><span class="sb-num">4</span>πŸš€ Generate</div>', unsafe_allow_html=True)
if st.button("🩺 Generate SOAP Note", use_container_width=True, type="primary",
help="Combines voice + documents for full analysis", key="btn_generate_soap") and API_KEY:
status_label = st.empty()
progress_bar = st.progress(0)
paths = [sample_path] if st.session_state.get("use_sample") else []
if files:
for f in files:
fp = f"/tmp/{f.name}"
with open(fp, "wb") as b:
b.write(f.getbuffer())
paths.append(fp)
if st.session_state.get("use_voice") and "voice_transcript" in st.session_state:
from langchain_core.documents import Document
docs = process_docs_from_list(paths)
docs.append(Document(page_content=st.session_state.voice_transcript, metadata={"source": "Voice Transcript"}))
else:
docs = process_docs_from_list(paths)
if docs:
status_label.info("πŸ“„ Indexing...")
progress_bar.progress(30)
vdb = build_semantic_index(docs)
status_label.info("✍️ Generating SOAP...")
progress_bar.progress(55)
st.session_state.raw_text = "\n".join([d.page_content for d in docs])
st.session_state.soap = generate_rag_soap(vdb, API_KEY, docs)
# Capture vitals from BOTH the raw transcript and the generated
# SOAP note, so every available vital (BP, HR, Temp, SpO2, RR) is
# picked up even if it only appears in the structured note.
st.session_state.vitals = extract_vitals(
f"{st.session_state.raw_text}\n{st.session_state.soap}"
)
status_label.info("🧾 Suggesting codes...")
progress_bar.progress(75)
st.session_state.codes = generate_codes(st.session_state.soap, API_KEY)
status_label.info("βœ… Auditing...")
progress_bar.progress(92)
st.session_state.eval = evaluate_clinical_output(
st.session_state.raw_text, str(st.session_state.soap), API_KEY)
progress_bar.progress(100)
st.success("βœ“ Complete!")
else:
st.warning("Upload docs or record voice first.")
st.markdown('</div>', unsafe_allow_html=True)
# --- 5. MAIN WORKSPACE ---
SOAP_META = {
"SUBJECTIVE": ("S", "#1f6feb", "What the patient reports"),
"OBJECTIVE": ("O", "#0f9d8f", "Exam & measured findings"),
"ASSESSMENT": ("A", "#c47f12", "Clinical impression"),
"PLAN": ("P", "#13447a", "Next steps & orders"),
}
if "soap" in st.session_state:
# Key Vitals strip (best-effort). Shown whenever at least one vital is found.
vitals = st.session_state.get("vitals") or {}
if vitals:
st.markdown('<div class="section-title" style="margin-bottom:8px;">🩺 Key Vitals</div>',
unsafe_allow_html=True)
chips = "".join(
f'<div class="vital"><span class="lbl">{k}</span><span class="val">{v}</span></div>'
for k, v in vitals.items()
)
st.markdown(f'<div class="vitals">{chips}</div>', unsafe_allow_html=True)
col_l, col_r = st.columns([1.7, 1.3])
with col_l:
st.markdown('<div class="section-title">🩺 Clinical SOAP Note</div>', unsafe_allow_html=True)
items = list(st.session_state.soap.items())
rows = [st.columns(2), st.columns(2)]
cells = [rows[0][0], rows[0][1], rows[1][0], rows[1][1]]
for cell, (sec, data) in zip(cells, items):
letter, color, sub = SOAP_META.get(sec, (sec[:1], "#1f6feb", ""))
body = bullets_to_html(data.get("content", ""))
with cell:
st.markdown(
f'<div class="soap-card">'
f'<div class="soap-head"><div class="soap-tag" style="background:{color}">{letter}</div>'
f'<div class="soap-ttl">{sec.title()}<small>{sub}</small></div></div>'
f'{body}</div>',
unsafe_allow_html=True,
)
with st.expander(f"πŸ” Evidence β€” {sec.title()}"):
if data.get("evidence"):
for i, snippet in enumerate(data["evidence"]):
redacted = redact_phi(snippet)[:300]
st.markdown(
f'<div style="background:var(--surface); padding:10px; border-radius:6px; '
f'border-left:3px solid var(--brand); margin-bottom:8px; font-size:.82rem; color:var(--ink-soft);">'
f'<b style="color:var(--brand);">Source {i+1}</b><br>{redacted}{"..." if len(snippet) > 300 else ""}</div>',
unsafe_allow_html=True,
)
else:
st.info("No direct transcript evidence found.")
# --- Coding / Superbill panel ---
codes = st.session_state.get("codes") or {"diagnoses": [], "procedures": []}
st.markdown('<div class="clinical-card">', unsafe_allow_html=True)
st.markdown(
'<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;">'
'<div class="section-title" style="margin:0;">🧾 Coding Suggestions β€” Superbill</div>'
'<div><span class="srcbadge">ICD-10-CM Β· NLM</span> <span class="srcbadge">HCPCS Β· CMS</span></div></div>',
unsafe_allow_html=True,
)
def render_code_group(entries, kind):
cls = "dx" if kind == "dx" else "px"
dot = "#185fa5" if kind == "dx" else "#0f6e56"
label = "Diagnoses (from Assessment)" if kind == "dx" else "Procedures & services (from Plan)"
html = f'<div class="grouphdr"><span class="dot" style="background:{dot}"></span>{label}</div>'
if not entries:
html += '<div class="code-alt" style="padding:6px 0;">No items extracted.</div>'
for e in entries:
cands = e.get("candidates") or []
if cands:
top = cands[0]
alts = ", ".join(f"{c['code']}" for c in cands[1:]) if len(cands) > 1 else ""
alt_html = f'<div class="code-alt">Alternatives: {alts}</div>' if alts else ""
code_html = (f'<div style="text-align:right;"><span class="code-pill {cls}">{top["code"]}</span>'
f'<div class="code-alt">{top["name"]}</div>{alt_html}</div>')
else:
code_html = '<div style="text-align:right;"><span class="code-none">no match β€” code manually</span></div>'
html += (f'<div class="code-row"><div class="code-finding">{e.get("finding","")}</div>{code_html}</div>')
return html
st.markdown(render_code_group(codes.get("diagnoses", []), "dx"), unsafe_allow_html=True)
st.markdown(render_code_group(codes.get("procedures", []), "px"), unsafe_allow_html=True)
st.markdown(
f'<div class="disc">⚠️ AI-suggested codes for clinician review only. Final code selection '
f'and billing responsibility rest with the provider. CPT excluded (AMA license required); '
f'procedures use free HCPCS Level II.</div>',
unsafe_allow_html=True,
)
st.download_button(
"⬇️ Export superbill (CSV)",
build_superbill_csv(codes),
"superbill.csv",
use_container_width=True,
)
st.markdown('</div>', unsafe_allow_html=True)
# --- Exports ---
st.markdown('<div class="section-title">πŸ“₯ Export Clinical Note</div>', unsafe_allow_html=True)
c1, c2 = st.columns(2)
with c1:
st.download_button("⬇️ Download FHIR (JSON)", export_as_fhir(st.session_state.soap),
"clinical_note.json", use_container_width=True, type="primary")
with c2:
st.download_button("⬇️ Download Text", str(st.session_state.soap),
"soap_note.txt", use_container_width=True)
with col_r:
st.markdown('<div class="section-title">πŸ“Š Quality Audit Report</div>', unsafe_allow_html=True)
for metric, details in st.session_state.get("eval", {}).items():
if isinstance(details, dict):
score = details.get("score", 0)
color = "metric-good" if score >= 4 else "metric-bad"
st.markdown(f'<div class="metric-card {color}">{metric}: {score}/5 ⭐</div>', unsafe_allow_html=True)
st.markdown(f'<div class="metric-reason">πŸ’‘ {details.get("reason", "Analysis verified.")}</div>',
unsafe_allow_html=True)
else:
st.markdown(
"""
<div class="empty-state">
<h3>πŸ₯ Ready to assist</h3>
<p>Follow the numbered steps in the sidebar to generate a SOAP note.</p>
<div class="step-grid">
<div class="step"><div class="circle">1</div><h4>πŸ” Sign in</h4><p>API key auto detected (this is a prototype)</p></div>
<div class="step"><div class="circle">2</div><h4>🎀 Record / upload</h4><p>Mic or drag a file</p></div>
<div class="step"><div class="circle">3</div><h4>πŸ“‚ Confirm source</h4><p>File, voice, or sample</p></div>
<div class="step"><div class="circle">4</div><h4>🩺 Generate</h4><p>Note appears here</p></div>
</div>
</div>
""",
unsafe_allow_html=True,
)