feat: enhance diagnostic dashboard with structured trace and copy functionality
Browse files- README.md +2 -2
- app/main.py +5 -1
- static/index.html +10 -2
- static/trace.html +32 -0
- 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
|
| 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 |
-
|
|
|
|
| 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
|
|
|
|
| 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
|