medication-safety / streamlit_app.py
Lincoln Gombedza
Initial commit: Medication Safety Checker
0410c26
"""
Medication Safety Checker
Streamlit app β€” Hugging Face Spaces (free CPU tier)
"""
import streamlit as st
from safety.high_alert import (get_all_drugs as ha_all, get_categories as ha_cats,
search_drugs as ha_search, get_by_category, is_high_alert,
HIGH_ALERT_DRUGS)
from safety.lasa import (get_all_pairs, search_lasa, get_critical_pairs,
get_high_risk_pairs, RISK_COLOURS as LASA_RISK_COLOURS,
TYPE_ICONS)
from safety.interactions import (check_all_interactions, check_interactions_local,
SEVERITY_COLOURS, SEVERITY_ICONS)
from safety.dose_checker import (search_drug, check_dose, get_drug_names, DOSE_DATABASE)
# ---------------------------------------------------------------------------
# Page config
# ---------------------------------------------------------------------------
st.set_page_config(
page_title="Medication Safety Checker β€” Student Nurses",
page_icon="πŸ’Š",
layout="wide",
initial_sidebar_state="expanded",
)
# ---------------------------------------------------------------------------
# CSS
# ---------------------------------------------------------------------------
st.markdown("""
<style>
.safety-card {
background:#f8fafc; border:1px solid #d0dae8;
border-radius:10px; padding:1.2rem 1.4rem; margin-bottom:1rem;
}
.high-alert-banner {
background:#fff3e0; border-left:5px solid #e65100;
padding:0.8rem 1.2rem; border-radius:4px; margin-bottom:0.8rem;
}
.critical-banner {
background:#fce4ec; border-left:5px solid #c62828;
padding:0.8rem 1.2rem; border-radius:4px; margin-bottom:0.8rem;
}
.safe-banner {
background:#e8f5e9; border-left:5px solid #2e7d32;
padding:0.8rem 1.2rem; border-radius:4px; margin-bottom:0.8rem;
}
.info-banner {
background:#e3f2fd; border-left:5px solid #1565c0;
padding:0.8rem 1.2rem; border-radius:4px; margin-bottom:0.8rem;
}
.warn-banner {
background:#fff8e1; border-left:5px solid #f9a825;
padding:0.8rem 1.2rem; border-radius:4px; margin-bottom:0.8rem;
}
.risk-critical { color:#c62828; font-weight:800; }
.risk-high { color:#e65100; font-weight:700; }
.risk-moderate { color:#f9a825; font-weight:700; }
.risk-low { color:#2e7d32; font-weight:600; }
.badge-critical { background:#fce4ec; color:#c62828; padding:3px 10px;
border-radius:12px; font-size:0.78em; font-weight:800; }
.badge-high { background:#fff3e0; color:#e65100; padding:3px 10px;
border-radius:12px; font-size:0.78em; font-weight:700; }
.badge-moderate{ background:#fff8e1; color:#e65100; padding:3px 10px;
border-radius:12px; font-size:0.78em; font-weight:600; }
.badge-low { background:#e8f5e9; color:#2e7d32; padding:3px 10px;
border-radius:12px; font-size:0.78em; font-weight:600; }
</style>
""", unsafe_allow_html=True)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def risk_badge(risk: str) -> str:
cls = f"badge-{risk.lower()}"
return f'<span class="{cls}">{risk}</span>'
def severity_badge(sev: str) -> str:
icon = SEVERITY_ICONS.get(sev.lower(), "βšͺ")
cls = f"badge-{sev.lower()}" if sev.lower() in ("critical","high","moderate","low","minor") else "badge-moderate"
return f'{icon} <span class="{cls}">{sev.upper()}</span>'
# ---------------------------------------------------------------------------
# Header
# ---------------------------------------------------------------------------
st.title("πŸ’Š Medication Safety Checker")
st.caption(
"ISMP High-Alert Medications Β· Drug Interactions Β· Safe Dose Ranges Β· "
"LASA Drug Warnings Β· Five Rights Checklist Β· For educational purposes only"
)
# ---------------------------------------------------------------------------
# Sidebar
# ---------------------------------------------------------------------------
with st.sidebar:
st.markdown("## πŸ’Š Medication Safety")
st.divider()
st.markdown("""
**Quick Reference**
- πŸ”΄ CRITICAL β€” extreme caution / specialty setting
- 🟠 HIGH β€” significant error potential
- 🟑 MODERATE β€” important precautions
- 🟒 LOW β€” standard care
**ISMP High-Alert Categories**
- Anticoagulants
- Concentrated Electrolytes
- Insulin (all types)
- Opioids
- Neuromuscular Blocking Agents
- Antineoplastics
""")
st.divider()
st.markdown("""
**⚠️ Disclaimer**
This tool is for **educational purposes only**. Always consult current clinical guidelines, pharmacy resources, and your institution's protocols before administering any medication.
""")
st.divider()
st.markdown(
"Part of the [Nursing Citizen Development](https://huggingface.co/NurseCitizenDeveloper) suite"
)
# ---------------------------------------------------------------------------
# Tabs
# ---------------------------------------------------------------------------
tab_ha, tab_inter, tab_dose, tab_lasa, tab_5rights = st.tabs([
"πŸ”” High-Alert Meds",
"⚑ Drug Interactions",
"πŸ“ Dose Safety Check",
"πŸ”€ LASA Warnings",
"βœ… Five Rights",
])
# ========================= HIGH-ALERT MEDICATIONS ===========================
with tab_ha:
st.subheader("πŸ”” ISMP High-Alert Medication Reference")
st.caption("Based on the Institute for Safe Medication Practices (ISMP) 2023 High-Alert Medications list.")
col_search, col_cat = st.columns([2, 2])
with col_search:
ha_query = st.text_input("πŸ” Search drug name", placeholder="e.g. heparin, insulin, morphine")
with col_cat:
categories = ["All categories"] + ha_cats()
sel_cat = st.selectbox("Filter by category", categories)
st.divider()
# Determine which drugs to show
if ha_query.strip():
drugs = ha_search(ha_query)
if not drugs:
st.warning(f"No high-alert medications found matching '{ha_query}'. Try generic or brand name.")
else:
st.success(f"Found {len(drugs)} high-alert medication(s) matching '{ha_query}'")
elif sel_cat != "All categories":
drugs = get_by_category(sel_cat)
else:
drugs = ha_all()
if not ha_query.strip() and sel_cat == "All categories":
st.info("πŸ’‘ Search for a drug above or filter by category to view high-alert details. "
f"Database contains **{len(drugs)} high-alert medications** across {len(ha_cats())} categories.")
# Summary table
for cat in ha_cats():
cat_drugs = get_by_category(cat)
names = ", ".join(d["name"] for d in cat_drugs)
st.markdown(f"**{cat}** β€” {names}")
else:
for drug in drugs:
rl = drug["risk_level"]
banner_class = "critical-banner" if rl == "CRITICAL" else "high-alert-banner"
icon = "πŸ”΄" if rl == "CRITICAL" else "🟠"
with st.expander(
f"{icon} {drug['name']} β€” {drug['category']}",
expanded=len(drugs) == 1
):
col1, col2 = st.columns([3, 1])
with col1:
st.markdown(f"**Generic:** {drug['generic'].title()}")
st.markdown(f"**Category:** {drug['category']} Β· **ISMP Scope:** {drug['ismp_class']}")
with col2:
st.markdown(
f'<div style="text-align:right">{risk_badge(rl)}</div>',
unsafe_allow_html=True
)
# Warnings
st.markdown("**⚠️ Key Safety Warnings:**")
for w in drug["warnings"]:
st.markdown(
f'<div class="{banner_class}">⚠️ {w}</div>',
unsafe_allow_html=True
)
col_a, col_b = st.columns(2)
with col_a:
st.markdown("**🩺 Nursing Considerations:**")
for n in drug["nursing_considerations"]:
st.markdown(f"β€’ {n}")
with col_b:
st.markdown("**πŸ“Š Monitoring Parameters:**")
for m in drug["monitoring"]:
st.markdown(f"β€’ {m}")
if drug.get("antidote"):
st.markdown(
f'<div class="info-banner">πŸ’‰ <b>Antidote / Reversal:</b> {drug["antidote"]}</div>',
unsafe_allow_html=True
)
if drug.get("lasa_risk"):
st.markdown(
f'<div class="warn-banner">πŸ”€ <b>LASA Risk:</b> May be confused with: {", ".join(drug["lasa_risk"])}</div>',
unsafe_allow_html=True
)
st.markdown(f"**Routes:** {', '.join(drug['route'])}")
# ========================= DRUG INTERACTIONS ================================
with tab_inter:
st.subheader("⚑ Drug Interaction Checker")
st.caption("Enter 2–5 medications to check for clinically significant interactions. Uses NIH RxNorm API + curated clinical database.")
st.markdown("**Enter medication names (one per line):**")
drug_input = st.text_area(
"Medications",
placeholder="e.g.\nwarfarin\naspirin\namiodarone",
height=140,
label_visibility="collapsed",
)
col_check, col_clear = st.columns([3, 1])
with col_check:
check_btn = st.button("⚑ Check Interactions", type="primary", use_container_width=True)
with col_clear:
if st.button("πŸ”„ Clear", use_container_width=True):
st.rerun()
st.divider()
# Quick common combinations
st.markdown("**πŸ’‘ Quick check β€” common combinations:**")
q_cols = st.columns(4)
quick_combos = [
("Warfarin + Aspirin", ["warfarin", "aspirin"]),
("Morphine + Lorazepam", ["morphine", "lorazepam"]),
("Metformin + Contrast", ["metformin", "iodinated contrast"]),
("SSRI + Tramadol", ["sertraline", "tramadol"]),
]
for i, (label, combo) in enumerate(quick_combos):
if q_cols[i].button(label, use_container_width=True, key=f"quick_{i}"):
drug_input = "\n".join(combo)
check_btn = True
if check_btn and drug_input.strip():
drug_list = [d.strip() for d in drug_input.strip().splitlines() if d.strip()]
drug_list = list(dict.fromkeys(drug_list)) # deduplicate
if len(drug_list) < 2:
st.error("Please enter at least 2 medications.")
elif len(drug_list) > 5:
st.warning("Maximum 5 drugs per check. Only the first 5 will be checked.")
drug_list = drug_list[:5]
else:
st.markdown(f"**Checking interactions between:** {', '.join(d.title() for d in drug_list)}")
with st.spinner("Checking interactions…"):
# Local check always; API check if available
local_results = check_interactions_local(drug_list)
api_results = []
from safety.interactions import get_rxcui, check_interactions_api
rxcuis = []
for name in drug_list:
rxcui = get_rxcui(name)
if rxcui:
rxcuis.append(rxcui)
if len(rxcuis) >= 2:
api_results = check_interactions_api(rxcuis)
# Merge
all_results = []
seen = set()
for r in (api_results + local_results):
pair = frozenset([r["drug1"].lower(), r["drug2"].lower()])
if pair not in seen:
all_results.append(r)
seen.add(pair)
severity_order = {"critical": 0, "high": 1, "moderate": 2, "minor": 3, "low": 4, "unknown": 5}
all_results.sort(key=lambda x: severity_order.get(x.get("severity", "unknown").lower(), 5))
if not all_results:
st.markdown(
'<div class="safe-banner">βœ… <b>No significant interactions found</b> in the checked database for these medications. '
'Always verify with a pharmacist or clinical drug reference for comprehensive checking.</div>',
unsafe_allow_html=True
)
else:
n_critical = sum(1 for r in all_results if r.get("severity","").lower() == "critical")
n_high = sum(1 for r in all_results if r.get("severity","").lower() == "high")
n_moderate = sum(1 for r in all_results if r.get("severity","").lower() == "moderate")
summary_parts = []
if n_critical: summary_parts.append(f"πŸ”΄ {n_critical} CRITICAL")
if n_high: summary_parts.append(f"🟠 {n_high} HIGH")
if n_moderate: summary_parts.append(f"🟑 {n_moderate} MODERATE")
if summary_parts:
st.markdown(
f'<div class="critical-banner"><b>Found {len(all_results)} interaction(s): {" Β· ".join(summary_parts)}</b></div>',
unsafe_allow_html=True
)
for r in all_results:
sev = r.get("severity", "unknown")
icon = SEVERITY_ICONS.get(sev.lower(), "βšͺ")
with st.expander(
f"{icon} {r['drug1'].title()} ↔ {r['drug2'].title()} β€” {sev.upper()}",
expanded=(sev.lower() in ("critical", "high"))
):
st.markdown(
f'<div class="{"critical-banner" if sev in ("critical","high") else "warn-banner"}">'
f'{severity_badge(sev)} &nbsp; <b>{r["drug1"].title()}</b> ↔ <b>{r["drug2"].title()}</b>'
f'</div>',
unsafe_allow_html=True
)
st.markdown(f"**Description:** {r['description']}")
if r.get("management"):
st.markdown(
f'<div class="info-banner">🩺 <b>Management:</b> {r["management"]}</div>',
unsafe_allow_html=True
)
if r.get("mechanism"):
st.markdown(f"**Mechanism:** {r['mechanism']}")
st.caption(f"Source: {r.get('source', 'Clinical Database')}")
# ========================= DOSE SAFETY CHECK ================================
with tab_dose:
st.subheader("πŸ“ Safe Dose Verification")
st.caption("Verify ordered doses against standard adult therapeutic ranges. Always check weight-based dosing.")
col_drug, col_dose, col_wt = st.columns([3, 2, 2])
with col_drug:
drug_names_list = [""] + get_drug_names()
sel_drug = st.selectbox("Medication", drug_names_list,
format_func=lambda x: "Select or type a medication…" if x == "" else x)
or_type = st.text_input("Or type drug name", placeholder="e.g. digoxin")
with col_dose:
ordered_dose = st.number_input("Ordered dose", min_value=0.0, step=0.5, format="%.2f")
with col_wt:
patient_weight = st.number_input("Patient weight (kg) β€” optional", min_value=0.0, step=1.0, format="%.1f")
if st.button("πŸ“ Check Dose", type="primary", use_container_width=True):
drug_to_check = or_type.strip() if or_type.strip() else sel_drug.strip()
if not drug_to_check:
st.error("Please select or enter a medication name.")
elif ordered_dose <= 0:
st.error("Please enter a valid ordered dose > 0.")
else:
wt = patient_weight if patient_weight > 0 else None
result = check_dose(drug_to_check, ordered_dose, wt)
if result["status"] == "not_found":
st.warning(result["message"])
else:
drug = result["drug"]
st.markdown(f"### {drug['generic'].title()} ({drug['brand']})")
st.markdown(f"**Category:** {drug['category']} Β· **Route:** {drug['route']} Β· **Unit:** {drug['unit']}")
# Range visual
pct = min(ordered_dose / drug["max_dose"], 1.0)
c1, c2, c3 = st.columns(3)
c1.metric("Minimum", f"{drug['min_dose']} {drug['unit']}")
c2.metric("Ordered", f"{ordered_dose} {drug['unit']}")
c3.metric("Maximum", f"{drug['max_dose']} {drug['unit']}")
st.progress(pct)
# Flags
for flag in result.get("flags", []):
if "βœ…" in flag:
st.markdown(f'<div class="safe-banner">{flag}</div>', unsafe_allow_html=True)
elif "πŸ”΄" in flag:
st.markdown(f'<div class="critical-banner">{flag}</div>', unsafe_allow_html=True)
elif "⚠️" in flag:
st.markdown(f'<div class="warn-banner">{flag}</div>', unsafe_allow_html=True)
elif "βš–οΈ" in flag:
st.markdown(f'<div class="info-banner">{flag}</div>', unsafe_allow_html=True)
else:
st.markdown(f'<div class="info-banner">{flag}</div>', unsafe_allow_html=True)
# Frequency and nursing notes
st.markdown(f"**Typical frequency:** {drug['frequency']}")
if drug.get("therapeutic_range"):
st.markdown(f"**Therapeutic range:** {drug['therapeutic_range']}")
st.divider()
st.markdown("**🩺 Nursing Notes:**")
for note in drug.get("nursing_notes", []):
st.markdown(f"β€’ {note}")
st.divider()
st.caption(f"πŸ“š Dose database covers {len(DOSE_DATABASE)} common medications. Always verify against current clinical guidelines and your institution's protocols.")
# ========================= LASA WARNINGS ====================================
with tab_lasa:
st.subheader("πŸ”€ Look-Alike / Sound-Alike (LASA) Drug Warnings")
st.caption("Based on ISMP LASA list and Joint Commission National Patient Safety Goals.")
col_ls, col_lt = st.columns([2, 2])
with col_ls:
lasa_query = st.text_input("πŸ” Search drug name", placeholder="e.g. morphine, insulin, warfarin", key="lasa_search")
with col_lt:
lasa_type_filter = st.selectbox(
"Filter by type",
["All types", "look-alike", "sound-alike", "both"],
format_func=lambda x: {"All types": "All LASA types", "look-alike": "πŸ‘οΈ Look-Alike",
"sound-alike": "πŸ‘‚ Sound-Alike", "both": "πŸ‘οΈπŸ‘‚ Both"}[x],
)
lasa_risk_filter = st.radio(
"Risk level",
["All", "CRITICAL + HIGH only", "CRITICAL only"],
horizontal=True
)
st.divider()
# Get pairs
all_pairs = get_all_pairs()
if lasa_query.strip():
pairs = search_lasa(lasa_query)
elif lasa_risk_filter == "CRITICAL only":
pairs = get_critical_pairs()
elif lasa_risk_filter == "CRITICAL + HIGH only":
pairs = get_high_risk_pairs()
else:
pairs = all_pairs
if lasa_type_filter != "All types":
pairs = [p for p in pairs if p["type"] == lasa_type_filter or
(lasa_type_filter != "both" and p["type"] == "both")]
st.markdown(f"**Showing {len(pairs)} LASA pair(s)**")
if not pairs:
st.info("No LASA pairs found matching your filters.")
else:
for pair in pairs:
risk = pair["risk"]
icon = TYPE_ICONS.get(pair["type"], "πŸ”€")
risk_icon = {"CRITICAL": "πŸ”΄", "HIGH": "🟠", "MODERATE": "🟑", "LOW": "🟒"}.get(risk, "βšͺ")
with st.expander(
f"{risk_icon} {pair['drug_a']} ↔ {pair['drug_b']} {icon}",
expanded=(risk == "CRITICAL")
):
col_a, col_b = st.columns([3, 1])
with col_a:
st.markdown(f"**Drug A:** {pair['drug_a']}")
st.markdown(f"**Drug B:** {pair['drug_b']}")
with col_b:
st.markdown(
f'<div style="text-align:right">'
f'{risk_badge(risk)}<br/>{icon} <small>{pair["type"].title()}</small>'
f'</div>',
unsafe_allow_html=True
)
banner = "critical-banner" if risk == "CRITICAL" else "high-alert-banner" if risk == "HIGH" else "warn-banner"
st.markdown(
f'<div class="{banner}">⚠️ <b>Clinical Risk:</b> {pair["clinical_note"]}</div>',
unsafe_allow_html=True
)
st.markdown(
f'<div class="info-banner">βœ… <b>Prevention Strategy:</b> {pair["strategy"]}</div>',
unsafe_allow_html=True
)
if pair.get("tags"):
tags = " Β· ".join(f"`{t}`" for t in pair["tags"])
st.markdown(f"**Tags:** {tags}")
# ========================= FIVE RIGHTS CHECKLIST ===========================
with tab_5rights:
st.subheader("βœ… Medication Administration: 9 Rights Checklist")
st.caption("The Five Rights (+ 4 additional) of safe medication administration. Use this as a pre-administration checklist.")
if "rights_state" not in st.session_state:
st.session_state.rights_state = {}
drug_name_input = st.text_input("πŸ’Š Medication being prepared:", placeholder="e.g. Metoprolol 50 mg PO")
st.divider()
RIGHTS = [
{
"right": "1️⃣ Right PATIENT",
"description": "Verify patient identity using TWO identifiers (name + DOB, or name + MRN)",
"checks": [
"Asked the patient to state their full name",
"Verified date of birth or MRN",
"Checked wristband against MAR",
"Confirmed allergies",
],
},
{
"right": "2️⃣ Right DRUG",
"description": "Confirm the correct medication name β€” generic AND brand",
"checks": [
"Compared drug name on MAR to medication label",
"Checked for LASA look-alikes",
"Verified correct formulation (IR vs XR, concentration)",
"Checked if medication is on ISMP high-alert list",
],
},
{
"right": "3️⃣ Right DOSE",
"description": "Verify the dose is correct and within safe therapeutic range",
"checks": [
"Calculated dose independently",
"Verified dose against standard range",
"Performed weight-based calculation if applicable",
"Second nurse verification completed (if required by policy)",
],
},
{
"right": "4️⃣ Right ROUTE",
"description": "Confirm the ordered route is appropriate and safe",
"checks": [
"Verified route on MAR (oral, IV, SubQ, IM, etc.)",
"Confirmed IV access is patent and suitable for drug",
"Confirmed oral route is appropriate (no dysphagia/NPO)",
"Verified compatibility if adding to existing IV",
],
},
{
"right": "5️⃣ Right TIME",
"description": "Administer the medication at the correct time",
"checks": [
"Confirmed time on MAR matches current administration window",
"Verified last dose time (avoid double dosing)",
"Confirmed patient has not already received the dose",
"For time-critical medications (antibiotics, insulin): on time",
],
},
{
"right": "6️⃣ Right DOCUMENTATION",
"description": "Document immediately after β€” never before β€” administration",
"checks": [
"MAR signed immediately after administration",
"Noted time, route, site (for injections), patient response",
"Documented any refused or withheld doses with reason",
"PRN medications: documented reason for administration",
],
},
{
"right": "7️⃣ Right REASON",
"description": "Understand WHY this medication is being given",
"checks": [
"Know the indication for this medication",
"Can explain purpose to patient if asked",
"Verified indication matches the patient's current diagnosis",
"Questioned any orders that don't match clinical presentation",
],
},
{
"right": "8️⃣ Right RESPONSE",
"description": "Evaluate the patient's response to the medication",
"checks": [
"Assessed baseline vital signs and relevant parameters before administration",
"Documented pre-administration assessment",
"Plan for post-administration re-assessment",
"Know expected therapeutic effect timeline",
],
},
{
"right": "9️⃣ Right to REFUSE",
"description": "Patient has the right to refuse any medication",
"checks": [
"Patient has been informed about the medication and its purpose",
"Patient understands the consequences of refusal",
"Refusal documented and provider notified",
"Patient's autonomy and right to decide is respected",
],
},
]
all_checked = True
total_checks = 0
completed_checks = 0
for right_item in RIGHTS:
right_key = right_item["right"]
with st.expander(right_key, expanded=True):
st.caption(right_item["description"])
for ci, check_text in enumerate(right_item["checks"]):
key = f"right_{right_key}_{ci}"
current = st.session_state.rights_state.get(key, False)
checked = st.checkbox(check_text, value=current, key=key)
st.session_state.rights_state[key] = checked
total_checks += 1
if checked:
completed_checks += 1
else:
all_checked = False
st.divider()
pct_done = round((completed_checks / total_checks) * 100) if total_checks > 0 else 0
st.progress(pct_done / 100)
st.markdown(f"**{completed_checks} / {total_checks} checks completed ({pct_done}%)**")
if all_checked:
drug_label = f" β€” {drug_name_input}" if drug_name_input else ""
st.markdown(
f'<div class="safe-banner">βœ… <b>All 9 Rights verified{drug_label}</b>. '
f'Safe to administer. Document immediately after administration.</div>',
unsafe_allow_html=True
)
elif pct_done >= 75:
st.markdown(
'<div class="warn-banner">🟑 Almost complete β€” review remaining unchecked items before administering.</div>',
unsafe_allow_html=True
)
else:
st.markdown(
'<div class="high-alert-banner">⚠️ Complete all verification checks before administering medication.</div>',
unsafe_allow_html=True
)
col_reset, _ = st.columns([1, 3])
with col_reset:
if st.button("πŸ”„ Reset Checklist"):
st.session_state.rights_state = {}
st.rerun()
# ---------------------------------------------------------------------------
# Footer
# ---------------------------------------------------------------------------
st.divider()
st.caption(
"Based on ISMP High-Alert Medications list (2023), Joint Commission NPSG, and standard clinical references. "
"Drug interaction data supplemented by NIH RxNorm API. "
"For educational purposes only β€” always follow your institution's protocols and consult a pharmacist."
)