3v324v23 commited on
Commit
de5c6dc
·
1 Parent(s): 3bc129f

Add issue reporting for imprecise phonemes and enhance scoring feedback

Browse files
Files changed (4) hide show
  1. app/scorer.py +26 -3
  2. app/tips.py +13 -0
  3. static/index.html +8 -1
  4. 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[:4]}
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(!r.unrelated && !r.tips.length && s>=85){
 
 
 
 
 
 
 
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()