| """ |
| demo.py โ Advanced Child-facing Gradio demo for the AI Math Tutor. |
| |
| Improvements over baseline: |
| 1. Animated, child-friendly UI with celebration effects |
| 2. Streak counter + XP points + level badges |
| 3. Per-skill progress bars visible during play |
| 4. Hint system (up to 2 hints before answer is shown) |
| 5. Graceful silence / no-audio fallback with better UX |
| 6. Language auto-detection persists across questions |
| 7. Session summary screen at the end |
| 8. Accessible large-font mode toggle |
| 9. Difficulty ramp indicator (star rating per question) |
| 10. Robust guard-rails: all external calls wrapped in try/except |
| |
| Run: |
| pip install -r requirements.txt |
| python3 scripts/generate_curriculum.py # one-time |
| python3 demo.py |
| """ |
| from __future__ import annotations |
|
|
| import time |
| import random |
| from pathlib import Path |
| from collections import defaultdict |
|
|
| import gradio as gr |
| import numpy as np |
|
|
| from tutor import curriculum_loader as cl |
| from tutor.adaptive import LearnerState |
| from tutor.lang_detect import detect as lang_detect, reply_lang |
| from tutor.asr_adapt import transcribe, extract_integer, is_silence |
| from tutor.visual_grounding import render_counting_stimulus |
| from tutor.model_loader import generate_feedback |
| from tutor.progress_store import ProgressStore |
|
|
| |
| |
| |
| DATA_DIR = Path("data/T3.1_Math_Tutor") |
| DB_PATH = Path("tutor_progress.db") |
| CURRICULUM_PATH = DATA_DIR / "curriculum_full.json" |
| if not CURRICULUM_PATH.exists(): |
| CURRICULUM_PATH = DATA_DIR / "curriculum_seed.json" |
|
|
| ALL_ITEMS = cl.load(CURRICULUM_PATH) |
| STORE = ProgressStore(DB_PATH) |
|
|
| MAX_HINTS = 2 |
| XP_CORRECT = 10 |
| XP_HINT = 5 |
| XP_SKIP = 0 |
|
|
| LEVEL_THRESHOLDS = [0, 30, 80, 160, 280, 450] |
| LEVEL_BADGES = ["๐ฃ Beginner", "๐ฅ Explorer", "๐ฑ Learner", |
| "๐ Achiever", "๐ Champion", "๐ฆ Math Hero"] |
|
|
| SKILL_ICONS = { |
| "counting": "๐ข", |
| "number_sense": "๐ง ", |
| "addition": "โ", |
| "subtraction": "โ", |
| "word_problem": "๐", |
| } |
|
|
| |
| CELEBRATIONS = { |
| "en": ["Amazing! ๐", "Superstar! โญ", "You got it! ๐ฅณ", "Brilliant! ๐", "Keep it up! ๐"], |
| "fr": ["Bravo! ๐", "Super! โญ", "Excellent! ๐ฅณ", "Fantastique! ๐"], |
| "kin": ["Byiza cyane! ๐", "Wabitsinze! โญ", "Ntangaza! ๐ฅณ", "Komeza! ๐"], |
| "sw": ["Vizuri sana! ๐", "Hongera! โญ", "Umeshinda! ๐ฅณ", "Endelea! ๐"], |
| } |
|
|
| TRY_AGAIN = { |
| "en": "Almost! Give it another try ๐ช", |
| "fr": "Presque ! Essaie encore ๐ช", |
| "kin": "Hafi! Gerageza nanone ๐ช", |
| "sw": "Karibu! Jaribu tena ๐ช", |
| } |
|
|
| HINT_PROMPT = { |
| "en": "Need a hint? Tap ๐ก Hint", |
| "fr": "Besoin d'aide ? Tape ๐ก Indice", |
| "kin": "Ukeneye ubufasha? Kanda ๐ก Ubwenge", |
| "sw": "Unahitaji kidokezo? Gonga ๐ก Dokezo", |
| } |
|
|
| SILENCE_MSG = { |
| "en": "I didn't hear you โ tap a number or try the mic again! ๐ค", |
| "fr": "Je ne t'ai pas entendu โ touche un chiffre ! ๐ค", |
| "kin": "Sinumvise โ kanda umubare! ๐ค", |
| "sw": "Sikukusikia โ gonga nambari! ๐ค", |
| } |
|
|
| |
| |
| |
|
|
| def _xp_to_level(xp: int) -> tuple[int, str]: |
| """Return (level_index 0-based, badge label).""" |
| for i in range(len(LEVEL_THRESHOLDS) - 1, -1, -1): |
| if xp >= LEVEL_THRESHOLDS[i]: |
| return i, LEVEL_BADGES[i] |
| return 0, LEVEL_BADGES[0] |
|
|
|
|
| def new_session(learner_id: str, lang: str = "en", age: int = 7) -> dict: |
| saved = STORE.load_latest_state(learner_id) |
| if saved: |
| state = LearnerState.from_dict(saved) |
| state.age = age |
| else: |
| state = LearnerState(learner_id=learner_id, lang=lang, age=age) |
| STORE.add_learner(learner_id, display_name=learner_id) |
| state.lang = lang |
| cfg = state.age_config |
| probes = cl.sample_diagnostic_probes( |
| ALL_ITEMS, n_per_skill=1, |
| diff_min=cfg["diff_min"], diff_max=cfg["diff_max"], |
| ) |
| return { |
| "learner_id": learner_id, |
| "lang": lang, |
| "age": age, |
| "state": state, |
| "queue": probes, |
| "current_item": None, |
| "session_id": STORE.start_session(learner_id, state.to_dict(), lang), |
| "phase": "diagnostic", |
| |
| "total_correct": 0, |
| "total_answered": 0, |
| "total_skipped": 0, |
| "streak": 0, |
| "max_streak": 0, |
| "xp": 0, |
| "hint_count": 0, |
| "first_try": True, |
| "silence_count": 0, |
| |
| "skill_correct": defaultdict(int), |
| "skill_total": defaultdict(int), |
| |
| "history": [], |
| } |
|
|
|
|
| def get_next_item(sess: dict) -> dict: |
| state: LearnerState = sess["state"] |
| sess["hint_count"] = 0 |
| sess["first_try"] = True |
| if sess["queue"]: |
| item = sess["queue"].pop(0) |
| if not sess["queue"]: |
| sess["phase"] = "adaptive" |
| else: |
| item = state.select_next_item(ALL_ITEMS, use_bkt=True) |
| sess["current_item"] = item |
| return sess |
|
|
|
|
| |
| |
| |
|
|
| def process_response( |
| sess: dict, |
| audio_data: tuple | None, |
| tap_answer: str, |
| ) -> tuple[dict, str, bool, np.ndarray | None, str, bool]: |
| """ |
| Returns (sess, feedback_text, is_correct, image, debug, session_done). |
| session_done=True triggers the summary screen. |
| """ |
| item = sess.get("current_item") |
| if item is None: |
| return sess, "Let's start!", False, None, "", False |
|
|
| state: LearnerState = sess["state"] |
| lang = sess["lang"] |
| t0 = time.time() |
|
|
| child_text = "" |
|
|
| |
| if audio_data is not None: |
| try: |
| sr, audio_np = audio_data |
| audio_f32 = audio_np.astype(np.float32) / 32768.0 |
| if sr != 16000: |
| ratio = 16000 / sr |
| n = int(len(audio_f32) * ratio) |
| idx = np.linspace(0, len(audio_f32) - 1, n) |
| li = np.floor(idx).astype(int) |
| ri = np.clip(li + 1, 0, len(audio_f32) - 1) |
| audio_f32 = audio_f32[li] * (1 - (idx - li)) + audio_f32[ri] * (idx - li) |
|
|
| if is_silence(audio_f32): |
| sess["silence_count"] += 1 |
| img = _item_image(item) |
| return sess, SILENCE_MSG.get(lang, SILENCE_MSG["en"]), False, img, "silence", False |
|
|
| sess["silence_count"] = 0 |
| child_text, detected_lang, _ = transcribe(audio_f32, lang_hint=lang) |
| if detected_lang in ("en", "fr", "kin", "sw"): |
| lang = detected_lang |
| elif detected_lang == "mix": |
| lang = reply_lang(detected_lang, fallback=lang) |
| sess["lang"] = lang |
| except Exception as e: |
| return sess, f"Audio error โ tap a number instead! ({e})", False, _item_image(item), str(e), False |
|
|
| elif tap_answer.strip(): |
| child_text = tap_answer.strip() |
|
|
| if not child_text: |
| return sess, HINT_PROMPT.get(lang, HINT_PROMPT["en"]), False, _item_image(item), "no input", False |
|
|
| |
| child_int = extract_integer(child_text) |
| is_correct = child_int is not None and child_int == item["answer_int"] |
| image_arr = _item_image(item) |
| latency_ms = int((time.time() - t0) * 1000) |
|
|
| |
| skill = item.get("skill", "unknown") |
| sess["skill_total"][skill] += 1 |
|
|
| if is_correct: |
| sess["skill_correct"][skill] += 1 |
| sess["total_correct"] += 1 |
| sess["streak"] = sess.get("streak", 0) + 1 |
| sess["max_streak"] = max(sess["streak"], sess.get("max_streak", 0)) |
| |
| xp_earned = XP_CORRECT if sess["first_try"] else XP_HINT |
| sess["xp"] = sess.get("xp", 0) + xp_earned |
| else: |
| sess["first_try"] = False |
| sess["streak"] = 0 |
|
|
| sess["total_answered"] += 1 |
|
|
| |
| try: |
| state.record_response(item, is_correct) |
| STORE.log_response( |
| sess["learner_id"], sess["session_id"], |
| item["id"], skill, |
| item.get("difficulty", 5), is_correct, latency_ms, |
| ) |
| except Exception: |
| pass |
|
|
| |
| try: |
| feedback = generate_feedback(is_correct, item["answer_int"], lang, child_text) |
| except Exception: |
| if is_correct: |
| feedback = random.choice(CELEBRATIONS.get(lang, CELEBRATIONS["en"])) |
| else: |
| feedback = TRY_AGAIN.get(lang, TRY_AGAIN["en"]) |
|
|
| |
| try: |
| warnings = state.dyscalculia_warning() |
| if warnings: |
| feedback += f"\n\n[๐จโ๐ฉโ๐ง Parent note: {', '.join(warnings)} may need attention]" |
| except Exception: |
| pass |
|
|
| |
| sess.setdefault("history", []).append({ |
| "id": item["id"], "skill": skill, |
| "correct": is_correct, "difficulty": item.get("difficulty", 5), |
| }) |
|
|
| |
| try: |
| STORE.end_session(sess["session_id"], state.to_dict()) |
| except Exception: |
| pass |
|
|
| |
| remaining = len(sess["queue"]) |
| session_done = (remaining == 0 and sess["phase"] == "diagnostic" |
| and sess["total_answered"] >= 5) |
|
|
| if not session_done: |
| sess = get_next_item(sess) |
|
|
| debug = (f"item={item['id']} correct={is_correct} lang={lang} " |
| f"latency={latency_ms}ms xp={sess['xp']} streak={sess['streak']}") |
| return sess, feedback, is_correct, image_arr, debug, session_done |
|
|
|
|
| def give_hint(sess: dict) -> tuple[dict, str]: |
| """Return a hint for the current question.""" |
| item = sess.get("current_item") |
| if item is None: |
| return sess, "Start first!" |
| lang = sess["lang"] |
| sess["hint_count"] = sess.get("hint_count", 0) + 1 |
| sess["first_try"] = False |
| hint_n = sess["hint_count"] |
| answer = item["answer_int"] |
|
|
| if hint_n == 1: |
| |
| lo = max(0, answer - 3) |
| hi = answer + 3 |
| hints = { |
| "en": f"๐ก The answer is between {lo} and {hi}", |
| "fr": f"๐ก La rรฉponse est entre {lo} et {hi}", |
| "kin": f"๐ก Igisubizo kiri hagati ya {lo} na {hi}", |
| "sw": f"๐ก Jibu liko kati ya {lo} na {hi}", |
| } |
| elif hint_n == 2: |
| |
| hint_val = answer - 1 if answer > 1 else answer + 1 |
| hints = { |
| "en": f"๐ก It's not {hint_val}โฆ count again carefully!", |
| "fr": f"๐ก Ce n'est pas {hint_val}โฆ compte encore !", |
| "kin": f"๐ก Si {hint_val}โฆ bara nanone!", |
| "sw": f"๐ก Si {hint_val}โฆ hesabu tena!", |
| } |
| else: |
| |
| hints = { |
| "en": f"โ
The answer is {answer}", |
| "fr": f"โ
La rรฉponse est {answer}", |
| "kin": f"โ
Igisubizo ni {answer}", |
| "sw": f"โ
Jibu ni {answer}", |
| } |
| return sess, hints.get(lang, hints["en"]) |
|
|
|
|
| def skip_question(sess: dict) -> dict: |
| """Skip current question with no XP penalty.""" |
| if sess is None: |
| return sess |
| sess["total_skipped"] = sess.get("total_skipped", 0) + 1 |
| sess["streak"] = 0 |
| sess.setdefault("history", []).append({ |
| "id": sess.get("current_item", {}).get("id", "?"), |
| "skill": sess.get("current_item", {}).get("skill", "?"), |
| "correct": None, "difficulty": sess.get("current_item", {}).get("difficulty", 5), |
| }) |
| sess = get_next_item(sess) |
| return sess |
|
|
|
|
| |
| |
| |
|
|
| def _item_image(item: dict) -> np.ndarray | None: |
| if item and item.get("skill") == "counting" and item.get("answer_int"): |
| try: |
| label = item.get("visual", "โ").split("_")[0] |
| return render_counting_stimulus(item["answer_int"], label=label) |
| except Exception: |
| return None |
| return None |
|
|
|
|
| def _star_rating(difficulty: int) -> str: |
| """Convert 1-10 difficulty to star display.""" |
| stars = max(1, min(5, round(difficulty / 2))) |
| return "โญ" * stars + "โ" * (5 - stars) |
|
|
|
|
| |
| |
| |
|
|
| def _question_html(item: dict | None, lang: str = "en", large: bool = False) -> str: |
| if item is None: |
| text = "Press START to begin! ๐" |
| diff_stars = "" |
| skill_icon = "" |
| else: |
| stem_key = f"stem_{lang}" |
| text = item.get(stem_key) or item.get("stem_en", "?") |
| diff_stars = _star_rating(item.get("difficulty", 5)) |
| skill_icon = SKILL_ICONS.get(item.get("skill", ""), "") |
|
|
| font_size = "2em" if large else "1.7em" |
| return ( |
| f'<div style="font-size:{font_size}; font-weight:800; text-align:center; ' |
| f'color:#1a3a8f; padding:22px 16px; ' |
| f'background:linear-gradient(135deg,#e8f0fe,#f3e8ff); ' |
| f'border-radius:20px; min-height:100px; display:flex; flex-direction:column; ' |
| f'align-items:center; justify-content:center; line-height:1.3; gap:8px">' |
| f'<div style="font-size:0.6em; color:#666; letter-spacing:2px">' |
| f'{skill_icon} {diff_stars}</div>' |
| f'<div>{text}</div>' |
| f'</div>' |
| ) |
|
|
|
|
| def _feedback_html(text: str, is_correct: bool, large: bool = False) -> str: |
| if not text: |
| return "" |
| font_size = "1.8em" if large else "1.5em" |
| if is_correct: |
| return ( |
| f'<div style="background:#d4edda; border:3px solid #28a745; ' |
| f'border-radius:20px; padding:18px; text-align:center; ' |
| f'font-size:{font_size}; font-weight:800; color:#155724;" ' |
| f'class="fb-correct">โ
{text}</div>' |
| '<style>' |
| '@keyframes pop{0%{transform:scale(.8)}60%{transform:scale(1.08)}100%{transform:scale(1)}}' |
| '.fb-correct{animation:pop .35s ease}' |
| '</style>' |
| ) |
| return ( |
| f'<div style="background:#fff3cd; border:3px solid #ffc107; ' |
| f'border-radius:20px; padding:18px; text-align:center; ' |
| f'font-size:{font_size}; font-weight:800; color:#7d5a00">' |
| f'๐ญ {text}</div>' |
| ) |
|
|
|
|
| def _progress_html(sess: dict, large: bool = False) -> str: |
| correct = sess.get("total_correct", 0) |
| answered = sess.get("total_answered", 0) |
| streak = sess.get("streak", 0) |
| xp = sess.get("xp", 0) |
| _, badge = _xp_to_level(xp) |
|
|
| stars = min(correct, 10) |
| bar = "โญ" * stars + "โ" * (10 - stars) |
| label = f"{correct}/{answered} correct" if answered else "Answer to earn stars!" |
| streak_d = f"๐ฅ Streak: {streak}" if streak >= 2 else "" |
| xp_d = f"โจ XP: {xp}" |
| font_em = "1.1em" if large else "0.9em" |
|
|
| return ( |
| f'<div style="text-align:center; padding:10px 0; font-size:{font_em}">' |
| f'<div style="font-size:1.6em; letter-spacing:3px">{bar}</div>' |
| f'<div style="color:#666; margin-top:2px">{label}</div>' |
| f'<div style="display:flex; justify-content:center; gap:20px; margin-top:4px; ' |
| f'font-weight:700; color:#444">' |
| f'<span>{xp_d}</span>' |
| f'<span>{badge}</span>' |
| f'{"<span>" + streak_d + "</span>" if streak_d else ""}' |
| f'</div>' |
| f'</div>' |
| ) |
|
|
|
|
| def _skill_bars_html(sess: dict) -> str: |
| sc = sess.get("skill_correct", defaultdict(int)) |
| st = sess.get("skill_total", defaultdict(int)) |
| if not st: |
| return "" |
| rows = "" |
| for skill, icon in SKILL_ICONS.items(): |
| total = st[skill] |
| correct = sc[skill] |
| if total == 0: |
| continue |
| pct = int(correct / total * 100) |
| color = "#22c55e" if pct >= 70 else ("#f59e0b" if pct >= 40 else "#ef4444") |
| w = int(pct * 1.5) |
| rows += ( |
| f'<div style="display:flex; align-items:center; gap:8px; margin:4px 0">' |
| f'<span style="font-size:1.2em; width:24px">{icon}</span>' |
| f'<span style="width:90px; font-size:0.85em; color:#444">{skill.replace("_"," ").title()}</span>' |
| f'<div style="background:#e5e7eb; border-radius:4px; width:150px; height:14px">' |
| f'<div style="background:{color}; width:{w}px; height:14px; border-radius:4px"></div>' |
| f'</div>' |
| f'<span style="font-size:0.8em; color:#555; font-weight:700">{pct}%</span>' |
| f'</div>' |
| ) |
| return ( |
| f'<div style="padding:8px 12px; background:#f8fafc; border-radius:12px; ' |
| f'border:1px solid #e5e7eb; margin-top:4px">' |
| f'<div style="font-weight:800; font-size:0.9em; color:#374151; margin-bottom:6px">' |
| f'๐ Skill progress</div>' |
| f'{rows}' |
| f'</div>' |
| ) if rows else "" |
|
|
|
|
| def _summary_html(sess: dict) -> str: |
| correct = sess.get("total_correct", 0) |
| answered = sess.get("total_answered", 0) |
| skipped = sess.get("total_skipped", 0) |
| xp = sess.get("xp", 0) |
| streak = sess.get("max_streak", 0) |
| _, badge = _xp_to_level(xp) |
| pct = int(correct / answered * 100) if answered else 0 |
|
|
| lang = sess.get("lang", "en") |
| color = "#22c55e" if pct >= 70 else ("#f59e0b" if pct >= 40 else "#ef4444") |
|
|
| skill_rows = "" |
| sc = sess.get("skill_correct", defaultdict(int)) |
| st = sess.get("skill_total", defaultdict(int)) |
| for skill, icon in SKILL_ICONS.items(): |
| if st[skill]: |
| sp = int(sc[skill] / st[skill] * 100) |
| skill_rows += f'<li>{icon} {skill.replace("_"," ").title()}: <b>{sc[skill]}/{st[skill]}</b> ({sp}%)</li>' |
|
|
| return f""" |
| <div style="text-align:center; padding:24px; background:linear-gradient(135deg,#1a56db,#9333ea); |
| border-radius:20px; color:white"> |
| <div style="font-size:3em">๐</div> |
| <h2 style="margin:8px 0">Session Complete!</h2> |
| <div style="font-size:1.8em; font-weight:900; margin:8px 0">{badge}</div> |
| <div style="font-size:1.2em; margin:4px 0">Score: <b style="color:{color}">{pct}%</b> |
| ({correct}/{answered} correct)</div> |
| <div style="font-size:1em; opacity:0.85">โจ XP earned: {xp} | |
| ๐ฅ Best streak: {streak} | โญ๏ธ Skipped: {skipped}</div> |
| </div> |
| <ul style="margin:16px 0 0; padding:0 20px; font-size:1em; line-height:2"> |
| {skill_rows} |
| </ul> |
| """ |
|
|
|
|
| |
| |
| |
|
|
| _NUM_COLORS = [ |
| "#6c757d","#2ecc71","#1abc9c","#3498db","#9b59b6", |
| "#e91e63","#f39c12","#e74c3c","#1e90ff","#8e44ad","#2c3e50", |
| ] |
|
|
| _NUM_CSS = "\n".join( |
| f'.nb{i} button {{ background:{c} !important; color:white !important; ' |
| f'font-size:1.8em !important; font-weight:900 !important; ' |
| f'border-radius:16px !important; height:72px !important; ' |
| f'transition:transform .12s, box-shadow .12s !important; border:none !important; ' |
| f'box-shadow: 0 4px 12px rgba(0,0,0,0.18) !important; }}\n' |
| f'.nb{i} button:hover {{ transform:scale(1.10) !important; box-shadow:0 6px 18px rgba(0,0,0,0.25) !important; }}\n' |
| f'.nb{i} button:active {{ transform:scale(0.90) !important; }}' |
| for i, c in enumerate(_NUM_COLORS) |
| ) |
|
|
| _GLOBAL_CSS = _NUM_CSS + """ |
| .start-btn button { |
| font-size:1.2em !important; font-weight:900 !important; |
| border-radius:16px !important; height:58px !important; |
| background: linear-gradient(135deg,#1a56db,#9333ea) !important; |
| color: white !important; border: none !important; |
| box-shadow: 0 4px 14px rgba(100,80,200,0.4) !important; |
| } |
| .hint-btn button { |
| font-size:1em !important; font-weight:700 !important; |
| border-radius:12px !important; height:48px !important; |
| background: #fff8e1 !important; color:#7d5a00 !important; |
| border: 2px solid #ffc107 !important; |
| } |
| .skip-btn button { |
| font-size:1em !important; font-weight:700 !important; |
| border-radius:12px !important; height:48px !important; |
| background: #f1f5f9 !important; color:#475569 !important; |
| border: 2px solid #cbd5e1 !important; |
| } |
| """ |
|
|
|
|
| def build_ui(): |
| theme = gr.themes.Soft( |
| primary_hue="blue", |
| font=[gr.themes.GoogleFont("Nunito"), "sans-serif"], |
| ) |
|
|
| with gr.Blocks(title="๐ Math Adventure", css=_GLOBAL_CSS, theme=theme) as demo: |
| sess_state = gr.State(None) |
| large_state = gr.State(False) |
|
|
| |
| gr.HTML(""" |
| <div style="text-align:center; padding:18px 12px 14px; |
| background:linear-gradient(135deg,#1a56db,#9333ea); |
| border-radius:20px; margin-bottom:14px"> |
| <div style="font-size:3em; line-height:1.1">๐ฆ</div> |
| <h1 style="color:white; font-size:2em; margin:4px 0 2px; font-weight:900"> |
| Math Adventure! |
| </h1> |
| <p style="color:rgba(255,255,255,0.88); font-size:0.92em; margin:0"> |
| Akanyamaswa k'Imibare ยท Aventure Maths ยท Hisabu ya Kusisimua |
| </p> |
| </div> |
| """) |
|
|
| |
| gr.HTML(""" |
| <details style="margin-bottom:14px; border:2px solid #d1d5db; border-radius:14px; |
| background:#f9fafb; padding:0; overflow:hidden"> |
| <summary style="cursor:pointer; padding:12px 18px; font-weight:800; color:#374151; |
| list-style:none; user-select:none"> |
| ๐จโ๐ฉโ๐ง For Parents & Teachers |
| <span style="font-size:0.8em; color:#6b7280">(tap to expand)</span> |
| </summary> |
| <div style="padding:14px 18px 16px; border-top:1px solid #e5e7eb; color:#374151; line-height:1.7"> |
| <b>How to set up:</b> |
| <ol style="margin:6px 0 12px; padding-left:20px"> |
| <li>Enter child's <b>name</b> โ progress saves across sessions.</li> |
| <li>Select <b>age (5โ9)</b> โ difficulty adjusts automatically.</li> |
| <li>Pick the <b>language</b> the child speaks.</li> |
| <li>Press <b>START</b> and hand the device to the child.</li> |
| </ol> |
| <b>How the child answers:</b> |
| <ul style="margin:6px 0 12px; padding-left:20px"> |
| <li>๐ Tap a <b>coloured number button</b> โ no reading needed.</li> |
| <li>๐ค Or <b>speak</b> the answer (microphone).</li> |
| <li>๐ก Use <b>Hint</b> for clues; โญ๏ธ <b>Skip</b> to move on.</li> |
| <li>โญ Stars + XP fill up as correct answers are given.</li> |
| <li>๐ฅ Keep a streak going for bonus encouragement!</li> |
| </ul> |
| <b>Progress report:</b> |
| <code style="background:#e5e7eb; padding:2px 8px; border-radius:4px"> |
| python3 parent_report.py <name> --lang kin |
| </code> |
| </div> |
| </details> |
| """) |
|
|
| with gr.Row(equal_height=False): |
|
|
| |
| with gr.Column(scale=1, min_width=215): |
| learner_id_box = gr.Textbox( |
| label="๐ค Name / Izina / Nom / Jina", |
| placeholder="e.g. Amani", |
| max_lines=1, |
| ) |
| age_radio = gr.Radio( |
| choices=[("5 ๐ฃ", 5), ("6 ๐ฅ", 6), ("7 ๐ฑ", 7), |
| ("8 ๐", 8), ("9 ๐", 9)], |
| value=7, |
| label="๐ Age / Imyaka / รge / Umri", |
| ) |
| lang_radio = gr.Radio( |
| choices=[ |
| ("๐ท๐ผ Kinyarwanda", "kin"), |
| ("๐น๐ฟ Kiswahili", "sw"), |
| ("๐ซ๐ท Franรงais", "fr"), |
| ("๐ฌ๐ง English", "en"), |
| ], |
| value="kin", |
| label="๐ Language", |
| ) |
| large_toggle = gr.Checkbox( |
| label="๐ Large text (accessibility)", |
| value=False, |
| ) |
| start_btn = gr.Button( |
| "โถ START / TANGIRA", |
| variant="primary", |
| size="lg", |
| elem_classes=["start-btn"], |
| ) |
|
|
| |
| skill_bars_html = gr.HTML("") |
|
|
| |
| with gr.Column(scale=3): |
| progress_html = gr.HTML(_progress_html(new_session("_init_"))) |
| question_html = gr.HTML(_question_html(None)) |
| item_image = gr.Image(label="", height=270, show_label=False) |
|
|
| audio_input = gr.Audio( |
| sources=["microphone"], |
| type="numpy", |
| label="๐ค Speak your answer (optional)", |
| ) |
|
|
| gr.HTML( |
| '<p style="text-align:center; font-weight:900; font-size:1.1em; ' |
| 'color:#444; margin:10px 0 4px">๐ Tap your answer:</p>' |
| ) |
|
|
| |
| with gr.Row(): |
| num_btns_r1 = [ |
| gr.Button(str(n), elem_classes=[f"nb{n}"]) |
| for n in range(6) |
| ] |
| |
| with gr.Row(): |
| num_btns_r2 = [ |
| gr.Button(str(n), elem_classes=[f"nb{n}"]) |
| for n in range(6, 11) |
| ] |
|
|
| with gr.Row(): |
| hint_btn = gr.Button( |
| "๐ก Hint", elem_classes=["hint-btn"], size="sm" |
| ) |
| skip_btn = gr.Button( |
| "โญ๏ธ Skip", elem_classes=["skip-btn"], size="sm" |
| ) |
|
|
| feedback_html = gr.HTML("") |
| summary_html = gr.HTML("", visible=False) |
| debug_box = gr.Textbox( |
| label="Debug", visible=False, interactive=False |
| ) |
|
|
| |
| OUTPUTS = [ |
| sess_state, |
| question_html, |
| item_image, |
| audio_input, |
| progress_html, |
| feedback_html, |
| skill_bars_html, |
| debug_box, |
| summary_html, |
| ] |
|
|
| |
| def on_start(learner_id, age, lang, large): |
| if not learner_id.strip(): |
| learner_id = "learner_1" |
| sess = new_session(learner_id.strip(), lang, age=int(age)) |
| sess = get_next_item(sess) |
| item = sess["current_item"] |
| img = _item_image(item) if item else None |
| return ( |
| sess, |
| _question_html(item, lang, large), |
| img, |
| None, |
| _progress_html(sess, large), |
| "", |
| "", |
| "", |
| gr.update(visible=False, value=""), |
| ) |
|
|
| start_btn.click( |
| on_start, |
| inputs=[learner_id_box, age_radio, lang_radio, large_toggle], |
| outputs=OUTPUTS, |
| ) |
|
|
| |
| def on_submit(sess, audio, tap_answer, large): |
| if sess is None: |
| return ( |
| None, |
| _question_html(None), |
| None, None, |
| _progress_html(new_session("_")), |
| "Press START first! ๐", |
| "", "no session", |
| gr.update(visible=False), |
| ) |
| sess, feedback, is_correct, img, debug, done = process_response( |
| sess, audio, tap_answer |
| ) |
| lang = sess["lang"] |
| item = sess.get("current_item") |
|
|
| if done: |
| return ( |
| sess, |
| _question_html(None, lang, large), |
| None, None, |
| _progress_html(sess, large), |
| "", |
| _skill_bars_html(sess), |
| debug, |
| gr.update(visible=True, value=_summary_html(sess)), |
| ) |
|
|
| return ( |
| sess, |
| _question_html(item, lang, large), |
| img, |
| None, |
| _progress_html(sess, large), |
| _feedback_html(feedback, is_correct, large), |
| _skill_bars_html(sess), |
| debug, |
| gr.update(visible=False, value=""), |
| ) |
|
|
| |
| all_num_btns = num_btns_r1 + num_btns_r2 |
| for btn in all_num_btns: |
| num_val = btn.value |
| btn.click( |
| fn=lambda s, a, lt, n=num_val: on_submit(s, a, n, lt), |
| inputs=[sess_state, audio_input, large_toggle], |
| outputs=OUTPUTS, |
| ) |
|
|
| |
| audio_input.change( |
| fn=lambda s, a, lt: on_submit(s, a, "", lt), |
| inputs=[sess_state, audio_input, large_toggle], |
| outputs=OUTPUTS, |
| ) |
|
|
| |
| def on_hint(sess, large): |
| if sess is None: |
| return sess, "Press START first!", "", "" |
| sess, hint_text = give_hint(sess) |
| lang = sess.get("lang", "en") |
| item = sess.get("current_item") |
| return ( |
| sess, |
| _feedback_html(hint_text, False, large), |
| _progress_html(sess, large), |
| _skill_bars_html(sess), |
| ) |
|
|
| hint_btn.click( |
| on_hint, |
| inputs=[sess_state, large_toggle], |
| outputs=[sess_state, feedback_html, progress_html, skill_bars_html], |
| ) |
|
|
| |
| def on_skip(sess, large): |
| if sess is None: |
| return (None, _question_html(None), None, None, |
| _progress_html(new_session("_")), "", "", "", |
| gr.update(visible=False)) |
| sess = skip_question(sess) |
| lang = sess["lang"] |
| item = sess.get("current_item") |
| img = _item_image(item) if item else None |
| skip_msg = { |
| "en": "โญ๏ธ Skipped! Next questionโฆ", |
| "fr": "โญ๏ธ Passรฉ ! Question suivanteโฆ", |
| "kin": "โญ๏ธ Basemukiye! Ikibazo gikurikiraโฆ", |
| "sw": "โญ๏ธ Imepita! Swali linalofuataโฆ", |
| }.get(lang, "โญ๏ธ Skipped!") |
| return ( |
| sess, |
| _question_html(item, lang, large), |
| img, None, |
| _progress_html(sess, large), |
| _feedback_html(skip_msg, False, large), |
| _skill_bars_html(sess), |
| "", |
| gr.update(visible=False, value=""), |
| ) |
|
|
| skip_btn.click( |
| on_skip, |
| inputs=[sess_state, large_toggle], |
| outputs=OUTPUTS, |
| ) |
|
|
| return demo, theme |
|
|
|
|
| |
| if __name__ == "__main__": |
| app, theme = build_ui() |
| app.launch(server_name="0.0.0.0", server_port=7860, share=False, theme=theme) |