ProfRick commited on
Commit
4ca3ada
·
verified ·
1 Parent(s): 7625127

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +469 -218
app.py CHANGED
@@ -1,228 +1,479 @@
1
- # app.py — Core Concepts Review (Streamlit)
2
- # Features:
3
- # - Title updated to "Core Concepts Review"
4
- # - Stage-by-stage progression (1→2→3→4) with the diagram visible throughout
5
- # - Prior completed stages remain visible within the same loop
6
- # - On completing stage 4, a NEW LOOP begins: only the diagram + Stage 1 are shown; previous loop's stages are hidden
7
- # - Proof-of-completion PDF button appears AT THE BOTTOM only after finishing TWO full loops of a scenario
8
- # - Unique keys for all buttons/inputs to avoid StreamlitDuplicateElementId
9
- # - Clean state management via st.session_state
10
- # - Minimal, self-contained validation logic (replace with your own as needed)
11
-
12
- import io
13
- from datetime import datetime
14
-
15
  import streamlit as st
16
- from reportlab.lib.pagesizes import letter
17
- from reportlab.pdfgen import canvas
18
-
19
- st.set_page_config(page_title="Core Concepts Review", page_icon="📘", layout="wide")
20
-
21
- ############################
22
- # ---------- DATA ----------
23
- ############################
24
- # Define your scenarios. Each scenario has: title, diagram (could be an image URL or placeholder), and 4 stages.
25
- # For demo purposes, we use placeholders and super-simple checkers.
26
-
27
- SCENARIOS = [
28
- {
29
- "id": "interdependence_sepsis",
30
- "title": "Interdependence of Body Systems — Sepsis",
31
- "diagram": "Sepsis flow diagram (placeholder)", # replace with st.image(...) source if desired
32
- "stages": [
33
- {"name": "Stage 1: Identify Trigger", "prompt": "What common trigger initiates sepsis?",
34
- "answer": "infection"},
35
- {"name": "Stage 2: Cascade", "prompt": "Name one key mediator elevated early in sepsis.",
36
- "answer": "tnf"},
37
- {"name": "Stage 3: System Impact", "prompt": "Which organ system often shows early dysfunction?",
38
- "answer": "cardiovascular"},
39
- {"name": "Stage 4: Feedback", "prompt": "Is the dysregulation in sepsis a failure of negative or positive feedback?",
40
- "answer": "negative"},
41
- ],
42
- },
43
- {
44
- "id": "flow_gradients",
45
- "title": "Flow Down Gradients — Pressure/Concentration/Electrical",
46
- "diagram": "Flow gradients diagram (placeholder)",
47
- "stages": [
48
- {"name": "Stage 1: Direction", "prompt": "Fluids move from _____ to _____ pressure.",
49
- "answer": "high to low"},
50
- {"name": "Stage 2: Diffusion", "prompt": "Particles diffuse from _____ concentration to _____ concentration.",
51
- "answer": "high to low"},
52
- {"name": "Stage 3: Membrane Potential", "prompt": "Resting neurons are typically around how many mV (approx)?",
53
- "answer": "-70"},
54
- {"name": "Stage 4: Electrochemical", "prompt": "Ions move according to the _________ gradient.",
55
- "answer": "electrochemical"},
56
- ],
57
- },
58
- ]
59
-
60
- REQUIRED_LOOPS_FOR_PDF = 2 # show the PDF button only after 2 full loops
61
-
62
- ############################
63
- # ------- UTILITIES --------
64
- ############################
65
-
66
- def _key(*parts):
67
- """Create a unique key string from parts to avoid duplicate element IDs."""
68
- return "::".join(str(p) for p in parts)
69
-
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  def init_state():
72
- if "scenario_idx" not in st.session_state:
73
- st.session_state.scenario_idx = 0 # which scenario is active
74
  if "loop_idx" not in st.session_state:
75
- st.session_state.loop_idx = 1 # loop counter within current scenario
76
- if "stage_visible" not in st.session_state:
77
- # Which stages are visible in the current loop (1..4)
78
- st.session_state.stage_visible = {1: True, 2: False, 3: False, 4: False}
79
- if "stage_correct" not in st.session_state:
80
- # Whether each stage has been correctly completed in the current loop
81
- st.session_state.stage_correct = {1: False, 2: False, 3: False, 4: False}
82
- if "loops_completed" not in st.session_state:
83
- # How many full loops completed (per scenario id)
84
- st.session_state.loops_completed = {s["id"]: 0 for s in SCENARIOS}
85
- if "answers" not in st.session_state:
86
- # Persist students' typed answers for the *current loop only*
87
- st.session_state.answers = {1: "", 2: "", 3: "", 4: ""}
88
-
89
-
90
- def reset_for_new_loop():
91
- st.session_state.loop_idx += 1
92
- st.session_state.stage_visible = {1: True, 2: False, 3: False, 4: False}
93
- st.session_state.stage_correct = {1: False, 2: False, 3: False, 4: False}
94
- st.session_state.answers = {1: "", 2: "", 3: "", 4: ""}
95
-
96
-
97
- def next_stage(stage_num):
98
- # Mark stage as visible/correct appropriately
99
- st.session_state.stage_correct[stage_num] = True
100
- if stage_num < 4:
101
- st.session_state.stage_visible[stage_num + 1] = True
102
- else:
103
- # Completed Stage 4 — full loop done
104
- scenario = SCENARIOS[st.session_state.scenario_idx]
105
- st.session_state.loops_completed[scenario["id"]] += 1
106
- reset_for_new_loop()
107
-
108
-
109
- def validate_answer(user_input: str, expected: str) -> bool:
110
- if expected is None or expected == "":
111
- return True
112
- ui = (user_input or "").strip().lower()
113
- exp = expected.strip().lower()
114
- # allow minor flexibility e.g., hyphens/spaces
115
- ui = ui.replace("—", "-").replace("–", "-")
116
- exp = exp.replace("—", "-").replace("–", "-")
117
- return ui == exp
118
-
119
-
120
- def generate_pdf_bytes(scenario_title: str, loops_done: int):
121
- """Return a BytesIO of the proof-of-completion PDF."""
122
- buffer = io.BytesIO()
123
- c = canvas.Canvas(buffer, pagesize=letter)
124
- width, height = letter
125
 
126
- c.setFont("Helvetica-Bold", 18)
127
- c.drawString(72, height - 72, "Core Concepts Review — Proof of Completion")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
  c.setFont("Helvetica", 12)
130
- y = height - 110
131
- c.drawString(72, y, f"Scenario: {scenario_title}")
132
  y -= 18
133
- c.drawString(72, y, f"Loops completed: {loops_done}")
134
- y -= 18
135
- c.drawString(72, y, f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
136
-
137
- c.showPage()
138
- c.save()
139
- buffer.seek(0)
140
- return buffer
141
-
142
- ############################
143
- # --------- UI -------------
144
- ############################
145
- init_state()
146
-
147
- scenario = SCENARIOS[st.session_state.scenario_idx]
148
-
149
- st.title("Core Concepts Review")
150
-
151
- # Scenario selector (optional)
152
- cols_top = st.columns([3, 1])
153
- with cols_top[0]:
154
- st.subheader(f"Scenario: {scenario['title']}")
155
- with cols_top[1]:
156
- if st.button("Switch Scenario", key=_key("switch", st.session_state.scenario_idx, st.session_state.loop_idx)):
157
- # rotate to next scenario and hard reset loop state
158
- st.session_state.scenario_idx = (st.session_state.scenario_idx + 1) % len(SCENARIOS)
159
- st.session_state.loop_idx = 1
160
- st.session_state.stage_visible = {1: True, 2: False, 3: False, 4: False}
161
- st.session_state.stage_correct = {1: False, 2: False, 3: False, 4: False}
162
- st.session_state.answers = {1: "", 2: "", 3: "", 4: ""}
163
- st.experimental_rerun()
164
-
165
- st.caption(f"Loop #{st.session_state.loop_idx}")
166
-
167
- # Diagram area — stays visible through all stages of the current loop
168
- with st.container(border=True):
169
- st.markdown("**Diagram**")
170
- # Replace with st.image(scenario['diagram']) if you have an actual file/URL
171
- st.write(scenario["diagram"]) # placeholder text
172
-
173
- st.divider()
174
-
175
- # Stages 1..4; each stage remains visible once unlocked, within the current loop
176
- for stage_idx in range(1, 5):
177
- if not st.session_state.stage_visible[stage_idx]:
178
- continue
179
-
180
- st.markdown(f"### {scenario['stages'][stage_idx-1]['name']}")
181
- prompt = scenario['stages'][stage_idx-1]['prompt']
182
- expected = scenario['stages'][stage_idx-1]['answer']
183
-
184
- st.write(prompt)
185
- ukey = _key("input", stage_idx, st.session_state.loop_idx, st.session_state.scenario_idx)
186
- st.session_state.answers[stage_idx] = st.text_input(
187
- label="Your answer",
188
- key=ukey,
189
- value=st.session_state.answers.get(stage_idx, ""),
190
- placeholder="Type your response...",
191
  )
192
-
193
- cols = st.columns([1, 5])
194
- with cols[0]:
195
- if st.button("Check", key=_key("check", stage_idx, st.session_state.loop_idx, st.session_state.scenario_idx)):
196
- ok = validate_answer(st.session_state.answers[stage_idx], expected)
197
- if ok:
198
- st.success("Correct! Proceeding to the next stage…")
199
- next_stage(stage_idx)
200
- st.experimental_rerun()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  else:
202
- # No hints; just feedback per your request to remove on-the-nose clues
203
- st.error("Not quite. Try again.")
204
-
205
- with cols[1]:
206
- if st.session_state.stage_correct[stage_idx]:
207
- st.info("✅ Completed")
208
-
209
- st.divider()
210
-
211
- # Bottom section: PDF appears ONLY after the required # of loops is completed for this scenario
212
- loops_done = st.session_state.loops_completed[scenario["id"]]
213
- if loops_done >= REQUIRED_LOOPS_FOR_PDF:
214
- st.markdown("#### Download your Proof-of-Completion (after two full loops)")
215
- pdf_bytes = generate_pdf_bytes(scenario_title=scenario["title"], loops_done=loops_done)
216
- st.download_button(
217
- label="⬇️ Generate & Download PDF",
218
- file_name=f"Core_Concepts_Review_{scenario['id']}_loops{loops_done}.pdf",
219
- mime="application/pdf",
220
- data=pdf_bytes,
221
- key=_key("pdf", st.session_state.scenario_idx, st.session_state.loop_idx),
222
- use_container_width=True,
223
- )
224
-
225
- # Footer spacing
226
- st.write("")
227
- st.write("")
228
- st.caption("© Your Course • Prepared for student practice • No hints shown on incorrect answers")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import streamlit as st
2
+ import streamlit.components.v1 as components
3
+ from io import BytesIO
4
+ from datetime import datetime
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
+ # --- PDF support (ReportLab) ---
7
+ try:
8
+ from reportlab.pdfgen import canvas
9
+ from reportlab.lib.pagesizes import letter
10
+ REPORTLAB_AVAILABLE = True
11
+ except Exception:
12
+ REPORTLAB_AVAILABLE = False
13
+
14
+ st.set_page_config(page_title="Core Concepts Review", page_icon="🧬", layout="centered")
15
+
16
+ # ==============================
17
+ # Content
18
+ # ==============================
19
+ SCENARIOS = {
20
+ "Blood Pressure (Hypotension)": [
21
+ {
22
+ "loop_name": "Nervous loop (Baroreflex)",
23
+ "sensor_options": ["Chemoreceptor", "Mechanoreceptor", "Thermoreceptor"],
24
+ "sensor_correct": "Mechanoreceptor",
25
+ "control_options": ["Hypothalamus", "Medulla", "Prefrontal cortex"],
26
+ "control_correct": "Medulla",
27
+ "effector_options":["Pancreas", "Skeletal Muscle", "Heart and Blood Vessels"],
28
+ "effector_correct":"Heart and Blood Vessels",
29
+ "stage2_desc": "Afferent neurons release neurotransmitters (hydrophilic) across a synapse to the control center neuron.",
30
+ "stage2_sig": "Paracrine",
31
+ "stage2_rec": "Cell membrane",
32
+ "stage2_grad": "Concentration",
33
+ "stage3_desc": "Controller neurons signal effectors via neurotransmitters (hydrophilic) across synapses.",
34
+ "stage3_sig": "Paracrine",
35
+ "stage3_rec": "Cell membrane",
36
+ "stage3_grad": "Concentration",
37
+ "outcome_question": "What will be the following response?",
38
+ "outcome_options": [
39
+ "Cardiac output increases and vasoconstriction increases",
40
+ "Cardiac output decreases and vasodilation increases",
41
+ "No change"
42
+ ],
43
+ "outcome_correct": "Cardiac output increases and vasoconstriction increases",
44
+ },
45
+ {
46
+ "loop_name": "Renal loop (RAAS)",
47
+ "sensor_options": ["Juxtaglomerular apparatus (kidney)", "Baroreceptors (carotid sinus)", "Osmoreceptors (hypothalamus)"],
48
+ "sensor_correct": "Juxtaglomerular apparatus (kidney)",
49
+ "control_options": ["Kidney (renin release)", "Medulla", "Anterior pituitary"],
50
+ "control_correct": "Kidney (renin release)",
51
+ "effector_options":["Kidney tubules (Na⁺ & water reabsorption)", "Sweat glands", "Airway smooth muscle"],
52
+ "effector_correct":"Kidney tubules (Na⁺ & water reabsorption)",
53
+ "stage2_desc": "Renin → angiotensin II (hydrophilic) travels **in the blood circulation** to reach distant targets.",
54
+ "stage2_sig": "Endocrine",
55
+ "stage2_rec": "Cell membrane",
56
+ "stage2_grad": "Pressure",
57
+ "stage3_desc": "Aldosterone (lipophilic steroid) travels **in the blood circulation** to tubular cells.",
58
+ "stage3_sig": "Endocrine",
59
+ "stage3_rec": "Inside the cell",
60
+ "stage3_grad": "Pressure",
61
+ "outcome_question": "What will be the following response?",
62
+ "outcome_options": [
63
+ "Sodium & water reabsorption increases",
64
+ "Sodium excretion increases",
65
+ "Urine volume increases"
66
+ ],
67
+ "outcome_correct": "Sodium & water reabsorption increases",
68
+ },
69
+ ],
70
+
71
+ "Glucose (Post-meal Hyperglycemia)": [
72
+ {
73
+ "loop_name": "Insulin loop",
74
+ "sensor_options": ["Pancreatic beta cells", "Pancreatic alpha cells", "Chemoreceptors (carotid)"],
75
+ "sensor_correct": "Pancreatic beta cells",
76
+ "control_options": ["Pancreas (islets of Langerhans)", "Hypothalamus", "Adrenal cortex"],
77
+ "control_correct": "Pancreas (islets of Langerhans)",
78
+ "effector_options":["Skeletal muscle & adipose", "Heart and Blood Vessels", "Kidney tubules"],
79
+ "effector_correct":"Skeletal muscle & adipose",
80
+ "stage2_desc": "A peptide messenger (hydrophilic) enters the bloodstream and circulates to distant tissues.",
81
+ "stage2_sig": "Endocrine",
82
+ "stage2_rec": "Cell membrane",
83
+ "stage2_grad": "Pressure",
84
+ "stage3_desc": "The messenger reaches distant tissues via circulation to increase glucose uptake.",
85
+ "stage3_sig": "Endocrine",
86
+ "stage3_rec": "Cell membrane",
87
+ "stage3_grad": "Pressure",
88
+ "outcome_question": "What will be the following response?",
89
+ "outcome_options": [
90
+ "Cellular glucose uptake increases",
91
+ "Hepatic glucose output increases",
92
+ "Lipolysis increases"
93
+ ],
94
+ "outcome_correct": "Cellular glucose uptake increases",
95
+ },
96
+ {
97
+ "loop_name": "Kidney excretion loop (overflow)",
98
+ "sensor_options": ["Kidney (proximal tubule)", "Pancreatic beta cells", "Baroreceptors (carotid)"],
99
+ "sensor_correct": "Kidney (proximal tubule)",
100
+ "control_options": ["Kidney (local tubular control)", "Pancreas", "Medulla"],
101
+ "control_correct": "Kidney (local tubular control)",
102
+ "effector_options":[
103
+ "Glucose excretion into the urine increases",
104
+ "Renal glucose reabsorption increases",
105
+ "Insulin secretion increases"
106
+ ],
107
+ "effector_correct":"Glucose excretion into the urine increases",
108
+ "stage2_desc": "Short-range paracrine signals in the tubule adjust transport locally (neighbor-to-neighbor; not via blood).",
109
+ "stage2_sig": "Paracrine",
110
+ "stage2_rec": "Cell membrane",
111
+ "stage2_grad": "Concentration",
112
+ "stage3_desc": "Local control continues at transporters within the same tissue.",
113
+ "stage3_sig": "Paracrine",
114
+ "stage3_rec": "Cell membrane",
115
+ "stage3_grad": "Concentration",
116
+ "outcome_question": "What will be the following response?",
117
+ "outcome_options": [
118
+ "Glucose excretion into the urine increases",
119
+ "Renal glucose reabsorption increases",
120
+ "Insulin secretion increases"
121
+ ],
122
+ "outcome_correct": "Glucose excretion into the urine increases",
123
+ },
124
+ ],
125
+
126
+ "Temperature (Hypothermia)": [
127
+ {
128
+ "loop_name": "Shivering loop (somatic motor)",
129
+ "sensor_options": ["Thermoreceptor", "Mechanoreceptor", "Chemoreceptor"],
130
+ "sensor_correct": "Thermoreceptor",
131
+ "control_options": ["Hypothalamus", "Medulla", "Prefrontal cortex"],
132
+ "control_correct": "Hypothalamus",
133
+ "effector_options":["Skeletal Muscle", "Pancreas", "Cutaneous blood vessels"],
134
+ "effector_correct":"Skeletal Muscle",
135
+ "stage2_desc": "Afferent neurons relay to the hypothalamus via neurotransmitters (hydrophilic) across synapses.",
136
+ "stage2_sig": "Paracrine",
137
+ "stage2_rec": "Cell membrane",
138
+ "stage2_grad": "Concentration",
139
+ "stage3_desc": "Motor pathways direct skeletal muscle via neurotransmitters across synapses.",
140
+ "stage3_sig": "Paracrine",
141
+ "stage3_rec": "Cell membrane",
142
+ "stage3_grad": "Concentration",
143
+ "outcome_question": "What will be the following response?",
144
+ "outcome_options": [
145
+ "Muscle contraction",
146
+ "Muscle relaxation",
147
+ "Cutaneous vasodilation"
148
+ ],
149
+ "outcome_correct": "Muscle contraction",
150
+ },
151
+ {
152
+ "loop_name": "Skin vessel loop (vasoconstriction/vasodilation)",
153
+ "sensor_options": ["Thermoreceptor", "Mechanoreceptor", "Chemoreceptor"],
154
+ "sensor_correct": "Thermoreceptor",
155
+ "control_options": ["Hypothalamus", "Medulla", "Anterior pituitary"],
156
+ "control_correct": "Hypothalamus",
157
+ "effector_options":["Cutaneous blood vessels", "Heart and Blood Vessels", "Skeletal Muscle"],
158
+ "effector_correct":"Cutaneous blood vessels",
159
+ "stage2_desc": "Afferent neurons signal the controller via neurotransmitters (hydrophilic) across synapses.",
160
+ "stage2_sig": "Paracrine",
161
+ "stage2_rec": "Cell membrane",
162
+ "stage2_grad": "Concentration",
163
+ "stage3_desc": "Autonomic outputs alter peripheral vessel tone via neurotransmitters (synaptic).",
164
+ "stage3_sig": "Paracrine",
165
+ "stage3_rec": "Cell membrane",
166
+ "stage3_grad": "Concentration",
167
+ "outcome_question": "What will be the following response?",
168
+ "outcome_options": [
169
+ "Cutaneous vasoconstriction increases",
170
+ "Cutaneous vasodilation increases",
171
+ "Sweating increases"
172
+ ],
173
+ "outcome_correct": "Cutaneous vasoconstriction increases",
174
+ },
175
+ ],
176
+ }
177
+
178
+ # ==============================
179
+ # Session state & helpers
180
+ # ==============================
181
  def init_state():
182
+ if "scenario" not in st.session_state:
183
+ st.session_state.scenario = list(SCENARIOS.keys())[0]
184
  if "loop_idx" not in st.session_state:
185
+ st.session_state.loop_idx = 0
186
+ if "assign" not in st.session_state:
187
+ st.session_state.assign = {"sensor": None, "control": None, "effector": None}
188
+ # per-loop highest unlocked stage: token -> 1..4
189
+ if "unlock" not in st.session_state:
190
+ st.session_state.unlock = {}
191
+ if "msgs" not in st.session_state:
192
+ st.session_state.msgs = {"s1":"", "s2":"", "s3":"", "s4":""}
193
+ if "progress" not in st.session_state:
194
+ st.session_state.progress = {sc: [False]*len(SCENARIOS[sc]) for sc in SCENARIOS}
195
+ if "nonce" not in st.session_state:
196
+ st.session_state.nonce = 0
197
+
198
+ def purge_widget_state():
199
+ """Remove old widget values so prior-loop widgets can't linger."""
200
+ prefixes = ("s1_", "st2_", "st3_", "st4_", "chk1_", "chk2_", "chk3_", "finish_", "cert_")
201
+ for k in list(st.session_state.keys()):
202
+ if any(k.startswith(p) for p in prefixes):
203
+ st.session_state.pop(k, None)
204
+
205
+ def loop_token() -> str:
206
+ return f"{st.session_state.scenario}|{st.session_state.loop_idx}"
207
+
208
+ def key_suffix() -> str:
209
+ # unique across loops and resets
210
+ return f"{st.session_state.scenario}_{st.session_state.loop_idx}_{st.session_state.nonce}"
211
+
212
+ def current_loop():
213
+ return SCENARIOS[st.session_state.scenario][st.session_state.loop_idx]
214
+
215
+ def safe_rerun():
216
+ try:
217
+ st.rerun()
218
+ except Exception:
219
+ st.experimental_rerun()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
+ def reset_loop():
222
+ # clear all loop-specific state
223
+ purge_widget_state()
224
+ st.session_state.assign = {"sensor": None, "control": None, "effector": None}
225
+ st.session_state.msgs = {"s1":"", "s2":"", "s3":"", "s4":""}
226
+ st.session_state.unlock = {} # ensures next loop starts at Stage 1 only
227
+ st.session_state.nonce += 1 # force new widget keys
228
+
229
+ def set_scenario(name):
230
+ st.session_state.scenario = name
231
+ st.session_state.loop_idx = 0
232
+ reset_loop()
233
+
234
+ def next_loop_or_finish():
235
+ st.session_state.progress[st.session_state.scenario][st.session_state.loop_idx] = True
236
+ if st.session_state.loop_idx + 1 < len(SCENARIOS[st.session_state.scenario]):
237
+ st.session_state.loop_idx += 1
238
+ reset_loop()
239
+ st.session_state.msgs["s1"] = "Great—now build the second loop for this variable."
240
+ else:
241
+ reset_loop()
242
+ st.session_state.msgs["s1"] = "Scenario complete! You can generate a certificate below."
243
+ safe_rerun()
244
+
245
+ def all_loops_complete_for_current_scenario() -> bool:
246
+ return all(st.session_state.progress[st.session_state.scenario])
247
+
248
+ # ==============================
249
+ # Diagram (arrows removed)
250
+ # ==============================
251
+ def diagram_html(sensor_txt, control_txt, effector_txt):
252
+ sx, sy, sw, sh = 110, 260, 240, 56 # Sensor (left)
253
+ cx, cy, cw, ch = 420, 70, 220, 56 # Control (top)
254
+ ex, ey, ew, eh = 740, 320, 260, 56 # Effector (right)
255
+
256
+ base_x, base_y, base_w, base_h = 330, 520, 360, 26
257
+ base_mid_x = base_x + base_w/2
258
+
259
+ html = f"""
260
+ <div style="position:relative;width:100%;background:#f6f5ff;border:1px solid #e3e3f8;border-radius:16px;overflow:hidden;">
261
+ <svg viewBox="0 0 1000 620" style="width:100%;height:auto;display:block" preserveAspectRatio="xMidYMid meet">
262
+ <rect x="{base_x}" y="{base_y}" width="{base_w}" height="{base_h}" rx="8"
263
+ fill="none" stroke="#4b2bb3" stroke-width="5"/>
264
+ <polygon points="{base_mid_x - 20},{base_y + base_h + 2}
265
+ {base_mid_x + 20},{base_y + base_h + 2}
266
+ {base_mid_x},{base_y + base_h + 44}"
267
+ fill="#4b2bb3"/>
268
+ <text x="{base_x + 70}" y="{base_y + base_h - 8}" font-size="16"
269
+ text-anchor="middle" fill="#4b2bb3">Imbalance</text>
270
+ <text x="{base_x + base_w - 70}" y="{base_y + base_h - 8}" font-size="16"
271
+ text-anchor="middle" fill="#4b2bb3">Balance</text>
272
+
273
+ <rect x="{sx}" y="{sy}" width="{sw}" height="{sh}" rx="12" fill="#e9ecff" stroke="#6b57e5"/>
274
+ <text x="{sx+sw/2}" y="{sy+sh/2+6}" font-size="16" text-anchor="middle" fill="#1f1d2e">{sensor_txt}</text>
275
+
276
+ <rect x="{cx}" y="{cy}" width="{cw}" height="{ch}" rx="12" fill="#e9ecff" stroke="#6b57e5"/>
277
+ <text x="{cx+cw/2}" y="{cy+ch/2+6}" font-size="16" text-anchor="middle" fill="#1f1d2e">{control_txt}</text>
278
+
279
+ <rect x="{ex}" y="{ey}" width="{ew}" height="{eh}" rx="12" fill="#e9ecff" stroke="#6b57e5"/>
280
+ <text x="{ex+ew/2}" y="{ey+eh/2+6}" font-size="16" text-anchor="middle" fill="#1f1d2e">{effector_txt}</text>
281
+ </svg>
282
+ </div>
283
+ """
284
+ return html
285
+
286
+ # ==============================
287
+ # Certificate
288
+ # ==============================
289
+ def generate_certificate_pdf(student_name: str, scenario_name: str, loops_done: list[str]) -> bytes:
290
+ buf = BytesIO()
291
+ c = canvas.Canvas(buf, pagesize=letter)
292
+ W, H = letter
293
+
294
+ c.setFont("Helvetica-Bold", 28)
295
+ c.drawCentredString(W/2, H - 100, "Certificate of Completion")
296
+
297
+ c.setFont("Helvetica", 13)
298
+ c.drawCentredString(W/2, H - 140, "This certifies that")
299
+ c.setFont("Helvetica-Bold", 20)
300
+ c.drawCentredString(W/2, H - 170, student_name if student_name.strip() else "Student")
301
+ c.setFont("Helvetica", 13)
302
+ c.drawCentredString(W/2, H - 200, "has successfully completed the scenario")
303
+ c.setFont("Helvetica-Bold", 16)
304
+ c.drawCentredString(W/2, H - 225, scenario_name)
305
 
306
  c.setFont("Helvetica", 12)
307
+ y = H - 270
308
+ c.drawCentredString(W/2, y, "Completed loops:")
309
  y -= 18
310
+ for lp in loops_done:
311
+ c.drawCentredString(W/2, y, f"• {lp}")
312
+ y -= 16
313
+
314
+ c.setFont("Helvetica-Oblique", 11)
315
+ c.drawCentredString(W/2, 80, f"Issued on {datetime.now().strftime('%Y-%m-%d %H:%M')}")
316
+
317
+ c.showPage(); c.save()
318
+ buf.seek(0)
319
+ return buf.read()
320
+
321
+ # ==============================
322
+ # UI
323
+ # ==============================
324
+ def init_and_render():
325
+ init_state()
326
+
327
+ st.title("Core Concepts Review")
328
+
329
+ scenario_list = list(SCENARIOS.keys())
330
+ idx = scenario_list.index(st.session_state.scenario)
331
+
332
+ def on_change_scenario():
333
+ st.session_state.scenario = st.session_state["scenario_select"]
334
+ st.session_state.loop_idx = 0
335
+ reset_loop()
336
+ safe_rerun()
337
+
338
+ top_col1, top_col2 = st.columns([2,1])
339
+ with top_col1:
340
+ st.selectbox("Scenario", scenario_list, index=idx, key="scenario_select", on_change=on_change_scenario)
341
+ with top_col2:
342
+ if st.button("Reset Loop"):
343
+ reset_loop()
344
+ safe_rerun()
345
+
346
+ cloop = current_loop()
347
+ token = loop_token()
348
+ ksfx = key_suffix()
349
+
350
+ # ensure unlock entry for this loop
351
+ if token not in st.session_state.unlock:
352
+ st.session_state.unlock[token] = 1
353
+
354
+ # ---------- Diagram ----------
355
+ st.subheader(f"Stage 1 · Build the negative feedback loop — **{cloop['loop_name']}**")
356
+ labels = st.session_state.assign
357
+ components.html(
358
+ diagram_html(
359
+ labels['sensor'] or "Sensor",
360
+ labels['control'] or "Control Center",
361
+ labels['effector'] or "Effector(s)"
362
+ ),
363
+ height=660, scrolling=False
 
 
 
 
364
  )
365
+ st.markdown("---")
366
+
367
+ # ---------- Placeholders for cumulative rendering ----------
368
+ ph1 = st.container()
369
+ ph2 = st.container()
370
+ ph3 = st.container()
371
+ ph4 = st.container()
372
+
373
+ # ---------------- Stage 1 (always visible) ----------------
374
+ with ph1:
375
+ st.write("**Sensor options**")
376
+ sel_s = st.radio("Sensor", cloop["sensor_options"], index=None, horizontal=True, key=f"s1_sensor_{ksfx}")
377
+ if sel_s is not None: st.session_state.assign["sensor"] = sel_s
378
+
379
+ st.write("**Control center options**")
380
+ sel_c = st.radio("Control center", cloop["control_options"], index=None, horizontal=True, key=f"s1_control_{ksfx}")
381
+ if sel_c is not None: st.session_state.assign["control"] = sel_c
382
+
383
+ st.write("**Effector options**")
384
+ sel_e = st.radio("Effector(s)", cloop["effector_options"], index=None, horizontal=True, key=f"s1_effector_{ksfx}")
385
+ if sel_e is not None: st.session_state.assign["effector"] = sel_e
386
+
387
+ if st.button("Check Stage 1", key=f"chk1_{ksfx}"):
388
+ a = st.session_state.assign
389
+ if not all([a["sensor"], a["control"], a["effector"]]):
390
+ st.session_state.msgs["s1"] = "Please complete all answers."
391
  else:
392
+ ok = (
393
+ a["sensor"] == cloop["sensor_correct"] and
394
+ a["control"] == cloop["control_correct"] and
395
+ a["effector"]== cloop["effector_correct"]
396
+ )
397
+ if ok:
398
+ st.session_state.msgs["s1"] = "Great! Proceed to Stage 2."
399
+ st.session_state.unlock[token] = max(st.session_state.unlock.get(token,1), 2)
400
+ safe_rerun()
401
+ else:
402
+ st.session_state.msgs["s1"] = "Please recheck your answers."
403
+ st.info(st.session_state.msgs["s1"])
404
+
405
+ # helper
406
+ def visible(stage_no: int) -> bool:
407
+ return st.session_state.unlock.get(token, 1) >= stage_no
408
+
409
+ # ---------------- Stage 2 ----------------
410
+ if visible(2):
411
+ with ph2:
412
+ st.subheader("Stage 2 · Sensor → Control Center")
413
+ st.markdown(cloop["stage2_desc"])
414
+ sig = st.radio("Signaling", ["Autocrine","Paracrine","Endocrine"], index=None, horizontal=True, key=f"st2_sig_{ksfx}")
415
+ rec = st.radio("Receptor", ["Cell membrane","Inside the cell"], index=None, horizontal=True, key=f"st2_rec_{ksfx}")
416
+ grd = st.radio("Gradient (primary transport)", ["Concentration","Electrochemical","Pressure"], index=None, horizontal=True, key=f"st2_grad_{ksfx}")
417
+
418
+ if st.button("Check Stage 2", key=f"chk2_{ksfx}"):
419
+ if sig==cloop["stage2_sig"] and rec==cloop["stage2_rec"] and grd==cloop["stage2_grad"]:
420
+ st.session_state.msgs["s2"] = "Correct! Continue to Stage 3."
421
+ st.session_state.unlock[token] = max(st.session_state.unlock.get(token,2), 3)
422
+ safe_rerun()
423
+ else:
424
+ st.session_state.msgs["s2"] = "Please recheck your answers."
425
+ st.info(st.session_state.msgs["s2"])
426
+
427
+ # ---------------- Stage 3 ----------------
428
+ if visible(3):
429
+ with ph3:
430
+ st.subheader("Stage 3 · Control Center → Effectors")
431
+ st.markdown(cloop["stage3_desc"])
432
+ sig3 = st.radio("Signaling", ["Autocrine","Paracrine","Endocrine"], index=None, horizontal=True, key=f"st3_sig_{ksfx}")
433
+ rec3 = st.radio("Receptor", ["Cell membrane","Inside the cell"], index=None, horizontal=True, key=f"st3_rec_{ksfx}")
434
+ grd3 = st.radio("Gradient (primary transport)", ["Concentration","Electrochemical","Pressure"], index=None, horizontal=True, key=f"st3_grad_{ksfx}")
435
+
436
+ if st.button("Check Stage 3", key=f"chk3_{ksfx}"):
437
+ if sig3==cloop["stage3_sig"] and rec3==cloop["stage3_rec"] and grd3==cloop["stage3_grad"]:
438
+ st.session_state.msgs["s3"] = "Nice! Final question…"
439
+ st.session_state.unlock[token] = max(st.session_state.unlock.get(token,3), 4)
440
+ safe_rerun()
441
+ else:
442
+ st.session_state.msgs["s3"] = "Please recheck your answers."
443
+ st.info(st.session_state.msgs["s3"])
444
+
445
+ # ---------------- Stage 4 ----------------
446
+ if visible(4):
447
+ with ph4:
448
+ st.subheader("Stage 4 · Outcome")
449
+ st.markdown(f"**{cloop['outcome_question']}**")
450
+ ans = st.radio("Choose one:", cloop["outcome_options"], index=None, key=f"st4_ans_{ksfx}")
451
+ if st.button("Finish Loop", key=f"finish_{ksfx}"):
452
+ if ans == cloop["outcome_correct"]:
453
+ st.session_state.msgs["s4"] = "✅ Correct."
454
+ next_loop_or_finish()
455
+ else:
456
+ st.session_state.msgs["s4"] = "Please recheck your answers."
457
+ st.info(st.session_state.msgs["s4"])
458
+
459
+ # ---------- CERTIFICATE (bottom only) ----------
460
+ st.markdown("---")
461
+ if all_loops_complete_for_current_scenario() and REPORTLAB_AVAILABLE:
462
+ st.success("Scenario complete. Generate your PDF certificate below.")
463
+ student_name = st.text_input("Student name for certificate", "")
464
+ loops_list = [lp["loop_name"] for lp in SCENARIOS[st.session_state.scenario]]
465
+ if st.button("Generate PDF Certificate", key=f"cert_{ksfx}"):
466
+ pdf_bytes = generate_certificate_pdf(student_name, st.session_state.scenario, loops_list)
467
+ st.download_button(
468
+ "Download certificate",
469
+ data=pdf_bytes,
470
+ file_name=f"certificate_{st.session_state.scenario.replace(' ','_')}.pdf",
471
+ mime="application/pdf"
472
+ )
473
+ elif all_loops_complete_for_current_scenario() and not REPORTLAB_AVAILABLE:
474
+ st.warning("Certificates are disabled (reportlab not available). Add 'reportlab' to requirements.txt.")
475
+
476
+ st.caption("Tip: Use 'Reset Loop' to try again or switch scenarios above.")
477
+
478
+ # Run
479
+ init_and_render()