Spaces:
Runtime error
Runtime error
Arthur_Diaz
feat(dictation): word-level dictation scoring with tolerant normalization (#8)
f6bff07 unverified | """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] | |
| 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, | |
| ) | |