""" Spelling Practice – Hugging Face Space Multi-user safe via gr.State (JSON string per session). """ # ── Import gradio first, then patch the gradio_client 0.8 schema bug ────────── # gradio_client 0.8 (bundled with gradio 4.40) crashes in get_api_info() when # any component schema contains `additionalProperties: true` (a bool). # The fix: wrap json_schema_to_python_type to catch the exception, patching # both the gradio_client module AND the already-imported reference in gradio.blocks. import gradio as gr try: import gradio_client.utils as _gcu import gradio.blocks as _gb _orig_jspt = _gcu.json_schema_to_python_type def _safe_jspt(schema): try: return _orig_jspt(schema) except Exception: return "Any" _gcu.json_schema_to_python_type = _safe_jspt _gb.client_utils.json_schema_to_python_type = _safe_jspt except Exception: pass # ───────────────────────────────────────────────────────────────────────────── import os import glob import random import tempfile import difflib import json import time import threading from gtts import gTTS try: from llama_cpp import Llama _LLAMA_AVAILABLE = True except ImportError: _LLAMA_AVAILABLE = False _llm = None _llm_lock = threading.Lock() def get_llm(): global _llm if _llm is not None: return _llm if not _LLAMA_AVAILABLE: return None with _llm_lock: if _llm is not None: return _llm gguf_files = glob.glob("/app/*.gguf") + glob.glob("*.gguf") if not gguf_files: print("[spelling-app] No .gguf file found – AI tips disabled.") return None model_path = gguf_files[0] print(f"[spelling-app] Loading model: {model_path}") _llm = Llama(model_path=model_path, n_ctx=512, n_threads=2, n_gpu_layers=int(os.getenv("N_GPU_LAYERS", "-1")), verbose=False) print("[spelling-app] Model ready.") return _llm SENTENCES = { "easy": [ "The student read a book.", "I like to learn English.", "The bank has no money.", "She has a new job.", "He can read very fast.", "The map shows a river.", "We write notes in class.", "Put the book on the desk.", "I see a small farm.", "The goal is very clear.", ], "medium": [ "The Southern colonies grew tobacco for Europe.", "The workers found jobs in the factory.", "He forgot to cite the original source.", "They experienced a major economic boom.", "My teacher helps me write better essays.", "The market was very weak in December.", "She received a new evening gown quickly.", "We need to manage our time properly.", "The group discussion went very well.", "The online platform closes at midnight.", ], "hard": [ "The government announced policies to address the downturn.", "She has an extraordinary talent for analyzing economic literature.", "The archaeologist made a fascinating discovery of ancient artifacts.", "It is necessary to manage your time effectively.", "The corporation was highly profitable after the merger.", "He struggled to translate the complex economic metaphors.", "The instructor approved the research outline immediately.", "She demonstrated remarkable perseverance throughout her academic journey.", "Maintaining a regular study schedule is very important.", "The researcher examined the primary historical document.", ], "expert": [ "The multinational corporation acknowledged the financial discrepancy.", "His idiosyncratic research methodologies bewildered his academic colleagues.", "The distinguished economist delivered a highly influential speech.", "Technological advancements fundamentally transformed nineteenth-century transportation.", "Maslow conceptualized self-actualization as the highest human need.", "Agricultural consolidation overwhelmed small family farming operations.", "Translating metaphorical expressions requires extraordinary cultural competence.", "Plagiarism often leads to severe academic consequences.", "The entrepreneur demonstrated extraordinary economic decision-making.", "The researcher meticulously catalogued primary historical sources.", ], } LEVELS = ["easy", "medium", "hard", "expert"] LEVEL_LABELS = ["🟢 Beginner", "🔵 Intermediate", "🟠 Advanced", "🔴 Expert"] PASS_REQ = 3 def fresh_state_str(): return json.dumps({"level": 0, "streak": 0, "sentence": "", "score": 0, "total": 0, "history": []}) def S(s): return json.loads(s) def D(d): return json.dumps(d) def _make_audio(text): try: tts = gTTS(text=text, lang="en", slow=True) path = tempfile.mktemp(suffix=".mp3") tts.save(path) return path except Exception as exc: print(f"[spelling-app] gTTS error: {exc}") return None def _find_errors(correct, attempt): c_words = correct.rstrip(".").lower().split() a_words = attempt.rstrip(".").lower().split() errors = [] for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a_words, c_words).get_opcodes(): if tag == "replace": for w, r in zip(a_words[i1:i2], c_words[j1:j2]): if w != r: errors.append((w, r)) elif tag == "delete": for w in a_words[i1:i2]: errors.append((w, "[extra word]")) elif tag == "insert": for r in c_words[j1:j2]: errors.append(("[missing]", r)) return errors def _ask_gemma(correct, attempt, errors): llm = get_llm() if not llm or not errors: return "" error_list = "\n".join(f'- You wrote "{w}", correct is "{r}"' for w, r in errors) prompt = (f"You are a friendly English spelling teacher.\n" f"The student had to write: {correct}\n" f"The student wrote: {attempt}\n" f"Spelling mistakes:\n{error_list}\n\n" "For each mistake, write one short sentence explaining the error " "and one memory tip. Be encouraging. Keep it brief.") out = llm(prompt, max_tokens=200, temperature=0.3, stop=["###"]) return out["choices"][0]["text"].strip() def _build_diff(correct, attempt): c_words, a_words = correct.split(), attempt.split() parts = [] sm = difflib.SequenceMatcher( None, [w.rstrip(".,").lower() for w in a_words], [w.rstrip(".,").lower() for w in c_words]) for tag, i1, i2, j1, j2 in sm.get_opcodes(): if tag == "equal": parts.append(" ".join(a_words[i1:i2])) elif tag == "replace": parts.append("~~"+" ".join(a_words[i1:i2])+"~~ **"+" ".join(c_words[j1:j2])+"**") elif tag == "delete": parts.append("~~"+" ".join(a_words[i1:i2])+"~~") elif tag == "insert": parts.append("**["+" ".join(c_words[j1:j2])+"]**") return " ".join(parts) def _level_info(state): lvl = state["level"] extra = f" · streak {state['streak']}/{PASS_REQ} to level up" if lvl < len(LEVELS)-1 else " · MAX LEVEL 🏆" return f"**{LEVEL_LABELS[lvl]}**{extra}" def _score_info(state): t, s = state["total"], state["score"] return f"**Score: {s}/{t}** ({int(s/t*100) if t else 0}%)" def new_sentence(ss): state = S(ss) sent = random.choice(SENTENCES[LEVELS[state["level"]]]) state["sentence"] = sent audio = _make_audio(sent) hint = "🎧 Listen carefully, then type what you heard and press **Submit**." if audio is None: hint = f"⚠️ Audio unavailable. Sentence: **{sent}**" return audio, "", hint, _level_info(state), _score_info(state), D(state) def submit_answer(attempt, ss): state = S(ss) if not state.get("sentence"): return "", "⚠️ Press **New Sentence** first!", _level_info(state), _score_info(state), ss correct = state["sentence"] state["total"] += 1 errors = _find_errors(correct, attempt) clean_a = attempt.strip().rstrip(".").lower() clean_c = correct.strip().rstrip(".").lower() if not errors and clean_a == clean_c: state["score"] += 1 state["streak"] += 1 msg = "✅ **Correct!** Every word spelled perfectly." if state["streak"] >= PASS_REQ and state["level"] < len(LEVELS)-1: state["level"] += 1 state["streak"] = 0 msg += f"\n\n🎉 **Level up! You are now {LEVEL_LABELS[state['level']]}.**" else: state["streak"] = 0 diff_str = _build_diff(correct, attempt) gemma_tip = _ask_gemma(correct, attempt, errors) ai_block = f"\n\n---\n\n**💡 Tips:**\n\n{gemma_tip}" if gemma_tip else "" msg = ("❌ **Your answer with corrections:**\n\n" + diff_str + "\n\n*(~~strikethrough~~ = your error · **bold** = correct spelling)*" + ai_block) state["history"].append({"correct": correct, "attempt": attempt, "ok": not bool(errors) and clean_a == clean_c, "level": LEVELS[state["level"]], "ts": time.strftime("%H:%M:%S")}) state["sentence"] = "" return attempt, msg, _level_info(state), _score_info(state), D(state) def replay_audio(ss): state = S(ss) return _make_audio(state["sentence"]) if state.get("sentence") else None def reset_game(_ss): state = S(fresh_state_str()) return None, "", "Game reset. Press **New Sentence** to start!", _level_info(state), _score_info(state), fresh_state_str() def export_history(ss): history = S(ss).get("history", []) if not history: return "No history yet." lines = [] for h in history: ok = "✅" if h["ok"] else "❌" lines.append(f'{ok} [{h["level"]}] {h["ts"]} | {h["correct"]} → {h["attempt"]}') return "\n".join(lines) CSS = "footer { display: none !important; }" _init = S(fresh_state_str()) with gr.Blocks(title="Spelling Practice", css=CSS) as demo: session = gr.State(fresh_state_str()) gr.Markdown("# 📝 Spelling Practice\nListen · Type · See errors · Get tips") with gr.Row(): level_md = gr.Markdown(_level_info(_init)) score_md = gr.Markdown(_score_info(_init)) with gr.Row(): new_btn = gr.Button("🎲 New Sentence", variant="primary") replay_btn = gr.Button("🔁 Replay Audio", variant="secondary") reset_btn = gr.Button("🔄 Reset Game", variant="secondary") audio_out = gr.Audio(label="Listen to the sentence", autoplay=True, interactive=False) hint_md = gr.Markdown("Press **New Sentence** to begin.") answer_box = gr.Textbox(label="Type the sentence you heard", placeholder="Write the full sentence here…", lines=2) submit_btn = gr.Button("✅ Submit", variant="primary") feedback = gr.Markdown("") with gr.Accordion("📊 Session History", open=False): export_btn = gr.Button("📋 Show history") history_box = gr.Textbox(label="Your session", lines=10, interactive=False) OUTS = [audio_out, answer_box, hint_md, level_md, score_md, session] new_btn.click(new_sentence, [session], OUTS) replay_btn.click(replay_audio, [session], [audio_out]) submit_btn.click(submit_answer, [answer_box, session], [answer_box, feedback, level_md, score_md, session]) answer_box.submit(submit_answer,[answer_box, session], [answer_box, feedback, level_md, score_md, session]) reset_btn.click(reset_game, [session], OUTS) export_btn.click(export_history,[session], [history_box]) if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860) """ Spelling Practice – Hugging Face Space Multi-user safe via gr.State (JSON string per session). """ # ── Import gradio first, then patch the gradio_client 0.8 schema bug ────────── # gradio_client 0.8 (bundled with gradio 4.40) crashes in get_api_info() when # any component schema contains `additionalProperties: true` (a bool). # The fix: wrap json_schema_to_python_type to catch the exception, patching # both the gradio_client module AND the already-imported reference in gradio.blocks. import gradio as gr try: import gradio_client.utils as _gcu import gradio.blocks as _gb _orig_jspt = _gcu.json_schema_to_python_type def _safe_jspt(schema): try: return _orig_jspt(schema) except Exception: return "Any" _gcu.json_schema_to_python_type = _safe_jspt _gb.client_utils.json_schema_to_python_type = _safe_jspt except Exception: pass # ───────────────────────────────────────────────────────────────────────────── import os import glob import random import tempfile import difflib import json import time import threading from gtts import gTTS try: from llama_cpp import Llama _LLAMA_AVAILABLE = True except ImportError: _LLAMA_AVAILABLE = False _llm = None _llm_lock = threading.Lock() def get_llm(): global _llm if _llm is not None: return _llm if not _LLAMA_AVAILABLE: return None with _llm_lock: if _llm is not None: return _llm gguf_files = glob.glob("/app/*.gguf") + glob.glob("*.gguf") if not gguf_files: print("[spelling-app] No .gguf file found – AI tips disabled.") return None model_path = gguf_files[0] print(f"[spelling-app] Loading model: {model_path}") _llm = Llama(model_path=model_path, n_ctx=512, n_threads=2, n_gpu_layers=int(os.getenv("N_GPU_LAYERS", "-1")), verbose=False) print("[spelling-app] Model ready.") return _llm SENTENCES = { "easy": [ "The student read a book.", "I like to learn English.", "The bank has no money.", "She has a new job.", "He can read very fast.", "The map shows a river.", "We write notes in class.", "Put the book on the desk.", "I see a small farm.", "The goal is very clear.", ], "medium": [ "The Southern colonies grew tobacco for Europe.", "The workers found jobs in the factory.", "He forgot to cite the original source.", "They experienced a major economic boom.", "My teacher helps me write better essays.", "The market was very weak in December.", "She received a new evening gown quickly.", "We need to manage our time properly.", "The group discussion went very well.", "The online platform closes at midnight.", ], "hard": [ "The government announced policies to address the downturn.", "She has an extraordinary talent for analyzing economic literature.", "The archaeologist made a fascinating discovery of ancient artifacts.", "It is necessary to manage your time effectively.", "The corporation was highly profitable after the merger.", "He struggled to translate the complex economic metaphors.", "The instructor approved the research outline immediately.", "She demonstrated remarkable perseverance throughout her academic journey.", "Maintaining a regular study schedule is very important.", "The researcher examined the primary historical document.", ], "expert": [ "The multinational corporation acknowledged the financial discrepancy.", "His idiosyncratic research methodologies bewildered his academic colleagues.", "The distinguished economist delivered a highly influential speech.", "Technological advancements fundamentally transformed nineteenth-century transportation.", "Maslow conceptualized self-actualization as the highest human need.", "Agricultural consolidation overwhelmed small family farming operations.", "Translating metaphorical expressions requires extraordinary cultural competence.", "Plagiarism often leads to severe academic consequences.", "The entrepreneur demonstrated extraordinary economic decision-making.", "The researcher meticulously catalogued primary historical sources.", ], } LEVELS = ["easy", "medium", "hard", "expert"] LEVEL_LABELS = ["🟢 Beginner", "🔵 Intermediate", "🟠 Advanced", "🔴 Expert"] PASS_REQ = 3 def fresh_state_str(): return json.dumps({"level": 0, "streak": 0, "sentence": "", "score": 0, "total": 0, "history": []}) def S(s): return json.loads(s) def D(d): return json.dumps(d) def _make_audio(text): try: tts = gTTS(text=text, lang="en", slow=True) path = tempfile.mktemp(suffix=".mp3") tts.save(path) return path except Exception as exc: print(f"[spelling-app] gTTS error: {exc}") return None def _find_errors(correct, attempt): c_words = correct.rstrip(".").lower().split() a_words = attempt.rstrip(".").lower().split() errors = [] for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a_words, c_words).get_opcodes(): if tag == "replace": for w, r in zip(a_words[i1:i2], c_words[j1:j2]): if w != r: errors.append((w, r)) elif tag == "delete": for w in a_words[i1:i2]: errors.append((w, "[extra word]")) elif tag == "insert": for r in c_words[j1:j2]: errors.append(("[missing]", r)) return errors def _ask_gemma(correct, attempt, errors): llm = get_llm() if not llm or not errors: return "" error_list = "\n".join(f'- You wrote "{w}", correct is "{r}"' for w, r in errors) prompt = (f"You are a friendly English spelling teacher.\n" f"The student had to write: {correct}\n" f"The student wrote: {attempt}\n" f"Spelling mistakes:\n{error_list}\n\n" "For each mistake, write one short sentence explaining the error " "and one memory tip. Be encouraging. Keep it brief.") out = llm(prompt, max_tokens=200, temperature=0.3, stop=["###"]) return out["choices"][0]["text"].strip() def _build_diff(correct, attempt): c_words, a_words = correct.split(), attempt.split() parts = [] sm = difflib.SequenceMatcher( None, [w.rstrip(".,").lower() for w in a_words], [w.rstrip(".,").lower() for w in c_words]) for tag, i1, i2, j1, j2 in sm.get_opcodes(): if tag == "equal": parts.append(" ".join(a_words[i1:i2])) elif tag == "replace": parts.append("~~"+" ".join(a_words[i1:i2])+"~~ **"+" ".join(c_words[j1:j2])+"**") elif tag == "delete": parts.append("~~"+" ".join(a_words[i1:i2])+"~~") elif tag == "insert": parts.append("**["+" ".join(c_words[j1:j2])+"]**") return " ".join(parts) def _level_info(state): lvl = state["level"] extra = f" · streak {state['streak']}/{PASS_REQ} to level up" if lvl < len(LEVELS)-1 else " · MAX LEVEL 🏆" return f"**{LEVEL_LABELS[lvl]}**{extra}" def _score_info(state): t, s = state["total"], state["score"] return f"**Score: {s}/{t}** ({int(s/t*100) if t else 0}%)" def new_sentence(ss): state = S(ss) sent = random.choice(SENTENCES[LEVELS[state["level"]]]) state["sentence"] = sent audio = _make_audio(sent) hint = "🎧 Listen carefully, then type what you heard and press **Submit**." if audio is None: hint = f"⚠️ Audio unavailable. Sentence: **{sent}**" return audio, "", hint, _level_info(state), _score_info(state), D(state) def submit_answer(attempt, ss): state = S(ss) if not state.get("sentence"): return "", "⚠️ Press **New Sentence** first!", _level_info(state), _score_info(state), ss correct = state["sentence"] state["total"] += 1 errors = _find_errors(correct, attempt) clean_a = attempt.strip().rstrip(".").lower() clean_c = correct.strip().rstrip(".").lower() if not errors and clean_a == clean_c: state["score"] += 1 state["streak"] += 1 msg = "✅ **Correct!** Every word spelled perfectly." if state["streak"] >= PASS_REQ and state["level"] < len(LEVELS)-1: state["level"] += 1 state["streak"] = 0 msg += f"\n\n🎉 **Level up! You are now {LEVEL_LABELS[state['level']]}.**" else: state["streak"] = 0 diff_str = _build_diff(correct, attempt) gemma_tip = _ask_gemma(correct, attempt, errors) ai_block = f"\n\n---\n\n**💡 Tips:**\n\n{gemma_tip}" if gemma_tip else "" msg = ("❌ **Your answer with corrections:**\n\n" + diff_str + "\n\n*(~~strikethrough~~ = your error · **bold** = correct spelling)*" + ai_block) state["history"].append({"correct": correct, "attempt": attempt, "ok": not bool(errors) and clean_a == clean_c, "level": LEVELS[state["level"]], "ts": time.strftime("%H:%M:%S")}) state["sentence"] = "" return attempt, msg, _level_info(state), _score_info(state), D(state) def replay_audio(ss): state = S(ss) return _make_audio(state["sentence"]) if state.get("sentence") else None def reset_game(_ss): state = S(fresh_state_str()) return None, "", "Game reset. Press **New Sentence** to start!", _level_info(state), _score_info(state), fresh_state_str() def export_history(ss): history = S(ss).get("history", []) if not history: return "No history yet." lines = [] for h in history: ok = "✅" if h["ok"] else "❌" lines.append(f'{ok} [{h["level"]}] {h["ts"]} | {h["correct"]} → {h["attempt"]}') return "\n".join(lines) CSS = "footer { display: none !important; }" _init = S(fresh_state_str()) with gr.Blocks(title="Spelling Practice", css=CSS) as demo: session = gr.State(fresh_state_str()) gr.Markdown("# 📝 Spelling Practice\nListen · Type · See errors · Get tips") with gr.Row(): level_md = gr.Markdown(_level_info(_init)) score_md = gr.Markdown(_score_info(_init)) with gr.Row(): new_btn = gr.Button("🎲 New Sentence", variant="primary") replay_btn = gr.Button("🔁 Replay Audio", variant="secondary") reset_btn = gr.Button("🔄 Reset Game", variant="secondary") audio_out = gr.Audio(label="Listen to the sentence", autoplay=True, interactive=False) hint_md = gr.Markdown("Press **New Sentence** to begin.") answer_box = gr.Textbox(label="Type the sentence you heard", placeholder="Write the full sentence here…", lines=2) submit_btn = gr.Button("✅ Submit", variant="primary") feedback = gr.Markdown("") with gr.Accordion("📊 Session History", open=False): export_btn = gr.Button("📋 Show history") history_box = gr.Textbox(label="Your session", lines=10, interactive=False) OUTS = [audio_out, answer_box, hint_md, level_md, score_md, session] new_btn.click(new_sentence, [session], OUTS) replay_btn.click(replay_audio, [session], [audio_out]) submit_btn.click(submit_answer, [answer_box, session], [answer_box, feedback, level_md, score_md, session]) answer_box.submit(submit_answer,[answer_box, session], [answer_box, feedback, level_md, score_md, session]) reset_btn.click(reset_game, [session], OUTS) export_btn.click(export_history,[session], [history_box]) if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860)