Spaces:
Sleeping
Sleeping
Download app.py from sultan-hassan/Homework_Hero: direct link, hf CLI and curl.
- Browser
- Download file 44 kB
-
https://huggingface.co/spaces/sultan-hassan/Homework_Hero/resolve/main/app.py
- Command line
-
hf download hf://spaces/sultan-hassan/Homework_Hero/app.py
-
curl -L -o app.py https://huggingface.co/spaces/sultan-hassan/Homework_Hero/resolve/main/app.py
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 | |
| 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 {})) | |