feat: add uncertainty handling in scoring and trace schema for improved feedback
Browse files- app/scorer.py +15 -5
- docs/trace-schema.ts +5 -0
- static/index.html +12 -7
- tests/test_scoring.py +21 -0
app/scorer.py
CHANGED
|
@@ -198,6 +198,7 @@ class Reference:
|
|
| 198 |
class ScorerConfig:
|
| 199 |
flag_below: float = 50.0 # phoneme score under which a consonant gets a tip
|
| 200 |
unrelated_ratio: float = 0.65 # normalised edit distance above which we say "different sentence"
|
|
|
|
| 201 |
issue_below: float = 90.0 # phoneme score under which it is listed as imprecise
|
| 202 |
max_issues: int = 8
|
| 203 |
max_tips: int | None = None # None: as many as the rules document says (scoring.max_tips)
|
|
@@ -581,6 +582,8 @@ class Scorer:
|
|
| 581 |
ar, ipa, toks, al, scores, unrelated = fit.ar, fit.ipa, fit.toks, fit.al, fit.scores, fit.unrelated
|
| 582 |
i2t = self.vocab.i2t
|
| 583 |
heard = " ".join(i2t[i] for i in fit.heard_ids)
|
|
|
|
|
|
|
| 584 |
# Measure against what the model gives a native on the same sound: a sound it misses in the
|
| 585 |
# reference voice too (ʕ in على heard as dropped) is the model's weakness, not the learner's.
|
| 586 |
heatmap = self._heatmap(fit.logp, toks, al, scores, duration_s) if diagnostics else None
|
|
@@ -617,7 +620,7 @@ class Scorer:
|
|
| 617 |
heard_ph = "_" if sc.deleted else (i2t.get(sc.competitor) if sc.competitor is not None else None)
|
| 618 |
check = self._check(tok, heard_ph)
|
| 619 |
beyond.append(self._beyond_margin(tok, sc, heard_ph))
|
| 620 |
-
flagged = (bool(sc.score < self.cfg.flag_below) and beyond[-1]
|
| 621 |
and (tok.phoneme not in VOWELS or self._flag_vowel(toks, idx)))
|
| 622 |
if idx in fit.inserted: # a vowel before this consonant (3.10): the reason its letter gives
|
| 623 |
spec = self.rules.insertion
|
|
@@ -644,7 +647,7 @@ class Scorer:
|
|
| 644 |
tip["idx"] = idx
|
| 645 |
tip["score"] = round(sc.score)
|
| 646 |
tips.append(tip)
|
| 647 |
-
if idx in fit.inserted and not unrelated and "cluster" not in seen:
|
| 648 |
seen.add("cluster")
|
| 649 |
spec = self.rules.insertion
|
| 650 |
tips.append({"title": self.rules.titles.get(spec["rule"], spec["rule"]), "text": str(spec["feedback"]),
|
|
@@ -665,7 +668,7 @@ class Scorer:
|
|
| 665 |
got = [i for j in at for i in carriers[w][j] if 0 <= i < len(phonemes)]
|
| 666 |
worst = min(got, key=lambda i: phonemes[i]["score"]) if got else None
|
| 667 |
letter = {"t": text, "score": phonemes[worst]["score"] if got else None}
|
| 668 |
-
if got and letter["score"] < LETTER_GOOD and not unrelated:
|
| 669 |
letter.update(letter_reason(toks[worst].sound, heard_as[worst], checks[worst]))
|
| 670 |
letters.append(letter)
|
| 671 |
if w in forgiven and min(ws) >= self.cfg.issue_below:
|
|
@@ -689,7 +692,7 @@ class Scorer:
|
|
| 689 |
# Anything short of 100 must say why: every imprecise sound that did not get a full tip,
|
| 690 |
# weakest first. If nothing is clearly off, name the weakest few.
|
| 691 |
issues = []
|
| 692 |
-
if round(overall) < 100 and not unrelated:
|
| 693 |
tipped = {t.pop("idx") for t in tips}
|
| 694 |
rest = sorted((i for i in range(len(toks)) if i not in tipped and scores[i].score < 100),
|
| 695 |
key=lambda i: scores[i].score)
|
|
@@ -711,8 +714,11 @@ class Scorer:
|
|
| 711 |
for t in tips:
|
| 712 |
t.pop("idx", None)
|
| 713 |
result = {"score": round(overall), "variant": {"ar": ar, "tr": " ".join(tr_words), "ipa": ipa}, "heard_ipa": heard, "heard_tr": " ".join(h for h in heard_words if h),
|
| 714 |
-
"unrelated": unrelated, "
|
|
|
|
| 715 |
**self._dialect(toks, scores)}
|
|
|
|
|
|
|
| 716 |
if calibrated:
|
| 717 |
result["calibrated"] = calibrated
|
| 718 |
if heatmap is not None:
|
|
@@ -785,6 +791,7 @@ class Scorer:
|
|
| 785 |
"deleted_comp": round(float(np.exp(g.deleted_comp)), 3),
|
| 786 |
"flag_below": cfg.flag_below, "issue_below": cfg.issue_below, "near_vowel_floor": cfg.near_vowel_floor,
|
| 787 |
"word_min_weight": cfg.word_min_weight, "vowel_weight": 0.5, "unrelated_ratio": cfg.unrelated_ratio,
|
|
|
|
| 788 |
"unrelated_cap": 30.0, "letter_good": LETTER_GOOD, "max_tips": max_tips, "max_issues": cfg.max_issues,
|
| 789 |
"ref_min_mean": cfg.ref_min_mean, "margin_default": float(self.rules.scoring.get("margin_default", 0.0)),
|
| 790 |
"ayin_deleted_floor": cfg.ayin_deleted_floor,
|
|
@@ -804,6 +811,9 @@ class Scorer:
|
|
| 804 |
"aipa": fit.aipa,
|
| 805 |
"unrelated": {"heard": heard, "distance": fit.distance, "tokens": n,
|
| 806 |
"ratio": round(fit.distance / max(1, n), 3), "threshold": cfg.unrelated_ratio,
|
|
|
|
|
|
|
|
|
|
| 807 |
"unrelated": fit.unrelated},
|
| 808 |
"references": [{"mean": round(float(np.mean(r.scores)), 1), "sounds": len(r.sounds)} for r in refs],
|
| 809 |
"tokens": tokens,
|
|
|
|
| 198 |
class ScorerConfig:
|
| 199 |
flag_below: float = 50.0 # phoneme score under which a consonant gets a tip
|
| 200 |
unrelated_ratio: float = 0.65 # normalised edit distance above which we say "different sentence"
|
| 201 |
+
feedback_uncertain_margin: float = 0.15 # below unrelated_ratio: score, but make no named sound claims
|
| 202 |
issue_below: float = 90.0 # phoneme score under which it is listed as imprecise
|
| 203 |
max_issues: int = 8
|
| 204 |
max_tips: int | None = None # None: as many as the rules document says (scoring.max_tips)
|
|
|
|
| 582 |
ar, ipa, toks, al, scores, unrelated = fit.ar, fit.ipa, fit.toks, fit.al, fit.scores, fit.unrelated
|
| 583 |
i2t = self.vocab.i2t
|
| 584 |
heard = " ".join(i2t[i] for i in fit.heard_ids)
|
| 585 |
+
fit_ratio = fit.distance / max(1, len(toks))
|
| 586 |
+
uncertain = bool(not unrelated and fit_ratio >= self.cfg.unrelated_ratio - self.cfg.feedback_uncertain_margin)
|
| 587 |
# Measure against what the model gives a native on the same sound: a sound it misses in the
|
| 588 |
# reference voice too (ʕ in على heard as dropped) is the model's weakness, not the learner's.
|
| 589 |
heatmap = self._heatmap(fit.logp, toks, al, scores, duration_s) if diagnostics else None
|
|
|
|
| 620 |
heard_ph = "_" if sc.deleted else (i2t.get(sc.competitor) if sc.competitor is not None else None)
|
| 621 |
check = self._check(tok, heard_ph)
|
| 622 |
beyond.append(self._beyond_margin(tok, sc, heard_ph))
|
| 623 |
+
flagged = (not uncertain and bool(sc.score < self.cfg.flag_below) and beyond[-1]
|
| 624 |
and (tok.phoneme not in VOWELS or self._flag_vowel(toks, idx)))
|
| 625 |
if idx in fit.inserted: # a vowel before this consonant (3.10): the reason its letter gives
|
| 626 |
spec = self.rules.insertion
|
|
|
|
| 647 |
tip["idx"] = idx
|
| 648 |
tip["score"] = round(sc.score)
|
| 649 |
tips.append(tip)
|
| 650 |
+
if idx in fit.inserted and not unrelated and not uncertain and "cluster" not in seen:
|
| 651 |
seen.add("cluster")
|
| 652 |
spec = self.rules.insertion
|
| 653 |
tips.append({"title": self.rules.titles.get(spec["rule"], spec["rule"]), "text": str(spec["feedback"]),
|
|
|
|
| 668 |
got = [i for j in at for i in carriers[w][j] if 0 <= i < len(phonemes)]
|
| 669 |
worst = min(got, key=lambda i: phonemes[i]["score"]) if got else None
|
| 670 |
letter = {"t": text, "score": phonemes[worst]["score"] if got else None}
|
| 671 |
+
if got and letter["score"] < LETTER_GOOD and not unrelated and not uncertain:
|
| 672 |
letter.update(letter_reason(toks[worst].sound, heard_as[worst], checks[worst]))
|
| 673 |
letters.append(letter)
|
| 674 |
if w in forgiven and min(ws) >= self.cfg.issue_below:
|
|
|
|
| 692 |
# Anything short of 100 must say why: every imprecise sound that did not get a full tip,
|
| 693 |
# weakest first. If nothing is clearly off, name the weakest few.
|
| 694 |
issues = []
|
| 695 |
+
if round(overall) < 100 and not unrelated and not uncertain:
|
| 696 |
tipped = {t.pop("idx") for t in tips}
|
| 697 |
rest = sorted((i for i in range(len(toks)) if i not in tipped and scores[i].score < 100),
|
| 698 |
key=lambda i: scores[i].score)
|
|
|
|
| 714 |
for t in tips:
|
| 715 |
t.pop("idx", None)
|
| 716 |
result = {"score": round(overall), "variant": {"ar": ar, "tr": " ".join(tr_words), "ipa": ipa}, "heard_ipa": heard, "heard_tr": " ".join(h for h in heard_words if h),
|
| 717 |
+
"unrelated": unrelated, "uncertain": uncertain,
|
| 718 |
+
"words": words, "tips": tips, "issues": issues, "references": len(refs),
|
| 719 |
**self._dialect(toks, scores)}
|
| 720 |
+
if uncertain:
|
| 721 |
+
result["uncertainty_message"] = "ההקלטה זוהתה רק באופן חלקי, ולכן אי אפשר לקבוע אילו הגאים נאמרו אחרת. נסו שוב."
|
| 722 |
if calibrated:
|
| 723 |
result["calibrated"] = calibrated
|
| 724 |
if heatmap is not None:
|
|
|
|
| 791 |
"deleted_comp": round(float(np.exp(g.deleted_comp)), 3),
|
| 792 |
"flag_below": cfg.flag_below, "issue_below": cfg.issue_below, "near_vowel_floor": cfg.near_vowel_floor,
|
| 793 |
"word_min_weight": cfg.word_min_weight, "vowel_weight": 0.5, "unrelated_ratio": cfg.unrelated_ratio,
|
| 794 |
+
"feedback_uncertain_ratio": round(cfg.unrelated_ratio - cfg.feedback_uncertain_margin, 3),
|
| 795 |
"unrelated_cap": 30.0, "letter_good": LETTER_GOOD, "max_tips": max_tips, "max_issues": cfg.max_issues,
|
| 796 |
"ref_min_mean": cfg.ref_min_mean, "margin_default": float(self.rules.scoring.get("margin_default", 0.0)),
|
| 797 |
"ayin_deleted_floor": cfg.ayin_deleted_floor,
|
|
|
|
| 811 |
"aipa": fit.aipa,
|
| 812 |
"unrelated": {"heard": heard, "distance": fit.distance, "tokens": n,
|
| 813 |
"ratio": round(fit.distance / max(1, n), 3), "threshold": cfg.unrelated_ratio,
|
| 814 |
+
"uncertain_threshold": round(cfg.unrelated_ratio - cfg.feedback_uncertain_margin, 3),
|
| 815 |
+
"uncertain": (not fit.unrelated and fit.distance / max(1, n) >=
|
| 816 |
+
cfg.unrelated_ratio - cfg.feedback_uncertain_margin),
|
| 817 |
"unrelated": fit.unrelated},
|
| 818 |
"references": [{"mean": round(float(np.mean(r.scores)), 1), "sounds": len(r.sounds)} for r in refs],
|
| 819 |
"tokens": tokens,
|
docs/trace-schema.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface ScoreResult {
|
|
| 22 |
heard_ipa: string; // greedy decode: what the model heard, free of the expected text
|
| 23 |
heard_tr: string; // the same in Hebrew letters, cut at the aligned word starts
|
| 24 |
unrelated: boolean; // a different sentence (TraceUnrelated): score capped, no tips
|
|
|
|
|
|
|
| 25 |
words: Word[];
|
| 26 |
tips: Tip[]; // the most crucial mistakes, severity first (at most config.max_tips)
|
| 27 |
issues: Issue[]; // the rest of the imprecise sounds
|
|
@@ -95,6 +97,7 @@ export interface TraceConfig {
|
|
| 95 |
word_min_weight: number;
|
| 96 |
vowel_weight: number; // a vowel's weight in the sentence's phone mean
|
| 97 |
unrelated_ratio: number;
|
|
|
|
| 98 |
unrelated_cap: number;
|
| 99 |
letter_good: number;
|
| 100 |
max_tips: number;
|
|
@@ -128,6 +131,8 @@ export interface TraceUnrelated {
|
|
| 128 |
tokens: number;
|
| 129 |
ratio: number; // distance / tokens
|
| 130 |
threshold: number; // over this: a different sentence
|
|
|
|
|
|
|
| 131 |
unrelated: boolean;
|
| 132 |
}
|
| 133 |
|
|
|
|
| 22 |
heard_ipa: string; // greedy decode: what the model heard, free of the expected text
|
| 23 |
heard_tr: string; // the same in Hebrew letters, cut at the aligned word starts
|
| 24 |
unrelated: boolean; // a different sentence (TraceUnrelated): score capped, no tips
|
| 25 |
+
uncertain: boolean; // marginal target fit: score retained, categorical sound feedback suppressed
|
| 26 |
+
uncertainty_message?: string;
|
| 27 |
words: Word[];
|
| 28 |
tips: Tip[]; // the most crucial mistakes, severity first (at most config.max_tips)
|
| 29 |
issues: Issue[]; // the rest of the imprecise sounds
|
|
|
|
| 97 |
word_min_weight: number;
|
| 98 |
vowel_weight: number; // a vowel's weight in the sentence's phone mean
|
| 99 |
unrelated_ratio: number;
|
| 100 |
+
feedback_uncertain_ratio: number;
|
| 101 |
unrelated_cap: number;
|
| 102 |
letter_good: number;
|
| 103 |
max_tips: number;
|
|
|
|
| 131 |
tokens: number;
|
| 132 |
ratio: number; // distance / tokens
|
| 133 |
threshold: number; // over this: a different sentence
|
| 134 |
+
uncertain_threshold: number; // at/over this: suppress categorical phoneme feedback
|
| 135 |
+
uncertain: boolean;
|
| 136 |
unrelated: boolean;
|
| 137 |
}
|
| 138 |
|
static/index.html
CHANGED
|
@@ -3170,7 +3170,7 @@ function drawStory(){
|
|
| 3170 |
}else if(b!=null) tools.append(pill(b,"הציון הטוב ביותר"));
|
| 3171 |
if(b!=null||r){ // a line that was said shows its Arabic
|
| 3172 |
const tr=document.createElement("span"); tr.className="tr"; row.classList.add("said");
|
| 3173 |
-
if(r&&!(r.score>STORY.pass&&!r.unrelated)&&r.words.length&&r.words.every(w=>w.letters)){
|
| 3174 |
tr.classList.add("lit");
|
| 3175 |
r.words.forEach((w,k)=>{ if(k) tr.append(" "); litWord(tr,w); });
|
| 3176 |
}else setTr(tr,p.tr);
|
|
@@ -3758,7 +3758,7 @@ function render(r){
|
|
| 3758 |
$("score").textContent=r.score;
|
| 3759 |
const pill=$("pill"); const s=r.score;
|
| 3760 |
pill.className="pill "+(s>=85?"good":s>=60?"mid":"bad");
|
| 3761 |
-
pill.textContent= r.unrelated ? "משפט אחר?" : s>=85?"מצוין": s>=60?"כמעט":"נסו שוב";
|
| 3762 |
|
| 3763 |
$("words").hidden=true;
|
| 3764 |
drawAnalyzedAnswer(r);
|
|
@@ -3769,6 +3769,10 @@ function render(r){
|
|
| 3769 |
const li=document.createElement("li");
|
| 3770 |
li.innerHTML='<span class="what">ההקלטה לא דומה למשפט הצפוי</span><span>ייתכן שבחרתם ניסוח אחר. גלו את מילות התשובה ונסו את הנוסח המוצע.</span>';
|
| 3771 |
tips.appendChild(li);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3772 |
}
|
| 3773 |
for(const t of r.tips){
|
| 3774 |
const li=document.createElement("li");
|
|
@@ -3785,7 +3789,7 @@ function render(r){
|
|
| 3785 |
setTr(body,r.issues.map(i=>`${i.sound} ב״${i.word_tr}״ (${i.score})`+(i.missing?" – לא נשמעה":i.heard?` – נשמע כמו ${i.heard}`:"")).join(" · "));
|
| 3786 |
li.append(what,body); tips.appendChild(li);
|
| 3787 |
}
|
| 3788 |
-
if(!r.unrelated && !r.tips.length && !r.issues.length){
|
| 3789 |
const li=document.createElement("li"); li.innerHTML='<span class="what">כל העיצורים נשמעו נכון</span><span>נסו לומר את המשפט בקצב דיבור טבעי.</span>';
|
| 3790 |
tips.appendChild(li);
|
| 3791 |
}
|
|
@@ -3794,9 +3798,9 @@ function render(r){
|
|
| 3794 |
keepScore(cur.id,s); if(daily) dailySaid.add(cur.id); // only an empty recording is not practice
|
| 3795 |
const story=!driving&&!$("story").hidden;
|
| 3796 |
if(story){ storyLast[cur.id]=r; drawStory(); } // the line was said: its grade and its Arabic appear on it
|
| 3797 |
-
if(story&&!(!r.unrelated&&s>STORY.pass)) storyRetry();
|
| 3798 |
if(bubbleCat()){ bubbleOutcome(r); return; }
|
| 3799 |
-
if(!driving&&!r.unrelated&&s>=AUTO_NEXT.minimum){ // driving mode moves on by itself, in its own loop
|
| 3800 |
$("status").textContent=`ציון ${s} ✓ עוברים למשפט הבא`;
|
| 3801 |
startAutoNext();
|
| 3802 |
}
|
|
@@ -3817,7 +3821,7 @@ function waText(r,passed){
|
|
| 3817 |
waMsg=el; $("bubbleFoot").prepend(el);
|
| 3818 |
}
|
| 3819 |
async function bubbleOutcome(r,expired=false){
|
| 3820 |
-
const id=cur.id, correct=!r.unrelated&&r.score>=BUBBLE_PASS;
|
| 3821 |
if(bubbleResolving) return;
|
| 3822 |
bubbleResolving=true;
|
| 3823 |
if(!expired){
|
|
@@ -3959,7 +3963,8 @@ function drawAnalyzedAnswer(r){
|
|
| 3959 |
const pair=mark("analysis-word","","span"), expected=mark("expected","","span");
|
| 3960 |
if(word.letters&&word.letters.length) litWord(expected,word);
|
| 3961 |
else{ expected.classList.add(band(word.score)); setTr(expected,word.tr); }
|
| 3962 |
-
const
|
|
|
|
| 3963 |
pair.append(expected,heard); main.append(pair);
|
| 3964 |
});
|
| 3965 |
card.replaceChildren(mark("analysis-key","למעלה: התשובה · למטה: המערכת שמעה","small"),main);
|
|
|
|
| 3170 |
}else if(b!=null) tools.append(pill(b,"הציון הטוב ביותר"));
|
| 3171 |
if(b!=null||r){ // a line that was said shows its Arabic
|
| 3172 |
const tr=document.createElement("span"); tr.className="tr"; row.classList.add("said");
|
| 3173 |
+
if(r&&!(r.score>STORY.pass&&!r.unrelated&&!r.uncertain)&&r.words.length&&r.words.every(w=>w.letters)){
|
| 3174 |
tr.classList.add("lit");
|
| 3175 |
r.words.forEach((w,k)=>{ if(k) tr.append(" "); litWord(tr,w); });
|
| 3176 |
}else setTr(tr,p.tr);
|
|
|
|
| 3758 |
$("score").textContent=r.score;
|
| 3759 |
const pill=$("pill"); const s=r.score;
|
| 3760 |
pill.className="pill "+(s>=85?"good":s>=60?"mid":"bad");
|
| 3761 |
+
pill.textContent= r.unrelated ? "משפט אחר?" : r.uncertain ? "לא ברור" : s>=85?"מצוין": s>=60?"כמעט":"נסו שוב";
|
| 3762 |
|
| 3763 |
$("words").hidden=true;
|
| 3764 |
drawAnalyzedAnswer(r);
|
|
|
|
| 3769 |
const li=document.createElement("li");
|
| 3770 |
li.innerHTML='<span class="what">ההקלטה לא דומה למשפט הצפוי</span><span>ייתכן שבחרתם ניסוח אחר. גלו את מילות התשובה ונסו את הנוסח המוצע.</span>';
|
| 3771 |
tips.appendChild(li);
|
| 3772 |
+
}else if(r.uncertain){
|
| 3773 |
+
const li=document.createElement("li");
|
| 3774 |
+
li.innerHTML=`<span class="what">הזיהוי לא היה מספיק ברור</span><span>${r.uncertainty_message||"אי אפשר לקבוע אילו הגאים נאמרו אחרת. נסו שוב."}</span>`;
|
| 3775 |
+
tips.appendChild(li);
|
| 3776 |
}
|
| 3777 |
for(const t of r.tips){
|
| 3778 |
const li=document.createElement("li");
|
|
|
|
| 3789 |
setTr(body,r.issues.map(i=>`${i.sound} ב״${i.word_tr}״ (${i.score})`+(i.missing?" – לא נשמעה":i.heard?` – נשמע כמו ${i.heard}`:"")).join(" · "));
|
| 3790 |
li.append(what,body); tips.appendChild(li);
|
| 3791 |
}
|
| 3792 |
+
if(!r.unrelated && !r.uncertain && !r.tips.length && !r.issues.length){
|
| 3793 |
const li=document.createElement("li"); li.innerHTML='<span class="what">כל העיצורים נשמעו נכון</span><span>נסו לומר את המשפט בקצב דיבור טבעי.</span>';
|
| 3794 |
tips.appendChild(li);
|
| 3795 |
}
|
|
|
|
| 3798 |
keepScore(cur.id,s); if(daily) dailySaid.add(cur.id); // only an empty recording is not practice
|
| 3799 |
const story=!driving&&!$("story").hidden;
|
| 3800 |
if(story){ storyLast[cur.id]=r; drawStory(); } // the line was said: its grade and its Arabic appear on it
|
| 3801 |
+
if(story&&!(!r.unrelated&&!r.uncertain&&s>STORY.pass)) storyRetry();
|
| 3802 |
if(bubbleCat()){ bubbleOutcome(r); return; }
|
| 3803 |
+
if(!driving&&!r.unrelated&&!r.uncertain&&s>=AUTO_NEXT.minimum){ // driving mode moves on by itself, in its own loop
|
| 3804 |
$("status").textContent=`ציון ${s} ✓ עוברים למשפט הבא`;
|
| 3805 |
startAutoNext();
|
| 3806 |
}
|
|
|
|
| 3821 |
waMsg=el; $("bubbleFoot").prepend(el);
|
| 3822 |
}
|
| 3823 |
async function bubbleOutcome(r,expired=false){
|
| 3824 |
+
const id=cur.id, correct=!r.unrelated&&!r.uncertain&&r.score>=BUBBLE_PASS;
|
| 3825 |
if(bubbleResolving) return;
|
| 3826 |
bubbleResolving=true;
|
| 3827 |
if(!expired){
|
|
|
|
| 3963 |
const pair=mark("analysis-word","","span"), expected=mark("expected","","span");
|
| 3964 |
if(word.letters&&word.letters.length) litWord(expected,word);
|
| 3965 |
else{ expected.classList.add(band(word.score)); setTr(expected,word.tr); }
|
| 3966 |
+
const heardText=r.uncertain?"לא זוהה בביטחון":(word.heard_tr||"לא זוהה");
|
| 3967 |
+
const heard=mark(`heard-word${word.heard_tr&&!r.uncertain?"":" empty"}`,"","span"); setTr(heard,heardText);
|
| 3968 |
pair.append(expected,heard); main.append(pair);
|
| 3969 |
});
|
| 3970 |
card.replaceChildren(mark("analysis-key","למעלה: התשובה · למטה: המערכת שמעה","small"),main);
|
tests/test_scoring.py
CHANGED
|
@@ -311,6 +311,27 @@ def test_unrelated_sentence():
|
|
| 311 |
assert r["unrelated"] and r["score"] <= 30
|
| 312 |
|
| 313 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
@pytest.mark.parametrize("p", PHRASES, ids=lambda p: p["he"])
|
| 315 |
def test_phrase_bank_word_counts_match(p):
|
| 316 |
for ar, ipa in p["variants"]:
|
|
|
|
| 311 |
assert r["unrelated"] and r["score"] <= 30
|
| 312 |
|
| 313 |
|
| 314 |
+
def test_marginal_sentence_fit_suppresses_categorical_sound_feedback():
|
| 315 |
+
# Half of the target phones differ: still score the attempt, but do not tell the learner they said specific sounds.
|
| 316 |
+
phrase = {"variants": [("تتتتت", "t a t a t a t a t a")]}
|
| 317 |
+
phones = ["d", "a", "d", "a", "d", "a", "d", "a", "d", "a"]
|
| 318 |
+
r = custom_scorer(["t"], phones).score(None, phrase, diagnostics=True)
|
| 319 |
+
assert not r["unrelated"] and r["uncertain"]
|
| 320 |
+
assert r["tips"] == [] and r["issues"] == []
|
| 321 |
+
assert not any(p["flagged"] or p["heard"] for w in r["words"] for p in w["phonemes"])
|
| 322 |
+
assert r["uncertainty_message"]
|
| 323 |
+
assert r["trace"]["unrelated"]["uncertain"]
|
| 324 |
+
assert r["trace"]["unrelated"]["ratio"] == 0.5
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def test_clear_sentence_fit_still_reports_a_real_substitution():
|
| 328 |
+
phrase = {"variants": [("تتتتت", "t a t a t a t a t a")]}
|
| 329 |
+
phones = ["d", "a", "t", "a", "t", "a", "t", "a", "t", "a"]
|
| 330 |
+
r = custom_scorer(["t"], phones).score(None, phrase)
|
| 331 |
+
assert not r["unrelated"] and not r["uncertain"]
|
| 332 |
+
assert r["tips"][0]["key"] == "t>d"
|
| 333 |
+
|
| 334 |
+
|
| 335 |
@pytest.mark.parametrize("p", PHRASES, ids=lambda p: p["he"])
|
| 336 |
def test_phrase_bank_word_counts_match(p):
|
| 337 |
for ar, ipa in p["variants"]:
|