CoolFace
Apppublic

blizzarman/polyglot-tutor

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
main.py405 linesDownload Raw Back to app
1"""Gradio entrypoint.2 3One tab per skill (wired milestone by milestone) plus a Diagnostics tab that4shows the resolved configuration, pings the configured LLM and runs the CEFR5classifier on demand. M1 ships the Reading tab: level-verified generated6texts (or the learner's own text) with judge-gated comprehension questions.7 8UI note: the question block uses `@gr.render`, Gradio's mechanism for dynamic9UI — components are created fresh from state on every change instead of being10patched through update payloads (which proved fragile in the installed Gradio11major version).12"""13 14import gradio as gr15 16from tutor.config import Settings, get_settings17from tutor.ml.cefr.inference import create_cefr_classifier18from tutor.services.asr.base import ASRError19from tutor.services.asr.factory import create_asr_client20from tutor.services.cache import FileCache21from tutor.services.dictation import DictationResult22from tutor.services.llm.base import ChatMessage, LLMError23from tutor.services.llm.factory import create_llm_client24from tutor.services.reading import (25    CEFR_LEVELS,26    ReadingError,27    ReadingExercise,28    build_reading_exercise,29    exercise_from_user_text,30)31 32 33def format_feedback(questions: list[dict], picked: list[str | None]) -> str:34    """Score the learner's answers against the exercise questions (pure, tested)."""35    if any(answer is None for answer in picked):36        return "Answer all the questions first."37    lines, score = [], 038    for index, (question, answer) in enumerate(zip(questions, picked, strict=True)):39        correct = question["options"][question["answer_index"]]40        good = answer == correct41        score += int(good)42        mark = "✅" if good else "❌"43        explanation = question.get("explanation", "")44        lines.append(f"{mark} **Q{index + 1}** — correct answer: **{correct}**. {explanation}")45    header = f"## Score: {score}/{len(questions)}"46    if score == len(questions):47        header += " 🎉"48    return header + "\n\n" + "\n\n".join(lines)49 50 51def _normalize(answer: str) -> str:52    """Casefold + strip accents, so 'Café ' matches 'cafe' for cloze scoring."""53    import unicodedata54 55    stripped = unicodedata.normalize("NFD", answer.strip().casefold())56    return "".join(ch for ch in stripped if unicodedata.category(ch) != "Mn")57 58 59def format_cloze_feedback(blanks: list[dict], typed: list[str | None]) -> str:60    """Score cloze answers; tolerant to case, accents and surrounding spaces."""61    filled = [(answer or "").strip() for answer in typed]62    if any(not answer for answer in filled):63        return "Fill in every blank first."64    lines, score = [], 065    for index, (blank, answer) in enumerate(zip(blanks, filled, strict=True)):66        good = _normalize(answer) == _normalize(blank["answer"])67        score += int(good)68        mark = "✅" if good else "❌"69        hint = f" — {blank['hint']}" if blank.get("hint") else ""70        if good:71            lines.append(f"{mark} **{index + 1}.** **{blank['answer']}**{hint}")72        else:73            lines.append(74                f"{mark} **{index + 1}.** you wrote *{answer}* — "75                f"correct: **{blank['answer']}**{hint}"76            )77    header = f"## Score: {score}/{len(blanks)}"78    if score == len(blanks):79        header += " 🎉"80    return header + "\n\n" + "\n\n".join(lines)81 82 83def render_cloze_text(text: str, blanks: list[dict]) -> str:84    """Replace each blank with a numbered placeholder, right to left to keep offsets valid."""85    rendered = text86    for index in range(len(blanks) - 1, -1, -1):87        blank = blanks[index]88        start, end = blank["start"], blank["start"] + len(blank["answer"])89        rendered = f"{rendered[:start]}**\\[{index + 1}: ____\\]**{rendered[end:]}"90    return rendered91 92 93def render_dictation_feedback(result: DictationResult) -> str:94    """Reference sentence with errors marked, plus WER and an error breakdown."""95    pieces: list[str] = []96    for op in result.ops:97        if op.op == "equal":98            pieces.append(op.ref_word or "")99        elif op.op == "substitute":100            pieces.append(f"~~{op.hyp_word}~~ **{op.ref_word}**")101        elif op.op == "delete":102            pieces.append(f"**[missing: {op.ref_word}]**")103        elif op.op == "insert":104            pieces.append(f"~~{op.hyp_word}~~")105    marked = " ".join(piece for piece in pieces if piece)106 107    accuracy = round((1.0 - result.wer) * 100)108    header = f"## {accuracy}% correct" + (" 🎉" if result.is_perfect else "")109    breakdown = (110        f"WER {result.wer:.0%} · {result.hits} correct, "111        f"{result.substitutions} wrong, {result.deletions} missed, "112        f"{result.insertions} extra"113    )114    if result.is_perfect:115        return f"{header}\n\n{breakdown}"116    legend = "_Marked below: **correct word**, ~~your version~~, **[missing: word]**._"117    return f"{header}\n\n{breakdown}\n\n{legend}\n\n> {marked}"118 119 120def build_app(settings: Settings | None = None) -> gr.Blocks:121    settings = settings or get_settings()122    llm = create_llm_client(settings)123    cache = FileCache(settings.cache_dir)124 125    classifier_cache: dict = {}126 127    def get_classifier():128        """Lazy, memoized; may raise (callers decide how to surface it)."""129        if "classifier" not in classifier_cache:130            classifier_cache["classifier"] = create_cefr_classifier(settings)131        return classifier_cache["classifier"]132 133    asr_cache: dict = {}134 135    def get_asr():136        """Lazy, memoized ASR client (model load is deferred to first use)."""137        if "asr" not in asr_cache:138            asr_cache["asr"] = create_asr_client(settings)139        return asr_cache["asr"]140 141    # ------------------------------------------------------------- Diagnostics142    def estimate_level(text: str) -> str:143        if not text.strip():144            return "Paste a text first."145        try:146            classifier = get_classifier()147            if classifier is None:148                return "CEFR model not configured (set CEFR_MODEL_ID or CEFR_MODEL_PATH)."149            prediction = classifier.classify_text(text)150        except Exception as exc:  # surfaced to the UI, never crashes the app151            return f"❌ {type(exc).__name__}: {exc}"152        top = sorted(prediction.per_level.items(), key=lambda kv: -kv[1])[:2]153        detail = ", ".join(f"{lvl} {p:.0%}" for lvl, p in top)154        caveat = ""155        if len(text.split()) < 30:156            caveat = (157                "\n\n⚠️ Very short input — the model is trained on sentences and "158                "passages; results on single words or fragments are anecdotal."159            )160        return (161            f"**{prediction.level}** — score {prediction.score:.2f}/5 "162            f"({detail}; {prediction.n_chunks} chunk(s))" + caveat163        )164 165    async def ping_llm() -> str:166        try:167            response = await llm.complete(168                [ChatMessage(role="user", content="Reply with exactly one word: pong")],169                temperature=0.0,170                max_tokens=16,171            )172        except LLMError as exc:173            return f"❌ {exc}"174        return f"✅ [{response.model}] {response.text.strip()}"175 176    async def transcribe_audio(audio_path: str | None) -> str:177        if not audio_path:178            return "Record or upload an audio clip first."179        from pathlib import Path180 181        try:182            transcription = await get_asr().transcribe(Path(audio_path), language="en")183        except ASRError as exc:184            return f"❌ {exc}"185        except Exception as exc:186            return f"❌ {type(exc).__name__}: {exc}"187        lang = f" ({transcription.language})" if transcription.language else ""188        return f"**Transcription{lang}:** {transcription.text}" if transcription.text else "(empty)"189 190    # ----------------------------------------------------------------- Reading191    def _classifier_or_none():192        try:193            return get_classifier()194        except Exception:195            return None  # Reading degrades to unverified texts instead of failing196 197    def _exercise_info(exercise: ReadingExercise) -> str:198        if exercise.source == "generated":199            verified = exercise.classified_level or "unverified — classifier not configured"200            info = f"Requested **{exercise.requested_level}** · classifier says **{verified}**"201            if exercise.classifier_score is not None:202                info += f" (score {exercise.classifier_score:.2f}/5)"203            if exercise.attempts > 1:204                info += f" · {exercise.attempts} generation attempts"205            if exercise.classified_level and exercise.classified_level != exercise.requested_level:206                info += (207                    "\n\n⚠️ The rewrite still classifies off-target — served with its honest level."208                )209            return info210        level = exercise.classified_level or "unverified — classifier not configured"211        info = f"Your text · classifier says **{level}**"212        if exercise.classifier_score is not None:213            info += f" (score {exercise.classifier_score:.2f}/5)"214        return info215 216    async def fetch_exercise(level: str, topic: str, activity: str):217        try:218            exercise = await build_reading_exercise(219                llm,220                _classifier_or_none(),221                cache,222                level=level,223                topic=topic,224                activity=activity,225                model_name=settings.llm_model,226            )227        except (ReadingError, LLMError) as exc:228            return f"❌ {exc}", "", None229        except Exception as exc:230            return f"❌ {type(exc).__name__}: {exc}", "", None231        # In cloze mode the intact text would reveal every answer — the gapped232        # version is rendered inside @gr.render instead.233        shown_text = "" if exercise.activity == "cloze" else exercise.text234        return shown_text, _exercise_info(exercise), exercise.model_dump()235 236    async def use_own_text(text: str, activity: str):237        try:238            exercise = await exercise_from_user_text(239                llm,240                _classifier_or_none(),241                cache,242                text=text,243                activity=activity,244                model_name=settings.llm_model,245            )246        except (ReadingError, LLMError) as exc:247            return f"❌ {exc}", "", None248        except Exception as exc:249            return f"❌ {type(exc).__name__}: {exc}", "", None250        shown_text = "" if exercise.activity == "cloze" else exercise.text251        return shown_text, _exercise_info(exercise), exercise.model_dump()252 253    # ---------------------------------------------------------------------- UI254    with gr.Blocks(title="Polyglot Tutor") as app:255        gr.Markdown(256            "# 🌍 Polyglot Tutor\n"257            f"Adaptive language tutor — learning **{settings.default_target_lang}** "258            f"from **{settings.default_source_lang}**. *M1: Reading is live.*"259        )260 261        with gr.Tab("📖 Reading"):262            gr.Markdown(263                "Pick your level and an exercise type, then get a text written **and "264                "verified** at that level by the CEFR classifier — or paste your own "265                "English text."266            )267            with gr.Row():268                level_dd = gr.Dropdown(choices=CEFR_LEVELS, value="B1", label="Your CEFR level")269                topic_tb = gr.Textbox(label="Topic (optional)", placeholder="e.g. the ocean")270            activity_radio = gr.Radio(271                choices=[272                    ("Comprehension questions", "questions"),273                    ("Fill in the blanks", "cloze"),274                ],275                value="questions",276                label="Exercise type",277            )278            fetch_btn = gr.Button("📖 Get a text", variant="primary")279            with gr.Accordion("…or use your own English text", open=False):280                own_tb = gr.Textbox(label="Your text", lines=5)281                own_btn = gr.Button("Use my text")282            gr.Markdown("---")283            text_md = gr.Markdown()284            info_md = gr.Markdown()285            exercise_state = gr.State(None)286 287            @gr.render(inputs=exercise_state)288            def render_activity(exercise: dict | None):289                if not exercise:290                    return291 292                if exercise.get("activity") == "cloze" and exercise.get("cloze"):293                    blanks = exercise["cloze"]["blanks"]294                    gr.Markdown(render_cloze_text(exercise["text"], blanks))295                    inputs = []296                    for index, blank in enumerate(blanks):297                        inputs.append(298                            gr.Textbox(299                                label=f"Blank {index + 1}", placeholder="type the missing word"300                            )301                        )302                        options = ", ".join(303                            sorted([*blank.get("distractors", []), blank["answer"]])304                        )305                        hint_label = f"💡 Hint for blank {index + 1}"306                        if blank.get("hint"):307                            hint_label += f" — {blank['hint']}"308                        with gr.Accordion(hint_label, open=False):309                            gr.Markdown(f"Options: {options}")310                    submit_btn = gr.Button("Check my answers", variant="primary")311                    feedback_md = gr.Markdown()312 313                    def grade_cloze(*typed: str | None) -> str:314                        return format_cloze_feedback(blanks, list(typed))315 316                    submit_btn.click(grade_cloze, inputs=inputs, outputs=feedback_md)317                    return318 319                questions = exercise["questions"]320                radios = [321                    gr.Radio(322                        choices=question["options"],323                        label=f"{index + 1}. {question['question']}",324                    )325                    for index, question in enumerate(questions)326                ]327                submit_btn = gr.Button("Check my answers", variant="primary")328                feedback_md = gr.Markdown()329 330                def grade(*picked: str | None) -> str:331                    return format_feedback(questions, list(picked))332 333                submit_btn.click(grade, inputs=radios, outputs=feedback_md)334 335            outputs = [text_md, info_md, exercise_state]336            fetch_btn.click(337                fetch_exercise,338                inputs=[level_dd, topic_tb, activity_radio],339                outputs=outputs,340                api_name="reading_fetch",341            )342            own_btn.click(343                use_own_text,344                inputs=[own_tb, activity_radio],345                outputs=outputs,346                api_name="reading_own",347            )348 349        with gr.Tab("🎧 Listening"):350            gr.Markdown("TTS audio + dictation with ASR scoring — **M2**.")351        with gr.Tab("✍️ Writing"):352            gr.Markdown("LLM correction with typed errors, per-learner error profile — **M3**.")353        with gr.Tab("🗣️ Speaking"):354            gr.Markdown("Read-aloud exercises with pronunciation scoring — **M5**.")355 356        with gr.Tab("⚙️ Diagnostics"):357            gr.Markdown(358                f"- env: `{settings.app_env}`\n"359                f"- LLM: `{settings.llm_provider}` / `{settings.llm_model}`\n"360                f"- ASR: `{settings.asr_provider}` · TTS: `{settings.tts_provider}` · "361                f"storage: `{settings.storage_backend}`"362            )363            ping_button = gr.Button("Ping LLM", variant="primary")364            ping_output = gr.Textbox(label="LLM response", interactive=False)365            ping_button.click(ping_llm, outputs=ping_output)366 367            gr.Markdown("### CEFR quick check (M1 model)")368            cefr_source = settings.cefr_model_path or settings.cefr_model_id or "not configured"369            gr.Markdown(f"Model source: `{cefr_source}`")370            cefr_input = gr.Textbox(label="English text", lines=4, placeholder="Paste a text...")371            cefr_button = gr.Button("Estimate CEFR level")372            cefr_output = gr.Markdown()373            cefr_button.click(estimate_level, inputs=cefr_input, outputs=cefr_output)374 375            gr.Markdown("### ASR quick check (M2 model)")376            gr.Markdown(377                f"Provider: `{settings.asr_provider}`"378                + (f" / `{settings.asr_model}`" if settings.asr_provider != "fake" else "")379            )380            asr_input = gr.Audio(sources=["upload", "microphone"], type="filepath", label="Audio")381            asr_button = gr.Button("Transcribe")382            asr_output = gr.Markdown()383            asr_button.click(transcribe_audio, inputs=asr_input, outputs=asr_output)384 385    return app386 387 388def main() -> None:389    settings = get_settings()390    auth: tuple[str, str] | None = None391    if settings.gradio_auth_username and settings.gradio_auth_password:392        auth = (393            settings.gradio_auth_username,394            settings.gradio_auth_password.get_secret_value(),395        )396    build_app(settings).launch(397        server_name=settings.host,398        server_port=settings.port,399        auth=auth,400    )401 402 403if __name__ == "__main__":404    main()405