"""
NCLEX Practice Question Generator
Streamlit app โ Hugging Face Spaces (free CPU tier)
"""
import random
import streamlit as st
from questions.bank import filter_questions, get_categories, get_all
from questions.calculations import generate_batch
from scorer import calculate_score, category_percentage
# ---------------------------------------------------------------------------
# Page config
# ---------------------------------------------------------------------------
st.set_page_config(
page_title="NCLEX Practice โ Student Nurses",
page_icon="๐",
layout="wide",
initial_sidebar_state="expanded",
)
# ---------------------------------------------------------------------------
# CSS
# ---------------------------------------------------------------------------
st.markdown("""
""", 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'{label}'
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('SELECT ALL', unsafe_allow_html=True)
elif qtype == "calculation":
st.markdown('CALCULATION', 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'
๐ Rationale: {q["rationale"]}
',
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'{pct}%
', unsafe_allow_html=True)
st.markdown(f"{band}
", unsafe_allow_html=True)
st.markdown(f"{score['feedback']}
", 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",
)
# ========================= 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."
)