Spaces:
Sleeping
Sleeping
| """ | |
| 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." | |
| ) | |