Homework_Hero / app.py
sultan-hassan's picture
Upload app.py
c81d507 verified
Raw History Blame Contribute Delete
44 kB
"""
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"<think>.*?</think>", "", 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": '<path d="M39 63 Q50 72 61 63" stroke="#1F2A44" stroke-width="4" fill="none" stroke-linecap="round"/>',
"cheer": '<path d="M37 60 Q50 80 63 60 Z" fill="#1F2A44"/><path d="M43 68 Q50 74 57 68" fill="#FF8FA3"/>',
"think": '<circle cx="56" cy="65" r="4" fill="#1F2A44"/>',
"calm": '<path d="M42 64 Q50 69 58 64" stroke="#1F2A44" stroke-width="4" fill="none" stroke-linecap="round"/>',
"oops": '<path d="M40 66 Q45 61 50 66 T60 66" stroke="#1F2A44" stroke-width="4" fill="none" stroke-linecap="round"/>',
}
eye_y = 44 if mood == "think" else 48
return f"""
<svg class="pip pip-{mood}" viewBox="0 0 100 100" aria-hidden="true">
<line x1="50" y1="20" x2="50" y2="8" stroke="#1F2A44" stroke-width="3" stroke-linecap="round"/>
<path d="M50 1 l2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9-4.3-4.1 5.9-.8z" fill="#FF7A6B"/>
<ellipse cx="50" cy="56" rx="38" ry="35" fill="#FFC83D" stroke="#1F2A44" stroke-width="3"/>
<ellipse cx="50" cy="66" rx="24" ry="16" fill="#FFE08A" opacity=".55"/>
<ellipse cx="37" cy="{eye_y}" rx="5" ry="6.5" fill="#1F2A44"/>
<ellipse cx="63" cy="{eye_y}" rx="5" ry="6.5" fill="#1F2A44"/>
<circle cx="39" cy="{eye_y - 2}" r="1.8" fill="#fff"/><circle cx="65" cy="{eye_y - 2}" r="1.8" fill="#fff"/>
<ellipse cx="26" cy="61" rx="6" ry="4" fill="#FF8FA3" opacity=".7"/>
<ellipse cx="74" cy="61" rx="6" ry="4" fill="#FF8FA3" opacity=".7"/>
{mouths.get(mood, mouths["happy"])}
</svg>"""
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"""
<div class="hh-buddy {tone}"{sfx_attr}>
{buddy_svg(mood)}
<div class="hh-bubble">{e(text)}</div>
</div>"""
# 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'<span class="chip fire">πŸ”₯ {streak} in a row</span>' if streak >= 2 else ""
dots = "".join(
f'<span class="dot {"lit" if i < done else ""}" style="left:{(i + 1) / total * 100:.2f}%"></span>'
for i in range(total - 1))
return f"""
<div class="hh-hud">
<div class="hh-track" role="progressbar" aria-valuemin="0" aria-valuemax="{total}" aria-valuenow="{done}">
<span class="earth">🌍</span>
<div class="rail"><div class="trail" style="width:{pct:.2f}%"></div>{dots}
<span class="rocket" style="left:{pct:.2f}%">πŸš€</span></div>
<span class="planet">πŸͺ</span>
</div>
<div class="hh-chips">
<span class="chip">⭐ {state.get('stars', 0)}</span>
<span class="chip">{done} / {total} done</span>
{fire}
</div>
</div>"""
def question_html(state: dict) -> str:
q = state["queue"][0]
n = state["done"] + 1
total = len(state["questions"])
return f"""
<div class="hh-card hh-q" data-lang="{SPEECH_LANG.get(state.get('lang'), 'en-US')}" data-id="{uid()}">
<p class="hh-label">Mission {n} of {total} Β· just this one</p>
<p class="hh-question">{e(q)}</p>
<div class="hh-timer" aria-hidden="true">
<div class="hh-fill" style="animation-duration:{QUESTION_SECONDS}s"></div>
</div>
<p class="hh-timer-note">The fuel bar is just a guide. Take the time you need.</p>
</div>"""
BREAK_ICONS = ["🀸", "🐱", "πŸ™Œ", "🎈"]
def break_html(n: int) -> str:
title, body = BREAKS[n % len(BREAKS)]
icon = BREAK_ICONS[n % len(BREAK_ICONS)]
return f"""
<div class="hh-card hh-break" data-sfx="break" data-id="{uid()}">
<p class="hh-label">Brain break!</p>
<div class="hh-break-icon">{icon}</div>
<p class="hh-question">{e(title)}</p>
<p class="hh-break-body">{e(body)}</p>
<div class="hh-ring"><svg viewBox="0 0 120 120"><circle cx="60" cy="60" r="52" class="ring-bg"/>
<circle cx="60" cy="60" r="52" class="ring-fg"/></svg><span>20s</span></div>
</div>"""
def done_html(state: dict) -> str:
total = len(state["questions"])
mins = max(1, round((time.time() - state["started"]) / 60))
return f"""
<div class="hh-card hh-done" data-sfx="win" data-id="{uid()}">
<div class="hh-trophy">πŸ†</div>
<p class="hh-question">Mission complete!</p>
<div class="hh-stats">
<div><b>{total}</b><span>questions</span></div>
<div><b>⭐ {state.get('stars', 0)}</b><span>stars</span></div>
<div><b>πŸ”₯ {state.get('best_streak', 0)}</b><span>best streak</span></div>
<div><b>{mins}</b><span>minute{'s' if mins != 1 else ''}</span></div>
</div>
{buddy_svg("cheer")}
<p class="hh-break-body">You used {state['hints']} hint{'s' if state['hints'] != 1 else ''}.
Asking for help is what heroes do!</p>
</div>"""
# ---------------------------------------------------------------- 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"""
<script>
(() => {
if (window.hhReady) return; window.hhReady = true;
let muted = false, autoRead = true;
try { muted = localStorage.getItem('hh-muted') === '1';
autoRead = localStorage.getItem('hh-autoread') !== '0'; } catch (e) {}
let ctx = null;
const ac = () => (ctx = ctx || new (window.AudioContext || window.webkitAudioContext)());
function tone(f, t, d, type = 'triangle', g = 0.18, slideTo = null) {
const a = ac(), o = a.createOscillator(), v = a.createGain(), s = a.currentTime + t;
o.type = type; o.frequency.setValueAtTime(f, s);
if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, s + d);
v.gain.setValueAtTime(0.0001, s); v.gain.exponentialRampToValueAtTime(g, s + 0.02);
v.gain.exponentialRampToValueAtTime(0.0001, s + d);
o.connect(v).connect(a.destination); o.start(s); o.stop(s + d + 0.05);
}
const SFX = {
correct: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.08, 0.25)),
again: () => { tone(392, 0, 0.18, 'sine', 0.12); tone(330, 0.16, 0.28, 'sine', 0.12); },
hint: () => [1319, 1568, 1976].forEach((f, i) => tone(f, i * 0.06, 0.18, 'sine', 0.08)),
start: () => tone(300, 0, 0.45, 'sine', 0.12, 900),
break: () => { tone(220, 0, 0.25, 'square', 0.06, 440); tone(440, 0.25, 0.25, 'square', 0.06, 220); },
win: () => [523, 659, 784, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.13, i === 5 ? 0.6 : 0.2, 'triangle', 0.2)),
};
function play(name) { if (!muted && SFX[name]) { try { SFX[name](); } catch (e) {} } }
function confetti(n) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let box = document.getElementById('hh-fx');
if (!box) { box = document.createElement('div'); box.id = 'hh-fx'; document.body.appendChild(box); }
const colors = ['#FFC83D', '#2FBF85', '#FF7A6B', '#7B61D9', '#4AA8FF'];
for (let i = 0; i < n; i++) {
const c = document.createElement('i'); c.className = 'cf';
c.style.left = Math.random() * 100 + 'vw';
c.style.background = colors[i % colors.length];
c.style.animationDuration = 1.6 + Math.random() * 1.6 + 's';
c.style.animationDelay = Math.random() * 0.3 + 's';
box.appendChild(c); setTimeout(() => c.remove(), 3800);
}
}
function wiggle() {
document.querySelectorAll('.hh-q').forEach(c => {
c.classList.remove('wiggle'); void c.offsetWidth; c.classList.add('wiggle'); });
}
const seen = new Set();
function scan() {
document.querySelectorAll('[data-sfx][data-id]').forEach(el => {
const id = el.dataset.id;
if (seen.has(id) || !el.offsetParent) return;
seen.add(id);
const s = el.dataset.sfx;
play(s);
if (s === 'correct') confetti(40);
if (s === 'win') { confetti(140); setTimeout(() => confetti(80), 700); }
if (s === 'again') wiggle();
});
// Read each new question out loud once, after the reward sound finishes.
document.querySelectorAll('.hh-q[data-id]').forEach(card => {
const id = 'q' + card.dataset.id;
if (seen.has(id) || !card.offsetParent) return;
seen.add(id);
if (autoRead && !muted) setTimeout(() => { if (card.offsetParent) window.hhSpeak(card); }, 900);
});
}
new MutationObserver(() => requestAnimationFrame(scan))
.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });
function label() {
document.querySelectorAll('.hh-sound').forEach(b => b.textContent = muted ? 'πŸ”‡ Sound off' : 'πŸ”Š Sound on');
document.querySelectorAll('.hh-voice').forEach(b => {
b.textContent = autoRead ? 'πŸ—£οΈ Read aloud: on' : 'πŸ—£οΈ Read aloud: off';
b.classList.toggle('off', !autoRead || muted);
});
}
document.addEventListener('click', ev => {
if (ctx && ctx.state === 'suspended') ctx.resume();
if (ev.target.closest('.hh-sound')) {
muted = !muted; try { localStorage.setItem('hh-muted', muted ? '1' : '0'); } catch (e) {}
if (muted && window.speechSynthesis) speechSynthesis.cancel();
label(); if (!muted) play('hint');
}
if (ev.target.closest('.hh-voice')) {
autoRead = !autoRead; try { localStorage.setItem('hh-autoread', autoRead ? '1' : '0'); } catch (e) {}
if (!autoRead && window.speechSynthesis) speechSynthesis.cancel();
label();
}
});
setInterval(label, 1500);
window.hhSpeak = (card) => {
card = card || [...document.querySelectorAll('.hh-q')].find(c => c.offsetParent);
if (!card || !window.speechSynthesis) return;
const u = new SpeechSynthesisUtterance(card.querySelector('.hh-question').textContent
.replace(/Γ—/g, ' times ').replace(/Γ·/g, ' divided by '));
u.lang = card.dataset.lang || 'en-US'; u.rate = 0.9; u.pitch = 1.1;
speechSynthesis.cancel(); speechSynthesis.speak(u);
};
})();
</script>
"""
HEAD = (
'<link rel="preconnect" href="https://fonts.googleapis.com">'
'<link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700'
'&family=Fredoka:wght@500;600&display=swap" rel="stylesheet">'
+ 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"""
<div class="hh-head">
{buddy_svg("happy")}
<div><h1>Homework Hero</h1><p>One question at a time. You've got this!</p></div>
<div class="hh-toggles">
<button class="hh-sound" type="button">πŸ”Š Sound on</button>
<button class="hh-voice" type="button">πŸ—£οΈ Read aloud: on</button>
</div>
</div>""")
# Screen 0: setup
with gr.Column(visible=True) as setup_screen:
gr.HTML('<div class="hh-steps"><span>πŸ“Έ 1. Snap your worksheet</span>'
'<span>🌍 2. Pick a language</span><span>πŸš€ 3. Blast off!</span></div>')
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 {}))