ramedde commited on
Commit
c8a6c00
Β·
verified Β·
1 Parent(s): 9abe6a8

Upload 4 files

Browse files
Files changed (4) hide show
  1. .gitattributes +4 -0
  2. README.md +58 -0
  3. app.py +348 -0
  4. requirements.txt +6 -0
.gitattributes ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ *.gguf filter=lfs diff=lfs merge=lfs -text
2
+ *.bin filter=lfs diff=lfs merge=lfs -text
3
+ *.pt filter=lfs diff=lfs merge=lfs -text
4
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Spelling Practice
3
+ emoji: πŸ“
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: gradio
7
+ sdk_version: "4.40.0"
8
+ app_file: app.py
9
+ pinned: false
10
+ license: mit
11
+ ---
12
+
13
+ # Spelling Practice πŸ“
14
+
15
+ An interactive English spelling trainer powered by gTTS (audio) and optionally Gemma (AI tips).
16
+
17
+ ## How to use
18
+
19
+ 1. Click **New Sentence** β€” a sentence is read aloud.
20
+ 2. Type exactly what you heard in the text box.
21
+ 3. Click **Submit** (or press Enter).
22
+ 4. Errors are shown with strikethrough (~~wrong~~) and the **correct** word in bold.
23
+ 5. Get 3 correct answers in a row to level up.
24
+
25
+ ## Difficulty levels
26
+
27
+ | Level | Label |
28
+ |-------|-------|
29
+ | 1 | 🟒 Beginner |
30
+ | 2 | πŸ”΅ Intermediate |
31
+ | 3 | 🟠 Advanced |
32
+ | 4 | πŸ”΄ Expert |
33
+
34
+ ## Multi-user support
35
+
36
+ Every browser session gets its own isolated state via `gr.State`.
37
+ Concurrent users don't share or interfere with each other.
38
+
39
+ ## Optional: AI Tips with Gemma
40
+
41
+ To enable in-app spelling tips from a local LLM:
42
+
43
+ 1. Upload a `.gguf` model file to your Space repository (e.g. `gemma-4-E2B-it-Q4_0.gguf`).
44
+ 2. Uncomment `llama-cpp-python` in `requirements.txt`.
45
+ 3. If your Space has a GPU, replace the pip line with the CUDA wheel:
46
+ ```
47
+ llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu121
48
+ ```
49
+ 4. Set the `N_GPU_LAYERS` Space secret to `-1` (full GPU offload) or `0` (CPU only).
50
+
51
+ Without a model file the app runs fine β€” the tip section is simply hidden.
52
+
53
+ ## Local development
54
+
55
+ ```bash
56
+ pip install gradio gTTS
57
+ python app.py
58
+ ```
app.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Spelling Practice – Hugging Face Space
3
+ Multi-user safe: all mutable state lives in gr.State (one copy per browser session).
4
+ """
5
+
6
+ import os
7
+ import glob
8
+ import random
9
+ import tempfile
10
+ import difflib
11
+ import json
12
+ import time
13
+ import threading
14
+ from pathlib import Path
15
+
16
+ import gradio as gr
17
+ from gtts import gTTS
18
+
19
+ # ── Optional: llama-cpp for Gemma tips ────────────────────────────────────────
20
+ try:
21
+ from llama_cpp import Llama
22
+ _LLAMA_AVAILABLE = True
23
+ except ImportError:
24
+ _LLAMA_AVAILABLE = False
25
+
26
+ # ── Model loading (shared across all users, loaded once at startup) ────────────
27
+ _llm = None
28
+ _llm_lock = threading.Lock()
29
+
30
+ def get_llm():
31
+ """Return the shared Llama instance, loading it on first call."""
32
+ global _llm
33
+ if _llm is not None:
34
+ return _llm
35
+ if not _LLAMA_AVAILABLE:
36
+ return None
37
+ with _llm_lock:
38
+ if _llm is not None:
39
+ return _llm
40
+ gguf_files = glob.glob("/app/*.gguf") + glob.glob("*.gguf")
41
+ if not gguf_files:
42
+ print("[spelling-app] No .gguf file found – AI tips disabled.")
43
+ return None
44
+ model_path = gguf_files[0]
45
+ print(f"[spelling-app] Loading model: {model_path}")
46
+ _llm = Llama(
47
+ model_path=model_path,
48
+ n_ctx=512,
49
+ n_threads=2,
50
+ n_gpu_layers=int(os.getenv("N_GPU_LAYERS", "-1")),
51
+ verbose=False,
52
+ )
53
+ print("[spelling-app] Model ready.")
54
+ return _llm
55
+
56
+ # ── Sentence bank ──────────────────────────────────────────────────────────────
57
+ SENTENCES = {
58
+ "easy": [
59
+ "The student read a book.",
60
+ "I like to learn English.",
61
+ "The bank has no money.",
62
+ "She has a new job.",
63
+ "He can read very fast.",
64
+ "The map shows a river.",
65
+ "We write notes in class.",
66
+ "Put the book on the desk.",
67
+ "I see a small farm.",
68
+ "The goal is very clear.",
69
+ ],
70
+ "medium": [
71
+ "The Southern colonies grew tobacco for Europe.",
72
+ "The workers found jobs in the factory.",
73
+ "He forgot to cite the original source.",
74
+ "They experienced a major economic boom.",
75
+ "My teacher helps me write better essays.",
76
+ "The market was very weak in December.",
77
+ "She received a new evening gown quickly.",
78
+ "We need to manage our time properly.",
79
+ "The group discussion went very well.",
80
+ "The online platform closes at midnight.",
81
+ ],
82
+ "hard": [
83
+ "The government announced policies to address the downturn.",
84
+ "She has an extraordinary talent for analyzing economic literature.",
85
+ "The archaeologist made a fascinating discovery of ancient artifacts.",
86
+ "It is necessary to manage your time effectively.",
87
+ "The corporation was highly profitable after the merger.",
88
+ "He struggled to translate the complex economic metaphors.",
89
+ "The instructor approved the research outline immediately.",
90
+ "She demonstrated remarkable perseverance throughout her academic journey.",
91
+ "Maintaining a regular study schedule is very important.",
92
+ "The researcher examined the primary historical document.",
93
+ ],
94
+ "expert": [
95
+ "The multinational corporation acknowledged the financial discrepancy.",
96
+ "His idiosyncratic research methodologies bewildered his academic colleagues.",
97
+ "The distinguished economist delivered a highly influential speech.",
98
+ "Technological advancements fundamentally transformed nineteenth-century transportation.",
99
+ "Maslow conceptualized self-actualization as the highest human need.",
100
+ "Agricultural consolidation overwhelmed small family farming operations.",
101
+ "Translating metaphorical expressions requires extraordinary cultural competence.",
102
+ "Plagiarism often leads to severe academic consequences.",
103
+ "The entrepreneur demonstrated extraordinary economic decision-making.",
104
+ "The researcher meticulously catalogued primary historical sources.",
105
+ ],
106
+ }
107
+
108
+ LEVELS = ["easy", "medium", "hard", "expert"]
109
+ LEVEL_LABELS = ["🟒 Beginner", "πŸ”΅ Intermediate", "🟠 Advanced", "πŸ”΄ Expert"]
110
+ PASS_REQ = 3
111
+
112
+
113
+ # ── Per-user state ─────────────────────────────────────────────────────────────
114
+
115
+ def fresh_state():
116
+ return {
117
+ "level": 0,
118
+ "streak": 0,
119
+ "sentence": "",
120
+ "score": 0,
121
+ "total": 0,
122
+ "history": [],
123
+ }
124
+
125
+
126
+ # ── Core helpers ───────────────────────────────────────────────────────────────
127
+
128
+ def _make_audio(text):
129
+ try:
130
+ tts = gTTS(text=text, lang="en", slow=True)
131
+ path = tempfile.mktemp(suffix=".mp3")
132
+ tts.save(path)
133
+ return path
134
+ except Exception as exc:
135
+ print(f"[spelling-app] gTTS error: {exc}")
136
+ return None
137
+
138
+
139
+ def _find_errors(correct, attempt):
140
+ c_words = correct.rstrip(".").lower().split()
141
+ a_words = attempt.rstrip(".").lower().split()
142
+ errors = []
143
+ matcher = difflib.SequenceMatcher(None, a_words, c_words)
144
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
145
+ if tag == "replace":
146
+ for w, r in zip(a_words[i1:i2], c_words[j1:j2]):
147
+ if w != r:
148
+ errors.append((w, r))
149
+ elif tag == "delete":
150
+ for w in a_words[i1:i2]:
151
+ errors.append((w, "[extra word]"))
152
+ elif tag == "insert":
153
+ for r in c_words[j1:j2]:
154
+ errors.append(("[missing]", r))
155
+ return errors
156
+
157
+
158
+ def _ask_gemma(correct, attempt, errors):
159
+ llm = get_llm()
160
+ if not llm or not errors:
161
+ return ""
162
+ error_list = "\n".join(
163
+ f'- You wrote "{w}", correct is "{r}"' for w, r in errors
164
+ )
165
+ prompt = (
166
+ "You are a friendly English spelling teacher.\n"
167
+ f"The student had to write: {correct}\n"
168
+ f"The student wrote: {attempt}\n"
169
+ f"Spelling mistakes:\n{error_list}\n\n"
170
+ "For each mistake, write one short sentence explaining the error "
171
+ "and one memory tip. Be encouraging. Keep it brief."
172
+ )
173
+ out = llm(prompt, max_tokens=200, temperature=0.3, stop=["###"])
174
+ return out["choices"][0]["text"].strip()
175
+
176
+
177
+ def _build_diff(correct, attempt):
178
+ c_words = correct.split()
179
+ a_words = attempt.split()
180
+ parts = []
181
+ sm = difflib.SequenceMatcher(
182
+ None,
183
+ [w.rstrip(".,").lower() for w in a_words],
184
+ [w.rstrip(".,").lower() for w in c_words],
185
+ )
186
+ for tag, i1, i2, j1, j2 in sm.get_opcodes():
187
+ if tag == "equal":
188
+ parts.append(" ".join(a_words[i1:i2]))
189
+ elif tag == "replace":
190
+ parts.append(
191
+ "~~" + " ".join(a_words[i1:i2])
192
+ + "~~ **" + " ".join(c_words[j1:j2]) + "**"
193
+ )
194
+ elif tag == "delete":
195
+ parts.append("~~" + " ".join(a_words[i1:i2]) + "~~")
196
+ elif tag == "insert":
197
+ parts.append("**[" + " ".join(c_words[j1:j2]) + "]**")
198
+ return " ".join(parts)
199
+
200
+
201
+ def _level_info(state):
202
+ lvl = state["level"]
203
+ label = LEVEL_LABELS[lvl]
204
+ extra = (
205
+ f" Β· streak {state['streak']}/{PASS_REQ} to level up"
206
+ if lvl < len(LEVELS) - 1
207
+ else " Β· MAX LEVEL πŸ†"
208
+ )
209
+ return f"**{label}**{extra}"
210
+
211
+
212
+ def _score_info(state):
213
+ t = state["total"]
214
+ s = state["score"]
215
+ pct = int(s / t * 100) if t else 0
216
+ return f"**Score: {s}/{t}** ({pct}%)"
217
+
218
+
219
+ # ── Event handlers ─────────────────────────────────────────────────────────────
220
+
221
+ def new_sentence(state):
222
+ sent = random.choice(SENTENCES[LEVELS[state["level"]]])
223
+ state = {**state, "sentence": sent}
224
+ audio = _make_audio(sent)
225
+ hint = "🎧 Listen carefully, then type what you heard and press **Submit**."
226
+ if audio is None:
227
+ hint = f"⚠️ Audio unavailable. Sentence: **{sent}** *(type it below)*"
228
+ return audio, "", hint, _level_info(state), _score_info(state), state
229
+
230
+
231
+ def submit_answer(attempt, state):
232
+ if not state.get("sentence"):
233
+ return "", "⚠️ Press **New Sentence** first!", _level_info(state), _score_info(state), state
234
+
235
+ correct = state["sentence"]
236
+ state = {**state, "total": state["total"] + 1}
237
+ errors = _find_errors(correct, attempt)
238
+ clean_a = attempt.strip().rstrip(".").lower()
239
+ clean_c = correct.strip().rstrip(".").lower()
240
+
241
+ if not errors and clean_a == clean_c:
242
+ state = {**state, "score": state["score"] + 1, "streak": state["streak"] + 1}
243
+ msg = "βœ… **Correct!** Every word spelled perfectly."
244
+ if state["streak"] >= PASS_REQ and state["level"] < len(LEVELS) - 1:
245
+ state = {**state, "level": state["level"] + 1, "streak": 0}
246
+ new_label = LEVEL_LABELS[state["level"]]
247
+ msg += f"\n\nπŸŽ‰ **Level up! You are now {new_label}.**"
248
+ else:
249
+ state = {**state, "streak": 0}
250
+ diff_str = _build_diff(correct, attempt)
251
+ gemma_tip = _ask_gemma(correct, attempt, errors)
252
+ ai_block = (
253
+ f"\n\n---\n\n**πŸ’‘ Tips:**\n\n{gemma_tip}"
254
+ if gemma_tip
255
+ else ""
256
+ )
257
+ msg = (
258
+ "❌ **Your answer with corrections:**\n\n"
259
+ + diff_str
260
+ + "\n\n*(~~strikethrough~~ = your error Β· **bold** = correct spelling)*"
261
+ + ai_block
262
+ )
263
+
264
+ entry = {
265
+ "correct": correct,
266
+ "attempt": attempt,
267
+ "ok": not bool(errors) and clean_a == clean_c,
268
+ "level": LEVELS[state["level"]],
269
+ "ts": time.strftime("%H:%M:%S"),
270
+ }
271
+ state = {**state, "sentence": "", "history": state["history"] + [entry]}
272
+ return attempt, msg, _level_info(state), _score_info(state), state
273
+
274
+
275
+ def replay_audio(state):
276
+ if state.get("sentence"):
277
+ return _make_audio(state["sentence"])
278
+ return None
279
+
280
+
281
+ def reset_game(state):
282
+ state = fresh_state()
283
+ return None, "", "Game reset. Press **New Sentence** to start!", _level_info(state), _score_info(state), state
284
+
285
+
286
+ def export_history(state):
287
+ history = state.get("history", [])
288
+ if not history:
289
+ return None
290
+ path = tempfile.mktemp(suffix=".json")
291
+ with open(path, "w") as f:
292
+ json.dump(history, f, indent=2, ensure_ascii=False)
293
+ return path
294
+
295
+
296
+ # ── UI ─────────────────────────────────────────────────────────────────────────
297
+
298
+ CSS = """
299
+ body { font-family: 'Segoe UI', sans-serif; }
300
+ footer { display: none !important; }
301
+ """
302
+
303
+ with gr.Blocks(title="Spelling Practice") as demo:
304
+
305
+ session = gr.State(fresh_state)
306
+
307
+ gr.Markdown(
308
+ "# πŸ“ Spelling Practice\n"
309
+ "Listen to the sentence Β· Type it Β· See errors highlighted Β· Get AI tips"
310
+ )
311
+
312
+ with gr.Row():
313
+ level_md = gr.Markdown(_level_info(fresh_state()))
314
+ score_md = gr.Markdown(_score_info(fresh_state()))
315
+
316
+ with gr.Row():
317
+ new_btn = gr.Button("🎲 New Sentence", variant="primary")
318
+ replay_btn = gr.Button("πŸ” Replay Audio", variant="secondary")
319
+ reset_btn = gr.Button("πŸ”„ Reset Game", variant="secondary")
320
+
321
+ audio_out = gr.Audio(label="Listen to the sentence", autoplay=True, interactive=False)
322
+ hint_md = gr.Markdown("Press **New Sentence** to begin.")
323
+ answer_box = gr.Textbox(
324
+ label="Type the sentence you heard",
325
+ placeholder="Write the full sentence here…",
326
+ lines=2,
327
+ )
328
+ submit_btn = gr.Button("βœ… Submit", variant="primary")
329
+ feedback = gr.Markdown("")
330
+
331
+ with gr.Accordion("πŸ“Š Session History", open=False):
332
+ export_btn = gr.Button("⬇️ Export history (JSON)")
333
+ history_file = gr.File(label="Download", visible=True)
334
+
335
+ OUTS = [audio_out, answer_box, hint_md, level_md, score_md, session]
336
+
337
+ new_btn.click(new_sentence, [session], OUTS)
338
+ replay_btn.click(replay_audio, [session], [audio_out])
339
+ submit_btn.click(submit_answer,[answer_box, session], [answer_box, feedback, level_md, score_md, session])
340
+ answer_box.submit(submit_answer,[answer_box, session], [answer_box, feedback, level_md, score_md, session])
341
+ reset_btn.click(reset_game, [session], OUTS)
342
+ export_btn.click(export_history,[session], [history_file])
343
+
344
+ if __name__ == "__main__":
345
+ demo.launch(
346
+ css=CSS,
347
+ share=False,
348
+ )
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio>=4.40.0
2
+ gTTS>=2.5.0
3
+ # llama-cpp-python is optional – uncomment the line below only if you
4
+ # upload a .gguf model file to the Space repository.
5
+ # If your Space has a GPU, use the CUDA wheel instead; see README.
6
+ # llama-cpp-python>=0.2.77