3v324v23 commited on
Commit
269a3c8
·
1 Parent(s): 0af696f

Enhance pronunciation rules and scoring system

Browse files

- Updated pronunciation rules in `pronunciation-rules.md` to clarify the handling of shwa and kubutz sounds, including new examples and scoring rules.
- Modified `index.html` to integrate scoring rules display, allowing teachers to toggle rules on and off.
- Added tests in `test_scoring.py` to verify the functionality of newly implemented scoring rules and their impact on pronunciation scoring.
- Introduced a new script `compare_verbs.py` to check the phonemes of Arabic verbs against the scorer's expectations, ensuring consistency in pronunciation handling.

README.md CHANGED
@@ -238,6 +238,20 @@ alter table public.calibrations enable row level security;
238
 
239
  An applied rule raises only the matching phoneme score to its configured floor; it never lowers a score. The score response includes `calibrated` when a rule changed it, so a flagged result records which rule effect was involved.
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  ### Levels
242
 
243
  The course has levels, each with a planned number of lessons (`LEVELS` in `app/workbook.py`: שלב א and שלב ב, 10 lessons each). Every lesson in `LESSONS` is keyed by its level and number, `(level, lesson)`; one outside the plan stops the app at startup. The path shows each level under its name, and the lessons not written yet as "בקרוב". Level 1's lessons keep their first ids ("1", topics "l1-…") so recorded attempts and saved classes still match; later levels' are "2.1", topics "l2.1-…".
 
238
 
239
  An applied rule raises only the matching phoneme score to its configured floor; it never lowers a score. The score response includes `calibrated` when a rule changed it, so a flagged result records which rule effect was involved.
240
 
241
+ ### Pronunciation rules
242
+
243
+ The scorer reads its dialect and Hebrew-speaker rules from the yaml blocks of `static/assets/pronunciation-rules.md` (see `app/rules.py` and `docs/scoring-dialects-plan.md`). The **ניטור** tab lists every rule taken from that document, under **כללי ההגייה מהמסמך**: which realisations of a letter count as correct, how a mistake is judged (exemptions, per-pair margins), and the feedback each rule gives. All are on by default. A teacher can switch one off, which brings back the behaviour from before the document for every student from the next recording on. The switches are loaded into the scorer at startup and refreshed right after a change. Create the private table once (without it the list still shows, all on, and cannot be changed):
244
+
245
+ ```sql
246
+ create table public.scoring_rules (
247
+ id text primary key, -- a rule of rules.TOGGLES, e.g. dialect_Q, margins, rule_pharyngeal_h
248
+ active boolean not null default true,
249
+ updated_by text not null default '',
250
+ updated_at timestamptz not null default now()
251
+ );
252
+ alter table public.scoring_rules enable row level security;
253
+ ```
254
+
255
  ### Levels
256
 
257
  The course has levels, each with a planned number of lessons (`LEVELS` in `app/workbook.py`: שלב א and שלב ב, 10 lessons each). Every lesson in `LESSONS` is keyed by its level and number, `(level, lesson)`; one outside the plan stops the app at startup. The path shows each level under its name, and the lessons not written yet as "בקרוב". Level 1's lessons keep their first ids ("1", topics "l1-…") so recorded attempts and saved classes still match; later levels' are "2.1", topics "l2.1-…".
app/archi.py CHANGED
@@ -117,8 +117,9 @@ def _align(letters: list[str], units: list[str]) -> tuple[dict[int, set[int]], f
117
  return matched, best
118
 
119
 
120
- def archify_word(ipa_word: str, ar_word: str) -> tuple[str, list[str]]:
121
- """One word: its IPA with symbol tokens, and the letters that could not be placed (ambiguous)."""
 
122
  phs = ipa_word.split()
123
  if any(split_token(p)[0] for p in phs):
124
  return ipa_word, [] # the phrase writes its symbols itself
@@ -127,7 +128,7 @@ def archify_word(ipa_word: str, ar_word: str) -> tuple[str, list[str]]:
127
  out, unresolved = list(phs), []
128
  for i, letter in enumerate(letters):
129
  sym = LETTER_SYMBOL.get(letter)
130
- if sym is None:
131
  continue
132
  hits = matched.get(i, {-1})
133
  if len(hits) != 1:
@@ -139,21 +140,21 @@ def archify_word(ipa_word: str, ar_word: str) -> tuple[str, list[str]]:
139
  for k in units[j][1]:
140
  out[k] = f"{sym}:{phs[k]}"
141
  # the vowel of a final ة (and ه written for it): a, e and i are all correct (section 6.3 #5)
142
- if (out and ar_word.endswith(("ة", "ه")) and "لله" not in ar_word
143
  and base(out[-1]) in {"a", "e", "i"} and len(out) > 1):
144
  out[-1] = f"E:{out[-1]}"
145
  return " ".join(out), unresolved
146
 
147
 
148
  @lru_cache(maxsize=32768) # the whole phrase bank (~12k variants) and the verb game's forms
149
- def archify(ar: str, ipa: str) -> str:
150
  """The variant's IPA with each phoneme of a many-ways letter rewritten as its symbol (see the module doc).
151
 
152
  A variant whose Arabic and IPA word counts differ is returned as it is."""
153
  ipa_words, ar_words = ipa.split("|"), ar.split()
154
  if len(ipa_words) != len(ar_words):
155
  return ipa
156
- return " | ".join(archify_word(w.strip(), a)[0] for w, a in zip(ipa_words, ar_words))
157
 
158
 
159
  def unresolved(ar: str, ipa: str) -> list[tuple[str, str]]:
 
117
  return matched, best
118
 
119
 
120
+ def archify_word(ipa_word: str, ar_word: str, off: frozenset[str] = frozenset()) -> tuple[str, list[str]]:
121
+ """One word: its IPA with symbol tokens, and the letters that could not be placed (ambiguous).
122
+ Symbols in `off` (switched off by the teacher) are left as the phoneme the phrase writes."""
123
  phs = ipa_word.split()
124
  if any(split_token(p)[0] for p in phs):
125
  return ipa_word, [] # the phrase writes its symbols itself
 
128
  out, unresolved = list(phs), []
129
  for i, letter in enumerate(letters):
130
  sym = LETTER_SYMBOL.get(letter)
131
+ if sym is None or sym in off:
132
  continue
133
  hits = matched.get(i, {-1})
134
  if len(hits) != 1:
 
140
  for k in units[j][1]:
141
  out[k] = f"{sym}:{phs[k]}"
142
  # the vowel of a final ة (and ه written for it): a, e and i are all correct (section 6.3 #5)
143
+ if ("E" not in off and out and ar_word.endswith(("ة", "ه")) and "لله" not in ar_word
144
  and base(out[-1]) in {"a", "e", "i"} and len(out) > 1):
145
  out[-1] = f"E:{out[-1]}"
146
  return " ".join(out), unresolved
147
 
148
 
149
  @lru_cache(maxsize=32768) # the whole phrase bank (~12k variants) and the verb game's forms
150
+ def archify(ar: str, ipa: str, off: frozenset[str] = frozenset()) -> str:
151
  """The variant's IPA with each phoneme of a many-ways letter rewritten as its symbol (see the module doc).
152
 
153
  A variant whose Arabic and IPA word counts differ is returned as it is."""
154
  ipa_words, ar_words = ipa.split("|"), ar.split()
155
  if len(ipa_words) != len(ar_words):
156
  return ipa
157
+ return " | ".join(archify_word(w.strip(), a, off)[0] for w, a in zip(ipa_words, ar_words))
158
 
159
 
160
  def unresolved(ar: str, ipa: str) -> list[tuple[str, str]]:
app/auth.py CHANGED
@@ -60,6 +60,7 @@ import time
60
  import urllib.error
61
  import urllib.parse
62
  import urllib.request
 
63
 
64
  log = logging.getLogger("ihki.auth")
65
  TIMEOUT = 10
@@ -207,14 +208,14 @@ class SupabaseAuth:
207
  with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
208
  return json.load(res)
209
 
210
- def _admin(self, path: str, method: str = "GET", body: dict | None = None):
211
  """A call with the secret key, for the teacher's panel. Unlike a student's own progress, a failure is raised."""
212
  if not self.secret:
213
  raise AuthUnavailable("SUPABASE_SECRET_KEY is not set")
214
  req = urllib.request.Request(
215
  self.url + path, method=method, data=json.dumps(body).encode() if body is not None else None,
216
  headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret, "Content-Type": "application/json",
217
- "Prefer": "return=representation", "User-Agent": "ihki/1.0"})
218
  try:
219
  with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
220
  return json.loads(res.read() or b"null")
@@ -502,6 +503,17 @@ class SupabaseAuth:
502
  """Deletes a calibration proposal or rule; returns whether it existed."""
503
  return bool(self._admin(f"/rest/v1/calibrations?id=eq.{int(calibration_id)}", "DELETE"))
504
 
 
 
 
 
 
 
 
 
 
 
 
505
  def users(self) -> list[dict]:
506
  """Every account that has signed in: id, email, name (as Google gave it; empty for one who signed in by email)."""
507
  users: list[dict] = []
 
60
  import urllib.error
61
  import urllib.parse
62
  import urllib.request
63
+ from datetime import datetime, timezone
64
 
65
  log = logging.getLogger("ihki.auth")
66
  TIMEOUT = 10
 
208
  with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
209
  return json.load(res)
210
 
211
+ def _admin(self, path: str, method: str = "GET", body: dict | None = None, prefer: str = "return=representation"):
212
  """A call with the secret key, for the teacher's panel. Unlike a student's own progress, a failure is raised."""
213
  if not self.secret:
214
  raise AuthUnavailable("SUPABASE_SECRET_KEY is not set")
215
  req = urllib.request.Request(
216
  self.url + path, method=method, data=json.dumps(body).encode() if body is not None else None,
217
  headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret, "Content-Type": "application/json",
218
+ "Prefer": prefer, "User-Agent": "ihki/1.0"})
219
  try:
220
  with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
221
  return json.loads(res.read() or b"null")
 
503
  """Deletes a calibration proposal or rule; returns whether it existed."""
504
  return bool(self._admin(f"/rest/v1/calibrations?id=eq.{int(calibration_id)}", "DELETE"))
505
 
506
+ def scoring_rules(self) -> list[dict]:
507
+ """The teacher's switches over the rules of pronunciation-rules.md: id, active, updated_by, updated_at.
508
+ A rule with no row is on."""
509
+ return self._admin("/rest/v1/scoring_rules?select=*") or []
510
+
511
+ def set_scoring_rule(self, rule_id: str, active: bool, by: str) -> None:
512
+ """Switches one rule on or off (an upsert on its id)."""
513
+ self._admin("/rest/v1/scoring_rules", "POST", {"id": rule_id, "active": bool(active), "updated_by": by,
514
+ "updated_at": datetime.now(timezone.utc).isoformat()},
515
+ prefer="resolution=merge-duplicates,return=minimal")
516
+
517
  def users(self) -> list[dict]:
518
  """Every account that has signed in: id, email, name (as Google gave it; empty for one who signed in by email)."""
519
  users: list[dict] = []
app/main.py CHANGED
@@ -32,7 +32,7 @@ from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
32
  from .phrases import CATEGORIES, GAMES, LESSONS, LEVELS, PHRASES, public_phrases
33
  from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
34
  from .reference import References
35
- from .rules import check_detectable
36
  from .scorer import Scorer, decode_audio, load_scorer
37
  from .speech_evaluation import SpeechEvaluationService
38
  from .texts import read_line, text_phrases
@@ -143,6 +143,10 @@ CREATE_CALIBRATIONS = ("create table public.calibrations (id bigint generated al
143
  "reviewed_by text, reviewed_at timestamptz, created_at timestamptz not null default now()); "
144
  "alter table public.calibrations enable row level security;")
145
  CALIBRATION_STATUSES = frozenset({"draft", "applied", "rejected"})
 
 
 
 
146
  # The students the teacher gave the tutor's rank (see auth.py): theirs in every class, so it is kept by email.
147
  CREATE_TUTORS = ("create table public.tutors (email text primary key, granted_by text not null default '', "
148
  "created_at timestamptz not null default now()); alter table public.tutors enable row level security;")
@@ -333,6 +337,11 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
333
  app.state.scorer.set_calibrations([r for r in rows if not r.get("model") or r.get("model") == MODEL_ID])
334
  except AuthUnavailable as e:
335
  log.warning("calibrations not loaded: %s", e)
 
 
 
 
 
336
  yield
337
 
338
  app = FastAPI(title="ihki", lifespan=lifespan)
@@ -625,6 +634,47 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
625
  rows = a.calibrations("applied")
626
  app.state.scorer.set_calibrations([r for r in rows if not r.get("model") or r.get("model") == MODEL_ID])
627
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
628
  @app.get("/api/calibrations")
629
  def calibration_proposals(a: SupabaseAuth = Depends(teacher_only)):
630
  try:
 
32
  from .phrases import CATEGORIES, GAMES, LESSONS, LEVELS, PHRASES, public_phrases
33
  from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
34
  from .reference import References
35
+ from .rules import TOGGLES, check_detectable
36
  from .scorer import Scorer, decode_audio, load_scorer
37
  from .speech_evaluation import SpeechEvaluationService
38
  from .texts import read_line, text_phrases
 
143
  "reviewed_by text, reviewed_at timestamptz, created_at timestamptz not null default now()); "
144
  "alter table public.calibrations enable row level security;")
145
  CALIBRATION_STATUSES = frozenset({"draft", "applied", "rejected"})
146
+ # The teacher's switches over the rules of pronunciation-rules.md (rules.TOGGLES): a rule with no row is on.
147
+ CREATE_SCORING_RULES = ("create table public.scoring_rules (id text primary key, active boolean not null default true, "
148
+ "updated_by text not null default '', updated_at timestamptz not null default now()); "
149
+ "alter table public.scoring_rules enable row level security;")
150
  # The students the teacher gave the tutor's rank (see auth.py): theirs in every class, so it is kept by email.
151
  CREATE_TUTORS = ("create table public.tutors (email text primary key, granted_by text not null default '', "
152
  "created_at timestamptz not null default now()); alter table public.tutors enable row level security;")
 
337
  app.state.scorer.set_calibrations([r for r in rows if not r.get("model") or r.get("model") == MODEL_ID])
338
  except AuthUnavailable as e:
339
  log.warning("calibrations not loaded: %s", e)
340
+ if app.state.auth and hasattr(app.state.auth, "scoring_rules"):
341
+ try:
342
+ reload_scoring_rules(app.state.auth)
343
+ except AuthUnavailable as e: # no table yet: every rule stays on
344
+ log.warning("scoring rule switches not loaded: %s", e)
345
  yield
346
 
347
  app = FastAPI(title="ihki", lifespan=lifespan)
 
634
  rows = a.calibrations("applied")
635
  app.state.scorer.set_calibrations([r for r in rows if not r.get("model") or r.get("model") == MODEL_ID])
636
 
637
+ def reload_scoring_rules(a: SupabaseAuth) -> None:
638
+ app.state.scorer.set_disabled(r["id"] for r in a.scoring_rules() if not r.get("active", True))
639
+
640
+ def scoring_rules_error(e: AuthUnavailable) -> HTTPException:
641
+ if "answered 404" in str(e):
642
+ return HTTPException(503, f"הטבלה scoring_rules לא קיימת. הריצו ב-SQL Editor: {CREATE_SCORING_RULES}")
643
+ return HTTPException(503, f"כללי ההגייה לא זמינים: {e}")
644
+
645
+ @app.get("/api/scoring-rules")
646
+ def scoring_rules(a: SupabaseAuth = Depends(teacher_only)):
647
+ """Every rule taken from pronunciation-rules.md, with whether it is on. All are on until a teacher says otherwise.
648
+ `stored` is False while the table is missing: the list still shows, but cannot be changed."""
649
+ s: Scorer = app.state.scorer
650
+ stored, rows = True, {}
651
+ try:
652
+ rows = {r["id"]: r for r in a.scoring_rules()}
653
+ except AuthUnavailable as e:
654
+ if "answered 404" not in str(e):
655
+ raise scoring_rules_error(e)
656
+ stored = False
657
+ detectable = check_detectable(s.rules, lambda ph: s.vocab.sound_id(ph) is not None, s.vocab.model_id)
658
+ return {"stored": stored, "create_sql": None if stored else CREATE_SCORING_RULES, "rules": [
659
+ {"id": t.id, "group": t.group, "section": t.section, "title": t.title, "description": t.description,
660
+ "active": t.id not in s.disabled,
661
+ "updated_by": rows.get(t.id, {}).get("updated_by", ""), "updated_at": rows.get(t.id, {}).get("updated_at"),
662
+ # a section-3 rule this model cannot hear at all (vowel length): switching it changes nothing
663
+ "detectable": detectable.get(t.id.removeprefix("rule_"), {}).get("detectable", True)}
664
+ for t in TOGGLES]}
665
+
666
+ @app.put("/api/scoring-rules/{rule_id}")
667
+ def switch_scoring_rule(rule_id: str, body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only),
668
+ user: dict | None = Depends(signed_in)):
669
+ if rule_id not in {t.id for t in TOGGLES} or not isinstance(body.get("active"), bool):
670
+ raise HTTPException(400, "unknown rule or invalid switch")
671
+ try:
672
+ a.set_scoring_rule(rule_id, body["active"], user["email"].lower())
673
+ reload_scoring_rules(a)
674
+ except AuthUnavailable as e:
675
+ raise scoring_rules_error(e)
676
+ return {"ok": True, "disabled": sorted(app.state.scorer.disabled)}
677
+
678
  @app.get("/api/calibrations")
679
  def calibration_proposals(a: SupabaseAuth = Depends(teacher_only)):
680
  try:
app/rules.py CHANGED
@@ -55,6 +55,10 @@ class Rules:
55
  titles: dict[str, str] # rule id -> title
56
  heard_labels: dict[str, str]
57
  margins: dict[tuple[str, str], float] = field(default_factory=dict)
 
 
 
 
58
 
59
  def members(self, symbol: str, written: str | None = None) -> list[str]:
60
  """The correct realisations of an abstract symbol, in base() names, first the dialects' own.
@@ -123,7 +127,13 @@ def parse(text: str) -> Rules:
123
  archi, dialects, optional, scoring, labels = {}, ["urban", "rural"], False, {}, {}
124
  checks: dict[tuple[str, str], Check] = {}
125
  titles: dict[str, str] = {}
 
126
  for doc in _blocks(text):
 
 
 
 
 
127
  if "archiphonemes" in doc:
128
  archi = {k: {d: list(v.get(d, [])) for d in ("urban", "rural", "also_ok", "optional")}
129
  for k, v in doc["archiphonemes"].items()}
@@ -140,7 +150,7 @@ def parse(text: str) -> Rules:
140
  key = (norm(str(c["target"])), norm(str(heard)))
141
  # two spellings of one sound (ʁ and ɣ) meet here: the first rule written wins
142
  checks.setdefault(key, Check(doc["id"], key[0], key[1], int(c["severity"]), str(c["feedback"])))
143
- rules = Rules(archi, dialects, optional, scoring, checks, titles, labels)
144
  for m in scoring.get("margins", []):
145
  # base() drops vowel length, so a margin for long vowels would land on quality pairs (a / i) it was not meant for
146
  for t in (t for t in m["target"] if "ː" not in str(t)):
@@ -235,3 +245,87 @@ EXEMPTIONS = [
235
  ("article_vowel_elided", _article_vowel_elided, None),
236
  ("near_vowel", _near_vowel, "near_vowel_floor"),
237
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  titles: dict[str, str] # rule id -> title
56
  heard_labels: dict[str, str]
57
  margins: dict[tuple[str, str], float] = field(default_factory=dict)
58
+ # a vowel heard between two consonants of a word (3.10): rule, severity, min_peak, min_frames, score_cap, feedback
59
+ insertion: dict | None = None
60
+ # short vowels a rule flags although short vowels are not flagged (6.3 #7): vowel -> rule id (3.11: u)
61
+ flag_vowels: dict[str, str] = field(default_factory=dict)
62
 
63
  def members(self, symbol: str, written: str | None = None) -> list[str]:
64
  """The correct realisations of an abstract symbol, in base() names, first the dialects' own.
 
127
  archi, dialects, optional, scoring, labels = {}, ["urban", "rural"], False, {}, {}
128
  checks: dict[tuple[str, str], Check] = {}
129
  titles: dict[str, str] = {}
130
+ insertion, flag_vowels = None, {}
131
  for doc in _blocks(text):
132
+ if "insertion" in doc:
133
+ titles[doc["id"]] = doc.get("title", doc["id"])
134
+ insertion = {"rule": doc["id"], **doc["insertion"]}
135
+ for v in doc.get("flag_vowels", []):
136
+ flag_vowels[str(v)] = doc["id"]
137
  if "archiphonemes" in doc:
138
  archi = {k: {d: list(v.get(d, [])) for d in ("urban", "rural", "also_ok", "optional")}
139
  for k, v in doc["archiphonemes"].items()}
 
150
  key = (norm(str(c["target"])), norm(str(heard)))
151
  # two spellings of one sound (ʁ and ɣ) meet here: the first rule written wins
152
  checks.setdefault(key, Check(doc["id"], key[0], key[1], int(c["severity"]), str(c["feedback"])))
153
+ rules = Rules(archi, dialects, optional, scoring, checks, titles, labels, insertion=insertion, flag_vowels=flag_vowels)
154
  for m in scoring.get("margins", []):
155
  # base() drops vowel length, so a margin for long vowels would land on quality pairs (a / i) it was not meant for
156
  for t in (t for t in m["target"] if "ː" not in str(t)):
 
245
  ("article_vowel_elided", _article_vowel_elided, None),
246
  ("near_vowel", _near_vowel, "near_vowel_floor"),
247
  ]
248
+
249
+
250
+ # ---------- the rules a teacher can switch off (ניטור): every behaviour taken from pronunciation-rules.md ----------
251
+
252
+ @dataclass(frozen=True)
253
+ class Toggle:
254
+ id: str
255
+ group: str # "accept": what counts as correct · "judge": how a mistake is judged · "feedback": what is said
256
+ section: str # where pronunciation-rules.md has it
257
+ title: str
258
+ description: str # what the scorer does while the rule is on; off is the behaviour before the document
259
+
260
+
261
+ def _toggles() -> list[Toggle]:
262
+ letters = [
263
+ ("Q", "ق", "ʔ (עירוני), k (כפרי) או q", "אַהְוֶה, כַּהְוֶה וקַהְוֶה נכונות שלושתן."),
264
+ ("K", "ك", "k או tʃ (כפרי)", "כַּלְבּ וצ'ַלְבּ נכונות שתיהן."),
265
+ ("TH", "ث", "t או θ (כפרי), ו־s במילים ספרותיות כשהמשפט כותב s", "תְלַאתֶה ות'לַאת'ֶה נכונות שתיהן."),
266
+ ("DH", "ذ", "d או ð (כפרי), ו־z במילים ספרותיות כשהמשפט כותב z", "דַהַבּ וד״ַהַבּ נכונות שתיהן."),
267
+ ("ZH", "ظ", "dˤ, zˤ או ðˤ", "ד'ֻהְר וט'ֻהְר נכונות שתיהן."),
268
+ ("J", "ج", "dʒ או ʒ", "ג' כמו בג'ינס או ז' כמו בז'קט."),
269
+ ]
270
+ out = [Toggle(f"dialect_{s}", "accept", "5", f"{ar}: {said}",
271
+ f"כל המימושים נכונים, וההגה נמדד מול סכום ההסתברויות שלהם (6.1). {ex} "
272
+ f"כבוי: נבדק רק המימוש שכתוב במשפט.") for s, ar, said, ex in letters]
273
+ out += [
274
+ Toggle("final_vowel", "accept", "6.3 #5", "תנועה סופית של ة: a, e או i",
275
+ "אַהְוֶה, אַהְוַה ואַהְוִי נכונות. כבוי: נבדקת התנועה שכתובה במשפט."),
276
+ Toggle("diphthong", "accept", "6.3 #8", "eː גם כ־aj, ו־oː גם כ־aw",
277
+ "בֵּית ובַּיְת נכונות שתיהן (נבדק כווריאנט נוסף). כבוי: רק התנועה הארוכה."),
278
+ Toggle("glottal_dropped", "judge", "6.3 #1–2", "עצירה סותמית שהושמטה אינה טעות",
279
+ "ʔ (ء, ו־ق עירונית) שלא נשמעה מקבלת ציון מלא, כמו אצל דוברים ילידיים (אַנַא ← אנה)."),
280
+ Toggle("article_vowel_elided", "judge", "6.3 #3", "תנועת ה־ال אחרי תנועה",
281
+ "ה־i של ال שלא נאמרה אחרי מילה שמסתיימת בתנועה (עַלַא לְ...) אינה טעות."),
282
+ Toggle("near_vowel", "judge", "6.3 #7", "תנועות קר��בות",
283
+ "תנועה שנשמעה כשכנתה (i/e, u/o, a/e) או באורך אחר מקבלת לפחות 90."),
284
+ Toggle("margins", "judge", "6.2", "מרווח ביטחון לזוגות שהמודל מבלבל",
285
+ "עיצור מודגש נחשב שגוי רק אם הרגיל סביר ממנו פי 2 בערך, ħ מול h פי 1.5, "
286
+ "ו־h נחשבת שנבלעה רק אם היא חלשה פי 2 מסף ההשמטה. "
287
+ "כבוי: כל מתחרה שמנצח מסמן טעות."),
288
+ Toggle("unnamed_sounds", "judge", "7", "צליל שאינו בערבית או בעברית לא נקרא בשמו",
289
+ "כשהמודל הרב־לשוני שומע צליל זר (נקישה, צליל רטרופלקסי) הוא מדווח כ'לא ברור'. כבוי: נאמר שם הסמל."),
290
+ Toggle("tips_by_severity", "feedback", "6.2", "סדר הטיפים לפי חומרה, עד 3",
291
+ "טעות שמשנה משמעות (ח, ע) מוצגת לפני מבטא, ואחריה הציון הנמוך. כבוי: לפי הציון בלבד, עד 4."),
292
+ ]
293
+ own = {
294
+ "cluster": "תנועה שהמודל שומע בבירור בין שני עיצורים של אותה מילה (katiir במקום ktiir) מורידה את העיצור השני "
295
+ "לכל היותר ל־60, עם טיפ בחומרה 1. לא בצרור שפותח את המשפט, ולא ליד w או j. כבוי: תנועה שנוספה לא נבדקת.",
296
+ "kubutz": "u אחרי עיצור היא התנועה הקצרה היחידה שמסומנת: כשנשמטה, או נשמעה כ־e, i או שווא. "
297
+ "כ־o היא עדיין תנועה קרובה. כבוי: כמו שאר התנועות הקצרות, לא מסומנת.",
298
+ }
299
+ for rule_id, title, section in (("pharyngeal_h", "ח לועית", "3.1"), ("ayin", "ע לועית", "3.2"),
300
+ ("emphatics", "עיצורים מודגשים", "3.3"), ("r_tap", "ר מתגלגלת", "3.4"),
301
+ ("ghain", "ע' היא ה־ר העברית", "3.5"), ("h", "ה נהגית תמיד", "3.6"),
302
+ ("w", "ו היא w", "3.7"), ("b", "ב היא תמיד b", "3.7"), ("f", "פ היא תמיד f", "3.7"),
303
+ ("vowel_length", "תנועה ארוכה", "3.8"), ("jim", "ג' ולא g", "3.9"),
304
+ ("cluster", "שני עיצורים צמודים", "3.10"), ("kubutz", "קובוץ ברור", "3.11")):
305
+ out.append(Toggle(f"rule_{rule_id}", "judge" if rule_id in own else "feedback", section, title,
306
+ own.get(rule_id) or
307
+ f"הטיפ והחומרה (בסוגריים) מהמסמך כשנשמע: {_confusions(rule_id)}. "
308
+ "כבוי: טיפ כללי בחומרה בינונית; ההחלטה אם לסמן לא משתנה."))
309
+ return out
310
+
311
+
312
+ def _confusions(rule_id: str) -> str:
313
+ """'ח ← ח עברית (3), ה (2), נבלעת (2)': the substitutions a section-3 rule speaks for, in Hebrew labels."""
314
+ rules = load()
315
+ by_target: dict[str, list[str]] = {}
316
+ for c in rules.checks.values():
317
+ if c.rule == rule_id:
318
+ heard = rules.label(c.heard) or c.heard
319
+ item = f"{'נבלעת' if c.heard == '_' else heard} ({c.severity})"
320
+ if item not in by_target.setdefault(c.target, []):
321
+ by_target[c.target].append(item)
322
+ return "; ".join(f"{_LETTER.get(t, t)} ← {', '.join(items)}" for t, items in by_target.items())
323
+
324
+
325
+ # a target sound by the letter of the transliteration (section 1), for the teacher's list
326
+ _LETTER = {"ħ": "ח", "ʕ": "ע", "sˤ": "צ", "tˤ": "ט", "dˤ": "ד'", "ZH": "ט'", "r": "ר", "ɣ": "ע'", "h": "ה", "w": "ו",
327
+ "b": "ב", "f": "פ", "J": "ג'", "a": "אַא", "i": "ִי", "u": "וּ", "e": "ֵי", "o": "וֹ"}
328
+
329
+
330
+ TOGGLES = _toggles()
331
+ TOGGLE_IDS = frozenset(t.id for t in TOGGLES)
app/scorer.py CHANGED
@@ -14,7 +14,7 @@ import numpy as np
14
  from .align import Alignment, GopConfig, TokenScore, ctc_viterbi, edit_distance, greedy_decode, score_tokens
15
  from .archi import archify, diphthong_variants, split_token, written
16
  from .phones import base
17
- from .rules import EXEMPTIONS, Rules, elided_article, near_vowels, load as load_rules # noqa: F401 (re-exported)
18
  from .tips import VOWELS, letter_reason, sound_name, tip_for
19
  from .translit import CONSONANTS as _HEB_C, HEARD_ONLY as _HEB_HEARD, VOWELS as _HEB_V, to_hebrew, word_letters, word_to_hebrew
20
 
@@ -220,6 +220,8 @@ class Fit:
220
  candidates: list[tuple[str, str, float | None]] = field(default_factory=list) # (ar, ipa, path log-prob)
221
  gop_scores: list[TokenScore] = field(default_factory=list) # before the exemptions
222
  exempted: list[str | None] = field(default_factory=list) # per token, the exemption that applied
 
 
223
  distance: int = 0 # edit distance of the greedy decode from the chosen variant
224
 
225
 
@@ -233,6 +235,59 @@ class Scorer:
233
  names = (set(HEBREW_SOUNDS) | self.rules.interference() | set(self.rules.heard_labels)
234
  | {p for s in self.rules.archiphonemes for p in self.rules.members(s)})
235
  self.named = {i for i in (vocab.sound_id(n) for n in names) if i is not None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
 
237
  def set_calibrations(self, rows: list[dict]) -> None:
238
  """Atomically replaces applied score floors. A blank phrase is a global rule."""
@@ -352,10 +407,11 @@ class Scorer:
352
  """(arabic, ipa, ipa with symbols, tokens) for each accepted variant and its diphthong forms. Variants
353
  that differ only in how a many-ways letter is said (ʔ / q / k for ق) become one: the first written.
354
  A diphthong form the model has no symbols for is left out."""
355
- out, seen = [], set()
356
  for ar, ipa in phrase["variants"]:
357
- for n, form in enumerate((ipa, *diphthong_variants(ar, ipa))):
358
- aipa = archify(ar, form)
 
359
  if aipa in seen:
360
  continue
361
  seen.add(aipa)
@@ -386,19 +442,25 @@ class Scorer:
386
  ids = [t.id for t in toks]
387
  ar_words = ar.split()
388
  scores = score_tokens(ext, al, ids, blank, self.cfg.gop, [vocab.members.get(i) for i in ids],
389
- vocab.n_real, self.named)
390
  gop_scores, exempted = list(scores), [None] * len(scores)
391
  for i, (t, sc) in enumerate(zip(toks, scores)):
392
  heard = vocab.i2t.get(sc.competitor) if sc.competitor is not None else None
393
  for name, applies, floor in EXEMPTIONS:
394
- if applies(toks, i, sc, heard, ar_words):
395
  scores[i] = replace(sc, score=max(sc.score, 100.0 if floor is None else getattr(self.cfg, floor)))
396
  exempted[i] = name
397
  break
 
 
 
 
 
398
  same = lambda h, t: h == t or h in vocab.members.get(t, ())
399
  distance = edit_distance(heard_ids, ids, same)
400
  unrelated = bool(distance / max(1, len(ids)) > self.cfg.unrelated_ratio)
401
- return Fit(ar, ipa, aipa, toks, al, scores, heard_ids, unrelated, ext, tried, gop_scores, exempted, distance)
 
402
 
403
  def _heatmap(self, logp: np.ndarray, toks: list[TargetToken], alignment, scores,
404
  duration_s: float | None) -> dict:
@@ -438,15 +500,25 @@ class Scorer:
438
  "frames": frames, "frame_bins": bins, "rows": rows,
439
  "tracks": {"expected": expected, "heard": heard}}
440
 
 
 
 
 
 
 
441
  def _beyond_margin(self, tok: TargetToken, sc: TokenScore, heard: str | None) -> bool:
442
  """Is the competitor likelier than the target by more than the pair's margin? Pairs the model confuses
443
  on natives too (emphatics, a dropped h) need a clearer win before they count as a mistake."""
444
  targets = [tok.sound, tok.phoneme]
445
  if sc.deleted:
446
- return sc.peak <= self.cfg.gop.deleted_peak - self.rules.margin(targets, "_")
447
  if heard is None:
448
  return True
449
- return -sc.gop >= self.rules.margin(targets, heard)
 
 
 
 
450
 
451
  def _dialect(self, toks: list[TargetToken], scores: list[TokenScore]) -> dict:
452
  """Which dialect the accepted realisations point to: {"dialect": urban | rural} when they agree,
@@ -502,13 +574,21 @@ class Scorer:
502
  tr_words = to_hebrew(ipa, ar)
503
  for idx, (tok, sc) in enumerate(zip(toks, scores)):
504
  heard_ph = "_" if sc.deleted else (i2t.get(sc.competitor) if sc.competitor is not None else None)
505
- check = self.rules.check([tok.sound, tok.phoneme], heard_ph)
506
  beyond.append(self._beyond_margin(tok, sc, heard_ph))
507
- flagged = bool(sc.score < self.cfg.flag_below) and tok.phoneme not in VOWELS and beyond[-1]
508
- heard_as.append(heard_ph)
509
- checks.append(check)
 
 
 
 
 
 
510
  p = {"ipa": tok.phoneme, "word": tok.word, "score": sc.score,
511
  "flagged": flagged, "heard": heard_ph if flagged else None}
 
 
512
  if tok.sound != tok.phoneme:
513
  p["sound"] = tok.sound
514
  if sc.realised is not None:
@@ -523,6 +603,13 @@ class Scorer:
523
  tip["idx"] = idx
524
  tip["score"] = round(sc.score)
525
  tips.append(tip)
 
 
 
 
 
 
 
526
 
527
  n_words = len(ipa.split("|"))
528
  heard_words = self._heard_hebrew(logp, toks, al, fit.aipa, ar_words)
@@ -553,8 +640,9 @@ class Scorer:
553
  if unrelated:
554
  overall = min(overall, 30.0)
555
  # most crucial first: the mistakes that change meaning (severity 3), then the worst-scoring sounds
556
- max_tips = self.cfg.max_tips or int(self.rules.scoring.get("max_tips", 4))
557
- tips = sorted(tips, key=lambda t: (-t["severity"], t["score"]))[:max_tips]
 
558
  tip_idx, issue_idx = {t["idx"] for t in tips}, []
559
 
560
  # Anything short of 100 must say why: every imprecise sound that did not get a full tip,
@@ -571,6 +659,11 @@ class Scorer:
571
  for i in issue_idx:
572
  tok, sc = toks[i], scores[i]
573
  comp = i2t.get(sc.competitor) if sc.competitor is not None else None
 
 
 
 
 
574
  issues.append({"sound": sound_name(tok.sound) or sound_name(tok.phoneme) or tok.phoneme,
575
  "word_tr": tr_words[tok.word], "score": round(sc.score), "missing": bool(sc.deleted),
576
  "heard": None if sc.deleted else sound_name(comp)})
@@ -594,7 +687,7 @@ class Scorer:
594
  (the 'stages' part of the phoneme analysis). Nothing here changes the result."""
595
  cfg, g, i2t = self.cfg, self.cfg.gop, self.vocab.i2t
596
  p = lambda lp: round(float(np.exp(lp)), 4) if lp > -1e20 else 0.0
597
- max_tips = cfg.max_tips or int(self.rules.scoring.get("max_tips", 4))
598
  n = len(fit.toks)
599
  tokens = []
600
  for i, (tok, gsc, sc) in enumerate(zip(fit.toks, fit.gop_scores, scores)):
@@ -606,7 +699,8 @@ class Scorer:
606
  b = bases[i]
607
  stages = [
608
  {"stage": "gop", "score": gsc.score},
609
- {"stage": "exemption", "score": fit.scores[i].score, "applied": fit.exempted[i]},
 
610
  {"stage": "baseline", "score": after_baseline[i].score, "ref": b,
611
  "applied": b is not None and b < 100},
612
  {"stage": "calibration", "score": sc.score, "floor": floors[i],
@@ -623,7 +717,7 @@ class Scorer:
623
  "gop_factor": round(float(np.exp(gsc.gop * g.leniency)), 3),
624
  "confidence_factor": round(float(np.sqrt(confidence)), 3),
625
  "deleted": gsc.deleted, "stages": stages, "final": sc.score,
626
- "margin": {"needed": self.rules.margin(targets, "_" if gsc.deleted else heard_ph),
627
  "got": (round(g.deleted_peak - gsc.peak, 3) if gsc.deleted else round(-gsc.gop, 3)),
628
  "beyond": beyond[i]},
629
  "vowel": tok.phoneme in VOWELS, "weight": 0.5 if tok.phoneme in VOWELS else 1.0,
@@ -641,8 +735,12 @@ class Scorer:
641
  "word_min_weight": cfg.word_min_weight, "vowel_weight": 0.5, "unrelated_ratio": cfg.unrelated_ratio,
642
  "unrelated_cap": 30.0, "letter_good": LETTER_GOOD, "max_tips": max_tips, "max_issues": cfg.max_issues,
643
  "ref_min_mean": cfg.ref_min_mean, "margin_default": float(self.rules.scoring.get("margin_default", 0.0)),
644
- "margins": [{"target": t, "heard": h, "margin": m} for (t, h), m in sorted(self.rules.margins.items())],
645
- "exemptions": [name for name, _, _ in EXEMPTIONS],
 
 
 
 
646
  },
647
  "variants": [{"ar": ar, "ipa": ipa, "logp": lp, "chosen": ar == fit.ar and ipa == fit.ipa}
648
  for ar, ipa, lp in fit.candidates],
 
14
  from .align import Alignment, GopConfig, TokenScore, ctc_viterbi, edit_distance, greedy_decode, score_tokens
15
  from .archi import archify, diphthong_variants, split_token, written
16
  from .phones import base
17
+ from .rules import EXEMPTIONS, TOGGLE_IDS, Check, Rules, elided_article, near_vowels, load as load_rules # noqa: F401 (re-exported)
18
  from .tips import VOWELS, letter_reason, sound_name, tip_for
19
  from .translit import CONSONANTS as _HEB_C, HEARD_ONLY as _HEB_HEARD, VOWELS as _HEB_V, to_hebrew, word_letters, word_to_hebrew
20
 
 
220
  candidates: list[tuple[str, str, float | None]] = field(default_factory=list) # (ar, ipa, path log-prob)
221
  gop_scores: list[TokenScore] = field(default_factory=list) # before the exemptions
222
  exempted: list[str | None] = field(default_factory=list) # per token, the exemption that applied
223
+ exempt_scores: list[TokenScore] = field(default_factory=list) # after the exemptions, before the inserted vowels
224
+ inserted: dict[int, str] = field(default_factory=dict) # token -> the vowel heard before it (rules 3.10)
225
  distance: int = 0 # edit distance of the greedy decode from the chosen variant
226
 
227
 
 
235
  names = (set(HEBREW_SOUNDS) | self.rules.interference() | set(self.rules.heard_labels)
236
  | {p for s in self.rules.archiphonemes for p in self.rules.members(s)})
237
  self.named = {i for i in (vocab.sound_id(n) for n in names) if i is not None}
238
+ self.disabled: frozenset[str] = frozenset() # rules.TOGGLES the teacher switched off (ניטור)
239
+
240
+ def set_disabled(self, ids) -> None:
241
+ """Atomically replaces the switched-off rules; unknown ids are ignored."""
242
+ self.disabled = frozenset(ids) & TOGGLE_IDS
243
+
244
+ def _on(self, toggle: str) -> bool:
245
+ return toggle not in self.disabled
246
+
247
+ def _off_symbols(self) -> frozenset[str]:
248
+ """The archi.py symbols not to use: letters whose dialect rule is off, and E for the final-vowel rule."""
249
+ off = {t.removeprefix("dialect_") for t in self.disabled if t.startswith("dialect_")}
250
+ return frozenset(off | ({"E"} if "final_vowel" in self.disabled else set()))
251
+
252
+ def _check(self, tok: TargetToken, heard: str | None):
253
+ """The rule of pronunciation-rules.md for this substitution, unless the teacher switched it off.
254
+ A rule for a short vowel (3.11: u) speaks for that vowel only, not for its long form (base() drops length)."""
255
+ check = self.rules.check([tok.sound, tok.phoneme], heard)
256
+ if check and check.target in self.rules.flag_vowels and tok.phoneme != check.target:
257
+ return None
258
+ return check if check and self._on(f"rule_{check.rule}") else None
259
+
260
+ def _flag_vowel(self, toks: list[TargetToken], i: int) -> bool:
261
+ """Is token i a short vowel a rule flags (3.11: u after a consonant of the same word, not the و of 'and')?"""
262
+ t = toks[i]
263
+ rule = self.rules.flag_vowels.get(t.phoneme)
264
+ return bool(rule and self._on(f"rule_{rule}") and i > 0 and toks[i - 1].word == t.word
265
+ and toks[i - 1].phoneme not in VOWELS)
266
+
267
+ _GLIDES = {"w", "j"} # the way into or out of a glide sounds like a vowel in anyone's speech
268
+
269
+ def _insertions(self, logp: np.ndarray, toks: list[TargetToken], al: Alignment) -> dict[int, str]:
270
+ """Rules 3.10: token -> the vowel the model clearly heard between it and the consonant before it, in one word
271
+ (katiir for ktiir). The cluster that opens the sentence is spared (6.3 #16), and so are pairs with w or j."""
272
+ spec = self.rules.insertion
273
+ if not spec or not self._on(f"rule_{spec['rule']}"):
274
+ return {}
275
+ vowels = {i: base(self.vocab.i2t[i]) for i in (self.vocab.sound_id(v) for v in ("a", "e", "i", "o", "u", "ə"))
276
+ if i is not None}
277
+ real = logp[:, :self.vocab.n_real]
278
+ greedy = real.argmax(1)
279
+ min_peak, min_frames = float(np.log(spec.get("min_peak", 0.5))), int(spec.get("min_frames", 2))
280
+ out: dict[int, str] = {}
281
+ for j in range(2, len(toks)): # j == 1 is the cluster that opens the sentence
282
+ a, b = toks[j - 1], toks[j]
283
+ if (a.word != b.word or a.phoneme in VOWELS or b.phoneme in VOWELS
284
+ or {base(a.phoneme), base(b.phoneme)} & self._GLIDES):
285
+ continue
286
+ frames = [t for t in range(al.spans[j - 1][0], al.spans[j][1])
287
+ if int(greedy[t]) in vowels and real[t, greedy[t]] >= min_peak]
288
+ if len(frames) >= min_frames:
289
+ out[j] = vowels[int(greedy[frames[0]])]
290
+ return out
291
 
292
  def set_calibrations(self, rows: list[dict]) -> None:
293
  """Atomically replaces applied score floors. A blank phrase is a global rule."""
 
407
  """(arabic, ipa, ipa with symbols, tokens) for each accepted variant and its diphthong forms. Variants
408
  that differ only in how a many-ways letter is said (ʔ / q / k for ق) become one: the first written.
409
  A diphthong form the model has no symbols for is left out."""
410
+ out, seen, off = [], set(), self._off_symbols()
411
  for ar, ipa in phrase["variants"]:
412
+ forms = (ipa, *diphthong_variants(ar, ipa)) if self._on("diphthong") else (ipa,)
413
+ for n, form in enumerate(forms):
414
+ aipa = archify(ar, form, off)
415
  if aipa in seen:
416
  continue
417
  seen.add(aipa)
 
442
  ids = [t.id for t in toks]
443
  ar_words = ar.split()
444
  scores = score_tokens(ext, al, ids, blank, self.cfg.gop, [vocab.members.get(i) for i in ids],
445
+ vocab.n_real, self.named if self._on("unnamed_sounds") else None)
446
  gop_scores, exempted = list(scores), [None] * len(scores)
447
  for i, (t, sc) in enumerate(zip(toks, scores)):
448
  heard = vocab.i2t.get(sc.competitor) if sc.competitor is not None else None
449
  for name, applies, floor in EXEMPTIONS:
450
+ if self._on(name) and applies(toks, i, sc, heard, ar_words):
451
  scores[i] = replace(sc, score=max(sc.score, 100.0 if floor is None else getattr(self.cfg, floor)))
452
  exempted[i] = name
453
  break
454
+ exempt_scores = list(scores)
455
+ inserted = self._insertions(logp, toks, al)
456
+ cap = float((self.rules.insertion or {}).get("score_cap", 60))
457
+ for j in inserted:
458
+ scores[j] = replace(scores[j], score=min(scores[j].score, cap))
459
  same = lambda h, t: h == t or h in vocab.members.get(t, ())
460
  distance = edit_distance(heard_ids, ids, same)
461
  unrelated = bool(distance / max(1, len(ids)) > self.cfg.unrelated_ratio)
462
+ return Fit(ar, ipa, aipa, toks, al, scores, heard_ids, unrelated, ext, candidates=tried, gop_scores=gop_scores,
463
+ exempted=exempted, exempt_scores=exempt_scores, inserted=inserted, distance=distance)
464
 
465
  def _heatmap(self, logp: np.ndarray, toks: list[TargetToken], alignment, scores,
466
  duration_s: float | None) -> dict:
 
500
  "frames": frames, "frame_bins": bins, "rows": rows,
501
  "tracks": {"expected": expected, "heard": heard}}
502
 
503
+ def _max_tips(self) -> int:
504
+ """As many tips as the rules document says (3), or the 4 of before when its severity order is off."""
505
+ if self.cfg.max_tips:
506
+ return self.cfg.max_tips
507
+ return int(self.rules.scoring.get("max_tips", 4)) if self._on("tips_by_severity") else 4
508
+
509
  def _beyond_margin(self, tok: TargetToken, sc: TokenScore, heard: str | None) -> bool:
510
  """Is the competitor likelier than the target by more than the pair's margin? Pairs the model confuses
511
  on natives too (emphatics, a dropped h) need a clearer win before they count as a mistake."""
512
  targets = [tok.sound, tok.phoneme]
513
  if sc.deleted:
514
+ return sc.peak <= self.cfg.gop.deleted_peak - self._margin(targets, "_")
515
  if heard is None:
516
  return True
517
+ return -sc.gop >= self._margin(targets, heard)
518
+
519
+ def _margin(self, targets: list[str], heard: str | None) -> float:
520
+ """The pair's margin (rules 6.2), or the default for every pair when the teacher switched margins off."""
521
+ return self.rules.margin(targets if self._on("margins") else [], heard)
522
 
523
  def _dialect(self, toks: list[TargetToken], scores: list[TokenScore]) -> dict:
524
  """Which dialect the accepted realisations point to: {"dialect": urban | rural} when they agree,
 
574
  tr_words = to_hebrew(ipa, ar)
575
  for idx, (tok, sc) in enumerate(zip(toks, scores)):
576
  heard_ph = "_" if sc.deleted else (i2t.get(sc.competitor) if sc.competitor is not None else None)
577
+ check = self._check(tok, heard_ph)
578
  beyond.append(self._beyond_margin(tok, sc, heard_ph))
579
+ flagged = (bool(sc.score < self.cfg.flag_below) and beyond[-1]
580
+ and (tok.phoneme not in VOWELS or self._flag_vowel(toks, idx)))
581
+ if idx in fit.inserted: # a vowel before this consonant (3.10): the reason its letter gives
582
+ spec = self.rules.insertion
583
+ heard_as.append("+" + fit.inserted[idx])
584
+ checks.append(Check(spec["rule"], tok.phoneme, "+", int(spec["severity"]), str(spec["feedback"])))
585
+ else:
586
+ heard_as.append(heard_ph)
587
+ checks.append(check)
588
  p = {"ipa": tok.phoneme, "word": tok.word, "score": sc.score,
589
  "flagged": flagged, "heard": heard_ph if flagged else None}
590
+ if idx in fit.inserted:
591
+ p["inserted"] = fit.inserted[idx]
592
  if tok.sound != tok.phoneme:
593
  p["sound"] = tok.sound
594
  if sc.realised is not None:
 
603
  tip["idx"] = idx
604
  tip["score"] = round(sc.score)
605
  tips.append(tip)
606
+ if idx in fit.inserted and not unrelated and "cluster" not in seen:
607
+ seen.add("cluster")
608
+ spec = self.rules.insertion
609
+ tips.append({"title": self.rules.titles.get(spec["rule"], spec["rule"]), "text": str(spec["feedback"]),
610
+ "key": spec["rule"], "severity": int(spec["severity"]),
611
+ "word": ar_words[tok.word] if tok.word < len(ar_words) else "",
612
+ "word_tr": tr_words[tok.word], "idx": idx, "score": round(sc.score)})
613
 
614
  n_words = len(ipa.split("|"))
615
  heard_words = self._heard_hebrew(logp, toks, al, fit.aipa, ar_words)
 
640
  if unrelated:
641
  overall = min(overall, 30.0)
642
  # most crucial first: the mistakes that change meaning (severity 3), then the worst-scoring sounds
643
+ max_tips = self._max_tips()
644
+ tips = (sorted(tips, key=lambda t: (-t["severity"], t["score"])) if self._on("tips_by_severity")
645
+ else sorted(tips, key=lambda t: t["score"]))[:max_tips]
646
  tip_idx, issue_idx = {t["idx"] for t in tips}, []
647
 
648
  # Anything short of 100 must say why: every imprecise sound that did not get a full tip,
 
659
  for i in issue_idx:
660
  tok, sc = toks[i], scores[i]
661
  comp = i2t.get(sc.competitor) if sc.competitor is not None else None
662
+ if i in fit.inserted:
663
+ issues.append({"sound": "תנועה מיותרת לפני " + (sound_name(tok.phoneme) or tok.phoneme),
664
+ "word_tr": tr_words[tok.word], "score": round(sc.score), "missing": False,
665
+ "heard": sound_name(fit.inserted[i])})
666
+ continue
667
  issues.append({"sound": sound_name(tok.sound) or sound_name(tok.phoneme) or tok.phoneme,
668
  "word_tr": tr_words[tok.word], "score": round(sc.score), "missing": bool(sc.deleted),
669
  "heard": None if sc.deleted else sound_name(comp)})
 
687
  (the 'stages' part of the phoneme analysis). Nothing here changes the result."""
688
  cfg, g, i2t = self.cfg, self.cfg.gop, self.vocab.i2t
689
  p = lambda lp: round(float(np.exp(lp)), 4) if lp > -1e20 else 0.0
690
+ max_tips = self._max_tips()
691
  n = len(fit.toks)
692
  tokens = []
693
  for i, (tok, gsc, sc) in enumerate(zip(fit.toks, fit.gop_scores, scores)):
 
699
  b = bases[i]
700
  stages = [
701
  {"stage": "gop", "score": gsc.score},
702
+ {"stage": "exemption", "score": fit.exempt_scores[i].score, "applied": fit.exempted[i]},
703
+ {"stage": "insertion", "score": fit.scores[i].score, "applied": fit.inserted.get(i)},
704
  {"stage": "baseline", "score": after_baseline[i].score, "ref": b,
705
  "applied": b is not None and b < 100},
706
  {"stage": "calibration", "score": sc.score, "floor": floors[i],
 
717
  "gop_factor": round(float(np.exp(gsc.gop * g.leniency)), 3),
718
  "confidence_factor": round(float(np.sqrt(confidence)), 3),
719
  "deleted": gsc.deleted, "stages": stages, "final": sc.score,
720
+ "margin": {"needed": self._margin(targets, "_" if gsc.deleted else heard_ph),
721
  "got": (round(g.deleted_peak - gsc.peak, 3) if gsc.deleted else round(-gsc.gop, 3)),
722
  "beyond": beyond[i]},
723
  "vowel": tok.phoneme in VOWELS, "weight": 0.5 if tok.phoneme in VOWELS else 1.0,
 
735
  "word_min_weight": cfg.word_min_weight, "vowel_weight": 0.5, "unrelated_ratio": cfg.unrelated_ratio,
736
  "unrelated_cap": 30.0, "letter_good": LETTER_GOOD, "max_tips": max_tips, "max_issues": cfg.max_issues,
737
  "ref_min_mean": cfg.ref_min_mean, "margin_default": float(self.rules.scoring.get("margin_default", 0.0)),
738
+ "margins": [{"target": t, "heard": h, "margin": m} for (t, h), m in sorted(self.rules.margins.items())]
739
+ if self._on("margins") else [],
740
+ "exemptions": [name for name, _, _ in EXEMPTIONS if self._on(name)],
741
+ "insertion": ({k: self.rules.insertion[k] for k in ("score_cap", "min_peak", "min_frames")}
742
+ if self.rules.insertion and self._on(f"rule_{self.rules.insertion['rule']}") else None),
743
+ "disabled": sorted(self.disabled),
744
  },
745
  "variants": [{"ar": ar, "ipa": ipa, "logp": lp, "chosen": ar == fit.ar and ipa == fit.ipa}
746
  for ar, ipa, lp in fit.candidates],
app/tips.py CHANGED
@@ -84,7 +84,9 @@ def letter_reason(expected: str, heard: str | None, check=None) -> dict:
84
  name = sound_name(expected) or expected
85
  heard = HEARD_ALIASES.get(heard, heard)
86
  other = sound_name(heard)
87
- if heard == "_":
 
 
88
  why = f"{name} לא נשמעה"
89
  elif other and other != name:
90
  why = f"{name} נשמעה כמו {other}"
@@ -104,7 +106,7 @@ def tip_for(expected: str, heard: str | None, check=None) -> dict | None:
104
 
105
  `check` (rules.Check) is the rule of pronunciation-rules.md for this substitution: its feedback and
106
  severity win over TIPS. The key stays "expected>heard" either way."""
107
- if expected in VOWELS:
108
  return None
109
  heard = HEARD_ALIASES.get(heard, heard) if heard else "_"
110
  if heard == "_" and expected in NO_DELETION_TIP:
 
84
  name = sound_name(expected) or expected
85
  heard = HEARD_ALIASES.get(heard, heard)
86
  other = sound_name(heard)
87
+ if heard and heard.startswith("+"): # a vowel added before this consonant (rules 3.10)
88
+ why = f"נוספה {sound_name(heard[1:]) or 'תנועה'} לפני {name}"
89
+ elif heard == "_":
90
  why = f"{name} לא נשמעה"
91
  elif other and other != name:
92
  why = f"{name} נשמעה כמו {other}"
 
106
 
107
  `check` (rules.Check) is the rule of pronunciation-rules.md for this substitution: its feedback and
108
  severity win over TIPS. The key stays "expected>heard" either way."""
109
+ if expected in VOWELS and not check: # a short vowel gets a tip only from a rule that flags it (3.11)
110
  return None
111
  heard = HEARD_ALIASES.get(heard, heard) if heard else "_"
112
  if heard == "_" and expected in NO_DELETION_TIP:
scripts/compare_verbs.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Check the phonemes of static/assets/arabic_verbs.json against what the scorer uses.
3
+
4
+ Usage: python scripts/compare_verbs.py # summary + every difference
5
+ python scripts/compare_verbs.py --all # also list the forms that match
6
+ python scripts/compare_verbs.py --no-vocab # skip the model-vocab check (no tokenizer download)
7
+
8
+ For every past / future form (and its accepted_phonemes) it reports:
9
+ bank the same Arabic word in the phrase bank (phrases.py, workbook LEX, grammar VERBS) and whether its IPA
10
+ agrees: "same" (identical), "same sounds" (identical once archify() symbols and base() are applied,
11
+ i.e. the scorer would build the same target), or "differs"
12
+ archi how archify() rewrites the form (ق ك ث ذ ظ ج as Q K TH DH ZH J) and the letters it could not place
13
+ vocab phonemes the model has no symbol for (mapped to a fallback, or not expressible at all)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import re
21
+ import sys
22
+ from collections import Counter
23
+ from pathlib import Path
24
+
25
+ ROOT = Path(__file__).resolve().parent.parent
26
+ sys.path.insert(0, str(ROOT))
27
+
28
+ from app.archi import archify, split_token, unresolved # noqa: E402
29
+ from app.grammar import VERBS # noqa: E402
30
+ from app.phones import base # noqa: E402
31
+ from app.phrases import PHRASES # noqa: E402
32
+ from app.workbook import LEX # noqa: E402
33
+
34
+ VERBS_JSON = ROOT / "static/assets/arabic_verbs.json"
35
+ _MARKS = re.compile(r"[ً-ْٰـ]") # harakat, shadda, sukun, dagger alif, tatweel
36
+
37
+
38
+ def loose(ar: str) -> str:
39
+ """Arabic without diacritics and with the hamza seats of alif folded, for a lookup that tolerates spelling."""
40
+ return _MARKS.sub("", ar).translate(str.maketrans("أإآ", "ااا")).replace("ى", "ي")
41
+
42
+
43
+ def bank_words() -> dict[str, set[str]]:
44
+ """Arabic word -> every IPA the bank gives it (single words of each variant, LEX, grammar VERBS)."""
45
+ out: dict[str, set[str]] = {}
46
+ for p in PHRASES:
47
+ for ar, ipa in p["variants"]:
48
+ ars, ipas = ar.split(), [w.strip() for w in ipa.split("|")]
49
+ if len(ars) == len(ipas):
50
+ for a, i in zip(ars, ipas):
51
+ out.setdefault(a, set()).add(i)
52
+ for key, (_tr, ipas) in LEX.items():
53
+ out.setdefault(key.partition("#")[0], set()).update(ipas)
54
+ for forms in VERBS.values():
55
+ for _he, _tr, ar, ipas in forms:
56
+ out.setdefault(ar, set()).update(ipas)
57
+ return out
58
+
59
+
60
+ def sounds(ar: str, ipa: str) -> tuple[str, ...]:
61
+ """The target as the scorer sees it: archify() symbols kept, other phonemes reduced to their base() sound."""
62
+ out = []
63
+ for tok in archify(ar, ipa).split():
64
+ if tok == "|":
65
+ continue
66
+ sym, _members, ph = split_token(tok)
67
+ out.append(sym if sym else base(ph))
68
+ return tuple(out)
69
+
70
+
71
+ def load_vocab():
72
+ from huggingface_hub import hf_hub_download
73
+
74
+ from app.scorer import Vocab
75
+ model_id = os.environ.get("MODEL_ID", "facebook/wav2vec2-xlsr-53-espeak-cv-ft")
76
+ path = Path(model_id) / "vocab.json" if Path(model_id).is_dir() else hf_hub_download(model_id, "vocab.json")
77
+ return Vocab(json.loads(Path(path).read_text(encoding="utf-8")))
78
+
79
+
80
+ def vocab_issues(vocab, ar: str, ipa: str) -> list[str]:
81
+ issues = []
82
+ for tok in archify(ar, ipa).split():
83
+ before = dict(vocab.downgraded)
84
+ try:
85
+ vocab.token(tok)
86
+ except KeyError as e:
87
+ issues.append(f"{tok}: {e}")
88
+ continue
89
+ issues += [f"{k}->{v}" for k, v in vocab.downgraded.items() if before.get(k) != v]
90
+ return issues
91
+
92
+
93
+ def main() -> None:
94
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
95
+ ap.add_argument("--all", action="store_true", help="also print forms that match the bank")
96
+ ap.add_argument("--no-vocab", action="store_true", help="skip the model-vocab check")
97
+ args = ap.parse_args()
98
+
99
+ data = json.loads(VERBS_JSON.read_text(encoding="utf-8"))
100
+ bank = bank_words()
101
+ bank_loose: dict[str, set[str]] = {}
102
+ for a, ipas in bank.items():
103
+ bank_loose.setdefault(loose(a), set()).update(ipas)
104
+ vocab = None if args.no_vocab else load_vocab()
105
+
106
+ tally: Counter[str] = Counter()
107
+ symbols: Counter[str] = Counter()
108
+ for section, entry in data.items():
109
+ for verb in entry["verbs"]:
110
+ for tense in ("past", "future"):
111
+ form = verb[tense]
112
+ ar = form["arabic"]
113
+ for ipa in [form["phonemes"], *form.get("accepted_phonemes", [])]:
114
+ lines = []
115
+ for tok in ipa.split():
116
+ if split_token(tok)[0]:
117
+ symbols[tok] += 1
118
+ known = bank.get(ar) or bank_loose.get(loose(ar))
119
+ if not known:
120
+ status = "not in bank"
121
+ elif ipa in known:
122
+ status = "same"
123
+ elif sounds(ar, ipa) in {sounds(ar, k) for k in known}:
124
+ status = "same sounds"
125
+ else:
126
+ status = "differs"
127
+ how = "" if ar in bank else " (matched without diacritics: may be another word)"
128
+ lines.append(f" bank: {' / '.join(sorted(known))}{how}")
129
+ tally[status] += 1
130
+ arch = archify(ar, ipa)
131
+ if arch != ipa:
132
+ lines.append(f" archi: {arch}")
133
+ if missing := unresolved(ar, ipa):
134
+ tally["unresolved"] += 1
135
+ lines.append(f" unresolved letters: {' '.join(l for _, l in missing)}")
136
+ if vocab is not None and (bad := vocab_issues(vocab, ar, ipa)):
137
+ tally["vocab"] += 1
138
+ lines.append(f" vocab: {', '.join(bad)}")
139
+ if status in ("differs",) or missing or args.all or (vocab is not None and bad):
140
+ print(f"[{section}] {verb['hebrew']} {tense} {ar} {ipa} -> {status}")
141
+ print("\n".join(lines)) if lines else None
142
+
143
+ print("\n== summary ==")
144
+ for k in ("same", "same sounds", "differs", "not in bank", "unresolved", "vocab"):
145
+ print(f"{k:12} {tally[k]}")
146
+ if symbols:
147
+ print("symbols written in the JSON itself (archify() leaves such a word as it is):",
148
+ ", ".join(f"{s}×{n}" for s, n in symbols.most_common()))
149
+
150
+
151
+ if __name__ == "__main__":
152
+ main()
static/assets/pronunciation-rules.md CHANGED
@@ -38,6 +38,8 @@
38
  | ה | נשיפה רכה, תמיד נשמעת | אַהְוֶה (קפה) |
39
  | א | עצירה קצרה בגרון | אַהְלַא (שלום) |
40
  | אַא, ִי, וּ | תנועה ארוכה: מחזיקים בערך כפול | כְּתִיר = ktiir |
 
 
41
  | ּ (דגש) | עיצור כפול: מחזיקים אותו | חַמַּאם = ħam-maam |
42
 
43
  ---
@@ -217,6 +219,40 @@ checks:
217
  - {target: J, heard: [g, ɡ], severity: 1, feedback: "נהגתה g. בערבית פלסטינית ג' היא dʒ או ז'."}
218
  ```
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  ---
221
 
222
  <!-- audience: student -->
@@ -226,10 +262,9 @@ checks:
226
 
227
  | כלל | מה עושים | דוגמה |
228
  |---|---|---|
229
- | **הטעם לא בסוף המילה** | בעברית הטעם בדרך כלל בסוף המילה. בערבית מטעימים את ההברה הסגורה האחרונה שאינה בסוף המילה. | מֻשְ־כִּ־לֶה = **MUSH**-kile, מַדְ־רַ־סֶה = **MAD**-rase, בְּתִעְ־מַל = b**tiʕ**-mal |
230
  | **ה סופית היא תנועה** | ה בסוף מילה היא לא אות שקטה. ברוב המילים נהגית e, ואחרי ח, ע, ר ועיצור מודגש נהגית a. בכפר שומעים לפעמים i. | אַהְוֶה, מֻשְכִּלֶה, אבל סַאעַה, מַחַטַּה |
231
  | **עיצור כפול** | מחזיקים את העיצור לפני שממשיכים. ההבדל משנה משמעות. | חַמַּאם (שירותים) ≠ ��ַמַאם (יונים) |
232
- | **שני עיצורים בהתחלה** | לא מוסיפים תנועה בין העיצורים. | כְּתִיר = ktiir (לא katiir), מְנִיח = mniiħ |
233
  | **אִל־ מתחברת** | לפני ת, ד, ס, צ, ש, ז, ר, נ, ט ו־ד' ה־ל נעלמת והעיצור מוכפל. | אִסְּסַאעַה (השעה), לא אִלְסַאעַה |
234
 
235
  ---
@@ -318,7 +353,7 @@ scoring:
318
  | 4 | אִל־ בלי התבוללות (il-saaʕa במקום is-saaʕa) | שגיאה קלה, וה־CTC לא מבחין בה באמינות | לא מסמנים. הכלל מופיע במדריך בלבד |
319
  | 5 | e מול a ב־ة סופית, וגם i בכפר | שונות לגיטימית | בהגה אחרון במילה שמקורה ב־ة: {a, e, i} שקולים |
320
  | 6 | o מול u בכינוי נסתר (בֵּיתוֹ/בֵּיתוּ) | הבדל בין ניבים | בהגה אחרון של כינוי: {o, u} שקולים |
321
- | 7 | שונות בתנועות קצרות (miʃ/muʃ, i/e, u/o) | שונות בין דוברים | תנועות קצרות לא מסומנות לעולם, רק אורך דרך כלל 3.8 |
322
  | 8 | eː/aj ו־oː/aw (בֵּית/בַּיְת) | הגייה כפרית או ותיקה | {eː, aj} ו־{oː, aw} שקולים |
323
  | 9 | תנועה כהה ליד עיצור מודגש | זו בדיוק ההגייה הנכונה | אין כלל שמסמן איכות תנועה |
324
  | 10 | עיצור כפול (חַמַּאם) | ה־CTC מכווץ כפילות לטוקן אחד, ואין מדידת משך אמינה | לא נבדק. הכלל מופיע במדריך בלבד |
@@ -327,6 +362,7 @@ scoring:
327
  | 13 | g במקום `Q` | מבטא בדואי ודרומי | מתקבל רק אם `optional_realisations_enabled: true`. אחרת סומן כשגיאה כללית, חומרה 1 |
328
  | 14 | חזרה, היסוס או התחלה מחדש | הפענוח רחוק מכל וריאנט | `unrelated=true`, ציון עד 30, בלי טיפים |
329
  | 15 | הקלטה קצרה מדי או שקטה | אין חלון לכל הגה | `error: too_short` |
 
330
 
331
  ---
332
 
@@ -345,6 +381,8 @@ scoring:
345
  | ה נבלעת | בינונית | |
346
  | אורך תנועה | נמוכה | חומרה 1 ו־`margin` גבוה |
347
  | טעם, כפילות, התבוללות | לא ניתן | מופיעים במדריך בלבד |
 
 
348
 
349
  ---
350
 
@@ -387,6 +425,7 @@ heard_labels:
387
  u: תנועה קצרה
388
  e: תנועה קצרה
389
  o: תנועה קצרה
 
390
  ```
391
 
392
  ---
 
38
  | ה | נשיפה רכה, תמיד נשמעת | אַהְוֶה (קפה) |
39
  | א | עצירה קצרה בגרון | אַהְלַא (שלום) |
40
  | אַא, ִי, וּ | תנועה ארוכה: מחזיקים בערך כפול | כְּתִיר = ktiir |
41
+ | ְ (שווא) | בלי תנועה: שני העיצורים צמודים. בתחילת משפט קשה להגות שווא, ולכן מותר שיישמע כמו e קצרה (צירה) | כְּתִיר = ktiir, ובתחילת משפט גם ketiir |
42
+ | ֻ (קובוץ) | u קצרה אבל ברורה ומלאה, אף פעם לא נבלעת לשווא | מֻחַאמִי = muħaami, לא mħaami |
43
  | ּ (דגש) | עיצור כפול: מחזיקים אותו | חַמַּאם = ħam-maam |
44
 
45
  ---
 
219
  - {target: J, heard: [g, ɡ], severity: 1, feedback: "נהגתה g. בערבית פלסטינית ג' היא dʒ או ז'."}
220
  ```
221
 
222
+ ### 3.10 שני עיצורים צמודים · דיוק
223
+ - **למה זה קשה:** בעברית מדוברת מוסיפים תנועה בין שני עיצורים שבאים ברצף (שווא נע), וכך גם בערבית: katiir במקום ktiir.
224
+ - **מה עושים:** מצמידים את שני העיצורים, בלי תנועה ביניהם. בתחילת משפט קשה להגות שווא, ולכן שם מותר שיישמע כמו e קצרה (צירה).
225
+ - **דוגמאות:** כְּתִיר = ktiir (לא katiir; בתחילת משפט גם ketiir), מְנִיח = mniiħ, בְּנְשוּף = bnʃuuf
226
+
227
+ ה־scorer בודק תנועה שהמודל שומע בבירור בין שני עיצורים של אותה מילה. הוא לא בודק את הצרור שפותח את המשפט (6.3 #16), ולא צירוף עם w או j, שהמעבר אליהם נשמע כמו תנועה גם אצל דוברים ילידיים.
228
+
229
+ ```yaml
230
+ id: cluster
231
+ title: שני עיצורים צמודים
232
+ insertion:
233
+ severity: 1
234
+ min_peak: 0.5 # a vowel the model is at least this sure of ...
235
+ min_frames: 2 # ... in at least this many frames (40 ms) between two consonants of one word
236
+ score_cap: 60 # the second consonant scores at most this
237
+ feedback: "נוספה תנועה בין שני העיצורים. הצמידו אותם, בלי תנועה ביניהם: ktiir ולא katiir. רק בתחילת משפט מותר e קצרה."
238
+ ```
239
+
240
+ ### 3.11 קובוץ ברור · דיוק
241
+ - **למה זה קשה:** את השווא מותר להחליש, ודוברי עברית מחלישים גם תנועות קצרות אחרות. אבל u (קובוץ) היא תנועה מובחנת מאוד: מֻחַאמִי (עורך דין) היא muħaami, ולא mħaami.
242
+ - **מה עושים:** הוגים את ה־u מלאה וברורה גם כשהיא קצרה, בלי להטעים אותה ובלי לבלוע אותה לשווא.
243
+ - **דוגמאות:** מֻחַאמִי = muħaami, בֻּכְּרַה = bukra, מֻשְכִּלֶה = muʃkile
244
+
245
+ זו התנועה הקצרה היחידה שה־scorer מסמן (ראו 6.3 #7), ורק אחרי עיצור באותה מילה, לא ו' החיבור.
246
+
247
+ ```yaml
248
+ id: kubutz
249
+ title: קובוץ ברור
250
+ flag_vowels: [u] # short u after a consonant is flagged, unlike the other short vowels (6.3 #7)
251
+ checks:
252
+ - {target: u, heard: ["_"], severity: 1, feedback: "ה־u נבלעה. הגו אותה מלאה וברורה גם כשהיא קצרה: muħaami ולא mħaami."}
253
+ - {target: u, heard: [e, i, ə], severity: 1, feedback: "ה־u נשמעה כמו e או שווא. u היא תנועה מובחנת: עגלו את השפתיים, muħaami."}
254
+ ```
255
+
256
  ---
257
 
258
  <!-- audience: student -->
 
262
 
263
  | כלל | מה עושים | דוגמה |
264
  |---|---|---|
265
+ | **הטעם לא בסוף המילה** | בעברית הטעם בדרך כלל בסוף המילה, ודוברי עברית נוטים להטעים את ההברה האחרונה גם בערבית, בלי צורך. בערבית מטעימים את ההברה הסגורה או הארוכה האחרונה שאינה בסוף המילה. | מֻ־חַא־מִי (עורך דין) = mu-**ĦAA**-mi, לא muħaa-**MI** · מֻשְ־כִּ־לֶה = **MUSH**-kile, מַדְ־רַ־סֶה = **MAD**-rase, בְּתִעְ־מַל = b**tiʕ**-mal |
266
  | **ה סופית היא תנועה** | ה בסוף מילה היא לא אות שקטה. ברוב המילים נהגית e, ואחרי ח, ע, ר ועיצור מודגש נהגית a. בכפר שומעים לפעמים i. | אַהְוֶה, מֻשְכִּלֶה, אבל סַאעַה, מַחַטַּה |
267
  | **עיצור כפול** | מחזיקים את העיצור לפני שממשיכים. ההבדל משנה משמעות. | חַמַּאם (שירותים) ≠ ��ַמַאם (יונים) |
 
268
  | **אִל־ מתחברת** | לפני ת, ד, ס, צ, ש, ז, ר, נ, ט ו־ד' ה־ל נעלמת והעיצור מוכפל. | אִסְּסַאעַה (השעה), לא אִלְסַאעַה |
269
 
270
  ---
 
353
  | 4 | אִל־ בלי התבוללות (il-saaʕa במקום is-saaʕa) | שגיאה קלה, וה־CTC לא מבחין בה באמינות | לא מסמנים. הכלל מופיע במדריך בלבד |
354
  | 5 | e מול a ב־ة סופית, וגם i בכפר | שונות לגיטימית | בהגה אחרון במילה שמקורה ב־ة: {a, e, i} שקולים |
355
  | 6 | o מול u בכינוי נסתר (בֵּיתוֹ/בֵּיתוּ) | הבדל בין ניבים | בהגה אחרון של כינוי: {o, u} שקולים |
356
+ | 7 | שונות בתנועות קצרות (miʃ/muʃ, i/e, u/o) | שונות בין דוברים | תנועות קצרות לא מסומנות, רק אורך דרך כלל 3.8. חריג: u אחרי עיצור שנשמטה או נשמעה כ־e, i או שווא (3.11). u כ־o היא עדיין תנועה קרובה |
357
  | 8 | eː/aj ו־oː/aw (בֵּית/בַּיְת) | הגייה כפרית או ותיקה | {eː, aj} ו־{oː, aw} שקולים |
358
  | 9 | תנועה כהה ליד עיצור מודגש | זו בדיוק ההגייה הנכונה | אין כלל שמסמן איכות תנועה |
359
  | 10 | עיצור כפול (חַמַּאם) | ה־CTC מכווץ כפילות לטוקן אחד, ואין מדידת משך אמינה | לא נבדק. הכלל מופיע במדריך בלבד |
 
362
  | 13 | g במקום `Q` | מבטא בדואי ודרומי | מתקבל רק אם `optional_realisations_enabled: true`. אחרת סומן כשגיאה כללית, חומרה 1 |
363
  | 14 | חזרה, היסוס או התחלה מחדש | הפענוח רחוק מכל וריאנט | `unrelated=true`, ציון עד 30, בלי טיפים |
364
  | 15 | הקלטה קצרה מדי או שקטה | אין חלון לכל הגה | `error: too_short` |
365
+ | 16 | שווא בתחילת משפט נשמע כ־e (ketiir במקום ktiir) | קשה להגות צרור עיצורים בפתיחת המשפט | לא מסמנים: הצרור הראשון של המשפט פטור מכלל 3.10. באמצע משפט תנועה כזו מסומנת. בבדיקת "משפט אחר" היא נספרת כהבדל אחד |
366
 
367
  ---
368
 
 
381
  | ה נבלעת | בינונית | |
382
  | אורך תנועה | נמוכה | חומרה 1 ו־`margin` גבוה |
383
  | טעם, כפילות, התבוללות | לא ניתן | מופיעים במדריך בלבד |
384
+ | תנועה שנוספה בין עיצורים (katiir) | בינונית | נבדקת רק כשהמודל בטוח בתנועה (0.5) לפחות 40 ms. ליד ח, ע ו־ה המעבר עלול להישמע כתנועה גם אצל דובר ילידי |
385
+ | קובוץ שנבלע או נהגה כ־e (mħaami) | בינונית | תנועות קצרות הן החלק החלש של המודל, ולכן חומרה 1 |
386
 
387
  ---
388
 
 
425
  u: תנועה קצרה
426
  e: תנועה קצרה
427
  o: תנועה קצרה
428
+ ə: שווא
429
  ```
430
 
431
  ---
static/index.html CHANGED
@@ -414,7 +414,19 @@ body:not(.manage) #manage,body.manage #wrap>header,body.manage #path,body.manage
414
  #manage.flags :is(#mClasses,#mContent,#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.flags) #mFlags{display:none}
415
  .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}
416
  .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)}
417
- .flag-review textarea{width:100%;resize:vertical}.flag-patterns{width:100%;border-collapse:collapse;text-align:start}.flag-patterns :is(th,td){padding:6px;border-bottom:1px solid var(--line)}
 
 
 
 
 
 
 
 
 
 
 
 
418
  .activity{display:flex;flex-direction:column;gap:12px}.activity-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}.activity-head h2{margin:0}.activity-head p{margin:3px 0 0;color:var(--muted);font-size:13px}
419
  .activity-head select{font:inherit;padding:7px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink)}
420
  .activity-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.activity-stat{padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--bg)}.activity-stat b{display:block;font:400 28px/1 var(--f-display);font-variant-numeric:tabular-nums}.activity-stat small{color:var(--muted);font-size:12px}
@@ -613,7 +625,7 @@ dialog::backdrop{background:rgba(0,0,0,.45)}
613
  .stage-table .dec-flag{color:var(--sumac);font-weight:700}
614
  .stage-table .dec-issue{color:var(--amber);font-weight:700}
615
  .stage-table .dec-ok{color:var(--ok)}
616
- #stagesBox[data-hl="gop"] .st-gop,#stagesBox[data-hl="exemption"] .st-exemption,#stagesBox[data-hl="baseline"] .st-baseline,
617
  #stagesBox[data-hl="calibration"] .st-calibration,#stagesBox[data-hl="decision"] .st-decision,#stagesBox[data-hl="variant"] .st-variant,
618
  #stagesBox[data-hl="tips"] .st-tips{outline:2px solid var(--olive);outline-offset:-2px}
619
  #stagesBox[data-hl="word"] tr.stage-word td,#stagesBox[data-hl="sentence"] #stageSentence,#stagesBox[data-hl="unrelated"] #stageVariants{outline:2px solid var(--olive);outline-offset:-2px}
@@ -2289,9 +2301,10 @@ function drawStages(r){
2289
  [["λ סלחנות",c.leniency],["p לביטחון מלא",c.confident_peak],["השמטה: p צפוי <",c.deleted_peak],["… וגם p מתחרה <",c.deleted_comp]],
2290
  `100·e^(GOP·${c.leniency})·√min(1,p/${c.confident_peak})`,
2291
  `${count(x=>stageOf(x,"gop").score<c.flag_below)} הגאים מתחת ל־${c.flag_below} · ${count(x=>x.deleted)} הושמטו`);
2292
- card("exemption",4,"פטורים (6.3)","מה שהמודל שומע כטעות אבל הוא דיבור רגיל.",
2293
- c.exemptions.map(e=>[EXEMPTION_NAMES[e]||e,e==="near_vowel"?"≥ "+c.near_vowel_floor:"100"]),null,
2294
- `${count(x=>stageOf(x,"exemption").applied)} פטורים הוחלו`);
 
2295
  card("baseline",5,"קו בסיס ילידי","ציון ההגה מחולק בציון שהמודל נותן לקול הילידי על אותו הגה.",
2296
  [["הקלטות ילידיות",t.references.length],["ממוצע מינימלי לאמון",c.ref_min_mean]],"min(100, 100·score / ref)",
2297
  `${count(x=>stageOf(x,"baseline").applied)} הגאים הותאמו`);
@@ -2340,7 +2353,7 @@ function drawStages(r){
2340
  // every sound through the stages
2341
  const tb=$("stageTable"); tb.innerHTML="";
2342
  const head=[["#",""],["הגה","כתוב · סמל · מחלקה"],["חלון","פריימים",""],["P צפוי","","gop"],["מתחרה","P","gop"],["GOP","log יחס","gop"],
2343
- ["e^(GOP·λ)",`λ=${c.leniency}`,"gop"],["√ביטחון",`p/${c.confident_peak}`,"gop"],["ציון GOP","","gop"],["פטור","","exemption"],
2344
  ["קו בסיס","ref","baseline"],["כיול","floor","calibration"],["סופי","",""],["מרווח","בפועל / נדרש","decision"],
2345
  ["החלטה",`סימון <${c.flag_below} · רשימה <${c.issue_below}`,"decision"],["כלל","חומרה","tips"],["משקל","","sentence"]];
2346
  const hr=E("tr"); for(const [a,b,st] of head){ const th=E("th",st?"st-"+st:"",a,b?E("small","",b):null); hr.append(th); } tb.append(E("thead","",hr));
@@ -2348,7 +2361,7 @@ function drawStages(r){
2348
  t.words.forEach((w,wi)=>{
2349
  body.append(E("tr","stage-word",(()=>{ const td=E("td","",`מילה ${wi+1}: ${w.tr} · ${num(w.score)}`,E("span","eq",`k·min + (1−k)·mean = ${c.word_min_weight}·${num(w.min)} + ${num(1-c.word_min_weight,2)}·${num(w.mean)} = ${num(w.score)}`)); td.colSpan=head.length; return td; })()));
2350
  for(const x of toks.filter(x=>x.word===wi)){
2351
- const gop=stageOf(x,"gop"), ex=stageOf(x,"exemption"), bl=stageOf(x,"baseline"), cal=stageOf(x,"calibration");
2352
  const who=E("td","",ltr(x.ipa));
2353
  if(x.sound!==x.ipa) who.append(E("small","",ltr(x.sound+(x.class?" {"+x.class.join(",")+"}":"")+(x.realised?" → "+x.realised:""))));
2354
  const comp=E("td","st-gop",x.deleted?"הושמט":x.competitor_other?"צליל לא מוכר":x.competitor?ltr(x.competitor):"—",E("small","",pct(x.p_competitor)));
@@ -2357,6 +2370,7 @@ function drawStages(r){
2357
  const m=x.margin, beyondTxt=m.beyond?"✓":"✗";
2358
  let dec, decCls;
2359
  if(x.flagged){ dec="מסומן"; decCls="dec-flag"; }
 
2360
  else if(x.issue){ dec="ברשימה"; decCls="dec-issue"; }
2361
  else if(x.final<c.flag_below&&x.vowel){ dec="תנועה: לא מסומנת"; decCls="dec-issue"; }
2362
  else if(x.final<c.flag_below&&!m.beyond){ dec="מרווח לא הושג"; decCls="dec-issue"; }
@@ -2370,6 +2384,7 @@ function drawStages(r){
2370
  E("td","st-gop",ltr(num(x.gop,3))), E("td","st-gop",ltr(num(x.gop_factor,3))), E("td","st-gop",ltr(num(x.confidence_factor,3))),
2371
  E("td","st-gop "+band(gop.score),num(gop.score)),
2372
  step(ex,"exemption",ex.applied?(EXEMPTION_NAMES[ex.applied]||ex.applied):""),
 
2373
  step(bl,"baseline",bl.ref!=null?"ref "+num(bl.ref):""),
2374
  step(cal,"calibration",cal.floor!=null?"floor "+num(cal.floor):""),
2375
  E("td",band(x.final),num(x.final)),
@@ -2864,6 +2879,7 @@ function sentence(p,under){ // a sentence as the student sees it, and a small
2864
  let CLASS=null, shownClass=store.get("ihki.mstudentClass","*"); // "*" is every student; otherwise the class on show
2865
  let mMode=store.get("ihki.mmode",""); // the panel's part on show: "classes" or "students" (the classes, before the first one)
2866
  let FLAG_REVIEWS={pending:[],confirmed:[]}, CALIBRATIONS=[], ATTEMPT_PATTERNS=[], flagsError="", flagsLoaded=false, flagAudioUrls=[];
 
2867
  let ACTIVITY=null, activityError="";
2868
  let activityHours=+store.get("ihki.activityHours","24"); if(![24,168].includes(activityHours)) activityHours=24;
2869
  let shownStudent="", classScroll=0; // the student whose screen is on show (an email; "" for the class's), and where the class's was left
@@ -2965,6 +2981,11 @@ async function loadFlags(){
2965
  get("/api/calibrations","calibrations"),get("/api/attempt-patterns","patterns")]);
2966
  FLAG_REVIEWS={pending,confirmed}; CALIBRATIONS=calibrations; ATTEMPT_PATTERNS=attemptPatterns; flagsLoaded=true;
2967
  }catch(err){ flagsError=err.message; flagsLoaded=true; }
 
 
 
 
 
2968
  await activity;
2969
  }
2970
  function flagIssues(f){
@@ -2973,10 +2994,58 @@ function flagIssues(f){
2973
  out.push({word,expected:p.ipa,heard:p.heard||"_",label:`${w.tr||w.ar}: ${p.ipa} ← ${p.heard||"חסר"}`});
2974
  return out;
2975
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2976
  function drawFlags(){
2977
  const box=$("mFlags"); box.innerHTML=""; for(const url of flagAudioUrls) URL.revokeObjectURL(url); flagAudioUrls=[];
2978
  drawActivity(box);
2979
  if(!flagsLoaded){ box.append(mark("card","בחרו בניטור כדי לטעון את הנתונים.")); return; }
 
2980
  if(flagsError){ const card=mark("card",""); card.append(mark("","לא ניתן לטעון את הדיווחים","h2"),mark("",flagsError,"p")); box.append(card); return; }
2981
  if(CALIBRATIONS.some(c=>c.status!=="rejected"&&!(c.evidence||{}).dismissed)){
2982
  const list=mark("card",""); list.append(mark("","הצעות כיול","h2"));
 
414
  #manage.flags :is(#mClasses,#mContent,#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.flags) #mFlags{display:none}
415
  .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}
416
  .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)}
417
+ .flag-review textarea{width:100%;resize:vertical}
418
+ .scoring-rules summary{cursor:pointer;font-size:17px}.scoring-rules details[open] summary{margin-bottom:8px}
419
+ .scoring-rules .meta{color:var(--muted);font-size:13px}
420
+ .rule-group h3{margin:14px 0 6px;font:700 14px/1.3 var(--f-body)}
421
+ .rule-row{display:flex;align-items:flex-start;gap:10px;padding:8px 10px;border:1px solid var(--line);border-radius:10px;margin-bottom:6px;cursor:pointer}
422
+ .rule-row input{margin-top:3px;width:17px;height:17px;flex:none}
423
+ .rule-row.off{background:var(--bg);color:var(--muted)}
424
+ .rule-text{display:flex;flex-wrap:wrap;gap:2px 8px;align-items:baseline}
425
+ .rule-sec{font-size:12px;color:var(--muted)}
426
+ .rule-desc{flex-basis:100%;font-size:13px;color:var(--muted)}
427
+ .rule-note{flex-basis:100%;font-size:12px;color:var(--amber)}
428
+ .rule-warn{padding:8px 10px;border-radius:10px;background:var(--amber-bg);color:var(--amber);font-size:13px}
429
+ .rule-warn code{display:block;margin-top:4px;white-space:pre-wrap;word-break:break-all}.flag-patterns{width:100%;border-collapse:collapse;text-align:start}.flag-patterns :is(th,td){padding:6px;border-bottom:1px solid var(--line)}
430
  .activity{display:flex;flex-direction:column;gap:12px}.activity-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}.activity-head h2{margin:0}.activity-head p{margin:3px 0 0;color:var(--muted);font-size:13px}
431
  .activity-head select{font:inherit;padding:7px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink)}
432
  .activity-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.activity-stat{padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--bg)}.activity-stat b{display:block;font:400 28px/1 var(--f-display);font-variant-numeric:tabular-nums}.activity-stat small{color:var(--muted);font-size:12px}
 
625
  .stage-table .dec-flag{color:var(--sumac);font-weight:700}
626
  .stage-table .dec-issue{color:var(--amber);font-weight:700}
627
  .stage-table .dec-ok{color:var(--ok)}
628
+ #stagesBox[data-hl="gop"] .st-gop,#stagesBox[data-hl="exemption"] .st-exemption,#stagesBox[data-hl="exemption"] .st-insertion,#stagesBox[data-hl="baseline"] .st-baseline,
629
  #stagesBox[data-hl="calibration"] .st-calibration,#stagesBox[data-hl="decision"] .st-decision,#stagesBox[data-hl="variant"] .st-variant,
630
  #stagesBox[data-hl="tips"] .st-tips{outline:2px solid var(--olive);outline-offset:-2px}
631
  #stagesBox[data-hl="word"] tr.stage-word td,#stagesBox[data-hl="sentence"] #stageSentence,#stagesBox[data-hl="unrelated"] #stageVariants{outline:2px solid var(--olive);outline-offset:-2px}
 
2301
  [["λ סלחנות",c.leniency],["p לביטחון מלא",c.confident_peak],["השמטה: p צפוי <",c.deleted_peak],["… וגם p מתחרה <",c.deleted_comp]],
2302
  `100·e^(GOP·${c.leniency})·√min(1,p/${c.confident_peak})`,
2303
  `${count(x=>stageOf(x,"gop").score<c.flag_below)} הגאים מתחת ל־${c.flag_below} · ${count(x=>x.deleted)} הושמטו`);
2304
+ card("exemption",4,"פטורים ותוספות","מה שהמודל שומע כטעות אבל הוא דיבור רגיל (6.3), ותנועה שנוספה בין עיצורים (3.10).",
2305
+ [...c.exemptions.map(e=>[EXEMPTION_NAMES[e]||e,e==="near_vowel"?"≥ "+c.near_vowel_floor:"100"]),
2306
+ ...(c.insertion?[["תנועה בין עיצורים: תקרה",c.insertion.score_cap],["… p תנועה ≥",c.insertion.min_peak],["… פריימים ≥",c.insertion.min_frames]]:[])],null,
2307
+ `${count(x=>stageOf(x,"exemption").applied)} פטורים · ${count(x=>(stageOf(x,"insertion")||{}).applied)} תוספות תנועה`);
2308
  card("baseline",5,"קו בסיס ילידי","ציון ההגה מחולק בציון שהמודל נותן לקול הילידי על אותו הגה.",
2309
  [["הקלטות ילידיות",t.references.length],["ממוצע מינימלי לאמון",c.ref_min_mean]],"min(100, 100·score / ref)",
2310
  `${count(x=>stageOf(x,"baseline").applied)} הגאים הותאמו`);
 
2353
  // every sound through the stages
2354
  const tb=$("stageTable"); tb.innerHTML="";
2355
  const head=[["#",""],["הגה","כתוב · סמל · מחלקה"],["חלון","פריימים",""],["P צפוי","","gop"],["מתחרה","P","gop"],["GOP","log יחס","gop"],
2356
+ ["e^(GOP·λ)",`λ=${c.leniency}`,"gop"],["√ביטחון",`p/${c.confident_peak}`,"gop"],["ציון GOP","","gop"],["פטור","","exemption"],["תנועה נוספת","3.10","insertion"],
2357
  ["קו בסיס","ref","baseline"],["כיול","floor","calibration"],["סופי","",""],["מרווח","בפועל / נדרש","decision"],
2358
  ["החלטה",`סימון <${c.flag_below} · רשימה <${c.issue_below}`,"decision"],["כלל","חומרה","tips"],["משקל","","sentence"]];
2359
  const hr=E("tr"); for(const [a,b,st] of head){ const th=E("th",st?"st-"+st:"",a,b?E("small","",b):null); hr.append(th); } tb.append(E("thead","",hr));
 
2361
  t.words.forEach((w,wi)=>{
2362
  body.append(E("tr","stage-word",(()=>{ const td=E("td","",`מילה ${wi+1}: ${w.tr} · ${num(w.score)}`,E("span","eq",`k·min + (1−k)·mean = ${c.word_min_weight}·${num(w.min)} + ${num(1-c.word_min_weight,2)}·${num(w.mean)} = ${num(w.score)}`)); td.colSpan=head.length; return td; })()));
2363
  for(const x of toks.filter(x=>x.word===wi)){
2364
+ const gop=stageOf(x,"gop"), ex=stageOf(x,"exemption"), ins=stageOf(x,"insertion")||{score:ex.score}, bl=stageOf(x,"baseline"), cal=stageOf(x,"calibration");
2365
  const who=E("td","",ltr(x.ipa));
2366
  if(x.sound!==x.ipa) who.append(E("small","",ltr(x.sound+(x.class?" {"+x.class.join(",")+"}":"")+(x.realised?" → "+x.realised:""))));
2367
  const comp=E("td","st-gop",x.deleted?"הושמט":x.competitor_other?"צליל לא מוכר":x.competitor?ltr(x.competitor):"—",E("small","",pct(x.p_competitor)));
 
2370
  const m=x.margin, beyondTxt=m.beyond?"✓":"✗";
2371
  let dec, decCls;
2372
  if(x.flagged){ dec="מסומן"; decCls="dec-flag"; }
2373
+ else if(ins.applied){ dec="תנועה מיותרת"; decCls="dec-flag"; }
2374
  else if(x.issue){ dec="ברשימה"; decCls="dec-issue"; }
2375
  else if(x.final<c.flag_below&&x.vowel){ dec="תנועה: לא מסומנת"; decCls="dec-issue"; }
2376
  else if(x.final<c.flag_below&&!m.beyond){ dec="מרווח לא הושג"; decCls="dec-issue"; }
 
2384
  E("td","st-gop",ltr(num(x.gop,3))), E("td","st-gop",ltr(num(x.gop_factor,3))), E("td","st-gop",ltr(num(x.confidence_factor,3))),
2385
  E("td","st-gop "+band(gop.score),num(gop.score)),
2386
  step(ex,"exemption",ex.applied?(EXEMPTION_NAMES[ex.applied]||ex.applied):""),
2387
+ step(ins,"insertion",ins.applied?"נוספה "+ins.applied:""),
2388
  step(bl,"baseline",bl.ref!=null?"ref "+num(bl.ref):""),
2389
  step(cal,"calibration",cal.floor!=null?"floor "+num(cal.floor):""),
2390
  E("td",band(x.final),num(x.final)),
 
2879
  let CLASS=null, shownClass=store.get("ihki.mstudentClass","*"); // "*" is every student; otherwise the class on show
2880
  let mMode=store.get("ihki.mmode",""); // the panel's part on show: "classes" or "students" (the classes, before the first one)
2881
  let FLAG_REVIEWS={pending:[],confirmed:[]}, CALIBRATIONS=[], ATTEMPT_PATTERNS=[], flagsError="", flagsLoaded=false, flagAudioUrls=[];
2882
+ let SCORING_RULES=null, scoringRulesError=""; // the rules of pronunciation-rules.md and whether each is on (see drawScoringRules)
2883
  let ACTIVITY=null, activityError="";
2884
  let activityHours=+store.get("ihki.activityHours","24"); if(![24,168].includes(activityHours)) activityHours=24;
2885
  let shownStudent="", classScroll=0; // the student whose screen is on show (an email; "" for the class's), and where the class's was left
 
2981
  get("/api/calibrations","calibrations"),get("/api/attempt-patterns","patterns")]);
2982
  FLAG_REVIEWS={pending,confirmed}; CALIBRATIONS=calibrations; ATTEMPT_PATTERNS=attemptPatterns; flagsLoaded=true;
2983
  }catch(err){ flagsError=err.message; flagsLoaded=true; }
2984
+ try{ // apart from the reports: a missing table here must not hide them
2985
+ const res=await api("/api/scoring-rules"), data=await res.json().catch(()=>({}));
2986
+ if(!res.ok) throw new Error(data.detail||res.status);
2987
+ SCORING_RULES=data; scoringRulesError="";
2988
+ }catch(err){ SCORING_RULES=null; scoringRulesError=err.message; }
2989
  await activity;
2990
  }
2991
  function flagIssues(f){
 
2994
  out.push({word,expected:p.ipa,heard:p.heard||"_",label:`${w.tr||w.ar}: ${p.ipa} ← ${p.heard||"חסר"}`});
2995
  return out;
2996
  }
2997
+ // The rules taken from pronunciation-rules.md, each with a switch. All are on by default; switching one off brings back
2998
+ // the behaviour from before the document, for every student, from the next recording on.
2999
+ const RULE_GROUPS={accept:"מה נחשב נכון (ניבים)",judge:"איך נשפטת טעות",feedback:"מה נאמר לתלמיד"};
3000
+ function drawScoringRules(box){
3001
+ const card=mark("card scoring-rules",""), details=document.createElement("details"), summary=document.createElement("summary");
3002
+ card.append(details); details.append(summary); box.append(card);
3003
+ if(!SCORING_RULES){ summary.textContent="כללי ההגייה"; details.append(mark("msg","לא ניתן לטעון את הכללים: "+(scoringRulesError||""),"p")); return; }
3004
+ const rules=SCORING_RULES.rules, on=rules.filter(r=>r.active).length;
3005
+ summary.append(mark("","כללי ההגייה מהמסמך","b"),mark("meta",` · ${on} מתוך ${rules.length} פעילים`));
3006
+ details.append(mark("meta","הכללים שנוספו לציון מתוך pronunciation-rules.md. כולם פעילים כברירת מחדל. כיבוי כלל מחזיר את ההתנהגות שלפני המסמך, לכל התלמידים, מההקלטה הבאה.","p"));
3007
+ if(!SCORING_RULES.stored){
3008
+ const warn=mark("rule-warn",""); warn.append(mark("","כדי לשנות כללים צריך ליצור את הטבלה scoring_rules. הריצו ב-SQL Editor של Supabase:","p"),mark("ltr",SCORING_RULES.create_sql,"code"));
3009
+ details.append(warn);
3010
+ }
3011
+ const msg=mark("msg","");
3012
+ const actions=mark("row",""), all=mark("btn","הפעלת כל הכללים","button");
3013
+ all.disabled=!SCORING_RULES.stored||on===rules.length;
3014
+ all.onclick=async()=>{ all.disabled=true; msg.textContent="שומר…";
3015
+ try{ for(const r of rules.filter(r=>!r.active)){ const res=await api(`/api/scoring-rules/${r.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({active:true})}); if(!res.ok) throw new Error((await res.json().catch(()=>({}))).detail||res.status); r.active=true; }
3016
+ msg.textContent=""; box.innerHTML=""; drawFlags();
3017
+ }catch(err){ msg.textContent="השמירה נכשלה: "+err.message; all.disabled=false; } };
3018
+ actions.append(all,msg); details.append(actions);
3019
+ for(const [group,label] of Object.entries(RULE_GROUPS)){
3020
+ const list=rules.filter(r=>r.group===group); if(!list.length) continue;
3021
+ const section=mark("rule-group",""); section.append(mark("",label,"h3"));
3022
+ for(const r of list){
3023
+ const row=document.createElement("label"), input=document.createElement("input"), text=mark("rule-text","");
3024
+ row.className="rule-row"+(r.active?"":" off"); input.type="checkbox"; input.checked=r.active; input.disabled=!SCORING_RULES.stored;
3025
+ text.append(mark("",r.title,"b"),mark("rule-sec",`סעיף ${r.section}`),mark("rule-desc",r.description,"span"));
3026
+ if(!r.detectable) text.append(mark("rule-note","המודל הנוכחי לא מבחין בזה, כך שהכלל לא משנה דבר כרגע","span"));
3027
+ if(r.updated_by) text.append(mark("rule-note",`${r.active?"הופעל":"כובה"} על ידי ${r.updated_by}`,"span"));
3028
+ input.onchange=async()=>{
3029
+ input.disabled=true; msg.textContent="שומר…";
3030
+ try{
3031
+ const res=await api(`/api/scoring-rules/${r.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({active:input.checked})});
3032
+ const data=await res.json().catch(()=>({})); if(!res.ok) throw new Error(data.detail||res.status);
3033
+ r.active=input.checked; row.classList.toggle("off",!r.active); msg.textContent="";
3034
+ summary.lastChild.textContent=` · ${rules.filter(x=>x.active).length} מתוך ${rules.length} פעילים`;
3035
+ all.disabled=rules.every(x=>x.active);
3036
+ }catch(err){ input.checked=!input.checked; msg.textContent="השמירה נכשלה: "+err.message; }
3037
+ input.disabled=false;
3038
+ };
3039
+ row.append(input,text); section.append(row);
3040
+ }
3041
+ details.append(section);
3042
+ }
3043
+ }
3044
  function drawFlags(){
3045
  const box=$("mFlags"); box.innerHTML=""; for(const url of flagAudioUrls) URL.revokeObjectURL(url); flagAudioUrls=[];
3046
  drawActivity(box);
3047
  if(!flagsLoaded){ box.append(mark("card","בחרו בניטור כדי לטעון את הנתונים.")); return; }
3048
+ drawScoringRules(box);
3049
  if(flagsError){ const card=mark("card",""); card.append(mark("","לא ניתן לטעון את הדיווחים","h2"),mark("",flagsError,"p")); box.append(card); return; }
3050
  if(CALIBRATIONS.some(c=>c.status!=="rejected"&&!(c.evidence||{}).dismissed)){
3051
  const list=mark("card",""); list.append(mark("","הצעות כיול","h2"));
tests/test_scoring.py CHANGED
@@ -1561,6 +1561,7 @@ def test_a_tutor_flags_a_recording_scored_wrong(monkeypatch):
1561
 
1562
  def __init__(self):
1563
  self.cls, self.flags, self.table, self.rank, self.calibration_rows = [], [], True, frozenset(), []
 
1564
 
1565
  def user(self, token):
1566
  return {"t": {"id": "u1", "email": "T@b.c"}, "s": {"id": "u2", "email": "s@b.c"}}.get(token)
@@ -1618,6 +1619,16 @@ def test_a_tutor_flags_a_recording_scored_wrong(monkeypatch):
1618
  self.calibration_rows = [r for r in self.calibration_rows if r["id"] != calibration_id]
1619
  return len(self.calibration_rows) < before
1620
 
 
 
 
 
 
 
 
 
 
 
1621
  def all_attempt_issues(self):
1622
  return [{"user_id": f"u{i}", "phrase": TOILET["he"], "score": 60,
1623
  "issues": [{"expected": "ħ", "heard": "χ", "score": 20}],
@@ -1677,6 +1688,21 @@ def test_a_tutor_flags_a_recording_scored_wrong(monkeypatch):
1677
  "evidence": {"dismissed": True, "source": "confirmed_flags"}})
1678
  assert dismissed.status_code == 200 and dismissed.json()["calibration"]["status"] == "draft"
1679
  assert dismissed.json()["calibration"]["evidence"] == {"dismissed": True, "source": "confirmed_flags"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1680
  a.teachers = frozenset()
1681
  a.table = False # before the table is created: the command to create it
1682
  r = send(c, "t")
@@ -1708,6 +1734,88 @@ def test_a_tutor_flags_a_recording_scored_wrong(monkeypatch):
1708
  assert r.status_code == 503 and "create table public.tutors" in r.json()["detail"]
1709
 
1710
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1711
  def test_teacher_trace_follows_every_stage():
1712
  # ħ said as χ, then forgiven by a teacher floor: each stage of the trace shows where the score came from
1713
  logp = emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"])
@@ -1717,7 +1825,7 @@ def test_teacher_trace_follows_every_stage():
1717
  t = scorer.score_logp(logp, TOILET, diagnostics=True)["trace"]
1718
  assert t["config"]["flag_below"] == 50 and t["config"]["leniency"] == 0.5 and t["variants"][0]["chosen"]
1719
  h = next(x for x in t["tokens"] if x["ipa"] == "ħ")
1720
- assert [s["stage"] for s in h["stages"]] == ["gop", "exemption", "baseline", "calibration"]
1721
- assert h["stages"][0]["score"] < 50 and h["stages"][3]["applied"] and h["final"] == 60
1722
  assert h["competitor"] == "χ" and h["rule"]["severity"] == 3 and not h["flagged"]
1723
  assert t["sentence"]["final"] == round(0.5 * t["sentence"]["phone_mean"] + 0.5 * t["sentence"]["word_mean"])
 
1561
 
1562
  def __init__(self):
1563
  self.cls, self.flags, self.table, self.rank, self.calibration_rows = [], [], True, frozenset(), []
1564
+ self.rule_rows = {}
1565
 
1566
  def user(self, token):
1567
  return {"t": {"id": "u1", "email": "T@b.c"}, "s": {"id": "u2", "email": "s@b.c"}}.get(token)
 
1619
  self.calibration_rows = [r for r in self.calibration_rows if r["id"] != calibration_id]
1620
  return len(self.calibration_rows) < before
1621
 
1622
+ def scoring_rules(self):
1623
+ if self.rule_rows is None:
1624
+ raise AuthUnavailable("supabase answered 404: no table")
1625
+ return list(self.rule_rows.values())
1626
+
1627
+ def set_scoring_rule(self, rule_id, active, by):
1628
+ if self.rule_rows is None:
1629
+ raise AuthUnavailable("supabase answered 404: no table")
1630
+ self.rule_rows[rule_id] = {"id": rule_id, "active": active, "updated_by": by}
1631
+
1632
  def all_attempt_issues(self):
1633
  return [{"user_id": f"u{i}", "phrase": TOILET["he"], "score": 60,
1634
  "issues": [{"expected": "ħ", "heard": "χ", "score": 20}],
 
1688
  "evidence": {"dismissed": True, "source": "confirmed_flags"}})
1689
  assert dismissed.status_code == 200 and dismissed.json()["calibration"]["status"] == "draft"
1690
  assert dismissed.json()["calibration"]["evidence"] == {"dismissed": True, "source": "confirmed_flags"}
1691
+ # the rules of pronunciation-rules.md: all on, a teacher switches one off, and the scorer follows at once
1692
+ listed = c.get("/api/scoring-rules", headers=hs).json()
1693
+ assert listed["stored"] and all(r["active"] for r in listed["rules"]) and len(listed["rules"]) >= 20
1694
+ assert all(isinstance(r["detectable"], bool) and r["title"] and r["description"] for r in listed["rules"])
1695
+ assert c.put("/api/scoring-rules/dialect_Q", headers=hs, json={"active": False}).json()["disabled"] == ["dialect_Q"]
1696
+ assert c.app.state.scorer.disabled == {"dialect_Q"} and a.rule_rows["dialect_Q"]["updated_by"] == "s@b.c"
1697
+ assert not next(r for r in c.get("/api/scoring-rules", headers=hs).json()["rules"] if r["id"] == "dialect_Q")["active"]
1698
+ assert c.put("/api/scoring-rules/nope", headers=hs, json={"active": False}).status_code == 400
1699
+ assert c.put("/api/scoring-rules/margins", headers=hs, json={"active": "no"}).status_code == 400
1700
+ assert c.put("/api/scoring-rules/margins", headers={"Authorization": "Bearer t"}, json={"active": False}).status_code == 403
1701
+ assert c.put("/api/scoring-rules/dialect_Q", headers=hs, json={"active": True}).json()["disabled"] == []
1702
+ a.rule_rows = None # no table yet: the list still shows, all on, with the SQL to create it
1703
+ missing = c.get("/api/scoring-rules", headers=hs).json()
1704
+ assert not missing["stored"] and "create table public.scoring_rules" in missing["create_sql"]
1705
+ assert c.put("/api/scoring-rules/margins", headers=hs, json={"active": False}).status_code == 503
1706
  a.teachers = frozenset()
1707
  a.table = False # before the table is created: the command to create it
1708
  r = send(c, "t")
 
1734
  assert r.status_code == 503 and "create table public.tutors" in r.json()["detail"]
1735
 
1736
 
1737
+ def test_switched_off_rules_bring_back_the_old_behaviour():
1738
+ kaddeesh = emissions(["k", "a", "d", "eː", "ʃ", "i", "s", "aː", "ʕ", "a"])
1739
+ s = scorer_for(kaddeesh)
1740
+ assert not s.score(None, TIME)["tips"] # rural ق accepted
1741
+ s.set_disabled({"dialect_Q", "not-a-rule"})
1742
+ assert s.disabled == {"dialect_Q"}
1743
+ assert [t["key"] for t in s.score(None, TIME)["tips"]] == ["ʔ>k"] # as before the document
1744
+ # the emphatic margin: off, plain t winning by less than twice is a mistake again
1745
+ phrase = {"variants": [("طين", "tˤ i n")]}
1746
+ s = margin_scorer(1.8)
1747
+ assert not s.score(None, phrase)["tips"]
1748
+ s.set_disabled({"margins"})
1749
+ assert [t["key"] for t in s.score(None, phrase)["tips"]] == ["tˤ>t"]
1750
+ # a section-3 rule off: the general tip and severity instead of the document's
1751
+ s = custom_scorer(["p", "f", "dʒ"], ["p", "i", "n", "dʒ", "aː", "n"])
1752
+ fin = {"variants": [("فنجان", "f i n dʒ aː n")]}
1753
+ assert s.score(None, fin)["tips"][0]["text"].startswith("נהגתה p")
1754
+ s.set_disabled({"rule_f"})
1755
+ tip = s.score(None, fin)["tips"][0]
1756
+ assert tip["key"] == "f>p" and not tip["text"].startswith("נהגתה p") and tip["severity"] == 2
1757
+ # the final vowel of ة: off, i for e is marked down like any other vowel
1758
+ s = scorer_for(emissions(["ʔ", "a", "h", "w", "i"]))
1759
+ assert s.score(None, COFFEE)["words"][0]["phonemes"][-1]["score"] >= 95
1760
+ s.set_disabled({"final_vowel", "near_vowel"})
1761
+ assert s.score(None, COFFEE)["words"][0]["phonemes"][-1]["score"] < 50
1762
+
1763
+
1764
+ THANKS = {"variants": [("شكرا كتير", "ʃ u k r a n | k t iː r")]}
1765
+ CLUSTER_SYMS = ["r", "t", "iː", "u", "ʃ"]
1766
+
1767
+
1768
+ def test_vowel_between_two_consonants_is_a_mistake_mid_sentence():
1769
+ clean = custom_scorer(CLUSTER_SYMS, ["ʃ", "u", "k", "r", "a", "n", "k", "t", "iː", "r"]).score(None, THANKS)
1770
+ assert not clean["tips"] and clean["score"] >= 95
1771
+ s = custom_scorer(CLUSTER_SYMS, ["ʃ", "u", "k", "r", "a", "n", "k", "a", "t", "iː", "r"]) # katiir
1772
+ r = s.score(None, THANKS)
1773
+ t = next(p for p in r["words"][1]["phonemes"] if p["ipa"] == "t")
1774
+ assert t["inserted"] == "a" and t["score"] == 60 and [x["key"] for x in r["tips"]] == ["cluster"]
1775
+ assert r["tips"][0]["severity"] == 1 and "ktiir" in r["tips"][0]["text"] and r["score"] < clean["score"]
1776
+ letter = next(l for l in r["words"][1]["letters"] if "ת" in l["t"])
1777
+ assert letter["why"].startswith("נוספה") and "ktiir" in letter["tip"]
1778
+ s.set_disabled({"rule_cluster"}) # switched off: not checked, as before
1779
+ assert not s.score(None, THANKS)["tips"]
1780
+
1781
+
1782
+ def test_vowel_in_the_cluster_that_opens_the_sentence_is_fine():
1783
+ # ketiir at the start of a sentence (6.3 #16), and the way into a glide (shuway)
1784
+ r = custom_scorer(["t", "iː", "r"], ["k", "e", "t", "iː", "r"]).score(None, {"variants": [("كتير", "k t iː r")]})
1785
+ assert not r["tips"] and r["score"] >= 95
1786
+ phrase = {"variants": [("شو شوي", "ʃ u | ʃ w a j j")]}
1787
+ r = custom_scorer(["ʃ", "u", "j"], ["ʃ", "u", "ʃ", "u", "w", "a", "j"]).score(None, phrase)
1788
+ assert not any(t["key"] == "cluster" for t in r["tips"])
1789
+
1790
+
1791
+ LAWYER = {"variants": [("محامي", "m u ħ aː m i")]}
1792
+
1793
+
1794
+ @pytest.mark.parametrize("said,flagged", [
1795
+ (["m", "u", "ħ", "aː", "m", "i"], False),
1796
+ (["m", "ħ", "aː", "m", "i"], True), # mħaami: the u swallowed
1797
+ (["m", "e", "ħ", "aː", "m", "i"], True), # said as e
1798
+ (["m", "o", "ħ", "aː", "m", "i"], False), # o is a near vowel
1799
+ ])
1800
+ def test_kubutz_is_a_distinct_vowel(said, flagged):
1801
+ r = custom_scorer(["u", "o"], said).score(None, LAWYER)
1802
+ u = r["words"][0]["phonemes"][1]
1803
+ assert u["ipa"] == "u" and u["flagged"] == flagged, u
1804
+ if flagged:
1805
+ assert r["tips"][0]["text"].startswith("ה־u") and r["tips"][0]["severity"] == 1
1806
+
1807
+
1808
+ def test_only_short_u_after_a_consonant_is_flagged():
1809
+ s = custom_scorer(["u", "o"], ["m", "ħ", "aː", "m", "i"])
1810
+ s.set_disabled({"rule_kubutz"}) # switched off: like any other short vowel
1811
+ assert not s.score(None, LAWYER)["words"][0]["phonemes"][1]["flagged"]
1812
+ # the و of "and" (u m a) and a long uː (ʃ uː f) are not the kubutz rule's
1813
+ r = custom_scorer(["u"], ["m", "a"]).score(None, {"variants": [("وما", "u m a")]})
1814
+ assert not r["words"][0]["phonemes"][0]["flagged"]
1815
+ r = custom_scorer(["u", "uː", "f", "ʃ"], ["ʃ", "e", "f"]).score(None, {"variants": [("شوف", "ʃ uː f")]})
1816
+ assert not r["words"][0]["phonemes"][1]["flagged"] and not r["tips"]
1817
+
1818
+
1819
  def test_teacher_trace_follows_every_stage():
1820
  # ħ said as χ, then forgiven by a teacher floor: each stage of the trace shows where the score came from
1821
  logp = emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"])
 
1825
  t = scorer.score_logp(logp, TOILET, diagnostics=True)["trace"]
1826
  assert t["config"]["flag_below"] == 50 and t["config"]["leniency"] == 0.5 and t["variants"][0]["chosen"]
1827
  h = next(x for x in t["tokens"] if x["ipa"] == "ħ")
1828
+ assert [s["stage"] for s in h["stages"]] == ["gop", "exemption", "insertion", "baseline", "calibration"]
1829
+ assert h["stages"][0]["score"] < 50 and h["stages"][4]["applied"] and h["final"] == 60
1830
  assert h["competitor"] == "χ" and h["rule"]["severity"] == 3 and not h["flagged"]
1831
  assert t["sentence"]["final"] == round(0.5 * t["sentence"]["phone_mean"] + 0.5 * t["sentence"]["word_mean"])