"""
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"""