AI_math / smart_math_tutor_enhanced.py
NSamson1's picture
Update smart_math_tutor_enhanced.py
beeda27 verified
"""
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
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Paths & constants
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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 # hints shown before auto-reveal
XP_CORRECT = 10 # XP awarded for correct first-try answer
XP_HINT = 5 # XP for correct after hint
XP_SKIP = 0
LEVEL_THRESHOLDS = [0, 30, 80, 160, 280, 450] # XP needed for levels 1-6
LEVEL_BADGES = ["๐Ÿฃ Beginner", "๐Ÿฅ Explorer", "๐ŸŒฑ Learner",
"๐ŸŒŸ Achiever", "๐Ÿš€ Champion", "๐Ÿฆ Math Hero"]
SKILL_ICONS = {
"counting": "๐Ÿ”ข",
"number_sense": "๐Ÿง ",
"addition": "โž•",
"subtraction": "โž–",
"word_problem": "๐Ÿ“–",
}
# Celebration messages per language
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! ๐ŸŽค",
}
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Session management
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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",
# Stats
"total_correct": 0,
"total_answered": 0,
"total_skipped": 0,
"streak": 0,
"max_streak": 0,
"xp": 0,
"hint_count": 0, # hints used on current question
"first_try": True, # no hints / wrong attempts yet
"silence_count": 0,
# Per-skill tracking
"skill_correct": defaultdict(int),
"skill_total": defaultdict(int),
# Session question log for summary
"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
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Core response processor
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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 = ""
# โ”€โ”€ Audio path โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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
# โ”€โ”€ Evaluate answer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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)
# โ”€โ”€ Update skill tracking โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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 reward
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
# โ”€โ”€ Log to DB โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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 # Don't crash on DB errors
# โ”€โ”€ Generate feedback โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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"])
# Dyscalculia warning for parents
try:
warnings = state.dyscalculia_warning()
if warnings:
feedback += f"\n\n[๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง Parent note: {', '.join(warnings)} may need attention]"
except Exception:
pass
# โ”€โ”€ Session history โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
sess.setdefault("history", []).append({
"id": item["id"], "skill": skill,
"correct": is_correct, "difficulty": item.get("difficulty", 5),
})
# โ”€โ”€ Advance to next item โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
try:
STORE.end_session(sess["session_id"], state.to_dict())
except Exception:
pass
# Check if curriculum exhausted
remaining = len(sess["queue"])
session_done = (remaining == 0 and sess["phase"] == "diagnostic"
and sess["total_answered"] >= 5) # show summary after โ‰ฅ5 Qs
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:
# First hint: range clue
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:
# Second hint: one digit revealed
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:
# Auto-reveal after 2 hints
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
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Visual helpers
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# HTML builders
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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} &nbsp;|&nbsp;
๐Ÿ”ฅ Best streak: {streak} &nbsp;|&nbsp; โญ๏ธ Skipped: {skipped}</div>
</div>
<ul style="margin:16px 0 0; padding:0 20px; font-size:1em; line-height:2">
{skill_rows}
</ul>
"""
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# CSS & Gradio UI
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_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) # accessibility large-font toggle
# โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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 &nbsp;ยท&nbsp; Aventure Maths &nbsp;ยท&nbsp; Hisabu ya Kusisimua
</p>
</div>
""")
# โ”€โ”€ For Parents collapsible โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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 &amp; Teachers &nbsp;
<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 &lt;name&gt; --lang kin
</code>
</div>
</details>
""")
with gr.Row(equal_height=False):
# โ”€โ”€ Setup panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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 progress (updates after each answer)
skill_bars_html = gr.HTML("")
# โ”€โ”€ Game panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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>'
)
# Number pad 0โ€“5
with gr.Row():
num_btns_r1 = [
gr.Button(str(n), elem_classes=[f"nb{n}"])
for n in range(6)
]
# Number pad 6โ€“10
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
)
# โ”€โ”€ Shared outputs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
OUTPUTS = [
sess_state,
question_html,
item_image,
audio_input,
progress_html,
feedback_html,
skill_bars_html,
debug_box,
summary_html,
]
# โ”€โ”€ on_start โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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,
)
# โ”€โ”€ on_submit (audio + number pad) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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=""),
)
# Wire number pad buttons
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 submit
audio_input.change(
fn=lambda s, a, lt: on_submit(s, a, "", lt),
inputs=[sess_state, audio_input, large_toggle],
outputs=OUTPUTS,
)
# โ”€โ”€ on_hint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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],
)
# โ”€โ”€ on_skip โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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)