Add issue reporting for imprecise phonemes and enhance scoring feedback
Browse files- app/scorer.py +26 -3
- app/tips.py +13 -0
- static/index.html +8 -1
- tests/test_scoring.py +10 -0
app/scorer.py
CHANGED
|
@@ -11,7 +11,7 @@ from typing import Callable, Protocol
|
|
| 11 |
import numpy as np
|
| 12 |
|
| 13 |
from .align import GopConfig, ctc_viterbi, edit_distance, greedy_decode, score_tokens
|
| 14 |
-
from .tips import VOWELS, tip_for
|
| 15 |
from .translit import to_hebrew
|
| 16 |
|
| 17 |
log = logging.getLogger("ihki.scorer")
|
|
@@ -83,6 +83,8 @@ def tokenize(ipa: str, vocab: Vocab) -> list[TargetToken]:
|
|
| 83 |
class ScorerConfig:
|
| 84 |
flag_below: float = 50.0 # phoneme score under which a consonant gets a tip
|
| 85 |
unrelated_ratio: float = 0.65 # normalised edit distance above which we say "different sentence"
|
|
|
|
|
|
|
| 86 |
gop: GopConfig = field(default_factory=GopConfig)
|
| 87 |
|
| 88 |
|
|
@@ -117,7 +119,7 @@ class Scorer:
|
|
| 117 |
phonemes, tips, seen = [], [], set()
|
| 118 |
ar_words = ar.split()
|
| 119 |
tr_words = to_hebrew(ipa, ar)
|
| 120 |
-
for tok, sc in zip(toks, scores):
|
| 121 |
heard_ph = "_" if sc.deleted else (self.vocab.i2t.get(sc.competitor) if sc.competitor is not None else None)
|
| 122 |
flagged = bool(sc.score < self.cfg.flag_below) and tok.phoneme not in VOWELS
|
| 123 |
phonemes.append({"ipa": tok.phoneme, "word": tok.word, "score": sc.score,
|
|
@@ -128,6 +130,7 @@ class Scorer:
|
|
| 128 |
seen.add(tip["key"])
|
| 129 |
tip["word"] = ar_words[tok.word] if tok.word < len(ar_words) else ""
|
| 130 |
tip["word_tr"] = tr_words[tok.word]
|
|
|
|
| 131 |
tips.append(tip)
|
| 132 |
|
| 133 |
n_words = len(ipa.split("|"))
|
|
@@ -144,8 +147,28 @@ class Scorer:
|
|
| 144 |
overall = 0.5 * phone_mean + 0.5 * word_mean # one wrong consonant must pull the sentence down
|
| 145 |
if unrelated:
|
| 146 |
overall = min(overall, 30.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
return {"score": round(overall), "variant": {"ar": ar, "tr": " ".join(tr_words), "ipa": ipa}, "heard_ipa": heard,
|
| 148 |
-
"unrelated": unrelated, "words": words, "tips": tips
|
| 149 |
|
| 150 |
|
| 151 |
# ---------- real model ----------
|
|
|
|
| 11 |
import numpy as np
|
| 12 |
|
| 13 |
from .align import GopConfig, ctc_viterbi, edit_distance, greedy_decode, score_tokens
|
| 14 |
+
from .tips import VOWELS, sound_name, tip_for
|
| 15 |
from .translit import to_hebrew
|
| 16 |
|
| 17 |
log = logging.getLogger("ihki.scorer")
|
|
|
|
| 83 |
class ScorerConfig:
|
| 84 |
flag_below: float = 50.0 # phoneme score under which a consonant gets a tip
|
| 85 |
unrelated_ratio: float = 0.65 # normalised edit distance above which we say "different sentence"
|
| 86 |
+
issue_below: float = 90.0 # phoneme score under which it is listed as imprecise
|
| 87 |
+
max_issues: int = 8
|
| 88 |
gop: GopConfig = field(default_factory=GopConfig)
|
| 89 |
|
| 90 |
|
|
|
|
| 119 |
phonemes, tips, seen = [], [], set()
|
| 120 |
ar_words = ar.split()
|
| 121 |
tr_words = to_hebrew(ipa, ar)
|
| 122 |
+
for idx, (tok, sc) in enumerate(zip(toks, scores)):
|
| 123 |
heard_ph = "_" if sc.deleted else (self.vocab.i2t.get(sc.competitor) if sc.competitor is not None else None)
|
| 124 |
flagged = bool(sc.score < self.cfg.flag_below) and tok.phoneme not in VOWELS
|
| 125 |
phonemes.append({"ipa": tok.phoneme, "word": tok.word, "score": sc.score,
|
|
|
|
| 130 |
seen.add(tip["key"])
|
| 131 |
tip["word"] = ar_words[tok.word] if tok.word < len(ar_words) else ""
|
| 132 |
tip["word_tr"] = tr_words[tok.word]
|
| 133 |
+
tip["idx"] = idx
|
| 134 |
tips.append(tip)
|
| 135 |
|
| 136 |
n_words = len(ipa.split("|"))
|
|
|
|
| 147 |
overall = 0.5 * phone_mean + 0.5 * word_mean # one wrong consonant must pull the sentence down
|
| 148 |
if unrelated:
|
| 149 |
overall = min(overall, 30.0)
|
| 150 |
+
tips = tips[:4]
|
| 151 |
+
|
| 152 |
+
# Anything short of 100 must say why: every imprecise sound that did not get a full tip,
|
| 153 |
+
# weakest first. If nothing is clearly off, name the weakest few.
|
| 154 |
+
issues = []
|
| 155 |
+
if round(overall) < 100 and not unrelated:
|
| 156 |
+
tipped = {t.pop("idx") for t in tips}
|
| 157 |
+
rest = sorted((i for i in range(len(toks)) if i not in tipped and scores[i].score < 100),
|
| 158 |
+
key=lambda i: scores[i].score)
|
| 159 |
+
picked = [i for i in rest if scores[i].score < self.cfg.issue_below]
|
| 160 |
+
if not picked and not tips:
|
| 161 |
+
picked = rest[:3]
|
| 162 |
+
for i in picked[:self.cfg.max_issues]:
|
| 163 |
+
tok, sc = toks[i], scores[i]
|
| 164 |
+
comp = self.vocab.i2t.get(sc.competitor) if sc.competitor is not None else None
|
| 165 |
+
issues.append({"sound": sound_name(tok.phoneme) or tok.phoneme, "word_tr": tr_words[tok.word],
|
| 166 |
+
"score": round(sc.score), "missing": bool(sc.deleted),
|
| 167 |
+
"heard": None if sc.deleted else sound_name(comp)})
|
| 168 |
+
for t in tips:
|
| 169 |
+
t.pop("idx", None)
|
| 170 |
return {"score": round(overall), "variant": {"ar": ar, "tr": " ".join(tr_words), "ipa": ipa}, "heard_ipa": heard,
|
| 171 |
+
"unrelated": unrelated, "words": words, "tips": tips, "issues": issues}
|
| 172 |
|
| 173 |
|
| 174 |
# ---------- real model ----------
|
app/tips.py
CHANGED
|
@@ -56,6 +56,19 @@ def with_hebrew(text: str) -> str:
|
|
| 56 |
return _BARE_AR.sub(lambda m: _PAIRED.get(m.group(), m.group()), text)
|
| 57 |
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
VOWELS = {"a", "i", "u", "e", "o", "aː", "iː", "uː", "eː", "oː"}
|
| 60 |
# the urban glottal stop is often dropped by natives too; don't nag about it
|
| 61 |
NO_DELETION_TIP = {"ʔ"}
|
|
|
|
| 56 |
return _BARE_AR.sub(lambda m: _PAIRED.get(m.group(), m.group()), text)
|
| 57 |
|
| 58 |
|
| 59 |
+
VOWEL_NAMES = {
|
| 60 |
+
"a": "תנועת אַ", "i": "תנועת אִ", "u": "תנועת אֻ", "e": "תנועת אֶ", "o": "תנועת אוֹ",
|
| 61 |
+
"aː": "תנועה ארוכה אַא", "iː": "תנועה ארוכה אִי", "uː": "תנועה ארוכה אוּ",
|
| 62 |
+
"eː": "תנועה ארוכה אֵי", "oː": "תנועה ארוכה אוֹ",
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def sound_name(phoneme: str | None) -> str | None:
|
| 67 |
+
"""Hebrew name of a phoneme (expected or heard), or None if we have no name for it."""
|
| 68 |
+
phoneme = HEARD_ALIASES.get(phoneme, phoneme)
|
| 69 |
+
return NAMES.get(phoneme) or VOWEL_NAMES.get(phoneme)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
VOWELS = {"a", "i", "u", "e", "o", "aː", "iː", "uː", "eː", "oː"}
|
| 73 |
# the urban glottal stop is often dropped by natives too; don't nag about it
|
| 74 |
NO_DELETION_TIP = {"ʔ"}
|
static/index.html
CHANGED
|
@@ -181,7 +181,14 @@ function render(r){
|
|
| 181 |
const body=document.createElement("span"); body.textContent=t.text;
|
| 182 |
li.append(what,body); tips.appendChild(li);
|
| 183 |
}
|
| 184 |
-
if(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
const li=document.createElement("li"); li.innerHTML='<span class="what">כל העיצורים נשמעו נכון</span><span>נסו לומר את המשפט בקצב דיבור טבעי.</span>';
|
| 186 |
tips.appendChild(li);
|
| 187 |
}
|
|
|
|
| 181 |
const body=document.createElement("span"); body.textContent=t.text;
|
| 182 |
li.append(what,body); tips.appendChild(li);
|
| 183 |
}
|
| 184 |
+
if(r.issues.length){
|
| 185 |
+
const li=document.createElement("li");
|
| 186 |
+
const what=document.createElement("span"); what.className="what"; what.textContent=r.tips.length?"עוד הגאים לא מדויקים":"מה לא היה מדויק";
|
| 187 |
+
const body=document.createElement("span");
|
| 188 |
+
body.textContent=r.issues.map(i=>`${i.sound} ב״${i.word_tr}״ (${i.score})`+(i.missing?" – לא נשמעה":i.heard?` – נשמע כמו ${i.heard}`:"")).join(" · ");
|
| 189 |
+
li.append(what,body); tips.appendChild(li);
|
| 190 |
+
}
|
| 191 |
+
if(!r.unrelated && !r.tips.length && !r.issues.length){
|
| 192 |
const li=document.createElement("li"); li.innerHTML='<span class="what">כל העיצורים נשמעו נכון</span><span>נסו לומר את המשפט בקצב דיבור טבעי.</span>';
|
| 193 |
tips.appendChild(li);
|
| 194 |
}
|
tests/test_scoring.py
CHANGED
|
@@ -134,3 +134,13 @@ def test_api_end_to_end_with_stub_model():
|
|
| 134 |
r = c.post("/api/score", data={"phrase_id": pid}, files={"audio": ("a.wav", buf.getvalue(), "audio/wav")})
|
| 135 |
assert r.status_code == 200, r.text
|
| 136 |
assert r.json()["tips"][0]["key"] == "ħ>x"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
r = c.post("/api/score", data={"phrase_id": pid}, files={"audio": ("a.wav", buf.getvalue(), "audio/wav")})
|
| 135 |
assert r.status_code == 200, r.text
|
| 136 |
assert r.json()["tips"][0]["key"] == "ħ>x"
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_anything_below_100_says_what_was_wrong():
|
| 140 |
+
for phones in (["w", "eː", "n", "i", "l", "ħ", "a", "m", "aː", "m"], # clean
|
| 141 |
+
["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"], # wrong consonant
|
| 142 |
+
["w", "e", "n", "i", "l", "ħ", "a", "m", "a", "m"]): # short vowels
|
| 143 |
+
r = scorer_for(emissions(phones)).score(None, TOILET)
|
| 144 |
+
assert r["score"] == 100 or r["unrelated"] or r["tips"] or r["issues"], phones
|
| 145 |
+
for i in r["issues"]:
|
| 146 |
+
assert i["word_tr"] and i["sound"] and not i["sound"].isascii()
|