Spaces:
Sleeping
Sleeping
Arthur_Diaz
feat(reading): add cloze (fill-in-the-blank) as a second exercise type (#5)
8a8cb44 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.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) | |
| 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() | |