Arthur_Diaz commited on
Commit
f6bff07
·
unverified ·
1 Parent(s): e667bd0

feat(dictation): word-level dictation scoring with tolerant normalization (#8)

Browse files

## What & why
The ML core of M2 dictation: compares the learner's typed answer to the
reference and produces a word-level diagnosis. Pure and network-free,
reused
by M5 (pronunciation).

## Design
- Tolerance calibrated to what dictation tests — hearing/spelling the
right
word. Case, punctuation, spacing ignored; contractions ("don't" = "do
not")
and US/UK spelling ("colour" = "color") accepted as transcription
variants;
the word itself counts ("sheep" vs "ship") — the signal dictation must
catch.
- Word-aligned feedback via Levenshtein backtrace (jiwer): reference
shown with
substitutions/omissions/insertions marked, plus WER and an error
breakdown.

## Changes
- services/dictation.py: normalize_words, score_dictation →
DictationResult
(WER, S/D/I/H counts, per-word ops)
- main.py: render_dictation_feedback (marked reference + breakdown),
pure+tested
- Tests: +8 (82 total) — normalization, perfect-despite-variants,
phonetic
confusion caught, deletion/insertion alignment, empty
hypothesis/reference,
feedback rendering

## How to test
uv run pytest

## Out of scope
Listening tab wiring ASR → scoring → feedback (PR 4).

pyproject.toml CHANGED
@@ -10,6 +10,7 @@ dependencies = [
10
  # Runtime deps only — keep the Docker image (HF Space, cpu-basic) lean.
11
  "gradio>=6.17,<7",
12
  "huggingface-hub>=1.18.0",
 
13
  "numpy>=2.4.6",
14
  "onnxruntime>=1.26.0",
15
  "openai>=2.41,<3", # single OpenAI-compatible client: Gemini / Mistral / Ollama / OpenAI (ADR 0002)
@@ -41,7 +42,6 @@ train = [
41
  ]
42
  asr = [
43
  "faster-whisper>=1.0",
44
- "jiwer>=3.0",
45
  "soundfile>=0.12",
46
  ]
47
 
 
10
  # Runtime deps only — keep the Docker image (HF Space, cpu-basic) lean.
11
  "gradio>=6.17,<7",
12
  "huggingface-hub>=1.18.0",
13
+ "jiwer>=4.0.0",
14
  "numpy>=2.4.6",
15
  "onnxruntime>=1.26.0",
16
  "openai>=2.41,<3", # single OpenAI-compatible client: Gemini / Mistral / Ollama / OpenAI (ADR 0002)
 
42
  ]
43
  asr = [
44
  "faster-whisper>=1.0",
 
45
  "soundfile>=0.12",
46
  ]
47
 
src/tutor/app/main.py CHANGED
@@ -18,6 +18,7 @@ from tutor.ml.cefr.inference import create_cefr_classifier
18
  from tutor.services.asr.base import ASRError
19
  from tutor.services.asr.factory import create_asr_client
20
  from tutor.services.cache import FileCache
 
21
  from tutor.services.llm.base import ChatMessage, LLMError
22
  from tutor.services.llm.factory import create_llm_client
23
  from tutor.services.reading import (
@@ -89,6 +90,33 @@ def render_cloze_text(text: str, blanks: list[dict]) -> str:
89
  return rendered
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def build_app(settings: Settings | None = None) -> gr.Blocks:
93
  settings = settings or get_settings()
94
  llm = create_llm_client(settings)
 
18
  from tutor.services.asr.base import ASRError
19
  from tutor.services.asr.factory import create_asr_client
20
  from tutor.services.cache import FileCache
21
+ from tutor.services.dictation import DictationResult
22
  from tutor.services.llm.base import ChatMessage, LLMError
23
  from tutor.services.llm.factory import create_llm_client
24
  from tutor.services.reading import (
 
90
  return rendered
91
 
92
 
93
+ def 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
+
120
  def build_app(settings: Settings | None = None) -> gr.Blocks:
121
  settings = settings or get_settings()
122
  llm = create_llm_client(settings)
src/tutor/services/dictation.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dictation scoring (M2) — pure, network-free, reused by M5 pronunciation.
2
+
3
+ Compares what the learner typed against the reference sentence and produces a
4
+ word-level diagnosis. Design choices (ADR 0004 spirit):
5
+
6
+ - Tolerance is calibrated to what dictation *tests*: hearing and spelling the
7
+ right word. Case, punctuation and spacing are ignored; contractions
8
+ ("don't" = "do not") and US/UK spelling ("colour" = "color") are accepted
9
+ (legitimate transcription variants, not listening errors); but the word
10
+ itself counts ("ship" vs "sheep", "there" vs "their") — that is exactly what
11
+ dictation must catch.
12
+ - Feedback is word-aligned (Levenshtein backtrace via jiwer): the reference is
13
+ shown with substitutions, omissions and insertions marked, plus a WER and an
14
+ error breakdown. Aligning two sequences of different lengths is the core bit.
15
+ """
16
+
17
+ import re
18
+ from typing import Literal
19
+
20
+ from pydantic import BaseModel
21
+
22
+ # Minimal US/UK normalisation: fold common -our/-or, -ise/-ize, -re/-er, -ll-.
23
+ # Not exhaustive — a pragmatic set that covers the bulk of dictation cases.
24
+ _UK_US_SUFFIXES = (
25
+ ("our", "or"), # colour -> color
26
+ ("ise", "ize"), # realise -> realize
27
+ ("isation", "ization"),
28
+ ("yse", "yze"), # analyse -> analyze
29
+ ("tre", "ter"), # centre -> center
30
+ )
31
+ _CONTRACTIONS = {
32
+ "don't": "do not",
33
+ "doesn't": "does not",
34
+ "didn't": "did not",
35
+ "won't": "will not",
36
+ "can't": "cannot",
37
+ "couldn't": "could not",
38
+ "wouldn't": "would not",
39
+ "shouldn't": "should not",
40
+ "isn't": "is not",
41
+ "aren't": "are not",
42
+ "wasn't": "was not",
43
+ "weren't": "were not",
44
+ "haven't": "have not",
45
+ "hasn't": "has not",
46
+ "hadn't": "had not",
47
+ "i'm": "i am",
48
+ "you're": "you are",
49
+ "we're": "we are",
50
+ "they're": "they are",
51
+ "it's": "it is",
52
+ "that's": "that is",
53
+ "i've": "i have",
54
+ "i'll": "i will",
55
+ "let's": "let us",
56
+ }
57
+
58
+ OpType = Literal["equal", "substitute", "delete", "insert"]
59
+
60
+
61
+ class WordOp(BaseModel):
62
+ """One alignment operation. ref_word/hyp_word are None where not applicable."""
63
+
64
+ op: OpType
65
+ ref_word: str | None = None
66
+ hyp_word: str | None = None
67
+
68
+
69
+ class DictationResult(BaseModel):
70
+ wer: float
71
+ hits: int
72
+ substitutions: int
73
+ deletions: int
74
+ insertions: int
75
+ reference_words: list[str]
76
+ ops: list[WordOp]
77
+
78
+ @property
79
+ def is_perfect(self) -> bool:
80
+ return self.substitutions == 0 and self.deletions == 0 and self.insertions == 0
81
+
82
+
83
+ def _expand_contractions(text: str) -> str:
84
+ return " ".join(_CONTRACTIONS.get(token, token) for token in text.split())
85
+
86
+
87
+ def _fold_spelling(word: str) -> str:
88
+ for uk, us in _UK_US_SUFFIXES:
89
+ if word.endswith(uk):
90
+ return word[: -len(uk)] + us
91
+ return word
92
+
93
+
94
+ def normalize_words(text: str) -> list[str]:
95
+ """Lowercase, drop punctuation, expand contractions, fold UK->US, split."""
96
+ text = text.lower()
97
+ text = re.sub(r"[^a-z0-9'\s]", " ", text) # keep the apostrophe for contractions
98
+ text = _expand_contractions(text)
99
+ text = re.sub(r"[^a-z0-9\s]", " ", text) # now drop stray apostrophes
100
+ return [_fold_spelling(word) for word in text.split()]
101
+
102
+
103
+ def score_dictation(reference: str, hypothesis: str) -> DictationResult:
104
+ """Word-level scoring with a tolerant normalisation. Pure, no I/O."""
105
+ import jiwer
106
+
107
+ ref_words = normalize_words(reference)
108
+ hyp_words = normalize_words(hypothesis)
109
+
110
+ # jiwer needs non-empty input; handle the degenerate cases explicitly.
111
+ if not ref_words:
112
+ msg = "reference is empty after normalisation"
113
+ raise ValueError(msg)
114
+ if not hyp_words:
115
+ return DictationResult(
116
+ wer=1.0,
117
+ hits=0,
118
+ substitutions=0,
119
+ deletions=len(ref_words),
120
+ insertions=0,
121
+ reference_words=ref_words,
122
+ ops=[WordOp(op="delete", ref_word=word) for word in ref_words],
123
+ )
124
+
125
+ output = jiwer.process_words([" ".join(ref_words)], [" ".join(hyp_words)])
126
+ ops: list[WordOp] = []
127
+ for chunk in output.alignments[0]:
128
+ if chunk.type == "equal":
129
+ for offset in range(chunk.ref_end_idx - chunk.ref_start_idx):
130
+ word = ref_words[chunk.ref_start_idx + offset]
131
+ ops.append(WordOp(op="equal", ref_word=word, hyp_word=word))
132
+ elif chunk.type == "substitute":
133
+ for offset in range(chunk.ref_end_idx - chunk.ref_start_idx):
134
+ ops.append(
135
+ WordOp(
136
+ op="substitute",
137
+ ref_word=ref_words[chunk.ref_start_idx + offset],
138
+ hyp_word=hyp_words[chunk.hyp_start_idx + offset],
139
+ )
140
+ )
141
+ elif chunk.type == "delete":
142
+ for offset in range(chunk.ref_end_idx - chunk.ref_start_idx):
143
+ ops.append(WordOp(op="delete", ref_word=ref_words[chunk.ref_start_idx + offset]))
144
+ elif chunk.type == "insert":
145
+ for offset in range(chunk.hyp_end_idx - chunk.hyp_start_idx):
146
+ ops.append(WordOp(op="insert", hyp_word=hyp_words[chunk.hyp_start_idx + offset]))
147
+
148
+ return DictationResult(
149
+ wer=output.wer,
150
+ hits=output.hits,
151
+ substitutions=output.substitutions,
152
+ deletions=output.deletions,
153
+ insertions=output.insertions,
154
+ reference_words=ref_words,
155
+ ops=ops,
156
+ )
tests/test_dictation.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from tutor.services.dictation import normalize_words, score_dictation
4
+
5
+
6
+ def test_normalize_folds_case_punctuation_contractions_spelling() -> None:
7
+ assert normalize_words("Hello, World!") == ["hello", "world"]
8
+ assert normalize_words("I don't know") == ["i", "do", "not", "know"]
9
+ assert normalize_words("The colour of the centre") == ["the", "color", "of", "the", "center"]
10
+
11
+
12
+ def test_perfect_despite_legitimate_variants() -> None:
13
+ result = score_dictation("I don't like the colour.", "I do not like the color")
14
+ assert result.is_perfect
15
+ assert result.wer == 0.0
16
+ assert result.substitutions == result.deletions == result.insertions == 0
17
+
18
+
19
+ def test_catches_phonetic_confusion() -> None:
20
+ result = score_dictation("I saw a sheep on the hill", "I saw a ship on the hill")
21
+ assert result.substitutions == 1
22
+ subs = [(op.ref_word, op.hyp_word) for op in result.ops if op.op == "substitute"]
23
+ assert subs == [("sheep", "ship")]
24
+
25
+
26
+ def test_deletion_and_alignment_order() -> None:
27
+ result = score_dictation("the quick brown fox jumps", "the quik brown fox")
28
+ assert result.substitutions == 1 # quick/quik
29
+ assert result.deletions == 1 # jumps missing
30
+ assert [op.op for op in result.ops] == ["equal", "substitute", "equal", "equal", "delete"]
31
+
32
+
33
+ def test_insertion_is_flagged() -> None:
34
+ result = score_dictation("the cat sat", "the big cat sat")
35
+ assert result.insertions == 1
36
+ inserted = [op.hyp_word for op in result.ops if op.op == "insert"]
37
+ assert inserted == ["big"]
38
+
39
+
40
+ def test_empty_hypothesis_is_all_deletions() -> None:
41
+ result = score_dictation("hello world", " ")
42
+ assert result.wer == 1.0
43
+ assert result.deletions == 2
44
+ assert all(op.op == "delete" for op in result.ops)
45
+
46
+
47
+ def test_empty_reference_raises() -> None:
48
+ with pytest.raises(ValueError, match="reference is empty"):
49
+ score_dictation("!!!", "anything")
50
+
51
+
52
+ def test_render_feedback_marks_errors() -> None:
53
+ from tutor.app.main import render_dictation_feedback
54
+
55
+ perfect = render_dictation_feedback(score_dictation("the cat sat", "the cat sat"))
56
+ assert "100% correct 🎉" in perfect
57
+
58
+ marked = render_dictation_feedback(score_dictation("I saw a sheep", "I saw a ship"))
59
+ assert "**sheep**" in marked # correct word in bold
60
+ assert "~~ship~~" in marked # learner's wrong version struck through
uv.lock CHANGED
@@ -2587,6 +2587,7 @@ source = { editable = "." }
2587
  dependencies = [
2588
  { name = "gradio" },
2589
  { name = "huggingface-hub" },
 
2590
  { name = "numpy" },
2591
  { name = "onnxruntime" },
2592
  { name = "openai" },
@@ -2598,7 +2599,6 @@ dependencies = [
2598
  [package.dev-dependencies]
2599
  asr = [
2600
  { name = "faster-whisper" },
2601
- { name = "jiwer" },
2602
  { name = "soundfile" },
2603
  ]
2604
  data = [
@@ -2624,6 +2624,7 @@ train = [
2624
  requires-dist = [
2625
  { name = "gradio", specifier = ">=6.17,<7" },
2626
  { name = "huggingface-hub", specifier = ">=1.18.0" },
 
2627
  { name = "numpy", specifier = ">=2.4.6" },
2628
  { name = "onnxruntime", specifier = ">=1.26.0" },
2629
  { name = "openai", specifier = ">=2.41,<3" },
@@ -2635,7 +2636,6 @@ requires-dist = [
2635
  [package.metadata.requires-dev]
2636
  asr = [
2637
  { name = "faster-whisper", specifier = ">=1.0" },
2638
- { name = "jiwer", specifier = ">=3.0" },
2639
  { name = "soundfile", specifier = ">=0.12" },
2640
  ]
2641
  data = [
 
2587
  dependencies = [
2588
  { name = "gradio" },
2589
  { name = "huggingface-hub" },
2590
+ { name = "jiwer" },
2591
  { name = "numpy" },
2592
  { name = "onnxruntime" },
2593
  { name = "openai" },
 
2599
  [package.dev-dependencies]
2600
  asr = [
2601
  { name = "faster-whisper" },
 
2602
  { name = "soundfile" },
2603
  ]
2604
  data = [
 
2624
  requires-dist = [
2625
  { name = "gradio", specifier = ">=6.17,<7" },
2626
  { name = "huggingface-hub", specifier = ">=1.18.0" },
2627
+ { name = "jiwer", specifier = ">=4.0.0" },
2628
  { name = "numpy", specifier = ">=2.4.6" },
2629
  { name = "onnxruntime", specifier = ">=1.26.0" },
2630
  { name = "openai", specifier = ">=2.41,<3" },
 
2636
  [package.metadata.requires-dev]
2637
  asr = [
2638
  { name = "faster-whisper", specifier = ">=1.0" },
 
2639
  { name = "soundfile", specifier = ">=0.12" },
2640
  ]
2641
  data = [