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 +14 -0
- app/archi.py +7 -6
- app/auth.py +14 -2
- app/main.py +51 -1
- app/rules.py +95 -1
- app/scorer.py +118 -20
- app/tips.py +4 -2
- scripts/compare_verbs.py +152 -0
- static/assets/pronunciation-rules.md +42 -3
- static/index.html +76 -7
- tests/test_scoring.py +110 -2
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":
|
| 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 |
-
|
| 358 |
-
|
|
|
|
| 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,
|
|
|
|
| 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.
|
| 447 |
if heard is None:
|
| 448 |
return True
|
| 449 |
-
return -sc.gop >= self.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 506 |
beyond.append(self._beyond_margin(tok, sc, heard_ph))
|
| 507 |
-
flagged = bool(sc.score < self.cfg.flag_below) and
|
| 508 |
-
|
| 509 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 557 |
-
tips = sorted(tips, key=lambda t: (-t["severity"], t["score"]))
|
|
|
|
| 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 =
|
| 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.
|
|
|
|
| 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.
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) | שונות בין דוברים | תנועות קצרות לא מסומנות
|
| 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}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,"פטורים
|
| 2293 |
-
c.exemptions.map(e=>[EXEMPTION_NAMES[e]||e,e==="near_vowel"?"≥ "+c.near_vowel_floor:"100"]),
|
| 2294 |
-
|
|
|
|
| 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"][
|
| 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"])
|