Skip to content

lang_tools.exercises

Exercise framework: shared interface and per-mechanic implementations.

Public API

EXERCISE_TYPES: tuple of supported exercise type strings. ExerciseType: literal alias. ExerciseRound, RoundResult, WordResult, SessionSummary: shared data shapes. SentenceReconstructionExercise, PairMatchingExercise, DiacriticTypingExercise, WordleExercise, ConversationalTutorExercise: concrete exercise classes. HintLevel: literal for diacritic-typing hint levels. WordleConfig: configuration for a Wordle exercise session.

Modules:

Classes:

ConversationalTutorExercise dataclass

ConversationalTutorExercise(
    chain: TutorChain | None = None,
    topic: str = "",
    **kwargs: object,
)

Bases: _BaseExercise

Conversational tutor session driven by a caller-supplied chain.

Attributes:

  • chain (TutorChain | None) –

    Callable matching the TutorChain signature.

  • topic (str) –

    Conversation topic shown to the user.

  • history (list[TutorMessage]) –

    Live conversation history; mutated in place.

Initialize with an optional chain and topic.

Parameters:

  • chain (TutorChain | None, default: None ) –

    Callable matching TutorChain. May be None if the exercise will be driven manually.

  • topic (str, default: '' ) –

    Conversation topic.

  • **kwargs (object, default: {} ) –

    Forwarded to _BaseExercise.

Methods:

  • start

    Open the session with an optional tutor greeting.

  • submit

    Send a user message and append the tutor reply.

Source code in src/lang_tools/exercises/conversational_tutor.py
def __init__(
    self,
    chain: TutorChain | None = None,
    topic: str = "",
    **kwargs: object,
) -> None:
    """Initialize with an optional chain and topic.

    Args:
        chain: Callable matching `TutorChain`. May be ``None`` if the
            exercise will be driven manually.
        topic: Conversation topic.
        **kwargs: Forwarded to `_BaseExercise`.
    """
    super().__init__(exercise_type="conversational_tutor", **kwargs)  # type: ignore[arg-type]
    self.chain = chain
    self.topic = topic
    self.history: list[TutorMessage] = []

start

start(
    greeting: TutorMessage | None = None,
) -> ExerciseRound

Open the session with an optional tutor greeting.

Parameters:

  • greeting (TutorMessage | None, default: None ) –

    Initial tutor message; appended to history if provided.

Returns:

  • ExerciseRound

    ExerciseRound whose prompt is the live history reference.

Source code in src/lang_tools/exercises/conversational_tutor.py
def start(self, greeting: TutorMessage | None = None) -> ExerciseRound:
    """Open the session with an optional tutor greeting.

    Args:
        greeting: Initial tutor message; appended to history if provided.

    Returns:
        `ExerciseRound` whose `prompt` is the live `history` reference.
    """
    self._ensure_started()
    if greeting is not None:
        self.history.append(greeting)
    return ExerciseRound(
        prompt={"topic": self.topic, "history": self.history},
        expected=None,
    )

submit

submit(
    round_: ExerciseRound, user_text: str
) -> RoundResult

Send a user message and append the tutor reply.

Parameters:

  • round_ (ExerciseRound) –

    The round returned by start.

  • user_text (str) –

    The user's target-language message.

Returns:

  • RoundResult

    RoundResult whose feedback carries the correction block (if any)

  • RoundResult

    and correct is True when no correction was returned.

Raises:

  • RuntimeError

    If chain is None when the user submits a message.

Source code in src/lang_tools/exercises/conversational_tutor.py
def submit(self, round_: ExerciseRound, user_text: str) -> RoundResult:
    """Send a user message and append the tutor reply.

    Args:
        round_: The round returned by `start`.
        user_text: The user's target-language message.

    Returns:
        `RoundResult` whose `feedback` carries the correction block (if any)
        and `correct` is True when no correction was returned.

    Raises:
        RuntimeError: If `chain` is None when the user submits a message.
    """
    del round_  # state lives on the exercise
    if self.chain is None:
        msg = "ConversationalTutorExercise.chain must be set before submit()."
        raise RuntimeError(msg)

    self.history.append(TutorMessage(role="user", content=user_text))
    reply = self.chain(user_text, list(self.history))
    self.history.append(reply)

    correct = not (reply.correction and reply.correction.strip())
    result = RoundResult(
        correct=correct,
        feedback=reply.correction,
    )
    self._bookkeep(result)
    return result

DiacriticTypingExercise dataclass

DiacriticTypingExercise(**kwargs: object)

Bases: _BaseExercise

Diacritic typing round factory.

Initialize the exercise.

Parameters:

  • **kwargs (object, default: {} ) –

    Forwarded to _BaseExercise (e.g. progress_callback).

Methods:

  • start

    Build a round for a single word.

  • submit

    Score a single keystroke.

Source code in src/lang_tools/exercises/diacritic_typing.py
def __init__(self, **kwargs: object) -> None:
    """Initialize the exercise.

    Args:
        **kwargs: Forwarded to `_BaseExercise` (e.g. `progress_callback`).
    """
    super().__init__(exercise_type="diacritic_typing", **kwargs)  # type: ignore[arg-type]

start

start(
    word: Word, *, hint_level: HintLevel = "off"
) -> ExerciseRound

Build a round for a single word.

Parameters:

  • word (Word) –

    Target word; must contain accented characters for the exercise to be meaningful.

  • hint_level (HintLevel, default: 'off' ) –

    Initial hint level (see HintLevel).

Returns:

  • ExerciseRound

    ExerciseRound whose expected is the internal _DiacriticState.

Source code in src/lang_tools/exercises/diacritic_typing.py
def start(
    self,
    word: Word,
    *,
    hint_level: HintLevel = "off",
) -> ExerciseRound:
    """Build a round for a single word.

    Args:
        word: Target word; must contain accented characters for the
            exercise to be meaningful.
        hint_level: Initial hint level (see `HintLevel`).

    Returns:
        `ExerciseRound` whose `expected` is the internal `_DiacriticState`.
    """
    self._ensure_started()
    state = _DiacriticState(
        word=word,
        hint_level=hint_level,
        display=_initial_display(word.text, hint_level),
    )
    # Advance cursor past pre-revealed characters.
    while state.cursor < len(word.text) and state.display[state.cursor] != "_":
        state.cursor += 1

    return ExerciseRound(
        prompt={
            "display": state.display.copy(),
            "hint_level": hint_level,
            "disabled_keys": set(),
            "glosses": [g.text for g in word.glosses],
        },
        expected=state,
    )

submit

submit(
    round_: ExerciseRound, character: str
) -> RoundResult

Score a single keystroke.

Parameters:

  • round_ (ExerciseRound) –

    The round returned by start.

  • character (str) –

    The character the user pressed.

Returns:

  • RoundResult

    RoundResult whose correct is True for that keystroke. Once the

  • RoundResult

    word is complete, a WordResult with overall correctness (no

  • RoundResult

    errors) is added to word_results.

Source code in src/lang_tools/exercises/diacritic_typing.py
def submit(self, round_: ExerciseRound, character: str) -> RoundResult:
    """Score a single keystroke.

    Args:
        round_: The round returned by `start`.
        character: The character the user pressed.

    Returns:
        `RoundResult` whose `correct` is True for that keystroke. Once the
        word is complete, a `WordResult` with overall correctness (no
        errors) is added to `word_results`.
    """
    state: _DiacriticState = round_.expected

    if state.cursor >= len(state.word.text):
        return RoundResult(correct=True, feedback="Word already complete.")

    expected_char = state.word.text[state.cursor]
    correct = character == expected_char

    if correct:
        state.display[state.cursor] = expected_char
        state.cursor += 1
        # Skip already-revealed positions.
        while (
            state.cursor < len(state.word.text)
            and state.display[state.cursor] != "_"
        ):
            state.cursor += 1
    else:
        state.disabled_keys.add(character)
        state.error_count += 1

    # Update prompt mirror so callers reading `round_.prompt` see live state.
    round_.prompt["display"] = state.display.copy()
    round_.prompt["disabled_keys"] = set(state.disabled_keys)

    word_results: list[WordResult] = []
    completed = state.cursor >= len(state.word.text)
    if completed:
        word_results.append(
            WordResult(word_id=state.word.id, correct=state.error_count == 0),
        )

    result = RoundResult(
        correct=correct,
        feedback=(
            None
            if correct
            else f"{character!r} is not at position {state.cursor}."
        ),
        word_results=word_results,
    )
    self._bookkeep(result)
    return result

ExerciseRound

Bases: BaseModel

Generic round container.

Attributes:

  • prompt (Any) –

    Exercise-specific prompt payload.

  • expected (Any) –

    Exercise-specific expected answer used by submit.

LetterResult

Bases: BaseModel

Per-letter evaluation of a guess.

Attributes:

  • letter (str) –

    The character (in the guess form, accent-stripped).

  • state (LetterState) –

    One of "correct", "misplaced", "wrong".

PairMatchingExercise dataclass

PairMatchingExercise(
    target_language: str = "en",
    rng: Random | None = None,
    **kwargs: object,
)

Bases: _BaseExercise

Pair-matching round factory.

Attributes:

  • target_language (str) –

    ISO 639-1 code for the translations side.

  • rng (Random) –

    Optional random.Random for deterministic shuffles.

Initialize with the translation language and an optional RNG.

Parameters:

  • target_language (str, default: 'en' ) –

    ISO 639-1 code used to look up Word.translations.

  • rng (Random | None, default: None ) –

    Optional random.Random; defaults to random.SystemRandom.

  • **kwargs (object, default: {} ) –

    Forwarded to _BaseExercise.

Methods:

  • start

    Build a round from a list of words.

  • submit

    Score a single (left_word, right_word) tap.

Source code in src/lang_tools/exercises/pair_matching.py
def __init__(
    self,
    target_language: str = "en",
    rng: random.Random | None = None,
    **kwargs: object,
) -> None:
    """Initialize with the translation language and an optional RNG.

    Args:
        target_language: ISO 639-1 code used to look up `Word.translations`.
        rng: Optional `random.Random`; defaults to `random.SystemRandom`.
        **kwargs: Forwarded to `_BaseExercise`.
    """
    super().__init__(exercise_type="pair_matching", **kwargs)  # type: ignore[arg-type]
    self.target_language = target_language
    self.rng = rng or random.SystemRandom()

start

start(words: list[Word]) -> ExerciseRound

Build a round from a list of words.

Parameters:

  • words (list[Word]) –

    The words to match. Each must have a translation in target_language.

Returns:

  • ExerciseRound

    ExerciseRound whose prompt carries the left/right columns and

  • ExerciseRound

    expected the correct mapping {left_text: right_text}.

Raises:

Source code in src/lang_tools/exercises/pair_matching.py
def start(self, words: list[Word]) -> ExerciseRound:
    """Build a round from a list of words.

    Args:
        words: The words to match. Each must have a translation in
            `target_language`.

    Returns:
        `ExerciseRound` whose `prompt` carries the left/right columns and
        `expected` the correct mapping ``{left_text: right_text}``.

    Raises:
        MissingTranslationError: When any word lacks a translation in
            `target_language`.
    """
    self._ensure_started()
    pairs: dict[str, str] = {}
    word_by_text: dict[str, Word] = {}
    for word in words:
        translation = word.translations.get(self.target_language)
        if translation is None:
            raise MissingTranslationError(word, self.target_language)
        pairs[word.text] = translation
        word_by_text[word.text] = word

    right_column = list(pairs.values())
    self.rng.shuffle(right_column)
    return ExerciseRound(
        prompt={
            "left_words": list(pairs.keys()),
            "right_words": right_column,
        },
        expected={"pairs": pairs, "word_by_text": word_by_text},
    )

submit

submit(
    round_: ExerciseRound, selected_pair: tuple[str, str]
) -> RoundResult

Score a single (left_word, right_word) tap.

Parameters:

  • round_ (ExerciseRound) –

    The round returned by start.

  • selected_pair (tuple[str, str]) –

    The user's chosen (left, right) pair.

Returns:

  • RoundResult

    RoundResult reflecting whether the right value matches the

  • RoundResult

    expected translation for the left word. Includes one WordResult

  • RoundResult

    for the left word.

Source code in src/lang_tools/exercises/pair_matching.py
def submit(
    self,
    round_: ExerciseRound,
    selected_pair: tuple[str, str],
) -> RoundResult:
    """Score a single ``(left_word, right_word)`` tap.

    Args:
        round_: The round returned by `start`.
        selected_pair: The user's chosen ``(left, right)`` pair.

    Returns:
        `RoundResult` reflecting whether the right value matches the
        expected translation for the left word. Includes one `WordResult`
        for the left word.
    """
    left, right = selected_pair
    expected_pairs: dict[str, str] = round_.expected["pairs"]
    word_by_text: dict[str, Word] = round_.expected["word_by_text"]
    correct = expected_pairs.get(left) == right
    word_results: list[WordResult] = []
    if left in word_by_text:
        word_results.append(
            WordResult(word_id=word_by_text[left].id, correct=correct),
        )
    result = RoundResult(
        correct=correct,
        feedback=None if correct else "Wrong pair.",
        word_results=word_results,
    )
    self._bookkeep(result)
    return result

RoundResult

Bases: BaseModel

Outcome of one submit() call.

Attributes:

  • correct (bool) –

    Whether the round overall counts as correct.

  • feedback (str | None) –

    Optional human-readable feedback message.

  • word_results (list[WordResult]) –

    Per-word outcomes for progress tracking.

SentenceReconstructionExercise dataclass

SentenceReconstructionExercise(
    rng: Random | None = None, **kwargs: object
)

Bases: _BaseExercise

Sentence reconstruction round factory.

Attributes:

  • rng (Random) –

    Optional random.Random for deterministic shuffles.

Initialize the exercise with a deterministic RNG by default.

Parameters:

  • rng (Random | None, default: None ) –

    Optional random.Random; defaults to random.SystemRandom.

  • **kwargs (object, default: {} ) –

    Forwarded to _BaseExercise (e.g. progress_callback).

Methods:

  • start

    Build a round from a target-language sentence.

  • submit

    Score a user-submitted ordering.

Source code in src/lang_tools/exercises/sentence_reconstruction.py
def __init__(self, rng: random.Random | None = None, **kwargs: object) -> None:
    """Initialize the exercise with a deterministic RNG by default.

    Args:
        rng: Optional `random.Random`; defaults to `random.SystemRandom`.
        **kwargs: Forwarded to `_BaseExercise` (e.g. `progress_callback`).
    """
    super().__init__(exercise_type="sentence_reconstruction", **kwargs)  # type: ignore[arg-type]
    self.rng = rng or random.SystemRandom()

start

start(
    sentence: str,
    translation: str,
    *,
    portions: list[str] | None = None,
) -> ExerciseRound

Build a round from a target-language sentence.

Parameters:

  • sentence (str) –

    Target-language sentence the user must reconstruct.

  • translation (str) –

    User-language translation shown as the prompt.

  • portions (list[str] | None, default: None ) –

    Pre-split portions; defaults to a whitespace split.

Returns:

  • ExerciseRound

    ExerciseRound whose prompt is a dict with shuffled portions.

Source code in src/lang_tools/exercises/sentence_reconstruction.py
def start(
    self,
    sentence: str,
    translation: str,
    *,
    portions: list[str] | None = None,
) -> ExerciseRound:
    """Build a round from a target-language sentence.

    Args:
        sentence: Target-language sentence the user must reconstruct.
        translation: User-language translation shown as the prompt.
        portions: Pre-split portions; defaults to a whitespace split.

    Returns:
        `ExerciseRound` whose `prompt` is a dict with shuffled portions.
    """
    self._ensure_started()
    ordered = merge_short_portions(
        portions if portions is not None else sentence.split(),
    )
    shuffled = ordered.copy()
    self.rng.shuffle(shuffled)
    return ExerciseRound(
        prompt={"translation": translation, "portions": shuffled},
        expected=ordered,
    )

submit

submit(
    round_: ExerciseRound, selected_order: list[str]
) -> RoundResult

Score a user-submitted ordering.

Parameters:

  • round_ (ExerciseRound) –

    The round returned by start.

  • selected_order (list[str]) –

    User's chosen portion order.

Returns:

  • RoundResult

    RoundResult whose correct flag is True iff orderings match.

Source code in src/lang_tools/exercises/sentence_reconstruction.py
def submit(self, round_: ExerciseRound, selected_order: list[str]) -> RoundResult:
    """Score a user-submitted ordering.

    Args:
        round_: The round returned by `start`.
        selected_order: User's chosen portion order.

    Returns:
        `RoundResult` whose `correct` flag is True iff orderings match.
    """
    correct = selected_order == round_.expected
    result = RoundResult(
        correct=correct,
        feedback=None if correct else "Order does not match the expected sentence.",
    )
    self._bookkeep(result)
    return result

SessionSummary

Bases: BaseModel

Aggregate stats produced by finish().

Attributes:

  • exercise_type (ExerciseType) –

    Tag of the originating exercise.

  • total_rounds (int) –

    Number of rounds played.

  • correct_rounds (int) –

    Number of rounds the user completed correctly.

  • words_practiced (list[str]) –

    Distinct word IDs touched during the session.

  • duration_seconds (float) –

    Wall-clock duration in seconds.

TutorMessage

Bases: BaseModel

One message in the tutor conversation history.

Attributes:

  • role (Literal['user', 'tutor']) –

    "user" or "tutor".

  • content (str) –

    Target-language text.

  • translation (str | None) –

    Optional user-language translation.

  • correction (str | None) –

    Optional correction block (tutor messages only).

WordResult

Bases: BaseModel

Per-word outcome of a single round.

Attributes:

  • word_id (str) –

    ID of the involved Word.

  • correct (bool) –

    Whether the user got it right.

WordleConfig

Bases: BaseModelKwargs

Configuration for a Wordle exercise session.

Attributes:

  • word_lengths (list[int]) –

    Allowed word lengths offered in the word-length selector. Defaults to [4, 5, 6, 7].

  • default_word_length (int) –

    Pre-selected word length when no user preference is stored. Defaults to 5.

WordleExercise dataclass

WordleExercise(**kwargs: object)

Bases: _BaseExercise

Wordle round factory.

Initialize the exercise.

Parameters:

  • **kwargs (object, default: {} ) –

    Forwarded to _BaseExercise.

Methods:

  • start

    Build a Wordle round around target.

  • submit

    Evaluate a single guess.

Source code in src/lang_tools/exercises/wordle.py
def __init__(self, **kwargs: object) -> None:
    """Initialize the exercise.

    Args:
        **kwargs: Forwarded to `_BaseExercise`.
    """
    super().__init__(exercise_type="wordle", **kwargs)  # type: ignore[arg-type]

start

start(
    target: Word, *, max_attempts: int | None = None
) -> ExerciseRound

Build a Wordle round around target.

Parameters:

  • target (Word) –

    The hidden word.

  • max_attempts (int | None, default: None ) –

    Override for the default len(target) + 1.

Returns:

  • ExerciseRound

    ExerciseRound whose expected is the internal _WordleState.

Source code in src/lang_tools/exercises/wordle.py
def start(self, target: Word, *, max_attempts: int | None = None) -> ExerciseRound:
    """Build a Wordle round around `target`.

    Args:
        target: The hidden word.
        max_attempts: Override for the default ``len(target) + 1``.

    Returns:
        `ExerciseRound` whose `expected` is the internal `_WordleState`.
    """
    self._ensure_started()
    normalized = _normalize(target.text)
    state = _WordleState(
        target=target,
        target_normalized=normalized,
        word_length=len(normalized),
        max_attempts=(
            max_attempts if max_attempts is not None else len(normalized) + 1
        ),
    )
    return ExerciseRound(
        prompt={
            "word_length": state.word_length,
            "max_attempts": state.max_attempts,
            "guesses": [],
            "results": [],
            "keyboard_state": {},
        },
        expected=state,
    )

submit

submit(round_: ExerciseRound, guess: str) -> RoundResult

Evaluate a single guess.

Parameters:

  • round_ (ExerciseRound) –

    The round returned by start.

  • guess (str) –

    User's guess. Compared against the normalized target.

Returns:

  • RoundResult

    RoundResult flagging this guess. When the round terminates (win

  • RoundResult

    or attempts exhausted) a WordResult is appended.

Source code in src/lang_tools/exercises/wordle.py
def submit(self, round_: ExerciseRound, guess: str) -> RoundResult:
    """Evaluate a single guess.

    Args:
        round_: The round returned by `start`.
        guess: User's guess. Compared against the normalized target.

    Returns:
        `RoundResult` flagging this guess. When the round terminates (win
        or attempts exhausted) a `WordResult` is appended.
    """
    state: _WordleState = round_.expected

    if state.won or len(state.guesses) >= state.max_attempts:
        return RoundResult(correct=False, feedback="Game already finished.")

    normalized_guess = _normalize(guess)
    if len(normalized_guess) != state.word_length:
        return RoundResult(
            correct=False,
            feedback=f"Guess must be {state.word_length} letters.",
        )

    letter_results = _evaluate(normalized_guess, state.target_normalized)
    state.guesses.append(normalized_guess)
    state.results.append(letter_results)
    _update_keyboard(state.keyboard_state, letter_results)

    round_.prompt["guesses"] = list(state.guesses)
    round_.prompt["results"] = [list(r) for r in state.results]
    round_.prompt["keyboard_state"] = dict(state.keyboard_state)

    correct = normalized_guess == state.target_normalized
    state.won = correct
    terminated = correct or len(state.guesses) >= state.max_attempts

    word_results: list[WordResult] = []
    if terminated:
        word_results.append(WordResult(word_id=state.target.id, correct=correct))

    result = RoundResult(
        correct=correct,
        feedback=None if correct else f"{guess!r} is not the word.",
        word_results=word_results,
    )
    self._bookkeep(result)
    return result