3v324v23 commited on
Commit
89d97df
·
1 Parent(s): 4f6b38e

feat: enhance diagnostic dashboard with structured trace and copy functionality

Browse files
Files changed (5) hide show
  1. README.md +2 -2
  2. app/main.py +5 -1
  3. static/index.html +10 -2
  4. static/trace.html +32 -0
  5. tests/test_scoring.py +2 -1
README.md CHANGED
@@ -235,7 +235,7 @@ Under each score a teacher or a tutor gets an optional note and the button "🚩
235
 
236
  - who and what: `email`, `cat`, `phrase`, `he`, `note`;
237
  - the answer the scorer matched (`ar`, `ipa`), its free decode (`heard_ipa`), `score` and `unrelated`;
238
- - `result`: the whole score response (every word and phoneme with its score and what took its place, the tips, the issues);
239
  - `model` and `version`, so a flag can be checked against the model and build that produced it;
240
  - `audio`: always null for new reports; the nullable legacy column is retained so old reports with recordings remain readable.
241
 
@@ -281,7 +281,7 @@ alter table public.flags
281
  add column if not exists false_issues jsonb not null default '[]'::jsonb;
282
  ```
283
 
284
- The management panel's **דיווחים** tab lets a teacher confirm or reject each report, classify it, and select the individual sound judgements that were false. It can still play a legacy recording when an older row has one, but new reports deliberately retain only diagnostics. The tab groups confirmed `expected → heard` pairs by reports, reporters, and phrases. Only confirmed reports should be used for calibration; review and aggregation use the stored structured result without needing the user's voice file.
285
 
286
  ### The lab (מעבדה)
287
 
 
235
 
236
  - who and what: `email`, `cat`, `phrase`, `he`, `note`;
237
  - the answer the scorer matched (`ar`, `ipa`), its free decode (`heard_ipa`), `score` and `unrelated`;
238
+ - `result`: the whole score response, including the structured diagnostic trace (every word and phoneme, pipeline stage, threshold, score, replacement, tip and issue);
239
  - `model` and `version`, so a flag can be checked against the model and build that produced it;
240
  - `audio`: always null for new reports; the nullable legacy column is retained so old reports with recordings remain readable.
241
 
 
281
  add column if not exists false_issues jsonb not null default '[]'::jsonb;
282
  ```
283
 
284
+ The management panel's **דיווחים** tab lets a teacher open the existing diagnostic dashboard beside the approval action, confirm or reject each report, classify it, and select the individual sound judgements that were false. The dashboard includes a copyable, identity-free text payload for asking a chat assistant to distinguish acoustic-model, alignment, rule, threshold and feedback failures and suggest regression tests. It can still play a legacy recording when an older row has one, but new reports deliberately retain only diagnostics. The tab groups confirmed `expected → heard` pairs by reports, reporters, and phrases. Only confirmed reports should be used for calibration; review and aggregation use the stored structured result without needing the user's voice file.
285
 
286
  ### The lab (מעבדה)
287
 
app/main.py CHANGED
@@ -2041,9 +2041,13 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
2041
  except ValueError:
2042
  raise HTTPException(400, "could not decode audio")
2043
  refs = app.state.refs.get(phrase_id, phrase) if app.state.refs else []
2044
- result = app.state.scorer.score(wav, phrase, refs)
 
2045
  if result.get("error"):
2046
  raise HTTPException(400, result["message"])
 
 
 
2047
  return save_flag(phrase, result, note, user)
2048
 
2049
  def save_flag(phrase: dict, result: dict, note: str, user: dict) -> dict:
 
2041
  except ValueError:
2042
  raise HTTPException(400, "could not decode audio")
2043
  refs = app.state.refs.get(phrase_id, phrase) if app.state.refs else []
2044
+ # Keep a structured pipeline trace for the diagnostic dashboard; the audio itself is discarded.
2045
+ result = app.state.scorer.score(wav, phrase, refs, diagnostics=True)
2046
  if result.get("error"):
2047
  raise HTTPException(400, result["message"])
2048
+ # The trace already contains every value used by the dashboard. The dense heatmap is only a display aid
2049
+ # for the immediate scoring screen and would make every database report unnecessarily large.
2050
+ result.pop("phoneme_heatmap", None)
2051
  return save_flag(phrase, result, note, user)
2052
 
2053
  def save_flag(phrase: dict, result: dict, note: str, user: dict) -> dict:
static/index.html CHANGED
@@ -4481,7 +4481,15 @@ function drawFlags(){
4481
  if(issues.length) card.append(field); else card.append(mark("meta","בדיווח הזה אין פונמה שסומנה במפורש; עדיין אפשר לסווג ולאשר אותו.","p"));
4482
  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); }
4483
  const note=document.createElement("textarea"); note.maxLength=500; note.rows=2; note.placeholder="הערת בדיקה (לא חובה)";
4484
- const actions=mark("row",""), confirm=mark("btn primary","אישור הדיווח","button"), reject=mark("btn danger","דחייה","button"), msg=mark("msg","");
 
 
 
 
 
 
 
 
4485
  const save=async status=>{
4486
  confirm.disabled=reject.disabled=true; msg.textContent="שומר…";
4487
  const false_issues=[...field.querySelectorAll("input:checked")].map(x=>({word:x._issue.word,expected:x._issue.expected,heard:x._issue.heard}));
@@ -4491,7 +4499,7 @@ function drawFlags(){
4491
  flagsLoaded=false; await loadFlags(); drawManage();
4492
  }catch(err){ msg.textContent="השמירה נכשלה: "+err.message; confirm.disabled=reject.disabled=false; }
4493
  };
4494
- confirm.onclick=()=>save("confirmed"); reject.onclick=()=>save("rejected"); actions.append(category,confirm,reject,msg); card.append(note,actions); box.append(card);
4495
  }
4496
  }
4497
  function drawSystemActivity(box){
 
4481
  if(issues.length) card.append(field); else card.append(mark("meta","בדיווח הזה אין פונמה שסומנה במפורש; עדיין אפשר לסווג ולאשר אותו.","p"));
4482
  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); }
4483
  const note=document.createElement("textarea"); note.maxLength=500; note.rows=2; note.placeholder="הערת בדיקה (לא חובה)";
4484
+ const actions=mark("row",""), diagnose=mark("btn","לוח אבחון","button"), confirm=mark("btn primary","אישור הדיווח","button"), reject=mark("btn danger","דחייה","button"), msg=mark("msg","");
4485
+ diagnose.disabled=!result.trace;
4486
+ diagnose.title=result.trace?"פתיחת האבחון המלא, כולל טבלת ההגאים":"הדיווח ישן ואינו כולל נתוני trace";
4487
+ diagnose.onclick=()=>{
4488
+ const selected=[...field.querySelectorAll("input:checked")].map(x=>({word:x._issue.word,expected:x._issue.expected,heard:x._issue.heard}));
4489
+ openTrace({...result,phrase:{he:f.he||f.phrase,ar:f.ar||""},_diagnostic:{prompt_he:f.he||f.phrase,answer_ar:f.ar,
4490
+ expected_ipa:f.ipa,model:f.model,version:f.version,report_note:f.note||"",review_category:category.value,
4491
+ reviewer_marked_false_issues:selected}});
4492
+ };
4493
  const save=async status=>{
4494
  confirm.disabled=reject.disabled=true; msg.textContent="שומר…";
4495
  const false_issues=[...field.querySelectorAll("input:checked")].map(x=>({word:x._issue.word,expected:x._issue.expected,heard:x._issue.heard}));
 
4499
  flagsLoaded=false; await loadFlags(); drawManage();
4500
  }catch(err){ msg.textContent="השמירה נכשלה: "+err.message; confirm.disabled=reject.disabled=false; }
4501
  };
4502
+ confirm.onclick=()=>save("confirmed"); reject.onclick=()=>save("rejected"); actions.append(category,diagnose,confirm,reject,msg); card.append(note,actions); box.append(card);
4503
  }
4504
  }
4505
  function drawSystemActivity(box){
static/trace.html CHANGED
@@ -141,6 +141,9 @@ table.data{border-collapse:collapse;font-size:12px;width:100%}
141
  table.data th,table.data td{padding:3px 6px;border-bottom:1px solid var(--grid);text-align:right;white-space:nowrap}
142
  details summary{cursor:pointer;color:var(--ink-2);font-size:12px}
143
  .scroll{overflow-x:auto}
 
 
 
144
  </style>
145
  </head>
146
  <body>
@@ -188,6 +191,12 @@ details summary{cursor:pointer;color:var(--ink-2);font-size:12px}
188
  <section class="panel">
189
  <details><summary>טבלת הגאים (כל המספרים)</summary><div class="scroll"><table class="data" id="table"></table></div></details>
190
  </section>
 
 
 
 
 
 
191
  </main>
192
  <dialog id="excludeDlg" aria-labelledby="excludeTitle">
193
  <form method="dialog" class="exclude-form">
@@ -221,11 +230,29 @@ let wordAudioUrl=new URLSearchParams(location.search).get("src")==="app"?(localS
221
  let traceContext={}; try{ traceContext=JSON.parse(localStorage.getItem("ihki.traceContext")||"{}"); }catch{}
222
  let excludedWords={...(traceContext.excluded_words||{})};
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  /* ---------- loading ---------- */
225
  function load(result,source){
226
  if(!result||!result.trace||!result.trace.tokens){ show("בקובץ אין trace. פתחו את הלוח מהמעבדה או מניתוח הפונמות של מורה, או טענו את הדוגמה."); return; }
227
  if(source!=="מהאפליקציה"){ traceContext={}; excludedWords={}; wordAudioUrl=""; }
228
  R=result; T=result.trace; PH=(R.words||[]).flatMap(w=>w.phonemes||[]); traceSource=source;
 
229
  $("framePlay").disabled=!wordAudioUrl;
230
  $("framePlay").title=wordAudioUrl?"נגינת חלון הפריימים עם סמן מסונכרן":"אין אודיו מצורף ל־trace";
231
  const worst=T.tokens.reduce((b,t)=>t.final<b.final?t:b,T.tokens[0]), asked=+new URLSearchParams(location.search).get("i");
@@ -239,6 +266,11 @@ async function loadMock(){
239
  catch(e){ show(`הדוגמה לא נטענה (${e.message}). הריצו python scripts/mock_trace.py`); }
240
  }
241
  $("mock").onclick=loadMock;
 
 
 
 
 
242
  $("file").onchange=async e=>{ const f=e.target.files[0]; if(!f) return; try{ load(JSON.parse(await f.text()),f.name); }catch(err){ show(`לא JSON תקין: ${err.message}`); } };
243
  addEventListener("keydown",e=>{ if(!T||e.target.tagName==="INPUT") return; if(e.key==="ArrowLeft"){ step(1); e.preventDefault(); } if(e.key==="ArrowRight"){ step(-1); e.preventDefault(); } });
244
  $("next").onclick=()=>step(1); $("prev").onclick=()=>step(-1);
 
141
  table.data th,table.data td{padding:3px 6px;border-bottom:1px solid var(--grid);text-align:right;white-space:nowrap}
142
  details summary{cursor:pointer;color:var(--ink-2);font-size:12px}
143
  .scroll{overflow-x:auto}
144
+ .chat-payload{width:100%;min-height:320px;resize:vertical;padding:10px;border:1px solid var(--axis);border-radius:9px;background:var(--page);color:var(--ink);font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;direction:ltr;text-align:left;white-space:pre}
145
+ .copy-row{display:flex;align-items:center;justify-content:flex-end;gap:10px}
146
+ .copy-status{color:var(--ink-2);font-size:13px}
147
  </style>
148
  </head>
149
  <body>
 
191
  <section class="panel">
192
  <details><summary>טבלת הגאים (כל המספרים)</summary><div class="scroll"><table class="data" id="table"></table></div></details>
193
  </section>
194
+
195
+ <section class="panel" aria-labelledby="chatPayloadTitle">
196
+ <div class="head"><h2 id="chatPayloadTitle">מידע להתייעצות על ה־pipeline</h2><span class="sub">אבחון מלא להעתקה לצ׳אט, ללא זהות המשתמש וללא קובץ קול</span></div>
197
+ <textarea class="chat-payload" id="chatPayload" readonly aria-label="מידע מלא להעתקה לצ׳אט"></textarea>
198
+ <div class="copy-row"><span class="copy-status" id="copyStatus" role="status"></span><button id="copyPayload">העתקה לצ׳אט</button></div>
199
+ </section>
200
  </main>
201
  <dialog id="excludeDlg" aria-labelledby="excludeTitle">
202
  <form method="dialog" class="exclude-form">
 
230
  let traceContext={}; try{ traceContext=JSON.parse(localStorage.getItem("ihki.traceContext")||"{}"); }catch{}
231
  let excludedWords={...(traceContext.excluded_words||{})};
232
 
233
+ function diagnosticChatText(result){
234
+ const t=result.trace||{}, context={...(result.phrase||{}),...(result._diagnostic||{})};
235
+ const trace={...t,
236
+ tokens:(t.tokens||[]).map(({curves,...token})=>token),
237
+ frames:t.frames?{count:t.frames.count,ms:t.frames.ms}:null
238
+ };
239
+ const publicResult={...result,trace};
240
+ delete publicResult.email; delete publicResult.audio; delete publicResult.phoneme_heatmap; delete publicResult._diagnostic;
241
+ return [
242
+ "Please analyze this Palestinian-Arabic pronunciation-scoring diagnostic.",
243
+ "Identify the most likely failure point and distinguish between acoustic-model, segmentation/alignment, pronunciation-rule, threshold/calibration, and feedback-generation problems.",
244
+ "Propose prioritized, concrete pipeline changes and regression tests. Do not infer the speaker's identity.",
245
+ "",
246
+ JSON.stringify({context,result:publicResult},null,2)
247
+ ].join("\n");
248
+ }
249
+
250
  /* ---------- loading ---------- */
251
  function load(result,source){
252
  if(!result||!result.trace||!result.trace.tokens){ show("בקובץ אין trace. פתחו את הלוח מהמעבדה או מניתוח הפונמות של מורה, או טענו את הדוגמה."); return; }
253
  if(source!=="מהאפליקציה"){ traceContext={}; excludedWords={}; wordAudioUrl=""; }
254
  R=result; T=result.trace; PH=(R.words||[]).flatMap(w=>w.phonemes||[]); traceSource=source;
255
+ $("chatPayload").value=diagnosticChatText(result); $("copyStatus").textContent="";
256
  $("framePlay").disabled=!wordAudioUrl;
257
  $("framePlay").title=wordAudioUrl?"נגינת חלון הפריימים עם סמן מסונכרן":"אין אודיו מצורף ל־trace";
258
  const worst=T.tokens.reduce((b,t)=>t.final<b.final?t:b,T.tokens[0]), asked=+new URLSearchParams(location.search).get("i");
 
266
  catch(e){ show(`הדוגמה לא נטענה (${e.message}). הריצו python scripts/mock_trace.py`); }
267
  }
268
  $("mock").onclick=loadMock;
269
+ $("copyPayload").onclick=async()=>{
270
+ const field=$("chatPayload"), status=$("copyStatus");
271
+ try{ await navigator.clipboard.writeText(field.value); status.textContent="הועתק."; }
272
+ catch{ field.focus(); field.select(); status.textContent="הטקסט סומן; העתיקו עם Ctrl/Cmd+C."; }
273
+ };
274
  $("file").onchange=async e=>{ const f=e.target.files[0]; if(!f) return; try{ load(JSON.parse(await f.text()),f.name); }catch(err){ show(`לא JSON תקין: ${err.message}`); } };
275
  addEventListener("keydown",e=>{ if(!T||e.target.tagName==="INPUT") return; if(e.key==="ArrowLeft"){ step(1); e.preventDefault(); } if(e.key==="ArrowRight"){ step(-1); e.preventDefault(); } });
276
  $("next").onclick=()=>step(1); $("prev").onclick=()=>step(-1);
tests/test_scoring.py CHANGED
@@ -1781,7 +1781,8 @@ def test_a_tutor_flags_a_recording_scored_wrong(monkeypatch):
1781
  assert r.status_code == 200 and r.json() == {"ok": True, "audio": False, "flag": row}, r.text
1782
  assert row["email"] == "t@b.c" and row["cat"] == TOILET["cat"] and row["he"] == TOILET["he"] and row["note"] == "אמרתי ח נכונה"
1783
  assert row["ipa"] == "w eː n | i l ħ a m m aː m" and "χ" in row["heard_ipa"] and row["score"] == row["result"]["score"] < 100
1784
- assert row["result"]["tips"][0]["key"] == "ħ>x" and "phoneme_heatmap" not in row["result"]
 
1785
  assert send(c, "t", phrase_id=10**6).status_code == 404
1786
  a.teachers = frozenset({"s@b.c"}) # a teacher flags too
1787
  assert c.get("/api/me", headers={"Authorization": "Bearer s"}).json()["tutor"] is True and send(c, "s").status_code == 200
 
1781
  assert r.status_code == 200 and r.json() == {"ok": True, "audio": False, "flag": row}, r.text
1782
  assert row["email"] == "t@b.c" and row["cat"] == TOILET["cat"] and row["he"] == TOILET["he"] and row["note"] == "אמרתי ח נכונה"
1783
  assert row["ipa"] == "w eː n | i l ħ a m m aː m" and "χ" in row["heard_ipa"] and row["score"] == row["result"]["score"] < 100
1784
+ assert row["result"]["tips"][0]["key"] == "ħ>x" and row["result"]["trace"]["tokens"]
1785
+ assert "phoneme_heatmap" not in row["result"]
1786
  assert send(c, "t", phrase_id=10**6).status_code == 404
1787
  a.teachers = frozenset({"s@b.c"}) # a teacher flags too
1788
  assert c.get("/api/me", headers={"Authorization": "Bearer s"}).json()["tutor"] is True and send(c, "s").status_code == 200