polyglot-tutor / src /tutor /app /main.py
Arthur_Diaz
feat(dictation): word-level dictation scoring with tolerant normalization (#8)
f6bff07 unverified
Raw
History Blame Contribute Delete
17.4 kB
"""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)
@gr.render(inputs=exercise_state)
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()