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

Refactor flag handling to discard audio recordings

Browse files

- Updated the flagging process to no longer store user audio recordings, retaining only structured diagnostics.
- Modified the `add_flag` method to accept only the row data without audio parameters.
- Adjusted related API endpoints and tests to reflect the removal of audio storage.
- Updated documentation to clarify changes in flagging behavior and data storage.

Files changed (6) hide show
  1. README.md +5 -6
  2. app/auth.py +3 -7
  3. app/main.py +13 -13
  4. static/guide.html +2 -4
  5. static/index.html +67 -60
  6. tests/test_scoring.py +7 -11
README.md CHANGED
@@ -231,15 +231,15 @@ create table public.tutors (
231
  alter table public.tutors enable row level security; -- no policy: only the secret key reads and writes it
232
  ```
233
 
234
- Under each score a teacher or a tutor gets an optional note and the button "🚩 אמרתי נכון, המערכת טעתה". Pressing it sends the recording to `POST /api/flags` (teachers and tutors only), which scores it again and keeps what describes the case in the table `flags`, written with `SUPABASE_SECRET_KEY`. A box then shows what was kept in words: the sentence, what the model heard, each word's score, the sounds that were marked down and what took their place, and whether the recording was stored. The row holds:
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`: the recording's name in the private storage bucket `flags` (null when it could not be stored).
241
 
242
- Create the table and the bucket once (until then a flag is refused with this command in the message):
243
 
244
  ```sql
245
  create table public.flags (
@@ -257,7 +257,7 @@ create table public.flags (
257
  result jsonb not null, -- the whole score response
258
  model text not null,
259
  version text not null default '',
260
- audio text, -- the recording's name in the storage bucket flags
261
  status text not null default 'pending', -- pending, confirmed, rejected, or fixed
262
  category text,
263
  reviewed_by text,
@@ -267,7 +267,6 @@ create table public.flags (
267
  created_at timestamptz not null default now()
268
  );
269
  alter table public.flags enable row level security; -- no policy: only the secret key reads and writes it
270
- insert into storage.buckets (id, name) values ('flags', 'flags') on conflict (id) do nothing;
271
  ```
272
 
273
  For a `flags` table created before reviews were added, run once:
@@ -282,7 +281,7 @@ alter table public.flags
282
  add column if not exists false_issues jsonb not null default '[]'::jsonb;
283
  ```
284
 
285
- The management panel's **דיווחים** tab lets a teacher privately play the recording, confirm or reject each report, classify it, and select the individual sound judgements that were false. The recording is fetched through a teacher-only endpoint, so neither the private bucket nor its secret key is exposed to the browser. The tab also groups confirmed `expected → heard` pairs by reports, reporters, and phrases. Only confirmed reports should be used for calibration; the review and aggregation themselves use the stored result. As a flag says the sentence was said as written, `audio,ipa` of these rows is also training data in the format `train/finetune.py` reads (see "Fine-tuning").
286
 
287
  ### The lab (מעבדה)
288
 
 
231
  alter table public.tutors enable row level security; -- no policy: only the secret key reads and writes it
232
  ```
233
 
234
+ Under each score a teacher or a tutor gets an optional note and the button "🚩 אמרתי נכון, המערכת טעתה". Pressing it sends the recording to `POST /api/flags` (teachers and tutors only), which scores it again in memory and then discards the audio. It keeps the structured diagnosis in the table `flags`, written with `SUPABASE_SECRET_KEY`: the sentence, what the model heard, each word's score, and the sounds that were marked down and what took their place. The row holds:
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
 
242
+ Create the table once (until then a flag is refused with this command in the message):
243
 
244
  ```sql
245
  create table public.flags (
 
257
  result jsonb not null, -- the whole score response
258
  model text not null,
259
  version text not null default '',
260
+ audio text, -- legacy recordings only; new reports leave this null
261
  status text not null default 'pending', -- pending, confirmed, rejected, or fixed
262
  category text,
263
  reviewed_by text,
 
267
  created_at timestamptz not null default now()
268
  );
269
  alter table public.flags enable row level security; -- no policy: only the secret key reads and writes it
 
270
  ```
271
 
272
  For a `flags` table created before reviews were added, run once:
 
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
 
app/auth.py CHANGED
@@ -492,13 +492,9 @@ class SupabaseAuth:
492
  media_type = mimetypes.guess_type(name)[0] or media_type
493
  return data, media_type
494
 
495
- def add_flag(self, row: dict, audio: bytes, mime: str, name: str) -> bool:
496
- """Keeps a flagged score: the recording in the storage bucket `flags` under `name`, and the row in the table
497
- `flags` with that name as its `audio`. A recording that cannot be stored is logged and the row is kept
498
- without it (`audio` null); False tells that. A row that cannot be kept is raised."""
499
- kept = self._put_object("flags", name, audio, mime)
500
- self._admin("/rest/v1/flags", "POST", {**row, "audio": name if kept else None})
501
- return kept
502
 
503
  def list_flags(self, status: str = "pending") -> list[dict]:
504
  """Flagged scores for the teacher's review, newest first."""
 
492
  media_type = mimetypes.guess_type(name)[0] or media_type
493
  return data, media_type
494
 
495
+ def add_flag(self, row: dict) -> None:
496
+ """Keeps the structured diagnosis of a flagged score, without retaining the user's recording."""
497
+ self._admin("/rest/v1/flags", "POST", {**row, "audio": None})
 
 
 
 
498
 
499
  def list_flags(self, status: str = "pending") -> list[dict]:
500
  """Flagged scores for the teacher's review, newest first."""
app/main.py CHANGED
@@ -708,7 +708,10 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
708
  result.append({**account, "roles": sorted(roles & ROLES),
709
  "entitlements": sorted(entitlements), "permissions": sorted(effective),
710
  "fixed_roles": sorted(set(fixed))})
711
- return {"users": result, "setup": "", "migration": MIGRATE_USER_ACCESS,
 
 
 
712
  "me": actor["id"] if actor else ""}
713
 
714
  @app.put("/api/access/users/{target_id}")
@@ -749,6 +752,8 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
749
  except AuthUnavailable as e:
750
  if "answered 404" in str(e):
751
  raise HTTPException(503, f"permissions table is missing; run: {CREATE_USER_ACCESS}")
 
 
752
  raise HTTPException(503, f"Supabase לא זמין: {e}")
753
  return {"ok": True}
754
 
@@ -1851,7 +1856,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
1851
  result = app.state.scorer.score(wav, phrase, [])
1852
  if result.get("error"):
1853
  raise HTTPException(400, result["message"])
1854
- return save_flag(phrase, result, data, audio.content_type, note, user)
1855
 
1856
  def tutor_for(user: dict | None) -> Tutor:
1857
  """The paid tutor game, after the tutor.use dependency authorized the account (or on an open service)."""
@@ -2023,10 +2028,8 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
2023
  @app.post("/api/flags")
2024
  def flag(phrase_id: int = Form(...), audio: UploadFile = File(...), note: str = Form(""),
2025
  user: dict | None = Depends(signed_in)):
2026
- """A tutor's flag: this recording of the phrase was said right and the score was wrong. The recording is scored
2027
- again here, so what is kept is the scorer's own answer and not the page's copy of it: the row of `flags`
2028
- (see CREATE_FLAGS) and the recording beside it. {"ok", "audio": whether the recording was kept too, "flag": the
2029
- row that was kept, which the page shows the tutor}."""
2030
  if not user or not user["tutor"]:
2031
  raise HTTPException(403, "tutors only")
2032
  phrase = phrase_of(phrase_id)
@@ -2041,25 +2044,22 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
2041
  result = app.state.scorer.score(wav, phrase, refs)
2042
  if result.get("error"):
2043
  raise HTTPException(400, result["message"])
2044
- return save_flag(phrase, result, data, audio.content_type, note, user)
2045
 
2046
- def save_flag(phrase: dict, result: dict, data: bytes, content_type: str | None,
2047
- note: str, user: dict) -> dict:
2048
  """Store a reviewed-score candidate from a bank phrase or a dynamic verb-game phrase."""
2049
- mime = (content_type or "application/octet-stream").lower()
2050
- ext = AUDIO_EXT.get(mime.partition(";")[0].strip().rpartition("/")[2], "bin")
2051
  row = {"email": user["email"].lower(), "cat": phrase["cat"], "phrase": phrase.get("key", phrase["he"]),
2052
  "he": phrase["he"], "ar": result["variant"]["ar"], "ipa": result["variant"]["ipa"],
2053
  "heard_ipa": result["heard_ipa"], "score": result["score"], "unrelated": bool(result["unrelated"]),
2054
  "note": " ".join(note.split())[:MAX_NOTE], "result": result, "model": MODEL_ID, "version": build_version()}
2055
  try:
2056
- kept = app.state.auth.add_flag(row, data, mime, f"{date.today():%Y-%m}/{uuid.uuid4().hex}.{ext}")
2057
  except AuthUnavailable as e:
2058
  if "answered 404" in str(e):
2059
  raise HTTPException(503, "הטבלה flags לא קיימת ב-Supabase. הריצו ב-SQL Editor: "
2060
  f"{CREATE_FLAGS} אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';")
2061
  raise HTTPException(503, f"Supabase לא זמין: {e}")
2062
- return {"ok": True, "audio": kept, "flag": row}
2063
 
2064
  return app
2065
 
 
708
  result.append({**account, "roles": sorted(roles & ROLES),
709
  "entitlements": sorted(entitlements), "permissions": sorted(effective),
710
  "fixed_roles": sorted(set(fixed))})
711
+ # A successful read cannot tell us which CHECK expression PostgreSQL has, and showing the
712
+ # migration unconditionally makes it look unfinished even after it was run. If an old
713
+ # constraint rejects a newly selected entitlement, update_access returns the migration there.
714
+ return {"users": result, "setup": "", "migration": "",
715
  "me": actor["id"] if actor else ""}
716
 
717
  @app.put("/api/access/users/{target_id}")
 
752
  except AuthUnavailable as e:
753
  if "answered 404" in str(e):
754
  raise HTTPException(503, f"permissions table is missing; run: {CREATE_USER_ACCESS}")
755
+ if any(mark in str(e).lower() for mark in ("valid_entitlements", "check constraint", "23514")):
756
+ raise HTTPException(409, f"permissions table needs upgrading; run: {MIGRATE_USER_ACCESS}")
757
  raise HTTPException(503, f"Supabase לא זמין: {e}")
758
  return {"ok": True}
759
 
 
1856
  result = app.state.scorer.score(wav, phrase, [])
1857
  if result.get("error"):
1858
  raise HTTPException(400, result["message"])
1859
+ return save_flag(phrase, result, note, user)
1860
 
1861
  def tutor_for(user: dict | None) -> Tutor:
1862
  """The paid tutor game, after the tutor.use dependency authorized the account (or on an open service)."""
 
2028
  @app.post("/api/flags")
2029
  def flag(phrase_id: int = Form(...), audio: UploadFile = File(...), note: str = Form(""),
2030
  user: dict | None = Depends(signed_in)):
2031
+ """A tutor's flag: this recording of the phrase was said right and the score was wrong. It is scored again in
2032
+ memory, then discarded. The structured diagnosis is kept in `flags`; the user's audio is not retained."""
 
 
2033
  if not user or not user["tutor"]:
2034
  raise HTTPException(403, "tutors only")
2035
  phrase = phrase_of(phrase_id)
 
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:
 
2050
  """Store a reviewed-score candidate from a bank phrase or a dynamic verb-game phrase."""
 
 
2051
  row = {"email": user["email"].lower(), "cat": phrase["cat"], "phrase": phrase.get("key", phrase["he"]),
2052
  "he": phrase["he"], "ar": result["variant"]["ar"], "ipa": result["variant"]["ipa"],
2053
  "heard_ipa": result["heard_ipa"], "score": result["score"], "unrelated": bool(result["unrelated"]),
2054
  "note": " ".join(note.split())[:MAX_NOTE], "result": result, "model": MODEL_ID, "version": build_version()}
2055
  try:
2056
+ app.state.auth.add_flag(row)
2057
  except AuthUnavailable as e:
2058
  if "answered 404" in str(e):
2059
  raise HTTPException(503, "הטבלה flags לא קיימת ב-Supabase. הריצו ב-SQL Editor: "
2060
  f"{CREATE_FLAGS} אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';")
2061
  raise HTTPException(503, f"Supabase לא זמין: {e}")
2062
+ return {"ok": True, "audio": False, "flag": row}
2063
 
2064
  return app
2065
 
static/guide.html CHANGED
@@ -92,7 +92,6 @@ section{display:flex;flex-direction:column;gap:16px}
92
  .ball.now{border-color:var(--olive);color:var(--olive);box-shadow:0 0 0 6px var(--olive-soft)}
93
  .ball.done{background:var(--ok-bg);border-color:var(--ok);color:var(--ok)}
94
  .ball.gold{background:var(--gold-bg);border-color:var(--gold);color:var(--gold)}
95
- .ball.locked{opacity:.6;font-size:18px}
96
  .ball.video{border-style:dashed;color:var(--olive)}
97
  .ball .badge{position:absolute;top:-4px;inset-inline-end:-4px;width:20px;height:20px;border-radius:50%;display:grid;place-items:center;font-size:11px;background:var(--ok);color:#fff}
98
  .ball.gold .badge{background:var(--gold)}
@@ -253,7 +252,7 @@ a,button{-webkit-tap-highlight-color:transparent}
253
  <div class="stop L" style="left:calc(26% - 26px);top:304px"><div class="ball started now"><div class="tag">התחילו</div><span>3</span></div><div class="lbl">משפטים א׳<span class="chip read">ער׳ ← עב׳</span><small>תורגלו 4 מתוך 10</small></div></div>
254
  <div class="stop" style="right:calc(50% - 26px);top:392px"><div class="ball"><div class="tag hw">שיעורי בית<small>עוד יומיים</small></div>💬</div><div class="lbl">סיפור השוק<small>תורגלו 0 מתוך 12</small></div></div>
255
  <span class="n" style="top:468px;right:-25px">6</span>
256
- <div class="stop" style="right:calc(26% - 26px);top:480px"><div class="ball locked">🔒</div><div class="lbl">כתיבת משפטים<small>נעול</small></div></div>
257
  </div>
258
  </div>
259
  <ol class="key">
@@ -262,7 +261,7 @@ a,button{-webkit-tap-highlight-color:transparent}
262
  <li><span class="n">3</span><div><b>לשוניות</b><span class="d">המסלול, הספרייה הדיגיטלית (סרטונים וטקסטים) וחבר מביא חבר.</span></div></li>
263
  <li><span class="n">4</span><div><b>כותרת השיעור</b><span class="d">הפס מראה כמה מהשיעור תורגל. לחיצה עליה פותחת את הנושא הבא שעוד לא נגמר. בסוף כל שיעור יש גביע 🏆 שמזהיב כשהשיעור הושלם.</span></div></li>
264
  <li><span class="n">5</span><div><b>הנושא של עכשיו</b><span class="d">מסומן בהילה ובבועה „התחילו” או „המשך”. אם גללתם רחוק, הכפתור „חזרה לנושא הנוכחי” מחזיר אליו.</span></div></li>
265
- <li><span class="n">6</span><div><b>נושאים נעולים</b><span class="d">נפתחים כשמתרגלים את כל המשפטים בנושאים שלפניהם. גם <b style="display:inline">מילים חדשות</b> ו<b style="display:inline">התאימו זוגות</b> נועלים את המשך השיעור עד שמסיימים אותם, אלא אם כבר התחלתם לתרגל את השיעור. נושא שהוא שיעורי בית פתוח תמיד, וסרטון או כתיבה לא נועלים את מה שאחריהם.</span></div></li>
266
  </ol>
267
  </div>
268
  </section>
@@ -278,7 +277,6 @@ a,button{-webkit-tap-highlight-color:transparent}
278
  <div class="state"><div class="ball started"><span>3</span></div><b>בתהליך</b><span>הטבעת מראה כמה כבר תורגל</span></div>
279
  <div class="state"><div class="ball done"><span class="badge">✓</span>2</div><b>הושלם</b><span>כל המשפטים נאמרו לפחות פעם אחת</span></div>
280
  <div class="state"><div class="ball gold"><span class="badge">★</span>1</div><b>זהב</b><span>כל המשפטים קיבלו „מצוין” (85 ומעלה)</span></div>
281
- <div class="state"><div class="ball locked">🔒</div><b>נעול</b><span>קודם מסיימים את הנושאים שלפניו</span></div>
282
  <div class="state"><div class="ball" style="border-color:var(--amber);margin-top:22px"><div class="tag hw" style="font-size:10px">עוד 3 ימים</div>5</div><b>שיעורי בית</b><span>הבועה סופרת את הימים עד ההגשה</span></div>
283
  </div>
284
  </section>
 
92
  .ball.now{border-color:var(--olive);color:var(--olive);box-shadow:0 0 0 6px var(--olive-soft)}
93
  .ball.done{background:var(--ok-bg);border-color:var(--ok);color:var(--ok)}
94
  .ball.gold{background:var(--gold-bg);border-color:var(--gold);color:var(--gold)}
 
95
  .ball.video{border-style:dashed;color:var(--olive)}
96
  .ball .badge{position:absolute;top:-4px;inset-inline-end:-4px;width:20px;height:20px;border-radius:50%;display:grid;place-items:center;font-size:11px;background:var(--ok);color:#fff}
97
  .ball.gold .badge{background:var(--gold)}
 
252
  <div class="stop L" style="left:calc(26% - 26px);top:304px"><div class="ball started now"><div class="tag">התחילו</div><span>3</span></div><div class="lbl">משפטים א׳<span class="chip read">ער׳ ← עב׳</span><small>תורגלו 4 מתוך 10</small></div></div>
253
  <div class="stop" style="right:calc(50% - 26px);top:392px"><div class="ball"><div class="tag hw">שיעורי בית<small>עוד יומיים</small></div>💬</div><div class="lbl">סיפור השוק<small>תורגלו 0 מתוך 12</small></div></div>
254
  <span class="n" style="top:468px;right:-25px">6</span>
255
+ <div class="stop" style="right:calc(26% - 26px);top:480px"><div class="ball">✎&#xFE0E;</div><div class="lbl">כתיבת משפטים<small>נכתבו 0 מתוך 8</small></div></div>
256
  </div>
257
  </div>
258
  <ol class="key">
 
261
  <li><span class="n">3</span><div><b>לשוניות</b><span class="d">המסלול, הספרייה הדיגיטלית (סרטונים וטקסטים) וחבר מביא חבר.</span></div></li>
262
  <li><span class="n">4</span><div><b>כותרת השיעור</b><span class="d">הפס מראה כמה מהשיעור תורגל. לחיצה עליה פותחת את הנושא הבא שעוד לא נגמר. בסוף כל שיעור יש גביע 🏆 שמזהיב כשהשיעור הושלם.</span></div></li>
263
  <li><span class="n">5</span><div><b>הנושא של עכשיו</b><span class="d">מסומן בהילה ובבועה „התחילו” או „המשך”. אם גללתם רחוק, הכפתור „חזרה לנושא הנוכחי” מחזיר אליו.</span></div></li>
264
+ <li><span class="n">6</span><div><b>כל הנושאים פתוחים</b><span class="d">אפשר לפתוח כל נושא בכל שיעור, גם בלי לסיים את מה שלפניו.</span></div></li>
265
  </ol>
266
  </div>
267
  </section>
 
277
  <div class="state"><div class="ball started"><span>3</span></div><b>בתהליך</b><span>הטבעת מראה כמה כבר תורגל</span></div>
278
  <div class="state"><div class="ball done"><span class="badge">✓</span>2</div><b>הושלם</b><span>כל המשפטים נאמרו לפחות פעם אחת</span></div>
279
  <div class="state"><div class="ball gold"><span class="badge">★</span>1</div><b>זהב</b><span>כל המשפטים קיבלו „מצוין” (85 ומעלה)</span></div>
 
280
  <div class="state"><div class="ball" style="border-color:var(--amber);margin-top:22px"><div class="tag hw" style="font-size:10px">עוד 3 ימים</div>5</div><b>שיעורי בית</b><span>הבועה סופרת את הימים עד ההגשה</span></div>
281
  </div>
282
  </section>
static/index.html CHANGED
@@ -68,8 +68,7 @@ header{display:flex;align-items:center;gap:4px 12px;flex-wrap:wrap}
68
  /* Home: the course as one winding road down the screen. A lesson opens with a banner and closes with a cup, and its
69
  topics are the round buttons along the road, each with its label beside it. The ring around a button fills as its
70
  sentences are practised: untouched is plain, started is tinted, finished is filled and gets a badge (gold when every
71
- sentence reached "מצוין"). The road is green after a finished topic. The one topic to do now carries a bubble and a halo.
72
- A locked topic (one before it is not finished) is grey with a padlock. */
73
  body.home #practice,body.home #driveBtn,body:not(.home) #path,body:not(.home) #today,body:not(.home) #toNow,body.home .where,body:not(.home) .brand,body:not(.home) .maphint{display:none}
74
  header .sub.maphint{flex:0 0 100%;color:var(--ink)}
75
  /* the student's guide (static/guide.html): a round "?" beside the class pill, and once, a line under the hint */
@@ -374,11 +373,6 @@ body.memory-game{overflow:hidden}
374
  .tag small{display:block;font:600 11px/1.3 var(--f-body);opacity:.85}
375
  .tag:has(small){text-align:center}
376
  .sat.soon{--c:var(--amber)} .sat.late{--c:var(--sumac)}
377
- .sat.locked{color:var(--muted);cursor:default}
378
- .sat.locked .dot{background:var(--bg);font-size:20px;filter:grayscale(1);opacity:.75}
379
- .sat.locked:active .dot{transform:none;box-shadow:0 var(--s) 0 var(--track)}
380
- .sat.locked .tag{background:var(--ink);color:var(--bg)}
381
- .chapter.locked,.chapter.locked:active{background:var(--track);box-shadow:0 4px 0 var(--muted);transform:none;cursor:default}
382
  .tag.soon{background:var(--amber)} .tag.late{background:var(--sumac)}
383
  .sat.now .ring::after{content:"";position:absolute;inset:-5px;border-radius:50%;border:2px solid var(--c);animation:beckon 1.8s ease-out infinite}
384
  @keyframes beckon{from{transform:scale(.92);opacity:.9}to{transform:scale(1.18);opacity:0}}
@@ -510,7 +504,8 @@ body.driving .mic::before{display:none} body.driving .mic-zone.two{display:flex}
510
  #denied button{display:block;margin:12px auto 0;font:600 16px/1 var(--f-body);padding:10px 22px;border-radius:10px;border:1px solid #4B6A2C;background:#fff;color:#4B6A2C;cursor:pointer}
511
  /* The teacher's panel (its parts are styled further down: the class tabs, the figures, the lessons, the students). */
512
  body:not(.manage) #manage,body.lab #manage,body:not(.lab) #lab,body.manage #wrap>header,body.manage #path,body.manage #today,body.manage #toNow,body.manage #practice,body:not(.home) #toManage{display:none}
513
- body.lab #wrap{max-width:none}
 
514
  .mtitle{font:400 24px/1 var(--f-display);margin:0}
515
  #mState.setup{padding:10px 12px;border:1px solid var(--sumac);border-radius:10px;color:var(--ink)}
516
  #mState.setup code{display:block;margin-top:6px;font-size:12px;overflow-wrap:anywhere;user-select:all}
@@ -736,7 +731,7 @@ body.lab #wrap{max-width:none}
736
  #manage.content :is(#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.content) #mContent{display:none}
737
  #manage.flags :is(#mClasses,#mContent,#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.flags) #mFlags{display:none}
738
  #manage.access :is(#mClasses,#mContent,#mFlags,#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.access) #mAccess{display:none}
739
- .access-scroll{overflow:auto}.access-table{width:100%;border-collapse:collapse;min-width:980px}.access-table :is(th,td){padding:9px 7px;border-bottom:1px solid var(--line);text-align:center}.access-table th{position:sticky;top:0;background:var(--surface);font-size:13px;white-space:nowrap;z-index:1}.access-table :is(th,td):first-child{text-align:start;position:sticky;right:0;background:var(--surface);z-index:2;min-width:190px}.access-table td:first-child{font-weight:700}.access-table td:first-child small{display:block;color:var(--muted);font-weight:400;direction:ltr;overflow:hidden;text-overflow:ellipsis}.access-table input{width:18px;height:18px}.access-table .save-cell{min-width:80px}
740
  @media(max-width:700px){.modes{grid-template-columns:repeat(2,1fr)}}
741
  .flag-review{gap:10px}.flag-review h2{margin:0}.flag-review .meta{color:var(--muted);font-size:13px}.flag-review fieldset{border:0;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:7px}
742
  .flag-review fieldset label{display:flex;align-items:center;gap:5px;padding:6px 8px;border:1px solid var(--line);border-radius:9px}.flag-review select,.flag-review textarea{font:inherit;border:1px solid var(--line);border-radius:9px;padding:7px;background:var(--surface);color:var(--ink)}
@@ -1726,9 +1721,8 @@ function setTr(el,text){
1726
  }
1727
  /* Home is the path: one road winding down the screen through every lesson. A lesson opens with a banner, which
1728
  opens its first unfinished topic; its topics follow as buttons along the road, and a cup closes it.
1729
- As in Duolingo, a topic is locked until every topic before it on the path is finished. A topic the student already
1730
- started stays open, and so does a homework topic: the teacher decides what the class practises. Nothing is locked for a teacher. */
1731
- let FREE=false; // a teacher: the whole path is open
1732
  let TUTOR_INFO=null, tc=null, tutorList=false; // what /api/tutor allows this account; the open conversation; the games tab shows the scenarios
1733
  let lastPhone=null; // the latest ordinary recording's score response, for the teacher's phoneme observer
1734
  let home=true; // the path is on screen, not the practice card: nothing is read aloud or recorded
@@ -1995,7 +1989,7 @@ function drawVerbTable(box){
1995
  tr.append(one(past),mark("","·","i"),one(future||"")); say.append(tr);
1996
  say.onclick=async()=>{
1997
  if(vtOnly) vtOnly.classList.remove("on"); vtOnly=say; say.classList.add("on");
1998
- const a=await answerAudio(p.id); if(a) await playUrl(a.url,true); else await speakLocal(p.ar,"ar");
1999
  say.classList.remove("on");
2000
  };
2001
  row.dataset.find=vtPlain(`${p.he} ${p.tr} ${p.ar}`); row.append(he,say); card.append(row);
@@ -2339,8 +2333,8 @@ function mgCount(){
2339
  async function mgSay(p){ // the Arabic, in the lessons' voice, or else the device's
2340
  if(mg.audio) mg.audio.pause();
2341
  const a=await answerAudio(p.id); if(!mg) return;
2342
- if(!a){ speakLocal(p.ar,"ar"); return; }
2343
- const au=answerPlayer(a.url); mg.audio=au; au.play().catch(()=>speakLocal(p.ar,"ar"));
2344
  }
2345
  function mgAt(b){ const r=b.el.getBoundingClientRect(), f=mg.field.getBoundingClientRect(); return {x:r.left-f.left+r.width/2,y:r.top-f.top+r.height/2}; }
2346
  function mgBurst(x,y,many=12){
@@ -2539,8 +2533,7 @@ $("writeClose").onclick=()=>$("writeDlg").close();
2539
  $("writeDlg").addEventListener("close",()=>{
2540
  if(writeTimer){ clearTimeout(writeTimer); writeTimer=null; sendWriting(writeOpen); } // what was just written is sent at once
2541
  writeOpen=null; if(home&&!document.body.classList.contains("manage")) drawPath(true); });
2542
- /* A lesson's new words ("learn" topic): a card a word, before the lesson's speaking topics, which stay locked until
2543
- every word was marked known once. Nothing is scored; what the student knows is kept on the device. A round goes
2544
  through the words not known yet (all of them, shuffled, when every one is); "not yet" sends a word to the round's end. */
2545
  let learned=store.get("ihki.learned",{}), learnRounds=store.get("ihki.learnRounds",{}), learning=null; // known words; unfinished rounds; the round on screen
2546
  let learnDriving=false, learnDriveRun=0, learnWakeLock=null;
@@ -2757,15 +2750,6 @@ function drawPath(stay){
2757
  const road=document.createElementNS(NS,"svg"), stops=[]; // stops: the points the road goes through
2758
  road.setAttribute("class","road"); road.setAttribute("aria-hidden","true"); box.innerHTML=""; box.append(road);
2759
  let next=true, y=0; // next: the first lesson that is not finished is the one to do now
2760
- let open=true; // every topic so far is finished: the next one is not locked
2761
- const lock=(b,dot,name)=>{ // a locked stop: a padlock, and a tap says why
2762
- b.classList.add("locked"); b.setAttribute("aria-disabled","true"); dot.textContent="🔒";
2763
- b.title=`נעול: ${name}. קודם מסיימים את הנושאים שלפניו`; b.setAttribute("aria-label",b.title);
2764
- b.onclick=()=>{
2765
- if(b.querySelector(".tag")) return;
2766
- const tag=mark("tag",""); tag.append(mark("","נעול","small"),"סיימו את הקודם"); dot.before(tag); setTimeout(()=>tag.remove(),2200);
2767
- };
2768
- };
2769
  let level=null;
2770
  for(let k=0;k<LESSONS.length;k++){
2771
  const l=LESSONS[k];
@@ -2782,21 +2766,11 @@ function drawPath(stay){
2782
  const whole=tally(listed.filter(p=>cats.some(c=>c.id===p.cat))), now=!whole.over&&next;
2783
  const todo=Math.max(0,parts.findIndex(t=>!t.over)); // the topic to do now, when this is the lesson to do now
2784
  if(now) next=false;
2785
- const shut=items.map(({c})=>{ // locked: a topic before it is not finished (a written one, like a video, holds nothing back)
2786
- if(!c) return !open;
2787
- const t=parts[cats.indexOf(c)], s=!open&&!t.done&&!hws.some(h=>h.c===c);
2788
- if(t.n&&!t.over&&!c.write&&!((c.learn||c.match)&&whole.done)&&!FREE) open=false; // new words and their game hold back only a lesson not yet practised
2789
- return s;
2790
- });
2791
- const go=parts.every(t=>t.over)?cats[0]:cats.find((c,i)=>!parts[i].over&&!shut[items.findIndex(it=>it.c===c)]); // the topic the banner opens: none in a locked lesson
2792
  const head=document.createElement("button");
2793
  head.className="chapter"+(whole.gold?" gold":""); head.style.top=y+"px"; head.style.setProperty("--p",`${whole.n?whole.done/whole.n*100:0}%`);
2794
  head.append(mark("",l.name),count(whole),document.createElement("i")); head.title=`${l.name}: תורגלו ${whole.done} מתוך ${whole.n}`;
2795
  if(go) head.onclick=()=>openCat(go);
2796
- else{ // a locked lesson: its banner leads back to the topic to do now
2797
- head.classList.add("locked"); head.setAttribute("aria-disabled","true"); head.title=`נעול: ${l.name}. קודם מסיימים את הנושאים שלפניו`; head.querySelector("small").prepend("🔒 ");
2798
- head.onclick=()=>{ const at=box.querySelector("[aria-current]"); if(at) at.scrollIntoView({block:"center",behavior:"smooth"}); };
2799
- }
2800
  box.append(head);
2801
  y+=64+86; // the banner, room for the bubble, half a button
2802
  const seat=(b,i)=>{ // a button's place on the road, and its label
@@ -2811,7 +2785,7 @@ function drawPath(stay){
2811
  b.classList.add("video"); dot.textContent="▶\uFE0E"; if(seen) dot.before(mark("badge","✓"));
2812
  label.append(mark("",v.title,"b"),mark("",seen?"✓ נצפה":"סרטון","small")); b.append(label);
2813
  b.title=`סרטון: ${v.title}`; b.setAttribute("aria-label",b.title);
2814
- b.onclick=()=>playVideo(v); if(shut[at]) lock(b,dot,v.title); box.append(b);
2815
  stops.push({x,y,went:seen,breakBefore:at===0}); y+=STEP; return;
2816
  }
2817
  const i=cats.indexOf(c), t=parts[i], cur=now&&i===todo, [b,dot]=ball(t,cur), [x,label]=seat(b,at);
@@ -2830,7 +2804,7 @@ function drawPath(stay){
2830
  const did=c.write?"נכתבו":c.learn?"נלמדו":c.match?"הותאמו":"תורגלו";
2831
  label.append(mine&&t.over?mark("","✓ שיעורי הבית הושלמו 🎉","small"):count(t,did)); b.append(label);
2832
  b.title=(mine&&!t.over?`שיעורי בית, ${hw.msg}. `:"")+`${c.name}: ${did} ${t.done} מתוך ${t.n}`; b.setAttribute("aria-label",b.title);
2833
- b.onclick=()=>openCat(c); if(shut[at]) lock(b,dot,c.name); box.append(b);
2834
  stops.push({x,y,went:t.over,cur,breakBefore:at===0}); y+=STEP;
2835
  });
2836
  const cup=document.createElement("div"), x=-SWAY[items.length%8]*amp; // the lesson ends with the masbaha's ornamental finial
@@ -3717,7 +3691,7 @@ $("flagBtn").onclick=async()=>{
3717
  const res=await api("/api/flags",{method:"POST",body:fd});
3718
  if(!res.ok){ const e=await res.json().catch(()=>({})); throw new Error(e.detail||res.status); }
3719
  const out=await res.json();
3720
- msg.textContent=out.audio?"נשמר למורה, עם ההקלטה. תודה!":"נשמר למורה, אבל בלי ההקלטה (אין מקום אחסון flags ב-Supabase).";
3721
  $("flagNote").disabled=true; // one flag for a recording
3722
  if(out.flag){ drawFlag(out.flag,out.audio); $("flagDlg").showModal(); }
3723
  }catch(err){ msg.textContent="הדיווח לא נשמר: "+err.message; btn.disabled=false; }
@@ -3744,7 +3718,7 @@ function drawFlag(f,audio){
3744
  fact("פונמות צפויות",mark("ipa",f.ipa)).className="ipa";
3745
  fact("פונמות שנשמעו",mark("ipa",f.heard_ipa||"–")).className="ipa";
3746
  if(f.note) fact("ההערה שלך",f.note);
3747
- fact("ההקלטה",audio?"נשמרה עם הדיווח":"לא נשמרה (אין מקום אחסון flags ב-Supabase)");
3748
  fact("המודל",mark("ipa",f.model+(f.version?" · "+f.version:""))).className="ipa";
3749
  }
3750
  $("flagClose").onclick=()=>$("flagDlg").close();
@@ -3859,7 +3833,7 @@ async function bubbleOutcome(r,expired=false){
3859
  if(cur.id!==id||home) return;
3860
  const a=await answerAudio(id);
3861
  if(cur.id!==id||home) return;
3862
- if(a) await playUrl(a.url,true); else await speakLocal(cur.ar,"ar");
3863
  await pause(80);
3864
  }
3865
  bubbleResolving=false;
@@ -4089,7 +4063,13 @@ function playUrl(url,answer){ // answer: the Arabic answer, played at the chos
4089
  function speakLocal(text,lang){ // device voice, when the server could not speak
4090
  return new Promise(res=>{
4091
  if(!("speechSynthesis" in window)) return res();
4092
- const u=new SpeechSynthesisUtterance(text); u.lang=lang; if(lang==="ar") u.rate=answerRate(); u.onend=u.onerror=()=>res();
 
 
 
 
 
 
4093
  speechSynthesis.cancel(); speechSynthesis.speak(u);
4094
  });
4095
  }
@@ -4233,7 +4213,7 @@ const ACCESS_COLUMNS=[
4233
  {label:"מעבדה",kind:"roles",value:"developer",permission:"lab.access"},
4234
  {label:"משחקים",kind:"entitlements",value:"games",permission:"games.use"},
4235
  {label:"ספרייה דיגיטלית",kind:"entitlements",value:"library",permission:"library.view"},
4236
- {label:"דיווח תקלות",kind:"roles",value:"flagger",permission:"flags.create"},
4237
  {label:"יאללה נדבר",kind:"entitlements",value:"tutor",permission:"tutor.use"},
4238
  {label:"מסלול",kind:"entitlements",value:"path",permission:"path.use"},
4239
  ];
@@ -4254,31 +4234,58 @@ function drawAccess(){
4254
  const note=mark("sub","כל עמודה היא הרשאה נפרדת. יאללה נדבר מופרד ממשחקים כי הוא משתמש בשירות API בתשלום. רק מפתחים יכולים לתת גישה למעבדה.","p"); box.append(note);
4255
  if(ACCESS.migration){ const details=document.createElement("details"), summary=document.createElement("summary"), code=mark("",ACCESS.migration,"code"); summary.textContent="שדרוג טבלת הרשאות קיימת"; code.dir="ltr"; details.className="setup"; details.append(summary,code); box.append(details); }
4256
  const scroll=mark("access-scroll",""), table=mark("access-table","","table"), head=document.createElement("thead"), hr=document.createElement("tr");
4257
- hr.append(mark("","משתמש","th")); for(const col of ACCESS_COLUMNS) hr.append(mark("",col.label,"th")); hr.append(mark("","","th")); head.append(hr); table.append(head);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4258
  const body=document.createElement("tbody");
 
 
 
 
 
 
 
 
 
 
 
4259
  for(const user of ACCESS.users||[]){
4260
- const row=document.createElement("tr"), who=document.createElement("td"), email=mark("",user.email,"small"); email.dir="ltr"; who.append(user.name||user.email,email); row.append(who);
4261
  for(const col of ACCESS_COLUMNS){
4262
  const td=document.createElement("td"), input=document.createElement("input"); input.type="checkbox"; input.dataset.kind=col.kind; input.value=col.value; input.checked=(user[col.kind]||[]).includes(col.value)||(user.permissions||[]).includes("*")||(user.permissions||[]).includes(col.permission);
4263
  input.setAttribute("aria-label",`${col.label}: ${user.name||user.email}`);
4264
- input.disabled=user.id===ACCESS.me||(col.value==="developer"&&!can("lab.access"))||(user.fixed_roles||[]).includes(col.value);
 
4265
  if((user.fixed_roles||[]).includes(col.value)) input.title="נקבע בהגדרות השרת"; td.append(input); row.append(td);
4266
  }
4267
- const save=mark("btn primary","שמירה","button"), saveCell=mark("save-cell","","td"); saveCell.append(save); row.append(saveCell); save.disabled=user.id===ACCESS.me||((user.fixed_roles||[]).includes("developer")&&!can("lab.access"));
4268
- save.onclick=async()=>{
4269
- const selected=kind=>[...row.querySelectorAll(`input[data-kind="${kind}"]:checked`)].map(x=>x.value);
4270
- const roles=[...(user.roles||[]).filter(x=>!["admin","developer","flagger"].includes(x)),...selected("roles")];
4271
- if(!roles.length) roles.push("student");
4272
- save.disabled=true;
4273
- try{
4274
- const res=await api(`/api/access/users/${user.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({roles,entitlements:selected("entitlements")})});
4275
- const data=await res.json().catch(()=>({})); if(!res.ok) throw new Error(data.detail||res.status);
4276
- note.textContent=`ההרשאות של ${user.name||user.email} נשמרו.`; await loadAccess(); drawAccess();
4277
- }catch(e){ note.textContent="השמירה נכשלה: "+e.message; save.disabled=false; }
4278
- };
4279
  body.append(row);
4280
  }
4281
- table.append(body); scroll.append(table); box.append(scroll);
 
 
 
 
 
 
 
 
 
 
4282
  }
4283
  function drawManage(){
4284
  const classes=CLASS.classes||[];
 
68
  /* Home: the course as one winding road down the screen. A lesson opens with a banner and closes with a cup, and its
69
  topics are the round buttons along the road, each with its label beside it. The ring around a button fills as its
70
  sentences are practised: untouched is plain, started is tinted, finished is filled and gets a badge (gold when every
71
+ sentence reached "מצוין"). The road is green after a finished topic. The one topic to do now carries a bubble and a halo. */
 
72
  body.home #practice,body.home #driveBtn,body:not(.home) #path,body:not(.home) #today,body:not(.home) #toNow,body.home .where,body:not(.home) .brand,body:not(.home) .maphint{display:none}
73
  header .sub.maphint{flex:0 0 100%;color:var(--ink)}
74
  /* the student's guide (static/guide.html): a round "?" beside the class pill, and once, a line under the hint */
 
373
  .tag small{display:block;font:600 11px/1.3 var(--f-body);opacity:.85}
374
  .tag:has(small){text-align:center}
375
  .sat.soon{--c:var(--amber)} .sat.late{--c:var(--sumac)}
 
 
 
 
 
376
  .tag.soon{background:var(--amber)} .tag.late{background:var(--sumac)}
377
  .sat.now .ring::after{content:"";position:absolute;inset:-5px;border-radius:50%;border:2px solid var(--c);animation:beckon 1.8s ease-out infinite}
378
  @keyframes beckon{from{transform:scale(.92);opacity:.9}to{transform:scale(1.18);opacity:0}}
 
504
  #denied button{display:block;margin:12px auto 0;font:600 16px/1 var(--f-body);padding:10px 22px;border-radius:10px;border:1px solid #4B6A2C;background:#fff;color:#4B6A2C;cursor:pointer}
505
  /* The teacher's panel (its parts are styled further down: the class tabs, the figures, the lessons, the students). */
506
  body:not(.manage) #manage,body.lab #manage,body:not(.lab) #lab,body.manage #wrap>header,body.manage #path,body.manage #today,body.manage #toNow,body.manage #practice,body:not(.home) #toManage{display:none}
507
+ body.lab #wrap,body.manage #wrap:has(#manage.access){max-width:none}
508
+ body.manage #manage{width:100%;max-width:none}
509
  .mtitle{font:400 24px/1 var(--f-display);margin:0}
510
  #mState.setup{padding:10px 12px;border:1px solid var(--sumac);border-radius:10px;color:var(--ink)}
511
  #mState.setup code{display:block;margin-top:6px;font-size:12px;overflow-wrap:anywhere;user-select:all}
 
731
  #manage.content :is(#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.content) #mContent{display:none}
732
  #manage.flags :is(#mClasses,#mContent,#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.flags) #mFlags{display:none}
733
  #manage.access :is(#mClasses,#mContent,#mFlags,#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.access) #mAccess{display:none}
734
+ .access-actions{display:flex;justify-content:flex-end;margin:8px 0}.access-scroll{overflow:auto}.access-table{width:100%;border-collapse:collapse;min-width:1180px}.access-table :is(th,td){padding:9px 10px;border-bottom:1px solid var(--line);text-align:center}.access-table th{position:sticky;top:0;background:var(--surface);font-size:13px;white-space:nowrap;z-index:1}.access-table th>span{display:block;margin-bottom:4px}.access-table .access-all{display:inline-flex;align-items:center;gap:4px;color:var(--muted);font-weight:600;cursor:pointer}.access-table .access-all small{font-size:11px}.access-table :is(th,td):first-child{text-align:start;position:sticky;right:0;background:var(--surface);z-index:2;min-width:260px}.access-table td:first-child{font-weight:700}.access-table td:first-child small{display:block;color:var(--muted);font-weight:400;direction:ltr;overflow:hidden;text-overflow:ellipsis}.access-table input{width:18px;height:18px}.access-table .save-cell{min-width:96px}
735
  @media(max-width:700px){.modes{grid-template-columns:repeat(2,1fr)}}
736
  .flag-review{gap:10px}.flag-review h2{margin:0}.flag-review .meta{color:var(--muted);font-size:13px}.flag-review fieldset{border:0;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:7px}
737
  .flag-review fieldset label{display:flex;align-items:center;gap:5px;padding:6px 8px;border:1px solid var(--line);border-radius:9px}.flag-review select,.flag-review textarea{font:inherit;border:1px solid var(--line);border-radius:9px;padding:7px;background:var(--surface);color:var(--ink)}
 
1721
  }
1722
  /* Home is the path: one road winding down the screen through every lesson. A lesson opens with a banner, which
1723
  opens its first unfinished topic; its topics follow as buttons along the road, and a cup closes it.
1724
+ Every topic is open, whatever comes before it on the path. */
1725
+ let FREE=false; // a teacher
 
1726
  let TUTOR_INFO=null, tc=null, tutorList=false; // what /api/tutor allows this account; the open conversation; the games tab shows the scenarios
1727
  let lastPhone=null; // the latest ordinary recording's score response, for the teacher's phoneme observer
1728
  let home=true; // the path is on screen, not the practice card: nothing is read aloud or recorded
 
1989
  tr.append(one(past),mark("","·","i"),one(future||"")); say.append(tr);
1990
  say.onclick=async()=>{
1991
  if(vtOnly) vtOnly.classList.remove("on"); vtOnly=say; say.classList.add("on");
1992
+ const a=await answerAudio(p.id); if(a) await playUrl(a.url,true); else await speakLocal(p.ar_pointed||p.ar,"ar");
1993
  say.classList.remove("on");
1994
  };
1995
  row.dataset.find=vtPlain(`${p.he} ${p.tr} ${p.ar}`); row.append(he,say); card.append(row);
 
2333
  async function mgSay(p){ // the Arabic, in the lessons' voice, or else the device's
2334
  if(mg.audio) mg.audio.pause();
2335
  const a=await answerAudio(p.id); if(!mg) return;
2336
+ if(!a){ speakLocal(p.ar_pointed||p.ar,"ar"); return; }
2337
+ const au=answerPlayer(a.url); mg.audio=au; au.play().catch(()=>speakLocal(p.ar_pointed||p.ar,"ar"));
2338
  }
2339
  function mgAt(b){ const r=b.el.getBoundingClientRect(), f=mg.field.getBoundingClientRect(); return {x:r.left-f.left+r.width/2,y:r.top-f.top+r.height/2}; }
2340
  function mgBurst(x,y,many=12){
 
2533
  $("writeDlg").addEventListener("close",()=>{
2534
  if(writeTimer){ clearTimeout(writeTimer); writeTimer=null; sendWriting(writeOpen); } // what was just written is sent at once
2535
  writeOpen=null; if(home&&!document.body.classList.contains("manage")) drawPath(true); });
2536
+ /* A lesson's new words ("learn" topic): a card a word, before the lesson's speaking topics. Nothing is scored; what the student knows is kept on the device. A round goes
 
2537
  through the words not known yet (all of them, shuffled, when every one is); "not yet" sends a word to the round's end. */
2538
  let learned=store.get("ihki.learned",{}), learnRounds=store.get("ihki.learnRounds",{}), learning=null; // known words; unfinished rounds; the round on screen
2539
  let learnDriving=false, learnDriveRun=0, learnWakeLock=null;
 
2750
  const road=document.createElementNS(NS,"svg"), stops=[]; // stops: the points the road goes through
2751
  road.setAttribute("class","road"); road.setAttribute("aria-hidden","true"); box.innerHTML=""; box.append(road);
2752
  let next=true, y=0; // next: the first lesson that is not finished is the one to do now
 
 
 
 
 
 
 
 
 
2753
  let level=null;
2754
  for(let k=0;k<LESSONS.length;k++){
2755
  const l=LESSONS[k];
 
2766
  const whole=tally(listed.filter(p=>cats.some(c=>c.id===p.cat))), now=!whole.over&&next;
2767
  const todo=Math.max(0,parts.findIndex(t=>!t.over)); // the topic to do now, when this is the lesson to do now
2768
  if(now) next=false;
2769
+ const go=cats[parts.findIndex(t=>!t.over)]||cats[0]; // the topic the banner opens: the first unfinished one
 
 
 
 
 
 
2770
  const head=document.createElement("button");
2771
  head.className="chapter"+(whole.gold?" gold":""); head.style.top=y+"px"; head.style.setProperty("--p",`${whole.n?whole.done/whole.n*100:0}%`);
2772
  head.append(mark("",l.name),count(whole),document.createElement("i")); head.title=`${l.name}: תורגלו ${whole.done} מתוך ${whole.n}`;
2773
  if(go) head.onclick=()=>openCat(go);
 
 
 
 
2774
  box.append(head);
2775
  y+=64+86; // the banner, room for the bubble, half a button
2776
  const seat=(b,i)=>{ // a button's place on the road, and its label
 
2785
  b.classList.add("video"); dot.textContent="▶\uFE0E"; if(seen) dot.before(mark("badge","✓"));
2786
  label.append(mark("",v.title,"b"),mark("",seen?"✓ נצפה":"סרטון","small")); b.append(label);
2787
  b.title=`סרטון: ${v.title}`; b.setAttribute("aria-label",b.title);
2788
+ b.onclick=()=>playVideo(v); box.append(b);
2789
  stops.push({x,y,went:seen,breakBefore:at===0}); y+=STEP; return;
2790
  }
2791
  const i=cats.indexOf(c), t=parts[i], cur=now&&i===todo, [b,dot]=ball(t,cur), [x,label]=seat(b,at);
 
2804
  const did=c.write?"נכתבו":c.learn?"נלמדו":c.match?"הותאמו":"תורגלו";
2805
  label.append(mine&&t.over?mark("","✓ שיעורי הבית הושלמו 🎉","small"):count(t,did)); b.append(label);
2806
  b.title=(mine&&!t.over?`שיעורי בית, ${hw.msg}. `:"")+`${c.name}: ${did} ${t.done} מתוך ${t.n}`; b.setAttribute("aria-label",b.title);
2807
+ b.onclick=()=>openCat(c); box.append(b);
2808
  stops.push({x,y,went:t.over,cur,breakBefore:at===0}); y+=STEP;
2809
  });
2810
  const cup=document.createElement("div"), x=-SWAY[items.length%8]*amp; // the lesson ends with the masbaha's ornamental finial
 
3691
  const res=await api("/api/flags",{method:"POST",body:fd});
3692
  if(!res.ok){ const e=await res.json().catch(()=>({})); throw new Error(e.detail||res.status); }
3693
  const out=await res.json();
3694
+ msg.textContent="האבחון נשמר למורה. קובץ הקול לא נשמר.";
3695
  $("flagNote").disabled=true; // one flag for a recording
3696
  if(out.flag){ drawFlag(out.flag,out.audio); $("flagDlg").showModal(); }
3697
  }catch(err){ msg.textContent="הדיווח לא נשמר: "+err.message; btn.disabled=false; }
 
3718
  fact("פונמות צפויות",mark("ipa",f.ipa)).className="ipa";
3719
  fact("פונמות שנשמעו",mark("ipa",f.heard_ipa||"–")).className="ipa";
3720
  if(f.note) fact("ההערה שלך",f.note);
3721
+ fact("פרטיות","קובץ הקול עובד לצורך האבחון ולא נשמר");
3722
  fact("המודל",mark("ipa",f.model+(f.version?" · "+f.version:""))).className="ipa";
3723
  }
3724
  $("flagClose").onclick=()=>$("flagDlg").close();
 
3833
  if(cur.id!==id||home) return;
3834
  const a=await answerAudio(id);
3835
  if(cur.id!==id||home) return;
3836
+ if(a) await playUrl(a.url,true); else await speakLocal(cur.ar_pointed||cur.ar,"ar");
3837
  await pause(80);
3838
  }
3839
  bubbleResolving=false;
 
4063
  function speakLocal(text,lang){ // device voice, when the server could not speak
4064
  return new Promise(res=>{
4065
  if(!("speechSynthesis" in window)) return res();
4066
+ const u=new SpeechSynthesisUtterance(text);
4067
+ if(lang==="ar"){
4068
+ const v=arVoice();
4069
+ if(v){ u.voice=v; u.lang=v.lang; } else u.lang="ar-PS";
4070
+ u.rate=answerRate();
4071
+ }else u.lang=lang;
4072
+ u.onend=u.onerror=()=>res();
4073
  speechSynthesis.cancel(); speechSynthesis.speak(u);
4074
  });
4075
  }
 
4213
  {label:"מעבדה",kind:"roles",value:"developer",permission:"lab.access"},
4214
  {label:"משחקים",kind:"entitlements",value:"games",permission:"games.use"},
4215
  {label:"ספרייה דיגיטלית",kind:"entitlements",value:"library",permission:"library.view"},
4216
+ {label:"דיווחי תקלות",kind:"roles",value:"flagger",permission:"flags.create"},
4217
  {label:"יאללה נדבר",kind:"entitlements",value:"tutor",permission:"tutor.use"},
4218
  {label:"מסלול",kind:"entitlements",value:"path",permission:"path.use"},
4219
  ];
 
4234
  const note=mark("sub","כל עמודה היא הרשאה נפרדת. יאללה נדבר מופרד ממשחקים כי הוא משתמש בשירות API בתשלום. רק מפתחים יכולים לתת גישה למעבדה.","p"); box.append(note);
4235
  if(ACCESS.migration){ const details=document.createElement("details"), summary=document.createElement("summary"), code=mark("",ACCESS.migration,"code"); summary.textContent="שדרוג טבלת הרשאות קיימת"; code.dir="ltr"; details.className="setup"; details.append(summary,code); box.append(details); }
4236
  const scroll=mark("access-scroll",""), table=mark("access-table","","table"), head=document.createElement("thead"), hr=document.createElement("tr");
4237
+ const allFor=col=>[...table.querySelectorAll('tbody input[type="checkbox"]')].filter(x=>!x.disabled&&x.dataset.kind===col.kind&&x.value===col.value);
4238
+ const syncAll=()=>{
4239
+ for(const col of ACCESS_COLUMNS){
4240
+ const master=head.querySelector(`input[data-kind="${col.kind}"][value="${col.value}"]`), inputs=allFor(col), marked=inputs.filter(x=>x.checked).length;
4241
+ master.checked=!!inputs.length&&marked===inputs.length; master.indeterminate=marked>0&&marked<inputs.length; master.disabled=!inputs.length;
4242
+ }
4243
+ };
4244
+ hr.append(mark("","משתמש","th"));
4245
+ for(const col of ACCESS_COLUMNS){
4246
+ const th=document.createElement("th"), label=mark("",col.label,"span"), all=document.createElement("input"), allLabel=mark("access-all","","label");
4247
+ all.type="checkbox"; all.dataset.kind=col.kind; all.value=col.value; all.setAttribute("aria-label",`סימון כל המשתמשים: ${col.label}`); all.title=`סימון כל המשתמשים בעמודה ${col.label}`;
4248
+ all.onchange=()=>{ for(const input of allFor(col)){ input.checked=all.checked; input.closest("tr").dataset.dirty="1"; } syncAll(); };
4249
+ allLabel.append(all,mark("","הכול","small")); th.append(label,allLabel); hr.append(th);
4250
+ }
4251
+ hr.append(mark("","","th")); head.append(hr); table.append(head);
4252
  const body=document.createElement("tbody");
4253
+ const selected=(row,kind)=>[...row.querySelectorAll(`input[data-kind="${kind}"]:checked`)].map(x=>x.value);
4254
+ const saveRow=async(row,user,save)=>{
4255
+ const roles=[...(user.roles||[]).filter(x=>!["admin","developer","flagger"].includes(x)),...selected(row,"roles")], entitlements=selected(row,"entitlements");
4256
+ if(!roles.length) roles.push("student");
4257
+ save.disabled=true;
4258
+ try{
4259
+ const res=await api(`/api/access/users/${user.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({roles,entitlements})});
4260
+ const data=await res.json().catch(()=>({})); if(!res.ok) throw new Error(data.detail||res.status);
4261
+ user.roles=roles; user.entitlements=entitlements; delete row.dataset.dirty; save.disabled=false; return true;
4262
+ }catch(e){ note.textContent="השמירה נכשלה: "+e.message; save.disabled=false; return false; }
4263
+ };
4264
  for(const user of ACCESS.users||[]){
4265
+ const row=document.createElement("tr"), who=document.createElement("td"), email=mark("",user.email,"small"), rowLocked=user.id===ACCESS.me||((user.fixed_roles||[]).includes("developer")&&!can("lab.access")); email.dir="ltr"; who.append(user.name||user.email,email); row.append(who);
4266
  for(const col of ACCESS_COLUMNS){
4267
  const td=document.createElement("td"), input=document.createElement("input"); input.type="checkbox"; input.dataset.kind=col.kind; input.value=col.value; input.checked=(user[col.kind]||[]).includes(col.value)||(user.permissions||[]).includes("*")||(user.permissions||[]).includes(col.permission);
4268
  input.setAttribute("aria-label",`${col.label}: ${user.name||user.email}`);
4269
+ input.disabled=rowLocked||(col.value==="developer"&&!can("lab.access"))||(user.fixed_roles||[]).includes(col.value);
4270
+ input.onchange=()=>{ row.dataset.dirty="1"; syncAll(); };
4271
  if((user.fixed_roles||[]).includes(col.value)) input.title="נקבע בהגדרות השרת"; td.append(input); row.append(td);
4272
  }
4273
+ const save=mark("btn primary","שמירה","button"), saveCell=mark("save-cell","","td"); saveCell.append(save); row.append(saveCell); save.disabled=rowLocked;
4274
+ save.onclick=async()=>{ if(await saveRow(row,user,save)) note.textContent=`ההרשאות של ${user.name||user.email} נשמרו.`; };
4275
+ row._access={user,save};
 
 
 
 
 
 
 
 
 
4276
  body.append(row);
4277
  }
4278
+ const saveAll=mark("btn primary","שמירת כל השינויים","button"), actions=mark("access-actions","","div"); actions.append(saveAll);
4279
+ saveAll.onclick=async()=>{
4280
+ const rows=[...body.rows].filter(row=>row.dataset.dirty&&row._access);
4281
+ if(!rows.length){ note.textContent="אין שינויים לשמור."; return; }
4282
+ saveAll.disabled=true; let saved=0;
4283
+ for(const row of rows){ const {user,save}=row._access; if(await saveRow(row,user,save)) saved++; }
4284
+ saveAll.disabled=false; syncAll();
4285
+ if(saved===rows.length) note.textContent=`נשמרו הרשאות עבור ${saved} משתמשים.`;
4286
+ else note.textContent=`נשמרו ${saved} מתוך ${rows.length} משתמשים. בדקו את השורות שלא נשמרו.`;
4287
+ };
4288
+ table.append(body); syncAll(); scroll.append(table); box.append(actions,scroll);
4289
  }
4290
  function drawManage(){
4291
  const classes=CLASS.classes||[];
tests/test_scoring.py CHANGED
@@ -1714,24 +1714,21 @@ def test_a_tutor_flags_a_recording_scored_wrong(monkeypatch):
1714
  def texts(self, fresh=False):
1715
  return []
1716
 
1717
- def add_flag(self, row, audio, mime, name):
1718
  if not self.table:
1719
  raise AuthUnavailable("supabase answered 404: no table")
1720
- self.flags.append((row, audio, mime, name))
1721
- return True
1722
 
1723
  def list_flags(self, status="pending"):
1724
  if status != "pending":
1725
  return []
1726
- return [{"id": i + 1, **row, "status": status} for i, (row, _, __, ___) in enumerate(self.flags)]
1727
 
1728
  def review_flag(self, flag_id, values):
1729
  self.review = (flag_id, values)
1730
 
1731
  def flag_audio(self, flag_id):
1732
- if flag_id < 1 or flag_id > len(self.flags):
1733
- return None
1734
- return self.flags[flag_id - 1][1], self.flags[flag_id - 1][2]
1735
 
1736
  def calibrations(self, status=None):
1737
  return [r for r in self.calibration_rows if not status or r["status"] == status]
@@ -1780,12 +1777,11 @@ def test_a_tutor_flags_a_recording_scored_wrong(monkeypatch):
1780
  assert c.post("/api/flags", data={"phrase_id": pid}, files={"audio": ("rec", b"x", "audio/wav")}).status_code == 401
1781
  assert send(c, "s").status_code == 403 and not a.flags # tutors only
1782
  r = send(c, "t", note=" אמרתי ח נכונה ")
1783
- (row, audio, mime, name), = a.flags
1784
- assert r.status_code == 200 and r.json() == {"ok": True, "audio": True, "flag": row}, r.text # the page shows what was kept
1785
  assert row["email"] == "t@b.c" and row["cat"] == TOILET["cat"] and row["he"] == TOILET["he"] and row["note"] == "אמרתי ח נכונה"
1786
  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
1787
  assert row["result"]["tips"][0]["key"] == "ħ>x" and "phoneme_heatmap" not in row["result"]
1788
- assert audio == buf.getvalue() and mime == "audio/wav" and re.fullmatch(r"\d{4}-\d\d/[0-9a-f]{32}\.wav", name)
1789
  assert send(c, "t", phrase_id=10**6).status_code == 404
1790
  a.teachers = frozenset({"s@b.c"}) # a teacher flags too
1791
  assert c.get("/api/me", headers={"Authorization": "Bearer s"}).json()["tutor"] is True and send(c, "s").status_code == 200
@@ -1793,7 +1789,7 @@ def test_a_tutor_flags_a_recording_scored_wrong(monkeypatch):
1793
  listed = c.get("/api/flags", headers=hs)
1794
  assert listed.status_code == 200 and len(listed.json()["flags"]) == 2
1795
  played = c.get("/api/flags/1/audio", headers=hs)
1796
- assert played.status_code == 200 and played.content == buf.getvalue() and played.headers["content-type"] == "audio/wav"
1797
  assert c.get("/api/flags/999/audio", headers=hs).status_code == 404
1798
  review = c.put("/api/flags/1", headers=hs, json={"status": "confirmed", "category": "recognition",
1799
  "false_issues": [{"word": 1, "expected": "ħ", "heard": "χ"}], "review_note": " תקין "})
 
1714
  def texts(self, fresh=False):
1715
  return []
1716
 
1717
+ def add_flag(self, row):
1718
  if not self.table:
1719
  raise AuthUnavailable("supabase answered 404: no table")
1720
+ self.flags.append(row)
 
1721
 
1722
  def list_flags(self, status="pending"):
1723
  if status != "pending":
1724
  return []
1725
+ return [{"id": i + 1, **row, "status": status} for i, row in enumerate(self.flags)]
1726
 
1727
  def review_flag(self, flag_id, values):
1728
  self.review = (flag_id, values)
1729
 
1730
  def flag_audio(self, flag_id):
1731
+ return None
 
 
1732
 
1733
  def calibrations(self, status=None):
1734
  return [r for r in self.calibration_rows if not status or r["status"] == status]
 
1777
  assert c.post("/api/flags", data={"phrase_id": pid}, files={"audio": ("rec", b"x", "audio/wav")}).status_code == 401
1778
  assert send(c, "s").status_code == 403 and not a.flags # tutors only
1779
  r = send(c, "t", note=" אמרתי ח נכונה ")
1780
+ row, = a.flags
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
 
1789
  listed = c.get("/api/flags", headers=hs)
1790
  assert listed.status_code == 200 and len(listed.json()["flags"]) == 2
1791
  played = c.get("/api/flags/1/audio", headers=hs)
1792
+ assert played.status_code == 404 # reports keep diagnostics, not the user's voice recording
1793
  assert c.get("/api/flags/999/audio", headers=hs).status_code == 404
1794
  review = c.put("/api/flags/1", headers=hs, json={"status": "confirmed", "category": "recognition",
1795
  "false_issues": [{"word": 1, "expected": "ħ", "heard": "χ"}], "review_note": " תקין "})