3v324v23 commited on
Commit
77ea3c2
·
1 Parent(s): 35394c4

feat: add competitor_frames to scoring and trace schema for improved diagnostics

Browse files
app/scorer.py CHANGED
@@ -763,6 +763,7 @@ class Scorer:
763
  "span": list(fit.al.spans[i]), "window": list(gsc.window), "curves": curves,
764
  "p_target": p(gsc.peak), "competitor": heard_ph if not gsc.deleted else None,
765
  "competitor_other": gsc.other, "p_competitor": p(gsc.comp_peak),
 
766
  "gop": gsc.gop, "confidence": round(confidence, 3),
767
  "gop_factor": round(float(np.exp(gsc.gop * g.leniency)), 3),
768
  "confidence_factor": round(float(np.sqrt(confidence)), 3),
 
763
  "span": list(fit.al.spans[i]), "window": list(gsc.window), "curves": curves,
764
  "p_target": p(gsc.peak), "competitor": heard_ph if not gsc.deleted else None,
765
  "competitor_other": gsc.other, "p_competitor": p(gsc.comp_peak),
766
+ "competitor_frames": gsc.comp_frames,
767
  "gop": gsc.gop, "confidence": round(confidence, 3),
768
  "gop_factor": round(float(np.exp(gsc.gop * g.leniency)), 3),
769
  "confidence_factor": round(float(np.sqrt(confidence)), 3),
docs/trace-schema.ts CHANGED
@@ -153,6 +153,7 @@ export interface TraceToken {
153
  competitor: Ipa | null; // strongest other sound, neighbours and blank excluded (null: none, or unnamed)
154
  competitor_other: boolean; // the strongest is a sound outside Arabic and Hebrew
155
  p_competitor: number; // its peak posterior in the window
 
156
  gop: number; // ln(p_target / max(p_target, p_competitor)) ≤ 0
157
  confidence: number;
158
  gop_factor: number;
 
153
  competitor: Ipa | null; // strongest other sound, neighbours and blank excluded (null: none, or unnamed)
154
  competitor_other: boolean; // the strongest is a sound outside Arabic and Hebrew
155
  p_competitor: number; // its peak posterior in the window
156
+ competitor_frames: number; // frames where it clears deleted_comp; one frame may be an onset transient
157
  gop: number; // ln(p_target / max(p_target, p_competitor)) ≤ 0
158
  confidence: number;
159
  gop_factor: number;
static/index.html CHANGED
@@ -752,6 +752,7 @@ body.manage #manage{width:100%;max-width:none}
752
  .activity-head select{font:inherit;padding:7px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink)}
753
  .activity-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.activity-stat{padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--bg)}.activity-stat b{display:block;font:400 28px/1 var(--f-display);font-variant-numeric:tabular-nums}.activity-stat small{color:var(--muted);font-size:12px}
754
  .activity-chart{width:100%;height:190px;overflow:visible}.activity-chart .grid{stroke:var(--line);stroke-width:1}.activity-chart .bar{fill:var(--olive);opacity:.82}.activity-chart .bar:hover{opacity:1}.activity-chart text{fill:var(--muted);font:11px var(--f-body)}
 
755
  .system-activity summary{cursor:pointer}.system-activity summary h2{display:inline;margin:0}.system-activity details[open]>summary{margin-bottom:8px}
756
  .system-hard{margin:2px 0 4px;padding:10px;border-radius:10px;background:var(--bg)}.system-hard h3{margin:0 0 6px;font:700 15px/1.3 var(--f-body)}.system-hard-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;padding:6px 0;border-bottom:1px solid var(--line)}.system-hard-row:last-child{border-bottom:0}.system-hard-row span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.system-hard-row small{color:var(--muted);white-space:nowrap}.system-feed{display:flex;flex-direction:column;gap:14px}.system-topic{border-top:1px solid var(--line);padding-top:10px}.system-topic:first-child{border-top:0;padding-top:0}.system-topic h3{margin:0 0 3px;font:700 15px/1.3 var(--f-body)}.system-topic h3 small{color:var(--muted);font-weight:600}.system-event{display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center;padding:8px 0;border-bottom:1px solid var(--line)}.system-event:last-child{border-bottom:0}.system-event i{display:grid;place-items:center;width:34px;height:34px;border-radius:50%;background:var(--olive-soft);font-style:normal}.system-event b,.system-event small{display:block}.system-event small,.system-event time{color:var(--muted);font-size:12px}.system-event time{direction:ltr;white-space:nowrap}
757
  /* the API keys card (developers only): a row per key, where it comes from and a field for a new one; a key is never shown */
@@ -4469,60 +4470,70 @@ function drawFlags(){
4469
  }
4470
  card.append(table); box.append(card);
4471
  }
4472
- if(!FLAG_REVIEWS.pending.length){ box.append(mark("card","אין כרגע דיווחים שממתינים לבדיקה.")); return; }
4473
- for(const f of FLAG_REVIEWS.pending){
4474
- const card=mark("card flag-review",""), title=mark("",f.he||f.phrase,"h2"), result=f.result||{}, issues=flagIssues(f);
4475
- const ar=mark("",f.ar||"","p"); ar.dir="rtl";
4476
- card.append(title,ar,mark("meta",`ציון ${f.score} · דווח על ידי ${f.email} · המודל שמע: ${f.heard_ipa||"–"}`,"p"));
4477
- if(f.note) card.append(mark("",`הערת המדווח/ת: ${f.note}`,"p"));
4478
- if(f.audio){
4479
- const audioRow=mark("row",""), play=mark("btn","▶ השמעת ההקלטה","button"), audio=document.createElement("audio"), audioMsg=mark("msg","");
4480
- audio.controls=true; audio.preload="none"; audio.hidden=true;
4481
- play.onclick=async()=>{
4482
- play.disabled=true; audioMsg.textContent="טוען…";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4483
  try{
4484
- const res=await api(`/api/flags/${f.id}/audio`), data=res.ok?await res.blob():null;
4485
- if(!res.ok) throw new Error((await res.json().catch(()=>({}))).detail||res.status);
4486
- const url=URL.createObjectURL(data); flagAudioUrls.push(url); audio.src=url; audio.hidden=false; play.hidden=true; audioMsg.textContent="";
4487
- audio.play().catch(()=>{});
4488
- }catch(err){ audioMsg.textContent="ההשמעה נכשלה: "+err.message; play.disabled=false; }
4489
  };
4490
- audioRow.append(play,audio,audioMsg); card.append(audioRow);
4491
- }else card.append(mark("meta","ההקלטה לא נשמרה ע�� הדיווח הזה.","p"));
4492
- const field=document.createElement("fieldset"); field.append(mark("","אילו אבחנות של המערכת היו שגויות?","legend"));
4493
- for(const issue of issues){ const label=document.createElement("label"), input=document.createElement("input"); input.type="checkbox"; input.checked=true; input._issue=issue; label.append(input,issue.label); field.append(label); }
4494
- if(issues.length) card.append(field); else card.append(mark("meta","בדיווח הזה אין פונמה שסומנה במפורש; עדיין אפשר לסווג ולאשר אותו.","p"));
4495
- const category=document.createElement("select"); for(const [value,label] of Object.entries(FLAG_CATEGORIES)){ const o=document.createElement("option"); o.value=value; o.textContent=label; category.append(o); }
4496
- const note=document.createElement("textarea"); note.maxLength=500; note.rows=2; note.placeholder="הערת בדיקה (לא חובה)";
4497
- const actions=mark("row",""), diagnose=mark("btn","לוח אבחון","button"), confirm=mark("btn primary","אישור הדיווח","button"), reject=mark("btn danger","דחייה","button"), msg=mark("msg","");
4498
- diagnose.disabled=!result.trace;
4499
- diagnose.title=result.trace?"פתיחת האבחון המלא, כולל טבלת ההגאים":"הדיווח ישן ואינו כולל נתוני trace";
4500
- diagnose.onclick=async()=>{
4501
- const tab=window.open("about:blank","_blank");
4502
- diagnose.disabled=true; msg.textContent=f.audio?"טוען אבחון וקול…":"פותח אבחון…";
4503
- const selected=[...field.querySelectorAll("input:checked")].map(x=>({word:x._issue.word,expected:x._issue.expected,heard:x._issue.heard}));
4504
- let audioUrl="";
4505
- if(f.audio) try{
4506
- const res=await api(`/api/flags/${f.id}/audio`); if(!res.ok) throw new Error(res.status);
4507
- audioUrl=URL.createObjectURL(await res.blob());
4508
- }catch(err){ msg.textContent="הקול לא נטען; לוח האבחון נפתח ללא קול."; }
4509
- openTrace({...result,phrase:{he:f.he||f.phrase,ar:f.ar||""},_diagnostic:{prompt_he:f.he||f.phrase,answer_ar:f.ar,
4510
- expected_ipa:f.ipa,model:f.model,version:f.version,report_note:f.note||"",review_category:category.value,
4511
- reviewer_marked_false_issues:selected}},audioUrl,tab);
4512
- diagnose.disabled=false; if(!msg.textContent.includes("לא נטען")) msg.textContent="";
4513
- };
4514
- const save=async status=>{
4515
- confirm.disabled=reject.disabled=true; msg.textContent="שומר…";
4516
- const false_issues=[...field.querySelectorAll("input:checked")].map(x=>({word:x._issue.word,expected:x._issue.expected,heard:x._issue.heard}));
4517
- try{
4518
- const res=await api(`/api/flags/${f.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({status,category:category.value,false_issues,review_note:note.value})});
4519
- const data=await res.json().catch(()=>({})); if(!res.ok) throw new Error(data.detail||res.status);
4520
- flagsLoaded=false; await loadFlags(); drawManage();
4521
- }catch(err){ msg.textContent="השמירה נכשלה: "+err.message; confirm.disabled=reject.disabled=false; }
4522
- };
4523
- confirm.onclick=()=>save("confirmed"); reject.onclick=()=>save("rejected"); actions.append(category,diagnose,confirm,reject,msg); card.append(note,actions); box.append(card);
4524
  }
4525
  }
 
4526
  let systemActivityOpen=false; // the recent activity starts closed; a redraw keeps it as the teacher left it
4527
  function drawSystemActivity(box){
4528
  const outer=mark("card system-activity",""), card=document.createElement("details"), summary=document.createElement("summary");
 
752
  .activity-head select{font:inherit;padding:7px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink)}
753
  .activity-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.activity-stat{padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--bg)}.activity-stat b{display:block;font:400 28px/1 var(--f-display);font-variant-numeric:tabular-nums}.activity-stat small{color:var(--muted);font-size:12px}
754
  .activity-chart{width:100%;height:190px;overflow:visible}.activity-chart .grid{stroke:var(--line);stroke-width:1}.activity-chart .bar{fill:var(--olive);opacity:.82}.activity-chart .bar:hover{opacity:1}.activity-chart text{fill:var(--muted);font:11px var(--f-body)}
755
+ .flag-group{display:flex;flex-direction:column;gap:10px}.flag-group>summary{cursor:pointer;padding:10px 14px;border:1px solid var(--line);border-radius:12px;background:var(--surface);font-size:16px}.flag-group>summary .meta{color:var(--muted);font-size:14px}.flag-group[open]>summary{margin-bottom:2px}.flag-group>.card{margin-inline-start:12px}
756
  .system-activity summary{cursor:pointer}.system-activity summary h2{display:inline;margin:0}.system-activity details[open]>summary{margin-bottom:8px}
757
  .system-hard{margin:2px 0 4px;padding:10px;border-radius:10px;background:var(--bg)}.system-hard h3{margin:0 0 6px;font:700 15px/1.3 var(--f-body)}.system-hard-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;padding:6px 0;border-bottom:1px solid var(--line)}.system-hard-row:last-child{border-bottom:0}.system-hard-row span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.system-hard-row small{color:var(--muted);white-space:nowrap}.system-feed{display:flex;flex-direction:column;gap:14px}.system-topic{border-top:1px solid var(--line);padding-top:10px}.system-topic:first-child{border-top:0;padding-top:0}.system-topic h3{margin:0 0 3px;font:700 15px/1.3 var(--f-body)}.system-topic h3 small{color:var(--muted);font-weight:600}.system-event{display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center;padding:8px 0;border-bottom:1px solid var(--line)}.system-event:last-child{border-bottom:0}.system-event i{display:grid;place-items:center;width:34px;height:34px;border-radius:50%;background:var(--olive-soft);font-style:normal}.system-event b,.system-event small{display:block}.system-event small,.system-event time{color:var(--muted);font-size:12px}.system-event time{direction:ltr;white-space:nowrap}
758
  /* the API keys card (developers only): a row per key, where it comes from and a field for a new one; a key is never shown */
 
4470
  }
4471
  card.append(table); box.append(card);
4472
  }
4473
+ const pending=FLAG_REVIEWS.pending, head=mark("card flag-head",""); box.append(head);
4474
+ head.append(mark("","דיווחים שממתינים לבדיקה","h2"));
4475
+ if(!pending.length){ head.append(mark("meta","אין כרגע דיווחים שממתינים לבדיקה.","p")); return; }
4476
+ const byUser=new Map; for(const f of pending){ const who=f.email||""; if(!byUser.has(who)) byUser.set(who,[]); byUser.get(who).push(f); }
4477
+ head.append(mark("meta",`${pending.length} ${pending.length===1?"דיווח":"דיווחים"} מ־${byUser.size} ${byUser.size===1?"מדווח/ת":"מדווחים"}, מקובצים לפי המדווח/ת.`,"p"));
4478
+ for(const [who,flags] of [...byUser].sort((a,b)=>b[1].length-a[1].length||a[0].localeCompare(b[0]))){ // the most reports first
4479
+ const group=document.createElement("details"), summary=document.createElement("summary");
4480
+ group.className="flag-group"; group.open=flagGroupOpen[who]!==false; group.ontoggle=()=>{ flagGroupOpen[who]=group.open; };
4481
+ summary.append(mark("",who||"ללא משתמש","b"),mark("meta",` · ${flags.length} ${flags.length===1?"דיווח":"דיווחים"}`)); group.append(summary); box.append(group);
4482
+ for(const f of flags){
4483
+ const card=mark("card flag-review",""), title=mark("",f.he||f.phrase,"h2"), result=f.result||{}, issues=flagIssues(f);
4484
+ const ar=mark("",f.ar||"","p"); ar.dir="rtl";
4485
+ card.append(title,ar,mark("meta",`ציון ${f.score} · המודל שמע: ${f.heard_ipa||"–"}`,"p"));
4486
+ if(f.note) card.append(mark("",`הערת המדווח/ת: ${f.note}`,"p"));
4487
+ if(f.audio){
4488
+ const audioRow=mark("row",""), play=mark("btn","▶ השמעת ההקלטה","button"), audio=document.createElement("audio"), audioMsg=mark("msg","");
4489
+ audio.controls=true; audio.preload="none"; audio.hidden=true;
4490
+ play.onclick=async()=>{
4491
+ play.disabled=true; audioMsg.textContent="טוען…";
4492
+ try{
4493
+ const res=await api(`/api/flags/${f.id}/audio`), data=res.ok?await res.blob():null;
4494
+ if(!res.ok) throw new Error((await res.json().catch(()=>({}))).detail||res.status);
4495
+ const url=URL.createObjectURL(data); flagAudioUrls.push(url); audio.src=url; audio.hidden=false; play.hidden=true; audioMsg.textContent="";
4496
+ audio.play().catch(()=>{});
4497
+ }catch(err){ audioMsg.textContent="ההשמעה נכשלה: "+err.message; play.disabled=false; }
4498
+ };
4499
+ audioRow.append(play,audio,audioMsg); card.append(audioRow);
4500
+ }else card.append(mark("meta","ההקלטה לא נשמרה עם הדיווח הזה.","p"));
4501
+ const field=document.createElement("fieldset"); field.append(mark("","אילו אבחנות של המערכת היו שגויות?","legend"));
4502
+ for(const issue of issues){ const label=document.createElement("label"), input=document.createElement("input"); input.type="checkbox"; input.checked=true; input._issue=issue; label.append(input,issue.label); field.append(label); }
4503
+ if(issues.length) card.append(field); else card.append(mark("meta","בדיווח הזה אין פונמה שסומנה במפורש; עדיין אפשר לסווג ולאשר אותו.","p"));
4504
+ const category=document.createElement("select"); for(const [value,label] of Object.entries(FLAG_CATEGORIES)){ const o=document.createElement("option"); o.value=value; o.textContent=label; category.append(o); }
4505
+ const note=document.createElement("textarea"); note.maxLength=500; note.rows=2; note.placeholder="הערת בדיקה (לא חובה)";
4506
+ const actions=mark("row",""), diagnose=mark("btn","לוח אבחון","button"), confirm=mark("btn primary","אישור הדיווח","button"), reject=mark("btn danger","דחייה","button"), msg=mark("msg","");
4507
+ diagnose.disabled=!result.trace;
4508
+ diagnose.title=result.trace?"פתיחת האבחון המלא, כולל טבלת ההגאים":"הדיווח ישן ואינו כולל נתוני trace";
4509
+ diagnose.onclick=async()=>{
4510
+ const tab=window.open("about:blank","_blank");
4511
+ diagnose.disabled=true; msg.textContent=f.audio?"טוען אבחון וקול…":"פותח אבחון…";
4512
+ const selected=[...field.querySelectorAll("input:checked")].map(x=>({word:x._issue.word,expected:x._issue.expected,heard:x._issue.heard}));
4513
+ let audioUrl="";
4514
+ if(f.audio) try{
4515
+ const res=await api(`/api/flags/${f.id}/audio`); if(!res.ok) throw new Error(res.status);
4516
+ audioUrl=URL.createObjectURL(await res.blob());
4517
+ }catch(err){ msg.textContent="הקול לא נטען; לוח האבחון נפתח ללא קול."; }
4518
+ openTrace({...result,phrase:{he:f.he||f.phrase,ar:f.ar||""},_diagnostic:{prompt_he:f.he||f.phrase,answer_ar:f.ar,
4519
+ expected_ipa:f.ipa,model:f.model,version:f.version,report_note:f.note||"",review_category:category.value,
4520
+ reviewer_marked_false_issues:selected}},audioUrl,tab);
4521
+ diagnose.disabled=false; if(!msg.textContent.includes("לא נטען")) msg.textContent="";
4522
+ };
4523
+ const save=async status=>{
4524
+ confirm.disabled=reject.disabled=true; msg.textContent="שומר…";
4525
+ const false_issues=[...field.querySelectorAll("input:checked")].map(x=>({word:x._issue.word,expected:x._issue.expected,heard:x._issue.heard}));
4526
  try{
4527
+ const res=await api(`/api/flags/${f.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({status,category:category.value,false_issues,review_note:note.value})});
4528
+ const data=await res.json().catch(()=>({})); if(!res.ok) throw new Error(data.detail||res.status);
4529
+ flagsLoaded=false; await loadFlags(); drawManage();
4530
+ }catch(err){ msg.textContent="השמירה נכשלה: "+err.message; confirm.disabled=reject.disabled=false; }
 
4531
  };
4532
+ confirm.onclick=()=>save("confirmed"); reject.onclick=()=>save("rejected"); actions.append(category,diagnose,confirm,reject,msg); card.append(note,actions); group.append(card);
4533
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4534
  }
4535
  }
4536
+ const flagGroupOpen={}; // reporter -> whether their group of reports is open; open until the teacher closes it
4537
  let systemActivityOpen=false; // the recent activity starts closed; a redraw keeps it as the teacher left it
4538
  function drawSystemActivity(box){
4539
  const outer=mark("card system-activity",""), card=document.createElement("details"), summary=document.createElement("summary");
tests/test_scoring.py CHANGED
@@ -361,6 +361,41 @@ def test_dropped_glottal_stop_is_not_an_error():
361
  assert not r["tips"] and not any("עצירה" in i["sound"] for i in r["issues"])
362
 
363
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  def test_ana_explicitly_accepts_the_natural_vowell_initial_form():
365
  from app.grammar import PRONOUNS
366
 
 
361
  assert not r["tips"] and not any("עצירה" in i["sound"] for i in r["issues"])
362
 
363
 
364
+ @pytest.mark.parametrize("competitor", ["f", "s", "χ"])
365
+ def test_one_frame_onset_spike_does_not_become_a_glottal_substitution(competitor):
366
+ """A generic microphone/breath transient must not be reported as a consonant the learner produced."""
367
+ syms = SYMS + ([competitor] if competitor not in SYMS else [])
368
+ vocab = {s: i for i, s in enumerate(syms)}
369
+ V = len(syms)
370
+
371
+ def row(sym, peak):
372
+ p = np.full(V, (1 - peak) / (V - 1))
373
+ p[vocab[sym]] = peak
374
+ return p
375
+
376
+ # No glottal stop: one weak arbitrary-phone spike, then the clean vowel-initial word.
377
+ rows = [row("<pad>", .95), row(competitor, .23)]
378
+ for ph in ["a", "h", "l", "a"]:
379
+ rows += [row(ph, .9)] * 3 + [row("<pad>", .95)] * 2
380
+ logp = np.log(np.array(rows))
381
+ phrase = {"variants": [("اهلا", "ʔ a h l a")]}
382
+ r = Scorer(lambda wav: logp, Vocab(vocab)).score_logp(logp, phrase, diagnostics=True)
383
+ onset = r["trace"]["tokens"][0]
384
+ assert onset["competitor_frames"] == 1
385
+ assert onset["stages"][1]["applied"] == "glottal_dropped"
386
+ assert r["words"][0]["score"] >= 90 and not r["tips"]
387
+
388
+
389
+ def test_sustained_initial_consonant_is_still_a_glottal_substitution():
390
+ phrase = {"variants": [("اهلا", "ʔ a h l a")]}
391
+ r = scorer_for(emissions(["s", "a", "h", "l", "a"])).score(None, phrase, diagnostics=True)
392
+ onset = r["trace"]["tokens"][0]
393
+ assert onset["competitor_frames"] >= 2
394
+ assert onset["stages"][1]["applied"] is None
395
+ assert r["words"][0]["phonemes"][0]["heard"] == "s"
396
+ assert r["tips"][0]["key"] == "ʔ>s"
397
+
398
+
399
  def test_ana_explicitly_accepts_the_natural_vowell_initial_form():
400
  from app.grammar import PRONOUNS
401