polyglot-tutor / tests /test_reading.py
Arthur_Diaz
feat(reading): add cloze (fill-in-the-blank) as a second exercise type (#5)
8a8cb44 unverified
Raw
History Blame Contribute Delete
14.2 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.cache import FileCache
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 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"]
# ------------------------------------------------------------- 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()}"
# ----------------------------------------------------------------- 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
return exercise.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
return exercise.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 = [
gr.Textbox(label=f"Blank {index + 1}", placeholder="type the missing word")
for index in range(len(blanks))
]
with gr.Accordion("Need a hint? Show multiple-choice options", open=False):
for index, blank in enumerate(blanks):
options = ", ".join(
sorted([*blank.get("distractors", []), blank["answer"]])
)
gr.Markdown(f"**{index + 1}.** {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)
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()