Spaces:
Sleeping
Sleeping
Arthur_Diaz
feat(dictation): word-level dictation scoring with tolerant normalization (#8)
f6bff07 unverified | """Gradio entrypoint. | |
| One tab per skill (wired milestone by milestone) plus a Diagnostics tab that | |
| shows the resolved configuration, pings the configured LLM and runs the CEFR | |
| classifier on demand. M1 ships the Reading tab: level-verified generated | |
| texts (or the learner's own text) with judge-gated comprehension questions. | |
| UI note: the question block uses `@gr.render`, Gradio's mechanism for dynamic | |
| UI — components are created fresh from state on every change instead of being | |
| patched through update payloads (which proved fragile in the installed Gradio | |
| major version). | |
| """ | |
| import gradio as gr | |
| from tutor.config import Settings, get_settings | |
| from tutor.ml.cefr.inference import create_cefr_classifier | |
| from tutor.services.asr.base import ASRError | |
| from tutor.services.asr.factory import create_asr_client | |
| from tutor.services.cache import FileCache | |
| from tutor.services.dictation import DictationResult | |
| from tutor.services.llm.base import ChatMessage, LLMError | |
| from tutor.services.llm.factory import create_llm_client | |
| from tutor.services.reading import ( | |
| CEFR_LEVELS, | |
| ReadingError, | |
| ReadingExercise, | |
| build_reading_exercise, | |
| exercise_from_user_text, | |
| ) | |
| def format_feedback(questions: list[dict], picked: list[str | None]) -> str: | |
| """Score the learner's answers against the exercise questions (pure, tested).""" | |
| if any(answer is None for answer in picked): | |
| return "Answer all the questions first." | |
| lines, score = [], 0 | |
| for index, (question, answer) in enumerate(zip(questions, picked, strict=True)): | |
| correct = question["options"][question["answer_index"]] | |
| good = answer == correct | |
| score += int(good) | |
| mark = "✅" if good else "❌" | |
| explanation = question.get("explanation", "") | |
| lines.append(f"{mark} **Q{index + 1}** — correct answer: **{correct}**. {explanation}") | |
| header = f"## Score: {score}/{len(questions)}" | |
| if score == len(questions): | |
| header += " 🎉" | |
| return header + "\n\n" + "\n\n".join(lines) | |
| def _normalize(answer: str) -> str: | |
| """Casefold + strip accents, so 'Café ' matches 'cafe' for cloze scoring.""" | |
| import unicodedata | |
| stripped = unicodedata.normalize("NFD", answer.strip().casefold()) | |
| return "".join(ch for ch in stripped if unicodedata.category(ch) != "Mn") | |
| def format_cloze_feedback(blanks: list[dict], typed: list[str | None]) -> str: | |
| """Score cloze answers; tolerant to case, accents and surrounding spaces.""" | |
| filled = [(answer or "").strip() for answer in typed] | |
| if any(not answer for answer in filled): | |
| return "Fill in every blank first." | |
| lines, score = [], 0 | |
| for index, (blank, answer) in enumerate(zip(blanks, filled, strict=True)): | |
| good = _normalize(answer) == _normalize(blank["answer"]) | |
| score += int(good) | |
| mark = "✅" if good else "❌" | |
| hint = f" — {blank['hint']}" if blank.get("hint") else "" | |
| if good: | |
| lines.append(f"{mark} **{index + 1}.** **{blank['answer']}**{hint}") | |
| else: | |
| lines.append( | |
| f"{mark} **{index + 1}.** you wrote *{answer}* — " | |
| f"correct: **{blank['answer']}**{hint}" | |
| ) | |
| header = f"## Score: {score}/{len(blanks)}" | |
| if score == len(blanks): | |
| header += " 🎉" | |
| return header + "\n\n" + "\n\n".join(lines) | |
| def render_cloze_text(text: str, blanks: list[dict]) -> str: | |
| """Replace each blank with a numbered placeholder, right to left to keep offsets valid.""" | |
| rendered = text | |
| for index in range(len(blanks) - 1, -1, -1): | |
| blank = blanks[index] | |
| start, end = blank["start"], blank["start"] + len(blank["answer"]) | |
| rendered = f"{rendered[:start]}**\\[{index + 1}: ____\\]**{rendered[end:]}" | |
| return rendered | |
| def render_dictation_feedback(result: DictationResult) -> str: | |
| """Reference sentence with errors marked, plus WER and an error breakdown.""" | |
| pieces: list[str] = [] | |
| for op in result.ops: | |
| if op.op == "equal": | |
| pieces.append(op.ref_word or "") | |
| elif op.op == "substitute": | |
| pieces.append(f"~~{op.hyp_word}~~ **{op.ref_word}**") | |
| elif op.op == "delete": | |
| pieces.append(f"**[missing: {op.ref_word}]**") | |
| elif op.op == "insert": | |
| pieces.append(f"~~{op.hyp_word}~~") | |
| marked = " ".join(piece for piece in pieces if piece) | |
| accuracy = round((1.0 - result.wer) * 100) | |
| header = f"## {accuracy}% correct" + (" 🎉" if result.is_perfect else "") | |
| breakdown = ( | |
| f"WER {result.wer:.0%} · {result.hits} correct, " | |
| f"{result.substitutions} wrong, {result.deletions} missed, " | |
| f"{result.insertions} extra" | |
| ) | |
| if result.is_perfect: | |
| return f"{header}\n\n{breakdown}" | |
| legend = "_Marked below: **correct word**, ~~your version~~, **[missing: word]**._" | |
| return f"{header}\n\n{breakdown}\n\n{legend}\n\n> {marked}" | |
| def build_app(settings: Settings | None = None) -> gr.Blocks: | |
| settings = settings or get_settings() | |
| llm = create_llm_client(settings) | |
| cache = FileCache(settings.cache_dir) | |
| classifier_cache: dict = {} | |
| def get_classifier(): | |
| """Lazy, memoized; may raise (callers decide how to surface it).""" | |
| if "classifier" not in classifier_cache: | |
| classifier_cache["classifier"] = create_cefr_classifier(settings) | |
| return classifier_cache["classifier"] | |
| asr_cache: dict = {} | |
| def get_asr(): | |
| """Lazy, memoized ASR client (model load is deferred to first use).""" | |
| if "asr" not in asr_cache: | |
| asr_cache["asr"] = create_asr_client(settings) | |
| return asr_cache["asr"] | |
| # ------------------------------------------------------------- Diagnostics | |
| def estimate_level(text: str) -> str: | |
| if not text.strip(): | |
| return "Paste a text first." | |
| try: | |
| classifier = get_classifier() | |
| if classifier is None: | |
| return "CEFR model not configured (set CEFR_MODEL_ID or CEFR_MODEL_PATH)." | |
| prediction = classifier.classify_text(text) | |
| except Exception as exc: # surfaced to the UI, never crashes the app | |
| return f"❌ {type(exc).__name__}: {exc}" | |
| top = sorted(prediction.per_level.items(), key=lambda kv: -kv[1])[:2] | |
| detail = ", ".join(f"{lvl} {p:.0%}" for lvl, p in top) | |
| caveat = "" | |
| if len(text.split()) < 30: | |
| caveat = ( | |
| "\n\n⚠️ Very short input — the model is trained on sentences and " | |
| "passages; results on single words or fragments are anecdotal." | |
| ) | |
| return ( | |
| f"**{prediction.level}** — score {prediction.score:.2f}/5 " | |
| f"({detail}; {prediction.n_chunks} chunk(s))" + caveat | |
| ) | |
| async def ping_llm() -> str: | |
| try: | |
| response = await llm.complete( | |
| [ChatMessage(role="user", content="Reply with exactly one word: pong")], | |
| temperature=0.0, | |
| max_tokens=16, | |
| ) | |
| except LLMError as exc: | |
| return f"❌ {exc}" | |
| return f"✅ [{response.model}] {response.text.strip()}" | |
| async def transcribe_audio(audio_path: str | None) -> str: | |
| if not audio_path: | |
| return "Record or upload an audio clip first." | |
| from pathlib import Path | |
| try: | |
| transcription = await get_asr().transcribe(Path(audio_path), language="en") | |
| except ASRError as exc: | |
| return f"❌ {exc}" | |
| except Exception as exc: | |
| return f"❌ {type(exc).__name__}: {exc}" | |
| lang = f" ({transcription.language})" if transcription.language else "" | |
| return f"**Transcription{lang}:** {transcription.text}" if transcription.text else "(empty)" | |
| # ----------------------------------------------------------------- Reading | |
| def _classifier_or_none(): | |
| try: | |
| return get_classifier() | |
| except Exception: | |
| return None # Reading degrades to unverified texts instead of failing | |
| def _exercise_info(exercise: ReadingExercise) -> str: | |
| if exercise.source == "generated": | |
| verified = exercise.classified_level or "unverified — classifier not configured" | |
| info = f"Requested **{exercise.requested_level}** · classifier says **{verified}**" | |
| if exercise.classifier_score is not None: | |
| info += f" (score {exercise.classifier_score:.2f}/5)" | |
| if exercise.attempts > 1: | |
| info += f" · {exercise.attempts} generation attempts" | |
| if exercise.classified_level and exercise.classified_level != exercise.requested_level: | |
| info += ( | |
| "\n\n⚠️ The rewrite still classifies off-target — served with its honest level." | |
| ) | |
| return info | |
| level = exercise.classified_level or "unverified — classifier not configured" | |
| info = f"Your text · classifier says **{level}**" | |
| if exercise.classifier_score is not None: | |
| info += f" (score {exercise.classifier_score:.2f}/5)" | |
| return info | |
| async def fetch_exercise(level: str, topic: str, activity: str): | |
| try: | |
| exercise = await build_reading_exercise( | |
| llm, | |
| _classifier_or_none(), | |
| cache, | |
| level=level, | |
| topic=topic, | |
| activity=activity, | |
| model_name=settings.llm_model, | |
| ) | |
| except (ReadingError, LLMError) as exc: | |
| return f"❌ {exc}", "", None | |
| except Exception as exc: | |
| return f"❌ {type(exc).__name__}: {exc}", "", None | |
| # In cloze mode the intact text would reveal every answer — the gapped | |
| # version is rendered inside @gr.render instead. | |
| shown_text = "" if exercise.activity == "cloze" else exercise.text | |
| return shown_text, _exercise_info(exercise), exercise.model_dump() | |
| async def use_own_text(text: str, activity: str): | |
| try: | |
| exercise = await exercise_from_user_text( | |
| llm, | |
| _classifier_or_none(), | |
| cache, | |
| text=text, | |
| activity=activity, | |
| model_name=settings.llm_model, | |
| ) | |
| except (ReadingError, LLMError) as exc: | |
| return f"❌ {exc}", "", None | |
| except Exception as exc: | |
| return f"❌ {type(exc).__name__}: {exc}", "", None | |
| shown_text = "" if exercise.activity == "cloze" else exercise.text | |
| return shown_text, _exercise_info(exercise), exercise.model_dump() | |
| # ---------------------------------------------------------------------- UI | |
| with gr.Blocks(title="Polyglot Tutor") as app: | |
| gr.Markdown( | |
| "# 🌍 Polyglot Tutor\n" | |
| f"Adaptive language tutor — learning **{settings.default_target_lang}** " | |
| f"from **{settings.default_source_lang}**. *M1: Reading is live.*" | |
| ) | |
| with gr.Tab("📖 Reading"): | |
| gr.Markdown( | |
| "Pick your level and an exercise type, then get a text written **and " | |
| "verified** at that level by the CEFR classifier — or paste your own " | |
| "English text." | |
| ) | |
| with gr.Row(): | |
| level_dd = gr.Dropdown(choices=CEFR_LEVELS, value="B1", label="Your CEFR level") | |
| topic_tb = gr.Textbox(label="Topic (optional)", placeholder="e.g. the ocean") | |
| activity_radio = gr.Radio( | |
| choices=[ | |
| ("Comprehension questions", "questions"), | |
| ("Fill in the blanks", "cloze"), | |
| ], | |
| value="questions", | |
| label="Exercise type", | |
| ) | |
| fetch_btn = gr.Button("📖 Get a text", variant="primary") | |
| with gr.Accordion("…or use your own English text", open=False): | |
| own_tb = gr.Textbox(label="Your text", lines=5) | |
| own_btn = gr.Button("Use my text") | |
| gr.Markdown("---") | |
| text_md = gr.Markdown() | |
| info_md = gr.Markdown() | |
| exercise_state = gr.State(None) | |
| def render_activity(exercise: dict | None): | |
| if not exercise: | |
| return | |
| if exercise.get("activity") == "cloze" and exercise.get("cloze"): | |
| blanks = exercise["cloze"]["blanks"] | |
| gr.Markdown(render_cloze_text(exercise["text"], blanks)) | |
| inputs = [] | |
| for index, blank in enumerate(blanks): | |
| inputs.append( | |
| gr.Textbox( | |
| label=f"Blank {index + 1}", placeholder="type the missing word" | |
| ) | |
| ) | |
| options = ", ".join( | |
| sorted([*blank.get("distractors", []), blank["answer"]]) | |
| ) | |
| hint_label = f"💡 Hint for blank {index + 1}" | |
| if blank.get("hint"): | |
| hint_label += f" — {blank['hint']}" | |
| with gr.Accordion(hint_label, open=False): | |
| gr.Markdown(f"Options: {options}") | |
| submit_btn = gr.Button("Check my answers", variant="primary") | |
| feedback_md = gr.Markdown() | |
| def grade_cloze(*typed: str | None) -> str: | |
| return format_cloze_feedback(blanks, list(typed)) | |
| submit_btn.click(grade_cloze, inputs=inputs, outputs=feedback_md) | |
| return | |
| questions = exercise["questions"] | |
| radios = [ | |
| gr.Radio( | |
| choices=question["options"], | |
| label=f"{index + 1}. {question['question']}", | |
| ) | |
| for index, question in enumerate(questions) | |
| ] | |
| submit_btn = gr.Button("Check my answers", variant="primary") | |
| feedback_md = gr.Markdown() | |
| def grade(*picked: str | None) -> str: | |
| return format_feedback(questions, list(picked)) | |
| submit_btn.click(grade, inputs=radios, outputs=feedback_md) | |
| outputs = [text_md, info_md, exercise_state] | |
| fetch_btn.click( | |
| fetch_exercise, | |
| inputs=[level_dd, topic_tb, activity_radio], | |
| outputs=outputs, | |
| api_name="reading_fetch", | |
| ) | |
| own_btn.click( | |
| use_own_text, | |
| inputs=[own_tb, activity_radio], | |
| outputs=outputs, | |
| api_name="reading_own", | |
| ) | |
| with gr.Tab("🎧 Listening"): | |
| gr.Markdown("TTS audio + dictation with ASR scoring — **M2**.") | |
| with gr.Tab("✍️ Writing"): | |
| gr.Markdown("LLM correction with typed errors, per-learner error profile — **M3**.") | |
| with gr.Tab("🗣️ Speaking"): | |
| gr.Markdown("Read-aloud exercises with pronunciation scoring — **M5**.") | |
| with gr.Tab("⚙️ Diagnostics"): | |
| gr.Markdown( | |
| f"- env: `{settings.app_env}`\n" | |
| f"- LLM: `{settings.llm_provider}` / `{settings.llm_model}`\n" | |
| f"- ASR: `{settings.asr_provider}` · TTS: `{settings.tts_provider}` · " | |
| f"storage: `{settings.storage_backend}`" | |
| ) | |
| ping_button = gr.Button("Ping LLM", variant="primary") | |
| ping_output = gr.Textbox(label="LLM response", interactive=False) | |
| ping_button.click(ping_llm, outputs=ping_output) | |
| gr.Markdown("### CEFR quick check (M1 model)") | |
| cefr_source = settings.cefr_model_path or settings.cefr_model_id or "not configured" | |
| gr.Markdown(f"Model source: `{cefr_source}`") | |
| cefr_input = gr.Textbox(label="English text", lines=4, placeholder="Paste a text...") | |
| cefr_button = gr.Button("Estimate CEFR level") | |
| cefr_output = gr.Markdown() | |
| cefr_button.click(estimate_level, inputs=cefr_input, outputs=cefr_output) | |
| gr.Markdown("### ASR quick check (M2 model)") | |
| gr.Markdown( | |
| f"Provider: `{settings.asr_provider}`" | |
| + (f" / `{settings.asr_model}`" if settings.asr_provider != "fake" else "") | |
| ) | |
| asr_input = gr.Audio(sources=["upload", "microphone"], type="filepath", label="Audio") | |
| asr_button = gr.Button("Transcribe") | |
| asr_output = gr.Markdown() | |
| asr_button.click(transcribe_audio, inputs=asr_input, outputs=asr_output) | |
| return app | |
| def main() -> None: | |
| settings = get_settings() | |
| auth: tuple[str, str] | None = None | |
| if settings.gradio_auth_username and settings.gradio_auth_password: | |
| auth = ( | |
| settings.gradio_auth_username, | |
| settings.gradio_auth_password.get_secret_value(), | |
| ) | |
| build_app(settings).launch( | |
| server_name=settings.host, | |
| server_port=settings.port, | |
| auth=auth, | |
| ) | |
| if __name__ == "__main__": | |
| main() | |