Nyingi101 commited on
Commit
e663f4e
ยท
verified ยท
1 Parent(s): a62b942

Deploy AI Math Tutor

Browse files
demo.py CHANGED
@@ -3,19 +3,11 @@ demo.py โ€” Child-facing Gradio demo for the AI Math Tutor.
3
 
4
  Run:
5
  pip install -r requirements.txt
6
- python demo.py
7
-
8
- First-time open sequence (first 90 seconds):
9
- 0 s โ€” Warm welcome audio plays in Kinyarwanda + English
10
- 5 s โ€” Big, icon-only "START" button appears (no reading required)
11
- 10 s โ€” If child hasn't tapped: gentle audio prompt replays, animated arrow bounces
12
- 15 s โ€” Diagnostic probe #1 appears (lowest difficulty counting item)
13
- ~90 s โ€” Five diagnostic probes complete; BKT initialised; adaptive session begins
14
  """
15
  from __future__ import annotations
16
 
17
- import json
18
- import os
19
  import time
20
  from pathlib import Path
21
 
@@ -26,13 +18,13 @@ from tutor import curriculum_loader as cl
26
  from tutor.adaptive import LearnerState
27
  from tutor.lang_detect import detect as lang_detect, reply_lang
28
  from tutor.asr_adapt import transcribe, extract_integer, is_silence
29
- from tutor.visual_grounding import render_counting_stimulus, count_objects
30
  from tutor.model_loader import generate_feedback
31
  from tutor.progress_store import ProgressStore
32
 
33
- # ------------------------------------------------------------------
34
  # Paths
35
- # ------------------------------------------------------------------
36
  DATA_DIR = Path("data/T3.1_Math_Tutor")
37
  DB_PATH = Path("tutor_progress.db")
38
  CURRICULUM_PATH = DATA_DIR / "curriculum_full.json"
@@ -42,15 +34,15 @@ if not CURRICULUM_PATH.exists():
42
  ALL_ITEMS = cl.load(CURRICULUM_PATH)
43
  STORE = ProgressStore(DB_PATH)
44
 
45
- # ------------------------------------------------------------------
46
- # Session state (Gradio uses dict-based state)
47
- # ------------------------------------------------------------------
48
 
49
  def new_session(learner_id: str, lang: str = "en", age: int = 7) -> dict:
50
  saved = STORE.load_latest_state(learner_id)
51
  if saved:
52
  state = LearnerState.from_dict(saved)
53
- state.age = age # allow age update each session
54
  else:
55
  state = LearnerState(learner_id=learner_id, lang=lang, age=age)
56
  STORE.add_learner(learner_id, display_name=learner_id)
@@ -68,13 +60,14 @@ def new_session(learner_id: str, lang: str = "en", age: int = 7) -> dict:
68
  "queue": probes,
69
  "current_item": None,
70
  "session_id": STORE.start_session(learner_id, state.to_dict(), lang),
71
- "phase": "diagnostic", # diagnostic โ†’ adaptive
72
  "silence_count": 0,
 
 
73
  }
74
 
75
 
76
  def get_next_item(sess: dict) -> dict:
77
- """Pop next item from queue or select adaptively."""
78
  state: LearnerState = sess["state"]
79
  if sess["queue"]:
80
  item = sess["queue"].pop(0)
@@ -86,32 +79,25 @@ def get_next_item(sess: dict) -> dict:
86
  return sess
87
 
88
 
89
- # ------------------------------------------------------------------
90
- # Response processing
91
- # ------------------------------------------------------------------
92
 
93
  def process_response(
94
  sess: dict,
95
  audio_data: tuple | None,
96
  tap_answer: str,
97
- ) -> tuple[dict, str, np.ndarray | None, str]:
98
- """
99
- Handle a child response (voice or tap).
100
- Returns: (updated_sess, feedback_text, image_array, debug_info)
101
- """
102
  item = sess.get("current_item")
103
  if item is None:
104
- return sess, "Let's start!", None, ""
105
 
106
  state: LearnerState = sess["state"]
107
  lang = sess["lang"]
108
  t0 = time.time()
109
 
110
- # ------------------------------------------------------------------
111
- # 1. Parse answer
112
- # ------------------------------------------------------------------
113
  child_text = ""
114
- detected_lang = lang
115
 
116
  if audio_data is not None:
117
  sr, audio_np = audio_data
@@ -126,69 +112,47 @@ def process_response(
126
 
127
  if is_silence(audio_f32):
128
  sess["silence_count"] += 1
129
- if sess["silence_count"] >= 2:
130
- # Two consecutive silences โ†’ repeat prompt gently
131
- return sess, _silence_prompt(lang), _item_image(item), ""
132
- return sess, _silence_prompt(lang), _item_image(item), ""
133
 
134
  sess["silence_count"] = 0
135
- child_text, detected_lang, conf = transcribe(audio_f32, lang_hint=lang)
136
  if detected_lang == "mix":
137
  lang = reply_lang(detected_lang, fallback=lang)
138
- elif detected_lang in ("en", "fr", "kin"):
139
  lang = detected_lang
140
  sess["lang"] = lang
141
 
142
  elif tap_answer.strip():
143
  child_text = tap_answer.strip()
144
 
145
- # ------------------------------------------------------------------
146
- # 2. Score
147
- # ------------------------------------------------------------------
148
  child_int = extract_integer(child_text)
149
  is_correct = child_int is not None and child_int == item["answer_int"]
150
 
151
- # ------------------------------------------------------------------
152
- # 3. Visual grounding items
153
- # ------------------------------------------------------------------
154
  image_arr = _item_image(item)
155
 
156
- # ------------------------------------------------------------------
157
- # 4. Update knowledge state
158
- # ------------------------------------------------------------------
159
  state.record_response(item, is_correct)
160
  latency_ms = int((time.time() - t0) * 1000)
161
  STORE.log_response(
162
- sess["learner_id"],
163
- sess["session_id"],
164
- item["id"],
165
- item["skill"],
166
- item.get("difficulty", 5),
167
- is_correct,
168
- latency_ms,
169
  )
170
 
171
- # ------------------------------------------------------------------
172
- # 5. Generate feedback
173
- # ------------------------------------------------------------------
174
  feedback = generate_feedback(is_correct, item["answer_int"], lang, child_text)
175
 
176
- # ------------------------------------------------------------------
177
- # 6. Dyscalculia early warning
178
- # ------------------------------------------------------------------
179
  warnings = state.dyscalculia_warning()
180
  if warnings:
181
- feedback += f"\n\n[Parent: {', '.join(warnings)} skill(s) may need teacher attention]"
 
 
 
 
182
 
183
- # ------------------------------------------------------------------
184
- # 7. Advance to next item
185
- # ------------------------------------------------------------------
186
  STORE.end_session(sess["session_id"], state.to_dict())
187
  sess = get_next_item(sess)
188
 
189
- total_elapsed = time.time() - t0
190
- debug = f"item={item['id']} correct={is_correct} lang={lang} latency={total_elapsed:.2f}s"
191
- return sess, feedback, image_arr, debug
192
 
193
 
194
  def _item_image(item: dict) -> np.ndarray | None:
@@ -200,17 +164,94 @@ def _item_image(item: dict) -> np.ndarray | None:
200
 
201
  def _silence_prompt(lang: str) -> str:
202
  msgs = {
203
- "en": "I didn't hear you. Try again! Tap the number or speak.",
204
- "fr": "Je ne t'ai pas entendu. Essaie encore !",
205
- "kin": "Sinumvise. Ongera ugerageze!",
206
- "sw": "Sikukusikia. Jaribu tena! Gonga nambari au sema.",
207
  }
208
  return msgs.get(lang, msgs["en"])
209
 
210
 
211
- # ------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  # Gradio UI
213
- # ------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
  def build_ui():
216
  theme = gr.themes.Soft(
@@ -218,62 +259,109 @@ def build_ui():
218
  font=[gr.themes.GoogleFont("Nunito"), "sans-serif"],
219
  )
220
 
221
- with gr.Blocks(title="AI Math Tutor") as demo:
222
  sess_state = gr.State(None)
223
 
 
224
  gr.HTML("""
225
- <div style="text-align:center; padding:20px">
226
- <h1 style="font-size:2.5em; color:#1a56db">๐Ÿงฎ Math Tutor</h1>
227
- <p style="font-size:1.2em; color:#555">
228
- Mwarimu wa Hesabu &nbsp;|&nbsp; Tuteur Maths &nbsp;|&nbsp; Math Tutor
 
 
 
 
 
229
  </p>
230
  </div>
231
  """)
232
 
233
- with gr.Row():
234
- with gr.Column(scale=1):
 
 
235
  learner_id_box = gr.Textbox(
236
- label="Learner name / Izina",
237
  placeholder="e.g. Amani",
238
  max_lines=1,
239
  )
240
  age_radio = gr.Radio(
241
- choices=[("5 yrs", 5), ("6 yrs", 6), ("7 yrs", 7), ("8 yrs", 8), ("9 yrs", 9)],
 
242
  value=7,
243
- label="Age / Imyaka / ร‚ge / Umri",
244
  )
245
  lang_radio = gr.Radio(
246
- choices=[("Kinyarwanda", "kin"), ("Kiswahili", "sw"), ("Franรงais", "fr"), ("English", "en")],
 
 
 
 
 
247
  value="kin",
248
- label="Language / Lugha / Langue / Ururimi",
249
  )
250
- start_btn = gr.Button("โ–ถ START / TANGIRA", variant="primary", size="lg")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
- with gr.Column(scale=2):
253
- question_md = gr.Markdown("### Press START to begin!")
254
- item_image = gr.Image(label="", height=200, show_label=False)
255
  audio_input = gr.Audio(
256
  sources=["microphone"],
257
  type="numpy",
258
- label="๐ŸŽค Say your answer / Vuga igisubizo",
259
- )
260
- tap_input = gr.Textbox(
261
- label="Or type / Andika",
262
- placeholder="e.g. 5",
263
- max_lines=1,
264
  )
265
- submit_btn = gr.Button("โœ… Submit / Ohereza", size="lg")
266
- feedback_box = gr.Textbox(
267
- label="Feedback",
268
- interactive=False,
269
- lines=3,
270
  )
271
- debug_box = gr.Textbox(label="Debug", visible=False, interactive=False)
272
 
273
- # ------------------------------------------------------------------
274
- # Event handlers
275
- # ------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  def on_start(learner_id, age, lang):
278
  if not learner_id.strip():
279
  learner_id = "learner_1"
@@ -283,31 +371,65 @@ def build_ui():
283
  if item:
284
  q = cl.stem(item, lang)
285
  img = _item_image(item)
286
- return sess, f"### {q}", img, None, "", ""
287
- return sess, "### No items available", None, None, "", ""
 
 
 
 
 
 
 
 
 
288
 
289
  start_btn.click(
290
  on_start,
291
  inputs=[learner_id_box, age_radio, lang_radio],
292
- outputs=[sess_state, question_md, item_image, audio_input, tap_input, feedback_box],
293
  )
294
 
295
- def on_submit(sess, audio, tap):
 
296
  if sess is None:
297
- return None, "### Press START first", None, "", "", "no session"
298
- sess, feedback, img, debug = process_response(sess, audio, tap)
 
 
 
 
 
 
 
 
 
299
  item = sess.get("current_item")
300
- if item:
301
- q = cl.stem(item, sess["lang"])
302
- question = f"### {q}"
303
- else:
304
- question = "### All done! Great work!"
305
- return sess, question, img, None, "", feedback, debug
306
-
307
- submit_btn.click(
308
- on_submit,
309
- inputs=[sess_state, audio_input, tap_input],
310
- outputs=[sess_state, question_md, item_image, audio_input, tap_input, feedback_box, debug_box],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  )
312
 
313
  return demo, theme
 
3
 
4
  Run:
5
  pip install -r requirements.txt
6
+ python3 scripts/generate_curriculum.py # one-time
7
+ python3 demo.py # opens http://localhost:7860
 
 
 
 
 
 
8
  """
9
  from __future__ import annotations
10
 
 
 
11
  import time
12
  from pathlib import Path
13
 
 
18
  from tutor.adaptive import LearnerState
19
  from tutor.lang_detect import detect as lang_detect, reply_lang
20
  from tutor.asr_adapt import transcribe, extract_integer, is_silence
21
+ from tutor.visual_grounding import render_counting_stimulus
22
  from tutor.model_loader import generate_feedback
23
  from tutor.progress_store import ProgressStore
24
 
25
+ # ---------------------------------------------------------------------------
26
  # Paths
27
+ # ---------------------------------------------------------------------------
28
  DATA_DIR = Path("data/T3.1_Math_Tutor")
29
  DB_PATH = Path("tutor_progress.db")
30
  CURRICULUM_PATH = DATA_DIR / "curriculum_full.json"
 
34
  ALL_ITEMS = cl.load(CURRICULUM_PATH)
35
  STORE = ProgressStore(DB_PATH)
36
 
37
+ # ---------------------------------------------------------------------------
38
+ # Session helpers
39
+ # ---------------------------------------------------------------------------
40
 
41
  def new_session(learner_id: str, lang: str = "en", age: int = 7) -> dict:
42
  saved = STORE.load_latest_state(learner_id)
43
  if saved:
44
  state = LearnerState.from_dict(saved)
45
+ state.age = age
46
  else:
47
  state = LearnerState(learner_id=learner_id, lang=lang, age=age)
48
  STORE.add_learner(learner_id, display_name=learner_id)
 
60
  "queue": probes,
61
  "current_item": None,
62
  "session_id": STORE.start_session(learner_id, state.to_dict(), lang),
63
+ "phase": "diagnostic",
64
  "silence_count": 0,
65
+ "total_correct": 0,
66
+ "total_answered": 0,
67
  }
68
 
69
 
70
  def get_next_item(sess: dict) -> dict:
 
71
  state: LearnerState = sess["state"]
72
  if sess["queue"]:
73
  item = sess["queue"].pop(0)
 
79
  return sess
80
 
81
 
82
+ # ---------------------------------------------------------------------------
83
+ # Core response processor
84
+ # ---------------------------------------------------------------------------
85
 
86
  def process_response(
87
  sess: dict,
88
  audio_data: tuple | None,
89
  tap_answer: str,
90
+ ) -> tuple[dict, str, bool, np.ndarray | None, str]:
91
+ """Returns (sess, feedback_text, is_correct, image, debug)."""
 
 
 
92
  item = sess.get("current_item")
93
  if item is None:
94
+ return sess, "Let's start!", False, None, ""
95
 
96
  state: LearnerState = sess["state"]
97
  lang = sess["lang"]
98
  t0 = time.time()
99
 
 
 
 
100
  child_text = ""
 
101
 
102
  if audio_data is not None:
103
  sr, audio_np = audio_data
 
112
 
113
  if is_silence(audio_f32):
114
  sess["silence_count"] += 1
115
+ return sess, _silence_prompt(lang), False, _item_image(item), ""
 
 
 
116
 
117
  sess["silence_count"] = 0
118
+ child_text, detected_lang, _ = transcribe(audio_f32, lang_hint=lang)
119
  if detected_lang == "mix":
120
  lang = reply_lang(detected_lang, fallback=lang)
121
+ elif detected_lang in ("en", "fr", "kin", "sw"):
122
  lang = detected_lang
123
  sess["lang"] = lang
124
 
125
  elif tap_answer.strip():
126
  child_text = tap_answer.strip()
127
 
 
 
 
128
  child_int = extract_integer(child_text)
129
  is_correct = child_int is not None and child_int == item["answer_int"]
130
 
 
 
 
131
  image_arr = _item_image(item)
132
 
 
 
 
133
  state.record_response(item, is_correct)
134
  latency_ms = int((time.time() - t0) * 1000)
135
  STORE.log_response(
136
+ sess["learner_id"], sess["session_id"],
137
+ item["id"], item["skill"],
138
+ item.get("difficulty", 5), is_correct, latency_ms,
 
 
 
 
139
  )
140
 
 
 
 
141
  feedback = generate_feedback(is_correct, item["answer_int"], lang, child_text)
142
 
 
 
 
143
  warnings = state.dyscalculia_warning()
144
  if warnings:
145
+ feedback += f"\n\n[Parent: {', '.join(warnings)} skill(s) may need attention]"
146
+
147
+ sess["total_answered"] = sess.get("total_answered", 0) + 1
148
+ if is_correct:
149
+ sess["total_correct"] = sess.get("total_correct", 0) + 1
150
 
 
 
 
151
  STORE.end_session(sess["session_id"], state.to_dict())
152
  sess = get_next_item(sess)
153
 
154
+ debug = f"item={item['id']} correct={is_correct} lang={lang} latency={latency_ms}ms"
155
+ return sess, feedback, is_correct, image_arr, debug
 
156
 
157
 
158
  def _item_image(item: dict) -> np.ndarray | None:
 
164
 
165
  def _silence_prompt(lang: str) -> str:
166
  msgs = {
167
+ "en": "I didn't hear you โ€” tap a number or try again!",
168
+ "fr": "Je ne t'ai pas entendu โ€” touche un chiffre !",
169
+ "kin": "Sinumvise โ€” kanda umubare!",
170
+ "sw": "Sikukusikia โ€” gonga nambari!",
171
  }
172
  return msgs.get(lang, msgs["en"])
173
 
174
 
175
+ # ---------------------------------------------------------------------------
176
+ # HTML renderers
177
+ # ---------------------------------------------------------------------------
178
+
179
+ def _question_html(text: str) -> str:
180
+ return (
181
+ '<div style="font-size:1.7em; font-weight:800; text-align:center; '
182
+ 'color:#1a3a8f; padding:22px 16px; background:linear-gradient(135deg,#e8f0fe,#f3e8ff); '
183
+ 'border-radius:20px; min-height:90px; display:flex; align-items:center; '
184
+ f'justify-content:center; line-height:1.3">{text}</div>'
185
+ )
186
+
187
+
188
+ def _feedback_html(text: str, is_correct: bool) -> str:
189
+ if not text:
190
+ return ""
191
+ if is_correct:
192
+ return (
193
+ '<div style="background:#d4edda; border:3px solid #28a745; border-radius:20px; '
194
+ 'padding:18px; text-align:center; font-size:1.5em; font-weight:800; color:#155724; '
195
+ 'animation:pop .25s ease" class="fb-panel">'
196
+ f'โœ… {text}</div>'
197
+ '<style>@keyframes pop{{0%{{transform:scale(.85)}}100%{{transform:scale(1)}}}}'
198
+ '.fb-panel{{animation:pop .25s ease}}</style>'
199
+ )
200
+ return (
201
+ '<div style="background:#fff3cd; border:3px solid #ffc107; border-radius:20px; '
202
+ 'padding:18px; text-align:center; font-size:1.5em; font-weight:800; color:#7d5a00">'
203
+ f'๐Ÿ’ญ {text}</div>'
204
+ )
205
+
206
+
207
+ def _progress_html(correct: int, answered: int) -> str:
208
+ stars = min(correct, 10)
209
+ bar = "โญ" * stars + "โ˜†" * (10 - stars)
210
+ label = f"{correct}/{answered} correct" if answered else "Answer to earn stars!"
211
+ return (
212
+ f'<div style="text-align:center; padding:8px 0">'
213
+ f'<div style="font-size:1.6em; letter-spacing:3px">{bar}</div>'
214
+ f'<div style="font-size:0.85em; color:#666; margin-top:2px">{label}</div>'
215
+ f'</div>'
216
+ )
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
  # Gradio UI
221
+ # ---------------------------------------------------------------------------
222
+
223
+ # Number button colours (0โ€“10)
224
+ _NUM_COLORS = [
225
+ "#6c757d", # 0 grey
226
+ "#2ecc71", # 1 green
227
+ "#1abc9c", # 2 teal
228
+ "#3498db", # 3 blue
229
+ "#9b59b6", # 4 purple
230
+ "#e91e63", # 5 pink
231
+ "#f39c12", # 6 amber
232
+ "#e74c3c", # 7 red
233
+ "#1e90ff", # 8 dodger blue
234
+ "#8e44ad", # 9 violet
235
+ "#2c3e50", # 10 dark
236
+ ]
237
+
238
+ _NUM_CSS = "\n".join(
239
+ f'.nb{i} button {{ background:{c} !important; color:white !important; '
240
+ f'font-size:1.7em !important; font-weight:900 !important; '
241
+ f'border-radius:14px !important; height:68px !important; '
242
+ f'transition:transform .1s !important; border:none !important; }}\n'
243
+ f'.nb{i} button:hover {{ transform:scale(1.07) !important; }}\n'
244
+ f'.nb{i} button:active {{ transform:scale(0.93) !important; }}'
245
+ for i, c in enumerate(_NUM_COLORS)
246
+ )
247
+
248
+ _GLOBAL_CSS = _NUM_CSS + """
249
+ .start-btn button {
250
+ font-size:1.3em !important; font-weight:800 !important;
251
+ border-radius:16px !important; height:60px !important;
252
+ }
253
+ """
254
+
255
 
256
  def build_ui():
257
  theme = gr.themes.Soft(
 
259
  font=[gr.themes.GoogleFont("Nunito"), "sans-serif"],
260
  )
261
 
262
+ with gr.Blocks(title="๐ŸŒŸ Math Adventure", css=_GLOBAL_CSS) as demo:
263
  sess_state = gr.State(None)
264
 
265
+ # โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
266
  gr.HTML("""
267
+ <div style="text-align:center; padding:18px 12px 14px;
268
+ background:linear-gradient(135deg,#1a56db,#9333ea);
269
+ border-radius:20px; margin-bottom:14px">
270
+ <div style="font-size:3.2em; line-height:1.1">๐Ÿฆ</div>
271
+ <h1 style="color:white; font-size:2.1em; margin:4px 0 2px; font-weight:900">
272
+ Math Adventure!
273
+ </h1>
274
+ <p style="color:rgba(255,255,255,0.88); font-size:0.95em; margin:0">
275
+ Akanyamaswa k'Imibare &nbsp;ยท&nbsp; Aventure Maths &nbsp;ยท&nbsp; Hisabu ya Kusisimua
276
  </p>
277
  </div>
278
  """)
279
 
280
+ with gr.Row(equal_height=False):
281
+
282
+ # โ”€โ”€ Setup panel (left) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
283
+ with gr.Column(scale=1, min_width=210):
284
  learner_id_box = gr.Textbox(
285
+ label="๐Ÿ‘ค Name / Izina / Nom / Jina",
286
  placeholder="e.g. Amani",
287
  max_lines=1,
288
  )
289
  age_radio = gr.Radio(
290
+ choices=[("5 yrs ๐Ÿฃ", 5), ("6 yrs ๐Ÿฅ", 6), ("7 yrs ๐ŸŒฑ", 7),
291
+ ("8 yrs ๐ŸŒŸ", 8), ("9 yrs ๐Ÿš€", 9)],
292
  value=7,
293
+ label="๐ŸŽ‚ Age / Imyaka / ร‚ge / Umri",
294
  )
295
  lang_radio = gr.Radio(
296
+ choices=[
297
+ ("๐Ÿ‡ท๐Ÿ‡ผ Kinyarwanda", "kin"),
298
+ ("๐Ÿ‡น๐Ÿ‡ฟ Kiswahili", "sw"),
299
+ ("๐Ÿ‡ซ๐Ÿ‡ท Franรงais", "fr"),
300
+ ("๐Ÿ‡ฌ๐Ÿ‡ง English", "en"),
301
+ ],
302
  value="kin",
303
+ label="๐ŸŒ Language / Lugha / Langue / Ururimi",
304
  )
305
+ start_btn = gr.Button(
306
+ "โ–ถ START / TANGIRA",
307
+ variant="primary",
308
+ size="lg",
309
+ elem_classes=["start-btn"],
310
+ )
311
+
312
+ # โ”€โ”€ Game panel (right) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
313
+ with gr.Column(scale=3):
314
+
315
+ progress_html = gr.HTML(_progress_html(0, 0))
316
+
317
+ question_html = gr.HTML(
318
+ _question_html("Press START to begin! ๐Ÿ‘†")
319
+ )
320
+
321
+ item_image = gr.Image(label="", height=270, show_label=False)
322
 
 
 
 
323
  audio_input = gr.Audio(
324
  sources=["microphone"],
325
  type="numpy",
326
+ label="๐ŸŽค Speak / Vuga / Parle / Sema (optional)",
 
 
 
 
 
327
  )
328
+
329
+ gr.HTML(
330
+ '<p style="text-align:center; font-weight:800; font-size:1.1em; '
331
+ 'color:#444; margin:10px 0 4px">๐Ÿ‘‡ Tap your answer:</p>'
 
332
  )
 
333
 
334
+ # Number pad row 1: 0โ€“5
335
+ with gr.Row():
336
+ num_btns_r1 = [
337
+ gr.Button(str(n), elem_classes=[f"nb{n}"])
338
+ for n in range(6)
339
+ ]
340
+ # Number pad row 2: 6โ€“10
341
+ with gr.Row():
342
+ num_btns_r2 = [
343
+ gr.Button(str(n), elem_classes=[f"nb{n}"])
344
+ for n in range(6, 11)
345
+ ]
346
+
347
+ feedback_html = gr.HTML("")
348
+
349
+ debug_box = gr.Textbox(
350
+ label="Debug", visible=False, interactive=False
351
+ )
352
 
353
+ # โ”€โ”€ Shared output list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
354
+ OUTPUTS = [
355
+ sess_state,
356
+ question_html,
357
+ item_image,
358
+ audio_input,
359
+ progress_html,
360
+ feedback_html,
361
+ debug_box,
362
+ ]
363
+
364
+ # โ”€โ”€ on_start โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
365
  def on_start(learner_id, age, lang):
366
  if not learner_id.strip():
367
  learner_id = "learner_1"
 
371
  if item:
372
  q = cl.stem(item, lang)
373
  img = _item_image(item)
374
+ else:
375
+ q, img = "No items available", None
376
+ return (
377
+ sess,
378
+ _question_html(q),
379
+ img,
380
+ None,
381
+ _progress_html(0, 0),
382
+ "",
383
+ "",
384
+ )
385
 
386
  start_btn.click(
387
  on_start,
388
  inputs=[learner_id_box, age_radio, lang_radio],
389
+ outputs=OUTPUTS,
390
  )
391
 
392
+ # โ”€โ”€ on_submit (shared by audio + number pad) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
393
+ def on_submit(sess, audio, tap_answer):
394
  if sess is None:
395
+ return (
396
+ None,
397
+ _question_html("Press START first! ๐Ÿ‘†"),
398
+ None, None,
399
+ _progress_html(0, 0),
400
+ "",
401
+ "no session",
402
+ )
403
+ sess, feedback, is_correct, img, debug = process_response(
404
+ sess, audio, tap_answer
405
+ )
406
  item = sess.get("current_item")
407
+ q = cl.stem(item, sess["lang"]) if item else "๐ŸŽ‰ All done! Great work!"
408
+ return (
409
+ sess,
410
+ _question_html(q),
411
+ img,
412
+ None,
413
+ _progress_html(sess.get("total_correct", 0), sess.get("total_answered", 0)),
414
+ _feedback_html(feedback, is_correct),
415
+ debug,
416
+ )
417
+
418
+ # Number pad buttons auto-submit with their value
419
+ all_num_btns = num_btns_r1 + num_btns_r2
420
+ for btn in all_num_btns:
421
+ num_val = btn.value # "0" โ€ฆ "10"
422
+ btn.click(
423
+ fn=lambda s, a, n=num_val: on_submit(s, a, n),
424
+ inputs=[sess_state, audio_input],
425
+ outputs=OUTPUTS,
426
+ )
427
+
428
+ # Audio submit (after recording)
429
+ audio_input.change(
430
+ fn=lambda s, a: on_submit(s, a, ""),
431
+ inputs=[sess_state, audio_input],
432
+ outputs=OUTPUTS,
433
  )
434
 
435
  return demo, theme
tutor/__pycache__/adaptive.cpython-313.pyc CHANGED
Binary files a/tutor/__pycache__/adaptive.cpython-313.pyc and b/tutor/__pycache__/adaptive.cpython-313.pyc differ
 
tutor/__pycache__/curriculum_loader.cpython-313.pyc CHANGED
Binary files a/tutor/__pycache__/curriculum_loader.cpython-313.pyc and b/tutor/__pycache__/curriculum_loader.cpython-313.pyc differ