""" Homework Hero — one question at a time. Hold a worksheet up to the camera. Gemma 4 reads it, then the app shows one question at a time with a gentle time bar, hints that never give the answer away, a progress path, and a movement break every few questions. Gemma 4 backends, tried in this order: 1. Google AI Studio (Gemini API, free tier), when GEMINI_API_KEY is set 2. OpenRouter free Gemma 4, when OPENROUTER_API_KEY is set 3. Hugging Face Inference Providers, when HF_TOKEN is set 4. Local Ollama, when it's running with a Gemma 4 model 5. Demo mode with the bundled sample worksheet, so the UI always works Each cloud backend retries and tries both Gemma 4 sizes (31B and 26B) before moving on. """ import base64 import html import io import json import os import re import time import gradio as gr import requests from PIL import Image # Backend 1: Google AI Studio / Gemini API (free tier), used when GEMINI_API_KEY is set. GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") # 26B A4B is a mixture-of-experts model: only ~4B parameters run per token, # so it answers about as fast as a 4B model. 31B is the slower fallback. GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemma-4-26b-a4b-it") GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" # Backend 2: OpenRouter free Gemma 4, used when OPENROUTER_API_KEY is set. OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "") OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" # Fastest multimodal Gemma 4 is 26B A4B (only ~4B parameters active per token). # Set OPENROUTER_MODEL=google/gemma-4-26b-a4b-it for the paid version: a few cents # for a whole hackathon, with more providers so it's faster and more reliable. OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "google/gemma-4-26b-a4b-it:free") OPENROUTER_MODELS = list(dict.fromkeys( [OPENROUTER_MODEL, "google/gemma-4-26b-a4b-it:free", "google/gemma-4-31b-it:free"])) # Backend 3: Hugging Face Inference Providers (used when HF_TOKEN is set). HF_TOKEN = os.getenv("HF_TOKEN", "") HF_URL = "https://router.huggingface.co/v1/chat/completions" HF_MODEL = os.getenv("HF_MODEL", "google/gemma-4-31B-it") # Backend 4: local Ollama (fallback, and best for a live demo on venue Wi-Fi). OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434") MODEL = os.getenv("HERO_MODEL", "gemma4:e4b") # Backend 5: demo mode with the bundled sample worksheet. FORCE_DEMO = os.getenv("HERO_DEMO", "0") == "1" QUESTION_SECONDS = 180 # length of the time bar for each question BREAK_EVERY = 3 # movement break after this many finished questions HERE = os.path.dirname(os.path.abspath(__file__)) SAMPLE = os.path.join(HERE, "sample_worksheet.png") LANGUAGES = ["English", "Spanish", "Amharic", "French"] def make_sample_worksheet(path: str): """Draw the demo worksheet so the app works even if the PNG wasn't uploaded.""" from PIL import ImageDraw, ImageFont try: big, small = ImageFont.load_default(size=52), ImageFont.load_default(size=38) except TypeError: # very old Pillow big = small = ImageFont.load_default() img = Image.new("RGB", (1240, 1600), "white") d = ImageDraw.Draw(img) d.text((80, 70), "Math Practice - Week 6", font=big, fill="black") d.text((80, 160), "Name: ________________ Date: ________", font=small, fill="black") d.text((80, 240), "Solve each problem. Show your work.", font=small, fill="black") probs = ["7 + 5 =", "14 - 6 =", "3 x 4 =", "9 + 8 =", "20 - 7 =", "6 x 2 =", "15 + 6 =", "18 / 3 ="] for i, p in enumerate(probs): d.text((80 + (i % 2) * 560, 340 + (i // 2) * 190), f"{i + 1}. {p} ______", font=small, fill="black") d.text((80, 1130), "9. Maya has 12 stickers. She gives 4 to her", font=small, fill="black") d.text((80, 1185), " brother. How many stickers does she have left?", font=small, fill="black") d.text((80, 1350), '10. Write the word that means the opposite of "hot".', font=small, fill="black") img.save(path) if not os.path.exists(SAMPLE): try: make_sample_worksheet(SAMPLE) except Exception as ex: print("Couldn't create the sample worksheet:", ex) BREAKS = [ ("Jumping jacks!", "Stand up and do 5 jumping jacks."), ("Stretch like a cat", "Reach up high, then touch your toes. Do it 3 times."), ("Wiggle break", "Wiggle your fingers, then your shoulders, then your whole body for 10 seconds."), ("Balloon breath", "Breathe in slowly like you're filling a balloon, then let it out. Do it 3 times."), ] DEMO_QUESTIONS = [ "7 + 5 = ?", "14 - 6 = ?", "3 × 4 = ?", "9 + 8 = ?", "20 - 7 = ?", "6 × 2 = ?", "15 + 6 = ?", "18 ÷ 3 = ?", "Maya has 12 stickers. She gives 4 to her brother. How many stickers does she have left?", "Write the word that means the opposite of \"hot\".", ] # ---------------------------------------------------------------- model calls def ollama_ready() -> bool: if FORCE_DEMO: return False try: tags = requests.get(f"{OLLAMA_URL}/api/tags", timeout=2).json() names = [m.get("name", "") for m in tags.get("models", [])] return any(n.startswith(MODEL.split(":")[0]) for n in names) except Exception: return False def available_backends() -> list[str]: if FORCE_DEMO: return [] found = [] if GEMINI_API_KEY: found.append("google") if OPENROUTER_API_KEY: found.append("openrouter") if HF_TOKEN: found.append("hf") if ollama_ready(): found.append("ollama") return found def image_b64(image: Image.Image) -> str: img = image.convert("RGB") img.thumbnail((1024, 1024)) # smaller image = fewer tokens = faster buf = io.BytesIO() img.save(buf, format="JPEG", quality=88) return base64.b64encode(buf.getvalue()).decode() def ask_hf(prompt: str, image: Image.Image | None, max_tokens=300) -> str: content = [] if image is not None: # Gemma works best with the image before the text content.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_b64(image)}"}}) content.append({"type": "text", "text": prompt + "\nReply with JSON only, no other text."}) r = requests.post( HF_URL, headers={"Authorization": f"Bearer {HF_TOKEN}"}, json={"model": HF_MODEL, "messages": [{"role": "user", "content": content}], "max_tokens": max_tokens, "temperature": 0.2}, timeout=45, ) if r.status_code == 402: raise RuntimeError("Hugging Face free credits are used up for this month") r.raise_for_status() return r.json()["choices"][0]["message"]["content"] RETRY_CODES = {429, 500, 502, 503, 504} def ask_google(prompt: str, image: Image.Image | None, max_tokens=300) -> str: """Gemma 4 on Google's free tier sometimes returns 500s, so retry and try both sizes.""" parts = [] if image is not None: parts.append({"inline_data": {"mime_type": "image/jpeg", "data": image_b64(image)}}) parts.append({"text": prompt + "\nReply with JSON only, no other text."}) body = {"contents": [{"role": "user", "parts": parts}], "generationConfig": {"temperature": 0.2, "maxOutputTokens": max_tokens}} last = "" for model in dict.fromkeys([GEMINI_MODEL, "gemma-4-26b-a4b-it", "gemma-4-31b-it"]): for attempt in range(2): r = requests.post(GEMINI_URL.format(model=model), headers={"x-goog-api-key": GEMINI_API_KEY}, json=body, timeout=45) if r.ok: out = r.json()["candidates"][0]["content"]["parts"] return "".join(p.get("text", "") for p in out if not p.get("thought")) last = f"{model} HTTP {r.status_code}: {r.text[:200]}" print("Google retry:", last) if r.status_code not in RETRY_CODES: raise RuntimeError(last) time.sleep(1.5 * (attempt + 1)) raise RuntimeError(f"Google is busy right now ({last.split(':')[0]})") def ask_openrouter(prompt: str, image: Image.Image | None, max_tokens=300) -> str: content = [] if image is not None: content.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_b64(image)}"}}) content.append({"type": "text", "text": prompt + "\nReply with JSON only, no other text."}) last = "" for model in OPENROUTER_MODELS: for attempt in range(2): r = requests.post( OPENROUTER_URL, headers={"Authorization": f"Bearer {OPENROUTER_API_KEY}", "X-Title": "Homework Hero"}, json={"model": model, "messages": [{"role": "user", "content": content}], "max_tokens": max_tokens, "temperature": 0.2, # pick whichever provider answers fastest "provider": {"sort": "latency"}}, timeout=45, ) if r.ok: data = r.json() if "choices" in data: return data["choices"][0]["message"].get("content") or "" last = f"{model}: {str(data.get('error', data))[:200]}" else: last = f"{model} HTTP {r.status_code}: {r.text[:200]}" if r.status_code not in RETRY_CODES: break print("OpenRouter retry:", last) time.sleep(1.5 * (attempt + 1)) raise RuntimeError(f"OpenRouter is busy right now ({last.split(':')[0]})") def ask_ollama(prompt: str, image: Image.Image | None, max_tokens=300) -> str: msg = {"role": "user", "content": prompt} if image is not None: msg["images"] = [image_b64(image)] body = {"model": MODEL, "messages": [msg], "stream": False, "format": "json", "options": {"temperature": 0.2, "num_predict": max_tokens}} r = requests.post(f"{OLLAMA_URL}/api/chat", json=body, timeout=180) r.raise_for_status() return r.json()["message"]["content"] def ask_gemma(prompt: str, image: Image.Image | None = None, backends=None, max_tokens=300) -> str: """Try each backend in order; if one fails (no credits, no network), use the next.""" errors = [] for b in backends or available_backends(): try: ask = {"google": ask_google, "openrouter": ask_openrouter, "hf": ask_hf, "ollama": ask_ollama}[b] text = ask(prompt, image, max_tokens) return re.sub(r".*?", "", text, flags=re.S).strip() except Exception as ex: errors.append(f"{b}: {ex}") print("Backend failed:", errors[-1]) raise RuntimeError("; ".join(errors) or "no Gemma backend available") def parse_json(text: str): text = re.sub(r"^```(?:json)?|```$", "", text.strip(), flags=re.M).strip() try: return json.loads(text) except json.JSONDecodeError: m = re.search(r"[\[{].*[\]}]", text, re.S) if m: try: return json.loads(m.group(0)) except json.JSONDecodeError: pass return None def salvage_questions(text: str) -> list[str]: """Rescue questions from a reply that was cut off or isn't clean JSON.""" found = [] if '"questions"' in text: body = text.split('"questions"', 1)[1] found = re.findall(r'"((?:[^"\\]|\\.)+)"', body) # complete "..." strings if found: return [json.loads(f'"{q}"') for q in found] lines = [] # fall back to one per line for ln in text.splitlines(): ln = re.sub(r"^\s*(?:[-*•]|\d+[.)])\s*", "", ln).strip() if ln and not ln.startswith(("{", "}", "[", "]", "```")): lines.append(ln) return lines def extract_questions(image: Image.Image, live: list) -> list[str]: if not live: return list(DEMO_QUESTIONS) prompt = ( "This is a photo of a child's homework worksheet. It may be written in any " "language, for example English, Spanish, French or Amharic (Ge'ez script).\n" "List every question or problem the child must answer, in order. Copy each one " "in the SAME language and script as the worksheet; do not translate. Include the " "numbers or words it needs so it makes sense on its own. Leave out names, dates, " "titles and general instructions. Never include answers.\n" 'Reply as JSON: {"questions": ["...", "..."]}' ) # Non-Latin scripts like Amharic use many tokens, so allow a long reply. raw = ask_gemma(prompt, image, live, max_tokens=3000) data = parse_json(raw) qs = data.get("questions", []) if isinstance(data, dict) else data if isinstance(data, list) else [] if not qs: qs = salvage_questions(raw) if not qs: print("Could not read questions. Gemma replied:", raw[:500]) return [str(q).strip() for q in qs if str(q).strip()] def simple_math(question: str): """Solve plain arithmetic like '7 + 5 = ?' so demo mode can check answers.""" expr = question.replace("×", "*").replace("x", "*").replace("÷", "/") m = re.match(r"^\s*(\d+)\s*([+\-*/])\s*(\d+)\s*=", expr) if not m: return None a, op, b = int(m.group(1)), m.group(2), int(m.group(3)) return {"+": a + b, "-": a - b, "*": a * b, "/": a / b if b else None}[op] INSTANT_MESSAGES = { "English": ("You got it!", "So close! Take another look. You can ask for a hint."), "Spanish": ("¡Lo lograste!", "¡Casi! Míralo otra vez. Puedes pedir una pista."), "French": ("Bravo, c'est juste !", "Presque ! Regarde encore. Tu peux demander un indice."), } def check_answer(question: str, answer: str, lang: str, live: list) -> dict: """Returns {"status": "correct" | "try_again" | "cant_tell", "message": str}.""" if not live: target = simple_math(question) if target is None: return {"status": "cant_tell", "message": "Nice work! Let's keep going."} try: ok = abs(float(answer.strip()) - target) < 1e-6 except ValueError: return {"status": "try_again", "message": "Try answering with a number."} if ok: return {"status": "correct", "message": "You got it!"} return {"status": "try_again", "message": "So close! Take another look. You can ask for a hint."} # Plain arithmetic is checked instantly here, with no API call at all. target = simple_math(question) msgs = INSTANT_MESSAGES.get(lang) if target is not None and msgs: try: ok = abs(float(answer.strip().replace(",", ".")) - target) < 1e-6 return {"status": "correct", "message": msgs[0]} if ok else \ {"status": "try_again", "message": msgs[1]} except ValueError: pass # not a number, let Gemma read it prompt = ( "You are a kind, upbeat homework coach for a child with ADHD.\n" f"Question: {question}\nChild's answer: {answer}\n" "Decide if the answer is correct. If it is wrong, do NOT reveal the right " "answer; give one short encouraging sentence that points them to look again. " "If the question is open-ended or you can't be sure, use cant_tell.\n" f"Write the message in {lang}, under 20 words, simple words a 7-year-old knows.\n" 'Reply as JSON: {"status": "correct" | "try_again" | "cant_tell", "message": "..."}' ) data = parse_json(ask_gemma(prompt, None, live)) or {} status = data.get("status", "cant_tell") if status not in ("correct", "try_again", "cant_tell"): status = "cant_tell" return {"status": status, "message": data.get("message", "Nice work!")} DEMO_HINTS = [ "Read the question out loud slowly. What is it asking you to find?", "Try using your fingers, or draw little dots on paper to count.", "Break it into smaller steps. What's the first step?", ] def get_hint(question: str, level: int, lang: str, live: list) -> str: if not live: return DEMO_HINTS[min(level, len(DEMO_HINTS) - 1)] prompt = ( "You are a kind homework coach for a child with ADHD.\n" f"Question: {question}\n" f"This is hint number {level + 1}. Each hint should help a bit more than the " "last, but NEVER say the final answer. Suggest one small, concrete step " "(count on fingers, draw it, break it into parts).\n" f"Write in {lang}, one or two short sentences, simple words.\n" 'Reply as JSON: {"hint": "..."}' ) data = parse_json(ask_gemma(prompt, None, live)) or {} return data.get("hint", DEMO_HINTS[0]) # ---------------------------------------------------------------- rendering def e(s: str) -> str: return html.escape(str(s)) def uid() -> str: return str(time.time_ns()) SPEECH_LANG = {"English": "en-US", "Spanish": "es-ES", "French": "fr-FR", "Amharic": "am-ET"} CHEERS = ["Boom! You got it!", "Rocket power!", "Nailed it!", "Super star!", "You're on fire!"] def buddy_svg(mood: str = "happy") -> str: """Pip, the space buddy. Moods: happy, cheer, think, calm, oops.""" mouths = { "happy": '', "cheer": '', "think": '', "calm": '', "oops": '', } eye_y = 44 if mood == "think" else 48 return f""" """ def buddy_html(mood: str, text: str, sfx: str = "", tone: str = "") -> str: """Pip with a speech bubble. data-sfx tells the browser which sound/effect to play.""" if not text: return "" sfx_attr = f' data-sfx="{sfx}" data-id="{uid()}"' if sfx else "" return f"""
{buddy_svg(mood)}
{e(text)}
""" # Old name kept so the setup screen code stays simple. def feedback_html(kind: str, text: str) -> str: moods = {"yay": ("cheer", "correct"), "again": ("oops", "again"), "hint": ("think", "hint"), "info": ("calm", ""), "oops": ("oops", "")} mood, sfx = moods.get(kind, ("happy", "")) return buddy_html(mood, text, sfx, tone=kind) def hud_html(state: dict) -> str: total = len(state["questions"]) done = state["done"] pct = 0 if total == 0 else done / total * 100 streak = state.get("streak", 0) fire = f'🔥 {streak} in a row' if streak >= 2 else "" dots = "".join( f'' for i in range(total - 1)) return f"""
🌍
{dots} 🚀
🪐
⭐ {state.get('stars', 0)} {done} / {total} done {fire}
""" def question_html(state: dict) -> str: q = state["queue"][0] n = state["done"] + 1 total = len(state["questions"]) return f"""

Mission {n} of {total} · just this one

{e(q)}

The fuel bar is just a guide. Take the time you need.

""" BREAK_ICONS = ["🤸", "🐱", "🙌", "🎈"] def break_html(n: int) -> str: title, body = BREAKS[n % len(BREAKS)] icon = BREAK_ICONS[n % len(BREAK_ICONS)] return f"""

Brain break!

{icon}

{e(title)}

{e(body)}

20s
""" def done_html(state: dict) -> str: total = len(state["questions"]) mins = max(1, round((time.time() - state["started"]) / 60)) return f"""
🏆

Mission complete!

{total}questions
⭐ {state.get('stars', 0)}stars
🔥 {state.get('best_streak', 0)}best streak
{mins}minute{'s' if mins != 1 else ''}
{buddy_svg("cheer")}

You used {state['hints']} hint{'s' if state['hints'] != 1 else ''}. Asking for help is what heroes do!

""" # ---------------------------------------------------------------- handlers SCREENS = 4 # setup, play, break, done def show(screen: int): return [gr.update(visible=(i == screen)) for i in range(SCREENS)] def start(image, lang, state): live = available_backends() keep = [gr.update()] * 3 if image is None and live: return [state, feedback_html("oops", "Show me a photo of your worksheet first!")] + keep + show(0) try: img = image if image is not None else Image.open(SAMPLE) questions = extract_questions(img, live) except Exception as ex: return [state, feedback_html("oops", f"I couldn't reach Gemma ({ex}). Check the API keys in the Space settings, then try again.")] + keep + show(0) if not questions: return [state, feedback_html("oops", "I couldn't find any questions. Try a closer photo with good light.")] + keep + show(0) state = { "questions": questions, "queue": list(questions), "done": 0, "hint_level": 0, "hints": 0, "breaks": 0, "stars": 0, "streak": 0, "best_streak": 0, "started": time.time(), "live": live, "lang": lang, } hello = f"I found {len(questions)} missions! Let's do them one at a time." if not live: hello = "Demo mode: no Gemma connection, so I'm using the sample worksheet. " + hello return [state, "", hud_html(state), question_html(state), buddy_html("cheer", hello, "start")] + show(1) def submit(answer_text, state): """Outputs: answer, state, hud, card, feedback, break_card, done_card, *screens""" keep = gr.update() if not answer_text or not answer_text.strip(): return [keep, state, keep, keep, feedback_html("info", "Type your answer, then press Check!"), keep, keep] + show(1) q = state["queue"][0] try: result = check_answer(q, answer_text, state["lang"], state["live"]) except Exception: result = {"status": "cant_tell", "message": "Nice work! Let's keep going."} if result["status"] == "try_again": state["streak"] = 0 return [keep, state, hud_html(state), keep, feedback_html("again", result["message"]), keep, keep] + show(1) state["queue"].pop(0) state["done"] += 1 state["hint_level"] = 0 state["stars"] += 1 state["streak"] += 1 state["best_streak"] = max(state["best_streak"], state["streak"]) hud = hud_html(state) if not state["queue"]: return ["", state, hud, keep, "", keep, done_html(state)] + show(3) if state["done"] % BREAK_EVERY == 0: state["breaks"] += 1 return ["", state, hud, keep, "", break_html(state["breaks"] - 1), keep] + show(2) msg = result["message"] if state["streak"] >= 3: msg += f" That's {state['streak']} in a row!" return ["", state, hud, question_html(state), feedback_html("yay", msg + " ⭐"), keep, keep] + show(1) def hint(state): q = state["queue"][0] try: h = get_hint(q, state["hint_level"], state["lang"], state["live"]) except Exception: h = DEMO_HINTS[0] state["hint_level"] += 1 state["hints"] += 1 return state, feedback_html("hint", h) def skip(state): q = state["queue"].pop(0) state["queue"].append(q) state["hint_level"] = 0 return state, question_html(state), feedback_html("info", "No problem! We'll come back to that one later.") def back_from_break(state): return [question_html(state), buddy_html("cheer", "Welcome back! Ready for the next mission?", "start")] + show(1) def restart(state): return [{}, ""] + show(0) # ---------------------------------------------------------------- styles, sounds, effects CSS = """ :root, .light { --hh-ink:#1F2A44; --hh-paper:#FFFFFF; --hh-bg1:#EEF2FF; --hh-bg2:#FFF7E6; --hh-sky:#E3E9FF; --hh-sun:#FFC83D; --hh-mint:#2FBF85; --hh-coral:#FF7A6B; --hh-plum:#7B61D9; --hh-muted:#5B6478; --hh-line:#D9DFF0; --hh-shadow:0 10px 30px rgba(40,50,110,.12); } .dark { --hh-ink:#EEF2FA; --hh-paper:#1D2438; --hh-bg1:#121729; --hh-bg2:#1A1730; --hh-sky:#2A3352; --hh-sun:#F5BE2E; --hh-mint:#4FD39E; --hh-coral:#FF8E80; --hh-plum:#A994F0; --hh-muted:#A8B1C4; --hh-line:#323C5C; --hh-shadow:0 10px 30px rgba(0,0,0,.35); } body, .gradio-container { background: radial-gradient(circle at 12% 8%, var(--hh-bg2) 0, transparent 40%), radial-gradient(circle at 90% 20%, var(--hh-bg1) 0, transparent 45%), var(--hh-bg1) !important; } .gradio-container { max-width: 780px !important; margin: 0 auto !important; font-family: 'Atkinson Hyperlegible', system-ui, sans-serif !important; } footer { opacity:.5; } /* header */ .hh-head { display:flex; align-items:center; gap:14px; margin:6px 0 2px; } .hh-head .pip { width:64px; height:64px; } .hh-head h1 { font-family:'Fredoka','Atkinson Hyperlegible',sans-serif; font-size:2.3rem; font-weight:600; color:var(--hh-ink); margin:0; line-height:1; } .hh-head p { color:var(--hh-muted); margin:4px 0 0; font-size:1.05rem; } .hh-toggles { margin-left:auto; display:flex; flex-direction:column; gap:6px; align-items:flex-end; } .hh-voice.off { opacity:.6; } .hh-sound, .hh-voice { border:2px solid var(--hh-line); background:var(--hh-paper); color:var(--hh-ink); border-radius:999px; padding:8px 14px; font-size:1rem; cursor:pointer; box-shadow:var(--hh-shadow); } .hh-steps { display:flex; gap:10px; flex-wrap:wrap; margin:10px 0 4px; } .hh-steps span { background:var(--hh-paper); border:2px solid var(--hh-line); border-radius:999px; padding:8px 14px; color:var(--hh-ink); font-weight:700; } /* buddy + bubble */ .hh-buddy { display:flex; align-items:center; gap:14px; margin:6px 0; animation:pop .35s ease-out; } .pip { width:84px; height:84px; flex:none; animation:bob 2.6s ease-in-out infinite; } .pip-cheer { animation:jump .6s ease-out 2, bob 2.6s ease-in-out 1.2s infinite; } .pip-oops { animation:tilt .5s ease-in-out 2; } .hh-bubble { position:relative; background:var(--hh-paper); color:var(--hh-ink); border:2px solid var(--hh-line); border-radius:20px; padding:14px 18px; font-size:1.25rem; line-height:1.4; box-shadow:var(--hh-shadow); } .hh-bubble::before { content:""; position:absolute; left:-11px; top:50%; width:18px; height:18px; background:inherit; border-left:2px solid var(--hh-line); border-bottom:2px solid var(--hh-line); transform:translateY(-50%) rotate(45deg); } .hh-buddy.yay .hh-bubble { border-color:var(--hh-mint); } .hh-buddy.again .hh-bubble { border-color:var(--hh-sun); } .hh-buddy.hint .hh-bubble { border-color:var(--hh-plum); } .hh-buddy.oops .hh-bubble { border-color:var(--hh-coral); } /* HUD: rocket progress */ .hh-hud { margin:4px 0 12px; } .hh-track { display:flex; align-items:center; gap:10px; font-size:1.8rem; } .rail { position:relative; flex:1; height:16px; background:var(--hh-sky); border-radius:999px; } .trail { height:100%; border-radius:999px; background:linear-gradient(90deg,var(--hh-plum),var(--hh-sun)); transition:width .8s cubic-bezier(.3,1.4,.5,1); } .rail .dot { position:absolute; top:50%; width:8px; height:8px; margin:-4px 0 0 -4px; border-radius:50%; background:var(--hh-line); } .rail .dot.lit { background:#fff; } .rocket { position:absolute; top:50%; transform:translate(-50%,-55%) rotate(45deg); font-size:2rem; transition:left .8s cubic-bezier(.3,1.4,.5,1); filter:drop-shadow(0 4px 6px rgba(0,0,0,.2)); } .hh-chips { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; } .chip { background:var(--hh-paper); border:2px solid var(--hh-line); color:var(--hh-ink); border-radius:999px; padding:6px 14px; font-weight:700; font-size:1.05rem; } .chip.fire { border-color:var(--hh-coral); animation:pop .4s ease-out; } /* question card */ .hh-card { background:var(--hh-paper); border:3px solid var(--hh-line); border-radius:28px; padding:28px 30px 22px; color:var(--hh-ink); box-shadow:var(--hh-shadow); animation:pop .4s ease-out; } .hh-card.wiggle { animation:wiggle .5s ease-in-out; border-color:var(--hh-sun); } .hh-label { color:var(--hh-muted); font-size:1rem; font-weight:700; letter-spacing:.02em; margin:0 0 8px; } .hh-question { font-family:'Fredoka','Atkinson Hyperlegible',sans-serif; font-weight:500; font-size:clamp(2rem, 6vw, 3.2rem); line-height:1.2; margin:0 0 22px; } .hh-timer { height:14px; border-radius:7px; background:var(--hh-sky); overflow:hidden; } .hh-fill { height:100%; width:100%; background:linear-gradient(90deg,var(--hh-mint),var(--hh-sun)); transform-origin:left; animation-name:shrink; animation-timing-function:linear; animation-fill-mode:forwards; } .hh-timer-note { color:var(--hh-muted); font-size:.95rem; margin:8px 0 0; } /* inputs and buttons */ .hh-answer textarea, .hh-answer input { font-size:1.8rem !important; padding:14px 16px !important; border-radius:18px !important; } .hh-btns button { font-size:1.15rem !important; border-radius:18px !important; min-height:58px; font-weight:700 !important; transition:transform .12s ease; } .hh-btns button:hover { transform:translateY(-2px); } .hh-btns button:active { transform:scale(.97); } .hh-go button, button.hh-go { background:var(--hh-mint) !important; color:#06281b !important; border:none !important; } .hh-help button, button.hh-help { background:#EFEAFF !important; color:#3B2A8C !important; border:none !important; } .hh-later button, button.hh-later { background:var(--hh-paper) !important; color:var(--hh-ink) !important; border:2px solid var(--hh-line) !important; } .hh-read button, button.hh-read { background:#FFF3D1 !important; color:#5A4300 !important; border:none !important; } /* break */ .hh-break { text-align:center; } .hh-break-icon { font-size:5rem; animation:jump .9s ease-in-out infinite; } .hh-break-body { font-size:1.35rem; line-height:1.5; margin:0 0 14px; } .hh-ring { position:relative; width:110px; height:110px; margin:6px auto 0; } .hh-ring svg { width:100%; height:100%; transform:rotate(-90deg); } .hh-ring circle { fill:none; stroke-width:10; } .ring-bg { stroke:var(--hh-sky); } .ring-fg { stroke:var(--hh-mint); stroke-linecap:round; stroke-dasharray:327; stroke-dashoffset:0; animation:ring 20s linear forwards; } .hh-ring span { position:absolute; inset:0; display:grid; place-items:center; font-weight:700; font-size:1.3rem; color:var(--hh-ink); } /* done */ .hh-done { text-align:center; } .hh-done .pip { width:110px; height:110px; margin:6px auto 0; display:block; } .hh-trophy { font-size:5.5rem; animation:jump .8s ease-out 3; } .hh-stats { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin:6px 0 4px; } .hh-stats div { background:var(--hh-sky); border-radius:18px; padding:12px 6px; } .hh-stats b { display:block; font-size:1.6rem; } .hh-stats span { color:var(--hh-muted); font-size:.95rem; } @media (max-width:560px) { .hh-stats { grid-template-columns:repeat(2,1fr); } .pip { width:64px; height:64px; } } /* confetti */ #hh-fx { position:fixed; inset:0; pointer-events:none; overflow:hidden; z-index:9999; } .cf { position:absolute; top:-20px; width:10px; height:16px; border-radius:3px; animation:fall linear forwards; } @keyframes pop { from { transform:scale(.92); opacity:0 } to { transform:scale(1); opacity:1 } } @keyframes bob { 0%,100% { transform:translateY(0) } 50% { transform:translateY(-6px) } } @keyframes jump { 0%,100% { transform:translateY(0) } 40% { transform:translateY(-16px) scale(1.06) } } @keyframes tilt { 0%,100% { transform:rotate(0) } 30% { transform:rotate(-8deg) } 70% { transform:rotate(8deg) } } @keyframes wiggle { 0%,100% { transform:translateX(0) } 20% { transform:translateX(-8px) } 40% { transform:translateX(8px) } 60% { transform:translateX(-5px) } 80% { transform:translateX(5px) } } @keyframes shrink { from { transform:scaleX(1) } to { transform:scaleX(0) } } @keyframes ring { to { stroke-dashoffset:327 } } @keyframes fall { to { transform:translateY(110vh) rotate(720deg) } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation:none !important; transition:none !important; } } """ FX_JS = r""" """ HEAD = ( '' '' + FX_JS ) THEME = gr.themes.Soft(primary_hue="indigo", radius_size="lg") # ZeroGPU Spaces need at least one @spaces.GPU function to start. The app never # calls it (Gemma runs on Hugging Face's servers), so it uses no GPU quota. try: import spaces @spaces.GPU def gpu_check(): return "ok" except ImportError: pass # Gradio 6 takes theme/css/head in launch(); Gradio 5 takes them in Blocks(). import inspect NEW_GRADIO = "css" in inspect.signature(gr.Blocks.launch).parameters STYLE = dict(theme=THEME, css=CSS, head=HEAD) with gr.Blocks(title="Homework Hero", **({} if NEW_GRADIO else STYLE)) as demo: state = gr.State({}) gr.HTML(f"""
{buddy_svg("happy")}

Homework Hero

One question at a time. You've got this!

""") # Screen 0: setup with gr.Column(visible=True) as setup_screen: gr.HTML('
📸 1. Snap your worksheet' '🌍 2. Pick a language🚀 3. Blast off!
') photo = gr.Image(label="Show me your worksheet", sources=["webcam", "upload"], type="pil", height=340) lang = gr.Dropdown(LANGUAGES, value="English", label="Language for hints and cheers") with gr.Row(elem_classes="hh-btns"): start_btn = gr.Button("🚀 Start my mission", elem_classes="hh-go", size="lg", min_width=90) sample_btn = gr.Button("📄 Try the sample worksheet", elem_classes="hh-later", size="lg", min_width=90) setup_msg = gr.HTML() # Screen 1: one question with gr.Column(visible=False) as play_screen: hud = gr.HTML() card = gr.HTML() answer = gr.Textbox(label="Your answer", placeholder="Type it here, then press Enter ↵", elem_classes="hh-answer", autofocus=True) with gr.Row(elem_classes="hh-btns"): check_btn = gr.Button("✅ Check", elem_classes="hh-go", size="lg", min_width=90) hint_btn = gr.Button("💡 Hint", elem_classes="hh-help", size="lg", min_width=90) read_btn = gr.Button("🔊 Read it", elem_classes="hh-read", size="lg", min_width=90) skip_btn = gr.Button("⏭️ Later", elem_classes="hh-later", size="lg", min_width=90) feedback = gr.HTML() # Screen 2: movement break with gr.Column(visible=False) as break_screen: break_card = gr.HTML() with gr.Row(elem_classes="hh-btns"): back_btn = gr.Button("🙌 I'm back!", elem_classes="hh-go", size="lg", min_width=90) # Screen 3: finished with gr.Column(visible=False) as done_screen: done_card = gr.HTML() with gr.Row(elem_classes="hh-btns"): again_btn = gr.Button("📄 Start a new worksheet", elem_classes="hh-go", size="lg", min_width=90) screens = [setup_screen, play_screen, break_screen, done_screen] start_outputs = [state, setup_msg, hud, card, feedback] + screens start_btn.click(start, [photo, lang, state], start_outputs) sample_btn.click(lambda l, s: start(Image.open(SAMPLE), l, s), [lang, state], start_outputs) route_outputs = [answer, state, hud, card, feedback, break_card, done_card] + screens check_btn.click(submit, [answer, state], route_outputs) answer.submit(submit, [answer, state], route_outputs) hint_btn.click(hint, [state], [state, feedback]) read_btn.click(None, None, None, js="() => { window.hhSpeak && window.hhSpeak(); }") skip_btn.click(skip, [state], [state, card, feedback]) back_btn.click(back_from_break, [state], [card, feedback] + screens) again_btn.click(restart, [state], [state, setup_msg] + screens) if __name__ == "__main__": names = {"google": f"Google AI Studio ({GEMINI_MODEL})", "openrouter": f"OpenRouter ({OPENROUTER_MODELS[0]})", "hf": f"Hugging Face ({HF_MODEL})", "ollama": f"Ollama ({MODEL})"} found = available_backends() print("Homework Hero backends:", ", ".join(names[b] for b in found) or "demo mode only") demo.launch(**(STYLE if NEW_GRADIO else {}))