mvp
Browse files- .gitattributes +35 -0
- .gitignore +23 -0
- app.py +413 -0
- debug_llm.py +82 -0
- diff_html.py +67 -0
- ocr/__init__.py +0 -0
- ocr/grading.py +236 -0
- ocr/transcribe.py +96 -0
- openai_client.py +70 -0
- prompts.py +53 -0
- requirements.txt +5 -0
- 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> · {_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]
|