polyglot-tutor / src /tutor /services /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
17.2 kB
"""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 = ""
@model_validator(mode="after")
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 = ""
@property
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,
)