nclex-prep / streamlit_app.py
Lincoln Gombedza
feat: add .docx export to NCLEX exam results
5b84275
"""
NCLEX Practice Question Generator
Streamlit app β€” Hugging Face Spaces (free CPU tier)
"""
import random
import streamlit as st
import io
from datetime import date
from questions.bank import filter_questions, get_categories, get_all
from questions.calculations import generate_batch
from scorer import calculate_score, category_percentage
# ---------------------------------------------------------------------------
# .docx export helper
# ---------------------------------------------------------------------------
def export_nclex_results_docx(questions: list, score: dict, answers: dict) -> bytes:
"""Return a formatted Word document of NCLEX practice results as bytes."""
try:
from docx import Document
from docx.shared import Pt, RGBColor, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
return b""
NHS_BLUE = RGBColor(0x00, 0x30, 0x87)
NHS_GREEN = RGBColor(0x00, 0x96, 0x39)
NHS_RED = RGBColor(0xDA, 0x29, 0x1C)
doc = Document()
for section in doc.sections:
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1.25)
section.right_margin = Inches(1.25)
doc.styles["Normal"].font.name = "Calibri"
doc.styles["Normal"].font.size = Pt(11)
pct = score.get("percentage", 0)
passed = pct >= 75
# Title
h = doc.add_heading("NCLEX Practice Results", level=0)
for run in h.runs:
run.font.color.rgb = NHS_BLUE
doc.add_paragraph(f"Date: {date.today().strftime('%d %B %Y')}")
sum_p = doc.add_paragraph()
sum_p.add_run(
f"Score: {score.get('correct', 0)} / {score.get('total', 0)} ({pct}%) | "
).bold = True
sum_r = sum_p.add_run(score.get("performance_band", ""))
sum_r.bold = True
sum_r.font.color.rgb = NHS_GREEN if passed else NHS_RED
doc.add_paragraph(score.get("feedback", ""))
doc.add_paragraph()
# Category breakdown table
from scorer import category_percentage
cat_pct = category_percentage(score)
if cat_pct:
ch = doc.add_heading("Performance by Category", level=1)
for run in ch.runs:
run.font.color.rgb = NHS_BLUE
cat_table = doc.add_table(rows=1, cols=2)
cat_table.style = "Table Grid"
hdr = cat_table.rows[0].cells
hdr[0].text = "Category"
hdr[1].text = "Score (%)"
for p in hdr:
for para in p.paragraphs:
for r in para.runs:
r.bold = True
for cat, p in sorted(cat_pct.items(), key=lambda x: x[1]):
row = cat_table.add_row().cells
row[0].text = cat
row[1].text = f"{p}%"
doc.add_paragraph()
# Difficulty breakdown
diff_data = score.get("by_difficulty", {})
if diff_data:
dh = doc.add_heading("Performance by Difficulty", level=1)
for run in dh.runs:
run.font.color.rgb = NHS_BLUE
diff_table = doc.add_table(rows=1, cols=3)
diff_table.style = "Table Grid"
dhdr = diff_table.rows[0].cells
dhdr[0].text = "Difficulty"
dhdr[1].text = "Correct"
dhdr[2].text = "Score"
for p in dhdr:
for para in p.paragraphs:
for r in para.runs:
r.bold = True
DIFF_LABELS = {"beginner": "Beginner", "intermediate": "Intermediate", "advanced": "Advanced"}
for diff, data in diff_data.items():
t = data["total"]
p = round((data["correct"] / t) * 100, 1) if t > 0 else 0
row = diff_table.add_row().cells
row[0].text = DIFF_LABELS.get(diff, diff.title())
row[1].text = f"{data['correct']} / {t}"
row[2].text = f"{p}%"
doc.add_paragraph()
# Wrong answers review
wrong = score.get("wrong_questions", [])
if wrong:
wh = doc.add_heading(f"Wrong Answers Review ({len(wrong)} questions)", level=1)
for run in wh.runs:
run.font.color.rgb = NHS_BLUE
for item in wrong:
q = item["question"]
doc.add_heading(f"{q['category']} | {q['subcategory']}", level=2)
doc.add_paragraph(q["stem"])
correct_val = q["correct"]
options = q.get("options", [])
if q["type"] == "sata":
correct_opts = [options[i] for i in correct_val if i < len(options)]
ca_p = doc.add_paragraph()
ca_r = ca_p.add_run("Correct answers: ")
ca_r.bold = True
ca_p.add_run("; ".join(correct_opts))
else:
ca_p = doc.add_paragraph()
ca_r = ca_p.add_run("Correct answer: ")
ca_r.bold = True
ca_p.add_run(options[correct_val] if correct_val < len(options) else str(correct_val))
rat_p = doc.add_paragraph()
rat_r = rat_p.add_run("Rationale: ")
rat_r.bold = True
rat_p.add_run(q.get("rationale", ""))
doc.add_paragraph()
# Footer
fp = doc.add_paragraph()
fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
fr = fp.add_run(
f"NurseCitizenDeveloper β€” NCLEX Practice Question Generator | {date.today().strftime('%d %B %Y')}"
)
fr.italic = True
fr.font.size = Pt(9)
fr.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
disc = doc.add_paragraph()
disc.alignment = WD_ALIGN_PARAGRAPH.CENTER
dr = disc.add_run(
"DISCLAIMER: For educational purposes only. Always apply clinical judgment in practice."
)
dr.italic = True
dr.font.size = Pt(8)
dr.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
# ---------------------------------------------------------------------------
# Page config
# ---------------------------------------------------------------------------
st.set_page_config(
page_title="NCLEX Practice β€” Student Nurses",
page_icon="πŸ“‹",
layout="wide",
initial_sidebar_state="expanded",
)
# ---------------------------------------------------------------------------
# CSS
# ---------------------------------------------------------------------------
st.markdown("""
<style>
.question-card {
background:#f8fafc; border:1px solid #d0dae8;
border-radius:10px; padding:1.2rem 1.4rem; margin-bottom:1rem;
}
.correct-ans { background:#e8f5e9; border-left:4px solid #2e7d32;
padding:0.6rem 1rem; border-radius:4px; margin-top:0.5rem; }
.wrong-ans { background:#fce4ec; border-left:4px solid #c62828;
padding:0.6rem 1rem; border-radius:4px; margin-top:0.5rem; }
.rationale { background:#e3f2fd; border-left:4px solid #1565c0;
padding:0.6rem 1rem; border-radius:4px; margin-top:0.5rem; }
.sata-badge { background:#fff8e1; color:#e65100; padding:2px 8px;
border-radius:4px; font-size:0.75em; font-weight:700; }
.calc-badge { background:#f3e5f5; color:#6a1b9a; padding:2px 8px;
border-radius:4px; font-size:0.75em; font-weight:700; }
.score-big { font-size:3rem; font-weight:800; text-align:center; }
.diff-easy { color:#2e7d32; font-weight:600; font-size:0.78em; }
.diff-inter { color:#e65100; font-weight:600; font-size:0.78em; }
.diff-hard { color:#c62828; font-weight:600; font-size:0.78em; }
</style>
""", unsafe_allow_html=True)
# ---------------------------------------------------------------------------
# Session state
# ---------------------------------------------------------------------------
_DEFAULTS = {
"quiz_questions": [],
"quiz_answers": {},
"quiz_submitted": False,
"quiz_score": None,
"calc_questions": [],
"calc_answers": {},
"calc_submitted": False,
"current_q_idx": 0,
"quiz_mode": "all", # "all" or "one-by-one"
}
for k, v in _DEFAULTS.items():
if k not in st.session_state:
st.session_state[k] = v
CATEGORIES = get_categories()
DIFFICULTY_LABELS = {"beginner": "🟒 Beginner", "intermediate": "🟑 Intermediate", "advanced": "πŸ”΄ Advanced"}
DIFF_CSS = {"beginner": "diff-easy", "intermediate": "diff-inter", "advanced": "diff-hard"}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _diff_badge(diff: str) -> str:
css = DIFF_CSS.get(diff, "diff-inter")
label = DIFFICULTY_LABELS.get(diff, diff.title())
return f'<span class="{css}">{label}</span>'
def render_question(q: dict, idx: int, key_prefix: str, show_answer: bool = False):
"""Render a question card. Returns the user's answer or None."""
qtype = q["type"]
with st.container():
# Header row
hcol1, hcol2, hcol3 = st.columns([4, 1, 1])
with hcol1:
st.markdown(f"**Question {idx + 1}** β€” {q['category']} β€Ί {q['subcategory']}")
with hcol2:
st.markdown(_diff_badge(q.get("difficulty", "")), unsafe_allow_html=True)
with hcol3:
if qtype == "sata":
st.markdown('<span class="sata-badge">SELECT ALL</span>', unsafe_allow_html=True)
elif qtype == "calculation":
st.markdown('<span class="calc-badge">CALCULATION</span>', unsafe_allow_html=True)
# Stem
st.markdown(f"**{q['stem']}**")
# Answer input
user_answer = None
options = q["options"]
if qtype == "sata":
st.caption("*Select all that apply*")
selected = []
for oi, opt in enumerate(options):
if show_answer:
is_correct_opt = oi in q["correct"]
icon = "βœ…" if is_correct_opt else "❌"
st.markdown(f"{icon} {opt}")
else:
chk = st.checkbox(opt, key=f"{key_prefix}_q{idx}_o{oi}")
if chk:
selected.append(oi)
user_answer = selected
else: # mcq or calculation
if show_answer:
correct_txt = options[q["correct"]]
for oi, opt in enumerate(options):
if oi == q["correct"]:
st.markdown(f"βœ… **{opt}**")
else:
st.markdown(f"β—‹ {opt}")
else:
choice = st.radio(
"Select your answer:",
options,
key=f"{key_prefix}_q{idx}",
index=None,
label_visibility="collapsed",
)
user_answer = options.index(choice) if choice else None
# Show rationale if reviewing
if show_answer:
st.markdown(
f'<div class="rationale">πŸ“– <b>Rationale:</b> {q["rationale"]}</div>',
unsafe_allow_html=True,
)
st.divider()
return user_answer
# ---------------------------------------------------------------------------
# Sidebar
# ---------------------------------------------------------------------------
with st.sidebar:
st.markdown("## πŸ“‹ NCLEX Practice")
st.divider()
st.markdown("**Quiz Settings**")
sel_cats = st.multiselect(
"Categories",
CATEGORIES,
default=CATEGORIES,
help="Select which NCLEX categories to include",
)
sel_diff = st.selectbox(
"Difficulty",
["Any", "beginner", "intermediate", "advanced"],
format_func=lambda x: "Any difficulty" if x == "Any" else DIFFICULTY_LABELS[x],
)
sel_type = st.selectbox(
"Question type",
["Any", "mcq", "sata", "calculation"],
format_func=lambda x: {
"Any": "All types", "mcq": "Multiple Choice (MCQ)",
"sata": "Select All That Apply (SATA)", "calculation": "Dosage Calculation"
}[x],
)
n_questions = st.slider("Number of questions", 5, 30, 10)
st.divider()
available = filter_questions(sel_cats, sel_diff, sel_type if sel_type != "calculation" else None)
st.caption(f"πŸ“š {len(get_all())} questions in bank Β· {len(available)} match filters")
st.divider()
st.markdown(
"""
**NCLEX-RN Test Plan**
- Safe & Effective Care Environment
- Health Promotion & Maintenance
- Psychosocial Integrity
- Physiological Integrity
"""
)
# ---------------------------------------------------------------------------
# Header
# ---------------------------------------------------------------------------
st.title("πŸ“‹ NCLEX Practice Question Generator")
st.caption(
"Evidence-based NCLEX-style practice questions Β· MCQ Β· Select All That Apply Β· "
"Dosage Calculations Β· Full rationales for every question"
)
# ---------------------------------------------------------------------------
# Tabs
# ---------------------------------------------------------------------------
tab_quiz, tab_calc, tab_results, tab_review = st.tabs(
["🎯 Practice Quiz", "πŸ’‰ Dosage Calculations", "πŸ“Š My Results", "πŸ” Review Wrong Answers"]
)
# ========================= PRACTICE QUIZ TAB ==============================
with tab_quiz:
col_start, col_reset = st.columns([3, 1])
with col_start:
if st.button("🎯 Generate New Quiz", type="primary", use_container_width=True):
bank = filter_questions(sel_cats, sel_diff, sel_type if sel_type != "calculation" else None)
if not bank:
st.error("No questions match your filters. Adjust the sidebar settings.")
else:
sampled = random.sample(bank, min(n_questions, len(bank)))
# Mix in calc questions if type is Any or calculation
if sel_type in ("Any", "calculation"):
n_calc = max(1, n_questions // 5)
calc_q = generate_batch(n_calc)
if sel_type == "calculation":
sampled = calc_q * ((n_questions // n_calc) + 1)
sampled = sampled[:n_questions]
else:
sampled = sampled[:-n_calc] + calc_q
random.shuffle(sampled)
st.session_state.quiz_questions = sampled[:n_questions]
st.session_state.quiz_answers = {}
st.session_state.quiz_submitted = False
st.session_state.quiz_score = None
st.rerun()
with col_reset:
if st.button("πŸ”„ Reset", use_container_width=True):
for k in ["quiz_questions","quiz_answers","quiz_submitted","quiz_score"]:
st.session_state[k] = [] if k == "quiz_questions" else ({} if "answers" in k else (False if "submitted" in k else None))
st.rerun()
questions = st.session_state.quiz_questions
if not questions:
st.info("Click **Generate New Quiz** to start practising.")
else:
submitted = st.session_state.quiz_submitted
if not submitted:
st.markdown(f"**{len(questions)} questions** β€” read each carefully, then click Submit.")
st.divider()
# Render all questions
for i, q in enumerate(questions):
ans = render_question(q, i, "quiz", show_answer=False)
if ans is not None and ans != []:
st.session_state.quiz_answers[i] = ans
if st.button("βœ… Submit Quiz", type="primary", use_container_width=False):
score = calculate_score(st.session_state.quiz_answers, questions)
st.session_state.quiz_score = score
st.session_state.quiz_submitted = True
st.rerun()
else:
# Show results summary
score = st.session_state.quiz_score
pct = score["percentage"]
band = score["performance_band"]
emoji = "πŸ†" if pct >= 75 else "πŸ“š"
c1, c2, c3 = st.columns(3)
c1.metric("Score", f"{score['correct']} / {score['total']}")
c2.metric("Percentage", f"{pct}%")
c3.metric("Result", band)
st.progress(pct / 100)
st.info(f"{emoji} {score['feedback']}")
st.divider()
# Show all questions with correct answers
st.subheader("Question Review")
for i, q in enumerate(questions):
user_ans = st.session_state.quiz_answers.get(i)
correct = q["correct"]
is_right = (set(user_ans) == set(correct)) if q["type"] == "sata" else user_ans == correct
icon = "βœ…" if is_right else "❌"
with st.expander(f"{icon} Q{i+1}: {q['stem'][:80]}…", expanded=not is_right):
render_question(q, i, "review", show_answer=True)
# ========================= DOSAGE CALCULATIONS TAB ========================
with tab_calc:
c_gen, c_rst = st.columns([3, 1])
with c_gen:
if st.button("πŸ’‰ Generate Calculation Set", type="primary", use_container_width=True):
st.session_state.calc_questions = generate_batch(n_questions)
st.session_state.calc_answers = {}
st.session_state.calc_submitted = False
st.rerun()
with c_rst:
if st.button("πŸ”„ Reset ", use_container_width=True, key="calc_rst"):
st.session_state.calc_questions = []
st.session_state.calc_answers = {}
st.session_state.calc_submitted = False
st.rerun()
calc_qs = st.session_state.calc_questions
if not calc_qs:
st.info(
"Click **Generate Calculation Set** for a fresh set of dosage math problems. "
"Each set is uniquely generated β€” unlimited practice!"
)
with st.expander("πŸ“ Dosage Calculation Formula Reference"):
st.markdown("""
**Oral Tablets:**
> (Ordered dose Γ· Dose on hand) Γ— Quantity = Tablets to give
**IV Rate (mL/hr):**
> Volume (mL) Γ· Time (hours) = mL/hr
**Manual Drip Rate (gtt/min):**
> (Volume Γ— Drop factor) Γ· Time (minutes) = gtt/min
**Weight-based IV Infusion:**
> (mcg/kg/min Γ— kg Γ— 60) Γ· Concentration (mcg/mL) = mL/hr
**Paediatric Oral Dose:**
> mg/kg Γ— weight (kg) Γ· concentration (mg/mL) = mL to give
""")
else:
calc_submitted = st.session_state.calc_submitted
if not calc_submitted:
for i, q in enumerate(calc_qs):
ans = render_question(q, i, "calc", show_answer=False)
if ans is not None and ans != []:
st.session_state.calc_answers[i] = ans
if st.button("βœ… Submit Calculations", type="primary"):
score = calculate_score(st.session_state.calc_answers, calc_qs)
st.session_state.calc_submitted = True
st.session_state.quiz_score = score
st.rerun()
else:
score = calculate_score(st.session_state.calc_answers, calc_qs)
pct = score["percentage"]
c1, c2, c3 = st.columns(3)
c1.metric("Correct", f"{score['correct']} / {score['total']}")
c2.metric("Percentage", f"{pct}%")
c3.metric("Band", score["performance_band"])
st.progress(pct / 100)
st.divider()
for i, q in enumerate(calc_qs):
user_ans = st.session_state.calc_answers.get(i)
is_right = user_ans == q["correct"]
icon = "βœ…" if is_right else "❌"
with st.expander(f"{icon} Q{i+1}: {q['stem'][:80]}…", expanded=not is_right):
render_question(q, i, "calc_rev", show_answer=True)
# ========================= RESULTS TAB ====================================
with tab_results:
score = st.session_state.quiz_score
if not score:
st.info("Complete a quiz to see your results here.")
else:
pct = score["percentage"]
band = score["performance_band"]
st.markdown(f'<div class="score-big">{pct}%</div>', unsafe_allow_html=True)
st.markdown(f"<h3 style='text-align:center'>{band}</h3>", unsafe_allow_html=True)
st.markdown(f"<p style='text-align:center'>{score['feedback']}</p>", unsafe_allow_html=True)
st.divider()
# Category breakdown
st.subheader("Performance by Category")
cat_pct = category_percentage(score)
for cat, p in sorted(cat_pct.items(), key=lambda x: x[1]):
col_l, col_r = st.columns([3, 1])
col_l.write(cat)
col_r.write(f"**{p}%**")
st.progress(p / 100)
st.divider()
# Difficulty breakdown
st.subheader("Performance by Difficulty")
diff_data = score["by_difficulty"]
dcols = st.columns(3)
for i, (diff, data) in enumerate(diff_data.items()):
t = data["total"]
p = round((data["correct"] / t) * 100, 1) if t > 0 else 0
dcols[i % 3].metric(
DIFFICULTY_LABELS.get(diff, diff.title()),
f"{p}%",
f"{data['correct']}/{t} correct",
)
st.divider()
docx_bytes = export_nclex_results_docx(
st.session_state.quiz_questions,
score,
st.session_state.quiz_answers,
)
st.download_button(
"πŸ“„ Download Results (.docx)",
data=docx_bytes,
file_name="nclex_practice_results.docx",
mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
disabled=len(docx_bytes) == 0,
)
# ========================= REVIEW TAB =====================================
with tab_review:
score = st.session_state.quiz_score
wrong = score["wrong_questions"] if score else []
if not wrong:
msg = "No wrong answers to review β€” great work! πŸŽ‰" if score else "Complete a quiz first."
st.info(msg)
else:
st.subheader(f"Review: {len(wrong)} question(s) to revisit")
st.caption("Study the rationale for each β€” understanding WHY is the key to NCLEX success.")
st.divider()
for i, item in enumerate(wrong):
q = item["question"]
with st.expander(f"❌ {q['category']} | {q['stem'][:70]}…"):
st.markdown(f"**Category:** {q['category']} β€Ί {q['subcategory']}")
st.markdown(f"**NCLEX Framework:** {q.get('nclex_framework','')}")
st.markdown(_diff_badge(q.get("difficulty", "")), unsafe_allow_html=True)
st.divider()
render_question(q, i, "wrong_rev", show_answer=True)
# ---------------------------------------------------------------------------
# Footer
# ---------------------------------------------------------------------------
st.divider()
st.caption(
"Questions aligned to the 2023 NCLEX-RN Test Plan (NCSBN). "
"Dosage calculations are dynamically generated β€” every set is unique. "
"For educational purposes only β€” always apply clinical judgment in practice."
)