nofater commited on
Commit
2a0825b
·
1 Parent(s): ec86fff
Files changed (12) hide show
  1. .gitattributes +35 -0
  2. .gitignore +23 -0
  3. app.py +413 -0
  4. debug_llm.py +82 -0
  5. diff_html.py +67 -0
  6. ocr/__init__.py +0 -0
  7. ocr/grading.py +236 -0
  8. ocr/transcribe.py +96 -0
  9. openai_client.py +70 -0
  10. prompts.py +53 -0
  11. requirements.txt +5 -0
  12. wizard.py +20 -0
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python bytecode / caches
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .pytest_cache/
6
+
7
+ # Gradio runtime artifacts (local cache, dev cert, flagged data, cached examples)
8
+ .gradio/
9
+ flagged/
10
+ gradio_cached_examples/
11
+
12
+ # Secrets / local env (MODAL_KEY, MODAL_SECRET, MODAL_*_URL live in Space secrets)
13
+ .env
14
+ .env.*
15
+
16
+ # Virtual envs
17
+ .venv/
18
+ venv/
19
+
20
+ # Editor / OS cruft
21
+ .idea/
22
+ .vscode/
23
+ .DS_Store
app.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Dictation Trainer — Gradio Space (specs/gradio.md).
3
+
4
+ A thin, mobile-first wrapper over three Modal endpoints. No models run here:
5
+ every inference is an HTTP call out to Modal.
6
+
7
+ Generate: word list + level --LLM--> German dictation --TTS--> audio
8
+ Check: photo of handwriting --OCR(blind)--> text --grade--> diff + score
9
+
10
+ Run locally from this directory (the Space root):
11
+ MODAL_LLM_URL=... MODAL_TTS_URL=... MODAL_OCR_URL=... uv run python app.py
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import tempfile
17
+ import time
18
+
19
+ import gradio as gr
20
+ from loguru import logger
21
+
22
+ # Flat imports: this file is the Space entrypoint, run with space/ as the root.
23
+ from diff_html import render_report_html
24
+ from ocr.grading import grade
25
+ from ocr.transcribe import transcribe_image
26
+ from openai_client import make_client
27
+ from prompts import (
28
+ DICTATION_SYSTEM_PROMPT,
29
+ build_user_prompt,
30
+ clean_dictation,
31
+ parse_word_list,
32
+ )
33
+ from wizard import nav
34
+
35
+ LLM_MODEL = "LiquidAI/LFM2.5-8B-A1B-GGUF"
36
+ # Higgs ignores the model field today, but the OpenAI SDK requires one; keep it
37
+ # descriptive in case the server starts validating it.
38
+ TTS_MODEL = "bosonai/higgs-audio-v3-tts-4b"
39
+ TTS_VOICE = "alba"
40
+ # Sampling fixed per spec §6 — deterministic, repeatable dictations. top_k and
41
+ # repeat_penalty aren't OpenAI-standard params, so they ride in extra_body.
42
+ LLM_SAMPLING = {
43
+ "temperature": 0.1,
44
+ "top_p": 0.1,
45
+ "top_k": 50,
46
+ "repeat_penalty": 1.05,
47
+ }
48
+ # LFM2.5 is a reasoning model: it emits a chain-of-thought into `reasoning_content`
49
+ # BEFORE the answer goes into `content`. The token budget must cover BOTH, or the
50
+ # reasoning consumes it all and `content` comes back empty (finish_reason=length).
51
+ # The verbose system prompt lengthens the reasoning, so keep this well above the
52
+ # ~900 tokens of CoT we observed. See space/debug_llm.py.
53
+ LLM_MAX_TOKENS = 2048
54
+ LANG = "de"
55
+
56
+ COLD_START_HINT = "First call after idle can take ~30-60s while backend warms up."
57
+
58
+ # Calm, modern theme: indigo/violet accents on slate neutrals, a soft gradient
59
+ # page, white cards with gentle shadows, roomy radius + spacing. Most of the look
60
+ # lives here (theme variables are version-stable); CSS below only does the things
61
+ # themes can't (phone framing, gradient title, status pill).
62
+ THEME = gr.themes.Soft(
63
+ primary_hue=gr.themes.colors.indigo,
64
+ secondary_hue=gr.themes.colors.sky,
65
+ neutral_hue=gr.themes.colors.slate,
66
+ font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
67
+ radius_size=gr.themes.sizes.radius_lg,
68
+ spacing_size=gr.themes.sizes.spacing_lg,
69
+ text_size=gr.themes.sizes.text_md,
70
+ ).set(
71
+ body_background_fill="linear-gradient(160deg, #eef2fb 0%, #e9ecf7 45%, #ece9f7 100%)",
72
+ body_background_fill_dark="linear-gradient(160deg, #0f1422 0%, #141b2d 100%)",
73
+ body_text_color="#1e293b",
74
+ body_text_color_subdued="#64748b",
75
+ block_background_fill="#ffffff",
76
+ block_background_fill_dark="#1a2234",
77
+ block_border_width="0px",
78
+ block_radius="18px",
79
+ block_shadow="0 6px 24px rgba(30, 41, 59, 0.08)",
80
+ block_shadow_dark="0 6px 24px rgba(0, 0, 0, 0.40)",
81
+ block_padding="20px",
82
+ layout_gap="14px",
83
+ input_background_fill="#f8fafc",
84
+ input_background_fill_dark="#0f1626",
85
+ input_radius="12px",
86
+ button_large_radius="12px",
87
+ button_large_padding="11px 18px",
88
+ button_primary_background_fill="linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
89
+ button_primary_background_fill_hover="linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)",
90
+ button_primary_background_fill_dark="linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
91
+ button_primary_text_color="#ffffff",
92
+ button_primary_shadow="0 4px 14px rgba(99, 102, 241, 0.35)",
93
+ button_primary_shadow_hover="0 6px 18px rgba(99, 102, 241, 0.45)",
94
+ )
95
+
96
+ # Portrait-phone framing + the bits the theme can't express: a narrow centered
97
+ # column, a gradient app title, and the centered status (spinner) pill.
98
+ MOBILE_CSS = """
99
+ .gradio-container {
100
+ max-width: 480px !important;
101
+ margin: 0 auto !important;
102
+ padding: 12px 14px 28px !important;
103
+ }
104
+ .gradio-container h1 {
105
+ font-size: 1.6rem;
106
+ font-weight: 700;
107
+ text-align: center;
108
+ margin: 10px 0 2px;
109
+ background: linear-gradient(135deg, #6366f1, #8b5cf6);
110
+ -webkit-background-clip: text;
111
+ background-clip: text;
112
+ -webkit-text-fill-color: transparent;
113
+ }
114
+ /* Step title: transparent (no clipped grey strip), larger, and padded so the
115
+ card's rounded corner never crops the leading "1 ·". */
116
+ .panel-title {
117
+ background: transparent !important;
118
+ box-shadow: none !important;
119
+ border: none !important;
120
+ padding: 6px 18px 2px !important;
121
+ }
122
+ .panel-title h3 {
123
+ font-size: 1.35rem !important;
124
+ font-weight: 700 !important;
125
+ color: #4f46e5 !important;
126
+ margin: 0 !important;
127
+ line-height: 1.3;
128
+ }
129
+ .status {
130
+ text-align: center;
131
+ font-weight: 600;
132
+ opacity: 0.9;
133
+ }
134
+ .status .fa-spinner {
135
+ margin-right: 6px;
136
+ }
137
+ """
138
+
139
+ # FontAwesome (CDN) for the animated status spinner (fa-spinner + fa-spin). Loaded
140
+ # into <head> at launch; the icon itself is rendered as raw HTML in the status
141
+ # fields (see _busy), which is why those Markdowns set sanitize_html=False.
142
+ FA_HEAD = (
143
+ '<link rel="stylesheet" '
144
+ 'href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">'
145
+ )
146
+ SPINNER = '<i class="fa-solid fa-spinner fa-spin"></i>'
147
+
148
+
149
+ def _require_env(name: str) -> str:
150
+ val = os.environ.get(name)
151
+ if not val:
152
+ raise gr.Error(f"{name} is not configured (set it in the Space secrets).")
153
+ return val
154
+
155
+
156
+ def _tts_base_url() -> str:
157
+ """MODAL_TTS_URL may be the server root or the full speech path; reduce it to
158
+ the server root so the client appends /v1/audio/speech itself."""
159
+ url = _require_env("MODAL_TTS_URL").rstrip("/")
160
+ for suffix in ("/v1/audio/speech", "/audio/speech"):
161
+ if url.endswith(suffix):
162
+ return url[: -len(suffix)]
163
+ return url
164
+
165
+
166
+ def call_llm(words: list[str], level: str) -> str:
167
+ """Word list + CEFR level -> one German dictation paragraph (cleaned)."""
168
+ client = make_client(_require_env("MODAL_LLM_URL"))
169
+ completion = client.chat.completions.create(
170
+ model=LLM_MODEL,
171
+ messages=[
172
+ {"role": "system", "content": DICTATION_SYSTEM_PROMPT},
173
+ {"role": "user", "content": build_user_prompt(words, level)},
174
+ ],
175
+ max_tokens=LLM_MAX_TOKENS,
176
+ extra_body=LLM_SAMPLING,
177
+ )
178
+ data = completion.model_dump()
179
+ choice = data.get("choices", [{}])[0]
180
+ content = (choice.get("message") or {}).get("content")
181
+ text = clean_dictation(content)
182
+ if not text:
183
+ # Evidence at the LLM boundary: see exactly what came back when empty.
184
+ logger.error(
185
+ "Empty dictation from LLM. finish_reason={} raw_content={!r}\nfull response: {}",
186
+ choice.get("finish_reason"),
187
+ content,
188
+ json.dumps(data, ensure_ascii=False)[:2000],
189
+ )
190
+ return text
191
+
192
+
193
+ def call_tts(text: str) -> str:
194
+ """Synthesize the dictation; return a temp audio file path. Suffix follows
195
+ the response Content-Type so gr.Audio plays it without transcoding."""
196
+ client = make_client(_tts_base_url())
197
+ response = client.audio.speech.create(model=TTS_MODEL, voice=TTS_VOICE, input=text)
198
+ audio = response.read()
199
+ content_type = response.response.headers.get("content-type", "")
200
+ suffix = ".mp3" if "mpeg" in content_type else ".wav"
201
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
202
+ f.write(audio)
203
+ return f.name
204
+
205
+
206
+ # ---- Step handlers ---------------------------------------------------------
207
+ #
208
+ # CTA handlers do their work, then append nav(...) so the wizard advances only on
209
+ # success; raising gr.Error leaves every output (including the views) untouched,
210
+ # so the user stays on the current step to fix the problem.
211
+
212
+
213
+ def start(words_raw: str, level: str, state: dict):
214
+ """Input → Listen: generate the dictation text + audio."""
215
+ words = parse_word_list(words_raw)
216
+ if not words:
217
+ raise gr.Error("Enter at least one word to practice.")
218
+
219
+ text = call_llm(words, level)
220
+ if not text:
221
+ logger.error("LLM returned an empty dictation ({} words, {})", len(words), level)
222
+ raise gr.Error("The model returned an empty dictation. Please try again.")
223
+ logger.info("Generated dictation ({} words, {}):\n{}", len(words), level, text)
224
+ state = {"diktat": text, "created_at": time.time()}
225
+
226
+ try:
227
+ audio_path = call_tts(text)
228
+ except Exception as e: # never lose the text because synthesis failed (spec §4)
229
+ gr.Warning(f"Audio synthesis failed ({e}). Text saved — open 'Show text'.")
230
+ audio_path = None
231
+
232
+ return audio_path, text, state, *nav("listen")
233
+
234
+
235
+ def check(image_path: str, state: dict):
236
+ """Upload → Results: transcribe the photo (blind) and grade it."""
237
+ if not state or not state.get("diktat"):
238
+ raise gr.Error("Generate a dictation first.")
239
+ if not image_path:
240
+ raise gr.Error("Upload a photo of your handwriting first.")
241
+
242
+ ocr_client = make_client(_require_env("MODAL_OCR_URL"))
243
+ transcription = transcribe_image(image_path, ocr_client)
244
+ logger.info("OCR transcription:\n{}", transcription)
245
+ report = grade(state["diktat"], transcription, LANG)
246
+ return transcription, render_report_html(report), *nav("results")
247
+
248
+
249
+ def restart():
250
+ """Results → Input: clear everything and start a fresh dictation."""
251
+ fresh = {"diktat": "", "created_at": 0}
252
+ # order matches the `outputs` list on the Start-over button below.
253
+ return "", None, "", None, "", "", fresh, *nav("input")
254
+
255
+
256
+ # Inline progress: the CTA's real outputs all live on the next (hidden) view, so
257
+ # Gradio's spinner would paint where the user can't see it. Instead each CTA runs
258
+ # as show-status -> work -> hide-status; the hide step uses .then so it fires even
259
+ # when the work raises (no spinner left stuck on screen).
260
+
261
+
262
+ def _busy(message: str):
263
+ return gr.update(value=f"{SPINNER} {message}", visible=True)
264
+
265
+
266
+ def _idle():
267
+ return gr.update(visible=False)
268
+
269
+
270
+ def _begin(message: str):
271
+ """Show the spinner and disable the CTA so it can't be re-fired mid-call."""
272
+ return _busy(message), gr.update(interactive=False)
273
+
274
+
275
+ def _end():
276
+ """Hide the spinner and re-enable the CTA on success (.then path)."""
277
+ return _idle(), gr.update(interactive=True)
278
+
279
+
280
+ def _recover(*_):
281
+ """Same cleanup for the failure path. A raised gr.Error aborts the chained
282
+ .then, so re-enabling has to be wired via .failure (which passes the
283
+ exception as an arg — ignored here). Without this, a validation error like
284
+ 'no photo uploaded' would leave the button stuck disabled."""
285
+ return _idle(), gr.update(interactive=True)
286
+
287
+
288
+ def goto(target: str):
289
+ """Switch to a view and clear transient state (both status spinners hidden,
290
+ both CTAs re-enabled). Navigating away during a wait shouldn't leave a stale
291
+ spinner or a disabled button behind on the view you left. Return order matches
292
+ the NAV_OUTPUTS list wired in build_ui."""
293
+ return (
294
+ *nav(target),
295
+ gr.update(visible=False), # input_status
296
+ gr.update(visible=False), # upload_status
297
+ gr.update(interactive=True), # start_btn
298
+ gr.update(interactive=True), # check_btn
299
+ )
300
+
301
+
302
+ # ---- UI --------------------------------------------------------------------
303
+
304
+
305
+ def build_ui() -> gr.Blocks:
306
+ with gr.Blocks(title="Dictation Trainer") as demo:
307
+ # localStorage-backed: survives a tab reload while the learner writes (spec §3).
308
+ state = gr.BrowserState({"diktat": "", "created_at": 0})
309
+
310
+ gr.Markdown("# ✍️ German Dictation Trainer")
311
+
312
+ # Four stacked views; nav() keeps exactly one visible. Order must match
313
+ # wizard.VIEWS.
314
+ with gr.Group(visible=True) as view_input:
315
+ gr.Markdown("### 1 · Words to practice", elem_classes="panel-title")
316
+ words_in = gr.Textbox(
317
+ label="Words to practice",
318
+ placeholder="Comma- or newline-separated, e.g. angeblich, ablehnen, Apfel",
319
+ lines=4,
320
+ )
321
+ level_in = gr.Dropdown(["A1", "A2", "B1", "B2"], value="A2", label="Level")
322
+ start_btn = gr.Button("Start", variant="primary")
323
+ input_status = gr.Markdown(visible=False, sanitize_html=False, elem_classes="status")
324
+
325
+ with gr.Group(visible=False) as view_listen:
326
+ gr.Markdown("### 2 · Listen", elem_classes="panel-title")
327
+ audio_out = gr.Audio(
328
+ type="filepath", interactive=False, label="Dictation audio"
329
+ )
330
+ gr.Markdown("🎧 Listen and write it down on paper, then click **Finished**.")
331
+ with gr.Accordion("Show text (debug)", open=False):
332
+ text_out = gr.Textbox(label="Dictation text", interactive=False, lines=4)
333
+ with gr.Row():
334
+ listen_back_btn = gr.Button("Back")
335
+ finished_btn = gr.Button("Finished", variant="primary")
336
+
337
+ with gr.Group(visible=False) as view_upload:
338
+ gr.Markdown("### 3 · Upload your handwriting", elem_classes="panel-title")
339
+ image_in = gr.Image(
340
+ type="filepath",
341
+ sources=["upload", "webcam"],
342
+ label="Photo of your handwriting",
343
+ )
344
+ with gr.Row():
345
+ upload_back_btn = gr.Button("Back")
346
+ check_btn = gr.Button("Check", variant="primary")
347
+ upload_status = gr.Markdown(visible=False, sanitize_html=False, elem_classes="status")
348
+
349
+ with gr.Group(visible=False) as view_results:
350
+ gr.Markdown("### 4 · Results", elem_classes="panel-title")
351
+ recognized_out = gr.Textbox(
352
+ label="Recognized text (OCR)", interactive=False, lines=4
353
+ )
354
+ diff_out = gr.HTML(label="Feedback")
355
+ with gr.Row():
356
+ results_back_btn = gr.Button("Back")
357
+ restart_btn = gr.Button("Start over", variant="primary")
358
+
359
+ views = [view_input, view_listen, view_upload, view_results]
360
+
361
+ # CTA handlers: disable the button + show status, do the work (advance, or
362
+ # raise gr.Error and stay). On success .then clears + re-enables; on error
363
+ # .failure does the same (the raise aborts .then), so the button is never
364
+ # left stuck disabled.
365
+ start_work = start_btn.click(
366
+ lambda: _begin(f"Generating dictation… {COLD_START_HINT}"),
367
+ outputs=[input_status, start_btn],
368
+ show_progress="hidden",
369
+ ).then(
370
+ start,
371
+ inputs=[words_in, level_in, state],
372
+ outputs=[audio_out, text_out, state, *views],
373
+ )
374
+ start_work.then(_end, outputs=[input_status, start_btn], show_progress="hidden")
375
+ start_work.failure(_recover, outputs=[input_status, start_btn], show_progress="hidden")
376
+
377
+ check_work = check_btn.click(
378
+ lambda: _begin(f"Reading your handwriting… {COLD_START_HINT}"),
379
+ outputs=[upload_status, check_btn],
380
+ show_progress="hidden",
381
+ ).then(
382
+ check,
383
+ inputs=[image_in, state],
384
+ outputs=[recognized_out, diff_out, *views],
385
+ )
386
+ check_work.then(_end, outputs=[upload_status, check_btn], show_progress="hidden")
387
+ check_work.failure(_recover, outputs=[upload_status, check_btn], show_progress="hidden")
388
+ restart_btn.click(
389
+ restart,
390
+ outputs=[words_in, audio_out, text_out, image_in, recognized_out, diff_out, state, *views],
391
+ )
392
+
393
+ # Navigation (Finished + Back buttons): switch view AND clear any leftover
394
+ # spinner / disabled CTA from an action on the view being left. `cancels`
395
+ # aborts an in-flight Start/Check so a call the user navigated away from
396
+ # can't complete and yank them forward (its outputs are discarded).
397
+ nav_outputs = [*views, input_status, upload_status, start_btn, check_btn]
398
+ in_flight = [start_work, check_work]
399
+ finished_btn.click(lambda: goto("upload"), outputs=nav_outputs, cancels=in_flight)
400
+ listen_back_btn.click(lambda: goto("input"), outputs=nav_outputs, cancels=in_flight)
401
+ upload_back_btn.click(lambda: goto("listen"), outputs=nav_outputs, cancels=in_flight)
402
+ results_back_btn.click(lambda: goto("upload"), outputs=nav_outputs, cancels=in_flight)
403
+
404
+ return demo
405
+
406
+
407
+ if __name__ == "__main__":
408
+ # On HF Spaces (SPACE_ID set) the runtime serves the app — don't request a
409
+ # share tunnel there; locally, share=True gives a link usable from a phone.
410
+ # theme, css and head all live on launch() in Gradio 6 (moved off Blocks).
411
+ build_ui().launch(
412
+ theme=THEME, css=MOBILE_CSS, head=FA_HEAD, share="SPACE_ID" not in os.environ
413
+ )
debug_llm.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Controlled experiment to find why the Space's dictation comes back empty.
3
+
4
+ Sends several payload variants to MODAL_LLM_URL, changing ONE variable at a
5
+ time vs. the known-good pipeline_tts.py recipe, and prints the raw response so
6
+ we can see which variable empties the output and where it disappears.
7
+
8
+ MODAL_LLM_URL=https://<ws>--lfm25-8b-a1b-serve.modal.run uv run python debug_llm.py
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import sys
14
+
15
+ import requests
16
+
17
+ URL = os.environ.get("MODAL_LLM_URL")
18
+ if not URL:
19
+ sys.exit("Set MODAL_LLM_URL first.")
20
+ ENDPOINT = f"{URL.rstrip('/')}/v1/chat/completions"
21
+ MODEL = "LiquidAI/LFM2.5-8B-A1B-GGUF"
22
+
23
+ from prompts import DICTATION_SYSTEM_PROMPT, build_user_prompt # noqa: E402
24
+
25
+ WORDS = ["angeblich", "ablehnen", "Apfel", "Birne", "mitkriegen"]
26
+ LEVEL = "B1"
27
+
28
+ PIPELINE_SAMPLING = {"temperature": 0.2, "top_k": 80, "repeat_penalty": 1.05}
29
+ APP_SAMPLING = {"temperature": 0.1, "top_p": 0.1, "top_k": 50, "repeat_penalty": 1.05}
30
+ USER_MSG = {"role": "user", "content": build_user_prompt(WORDS, LEVEL)}
31
+ SYSTEM_MSG = {"role": "system", "content": DICTATION_SYSTEM_PROMPT}
32
+
33
+ # Each variant flips one thing relative to "A" (the known-good pipeline recipe).
34
+ VARIANTS = {
35
+ "A pipeline recipe (user-only, pipeline sampling, 1024)": {
36
+ "messages": [USER_MSG], "max_tokens": 1024, **PIPELINE_SAMPLING},
37
+ "B +system role (pipeline sampling)": {
38
+ "messages": [SYSTEM_MSG, USER_MSG], "max_tokens": 1024, **PIPELINE_SAMPLING},
39
+ "C app sampling, user-only": {
40
+ "messages": [USER_MSG], "max_tokens": 512, **APP_SAMPLING},
41
+ "D full app config (system + app sampling, 512)": {
42
+ "messages": [SYSTEM_MSG, USER_MSG], "max_tokens": 512, **APP_SAMPLING},
43
+ # E: the fix — same as D but with the larger budget now in app.py.
44
+ "E FIX: app config + max_tokens 2048": {
45
+ "messages": [SYSTEM_MSG, USER_MSG], "max_tokens": 2048, **APP_SAMPLING},
46
+ # F: better optimization — ask the server to skip thinking entirely. If the
47
+ # LFM2.5 chat template honors this, reasoning_content vanishes and a small
48
+ # budget suffices again (faster, cheaper). If ignored, behaves like D.
49
+ "F try disable thinking (chat_template_kwargs, 512)": {
50
+ "messages": [SYSTEM_MSG, USER_MSG], "max_tokens": 512,
51
+ "chat_template_kwargs": {"enable_thinking": False}, **APP_SAMPLING},
52
+ }
53
+
54
+
55
+ def run(name: str, extra: dict) -> None:
56
+ payload = {"model": MODEL, **extra}
57
+ print(f"\n===== {name} =====")
58
+ try:
59
+ r = requests.post(ENDPOINT, json=payload, timeout=900)
60
+ r.raise_for_status()
61
+ data = r.json()
62
+ except Exception as e:
63
+ print(" REQUEST FAILED:", e)
64
+ return
65
+ choice = (data.get("choices") or [{}])[0]
66
+ msg = choice.get("message") or {}
67
+ content = msg.get("content")
68
+ print(" finish_reason:", choice.get("finish_reason"))
69
+ print(" content len :", len(content or ""))
70
+ print(" content repr :", repr(content)[:300])
71
+ # Surface any non-standard fields (reasoning channel, etc.).
72
+ extra_keys = {k: v for k, v in msg.items() if k not in ("role", "content")}
73
+ if extra_keys:
74
+ print(" other message keys:", json.dumps(extra_keys, ensure_ascii=False)[:300])
75
+ if not (content or "").strip():
76
+ print(" >> EMPTY. full response:", json.dumps(data, ensure_ascii=False)[:1500])
77
+
78
+
79
+ if __name__ == "__main__":
80
+ print("endpoint:", ENDPOINT)
81
+ for name, extra in VARIANTS.items():
82
+ run(name, extra)
diff_html.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render a GradeReport as color-coded HTML for the Gradio Space.
2
+
3
+ This is the presentation layer over the deterministic grader in
4
+ space.ocr.grading (spec §7): the diff and score are computed there; here we
5
+ only turn the report into spans the learner can read. Pure, no network.
6
+ """
7
+
8
+ import html
9
+
10
+ try: # package context (space.diff_html, e.g. tests at repo root)
11
+ from .ocr.grading import GradeReport
12
+ except ImportError: # flat context (HF Space root, app.py)
13
+ from ocr.grading import GradeReport
14
+
15
+ # Inline styles (Gradio sanitizes <style> blocks; inline survives).
16
+ _CORRECT = "color:#16794a;"
17
+ _WRONG = "color:#c0392b;font-weight:600;"
18
+ _MISSING = "color:#b9770e;font-style:italic;"
19
+ _EXTRA = "color:#8e44ad;font-style:italic;text-decoration:line-through;"
20
+
21
+
22
+ def _verdict(accuracy: float) -> str:
23
+ if accuracy >= 0.95:
24
+ return "Excellent"
25
+ if accuracy >= 0.8:
26
+ return "Good"
27
+ if accuracy >= 0.6:
28
+ return "Keep practicing"
29
+ return "Needs work"
30
+
31
+
32
+ def render_report_html(report: GradeReport) -> str:
33
+ """Color-coded word-level diff plus a score header (spec §7)."""
34
+ s = report.summary
35
+ score = round(s.accuracy * 100)
36
+
37
+ spans: list[str] = []
38
+ for w in report.words:
39
+ if w.status == "correct":
40
+ spans.append(f'<span style="{_CORRECT}">{html.escape(w.read or "")}</span>')
41
+ elif w.status == "misspelled":
42
+ wrote = html.escape(w.read or "")
43
+ correct = html.escape(w.expected or "")
44
+ spans.append(
45
+ f'<span style="{_WRONG}" title="{html.escape(w.diff or "")}">'
46
+ f"{wrote} → {correct}</span>"
47
+ )
48
+ elif w.status == "missing":
49
+ spans.append(
50
+ f'<span style="{_MISSING}">[missing: {html.escape(w.expected or "")}]</span>'
51
+ )
52
+ else: # extra
53
+ spans.append(
54
+ f'<span style="{_EXTRA}">[extra: {html.escape(w.read or "")}]</span>'
55
+ )
56
+
57
+ body = " ".join(spans) if spans else "<em>No text recognized.</em>"
58
+
59
+ return (
60
+ f'<div style="font-size:1.05rem;line-height:1.7;">'
61
+ f'<p style="font-size:1.2rem;margin:0 0 .4rem;">'
62
+ f"<strong>{score}%</strong> &middot; {_verdict(s.accuracy)} "
63
+ f'<span style="color:#666;">({s.correct}/{s.total} correct, '
64
+ f"misspelled {s.misspelled}, missing {s.missing}, extra {s.extra})</span></p>"
65
+ f"<p>{body}</p>"
66
+ f"</div>"
67
+ )
ocr/__init__.py ADDED
File without changes
ocr/grading.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic dictation grading: align a blind transcription against a
2
+ known reference and emit a strict JSON report.
3
+
4
+ See specs/ocr.md. The VLM transcription step lives elsewhere; this module
5
+ never sees the image and is fully testable with a fixed transcription string.
6
+ """
7
+
8
+ import re
9
+ import unicodedata
10
+ from difflib import SequenceMatcher
11
+ from typing import Literal, NamedTuple
12
+
13
+ from pydantic import BaseModel
14
+
15
+ Status = Literal["correct", "misspelled", "missing", "extra"]
16
+
17
+ # A word is a maximal run of word characters (Unicode letters/digits/_);
18
+ # any other non-space character is a standalone punctuation token.
19
+ _TOKEN_RE = re.compile(r"\w+|[^\w\s]", re.UNICODE)
20
+
21
+
22
+ class Token(NamedTuple):
23
+ text: str
24
+ is_word: bool
25
+
26
+
27
+ class GradeOptions(NamedTuple):
28
+ """Pedagogy knobs for grading (see specs/ocr.md §8). Defaults match the
29
+ spec: case matters, punctuation is reported but not counted, diacritics
30
+ are graded."""
31
+
32
+ case_sensitive: bool = True
33
+ grade_punctuation: bool = False
34
+ grade_diacritics: bool = True
35
+
36
+
37
+ def comparison_key(word: str, options: GradeOptions) -> str:
38
+ """Map a surface word to the key used for alignment/equality, applying the
39
+ active grading options. The surface form is preserved for display; only
40
+ this key decides whether two words count as 'the same'.
41
+
42
+ Never maps ß→ss or ö→oe — diacritic *grading* only strips combining marks
43
+ (accents), and ß is not a combining mark, so it always stays distinct."""
44
+ key = unicodedata.normalize("NFC", word)
45
+ if not options.case_sensitive:
46
+ key = key.casefold()
47
+ if not options.grade_diacritics:
48
+ decomposed = unicodedata.normalize("NFD", key)
49
+ key = "".join(ch for ch in decomposed if not unicodedata.combining(ch))
50
+ key = unicodedata.normalize("NFC", key)
51
+ return key
52
+
53
+
54
+ def tokenize(text: str) -> list[Token]:
55
+ """NFC-normalize then split into word and punctuation tokens."""
56
+ text = unicodedata.normalize("NFC", text)
57
+ tokens: list[Token] = []
58
+ for match in _TOKEN_RE.finditer(text):
59
+ piece = match.group()
60
+ tokens.append(Token(piece, is_word=bool(re.match(r"\w", piece))))
61
+ return tokens
62
+
63
+
64
+ def graphemes(text: str) -> list[str]:
65
+ """Split a string into grapheme clusters (a base char plus any trailing
66
+ combining marks), so combining diacritics don't desync a character diff."""
67
+ clusters: list[str] = []
68
+ for ch in text:
69
+ if clusters and unicodedata.combining(ch):
70
+ clusters[-1] += ch
71
+ else:
72
+ clusters.append(ch)
73
+ return clusters
74
+
75
+
76
+ def char_diff(expected: str, read: str) -> str:
77
+ """Human-readable grapheme-level diff describing how ``read`` deviates from
78
+ ``expected`` (e.g. ``ß→ss``, ``-t``, ``+e``). Empty string if identical."""
79
+ a, b = graphemes(expected), graphemes(read)
80
+ parts: list[str] = []
81
+ for op, i1, i2, j1, j2 in SequenceMatcher(a=a, b=b).get_opcodes():
82
+ if op == "equal":
83
+ continue
84
+ exp_chunk, read_chunk = "".join(a[i1:i2]), "".join(b[j1:j2])
85
+ if op == "replace":
86
+ parts.append(f"{exp_chunk}→{read_chunk}")
87
+ elif op == "delete":
88
+ parts.append(f"-{exp_chunk}")
89
+ elif op == "insert":
90
+ parts.append(f"+{read_chunk}")
91
+ return ", ".join(parts)
92
+
93
+
94
+ class Word(BaseModel):
95
+ """One graded word in the report (specs/ocr.md §7).
96
+
97
+ ``expected`` is None for ``extra``; ``read`` is None for ``missing``;
98
+ ``diff`` is present only for ``misspelled``."""
99
+
100
+ index: int
101
+ expected: str | None
102
+ read: str | None
103
+ status: Status
104
+ diff: str | None = None
105
+
106
+
107
+ def _word_texts(text: str, options: GradeOptions) -> list[str]:
108
+ """Token surface forms to align: words always, punctuation only when graded."""
109
+ return [
110
+ tok.text
111
+ for tok in tokenize(text)
112
+ if tok.is_word or options.grade_punctuation
113
+ ]
114
+
115
+
116
+ def align_words(
117
+ reference: str, transcription: str, options: GradeOptions | None = None
118
+ ) -> list[Word]:
119
+ """Align the blind transcription against the reference at word level and
120
+ classify each token (specs/ocr.md stages 3-4). Deterministic, no model."""
121
+ options = options or GradeOptions()
122
+ ref = _word_texts(reference, options)
123
+ read = _word_texts(transcription, options)
124
+ ref_keys = [comparison_key(w, options) for w in ref]
125
+ read_keys = [comparison_key(w, options) for w in read]
126
+
127
+ words: list[Word] = []
128
+
129
+ def emit(expected: str | None, got: str | None, status: Status) -> None:
130
+ diff = char_diff(expected, got) if status == "misspelled" else None
131
+ words.append(
132
+ Word(
133
+ index=len(words),
134
+ expected=expected,
135
+ read=got,
136
+ status=status,
137
+ diff=diff or None,
138
+ )
139
+ )
140
+
141
+ for op, i1, i2, j1, j2 in SequenceMatcher(a=ref_keys, b=read_keys).get_opcodes():
142
+ if op == "equal":
143
+ for i, j in zip(range(i1, i2), range(j1, j2)):
144
+ emit(ref[i], read[j], "correct")
145
+ elif op == "replace":
146
+ # Pair up as misspellings; leftovers are missing/extra.
147
+ paired = min(i2 - i1, j2 - j1)
148
+ for k in range(paired):
149
+ emit(ref[i1 + k], read[j1 + k], "misspelled")
150
+ for i in range(i1 + paired, i2):
151
+ emit(ref[i], None, "missing")
152
+ for j in range(j1 + paired, j2):
153
+ emit(None, read[j], "extra")
154
+ elif op == "delete":
155
+ for i in range(i1, i2):
156
+ emit(ref[i], None, "missing")
157
+ elif op == "insert":
158
+ for j in range(j1, j2):
159
+ emit(None, read[j], "extra")
160
+
161
+ return words
162
+
163
+
164
+ class Summary(BaseModel):
165
+ """Tally over the graded words. ``total`` counts reference words only
166
+ (correct + misspelled + missing); extras don't inflate it."""
167
+
168
+ total: int
169
+ correct: int
170
+ misspelled: int
171
+ missing: int
172
+ extra: int
173
+ accuracy: float
174
+
175
+
176
+ class GradeReport(BaseModel):
177
+ """The strict JSON grading report (specs/ocr.md §7)."""
178
+
179
+ lang: str
180
+ reference: str
181
+ transcription: str
182
+ words: list[Word]
183
+ summary: Summary
184
+
185
+
186
+ def _summarize(words: list[Word]) -> Summary:
187
+ counts = {"correct": 0, "misspelled": 0, "missing": 0, "extra": 0}
188
+ for w in words:
189
+ counts[w.status] += 1
190
+ total = counts["correct"] + counts["misspelled"] + counts["missing"]
191
+ accuracy = round(counts["correct"] / total, 4) if total else 0.0
192
+ return Summary(total=total, accuracy=accuracy, **counts)
193
+
194
+
195
+ def grade(
196
+ reference: str,
197
+ transcription: str,
198
+ lang: str,
199
+ options: GradeOptions | None = None,
200
+ ) -> GradeReport:
201
+ """Grade a blind transcription against the reference and return a validated
202
+ report (specs/ocr.md stages 3-5). The image/VLM never enters here."""
203
+ options = options or GradeOptions()
204
+ words = align_words(reference, transcription, options)
205
+ return GradeReport(
206
+ lang=lang,
207
+ reference=unicodedata.normalize("NFC", reference),
208
+ transcription=unicodedata.normalize("NFC", transcription),
209
+ words=words,
210
+ summary=_summarize(words),
211
+ )
212
+
213
+
214
+ _MARKS = {"correct": "✓", "misspelled": "✗", "missing": "·", "extra": "+"}
215
+
216
+
217
+ def format_text_report(report: GradeReport) -> str:
218
+ """Render a GradeReport as a human-readable plain-text report (derivable
219
+ purely from the JSON, specs/ocr.md §2)."""
220
+ s = report.summary
221
+ lines = [
222
+ f"[{report.lang}] {s.correct}/{s.total} correct "
223
+ f"({s.accuracy * 100:.0f}%) "
224
+ f"misspelled={s.misspelled} missing={s.missing} extra={s.extra}",
225
+ ]
226
+ for w in report.words:
227
+ mark = _MARKS[w.status]
228
+ if w.status == "correct":
229
+ lines.append(f" {mark} {w.read}")
230
+ elif w.status == "misspelled":
231
+ lines.append(f" {mark} {w.expected} → {w.read} [{w.diff}]")
232
+ elif w.status == "missing":
233
+ lines.append(f" {mark} {w.expected} (missing)")
234
+ else: # extra
235
+ lines.append(f" {mark} {w.read} (extra)")
236
+ return "\n".join(lines)
ocr/transcribe.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Blind handwriting transcription via an OpenAI-compatible VLM (Qwen3-VL on
2
+ Modal, see modal/ocr/modal_qwen3vl.py).
3
+
4
+ The one rule (specs/ocr.md §3): this module NEVER sees the reference text. It
5
+ asks the model for a faithful, uncorrected transcription of the page; grading
6
+ happens afterwards in ocr.grading. Note there is deliberately no ``reference``
7
+ parameter anywhere here."""
8
+
9
+ import base64
10
+ import json
11
+ import mimetypes
12
+ from pathlib import Path
13
+
14
+ # specs/ocr.md §10, hardened. The "word by word" + explicit spelling-error +
15
+ # umlaut-dot framing beat both the original prompt and a "letter by letter"
16
+ # variant on a real handwriting sample: on Qwen3-VL-8B it fixed gross misreads
17
+ # without adding noise. The one residual failure is the language-model prior
18
+ # auto-correcting missing umlauts on very common words (e.g. a deliberately
19
+ # written "Fruhstück" still came back "Frühstück") — see specs/ocr.md §9.
20
+ SYSTEM_PROMPT = (
21
+ "You are a forensic handwriting transcriber. Transcribe the page WORD BY "
22
+ "WORD, copying each written word exactly as it appears on the paper.\n"
23
+ "\n"
24
+ "This text was written in a SPELLING TEST and DELIBERATELY CONTAINS "
25
+ "SPELLING ERRORS. Reproduce every error faithfully. NEVER replace a word "
26
+ "with its dictionary-correct spelling. If a word is written wrong, "
27
+ "transcribe it exactly as wrongly written.\n"
28
+ "\n"
29
+ "Umlaut rule (critical): write a/o/u with two dots (a->ä, o->ö, u->ü) ONLY "
30
+ "when you can clearly see two dots above that exact letter. If a u, a or o "
31
+ "has NO dots above it, keep it plain (u, a, o) even if correct German would "
32
+ "use an umlaut. Never add dots that are not on the page; never drop dots "
33
+ "that are.\n"
34
+ "Do NOT change ß to ss or ss to ß. Do NOT translate. Do NOT fix spacing, "
35
+ "grammar, or capitalization.\n"
36
+ "Output ONLY the transcribed text, one line per written line."
37
+ )
38
+
39
+ USER_INSTRUCTION = (
40
+ "Transcribe this page word by word, reproducing every spelling error "
41
+ "exactly as written."
42
+ )
43
+
44
+ def guess_mime(path: str) -> str:
45
+ """MIME type for an image path, defaulting to PNG."""
46
+ mime, _ = mimetypes.guess_type(path)
47
+ return mime or "image/png"
48
+
49
+
50
+ def data_uri(image_bytes: bytes, mime: str) -> str:
51
+ """Encode raw image bytes as an OpenAI ``image_url`` data URI."""
52
+ b64 = base64.b64encode(image_bytes).decode("ascii")
53
+ return f"data:{mime};base64,{b64}"
54
+
55
+
56
+ def encode_image(path: str) -> str:
57
+ """Read an image file and return its data URI."""
58
+ return data_uri(Path(path).read_bytes(), guess_mime(path))
59
+
60
+
61
+ def build_payload(image_uri: str, model: str, temperature: float = 0.0) -> dict:
62
+ """Build the chat-completion request for a blind transcription. No
63
+ reference text appears anywhere (specs/ocr.md §3)."""
64
+ return {
65
+ "model": model,
66
+ "temperature": temperature,
67
+ "messages": [
68
+ {"role": "system", "content": SYSTEM_PROMPT},
69
+ {
70
+ "role": "user",
71
+ "content": [
72
+ {"type": "text", "text": USER_INSTRUCTION},
73
+ {"type": "image_url", "image_url": {"url": image_uri}},
74
+ ],
75
+ },
76
+ ],
77
+ }
78
+
79
+
80
+ def parse_response(data: dict) -> str:
81
+ """Extract the transcription text from an OpenAI-style response dict."""
82
+ try:
83
+ return data["choices"][0]["message"]["content"].strip()
84
+ except (KeyError, IndexError, TypeError, AttributeError) as e:
85
+ raise ValueError(f"Unexpected VLM response: {json.dumps(data)[:500]}") from e
86
+
87
+
88
+ def transcribe_image(image_path: str, client, model: str = "qwen3-vl") -> str:
89
+ """Transcribe a handwritten page blind.
90
+
91
+ ``client`` is an OpenAI-compatible client pointed at the Modal VLM server
92
+ (build one with ``openai_client.make_client``). Cold-start retries live in
93
+ the client, not here."""
94
+ payload = build_payload(encode_image(image_path), model)
95
+ completion = client.chat.completions.create(**payload)
96
+ return parse_response(completion.model_dump())
openai_client.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared OpenAI-compatible client factory for the Modal endpoints.
2
+
3
+ All three models (LFM2.5 text, Higgs TTS, Qwen3-VL OCR) speak the OpenAI HTTP
4
+ API, so a single ``OpenAI`` client talks to each — only the base URL differs.
5
+
6
+ The one project-specific concern lives here: **Modal cold starts.** An idle
7
+ container answers the first request with a 502/503/504 while it boots (~30-60 s,
8
+ see app.py COLD_START_HINT). The SDK already retries 5xx/connection errors with
9
+ exponential backoff; we just raise ``max_retries`` and ``timeout`` well past the
10
+ SDK defaults (2 retries) so a cold boot is absorbed rather than surfaced.
11
+
12
+ This module is intentionally self-contained (no intra-package imports) so it can
13
+ be imported both as ``openai_client`` (from the Space root) and
14
+ ``space.openai_client`` (from the repo root) — the same dual-path trick the
15
+ ``ocr`` package relies on.
16
+ """
17
+
18
+ import os
19
+
20
+ from openai import OpenAI
21
+
22
+ TIMEOUT = 900 # seconds; headroom for a Modal cold start
23
+ MAX_RETRIES = 15 # SDK backoff caps each sleep at ~8 s -> covers a ~1-2 min boot
24
+
25
+ # Modal endpoints don't authenticate the OpenAI API key, but the SDK requires a
26
+ # non-empty one. (Proxy auth, below, is a separate Modal-Key/Modal-Secret pair.)
27
+ DUMMY_API_KEY = "EMPTY"
28
+
29
+ # Modal proxy auth: when an endpoint is deployed with requires_proxy_auth=True,
30
+ # every request must carry these two headers, sourced from a proxy auth token.
31
+ MODAL_KEY_ENV = "MODAL_KEY"
32
+ MODAL_SECRET_ENV = "MODAL_SECRET"
33
+
34
+
35
+ def proxy_auth_headers() -> dict[str, str]:
36
+ """Modal proxy-auth headers from the environment, or empty if not configured.
37
+
38
+ Both halves are required, so a lone key/secret yields nothing — the client
39
+ stays unauthenticated and keeps working against unprotected endpoints until
40
+ both ``MODAL_KEY`` and ``MODAL_SECRET`` are set."""
41
+ key = os.environ.get(MODAL_KEY_ENV)
42
+ secret = os.environ.get(MODAL_SECRET_ENV)
43
+ if key and secret:
44
+ return {"Modal-Key": key, "Modal-Secret": secret}
45
+ return {}
46
+
47
+
48
+ def make_client(
49
+ base_url: str,
50
+ *,
51
+ timeout: float = TIMEOUT,
52
+ max_retries: int = MAX_RETRIES,
53
+ ) -> OpenAI:
54
+ """Build an OpenAI client pointed at a Modal server.
55
+
56
+ ``base_url`` may be the server root or already include the ``/v1`` suffix;
57
+ both are accepted (and a trailing slash is fine). Modal proxy-auth headers
58
+ are attached automatically when configured (see ``proxy_auth_headers``).
59
+ """
60
+ base = base_url.rstrip("/")
61
+ if not base.endswith("/v1"):
62
+ base = f"{base}/v1"
63
+ headers = proxy_auth_headers()
64
+ return OpenAI(
65
+ base_url=base,
66
+ api_key=DUMMY_API_KEY,
67
+ timeout=timeout,
68
+ max_retries=max_retries,
69
+ default_headers=headers or None,
70
+ )
prompts.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dictation generation prompt + light text cleanup for the Gradio Space.
2
+
3
+ The LLM turns a learner's word list + CEFR level into ONE short German
4
+ dictation paragraph. This is distinct from the Anki flashcard prompts in
5
+ sys.md / sys1.md, which emit JSON — here we want plain prose to be read aloud.
6
+ """
7
+
8
+ import re
9
+
10
+ DICTATION_SYSTEM_PROMPT = (
11
+ "You are a German teacher writing a short dictation (Diktat) for a language "
12
+ "learner.\n"
13
+ "\n"
14
+ "Write ONE coherent German paragraph of 2-4 natural, everyday sentences that "
15
+ "uses EVERY target word the user provides at least once (inflect it naturally; "
16
+ "the exact base form need not appear). The text must suit the given CEFR level:\n"
17
+ "- A1/A2: short main clauses, common vocabulary, present tense, simple word order.\n"
18
+ "- B1/B2: allow subordinate clauses, connectors and varied tenses, still everyday.\n"
19
+ "\n"
20
+ "Use correct standard German spelling, capitalization, umlauts (ä/ö/ü) and ß.\n"
21
+ "Output ONLY the German dictation text. No title, no label, no quotation marks, "
22
+ "no translation, no commentary, no markdown."
23
+ )
24
+
25
+
26
+ def build_user_prompt(words: list[str], level: str) -> str:
27
+ """The user turn: the target words and the requested level."""
28
+ joined = ", ".join(words)
29
+ return f"Level: {level}\nTarget words: {joined}"
30
+
31
+
32
+ def parse_word_list(raw: str) -> list[str]:
33
+ """Split learner input on commas and/or newlines into trimmed words,
34
+ dropping blanks while preserving order (spec §4)."""
35
+ parts = re.split(r"[,\n]+", raw or "")
36
+ return [w.strip() for w in parts if w.strip()]
37
+
38
+
39
+ _LABEL_RE = re.compile(r"^\s*(diktat|dictation|text)\s*:\s*", re.IGNORECASE)
40
+
41
+
42
+ def clean_dictation(text: str) -> str:
43
+ """Strip a leading label line (e.g. 'Diktat:') and wrapping quotes the LLM
44
+ may add despite instructions (spec §4)."""
45
+ text = (text or "").strip()
46
+ text = _LABEL_RE.sub("", text, count=1).strip()
47
+ # Strip one layer of matching surrounding quotes (straight or typographic).
48
+ pairs = [('"', '"'), ("'", "'"), ("“", "”"), ("„", "“")]
49
+ for open_q, close_q in pairs:
50
+ if len(text) >= 2 and text[0] == open_q and text[-1] == close_q:
51
+ text = text[1:-1].strip()
52
+ break
53
+ return text
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio==6.18.0
2
+ requests
3
+ openai>=2,<3
4
+ pydantic
5
+ loguru
wizard.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Navigation for the 4-step dictation wizard.
2
+
3
+ The UI is four ``gr.Group`` views stacked in one column; exactly one is visible
4
+ at a time. ``nav`` is the single source of truth for "show this step, hide the
5
+ rest" — handlers append its result to their outputs to move between steps. Kept
6
+ free of flat ``app`` imports so it can be imported both as ``wizard`` (Space
7
+ root) and ``space.wizard`` (repo root), and unit-tested without the app."""
8
+
9
+ import gradio as gr
10
+
11
+ # Order matters: it's the order the view Groups are created in build_ui and the
12
+ # order nav() returns visibility updates.
13
+ VIEWS = ("input", "listen", "upload", "results")
14
+
15
+
16
+ def nav(target: str) -> list:
17
+ """Visibility updates for the four views; only ``target`` is shown."""
18
+ if target not in VIEWS:
19
+ raise ValueError(f"unknown view: {target!r} (expected one of {VIEWS})")
20
+ return [gr.update(visible=(view == target)) for view in VIEWS]