Spaces:
Sleeping
Sleeping
Arthur_Diaz
feat(reading): add cloze (fill-in-the-blank) as a second exercise type (#5)
8a8cb44 unverified | """Reading exercise pipeline (M1). | |
| Generator proposes, classifier disposes: texts are produced by the LLM at a | |
| requested CEFR level, then *verified* by our own classifier — a mismatch is | |
| sent back once with corrective feedback, and an honest label is served if the | |
| rewrite still disagrees. Comprehension questions are requested as strict JSON, | |
| validated with pydantic (one retry on malformed output), then gated by a | |
| minimal LLM judge before being served (the judge fails open: a broken judge | |
| must not block the product). Every LLM product is cached content-addressed | |
| (prompt version + model + inputs), so repeated demos cost zero quota. | |
| """ | |
| import json | |
| import random | |
| from typing import Literal | |
| from pydantic import BaseModel, Field, ValidationError, model_validator | |
| from tutor.services.cache import FileCache | |
| from tutor.services.llm.base import ChatMessage, LLMClient | |
| PROMPT_VERSION = "1" | |
| CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"] | |
| WORD_TARGETS = {"A1": 80, "A2": 120, "B1": 180, "B2": 220, "C1": 260, "C2": 280} | |
| LEVEL_DESCRIPTORS = { | |
| "A1": "very short simple sentences, present tense, the ~500 most frequent words", | |
| "A2": "short sentences, simple past and future, everyday topics and vocabulary", | |
| "B1": "connected paragraphs, some subordinate clauses, common idioms, familiar topics", | |
| "B2": "varied sentence structures, abstract topics, opinion and argument, wider vocabulary", | |
| "C1": "complex structures, low-frequency vocabulary, implicit meaning, nuanced argument", | |
| "C2": "sophisticated prose, rare vocabulary, dense argumentation, subtle register shifts", | |
| } | |
| DEFAULT_TOPICS = [ | |
| "a city changing through the seasons", | |
| "an unexpected friendship", | |
| "how a small invention changed daily life", | |
| "a journey that did not go as planned", | |
| "food and memory", | |
| "the ocean", | |
| "learning a new skill as an adult", | |
| "a place that no longer exists", | |
| ] | |
| class ReadingError(RuntimeError): | |
| """A reading exercise could not be built (surfaced to the UI).""" | |
| class Question(BaseModel): | |
| question: str | |
| options: list[str] = Field(min_length=3, max_length=4) | |
| answer_index: int = Field(ge=0) | |
| explanation: str = "" | |
| def _index_in_range(self) -> "Question": | |
| if self.answer_index >= len(self.options): | |
| msg = f"answer_index {self.answer_index} out of range for {len(self.options)} options" | |
| raise ValueError(msg) | |
| return self | |
| class ClozeBlank(BaseModel): | |
| """One validated gap: the answer is a verbatim token of the text.""" | |
| answer: str | |
| start: int # character offset in the text (computed Python-side, never trusted from the LLM) | |
| distractors: list[str] = Field(default_factory=list) | |
| hint: str = "" | |
| def end(self) -> int: | |
| return self.start + len(self.answer) | |
| def options(self) -> list[str]: | |
| """Answer + distractors, de-duplicated, for the optional multiple-choice hint.""" | |
| seen, out = set(), [] | |
| for candidate in [self.answer, *self.distractors]: | |
| key = candidate.casefold() | |
| if key not in seen: | |
| seen.add(key) | |
| out.append(candidate) | |
| return out | |
| class ClozeExercise(BaseModel): | |
| blanks: list[ClozeBlank] | |
| class ReadingExercise(BaseModel): | |
| text: str | |
| source: Literal["generated", "user"] | |
| activity: Literal["questions", "cloze"] = "questions" | |
| requested_level: str | None = None | |
| classified_level: str | None = None | |
| classifier_score: float | None = None | |
| attempts: int = 1 | |
| questions: list[Question] = Field(default_factory=list) | |
| cloze: ClozeExercise | None = None | |
| TEXT_PROMPT = """You are writing graded reading material for English learners. | |
| Write one self-contained text in English at CEFR level {level}, about: {topic}. | |
| Target length: about {words} words. | |
| Level guidance for {level}: {descriptor}. | |
| Rules: return ONLY the text itself — no title, no preamble, no notes.{feedback}""" | |
| QUESTIONS_PROMPT = """You write comprehension checks for English learners. | |
| Text: | |
| \"\"\"{text}\"\"\" | |
| Create exactly {n} multiple-choice comprehension questions strictly answerable | |
| from the text alone{level_clause}. | |
| Return ONLY a JSON object — no markdown fences, no commentary — exactly this shape: | |
| {{"questions": [{{"question": "...", "options": ["...", "...", "...", "..."], | |
| "answer_index": 0, "explanation": "..."}}]}} | |
| Rules: 4 options per question; exactly one correct option; answer_index is the | |
| 0-based index of the correct option; the explanation points to the relevant | |
| part of the text.{retry_clause}""" | |
| JUDGE_PROMPT = """You are a strict reviewer of reading-comprehension questions. | |
| Text: | |
| \"\"\"{text}\"\"\" | |
| Questions (JSON): {questions_json} | |
| For each question, check that: (a) it is answerable from the text alone, | |
| (b) the option at answer_index is correct, (c) no other option is also correct. | |
| Return ONLY JSON: {{"verdicts": [{{"index": 0, "valid": true, "reason": ""}}]}}""" | |
| CLOZE_PROMPT = """You design fill-in-the-blank (cloze) exercises for English learners. | |
| Text: | |
| \"\"\"{text}\"\"\" | |
| Choose exactly {n} words to blank out that test real language mastery at CEFR | |
| level {level}{level_clause} — prefer verbs (tense/form), prepositions, | |
| connectors and collocations over content nouns or names. Avoid blanking the | |
| same word twice, and avoid trivial words (the, a, is). | |
| Each "answer" MUST be a single word copied EXACTLY (same case) from the text. | |
| Provide 3 plausible-but-wrong distractors of the same kind, and a short hint. | |
| Return ONLY a JSON object — no markdown fences, no commentary — exactly: | |
| {{"blanks": [{{"answer": "...", "distractors": ["...", "...", "..."], | |
| "hint": "..."}}]}}{retry_clause}""" | |
| def locate_blanks(text: str, raw_blanks: list[dict]) -> list[ClozeBlank]: | |
| """Validate LLM-proposed answers against the text and assign real offsets. | |
| The LLM is not trusted for indexing: each answer must occur verbatim as a | |
| whole word, occurrences are consumed left to right so repeats land on | |
| distinct positions, and unfound answers are dropped. Result is sorted by | |
| position. Raises ValueError if nothing usable remains. | |
| """ | |
| blanks: list[ClozeBlank] = [] | |
| search_from: dict[str, int] = {} | |
| for item in raw_blanks: | |
| answer = str(item.get("answer", "")).strip() | |
| if not answer or " " in answer: | |
| continue | |
| cursor = search_from.get(answer, 0) | |
| while True: | |
| start = text.find(answer, cursor) | |
| if start == -1: | |
| break | |
| before = text[start - 1] if start > 0 else " " | |
| after = text[start + len(answer)] if start + len(answer) < len(text) else " " | |
| if not before.isalnum() and not after.isalnum(): # whole-word match | |
| break | |
| cursor = start + 1 | |
| if start == -1: | |
| continue # the model invented a word that is not in the text | |
| search_from[answer] = start + len(answer) | |
| blanks.append( | |
| ClozeBlank( | |
| answer=answer, | |
| start=start, | |
| distractors=[str(d) for d in item.get("distractors", [])][:3], | |
| hint=str(item.get("hint", "")), | |
| ) | |
| ) | |
| if not blanks: | |
| msg = "none of the proposed blanks were found verbatim in the text" | |
| raise ValueError(msg) | |
| return sorted(blanks, key=lambda blank: blank.start) | |
| def extract_json(raw: str) -> dict: | |
| """Parse a JSON object out of an LLM reply, tolerating fences and chatter.""" | |
| start, end = raw.find("{"), raw.rfind("}") | |
| if start == -1 or end <= start: | |
| msg = "no JSON object found in the model reply" | |
| raise ValueError(msg) | |
| return json.loads(raw[start : end + 1]) | |
| async def _ask(llm: LLMClient, prompt: str, *, temperature: float, max_tokens: int) -> str: | |
| response = await llm.complete( | |
| [ChatMessage(role="user", content=prompt)], | |
| temperature=temperature, | |
| max_tokens=max_tokens, | |
| ) | |
| return response.text.strip() | |
| def _parse_questions(raw: str) -> list[Question]: | |
| payload = extract_json(raw) | |
| items = payload.get("questions") | |
| if not isinstance(items, list) or not items: | |
| msg = "JSON has no non-empty 'questions' list" | |
| raise ValueError(msg) | |
| return [Question.model_validate(item) for item in items] | |
| async def _judge_questions( | |
| llm: LLMClient, *, text: str, questions: list[Question] | |
| ) -> list[Question]: | |
| """Keep only judge-approved questions; fail open if the judge misbehaves.""" | |
| questions_json = json.dumps([q.model_dump() for q in questions], ensure_ascii=False) | |
| try: | |
| raw = await _ask( | |
| llm, | |
| JUDGE_PROMPT.format(text=text, questions_json=questions_json), | |
| temperature=0.0, | |
| max_tokens=400, | |
| ) | |
| verdicts = { | |
| int(v["index"]): bool(v["valid"]) for v in extract_json(raw).get("verdicts", []) | |
| } | |
| except Exception: # judge is a gate, not a wall | |
| return questions | |
| kept = [q for i, q in enumerate(questions) if verdicts.get(i, True)] | |
| return kept or questions # a judge that rejects everything is suspect: fail open | |
| async def generate_questions( | |
| llm: LLMClient, | |
| cache: FileCache, | |
| *, | |
| text: str, | |
| level: str | None, | |
| model_name: str, | |
| n: int = 3, | |
| ) -> list[Question]: | |
| key = FileCache.key("questions", PROMPT_VERSION, model_name, level or "", str(n), text) | |
| if (cached := cache.get(key)) is not None: | |
| return [Question.model_validate(item) for item in cached["questions"]] | |
| level_clause = f", with question language at or below CEFR {level}" if level else "" | |
| prompt = QUESTIONS_PROMPT.format(text=text, n=n, level_clause=level_clause, retry_clause="") | |
| raw = await _ask(llm, prompt, temperature=0.3, max_tokens=900) | |
| try: | |
| questions = _parse_questions(raw) | |
| except (ValueError, ValidationError) as exc: | |
| retry_clause = ( | |
| f"\nYour previous reply was rejected ({exc}). Output valid JSON of the exact " | |
| "shape above and nothing else." | |
| ) | |
| prompt = QUESTIONS_PROMPT.format( | |
| text=text, n=n, level_clause=level_clause, retry_clause=retry_clause | |
| ) | |
| raw = await _ask(llm, prompt, temperature=0.0, max_tokens=900) | |
| try: | |
| questions = _parse_questions(raw) | |
| except (ValueError, ValidationError) as exc2: | |
| msg = f"the model could not produce valid questions ({exc2})" | |
| raise ReadingError(msg) from exc2 | |
| questions = await _judge_questions(llm, text=text, questions=questions) | |
| cache.set(key, {"questions": [q.model_dump() for q in questions]}) | |
| return questions | |
| async def generate_cloze( | |
| llm: LLMClient, | |
| cache: FileCache, | |
| *, | |
| text: str, | |
| level: str | None, | |
| model_name: str, | |
| n: int = 5, | |
| ) -> ClozeExercise: | |
| key = FileCache.key("cloze", PROMPT_VERSION, model_name, level or "", str(n), text) | |
| if (cached := cache.get(key)) is not None: | |
| return ClozeExercise.model_validate(cached) | |
| level_clause = f" (keep distractors at or below CEFR {level})" if level else "" | |
| def parse(raw: str) -> ClozeExercise: | |
| payload = extract_json(raw) | |
| items = payload.get("blanks") | |
| if not isinstance(items, list) or not items: | |
| msg = "JSON has no non-empty 'blanks' list" | |
| raise ValueError(msg) | |
| return ClozeExercise(blanks=locate_blanks(text, items)) | |
| prompt = CLOZE_PROMPT.format( | |
| text=text, n=n, level=level or "B1", level_clause=level_clause, retry_clause="" | |
| ) | |
| raw = await _ask(llm, prompt, temperature=0.4, max_tokens=700) | |
| try: | |
| cloze = parse(raw) | |
| except (ValueError, ValidationError) as exc: | |
| retry_clause = ( | |
| f"\nYour previous reply was rejected ({exc}). Every answer must be a single " | |
| "word copied verbatim from the text. Output valid JSON of the exact shape " | |
| "above and nothing else." | |
| ) | |
| prompt = CLOZE_PROMPT.format( | |
| text=text, | |
| n=n, | |
| level=level or "B1", | |
| level_clause=level_clause, | |
| retry_clause=retry_clause, | |
| ) | |
| raw = await _ask(llm, prompt, temperature=0.0, max_tokens=700) | |
| try: | |
| cloze = parse(raw) | |
| except (ValueError, ValidationError) as exc2: | |
| msg = f"the model could not produce a valid cloze exercise ({exc2})" | |
| raise ReadingError(msg) from exc2 | |
| cache.set(key, cloze.model_dump()) | |
| return cloze | |
| async def generate_verified_text( | |
| llm: LLMClient, | |
| classifier, | |
| *, | |
| level: str, | |
| topic: str, | |
| max_attempts: int = 2, | |
| ) -> tuple[str, str | None, float | None, int]: | |
| """Generate at `level`, verify with the classifier, retry once with feedback.""" | |
| feedback = "" | |
| text, classified, score = "", None, None | |
| for attempt in range(1, max_attempts + 1): | |
| text = await _ask( | |
| llm, | |
| TEXT_PROMPT.format( | |
| level=level, | |
| topic=topic, | |
| words=WORD_TARGETS[level], | |
| descriptor=LEVEL_DESCRIPTORS[level], | |
| feedback=feedback, | |
| ), | |
| temperature=0.8, | |
| max_tokens=700, | |
| ) | |
| if not text: | |
| msg = "the model returned an empty text" | |
| raise ReadingError(msg) | |
| if classifier is None: | |
| return text, None, None, attempt | |
| prediction = classifier.classify_text(text) | |
| classified, score = prediction.level, prediction.score | |
| if classified == level: | |
| return text, classified, score, attempt | |
| direction = ( | |
| "simplify it: shorter sentences, more frequent vocabulary" | |
| if CEFR_LEVELS.index(classified) > CEFR_LEVELS.index(level) | |
| else "make it harder: longer sentences, less frequent vocabulary, " | |
| "more advanced structures" | |
| ) | |
| feedback = ( | |
| f"\nYour previous attempt was classified as {classified} by an automatic " | |
| f"CEFR grader, but {level} was requested — {direction}." | |
| ) | |
| return text, classified, score, max_attempts # served with an honest mismatch label | |
| async def build_reading_exercise( | |
| llm: LLMClient, | |
| classifier, | |
| cache: FileCache, | |
| *, | |
| level: str, | |
| model_name: str, | |
| topic: str | None = None, | |
| activity: Literal["questions", "cloze"] = "questions", | |
| ) -> ReadingExercise: | |
| if level not in CEFR_LEVELS: | |
| msg = f"unknown CEFR level {level!r}" | |
| raise ReadingError(msg) | |
| topic = (topic or "").strip() or random.choice(DEFAULT_TOPICS) | |
| text_key = FileCache.key("text", PROMPT_VERSION, model_name, level, topic) | |
| if (cached := cache.get(text_key)) is not None: | |
| text = cached["text"] | |
| classified, score, attempts = ( | |
| cached.get("classified_level"), | |
| cached.get("classifier_score"), | |
| cached.get("attempts", 1), | |
| ) | |
| else: | |
| text, classified, score, attempts = await generate_verified_text( | |
| llm, classifier, level=level, topic=topic | |
| ) | |
| cache.set( | |
| text_key, | |
| { | |
| "text": text, | |
| "classified_level": classified, | |
| "classifier_score": score, | |
| "attempts": attempts, | |
| }, | |
| ) | |
| questions: list[Question] = [] | |
| cloze: ClozeExercise | None = None | |
| if activity == "cloze": | |
| cloze = await generate_cloze(llm, cache, text=text, level=level, model_name=model_name) | |
| else: | |
| questions = await generate_questions( | |
| llm, cache, text=text, level=level, model_name=model_name | |
| ) | |
| return ReadingExercise( | |
| text=text, | |
| source="generated", | |
| activity=activity, | |
| requested_level=level, | |
| classified_level=classified, | |
| classifier_score=score, | |
| attempts=attempts, | |
| questions=questions, | |
| cloze=cloze, | |
| ) | |
| async def exercise_from_user_text( | |
| llm: LLMClient, | |
| classifier, | |
| cache: FileCache, | |
| *, | |
| text: str, | |
| model_name: str, | |
| activity: Literal["questions", "cloze"] = "questions", | |
| ) -> ReadingExercise: | |
| text = text.strip() | |
| if not text: | |
| msg = "paste a text first" | |
| raise ReadingError(msg) | |
| classified_level: str | None = None | |
| classifier_score: float | None = None | |
| if classifier is not None: | |
| prediction = classifier.classify_text(text) | |
| classified_level, classifier_score = prediction.level, prediction.score | |
| questions: list[Question] = [] | |
| cloze: ClozeExercise | None = None | |
| if activity == "cloze": | |
| cloze = await generate_cloze( | |
| llm, cache, text=text, level=classified_level, model_name=model_name | |
| ) | |
| else: | |
| questions = await generate_questions( | |
| llm, cache, text=text, level=classified_level, model_name=model_name | |
| ) | |
| return ReadingExercise( | |
| text=text, | |
| source="user", | |
| activity=activity, | |
| classified_level=classified_level, | |
| classifier_score=classifier_score, | |
| questions=questions, | |
| cloze=cloze, | |
| ) | |