polyglot-tutor / src /tutor /services /dictation.py
Arthur_Diaz
feat(dictation): word-level dictation scoring with tolerant normalization (#8)
f6bff07 unverified
Raw
History Blame Contribute Delete
5.4 kB
"""Dictation scoring (M2) — pure, network-free, reused by M5 pronunciation.
Compares what the learner typed against the reference sentence and produces a
word-level diagnosis. Design choices (ADR 0004 spirit):
- Tolerance is calibrated to what dictation *tests*: hearing and spelling the
right word. Case, punctuation and spacing are ignored; contractions
("don't" = "do not") and US/UK spelling ("colour" = "color") are accepted
(legitimate transcription variants, not listening errors); but the word
itself counts ("ship" vs "sheep", "there" vs "their") — that is exactly what
dictation must catch.
- Feedback is word-aligned (Levenshtein backtrace via jiwer): the reference is
shown with substitutions, omissions and insertions marked, plus a WER and an
error breakdown. Aligning two sequences of different lengths is the core bit.
"""
import re
from typing import Literal
from pydantic import BaseModel
# Minimal US/UK normalisation: fold common -our/-or, -ise/-ize, -re/-er, -ll-.
# Not exhaustive — a pragmatic set that covers the bulk of dictation cases.
_UK_US_SUFFIXES = (
("our", "or"), # colour -> color
("ise", "ize"), # realise -> realize
("isation", "ization"),
("yse", "yze"), # analyse -> analyze
("tre", "ter"), # centre -> center
)
_CONTRACTIONS = {
"don't": "do not",
"doesn't": "does not",
"didn't": "did not",
"won't": "will not",
"can't": "cannot",
"couldn't": "could not",
"wouldn't": "would not",
"shouldn't": "should not",
"isn't": "is not",
"aren't": "are not",
"wasn't": "was not",
"weren't": "were not",
"haven't": "have not",
"hasn't": "has not",
"hadn't": "had not",
"i'm": "i am",
"you're": "you are",
"we're": "we are",
"they're": "they are",
"it's": "it is",
"that's": "that is",
"i've": "i have",
"i'll": "i will",
"let's": "let us",
}
OpType = Literal["equal", "substitute", "delete", "insert"]
class WordOp(BaseModel):
"""One alignment operation. ref_word/hyp_word are None where not applicable."""
op: OpType
ref_word: str | None = None
hyp_word: str | None = None
class DictationResult(BaseModel):
wer: float
hits: int
substitutions: int
deletions: int
insertions: int
reference_words: list[str]
ops: list[WordOp]
@property
def is_perfect(self) -> bool:
return self.substitutions == 0 and self.deletions == 0 and self.insertions == 0
def _expand_contractions(text: str) -> str:
return " ".join(_CONTRACTIONS.get(token, token) for token in text.split())
def _fold_spelling(word: str) -> str:
for uk, us in _UK_US_SUFFIXES:
if word.endswith(uk):
return word[: -len(uk)] + us
return word
def normalize_words(text: str) -> list[str]:
"""Lowercase, drop punctuation, expand contractions, fold UK->US, split."""
text = text.lower()
text = re.sub(r"[^a-z0-9'\s]", " ", text) # keep the apostrophe for contractions
text = _expand_contractions(text)
text = re.sub(r"[^a-z0-9\s]", " ", text) # now drop stray apostrophes
return [_fold_spelling(word) for word in text.split()]
def score_dictation(reference: str, hypothesis: str) -> DictationResult:
"""Word-level scoring with a tolerant normalisation. Pure, no I/O."""
import jiwer
ref_words = normalize_words(reference)
hyp_words = normalize_words(hypothesis)
# jiwer needs non-empty input; handle the degenerate cases explicitly.
if not ref_words:
msg = "reference is empty after normalisation"
raise ValueError(msg)
if not hyp_words:
return DictationResult(
wer=1.0,
hits=0,
substitutions=0,
deletions=len(ref_words),
insertions=0,
reference_words=ref_words,
ops=[WordOp(op="delete", ref_word=word) for word in ref_words],
)
output = jiwer.process_words([" ".join(ref_words)], [" ".join(hyp_words)])
ops: list[WordOp] = []
for chunk in output.alignments[0]:
if chunk.type == "equal":
for offset in range(chunk.ref_end_idx - chunk.ref_start_idx):
word = ref_words[chunk.ref_start_idx + offset]
ops.append(WordOp(op="equal", ref_word=word, hyp_word=word))
elif chunk.type == "substitute":
for offset in range(chunk.ref_end_idx - chunk.ref_start_idx):
ops.append(
WordOp(
op="substitute",
ref_word=ref_words[chunk.ref_start_idx + offset],
hyp_word=hyp_words[chunk.hyp_start_idx + offset],
)
)
elif chunk.type == "delete":
for offset in range(chunk.ref_end_idx - chunk.ref_start_idx):
ops.append(WordOp(op="delete", ref_word=ref_words[chunk.ref_start_idx + offset]))
elif chunk.type == "insert":
for offset in range(chunk.hyp_end_idx - chunk.hyp_start_idx):
ops.append(WordOp(op="insert", hyp_word=hyp_words[chunk.hyp_start_idx + offset]))
return DictationResult(
wer=output.wer,
hits=output.hits,
substitutions=output.substitutions,
deletions=output.deletions,
insertions=output.insertions,
reference_words=ref_words,
ops=ops,
)