3v324v23 commited on
Commit
e586f48
·
1 Parent(s): 937f94c

Remove obsolete test files and training scripts for phoneme CTC model on Palestinian Arabic recordings

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.env.example CHANGED
@@ -1,86 +1,12 @@
1
- # Copy to .env and fill in. .env is git-ignored and is not copied into the Docker image.
2
- # On Hugging Face, set these as Space *secrets* instead (Settings -> Variables and secrets).
3
-
4
- # Sawtak Arabi TTS key for the model-answer playback (https://sawtakarabi.ai/dashboard).
5
- # Without it the page falls back to the device's Arabic voice.
6
- SAWTAK_API_KEY=
7
- # Optional: the Sawtak voice used for normal model-answer playback. Empty = Raef.
8
- # Pronunciation-score baselines keep their fixed reference voices and do not use this setting.
9
- SAWTAK_VOICE_ID=
10
- # its display name (default אמיר); playback only, the score baseline stays Raef + Fadwa
11
- SAWTAK_VOICE_NAME=
12
-
13
- # Optional: the edge-tts voice of the spoken Hebrew prompts. Empty = he-IL-HilaNeural.
14
- HEBREW_VOICE=
15
-
16
- # Optional: where the generated speech (Arabic answers, Hebrew prompts) is cached. Empty = ~/.cache/ihki-tts.
17
- TTS_CACHE_DIR=
18
- # The Hebrew read aloud gets niqqud first (Dicta's free Nakdan, cached in TTS_CACHE_DIR). 0 turns it off.
19
- HEBREW_NIQQUD=
20
-
21
- # Optional: the Hugging Face CTC phoneme model the scorer runs. Unset = facebook/wav2vec2-xlsr-53-espeak-cv-ft.
22
- # Leave the line commented out rather than empty: an empty value is used as is and fails to load.
23
- # scripts/bench.py and scripts/rescore.py read it too, so a benchmark runs on the same model as the server.
24
- # MODEL_ID=facebook/wav2vec2-xlsr-53-espeak-cv-ft
25
-
26
- # Optional: how a vowel's GOP pools its frames (docs/gop-pooling.md): max | mean | topk | lse | energy | gated.
27
- # Empty = gated. "max" is the scoring before pooling. The lab's results show it beside the version, to compare runs.
28
- GOP_RATIO_POOL=
29
-
30
- # Optional: which frames a vowel's competitors are read from. Empty = wide (from the sound before to the one after,
31
- # as consonants). "narrow" = its aligned frames +- 1, or a number for another margin. Narrow drops spikes in the
32
- # transitions, but also misses a substitution held just outside it; see scripts/gop_window_bench.py.
33
- GOP_VOWEL_WINDOW=
34
-
35
- # Google sign-in through Supabase (Project Settings -> API Keys). Both values are public: the page gets them too.
36
- # Empty = no sign-in, the service is open. On Hugging Face these can be plain Space *variables*.
37
  SUPABASE_URL=
38
  SUPABASE_PUBLISHABLE_KEY=
39
 
40
- # Optional: the teacher's account (or several, separated by commas or spaces). After sign-in a teacher chooses
41
- # between "תרגול" (the same as a student) and "ניהול" (the management panel: every student's progress, and the
42
- # classes). Who may sign in is set by the classes the teacher opens there (see the README), not here.
43
  TEACHER_EMAILS=
44
-
45
- # Bootstrap accounts with full access, including the Lab. Admins do not get Lab access. Keep at least one address here
46
- # so database permission mistakes cannot lock every developer out.
47
  DEVELOPER_EMAILS=
48
 
49
- # Needed by the management panel and the classes: the project's secret key (Project Settings -> API Keys -> secret, or the
50
- # legacy service_role key). It reads every student's rows, so it is NOT public: on Hugging Face set it as a *secret*.
51
- # scripts/bench.py needs it (with the two values above) to read the lab's recordings.
52
- SUPABASE_SECRET_KEY=
53
-
54
- # Public URL of the restricted Grok Business MCP connector. On Hugging Face this is inferred from
55
- # SPACE_HOST; set it explicitly for a custom domain or local OAuth test. It must end in /mcp/ and
56
- # exactly match the URL entered in Grok. Authentication is handled by Supabase OAuth, not a shared secret.
57
- MCP_PUBLIC_URL=
58
-
59
- # Optional durable store for flag and Lab recordings. Set AUDIO_BUCKET to a private Hugging Face Storage Bucket
60
- # and HF_TOKEN as a Space secret with write access. New audio goes only to HF. To migrate old Supabase audio once
61
- # while preserving a fixed compatibility set, set AUDIO_MIGRATE_BEFORE to the oldest timestamp you want to keep.
62
- AUDIO_BUCKET=
63
- AUDIO_BUCKET_PREFIX=ihki-audio
64
- AUDIO_MIGRATE_BEFORE=2026-10-17T00:00:00+03:00
65
-
66
- # Optional: accounts (separated by commas or spaces) that, like the teachers, get a flag under each score, for a recording they said
67
- # right and the system scored wrong. A flag is kept in the Supabase table `flags` with its recording (see the README),
68
- # so it needs SUPABASE_SECRET_KEY. These accounts may sign in without being in a class.
69
- TUTORS_EMAIL=
70
-
71
- # Optional deployment metadata shown to teachers at the bottom of the management panel.
72
- # Without these, the app uses a content hash, the newest source-file time, and the local git commit when available.
73
- APP_VERSION=
74
- BUILD_DATE=
75
- GIT_COMMIT=
76
- GIT_COMMIT_MESSAGE=
77
-
78
- # Lets a developer replace SAWTAK_API_KEY / ANTHROPIC_API_KEY from the management panel (kept encrypted in Supabase,
79
- # table app_secrets; see the README). Make one with:
80
- # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
81
- # Keep it only here / as a Space secret. Losing it loses the keys set in the panel (the ones above still work).
82
- SECRETS_MASTER_KEY=
83
-
84
- # the daily video's morning reminder (app/push.py): make one with python -m app.push
85
- VAPID_PRIVATE_KEY=
86
- # PUSH_HOUR=8
 
1
+ # Optional Supabase authentication. Leave both empty for a public local app.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  SUPABASE_URL=
3
  SUPABASE_PUBLISHABLE_KEY=
4
 
5
+ # Required for class roster management.
6
+ SUPABASE_SECRET_KEY=
 
7
  TEACHER_EMAILS=
 
 
 
8
  DEVELOPER_EMAILS=
9
 
10
+ # Enables photographed-problem hints and solutions.
11
+ ANTHROPIC_API_KEY=
12
+ # MATH_MODEL=claude-opus-5-5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile CHANGED
@@ -2,27 +2,15 @@
2
  # CPU image; runs on Hugging Face Spaces (Docker SDK), Render, Fly, or any VM.
3
  FROM python:3.11-slim
4
 
5
- RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
6
-
7
  # Spaces runs the container as uid 1000
8
  RUN useradd -m -u 1000 user
9
  ENV HOME=/home/user HF_HOME=/home/user/.cache/huggingface PYTHONUNBUFFERED=1
10
  WORKDIR /home/user/app
11
 
12
- RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
13
  COPY requirements.txt .
14
  RUN pip install --no-cache-dir -r requirements.txt
15
 
16
- ARG MODEL_ID=facebook/wav2vec2-xlsr-53-espeak-cv-ft
17
- ENV MODEL_ID=${MODEL_ID}
18
  USER user
19
- # bake the model into the image so cold starts don't download it (1.2-1.6 GB). MODEL_ID is a Space variable:
20
- # a wav2vec2 repo, or powsm-onnx:<repo> for the exported POWSM (app/powsm.py). A private repo needs a Space secret
21
- # HF_TOKEN; it is mounted for this step only and never stored in the image.
22
- COPY --chown=user app/__init__.py app/powsm.py app/prefetch.py ./app/
23
- RUN --mount=type=secret,id=HF_TOKEN,mode=0444,required=false \
24
- if [ -s /run/secrets/HF_TOKEN ]; then export HF_TOKEN="$(cat /run/secrets/HF_TOKEN)"; fi; python -m app.prefetch
25
-
26
  COPY --chown=user app ./app
27
  COPY --chown=user static ./static
28
 
 
2
  # CPU image; runs on Hugging Face Spaces (Docker SDK), Render, Fly, or any VM.
3
  FROM python:3.11-slim
4
 
 
 
5
  # Spaces runs the container as uid 1000
6
  RUN useradd -m -u 1000 user
7
  ENV HOME=/home/user HF_HOME=/home/user/.cache/huggingface PYTHONUNBUFFERED=1
8
  WORKDIR /home/user/app
9
 
 
10
  COPY requirements.txt .
11
  RUN pip install --no-cache-dir -r requirements.txt
12
 
 
 
13
  USER user
 
 
 
 
 
 
 
14
  COPY --chown=user app ./app
15
  COPY --chown=user static ./static
16
 
README.md CHANGED
@@ -1,986 +1,25 @@
1
- ---
2
- title: mathly
3
- emoji: ➗
4
- colorFrom: blue
5
- colorTo: gray
6
- sdk: docker
7
- app_port: 7860
8
- ---
9
 
10
- # احكي · ihki — pronunciation scoring service
11
 
12
- Hebrew prompt → the student says it in spoken Arabic → per-phoneme feedback.
13
-
14
- The service replaces the browser speech-recognition diff with phoneme-level scoring:
15
-
16
- 1. **Phoneme posteriors.** `facebook/wav2vec2-xlsr-53-espeak-cv-ft`, a multilingual wav2vec2 CTC model that outputs IPA phonemes. It was fine-tuned on Common Voice data that includes Arabic, so its vocab should cover the pharyngeals; `/api/health` confirms this.
17
- 2. **Forced alignment.** A CTC Viterbi pass (`app/align.py`, numpy) aligns the audio against each accepted variant's phoneme string and keeps the best-fitting variant. Example: "قديش" said with *k* aligns to the urban variant `ʔ a d d eː ʃ`.
18
- 3. **GOP per phoneme.** Inside each phoneme's window (the frames between its neighbours), the scorer compares the target's peak posterior with the strongest competing phoneme.
19
- - Example: for وين الحمام said with a Hebrew ח, the window of `ħ` is dominated by `χ`. The result is `ħ` score ≈ 0, `heard: χ`, and the Hebrew tip for ح→خ.
20
- 4. **Scoring.**
21
- - A phoneme's score is a softened target/competitor ratio (`GopConfig.leniency`); a vowel heard as its neighbour (i/e, u/o, short/long) scores at least 90; a dropped glottal stop is free.
22
- - A word scores halfway between its weakest phoneme and the mean of its phonemes.
23
- - The sentence score averages the phoneme mean and the word mean, so one wrong consonant moves "excellent" down to "almost".
24
- - If the greedy phoneme decode is far from every variant, the response says "different sentence?" instead of listing tips.
25
- 5. **Native baseline.** With a TTS key, the phrase's model answer is scored by the same model, and each of the learner's sounds is measured against the score the voice got on it (`app/reference.py`). A sound the model mishears from the native voice too is not marked down and gets no tip.
26
-
27
- **Why a letter is coloured.** The answer is shown letter by letter, each coloured by the weakest of its sounds (on a story's line and in every other exercise). A letter under 75 comes with its reason from the server: `why`, the sound and what was heard in its place ("ח (ح) נשמעה כמו ח' (خ)"), and `tip`, the advice for that substitution when `app/tips.py` has one. On the page the reason is the letter's tooltip. A screen with no mouse has no tooltip, so such a letter is also underlined with a dotted line, a tap on it opens the reason in a bubble over the letter, and a line under the result says so.
28
-
29
- ```
30
- app/
31
- align.py CTC Viterbi alignment, GOP, greedy decode, edit distance (no torch)
32
- scorer.py model wrapper, IPA → vocab mapping with fallbacks, result assembly, ffmpeg decode
33
- tips.py Hebrew feedback keyed by (expected, heard) phoneme pairs
34
- reference.py native baseline: the TTS voices scored by the same model
35
- phrases.py phrase bank: accepted variants (Arabic + IPA) per phrase
36
- grammar.py day-2 sentences as grammar: every pronoun × positive/negative, for the negation and conjugation buttons
37
- workbook.py the course workbook lesson by lesson: each lesson's topics (pronouns, verbs, conjugations, the Hebrew → Arabic and Arabic → Hebrew sentences), and the levels
38
- verb_game.py "פועל יוצא" levels 1–5: exercise generation, adaptive choice, server-side grading
39
- main.py FastAPI: page, phrases/scoring, and "פועל יוצא" endpoints
40
- static/index.html frontend (MediaRecorder → /api/score)
41
- train/finetune.py CTC fine-tuning on your own recordings
42
- tests/ synthetic-emission tests, including an API test through ffmpeg
43
- ```
44
-
45
- ## Run locally (Ubuntu)
46
 
47
  ```bash
48
- sudo apt install ffmpeg
49
- python3 -m venv .venv && source .venv/bin/activate
50
- pip install torch --index-url https://download.pytorch.org/whl/cpu
51
- pip install -r requirements.txt
52
- uvicorn app.main:app --host 0.0.0.0 --port 7860
53
  ```
54
 
55
- Open http://localhost:7860. The first start downloads the model (~1.2 GB). CPU inference on a 3 s clip takes about a second.
56
 
57
- Then check `http://localhost:7860/api/health`. Its `phoneme_fallbacks` field lists any phoneme from `phrases.py` that the model's vocab lacks, and what it was mapped to (e.g. `"sˤ": "s"`). A fallback on an emphatic means that contrast cannot be scored until you fine-tune. Look at this first.
58
 
59
- Tests (no model needed):
60
- ```bash
61
- pip install -r requirements-dev.txt && pytest -q
62
- ```
63
 
64
- ### Browse OpenSpec requirements locally
65
 
66
- Run [OpenSpec WebUI](https://github.com/oioi555/openspec-webui) from the project root (needs Node 20.19+), then open http://127.0.0.1:3001:
67
 
68
  ```bash
69
- openspec-webui
70
- ```
71
-
72
- ## פועל יוצא (Switch & Speak)
73
-
74
- The Games tab contains an RTL, touch-first conjugation drill with two paths: learning new verbs and a daily retrieval workout. Levels 1–3 implement recognition, pronoun matching, and timed switching. Level 4 records a spoken conjugation; Level 5 starts with a controlled Palestinian-Arabic prompt and evaluates the target conjugation in the response.
75
-
76
- Conjugations come only from complete tables in `app/grammar.py` and the fully populated tables verified from the parsed Level 1/2 booklets. Every added booklet form must resolve to an Arabic entry in `workbook.LEX`; incomplete fill-in tables are excluded. Each verb carries the lesson where it is introduced, and signed-in learners receive only forms from their class's lessons.
77
-
78
- API endpoints:
79
-
80
- - `GET /api/verb-game` returns the available levels, tenses, and lesson-unlocked verbs.
81
- - `POST /api/verb-game/question` accepts `{"level": 1..3, "mastery": {...}, "recent": [...]}` and returns an opaque question. Correct answers are not sent to the browser.
82
- - `POST /api/verb-game/answer` accepts `{"question_id": "...", "answer": ..., "response_ms": 1234}`. A question can be graded only once. For Level 2, `answer` is a map from pronoun-card IDs to form-card IDs.
83
- - `POST /api/verb-game/speech` accepts multipart `question_id` and `audio` for Levels 4–5. `SpeechEvaluationService` wraps the existing phoneme scorer so another dialect ASR can replace it later. An unrelated or low-confidence recognition returns `outcome: "uncertain"` and `counts_as_attempt: false`; it never becomes a grammatical error automatically.
84
-
85
- Mastery is currently stored in browser local storage under `ihki.verbMastery` and `ihki.verbSummary`. `VERB_THRESH` in `static/index.html` configures the 85%/90% recognition gates, the Level 3 median response-time gate, and the 85% spoken-response gate; minimum attempt counts are defined next to it. `LEVELS` in `app/verb_game.py` configures choice counts and response windows. Persistent server mastery and spaced repetition remain separate work.
86
-
87
- ## Deploy for the teacher: Hugging Face Spaces (free, https)
88
-
89
- 1. Create a Space at huggingface.co → New Space → SDK **Docker** → hardware **CPU basic** (free).
90
- 2. Push this folder to the Space's git repo. The YAML header of this README configures it.
91
- 3. The build bakes the model into the image. The page is then live at `https://<user>-ihki.hf.space`.
92
-
93
- The teacher only needs that link. Free Spaces sleep after inactivity, so the first request after a pause takes ~30 s.
94
-
95
- `docker build -t ihki . && docker run -p 7860:7860 ihki` runs the same image anywhere.
96
-
97
- ## Sign-in and progress (Supabase)
98
-
99
- Optional. With `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY` set, the page asks for a sign-in (through Supabase Auth): with Google, or with a link sent to the student's email (no password), for one without a Google account. The practice endpoints refuse calls without a session. Who may sign in is set by the teacher's classes (see below). Without the two values the service is open.
100
-
101
- The email link needs, in Supabase: Authentication → Sign In / Providers → Email enabled; Authentication → URL Configuration → the app's address as the Site URL (or among the Redirect URLs); and Authentication → Emails → SMTP Settings → your own SMTP server, as Supabase's built-in one sends only to the project's team members, a few emails an hour.
102
-
103
- Each scored recording adds metadata to the table `attempts`, written with the student's own token so row-level security ties the row to them. The recording bytes are never written to this table or to Storage. Only a teacher/tutor's explicit `/api/flags` report stores its recording in the private `flags` bucket. Create `attempts` once in the Supabase SQL editor:
104
-
105
- ```sql
106
- create table public.attempts (
107
- id bigint generated always as identity primary key,
108
- user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
109
- cat text not null, -- the topic id, e.g. l1-verbs
110
- phrase text not null, -- the Hebrew prompt
111
- score int not null, -- 0-100
112
- unrelated boolean not null default false, -- "different sentence?"
113
- issues jsonb not null default '[]'::jsonb, -- compact [{word, expected, heard, score}], never audio
114
- model text not null default '',
115
- version text not null default '',
116
- created_at timestamptz not null default now()
117
- );
118
- create index attempts_user_time on public.attempts (user_id, created_at desc);
119
- create index attempts_created_at_idx on public.attempts (created_at desc);
120
-
121
- alter table public.attempts enable row level security;
122
- grant select, insert on public.attempts to authenticated;
123
- create policy "students add their own attempts" on public.attempts
124
- for insert to authenticated with check (user_id = auth.uid());
125
- create policy "students read their own attempts" on public.attempts
126
- for select to authenticated using (user_id = auth.uid());
127
-
128
- -- Anonymous aggregate buckets for the teacher's Monitoring graph. Only the backend service role may call it.
129
- create or replace function public.activity_buckets(p_hours integer default 24, p_bucket_minutes integer default 15)
130
- returns table (bucket timestamptz, attempts bigint, active_students bigint)
131
- language sql stable security invoker set search_path = '' as $$
132
- select date_bin(make_interval(mins => greatest(1, least(p_bucket_minutes, 60))), a.created_at,
133
- timestamptz '2000-01-01 00:00:00+00'),
134
- count(*)::bigint, count(distinct a.user_id)::bigint
135
- from public.attempts a
136
- where a.created_at >= now() - make_interval(hours => greatest(1, least(p_hours, 168)))
137
- group by 1 order by 1
138
- $$;
139
- revoke execute on function public.activity_buckets(integer, integer) from public, anon, authenticated;
140
- grant execute on function public.activity_buckets(integer, integer) to service_role;
141
-
142
- -- Totals for the full selected window. This is separate from the buckets so a student who practised in several
143
- -- buckets is counted only once.
144
- drop function if exists public.activity_summary(integer);
145
- create or replace function public.activity_summary(p_hours integer default 24)
146
- returns table (attempts bigint, active_students bigint, returning_students bigint, active_days bigint)
147
- language sql stable security invoker set search_path = '' as $$
148
- with windowed as (
149
- select a.user_id, (a.created_at at time zone 'Asia/Jerusalem')::date as practice_day
150
- from public.attempts a
151
- where a.created_at >= now() - make_interval(hours => greatest(1, least(p_hours, 168)))
152
- ), per_user as (
153
- select user_id, count(distinct practice_day) as days from windowed group by user_id
154
- )
155
- select (select count(*) from windowed)::bigint,
156
- (select count(*) from per_user)::bigint,
157
- (select count(*) from per_user where days >= 2)::bigint,
158
- (select count(distinct practice_day) from windowed)::bigint
159
- $$;
160
- revoke execute on function public.activity_summary(integer) from public, anon, authenticated;
161
- grant execute on function public.activity_summary(integer) to service_role;
162
-
163
- -- The same aggregates, limited to the students visible to a subject admin. Keeping this in PostgreSQL avoids
164
- -- downloading and truncating a busy class's attempts in the monitoring page.
165
- create or replace function public.activity_buckets_scoped(
166
- p_user_ids uuid[], p_since timestamptz, p_bucket_minutes integer default 15
167
- )
168
- returns table (bucket timestamptz, attempts bigint, active_students bigint)
169
- language sql stable security invoker set search_path = '' as $$
170
- select date_bin(make_interval(mins => greatest(1, least(p_bucket_minutes, 60))), a.created_at,
171
- timestamptz '2000-01-01 00:00:00+00'),
172
- count(*)::bigint, count(distinct a.user_id)::bigint
173
- from public.attempts a
174
- where a.created_at >= p_since and a.user_id = any(p_user_ids)
175
- group by 1 order by 1
176
- $$;
177
- revoke execute on function public.activity_buckets_scoped(uuid[], timestamptz, integer) from public, anon, authenticated;
178
- grant execute on function public.activity_buckets_scoped(uuid[], timestamptz, integer) to service_role;
179
-
180
- create or replace function public.activity_summary_scoped(p_user_ids uuid[], p_since timestamptz)
181
- returns table (attempts bigint, active_students bigint, returning_students bigint, active_days bigint)
182
- language sql stable security invoker set search_path = '' as $$
183
- with windowed as (
184
- select a.user_id, (a.created_at at time zone 'Asia/Jerusalem')::date as practice_day
185
- from public.attempts a
186
- where a.created_at >= p_since and a.user_id = any(p_user_ids)
187
- ), per_user as (
188
- select user_id, count(distinct practice_day) as days from windowed group by user_id
189
- )
190
- select (select count(*) from windowed)::bigint,
191
- (select count(*) from per_user)::bigint,
192
- (select count(*) from per_user where days >= 2)::bigint,
193
- (select count(distinct practice_day) from windowed)::bigint
194
- $$;
195
- revoke execute on function public.activity_summary_scoped(uuid[], timestamptz) from public, anon, authenticated;
196
- grant execute on function public.activity_summary_scoped(uuid[], timestamptz) to service_role;
197
-
198
- -- Recent system activity shown only in the management panel. The backend writes with the service-role key;
199
- -- no browser role gets direct table access.
200
- create table public.system_events (
201
- id bigint generated always as identity primary key,
202
- kind text not null check (kind in ('exercise','game','permission','content')),
203
- actor_id uuid references auth.users(id) on delete set null,
204
- actor_email text not null default '',
205
- label text not null default '',
206
- details jsonb not null default '{}'::jsonb,
207
- created_at timestamptz not null default now()
208
- );
209
- create index system_events_created_at_idx on public.system_events (created_at desc);
210
- alter table public.system_events enable row level security;
211
- ```
212
-
213
- API keys a developer replaces from the panel (`SAWTAK_API_KEY`, `ANTHROPIC_API_KEY`, see `app/keystore.py`), and the tutor's model (`TUTOR_MODEL`, chosen from a list or typed), so changing one needs no access to the Space. Each value is stored encrypted with `SECRETS_MASTER_KEY`, a Space secret, and the page never gets a key back (the model, which is no secret, it shows). No browser role can read the table:
214
-
215
- ```sql
216
- create table public.app_secrets (
217
- name text primary key check (name in ('SAWTAK_API_KEY','ANTHROPIC_API_KEY','TUTOR_MODEL')),
218
- ciphertext text not null,
219
- last4 text not null default '',
220
- updated_by text not null default '',
221
- updated_at timestamptz not null default now()
222
- );
223
- alter table public.app_secrets enable row level security;
224
- revoke all on public.app_secrets from anon, authenticated;
225
- ```
226
-
227
- A table made before the panel could set `TUTOR_MODEL` needs its name check widened once (saving the model answers 409 with this SQL until then):
228
-
229
- ```sql
230
- alter table public.app_secrets drop constraint if exists app_secrets_name_check;
231
- alter table public.app_secrets add constraint app_secrets_name_check
232
- check (name in ('SAWTAK_API_KEY','ANTHROPIC_API_KEY','TUTOR_MODEL'));
233
- ```
234
-
235
- For an existing `attempts` table, add the compact diagnostics once. Until this migration is run, attempts continue to be saved without diagnostics:
236
-
237
- ```sql
238
- alter table public.attempts
239
- add column if not exists issues jsonb not null default '[]'::jsonb,
240
- add column if not exists model text not null default '',
241
- add column if not exists version text not null default '';
242
- ```
243
-
244
- The page reads these rows back through `GET /api/progress` (best score and tries per phrase, recordings, days practised, distinct practice days in the current week, and current/longest consecutive-day streaks); the best scores then follow the student between devices. Above the path, **הערבית שכבר שלכם** turns every practised phrase into a bead, except that a story or dialogue is represented by one bead for the whole topic. A phrase turns gold at 75 or more; a story's bead turns gold only when all of its lines do. Completing every listed spoken phrase in a lesson awards one **מסבחה**. The prominent collection card separately counts regular beads, gold beads, and מסבחות; its detail view marks each completed lesson's מסבחה. A compact collector at the top of the exercise card opens the same collection. When a new exercise bead is earned, its regular or gold bead flies from the microphone into that collector instead of showing a text popup. It also shows a four-day weekly practice ring and a 🔥 consecutive-day streak; yesterday keeps a streak alive until the student has had today to continue it. Gold upgrades, a newly completed lesson's מסבחה, meaningful personal bests, streaks and completed topics are celebrated on the practice card. Moving past a topic's last sentence once every sentence was practised opens the topic's end: a bead per sentence (gold 75+, red under 60). With no red bead it is a medal, stars, confetti and a chime, and the button goes on to the next topic (the lesson's next, or the next lesson's first). With red beads it offers a short round of just those sentences, after which the dialog comes back showing what improved; the student can also go on regardless. A bead opens its sentence. Hands-free driving mode skips the dialog. The teacher's **דיווחים** tab aggregates `issues` without returning student identities. It shows a candidate when the same judgement occurred in at least five attempts, for at least three students and 30% of the students who attempted that phrase. These are investigation candidates, not proof of a scoring error; a teacher must explicitly turn one into a calibration draft and apply it.
245
-
246
- ### The teacher
247
-
248
- Access is managed from **ניהול → הרשאות**. Its matrix grants practice, management, Lab, games, the digital library,
249
- problem reporting, יאללה נדבר, and the learning path independently. יאללה נדבר has its own permission because each
250
- conversation uses the paid model API; unlike the other student features it is off until explicitly granted.
251
- Developers have full access including the Lab; admins do not receive Lab access. `DEVELOPER_EMAILS` bootstraps one or more developer
252
- accounts; keep at least one there to prevent a database mistake from locking every developer out. Existing
253
- `TEACHER_EMAILS` accounts retain the old management abilities during migration, but no longer receive Lab access.
254
-
255
- On first use, the permissions tab displays this migration to run in the Supabase SQL editor:
256
-
257
- ```sql
258
- create table public.user_access (
259
- user_id uuid primary key references auth.users(id) on delete cascade,
260
- email text not null,
261
- roles text[] not null default array['student']::text[],
262
- entitlements text[] not null default '{}'::text[],
263
- subject text not null default '', -- developer-set subject for admin-created classes
264
- updated_at timestamptz not null default now(),
265
- updated_by uuid references auth.users(id),
266
- constraint valid_roles check (roles <@ array['student','teacher','flagger','admin','developer']::text[]),
267
- constraint valid_entitlements check (entitlements <@ array['practice','games','tutor','path','library']::text[])
268
- );
269
- create unique index user_access_email_idx on public.user_access (lower(email));
270
- alter table public.user_access enable row level security;
271
- create table public.access_audit (
272
- id bigint generated always as identity primary key,
273
- target_user_id uuid not null,
274
- changed_by uuid not null,
275
- old_access jsonb not null,
276
- new_access jsonb not null,
277
- created_at timestamptz not null default now()
278
- );
279
- alter table public.access_audit enable row level security;
280
- ```
281
-
282
- If `user_access` was created with the earlier three student features, run this once:
283
-
284
- ```sql
285
- alter table public.user_access add column if not exists subject text not null default '';
286
- alter table public.user_access drop constraint if exists valid_entitlements;
287
- update public.user_access
288
- set entitlements = array_append(entitlements, 'practice')
289
- where 'path' = any(entitlements) and not ('practice' = any(entitlements));
290
- alter table public.user_access add constraint valid_entitlements check (
291
- entitlements <@ array['practice','games','tutor','path','library']::text[]
292
- );
293
- ```
294
-
295
- `TEACHER_EMAILS` names the teacher's Google account (several are separated by commas or spaces). After sign-in a teacher chooses between **תרגול**, the same practice a student gets, and **ניהול**, the management panel. The panel lists every student with their usage (recordings, those of the last 7 days, days practised, last practice), progress (sentences practised) and grade (the average of the best scores), and pressing a student opens their own screen in place of the class's: their figures, their homework, every lesson of the class with its topics, and their best and weakest sentences, all counted as the class counts. "חזרה לכיתה", or the browser's back button, returns to the class.
296
-
297
- ### Flags: a score that is wrong
298
-
299
- Sometimes a sentence is said right and the system scores it wrong. The teachers (`TEACHER_EMAILS`) report such cases, and `TUTORS_EMAIL` names more accounts that do (several are separated by commas or spaces; they may sign in without being in a class, and get no management panel). The rank is kept by email in the table `tutors`:
300
-
301
- ```sql
302
- create table public.tutors (
303
- email text primary key,
304
- granted_by text not null default '', -- the teacher who gave the rank
305
- created_at timestamptz not null default now()
306
- );
307
- alter table public.tutors enable row level security; -- no policy: only the secret key reads and writes it
308
- ```
309
-
310
- Under each score a teacher or a tutor gets an optional note and the button "🚩 אמרתי נכון, המערכת טעתה". Pressing it sends the recording to `POST /api/flags` (teachers and tutors only), which keeps the structured diagnosis in the table `flags`, written with `SUPABASE_SECRET_KEY`. By default the recording is kept in the private Supabase `flags` Storage bucket. When `AUDIO_BUCKET` names a private Hugging Face Storage Bucket (and the `HF_TOKEN` Space secret can write it), new recordings go only below `AUDIO_BUCKET_PREFIX/flags`. Reads fall back to Supabase for existing recordings. Once, recordings older than the fixed `AUDIO_MIGRATE_BEFORE` boundary are copied to HF and only successfully copied objects are deleted from Supabase; a completion marker prevents later startup scans. Its default is `2026-10-17T00:00:00+03:00`, preserving the seven days that were current when this migration was introduced; that boundary does not move. The row holds:
311
-
312
- - who and what: `email`, `cat`, `phrase`, `he`, `note`;
313
- - the answer the scorer matched (`ar`, `ipa`), its free decode (`heard_ipa`), `score` and `unrelated`;
314
- - `result`: the whole score response, including the structured diagnostic trace (every word and phoneme, pipeline stage, threshold, score, replacement, tip and issue);
315
- - `model` and `version`, so a flag can be checked against the model and build that produced it;
316
- - `audio`: the private Storage object name when the upload succeeded, otherwise null.
317
-
318
- Create the table once (until then a flag is refused with this command in the message):
319
-
320
- ```sql
321
- create table public.flags (
322
- id bigint generated always as identity primary key,
323
- email text not null, -- the tutor
324
- cat text not null, -- the topic id
325
- phrase text not null,
326
- he text not null, -- the Hebrew prompt
327
- ar text not null, -- the accepted answer the scorer matched
328
- ipa text not null, -- its phonemes: what the tutor says was said
329
- heard_ipa text not null, -- the model's free decode
330
- score int not null,
331
- unrelated boolean not null default false,
332
- note text not null default '',
333
- result jsonb not null, -- the whole score response
334
- model text not null,
335
- version text not null default '',
336
- audio text, -- object name in the private flags Storage bucket
337
- status text not null default 'pending', -- pending, confirmed, rejected, or fixed
338
- category text,
339
- reviewed_by text,
340
- reviewed_at timestamptz,
341
- review_note text not null default '',
342
- false_issues jsonb not null default '[]'::jsonb, -- [{word, expected, heard}]
343
- created_at timestamptz not null default now()
344
- );
345
- alter table public.flags enable row level security; -- no policy: only the secret key reads and writes it
346
-
347
- insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
348
- values (
349
- 'flags',
350
- 'flags',
351
- false,
352
- 2000000,
353
- array['audio/webm','audio/ogg','audio/mp4','audio/mpeg','audio/wav','audio/x-wav']
354
- )
355
- on conflict (id) do update set
356
- public = excluded.public,
357
- file_size_limit = excluded.file_size_limit,
358
- allowed_mime_types = excluded.allowed_mime_types;
359
- ```
360
-
361
- For a `flags` table created before reviews were added, run once:
362
-
363
- ```sql
364
- alter table public.flags
365
- add column if not exists status text not null default 'pending',
366
- add column if not exists category text,
367
- add column if not exists reviewed_by text,
368
- add column if not exists reviewed_at timestamptz,
369
- add column if not exists review_note text not null default '',
370
- add column if not exists false_issues jsonb not null default '[]'::jsonb;
371
- ```
372
-
373
- The management panel's **דיווחים** tab lets a teacher open the existing diagnostic dashboard beside the approval action, hear the stored recording, confirm or reject each report, classify it, and select the individual sound judgements that were false. The dashboard includes synchronized recording playback and a copyable, identity-free text payload for asking a chat assistant to distinguish acoustic-model, alignment, rule, threshold and feedback failures and suggest regression tests. The tab groups confirmed `expected → heard` pairs by reports, reporters, and phrases. Only confirmed reports should be used for calibration.
374
-
375
- ### The lab (מעבדה)
376
-
377
- A teacher gets a third button on the opening screen, **מעבדה**, beside תרגול and ניהול. It is the recording bench for measuring the phoneme recognition. [app/lab.py](app/lab.py) holds a fixed diagnostic list: hard letters as syllables (حا, عا, صا …), minimal pairs (حال / هال / خال, صيف / سيف, طين / تين …) and each hard sound at the start, middle and end of a word. Before recording an item, the teacher marks how it will be said: right, or with a planted mistake on one sound. `POST /api/lab/score` scores the recording as practice does (with the native baseline and the stage-by-stage trace) and keeps it with that label. The page shows, sound by sound, whether the scorer agreed with the label: a sound said right but flagged, or a planted mistake not flagged.
378
-
379
- The lab opens on **מבחן**, a test that walks one speaker through the whole list. The speaker types their name; the list shows a row per item (what to say, its state, and record / play / accept / reject), grouped like the list, with the next row not yet submitted highlighted. Recording stops by itself on silence and the take plays back at once; after it was heard, **✓ אישור** keeps it as said right (`POST /api/lab/score`, the speaker's name with it) and moves the highlight to the next row, and **✗** discards it so the row can be recorded again. Every row is new, recorded (waiting for approval), submitted or rejected. Submitted comes from the server (`GET /api/lab?speaker=` returns the speaker's own counts as `mine`), so it holds across devices; rejected is remembered on the device; a take waiting for approval lives in the page only. Any row can be recorded out of turn. Keys, on the highlighted row: space records, Enter accepts, Backspace rejects. **הקלטה** is the single-item bench, where a planted mistake can be marked. Under it, **מבחן אוטומטי בקול TTS** runs the same test without a person: a TTS voice (ראאף or חנאן) says each item not yet submitted (`POST /api/lab/tts`), and it is kept as said right by the speaker "TTS <voice>", with a log line per item (its score, what was flagged, ▶ to hear what was sent). It is scored without the native baseline, which is made of the same voice and would measure every sound against itself. A TTS voice is one more near-native speaker for the benchmark, not a replacement for people: compare them in **תוצאות** by speaker.
380
-
381
- The lab has two more tabs. **דיווחים** runs the confirmed score reports through the current scorer, and shows for each sound a teacher marked in ניטור as wrongly flagged whether it is still flagged. **תוצאות** is the benchmark of the lab's recordings as they were scored, per sound: how often a sound said right was flagged (false rejections), how often a planted mistake was caught (detection), the mean score, what was heard instead, and how many confirmed reports name the sound. To rescore everything with the current model and rules and compare against the stored numbers, run `python scripts/bench.py` (`--flags` adds the confirmed reports, `--no-baseline` scores without the TTS baseline, `--speaker` keeps one speaker). Run it before shipping a scoring or model change: false rejections should go down without detection going down with them.
382
-
383
- Recordings go to the table `lab_samples` and, by default, the private Supabase storage bucket `lab`, written with `SUPABASE_SECRET_KEY`. With `AUDIO_BUCKET`, new recordings go only below `AUDIO_BUCKET_PREFIX/lab`; the optional fixed-cutoff migration first copies older recordings to Hugging Face and only then removes their Supabase copies. Until the table exists, the lab still scores but keeps nothing, and shows this SQL to run once:
384
-
385
- ```sql
386
- create table public.lab_samples (
387
- id bigint generated always as identity primary key, email text not null,
388
- item text not null, ar text not null, ipa text not null, speaker text not null default '',
389
- label text not null, -- 'correct' or 'mistake'
390
- mistake jsonb, -- {"index", "expected"}: the sound said wrong on purpose
391
- device text not null default '', score int not null, heard_ipa text not null,
392
- flagged jsonb not null default '[]'::jsonb, -- the sounds the scorer marked down, "expected>heard"
393
- result jsonb not null, model text not null, version text not null default '',
394
- audio text, -- the recording's name in the storage bucket lab
395
- created_at timestamptz not null default now());
396
- alter table public.lab_samples enable row level security;
397
- insert into storage.buckets (id, name) values ('lab', 'lab') on conflict (id) do nothing;
398
- ```
399
-
400
- ### The trace dashboard (לוח אבחון)
401
-
402
- [static/trace.html](static/trace.html) shows one recording through the scorer, for engineers, linguists and teachers. Open it with **לוח אבחון** from a lab recording or from a teacher's phoneme dialog; it reads that result's trace in a new tab. It has four panels that follow one selected sound (hover it anywhere, click to pin, ← → to step):
403
-
404
- 1. the frames around the sound: target, competitor and neighbour posteriors, frame energy, and the window it was read from (the end of the sound before to the start of the one after)
405
- 2. its score stage by stage: raw GOP → exemptions → cluster cap → native baseline → teacher floor → final
406
- 3. the readings the audio was aligned to, with the winning CTC path, and the edit-distance gauge for a different sentence
407
- 4. the sentence by word and sound, coloured by score, with how sounds add up to words and words to the sentence. When the recording was scored against native baselines, each TTS voice gets a row of its own scores under the sounds; the voice a sound was measured against (the lowest) is in bold. The waterfall marks every voice on the baseline step, and the table has a column per voice
408
-
409
- The data's schema is [docs/trace-schema.ts](docs/trace-schema.ts). `python scripts/mock_trace.py` regenerates the example (`static/assets/trace-mock.json`, the "דוגמה" button) by running the real scorer on synthetic posteriors, with a substitution, a teacher floor, a native baseline and a noise spike beside a short vowel planted in it (the spike shows how a sound's window takes in its neighbours' blank frames). `?src=mock` opens the example, `?i=6` pins a sound.
410
-
411
- ### Calibration proposals
412
-
413
- Beside every confirmed `expected → heard` pattern, the teacher can create a calibration proposal. A proposal is only a draft until the teacher chooses its score floor and applies it, globally or to one of the supporting phrases. Applied rules are loaded into the scorer at startup and refreshed immediately after a proposal is applied, updated, or disabled; Supabase is not queried while scoring each recording. Phrase-specific rules take precedence over global ones. Create the private table once:
414
-
415
- ```sql
416
- create table public.calibrations (
417
- id bigint generated always as identity primary key,
418
- expected text not null,
419
- heard text not null,
420
- floor numeric not null default 70,
421
- scope text not null default 'global', -- global or phrase
422
- phrase text not null default '',
423
- evidence jsonb not null default '{}', -- report/reporter counts and supporting phrase keys
424
- model text not null default '',
425
- status text not null default 'draft', -- draft, applied, or rejected
426
- proposed_by text not null,
427
- reviewed_by text,
428
- reviewed_at timestamptz,
429
- created_at timestamptz not null default now()
430
- );
431
- alter table public.calibrations enable row level security;
432
- ```
433
-
434
- 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.
435
-
436
- ### Pronunciation rules
437
-
438
- 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):
439
-
440
- ```sql
441
- create table public.scoring_rules (
442
- id text primary key, -- a rule of rules.TOGGLES, e.g. dialect_Q, margins, rule_pharyngeal_h
443
- active boolean not null default true,
444
- updated_by text not null default '',
445
- updated_at timestamptz not null default now()
446
- );
447
- alter table public.scoring_rules enable row level security;
448
- ```
449
-
450
- ### Levels
451
-
452
- 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-…".
453
-
454
- A lesson's homework in the workbook, sentences of the student's own, is a topic of the kind `"write"`, the lesson's last ("כתיבת משפטים": `l1-hw`, `l2-hw`). It has no sentences to say: its task and the number of sentences are in `WRITING` in `app/workbook.py`, and on the path it opens a box with the task and a line for each sentence; it is done once every line is filled. Nothing is scored, so the lesson's count of practised sentences leaves it out.
455
-
456
- What a signed-in student writes is sent to the server as they write (`PUT /api/writing/<topic>`) and kept in the table `writings`, a row per student and topic, so it follows them to any device (`GET /api/writing`). The device keeps a copy too: lines the server did not take stay there and are sent on the next visit, and without sign-in the copy is all there is. Create the table once in the Supabase SQL editor:
457
-
458
- ```sql
459
- create table public.writings (
460
- user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
461
- topic text not null, -- the topic id, e.g. l1-hw
462
- lines jsonb not null, -- the sentences as written, "" for one not written yet
463
- updated_at timestamptz not null default now(),
464
- primary key (user_id, topic)
465
- );
466
-
467
- alter table public.writings enable row level security;
468
- grant select, insert, update on public.writings to authenticated;
469
- create policy "students read their own writings" on public.writings
470
- for select to authenticated using (user_id = auth.uid());
471
- create policy "students add their own writings" on public.writings
472
- for insert to authenticated with check (user_id = auth.uid());
473
- create policy "students change their own writings" on public.writings
474
- for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid());
475
- ```
476
-
477
- Until the table exists the sentences stay on each student's device and the panel shows none.
478
-
479
- A writing topic can be the class's homework like any topic, and is the one offered: the new-class steps open with it chosen, and in a class's "עריכה" setting a day with no topic chosen picks the written homework of the class's last lesson that has one, so the teacher has only the day to set. The panel then counts its sentences ("נכתבו 3 מתוך 5") in the homework's summary and column, and a student's own screen lists the sentences they wrote under the topic.
480
-
481
- ### Classes
482
-
483
- In the panel the teacher opens a class in five steps, a question each: its name (such as "מתחילים אוקטובר 2026"), its level (the class studies all the level's lessons; one not written yet opens to the class once it is), its students (ticked from the accounts the panel knows, or added by Google email, each with an optional nickname), its homework, and a summary to confirm, which says where all of it is changed later. That is the class's edit dialog ("עריכה", beside the class in the panel's list of classes): the name, the lessons one by one (by level: a level's box ticks all of it), the homework, and the students, a line each with the Google email and an optional nickname (`dana@gmail.com דנה`); the class is deleted there too. A student may be in several classes: after the opening screen the page asks which one they practise in now, and the learning path shows that class's lessons (a teacher, and anyone before the first class, sees every lesson and is not asked). The page shows the class under the app's name, and pressing it lists the class's students, with a button to move to another class; each student appears by their nickname in it or else their Google name (no addresses; `GET /api/classmates`). Once there is a class, only the classes' students and the teachers can sign in; before the first one, any Google account can.
484
-
485
- The panel's parts are chosen at its top (**תוכן** is described under "Content and videos"). **כיתות** lists the classes, each with its lessons, students and homework and its buttons: "עריכה", "מחיקה", and "התקדמות התלמידים", which moves to the other part; "כיתה חדשה" opens the steps above. An admin sees, edits and deletes only classes whose `subject` matches the subject assigned to that admin by a developer; developers see every subject. A new class receives its admin's subject on the server, regardless of anything sent by the browser. **תלמידים** starts with one unique list of the students in those visible classes, with an "all students" filter and a filter for each class; every row names the student's classes. An admin with no matching classes sees no students; the old unscoped setup shows every signed-in account before its first class is created. The all-students view uses the student's overall progress, and their page offers buttons for switching into each class's context. A class filter counts only what its students practised since the class was opened (its `created_at`), so a new class starts clean even when its students practised the same lessons before, in another class or in none; the students' own path keeps all they did. Every non-empty recording is a try and completes its exercise, including one that did not match the sentence on the screen (`unrelated`); only an empty recording remains incomplete. The panel still shows unmatched recordings diagnostically as "לא זוהו" beside the student and each topic. A class filter shows the class's figures, its lessons (each with the sentences the class finds hard: the class average of each student's best score under 70) and its students, with the same columns however many lessons the class has: the homework topic (when one is set), all the class's sentences, and the grade. This replaces the former `ALLOWED_EMAILS`, which is no longer read. The lessons limit what the path shows; the sentences themselves stay public, as before.
486
-
487
- ### Content and videos
488
-
489
- The panel's third part, **תוכן**, shows the course as the students get it: every level, its lessons (those not written yet as "בקרוב"), and under each ready lesson its topics in the path's order. There the teacher adds a video to a lesson: a name and a Vimeo or YouTube link (any form of a YouTube video's link: `youtube.com/watch?v=…`, `youtu.be/…`, a short, a live), a Vimeo one as Vimeo's share button gives it (`https://vimeo.com/1233611297/d46ac373c8?share=copy&fl=sv&fe=ci`; the hash after the number is what opens an unlisted video, and the sharing parameters are dropped). The video becomes a topic of the lesson, with ▶ on its circle, and pressing it plays it in a box over the page, in Vimeo's player. A lesson may have several videos. A new one is added at the lesson's end, and the ↑ ↓ arrows on its row move it a step at a time to its place: before the first topic, between two topics, beside another video, or after them all. The path shows the lesson in that order. A video is not practised, so it counts in no figure; the path marks the ones a student opened ("✓ נצפה"), on their device only. In the panel each video also has "צפייה" and "מחיקה". Students get the videos of their classes' lessons (`GET /api/videos`); adding, moving and deleting (`POST /api/videos`, `PUT /api/content/order`, `DELETE /api/videos/{id}`) answer teachers only.
490
-
491
- The videos are kept in Supabase like the classes, read and written with `SUPABASE_SECRET_KEY` only. Create the table once (until it exists the lessons have no videos, and adding one is refused with this command in the message):
492
-
493
- ```sql
494
- create table public.videos (
495
- id bigint generated always as identity primary key,
496
- lesson text not null, -- the lesson id, e.g. 2 or 2.1
497
- title text not null, -- the topic's name on the path
498
- url text not null, -- https://vimeo.com/<number>[/<hash>] or https://www.youtube.com/watch?v=<id>
499
- pos int, -- how many of the lesson's own topics stand before it; null = after them all
500
- ord int not null default 0, -- its turn among the videos of one place
501
- created_at timestamptz not null default now()
502
- );
503
- alter table public.videos enable row level security;
504
- ```
505
-
506
- A table made before videos had places needs the two columns (until then its videos show after their lessons' topics, and adding or moving one is refused with this command in the message):
507
-
508
- ```sql
509
- alter table public.videos add column if not exists pos int, add column if not exists ord int not null default 0;
510
- ```
511
-
512
- ### Daily videos
513
-
514
- Apart from a lesson's videos, each level has a library of daily videos. In **תוכן** each level opens and closes on its name (it stays as it was left, on that device); inside, the card "סרטונים יומיים" lists them; the teacher adds one (a name and a Vimeo or YouTube link) and deletes one there. A video in the library is shown to nobody until a class gives it a day: in the class's dialog ("עריכה"), and in the fourth step of opening a class, every daily video of the class's levels has a date field. The students find them in the "סרטון יומי" button above the path: it opens today's video, and is highlighted until that one is opened (on that device); with no video to watch today it opens the list of the earlier ones, the latest first, so a missed day can be caught up. A video whose day has not come, one without a date, one deleted from the library, or one of a level the class does not study, is not shown. It is the first of three buttons that stay in sight above the path: "סרטון יומי", "תרגול יומי" and "שיעורי בית" (the nearest one still to do). A teacher, who practises in no class, sees the daily videos of every class.
515
-
516
- The library is kept in Supabase, read and written with `SUPABASE_SECRET_KEY` only. Create the table once (until it exists adding a video is refused with this command in the message), and add the `dailies` column to `classes` (see "Classes" below):
517
-
518
- ```sql
519
- create table public.dailies (
520
- id bigint generated always as identity primary key,
521
- level text not null, -- a level id, e.g. 1
522
- title text not null,
523
- url text not null, -- https://vimeo.com/<number>[/<hash>] or a YouTube link
524
- created_at timestamptz not null default now()
525
- );
526
- alter table public.dailies enable row level security;
527
  ```
528
-
529
- ### The daily video's morning reminder
530
-
531
- On the "סרטון יומי" screen a student taps "🔔 הזכירו לי כל בוקר" to get a push notification on that device each morning that one of their classes has a daily video (a teacher's account: any class's). It goes at `PUSH_HOUR` (default 8) Jerusalem time, once a day per device, and opens the daily videos' screen. On an iPhone it works only from the app added to the home screen (iOS 16.4+). See `app/push.py`.
532
-
533
- Set the Space secret `VAPID_PRIVATE_KEY` (make one with `python -m app.push`; changing it drops every device's subscription), and create the table once:
534
-
535
- ```sql
536
- create table public.push_subs (
537
- endpoint text primary key, -- the browser's push address
538
- email text not null,
539
- p256dh text not null,
540
- auth text not null,
541
- every_class boolean not null default false, -- a teacher's: every class's daily videos
542
- last_daily date, -- the day it was last reminded
543
- created_at timestamptz not null default now()
544
- );
545
- alter table public.push_subs enable row level security;
546
- revoke all on public.push_subs from anon, authenticated;
547
- ```
548
-
549
- The Space must be awake at that hour for the reminder to go.
550
-
551
- ### Video library
552
-
553
- Apart from those there is one library of videos for the whole course: they are not part of the path, need no date, and are the same for every level and class. In **תוכן** the card "ספריה דיגיטלית" comes first, above the levels; the teacher adds a video (a name and a link), renames one, moves one up or down the list with its arrows, and deletes one there. A permitted student enters it from the "ספרייה דיגיטלית" button on the opening screen: a list appears, and a video plays in the same box as the path's videos. The ones opened are marked (on that device). Create the table once (its `level` column stays empty):
554
-
555
- ```sql
556
- create table public.library (
557
- id bigint generated always as identity primary key,
558
- level text not null, -- a level id, e.g. 1
559
- title text not null,
560
- url text not null,
561
- ord int, -- its turn in the list; none until the teacher moves a video
562
- created_at timestamptz not null default now()
563
- );
564
- alter table public.library enable row level security;
565
- ```
566
-
567
- A library made before its videos could be moved has no `ord` column; until it is added, moving a video is refused with this command in the message (renaming works without it):
568
-
569
- ```sql
570
- alter table public.library add column if not exists ord int;
571
- notify pgrst, 'reload schema';
572
- ```
573
-
574
- For the video to play inside the page, its Vimeo privacy setting has to allow embedding (on this site's domain, or anywhere); a YouTube video has to allow embedding too (it does unless its owner turned that off), and plays from `youtube-nocookie.com`.
575
-
576
- Keeping the link from being passed on is done in Vimeo, not here: the page has to give the browser the player's address, so a student who looks for it finds it (the server only keeps the link itself, `url`, for teachers). In the video's privacy settings on Vimeo, hide it from vimeo.com and allow embedding on specific domains only, naming the app's own domain (on Hugging Face, `<owner>-<space>.hf.space`). The video then plays inside the app and nowhere else: its link, opened or embedded anywhere else, shows nothing. Domain-level privacy needs a paid Vimeo plan.
577
-
578
- #### Managing the library and class rosters from Grok
579
-
580
- The app can expose a restricted [Model Context Protocol](https://modelcontextprotocol.io/) connector so a teacher can manage the digital library and basic class rosters, and read privacy-preserving monitoring data, from Grok Business without receiving a Supabase secret, Hugging Face token, or general administrator access. It exposes fifteen tools: four library tools, three class tools, and eight read-only monitoring/analytics tools: `get_activity_timeseries`, `get_current_activity`, `get_system_health`, `get_exercise_difficulty`, `get_pronunciation_difficulties`, `get_learning_progress`, `compare_class_difficulties`, and `get_learning_bottlenecks`. Class changes are limited to creating or renaming a class and adding or removing one student at a time.
581
-
582
- The connector uses the teacher's existing Supabase account through OAuth 2.1. Grok receives revocable user tokens; it never receives `SUPABASE_SECRET_KEY`. On every MCP request the app asks Supabase who the token belongs to and freshly checks that the account still has `management.access`, so removing the admin/developer role stops the connector even if Grok still has a token.
583
-
584
- Configure Supabase first:
585
-
586
- 1. In **Authentication → Signing Keys**, migrate to an asymmetric signing key such as ES256 or RS256 if the project still uses HS256.
587
- 2. In **Authentication → URL Configuration**, set **Site URL** to the public app origin, for example `https://<owner>-<space>.hf.space`.
588
- 3. In **Authentication → OAuth Server**, enable the OAuth 2.1 server and **Dynamic Client Registration**. Set **Authorization Path** to `/oauth/consent`.
589
- 4. Keep explicit user approval enabled. Dynamic registration lets compatible MCP clients register their exact callback URL, so periodically review OAuth applications and grants in Supabase.
590
-
591
- In Hugging Face **Settings → Variables and secrets**, keep `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, and `SUPABASE_SECRET_KEY` configured as described above. `SUPABASE_SECRET_KEY` remains a Space secret. `MCP_PUBLIC_URL` is normally inferred from `SPACE_HOST`; for a custom domain set it to the exact final URL:
592
-
593
- ```text
594
- https://<your-domain>/mcp/
595
- ```
596
-
597
- Restart the Space, then check these URLs in a browser:
598
-
599
- - `https://<host>/.well-known/oauth-protected-resource/mcp/` names the MCP resource and the Supabase authorization server.
600
- - `https://<project-ref>.supabase.co/.well-known/oauth-authorization-server/auth/v1` is Supabase's OAuth discovery document.
601
- - `https://<host>/oauth/consent` shows an error without an active `authorization_id`; that is expected because Supabase supplies it during sign-in.
602
-
603
- In Grok Business, add a Custom MCP connector whose final Streamable HTTP URL is:
604
-
605
- ```text
606
- https://<owner>-<space>.hf.space/mcp/
607
- ```
608
-
609
- Only the name and Server URL are needed. Grok should discover Supabase OAuth and display **Sign in**. Sign in with an account that currently has the app's admin or developer role, review the client name, callback and scopes on the Hebrew consent screen, then approve. A student or a user without `management.access` cannot approve or call MCP tools.
610
-
611
- Grok must first call `prepare_library_video`; the app returns the normalized title, URL and a single-use approval that expires after ten minutes. Only after the teacher explicitly confirms those details should Grok call `publish_library_video`. Pending approvals disappear safely when the Space restarts.
612
-
613
- Class mutations use the same two-step rule. Useful prompts include:
614
-
615
- ```text
616
- הצג את הכיתות והתלמידים שאני רשאי/ת לנהל.
617
- הכן יצירת כיתה בשם "מתחילים נובמבר" עם התלמיד dana@example.com. אל תבצע עדיין.
618
- הכן הוספת התלמיד noam@example.com לכיתה 12 עם הכינוי נועם. אל תבצע עדיין.
619
- הכן הסרת התלמיד old@example.com מכיתה 12. אל תבצע עדיין.
620
- ```
621
-
622
- Grok calls `prepare_class_change` with one of `create_class`, `rename_class`, `add_student`, or `remove_student`, then displays the normalized class and email details. After the teacher checks them and explicitly confirms, Grok calls `publish_class_change` with the returned approval id. The approval lasts ten minutes, is single-use, is bound to that signed-in manager, and is refused if the class or the manager's subject scope changed meanwhile.
623
-
624
- Class listing returns only class id, name, subject, student emails, and optional class-local nicknames. The connector cannot delete a class or account, create a login account, change roles or entitlements, assign teachers, edit lessons/homework/daily videos, or read individual progress, grades, raw attempts, or recordings. Those operations remain in the browser management screen where applicable. Adding an email to a class does not create a Supabase user; that student still signs in normally.
625
-
626
- The eight monitoring and analytics tools are read-only and do not use the prepare/publish flow. `get_activity_timeseries` accepts `from_date` and `to_date` as `YYYY-MM-DD`, an `interval` of `day` or `week`, and an optional `class_id`. The inclusive range is limited to 90 non-future days. With a class id it returns only that authorized class; without one it combines only classes currently visible to the signed-in manager. The response contains aggregate attempts, the peak simultaneously active learners reported by the source buckets, and a range summary. It never contains student names, emails, ids, individual scores, answers, recordings, or exact login events.
627
-
628
- `get_current_activity` accepts `window_minutes` equal to `5` or `15` (default `5`) and an optional `class_id`. `recent_practice` counts distinct learners and stored attempts in that window but explicitly sets `presence_claim: false`: a recent attempt does not prove that the learner is still connected. `live_presence` is available only during an explicit live-class session and defines active as a heartbeat within 20 seconds of `checked_at`; this process-local state disappears when the session ends or the service restarts. Combined class scopes deduplicate learners enrolled in more than one class.
629
-
630
- Attempt-derived counts and timestamps require at least three learners and five attempts. Live counts of one or two are suppressed. The result distinguishes `no_activity` (a successful window query with no events), `no_data` (no observation establishes a last time), `unavailable` (the source does not apply or cannot be read), and `suppressed` (data exists but privacy prevents display). Stored attempts are current as of the database read, not real-time telemetry. Timestamps use UTC ISO 8601 and include an `Asia/Jerusalem` rendering. The app stores no durable general application-activity event, so `last_application_activity` is `unavailable`; lack of practice does not mean the app was unused. No new tracking or retention policy is introduced.
631
-
632
- Example current-activity response (shortened):
633
-
634
- ```json
635
- {
636
- "ok": true,
637
- "class_id": 12,
638
- "checked_at": "2026-10-27T10:10:00Z",
639
- "timezone": "Asia/Jerusalem",
640
- "privacy_threshold": {"minimum_students": 3, "minimum_attempts": 5},
641
- "recent_practice": {
642
- "status": "complete",
643
- "source": "persisted_attempts",
644
- "window_minutes": 5,
645
- "students_with_activity": 4,
646
- "attempts": 7,
647
- "presence_claim": false,
648
- "update_semantics": "current_as_of_database_read"
649
- },
650
- "live_presence": {
651
- "status": "complete",
652
- "source": "live_session_heartbeat",
653
- "active_users": 3,
654
- "heartbeat_ttl_seconds": 20,
655
- "ephemeral": true
656
- },
657
- "last_practice_activity": {
658
- "status": "complete",
659
- "event_type": "practice_attempt",
660
- "timestamp": "2026-10-27T10:09:40Z",
661
- "local_timestamp": "2026-10-27T12:09:40+02:00",
662
- "timezone": "Asia/Jerusalem",
663
- "elapsed_seconds": 20
664
- },
665
- "last_application_activity": {"status": "unavailable", "reason_code": "not_collected"}
666
- }
667
- ```
668
-
669
- `get_system_health` returns only `healthy`, `degraded`, `unavailable`, or `not_configured` for the application, authentication, database, model, and text-to-speech components. `not_configured` for an optional component such as text-to-speech does not by itself make the service unhealthy. A developer may also see a non-sensitive build version and revision. No caller receives raw logs, stack traces, database queries, internal URLs, paths, environment variables, tokens, credentials, or secret-presence values.
670
-
671
- `get_exercise_difficulty` accepts `from_date`, `to_date`, optional `class_id` and `exercise_type`, `limit` from 1 through 20 (default 20), and `min_attempts` (default 10). Exercise types are `pronunciation`, `verb`, `game`, and `other`. It returns stable exercise metadata, attempts, students, successes, success rate, attempts per student, average score, retry metrics, and rank. Existing aliases such as `exercise`, `category`, `attempt_count`, and `student_count` remain available.
672
-
673
- `get_pronunciation_difficulties` uses only stored, actually flagged phoneme diagnostics. It returns a phoneme, safe example words, error attempts, affected students, average diagnostic score, and weekly trend. Stored `issues` do not contain every correctly pronounced exposure, so `pronunciation_error_rate` is currently `null` rather than a fabricated denominator. Missing optional diagnostic columns produce `status: "insufficient_data"`.
674
-
675
- `get_learning_progress` returns `day`, `week`, or `month` buckets with attempts, students, success and average score, first-observed-attempt success, retry success, and an aggregate score trend. Its optional stable `exercise_id` restricts the report. “First” means first persisted attempt inside the requested range, not lifetime first.
676
-
677
- `compare_class_difficulties` applies the same definitions to optional `class_ids` and normalizes by participating students and observed exercise exposure. Overlapping enrollment is disclosed because class populations may not be mutually exclusive.
678
-
679
- `get_learning_bottlenecks` returns qualifying exercise/topic aggregates for affected students, repeated-failure rate, attempts until success, improvement rate, and severity. Measured `observed_metrics` remain separate from `suggested_intervention`, which is labeled `recommendation` and lists its measured basis.
680
-
681
- Across analytics, success means `score >= 75` and `unrelated = false`. A group requires at least three students and `max(5, min_attempts)` attempts. Sparse groups are suppressed or marked `insufficient_data`; missing metrics are `null`. Dates use ISO 8601 and Asia/Jerusalem calendar boundaries.
682
-
683
- An exercise is omitted unless at least three students made at least five attempts in the selected scope and period. Suppressed exercise names and counts are not revealed. “Did not reach success” is not called abandonment because attempt data cannot show whether somebody quit. The report never returns student identities, individual scores or sequences, timestamps, answers, recordings, or pronunciation traces. If the bounded attempt safety cap is reached, the tool refuses partial analysis and asks for a narrower date range or one class.
684
-
685
- Example difficulty response (shortened):
686
-
687
- ```json
688
- {
689
- "ok": true,
690
- "status": "complete",
691
- "from_date": "2026-10-01",
692
- "to_date": "2026-10-26",
693
- "privacy_threshold": {"minimum_students": 3, "minimum_attempts": 10},
694
- "exercises": [{
695
- "exercise_id": "a1b2c3d4e5f6",
696
- "exercise_name": "תרגיל לדוגמה",
697
- "exercise_type": "pronunciation",
698
- "topic": "הגופים",
699
- "total_attempts": 18,
700
- "unique_students": 6,
701
- "successful_attempts": 7,
702
- "success_rate": 38.9,
703
- "average_attempts_per_student": 3.0,
704
- "average_score": 61.4,
705
- "difficulty_rank": 1
706
- }]
707
- }
708
- ```
709
-
710
- Persisted data has no reliable session boundaries, abandoned or incomplete exercise event, unanswered exposure, or item-level correctness for every non-spoken game. The tools therefore do not report sessions or abandonment and return `insufficient_data` for untracked answers. Trends describe association; they do not prove that practice caused learning.
711
-
712
- Useful read-only prompts include:
713
-
714
- ```text
715
- הצג גרף פעילות יומי של כיתה 12 בין 2026-10-01 ל-2026-10-26. אל תציג מידע אישי.
716
- הצג פעילות שבועית בכל הכיתות שאני מורשה/ית לראות ב-30 הימים האחרונים.
717
- הצג פעילות עדכנית בכיתה 12 בחמש הדקות האחרונות, והפרד בין נוכחות חיה לבין תרגול שנשמר.
718
- מתי היה ניסיון התרגול האחרון בכל הכיתות שאני מורשה/ית לראות? ציין UTC וגם שעון ישראל ואל תציג מידע אישי.
719
- בדוק את מצב המערכת והסבר בעברית פשוטה אם רכיב כלשהו אינו זמין.
720
- הצג את 10 התרגילים הקשים ביותר בכל הכיתות שאני מורשה/ית לראות בין 2026-10-01 ל-2026-10-26. הסבר את המדדים ואל תציג מידע אישי.
721
- אילו פונמות ערביות גרמו לשגיאות הגייה מדודות בכיתה 12 בחודש האחרון? הצג רק קבוצות שעוברות את סף הפרטיות.
722
- הצג התקדמות שבועית בכיתה 12, כולל הצלחה בניסיון הראשון לעומת ניסיונות חוזרים.
723
- השווה בין כיתות 12 ו-15 לפי הצלחה, השתתפות וחשיפה לתרגילים. ציין אם יש תלמידים החופפים בין הכיתות.
724
- מצא צווארי בקבוק בלמידה בכל הכיתות המורשות. הפרד בבירור בין המדדים שנמדדו לבין המלצות הוראה.
725
- ```
726
-
727
- The connector does not make calls to the xAI API, so an xAI developer API key and API credits are not required. Grok Business plan or connector charges, if any, are handled by xAI. To revoke one connector, revoke its grant or OAuth application in Supabase. To revoke the teacher immediately, remove their admin/developer role in the management screen; the next MCP request is refused. Existing videos and ordinary management-screen access are otherwise unaffected. Connector mutations appear in the existing management activity log when its optional `system_events` table is installed; application logs are the fallback and never contain OAuth tokens.
728
-
729
- If Grok does not show sign-in, confirm that both discovery URLs return JSON, dynamic registration is enabled, `MCP_PUBLIC_URL` matches Grok's URL including `/mcp/`, and the Space uses HTTPS. A registration 403/404 usually means dynamic registration is disabled. `invalid_grant` usually means the authorization code expired, its PKCE verifier differs, or the registered callback does not match exactly; remove and recreate the connector. A consent-page configuration error means the Site URL, Authorization Path, or Supabase public settings are missing. OAuth configuration failures never fall back to public or shared-secret access.
730
-
731
- ### Texts
732
-
733
- In **תוכן** the teacher also adds texts to a lesson, as many as they like: a story or a dialogue, practised like the course's own conversation "שיחת היכרות". "+ הוספת טקסט" under the lesson asks for a name and the text twice, a line per sentence: in Hebrew, and the same lines, in the same order, in Arabic written in Hebrew letters as the course writes it (`כִּיף חַאלַכּ?`). Each is pasted into its box, or read into it from a plain-text file (`.txt`, UTF-8), and can be corrected there before it is added. Empty lines are not counted, and the two must have the same number of lines; otherwise the text is refused, with both numbers in the message. The kind is chosen there too:
734
-
735
- - **סיפור**: every line is played in the voice the student chose in the settings.
736
- - **שיחה**: the teacher picks a voice for the odd lines and one for the even lines, from the app's voices.
737
-
738
- The text becomes a topic of the lesson, laid out as a story: all its lines on the card, said one after the other, with "השמעת השיחה" and "בוחן". It counts like any topic (the lesson's progress, the class's figures, and it can be the homework). Like a video it is added at the lesson's end and moved with the ↑ ↓ arrows on its row, past topics, videos and other texts (`PUT /api/content/order` places both kinds); "תרגול" on the row opens it as the students get it, and "מחיקה" removes it (the recordings made in it stay in `attempts`, uncounted).
739
-
740
- The scorer needs each line's phonemes and the answer's voice needs it in Arabic letters, so the server reads the transliteration word by word (`app/texts.py`). A word of the course is found by its transliteration and gets the course's own Arabic spelling and accepted pronunciations (dagesh, shva and final letters are not compared; an unpointed word is found by its letters alone). Any other word is read by rule from its letters and points, which is close but not always right: vowel length and some spellings (ى, ء, ة) cannot be told from the transliteration, and an unpointed word outside the course has no vowels to read. The panel lists those words on the text's row ("מילים שאינן במילון הקורס נקראו לפי הכתיב"), so the teacher knows which lines are worth a listen. A word the course should know is added to `LEX` in `app/workbook.py`.
741
-
742
- A line's phrase id is past the bank's (1,000,000 + the text's id × 1000 + the line's number) and its topic is `t<text id>`; an attempt is recorded under that topic and the line's Hebrew (numbered from its second appearance in the text: `כן (2)`). The texts are kept in Supabase like the videos (`GET /api/texts` gives a student those of their classes' lessons; `POST /api/texts` and `DELETE /api/texts/{id}` answer teachers only). Create the table once:
743
-
744
- ```sql
745
- create table public.texts (
746
- id bigint generated always as identity primary key,
747
- lesson text not null, -- the lesson id, e.g. 2 or 2.1
748
- title text not null, -- the topic's name on the path
749
- voices text[] not null default '{}', -- a dialogue's two voices by name (odd lines, even lines); empty = a story
750
- lines jsonb not null, -- [[Hebrew, Arabic in Hebrew letters], ...]
751
- pos int, -- as in videos
752
- ord int not null default 0,
753
- created_at timestamptz not null default now()
754
- );
755
- alter table public.texts enable row level security;
756
- ```
757
-
758
- ### Homework
759
-
760
- #### מדריך להגדרת שיעורי בית
761
-
762
- 1. היכנסו לחשבון המורה ובחרו **ניהול** ואז **כיתות**.
763
- 2. בכיתה קיימת לחצו **עריכה**. בעת יצירת כיתה חדשה מגיעים לאותה הגדרה בשלב **שיעורי הבית**.
764
- 3. בשורת שיעורי הבית בחרו נושא מתוך השיעורים שסומנו לכיתה. אפשר לבחור גם **כתיבת משפטים** או טקסט שהוספתם לשיעור.
765
- 4. בחרו תאריך הגשה, אם יש. אפשר להשאיר את התאריך ריק כדי להציג את המטלה ללא מועד אחרון.
766
- 5. כדי לתת יותר ממטלה אחת לחצו **+ הוספת שיעורי בית**, ובחרו נושא ותאריך לכל שורה. אי אפשר לבחור את אותו נושא פעמיים.
767
- 6. לחצו **שמירה**. התלמידים יראו מעל הנושא במסלול בועה של **שיעורי בית** ואת הזמן שנותר להגשה; השינוי יופיע אצלם בפתיחה הבאה של העמוד.
768
-
769
- כדי לשנות מטלה חוזרים ל־**ניהול** → **כיתות** → **עריכה** ומשנים את הנושא או התאריך. להסרת מטלה לוחצים **✕** בשורה שלה ושומרים. התקדמות והשלמה מופיעות בכרטיס הכיתה ובמסך התלמידים; מטלת תרגול מושלמת לאחר שכל משפטי הנושא תורגלו, ומטלת כתיבה לאחר שכל השורות מולאו.
770
-
771
- A class may have several homeworks at once, a topic each with its own day: for example the last topic of every lesson it studies (the written homework). In the class's dialog each is a row, the topic (named with its lesson) and its day, with "✕" to drop it; "+ הוספת שיעורי בית" adds the last topic of the first ticked lesson that has none yet. A topic can be homework once. On the path every homework topic has its own bubble; in the panel the class's card has a line per homework (who finished, who is still to), the tile counts the students who finished all of them, and the students' "שיעורי בית" column sums them. They are kept in the class's `homeworks` column, `[{"topic", "due"}]`; `homework` and `due` hold the first, as before. What follows describes one homework.
772
-
773
- In the class's dialog the teacher sets its homework: one topic of the class's lessons (for example the conversation "שיחת היכרות"), and optionally the day it is due. The students see it on the path itself: the bubble over the homework topic's circle says "שיעורי בית" and counts the days left ("נשארו 3 ימים", "מגישים מחר!", "מגישים היום!", and "באיחור של יומיים" once the day passed). On the last two days the bubble and the circle's ring turn amber, and red after them. The path's focus does not move: the page still opens on the next topic in the course's order, which keeps its own "התחילו" bubble, so the homework's bubble may be further up or down the path. The homework is done when every sentence of the topic was practised, whenever that was; the circle then gets its ✓ and "שיעורי הבית הושלמו". In the panel the class's card shows how many students finished and names those who did not, the furthest from it first. The students get the homework with `GET /api/me`, so a change reaches them the next time they open the page.
774
-
775
- ### The daily session
776
-
777
- Above the path, "תרגול יומי" opens a session of 10 sentences, so a student with five minutes does not have to choose a topic. Half are sentences they already said with a best score under 75, drawn from their 15 weakest (so the same ones do not return every day); the other half are new, in the course's order, from the homework topic and then the rest of its lesson, or else from the first unfinished lesson. When one kind runs short the other fills in; stories are left out. At the end the page says how many were practised and how many scores went up, and the button shows "✓ תרגלתם היום" for the rest of the day (kept on the device, and only when at least half the sentences were said). The numbers are `DAILY` in `static/index.html`. Driving mode works in a session too.
778
-
779
- Under the microphone of a sentence translated from Hebrew to Arabic (the topics named "... עברית ← ערבית"), "תרגול שקט ללא ציון" swaps it for an exercise on the same sentence, for a student who cannot speak where they are: the sentence is built from its words, tapped in order out of a bank that also holds a few words of the topic's other sentences. The other topics (words, conjugations, Arabic → Hebrew, conversations) have no such exercise: their card keeps the microphone and has no button. Nothing is scored or sent to the server: a sentence answered this way is still not practised, on the path, in the homework and in the teacher's panel. It gets a mark of its own, kept on the device ("✓ בלי מיקרופון" in the list of sentences), and leaves the round. The mode lasts until the button is pressed again or the page is reloaded. `SILENT` in `static/index.html` is how many words too many the bank holds.
780
-
781
- A sentence translated from Arabic to Hebrew (the topics named "... ערבית ← עברית") can be answered in either language. Its card has two microphones side by side, each with its language's letter on it and a line under it: the filled one (ع, "קוראים בערבית") records the Arabic read aloud and scores its pronunciation, as before; the outlined one (א, "מתרגמים לעברית") listens to the translation instead, with the browser's own speech recognition (`he-IL`), and compares the words it heard with the translation. The card then shows the translation's grade, 0 to 100: the share of the translation's words that were heard, in order ("ציון התרגום: 70"), with what was heard and how many words matched, since a correct translation may be worded differently. 80 or more is "תרגום נכון" and shows the translation. The grade is shown only: it is not kept and does not reach the server, so such a sentence is still not practised and the teacher's panel counts the Arabic recordings alone. In Chrome the audio goes to Google's recognition service (the privacy page says so). A browser without speech recognition (Firefox) has the Arabic microphone alone, as before, and one that turns out not to recognise Hebrew drops the Hebrew one after the first try. The numbers are `HE` in `static/index.html`.
782
-
783
- ### The story quiz
784
-
785
- A story topic (a conversation, all its lines on the card) has a "בוחן" button: the student says the whole conversation in one go. Each line is recorded by itself, once; its score appears beside the line, its Arabic under it, and the next line is recorded, with no answer played and no second try. A line that was not heard waits for the microphone button; one that was skipped comes back after the last line. At the end the card shows the grade (the average of the lines' scores, and how many were said) and the three mistakes made most often across the lines, each with its tip and the words it was heard in (`QUIZ` in `static/index.html`). "סיום הבוחן" ends it early on the lines said so far. The lines are recorded as attempts like any other, so a quiz counts in the student's progress and in the panel. Story driving mode is listening-only and asks how to play the conversation: **Arabic only** plays every Arabic line with a three-second pause between lines; **Hebrew then Arabic** reads each Hebrew sentence followed by its Arabic line. Throughout driving mode, the screen shows the Hebrew meaning and the spoken Arabic in Hebrew transliteration together.
786
-
787
- The classes are kept in Supabase and read and written with `SUPABASE_SECRET_KEY` only, so the table has row-level security and no policy. Create it once:
788
-
789
- ```sql
790
- create table public.classes (
791
- id bigint generated always as identity primary key,
792
- name text not null unique, -- e.g. אוקטובר 2026
793
- emails text[] not null default '{}', -- the students' Google emails, lower-case
794
- nicknames jsonb not null default '{}', -- {email: nickname}, for the students who have one
795
- lessons text[] not null default '{}', -- the lesson ids the class studies; empty = every lesson
796
- subject text not null default 'ערבית', -- existing classes; new ones copy the creating admin
797
- teachers text[] not null default '{}', -- class-scoped teachers; each address is also in emails
798
- homework text not null default '', -- the topic id the class practises now, e.g. l2-talk; '' = none
799
- due date, -- the day the homework is due, if it has one
800
- homeworks jsonb not null default '[]', -- every homework of the class: [{"topic": ..., "due": ...}]
801
- dailies jsonb not null default '[]', -- the days of its daily videos: [{"video": id, "date": ...}]
802
- created_at timestamptz not null default now()
803
- );
804
- alter table public.classes enable row level security;
805
- ```
806
-
807
- A table made before the nicknames, the lessons, the homework or the daily videos needs the columns (a class saved before then studies every lesson and has no homework). Until they are there, saving a class in the panel is refused with this command in the message:
808
-
809
- ```sql
810
- alter table public.classes add column if not exists nicknames jsonb not null default '{}',
811
- add column if not exists lessons text[] not null default '{}',
812
- add column if not exists homework text not null default '',
813
- add column if not exists due date,
814
- add column if not exists homeworks jsonb not null default '[]',
815
- add column if not exists teachers text[] not null default '{}',
816
- add column if not exists dailies jsonb not null default '[]',
817
- add column if not exists subject text not null default 'ערבית';
818
- update public.classes set subject = 'ערבית' where subject = '';
819
- ```
820
-
821
- A subject is one of a fixed list, `SUBJECT_LABELS` in `app/main.py`: ערבית (served at `/`, and `/arabic`) and מתמטיקה (`/math`). It is kept in Hebrew, as the panel shows it, and the developer picks an admin's subject from that list in Monitoring; any other text is refused. `/api/me` names the subject of the account (an admin's own, or their class's), and the page moves an account that opened another subject's address to its own. A developer stays where they are. Each subject has its own title, colour and opening screen (`SUBJECTS`), and the installed app of a subject other than Arabic has its own manifest, `/math/manifest.webmanifest`, so its home-screen icon is named for it and opens `/math`.
822
-
823
- **The math path.** On `/math`, the start button opens the math path (`static/math.js`, `static/math.css`) in place of the Arabic one. Its skill graph is `app/math_algebra9.json`, served at `/api/math/skills`: 9th-grade algebra for the 5-unit track, by the Ministry's 2026/27 teaching schedule, in six units (foundations, algebraic technique, function properties, the quadratic function and equation, powers and roots, word problems). Each skill names the skills it needs, and has a sample question whose wrong choices are each a known mistake; a mistake sends the student back to the skill it comes from. `app/math_skills.py` refuses a graph with a cycle, an unknown skill, a redundant link, a question without exactly one right answer, or a mistake that sends a student forward. A skill is open once every skill it needs is done, and the path goes on from the first open one; a locked skill can still be opened, with the skills to do first named on it. For now a right answer to the sample question marks the skill done, and progress is kept on the device (`ihki.math.v1:<email>`). The questions were written for the app and still need a 5-unit teacher's review.
824
-
825
- **A photographed problem.** "צילום תרגיל" on the math path lets a student photograph a problem (camera or gallery). The page shrinks the photo to about 1600 px and sends it to `POST /api/math/photo`, where Claude (`app/math_photo.py`) reads it and solves it in a fixed JSON shape: the problem as read, the solution as steps (each a hint and the step itself), the answer, and the path's skill it practises. The sheet shows the reading and one hint; further hints, each step and the full solution with its answer come only when the student asks, and the skill links into the path. It runs on the tutor's Anthropic key (`ANTHROPIC_API_KEY`, or the panel's) and on `MATH_MODEL` (default `claude-opus-5-5`, with the API's default fallback model when it declines). A signed-in student has `DAILY_PHOTOS` (20) a day, counted in memory; a photo it could not read does not count, and developers are exempt. The photo is sent to Anthropic's API and not kept by the app; say so on the privacy page before students use it.
826
-
827
- The server keeps the list for a minute, so a change in the SQL editor (rather than the panel) applies within that time.
828
-
829
- ### Current lesson and the focused home
830
-
831
- The first time a student enters an Arabic class, the page asks which lesson the class is on now, offering only that class's lessons. A class must study lessons of exactly one level for this: a class whose lessons span several levels, or that names no lessons (and so studies every one), shows a message asking to correct the class instead. Math classes keep their own path and are not asked. After the choice the student lands on a focused home of at most three actions, the first three that apply of: the nearest-due homework, today's daily video when not yet watched on that device, continuing the current lesson, and a short review of up to five sentences. The full path is one button away and opens at the current lesson. "שינוי" next to the lesson changes it; no progress is lost. Teachers, and the open service without sign-in, keep the path as their first screen.
832
-
833
- Each student's choice is kept for each class in the table `learner_class_state`, read and written with `SUPABASE_SECRET_KEY` only. Create it once:
834
-
835
- ```sql
836
- create table public.learner_class_state (
837
- email text not null, -- the student's sign-in email, lower-case
838
- class_id bigint not null references public.classes (id) on delete cascade,
839
- current_lesson text not null, -- a lesson id of the class, e.g. 5 or 2.3
840
- created_at timestamptz not null default now(),
841
- updated_at timestamptz not null default now(),
842
- primary key (email, class_id) -- one current lesson per student and class
843
- );
844
- alter table public.learner_class_state enable row level security;
845
- revoke all on public.learner_class_state from anon, authenticated;
846
- ```
847
-
848
- `/api/me` gives each of the student's classes its `id`, its `level` (`null` when it is not one level), the `current_lesson` the student chose (`null` before a choice, or when the class no longer has that lesson) and `teacher: true` for a class the account teaches. The page saves a choice with:
849
-
850
- ```
851
- PUT /api/me/classes/{class_id}/current-lesson
852
- {"lesson": "5"}
853
- ```
854
-
855
- The server takes the email from the session, never from the body. It answers 401 without sign-in, 403 for a class the caller does not study in (or teaches), 409 for a class that is not one level, 400 for a lesson that is not the class's, and 503 when Supabase cannot save it. Until the table exists, or while Supabase is unreachable, the page keeps the choice on the device under `ihki.lesson.v1:<subject>:<email>:<class id>`, so another account on the same device does not get it, and sends it again on a later sign-in. A choice the server has wins over the device's.
856
-
857
- ### Live lessons
858
-
859
- An admin can choose several class teachers from the class's own student list. The assignment is scoped to that class:
860
- a person may teach one class and remain a student in another. Developers can edit every class; subject admins can edit
861
- only classes in their assigned subject.
862
-
863
- A class teacher opens **שיעור חי**, chooses their class and starts its single active session. Students see active sessions
864
- from their registered classes in the same tab. A six-digit code is also shown as a fallback; any signed-in account may
865
- join with it for that session only, without becoming a member of the class. The teacher chooses an existing sentence or
866
- searches the class's lesson bank by Hebrew text. No translation or LLM is used.
867
-
868
- The page polls every three seconds. The teacher sees the whole roster, who has joined, who is online, and each submitted
869
- score; a student sees only their own result. Every student gets one scored attempt per question. Empty or undecodable
870
- recordings may be retried because they never became a scored attempt. Session state, attendance and answers live only in
871
- the application process: ending a session, restarting the server, or two hours without a teacher heartbeat erases them.
872
- No live-session audio or result history is stored.
873
-
874
- The **ניטור** activity dashboard can switch between the last 5 minutes, 24 hours and 7 days. It also reports Sawtak cache usage: cached WAV count, disk size, cache hits, generated misses, and the hit rate since the current server process started. **הכנת כל ההקלטות** asks for a Sawtak voice id and starts a resumable, sequential background pass over every unique phrase for that voice; existing files are skipped and progress is returned by `GET /api/tts-cache`. Recordings are stored below `TTS_CACHE_DIR/<voice-id>/`. When `TTS_BUCKET` is set (a Hugging Face Storage Bucket id, e.g. `wzender/ihki-storage`, with an `HF_TOKEN` secret that can write to it), the sweep also uploads every WAV through the Bucket API to `TTS_BUCKET_PREFIX/<voice-id>/` (default prefix `ihki-tts`), and at the end uploads every other WAV already on disk; files already in the bucket with the same size are skipped. Writing to a mounted `/data` folder alone was not reaching the bucket, and was slow (the mount flushes a few files every ~30 s), so with `TTS_BUCKET` set new recordings are written to a local staging folder (`TTS_STAGING_DIR`, default `/tmp/ihki-tts-staging`) and uploaded through the API: the sweep in batches of 25 (at least every 30 s), playback misses one by one in the background. Lookups check the staging folder first, then `TTS_CACHE_DIR`. Clearing the cache resets the process counters and leaves the bucket untouched.
875
-
876
- The panel reads through `GET /api/class`, which answers teachers only. Row-level security hides other students' rows from the teacher's own token, so the server reads them with `SUPABASE_SECRET_KEY` (Supabase → Project Settings → API Keys → secret key). Unlike the publishable key it must stay private: set it as a Space **secret**. Without it the panel opens and says the data is not available.
877
-
878
- The same figures as a query, in the SQL editor (it runs as the owner, so it sees every student):
879
-
880
- ```sql
881
- select u.email, a.cat, count(distinct a.phrase) as phrases, count(*) as tries,
882
- round(avg(a.score)) as average, max(a.created_at) as last_practice
883
- from public.attempts a join auth.users u on u.id = a.user_id
884
- group by u.email, a.cat order by u.email, a.cat;
885
- ```
886
-
887
- ## Fine-tuning
888
-
889
- The base model was not trained on Palestinian Arabic, and it has heard few Hebrew-accented pharyngeals. Expect the zero-shot scores to be noisy until it is fine-tuned on matched data.
890
-
891
- **Data to collect** (CSV format in `data/example.csv`):
892
- - **Native recordings.** The teacher records each phrase 3–5 times, labelled with the main variant's IPA.
893
- - **Learner recordings, labelled with what was actually said.** Label الحمام said with ח as `… χ a m m aː m`, not `ħ`. These rows teach the model the exact contrasts the tips depend on (ħ/χ, ħ/h, ʕ/ʔ, ʔ/k, r/ʁ, emphatics).
894
-
895
- A few hundred utterances are enough to move the needle with the feature encoder frozen.
896
-
897
- ```bash
898
- pip install -r requirements-train.txt
899
- python -m train.finetune --train data/train.csv --eval data/eval.csv --out checkpoints/ihki-ft
900
- MODEL_ID=checkpoints/ihki-ft uvicorn app.main:app --host 0.0.0.0 --port 7860
901
- ```
902
-
903
- The script reports phoneme error rate (PER) before and after training. A GPU is advisable. A free Colab T4 handles a few hundred clips in minutes.
904
-
905
- ### POWSM on the Space
906
-
907
- The most accurate model so far is POWSM-CTC fine-tuned on the Minerva lines with short padding (`train/finetune_powsm.py --pad random`). On the test lines it reaches 16.5% PER, against 38% for the base model, at 0.57 s a recording on 2 CPU threads. See [docs/zero-shot-phone-models.md](docs/zero-shot-phone-models.md). The Space runs it as an ONNX export, with `onnxruntime` and torch only. ESPnet, which training needs, stays on the training machine.
908
-
909
- 1. **Export it.** Do this once per fine-tune, on the training machine, after running `scripts/setup_zero_shot_deps.sh`:
910
- ```bash
911
- python scripts/export_powsm_onnx.py checkpoints/powsm-ft-shortpad/powsm_ft.pt exports/powsm-ft-shortpad \
912
- --check data/minerva/dev.csv # each frame's best phone should match ESPnet's 100%
913
- ```
914
- 2. **Upload the folder to a Hub model repo.** Use `--private` if the model should not be public: it was trained on the copyrighted Minerva clips.
915
- ```bash
916
- hf repo create wzender/ihki-powsm-ft --repo-type model --private
917
- hf upload wzender/ihki-powsm-ft exports/powsm-ft-shortpad .
918
- ```
919
- 3. **Point the Space at it.** In the Space settings, set the variable `MODEL_ID=powsm-onnx:wzender/ihki-powsm-ft`, and `POWSM_ONNX_THREADS=2` to match the free hardware. For a private repo, also add a secret `HF_TOKEN` with read access. The Docker build passes that secret only to the step that downloads the model, which is then baked into the image. Saving the settings rebuilds the Space. `/api/health` and the panel's פרטי המערכת show the model.
920
-
921
- Settings:
922
- - `POWSM_ONNX_PRECISION=int8` loads the int8 copy: 0.38 s a recording, but ط and ص drop by 5–6 points.
923
- - `POWSM_PAD_SECONDS` overrides the padding (default: what the export was trained for, none here).
924
- - `POWSM_ONNX_REPO` names the repo the panel's "מודלים" tab offers.
925
-
926
- To roll back, delete `MODEL_ID`. The Space rebuilds onto the base model.
927
-
928
- ## Calibration knobs
929
-
930
- - `ScorerConfig.flag_below` (50): the phoneme score under which a consonant gets a tip.
931
- - `ScorerConfig.unrelated_ratio` (0.65): above this, the response says "different sentence".
932
- - `GopConfig.*`: the deletion thresholds, and the target posterior at which confidence stops lowering the score.
933
-
934
- Tune them on a handful of labelled learner recordings. A useful target is that natives score ≥ 85 and known ħ→χ errors get flagged.
935
-
936
- ## Playback voice (Sawtak Arabi)
937
-
938
- "🔊 השמע" plays the model answer through `GET /api/tts/{phrase_id}`, which calls the [Sawtak Arabi](https://sawtakarabi.ai/docs) TTS API server-side and caches each phrase on disk. The key never reaches the browser.
939
-
940
- - Local: `cp .env.example .env` and set `SAWTAK_API_KEY` (`.env` is git-ignored and excluded from the Docker image).
941
- - Hugging Face: add `SAWTAK_API_KEY` as a Space **secret** (Settings → Variables and secrets). It is injected as an environment variable at runtime.
942
- - Model-answer playback uses `SAWTAK_VOICE_ID`, or the Palestinian voice רַאאִף when it is unset.
943
- - Without a key the endpoint answers 503 and the page falls back to the device's Arabic voice.
944
- - Score calibration keeps its fixed native baseline voices; it does not use `SAWTAK_VOICE_ID`. A baseline is prepared once for every phrase that gets scored, also when the learner never presses "השמע". The first score of a phrase waits up to 8 s for it. Without a key the scores are not calibrated; `/api/health` reports this as `references`.
945
-
946
- ## Cost estimate (API keys)
947
-
948
- Estimated 2026-10-18 for **100 students, each doing 5 tutor chats a day (the daily limit, `DAILY_LIMIT`) of 10 turns each**: 500 chats a day, 15,000 a month (30 days). Anthropic prices are list prices per million tokens; the Sawtak price is the rate your own account was charged.
949
-
950
- ### Anthropic (`ANTHROPIC_API_KEY`) — the tutor game יאללה נדבר
951
-
952
- How a chat is billed: 11 calls (the opening plus 10 turns). Each call sends the scenario's system prompt and the reply schema (the prefix), plus the conversation so far. The prefix grows with the lessons' vocabulary: 4,364 tokens in lesson 1, 16,466 mid-course and 22,728 in the last lesson (measured with `count_tokens`; the 5.5 models count the same). Each turn adds about 540 tokens of history, and each answer is about 490 output tokens. `cache_control` caches the prompt, so every call after the first reads the earlier part from the cache and writes only the new turn. That assumes the turns are less than 5 minutes apart, which a spoken conversation is.
953
-
954
- Tokens per chat (mid-course): ~22K cache writes (1.25× the input price), ~189K cache reads, ~5.7K output.
955
-
956
- | Model (`TUTOR_MODEL`) | $ in / out / cache read per 1M | Per chat (mid-course) | Per day | **Per month**: lesson 1 / mid / last | Per student per month (mid) |
957
- |---|---|---|---|---|---|
958
- | `claude-haiku-5-5` (the default) | 0.10 / 0.50 / 0.01 | $0.0075 | $3.73 | **$71 / $112 / $133** | $1.12 |
959
- | `claude-haiku-4-5` | 1 / 5 / 0.10 | $0.075 | $37 | $712 / $1,120 / $1,332 | $11.20 |
960
- | `claude-sonnet-5-5` | 2 / 10 / 0.20 | $0.149 | $75 | $1,424 / $2,241 / $2,663 | $22.40 |
961
- | `claude-opus-5-5` ¹ | 4 / 20 / 0.20 | $0.261 | $130 | $2,644 / $3,914 / $4,572 | $39.10 |
962
- | `claude-fable-5-1` ¹ | 10 / 50 / 0.25 | $0.605 | $303 | $6,354 / $9,077 / $10,486 | $90.80 |
963
-
964
- - **Without the prompt cache** the input would cost 3–3.5× more (for example Haiku 5.5 mid-course: $359 a month instead of $112). Keep `cache_control` in `Tutor._ask`.
965
- - These are upper bounds for the volume: every student uses all 5 chats, every day, all 10 turns. Real use is usually lower; `tutor_chats` records each chat's `input_tokens` and `output_tokens`, so the real bill can be checked against this table.
966
- - ¹ Opus 5.5 and Fable 5.1 cannot turn thinking off, so the tutor leaves it adaptive at `effort: "low"` (`thinking_for()` in `app/tutor.py`). The thinking tokens they spend are billed as output and are not in this table, so expect somewhat more for these two.
967
- - Set a monthly spending limit in the Console (Settings → Limits), so that a bug or unexpected use cannot cost more than planned.
968
-
969
- ### Sawtak Arabi (`SAWTAK_API_KEY`) — the model-answer voice
970
-
971
- Sawtak charges per character of text spoken. This account was charged **$5.00 for 99,600 characters (about $0.05 per 1,000 characters, $50 per million)** in the 30 days before 2026-10-18 (`GET /v1/usage`).
972
-
973
- - **If the tutor's messages are spoken with Sawtak too** (the code does not do this yet), each one costs again: every message is new text, so none comes from the cache. A message is one or two short sentences, about 90 characters with vowel marks (the phrase bank averages 51 per phrase). A chat has 11 messages (the opening and 10 answers), so about 1,000 characters:
974
-
975
- | | Characters | Cost |
976
- |---|---|---|
977
- | Per chat | ~990 | ~$0.05 |
978
- | Per day (500 chats) | ~495,000 | ~$25 |
979
- | **Per month (15,000 chats)** | ~14.9 million | **~$745** (between ~$500 and ~$1,000 for messages of 60–120 characters) |
980
- | Per student per month | ~149,000 | ~$7.45 |
981
-
982
- That is more than the tutor's model costs on Haiku 5.5 (~$112 a month), so voicing the tutor would be most of the bill.
983
- - The rest of the cost comes from "🔊 השמע" and from the native baseline the scores are calibrated against, both for the phrase bank:
984
- - Each phrase is generated once and kept in the voice cache (`TTS_CACHE_DIR`). All 3,546 phrases come to about 180,000 characters with their vowel marks, so **voicing the whole bank costs about $9, once**. A text's dialogue lines in the second voice add a little.
985
- - **The number of students does not change this cost**: 100 students cost the same as 1, because the second student to play a phrase gets it from the cache.
986
- - What does cost more: the cache is a folder on the server's disk. A Hugging Face Space without persistent storage loses it on every restart and rebuild, and "מחיקת מטמון ההקלטות" in the panel empties it. After either, the phrases are generated again as they are played, up to about $9 each time. To avoid that, give the Space persistent storage and set `TTS_CACHE_DIR=/data/ihki-tts`.
 
1
+ # Mathly
 
 
 
 
 
 
 
2
 
3
+ Mathly is a Hebrew ninth-grade algebra practice path. It contains 56 linked skills across six units, immediate feedback for known mistakes, and optional photographed-problem help.
4
 
5
+ ## Run locally
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  ```bash
8
+ python3 -m venv venv
9
+ venv/bin/python -m pip install -r requirements.txt -r requirements-dev.txt
10
+ venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 7860
 
 
11
  ```
12
 
13
+ Open <http://localhost:7860>. Authentication is disabled when the Supabase values are empty.
14
 
15
+ ## Configuration
16
 
17
+ Copy `.env.example` to `.env`. `ANTHROPIC_API_KEY` enables photographed-problem help. Supabase settings enable Google sign-in and math-class rosters.
 
 
 
18
 
19
+ For an existing deployment, back up Supabase and then run [docs/mathly-supabase.sql](docs/mathly-supabase.sql) in its SQL editor. The migration deliberately deletes the removed product's tables and non-math classes.
20
 
21
+ ## Tests
22
 
23
  ```bash
24
+ venv/bin/python -m pytest
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/access.py CHANGED
@@ -1,72 +1,36 @@
1
- """Shared user-session and permission resolution for browser and MCP callers."""
2
  from __future__ import annotations
3
 
4
- from .auth import AuthUnavailable, SupabaseAuth
5
 
6
-
7
- ROLES = frozenset({"student", "teacher", "flagger", "admin", "developer"})
8
- DEFAULT_ROLES = ("student", "flagger")
9
- ENTITLEMENTS = frozenset({"practice", "games", "tutor", "path", "library"})
10
- DEFAULT_ENTITLEMENTS = ENTITLEMENTS - {"tutor"}
11
  ROLE_PERMISSIONS = {
12
- "student": frozenset(),
13
- "teacher": frozenset({"exercises.use_all", "students.view", "games.use", "path.use", "exercises.use", "library.view"}),
14
- "flagger": frozenset({"flags.create"}),
15
- "admin": frozenset({"exercises.use_all", "students.view", "management.access", "users.manage",
16
- "classes.manage", "content.manage", "flags.review", "games.use", "path.use",
17
- "exercises.use", "library.view"}),
18
  "developer": frozenset({"*"}),
19
  }
20
- ENTITLEMENT_PERMISSIONS = {"practice": {"practice.use", "exercises.use"}, "games": {"games.use"},
21
- "tutor": {"tutor.use"}, "path": {"path.use"}, "library": {"library.view"}}
22
-
23
 
24
- def resolve_access(a: SupabaseAuth, token: str, *, fresh_permissions: bool = False) -> dict | None:
25
- """Return the current application identity for a verified Supabase access token.
26
 
27
- ``fresh_permissions`` bypasses the short user-access cache. MCP uses it on every
28
- request so revoking management access takes effect immediately.
29
- """
30
- user = a.user(token)
31
  if user is None:
32
  return None
33
  email = user["email"].lower()
34
- classes = a.classes()
35
- mine = [c for c in classes if email in c["emails"]]
36
- teaches = [c for c in classes if email in (c.get("teachers") or [])]
37
- member = bool(mine or teaches or not classes)
38
- roles, entitlements, assigned = {"student"}, set(DEFAULT_ENTITLEMENTS) if member else set(), None
39
- if hasattr(a, "access_for"):
40
- try:
41
- assigned = a.access_for(user["id"], fresh=fresh_permissions)
42
- except TypeError: # compatible with small test/deployment adapters predating ``fresh``
43
- assigned = a.access_for(user["id"])
44
- if assigned:
45
- roles = set(assigned.get("roles") or ["student"]) & ROLES
46
- entitlements = set(assigned.get("entitlements") or []) & ENTITLEMENTS
47
- if email in getattr(a, "teachers", ()):
48
- roles.update({"teacher", "admin", "flagger"})
49
- if email in getattr(a, "tutors", ()):
50
- roles.add("flagger")
51
- if email in getattr(a, "developers", ()):
52
  roles.add("developer")
53
- privileged = roles - {"student"}
54
- if classes and not mine and not privileged and not entitlements:
55
  return {**user, "denied": True, "permissions": []}
56
- if not assigned:
57
- roles.update(DEFAULT_ROLES)
58
- if "flagger" not in roles:
59
- try:
60
- if email in (a.ranked() or ()):
61
- roles.add("flagger")
62
- except AuthUnavailable:
63
- pass
64
- permissions = set().union(*(ROLE_PERMISSIONS[r] for r in roles))
65
- permissions.update(*[ENTITLEMENT_PERMISSIONS[e] for e in entitlements])
66
- return {**user, "token": token, "roles": sorted(roles), "entitlements": sorted(entitlements),
67
- "subject": str((assigned or {}).get("subject") or ""), "permissions": sorted(permissions),
68
- "teacher": bool(roles & {"teacher", "admin", "developer"}),
69
- "tutor": "flags.create" in permissions or "*" in permissions, "classes": mine, "teaches": teaches}
70
 
71
 
72
  def has_permission(user: dict | None, permission: str) -> bool:
 
1
+ """Mathly session roles and permissions."""
2
  from __future__ import annotations
3
 
4
+ from .auth import SupabaseAuth
5
 
6
+ ROLES = frozenset({"student", "teacher", "admin", "developer"})
7
+ DEFAULT_ROLES = ("student",)
 
 
 
8
  ROLE_PERMISSIONS = {
9
+ "student": frozenset({"path.use"}),
10
+ "teacher": frozenset({"path.use", "students.view"}),
11
+ "admin": frozenset({"path.use", "students.view", "management.access", "classes.manage"}),
 
 
 
12
  "developer": frozenset({"*"}),
13
  }
 
 
 
14
 
 
 
15
 
16
+ def resolve_access(store: SupabaseAuth, token: str, *, fresh_permissions: bool = False) -> dict | None:
17
+ user = store.user(token)
 
 
18
  if user is None:
19
  return None
20
  email = user["email"].lower()
21
+ classes = store.classes()
22
+ mine = [row for row in classes if email in row.get("emails", ()) or email in row.get("teachers", ())]
23
+ assigned = store.access_for(user["id"], fresh=fresh_permissions)
24
+ roles = set((assigned or {}).get("roles") or DEFAULT_ROLES) & ROLES
25
+ if email in store.teachers:
26
+ roles.update({"teacher", "admin"})
27
+ if email in store.developers:
 
 
 
 
 
 
 
 
 
 
 
28
  roles.add("developer")
29
+ if classes and not mine and roles == {"student"}:
 
30
  return {**user, "denied": True, "permissions": []}
31
+ permissions = set().union(*(ROLE_PERMISSIONS[role] for role in roles))
32
+ return {**user, "token": token, "roles": sorted(roles), "permissions": sorted(permissions),
33
+ "teacher": bool(roles & {"teacher", "admin", "developer"}), "classes": mine}
 
 
 
 
 
 
 
 
 
 
 
34
 
35
 
36
  def has_permission(user: dict | None, permission: str) -> bool:
app/align.py DELETED
@@ -1,316 +0,0 @@
1
- """CTC forced alignment and goodness-of-pronunciation (GOP), in plain numpy.
2
-
3
- Inputs are frame-level log-posteriors `logp` of shape (T, V) from a CTC phoneme model.
4
- Nothing here depends on torch, so it is unit-testable with synthetic emissions.
5
- """
6
- from __future__ import annotations
7
-
8
- import logging
9
- import os
10
- from dataclasses import dataclass, field
11
-
12
- import numpy as np
13
-
14
- log = logging.getLogger("ihki.align")
15
-
16
- NEG = -1e30
17
- POOLS = ("max", "mean", "topk", "lse", "energy", "gated")
18
- DEFAULT_POOL = "gated"
19
-
20
-
21
- def env_pool() -> str:
22
- """The ratio pool GOP_RATIO_POOL names (read when a GopConfig is made, so after main.py loads .env).
23
- Empty or unset: DEFAULT_POOL. An unknown name is logged and DEFAULT_POOL is used."""
24
- pool = os.environ.get("GOP_RATIO_POOL", "").strip().lower() or DEFAULT_POOL
25
- if pool not in POOLS:
26
- log.warning("GOP_RATIO_POOL=%r is not one of %s: using %s", pool, ", ".join(POOLS), DEFAULT_POOL)
27
- return DEFAULT_POOL
28
- return pool
29
-
30
-
31
- NARROW_PAD = 1
32
-
33
-
34
- def env_vowel_window() -> int | None:
35
- """GOP_VOWEL_WINDOW: empty, unset or "wide" (None: a vowel reads the consonants' wide window), "narrow" (its
36
- aligned span +- NARROW_PAD frames), or that pad as a frame count. Anything else is logged and "wide" used.
37
- Wide is the default: with a pooled ratio it absorbs a transition spike without the narrow window's blind spot
38
- (docs/gop-pooling.md, scripts/gop_window_bench.py)."""
39
- raw = os.environ.get("GOP_VOWEL_WINDOW", "").strip().lower()
40
- if raw in ("", "wide"):
41
- return None
42
- if raw == "narrow":
43
- return NARROW_PAD
44
- if raw.isdigit():
45
- return int(raw)
46
- log.warning("GOP_VOWEL_WINDOW=%r is not wide, narrow or a frame count: using wide", raw)
47
- return None
48
-
49
-
50
- @dataclass
51
- class Alignment:
52
- total_logp: float # log-prob of the best CTC path
53
- spans: list[tuple[int, int]] # [start, end) frames where each target token is emitted
54
-
55
-
56
- def ctc_viterbi(logp: np.ndarray, tokens: list[int], blank: int) -> Alignment | None:
57
- """Best CTC path that emits exactly `tokens`. Returns None if the audio is too short."""
58
- T, _ = logp.shape
59
- N = len(tokens)
60
- if N == 0 or T < N:
61
- return None
62
- S = 2 * N + 1
63
- ext = np.array([blank if s % 2 == 0 else tokens[s // 2] for s in range(S)])
64
- # skip transition s-2 -> s allowed for non-blank states whose label differs from s-2
65
- can_skip = np.zeros(S, dtype=bool)
66
- can_skip[2:] = (ext[2:] != blank) & (ext[2:] != ext[:-2])
67
-
68
- dp = np.full((T, S), NEG)
69
- bp = np.zeros((T, S), dtype=np.int8) # 0 = stay, 1 = from s-1, 2 = from s-2
70
- dp[0, 0] = logp[0, blank]
71
- dp[0, 1] = logp[0, ext[1]]
72
- for t in range(1, T):
73
- prev = dp[t - 1]
74
- stay = prev
75
- one = np.concatenate(([NEG], prev[:-1]))
76
- two = np.where(can_skip, np.concatenate(([NEG, NEG], prev[:-2])), NEG)
77
- stacked = np.stack([stay, one, two])
78
- choice = stacked.argmax(0)
79
- dp[t] = stacked[choice, np.arange(S)] + logp[t, ext]
80
- bp[t] = choice
81
-
82
- # path must end in the last token or the trailing blank
83
- end = S - 1 if dp[T - 1, S - 1] >= dp[T - 1, S - 2] else S - 2
84
- total = float(dp[T - 1, end])
85
- if total <= NEG / 2:
86
- return None
87
-
88
- states = np.empty(T, dtype=np.int64)
89
- s = end
90
- for t in range(T - 1, -1, -1):
91
- states[t] = s
92
- s -= int(bp[t, s])
93
-
94
- spans: list[list[int]] = [[-1, -1] for _ in range(N)]
95
- for t, st in enumerate(states):
96
- if st % 2 == 1:
97
- i = st // 2
98
- if spans[i][0] < 0:
99
- spans[i][0] = t
100
- spans[i][1] = t + 1
101
- return Alignment(total, [tuple(x) for x in spans])
102
-
103
-
104
- @dataclass
105
- class TokenScore:
106
- score: float # 0..100
107
- gop: float # log ratio target vs strongest competitor, <= 0
108
- peak: float # max log-posterior of the target inside its window
109
- competitor: int | None # strongest non-target, non-blank token in the window (None: nothing, or a sound we do not name)
110
- deleted: bool # target barely present and nothing else either
111
- realised: int | None = None # a class target (several correct realisations): the one said
112
- other: bool = False # the strongest competitor is a sound outside the named set
113
- comp_peak: float = NEG # max log-posterior of the strongest competitor (named or not) in the window
114
- comp_frames: int = 0 # frames where that competitor clears deleted_comp (duration evidence)
115
- window: tuple[int, int] = (0, 0) # [start, end) frames the target and its competitors were read from
116
-
117
-
118
- @dataclass
119
- class GopConfig:
120
- deleted_peak: float = float(np.log(0.05)) # target this weak ...
121
- deleted_comp: float = float(np.log(0.20)) # ... and no strong competitor -> deletion
122
- confident_peak: float = 0.30 # target posterior at which the peak no longer lowers the score
123
- # Softens the target/competitor ratio: at 0.5 a competitor twice as likely still scores 71, not 50.
124
- # The zero-shot model is noisy on accented speech, so a linear ratio punishes its own uncertainty.
125
- leniency: float = 0.5
126
- # How the target/competitor ratio pools the window's frames (docs/gop-pooling.md). The peak behind
127
- # `confidence` and `deleted` stays the max, so a peaky CTC emission is never diluted by its blanks.
128
- ratio_pool: str = field(default_factory=env_pool) # max | mean | topk | lse | energy | gated (GOP_RATIO_POOL)
129
- pool_k: int = 2 # topk: frames averaged per column
130
- pool_tau: float = 0.5 # lse: temperature (0 -> max, 1 -> mean)
131
- energy_gamma: float = 1.0 # energy: frame weight = (RMS / loudest in the window) ** gamma
132
- energy_gate: float = 0.3 # gated: frames quieter than this fraction of the window's loudest are dropped
133
- energy_spread: int = 1 # frames the energy is max-filtered over (CTC peaks lead/lag the acoustics)
134
- pool_vowels_only: bool = True # consonants keep the max (bursts and voiceless sounds are short and quiet)
135
- # Consonants need some surrounding blank for a CTC peak that leads/lags the forced span, but must not inherit an
136
- # arbitrarily long pause up to the next token (e.g. a 760 ms window across a sentence break).
137
- consonant_window: int | None = 4
138
- # A vowel's competitors are read from its aligned span +- this many frames (None: the wide window consonants
139
- # use). Its presence (`peak`, `deleted`) is still read from the wide window. GOP_VOWEL_WINDOW sets it.
140
- vowel_window: int | None = field(default_factory=env_vowel_window)
141
-
142
-
143
- def pool_frames(seg: np.ndarray, how: str = "max", k: int = 2, tau: float = 0.5,
144
- weight: np.ndarray | None = None) -> np.ndarray:
145
- """Pool a window's (n, V) log-posteriors over time into one log-score per column.
146
-
147
- max: the single best frame. topk: log of the mean of each column's k best frames. lse: the power mean
148
- tau * log(mean(p ** (1/tau))), between the max (tau -> 0) and the mean (tau = 1). mean / energy / gated:
149
- log of the weighted mean posterior, `weight` (uniform when None) normalised over the window.
150
- """
151
- n = len(seg)
152
- if how == "max" or n == 1:
153
- return seg.max(0)
154
- if how == "topk":
155
- k = max(1, min(k, n))
156
- return np.logaddexp.reduce(np.partition(seg, n - k, axis=0)[n - k:], axis=0) - np.log(k)
157
- if how == "lse":
158
- return tau * (np.logaddexp.reduce(seg / tau, axis=0) - np.log(n))
159
- w = np.ones(n) if weight is None else np.asarray(weight, dtype=np.float64)
160
- w = w / w.sum() if w.sum() > 0 else np.full(n, 1.0 / n)
161
- return np.logaddexp.reduce(seg + np.log(np.maximum(w, 1e-300))[:, None], axis=0)
162
-
163
-
164
- def energy_weights(energy: np.ndarray, how: str, gamma: float = 1.0, gate: float = 0.3) -> np.ndarray:
165
- """Frame weights of one window from its (already smoothed) RMS: soft (`energy`) or a 0/1 voice gate (`gated`)."""
166
- e = energy / max(float(energy.max()), 1e-12)
167
- return (e >= gate).astype(np.float64) if how == "gated" else e ** gamma
168
-
169
-
170
- def spread_energy(energy: np.ndarray, spread: int) -> np.ndarray:
171
- """Max-filter over +-spread frames: a CTC emission a frame off the loud nucleus still gets its weight."""
172
- out = energy.astype(np.float64).copy()
173
- for s in range(1, spread + 1):
174
- out[s:] = np.maximum(out[s:], energy[:-s])
175
- out[:-s] = np.maximum(out[:-s], energy[s:])
176
- return out
177
-
178
-
179
- def token_window(spans: list[tuple[int, int]], i: int, T: int, pad: int | None = None) -> tuple[int, int]:
180
- """[start, end) frames token i is read from. Wide (pad None): from the end of token i-1 to the start of token i+1,
181
- the blanks around the emission where a substituted consonant peaks. Narrow: its own span +- `pad` frames, inside
182
- the wide window (a vowel is its syllable's loud nucleus, not a sound hiding in the blanks)."""
183
- a = spans[i - 1][1] if i > 0 else 0
184
- b = spans[i + 1][0] if i + 1 < len(spans) else T
185
- a, b = min(a, spans[i][0]), max(b, spans[i][1])
186
- if pad is None:
187
- return a, b
188
- return max(a, spans[i][0] - pad), min(b, spans[i][1] + pad)
189
-
190
-
191
- def compute_token_gop(logp: np.ndarray, spans: list[tuple[int, int]], i: int, tokens: list[int], blank: int,
192
- cfg: GopConfig, vowel: bool = False, accept: list[set[int] | None] | None = None,
193
- n_real: int | None = None, named: set[int] | None = None, energy: np.ndarray | None = None,
194
- edge: tuple[set[int], set[int]] | None = None, pooled: bool | None = None) -> TokenScore:
195
- """GOP of token i (see score_tokens), its window and pooling chosen by its class.
196
-
197
- Presence is read from the wide window for every token: `peak` (behind the confidence) and `deleted` (target under
198
- deleted_peak and no competitor over deleted_comp), so a vowel aligned to a single frame is still found deleted.
199
- The competitor ratio of a vowel is read from its narrow window (cfg.vowel_window) and pooled with cfg.ratio_pool;
200
- a consonant is capped at its span +- cfg.consonant_window and keeps the max unless cfg.pool_vowels_only is off.
201
- `pooled` overrides the latter.
202
- `edge`: ids that are no competitor on the frames between the token's span and its left / right neighbour (a vowel
203
- gliding into j or w passes through i or u: coarticulation, not a substitution). `energy` is already spread.
204
- """
205
- T = logp.shape[0]
206
- accept = accept or [None] * len(tokens)
207
- a, b = token_window(spans, i, T, cfg.consonant_window) if not vowel else token_window(spans, i, T)
208
- ra, rb = token_window(spans, i, T, cfg.vowel_window) if vowel and cfg.vowel_window is not None else (a, b)
209
- tok, (s0, s1) = tokens[i], spans[i]
210
- seg = logp[a:b]
211
- peak = float(seg[:, tok].max())
212
- members = accept[i] or set()
213
- realised = max(members, key=lambda m: seg[:, m].max()) if members else None
214
-
215
- cols = (seg[:, :n_real] if n_real else seg).copy()
216
- if edge:
217
- for ids, lo, hi in ((edge[0], a, s0), (edge[1], s1, b)):
218
- ids = [x for x in ids if x < cols.shape[1]]
219
- if ids and hi > lo:
220
- cols[lo - a:hi - a, ids] = NEG
221
- mask = np.zeros(cols.shape[1], dtype=bool)
222
- mask[blank] = True
223
- for j in (i - 1, i, i + 1):
224
- if 0 <= j < len(tokens):
225
- for x in {tokens[j], *(accept[j] or ())}:
226
- if x < len(mask):
227
- mask[x] = True
228
- deleted = bool(peak < cfg.deleted_peak and float(np.where(mask, NEG, cols.max(0)).max()) < cfg.deleted_comp)
229
-
230
- how = cfg.ratio_pool if (pooled if pooled is not None else vowel or not cfg.pool_vowels_only) else "max"
231
- weight = None
232
- if how in ("energy", "gated"):
233
- if energy is not None:
234
- weight = energy_weights(energy[ra:rb], how, cfg.energy_gamma, cfg.energy_gate)
235
- weight[max(0, s0 - ra):max(0, s1 - ra)] = 1.0 # the frames the token was emitted on count in full
236
- how = "mean"
237
- rcols = cols[ra - a:rb - a]
238
- maxed = rcols.max(0)
239
- ratio = maxed.copy() if how == "max" else pool_frames(rcols, how, cfg.pool_k, cfg.pool_tau, weight)
240
- ratio[mask] = NEG
241
- comp = int(ratio.argmax())
242
- comp_peak = float(maxed[comp]) if ratio[comp] > NEG / 2 else NEG
243
- # A lone CTC spike can be a breath or microphone onset rather than a produced consonant. Keep duration
244
- # evidence separately from the peak so pronunciation policy can distinguish transients from substitutions.
245
- comp_frames = int(np.count_nonzero(rcols[:, comp] >= cfg.deleted_comp)) if ratio[comp] > NEG / 2 else 0
246
- tseg = logp[ra:rb, [tok]]
247
- target = float(tseg.max()) if how == "max" else float(pool_frames(tseg, how, cfg.pool_k, cfg.pool_tau, weight)[0])
248
-
249
- gop = target - max(target, float(ratio[comp]))
250
- confidence = min(1.0, float(np.exp(peak)) / cfg.confident_peak)
251
- score = 100.0 * float(np.exp(gop * cfg.leniency)) * float(np.sqrt(confidence))
252
- present = comp_peak > NEG / 2
253
- other = bool(present and named is not None and comp not in named)
254
- return TokenScore(round(score, 1), round(gop, 3), round(peak, 3), comp if present and not other else None,
255
- deleted, realised, other, round(comp_peak, 3), comp_frames, (int(ra), int(rb)))
256
-
257
-
258
- def score_tokens(logp: np.ndarray, align: Alignment, tokens: list[int], blank: int,
259
- cfg: GopConfig | None = None, accept: list[set[int] | None] | None = None,
260
- n_real: int | None = None, named: set[int] | None = None,
261
- energy: np.ndarray | None = None, pooled: list[bool] | None = None,
262
- vowels: list[bool] | None = None,
263
- edges: list[tuple[set[int], set[int]] | None] | None = None) -> list[TokenScore]:
264
- """Per-token GOP (compute_token_gop for each token).
265
-
266
- The neighbours' own labels are excluded from the competitor set (coarticulation is not an error).
267
- A class token (a virtual column pooling its correct realisations, `accept[i]` their ids) is scored on the
268
- pooled column; its members are not competitors. Competitors come from the first `n_real` columns only, and
269
- one outside `named` is reported as `other` rather than by name.
270
-
271
- `vowels` marks the tokens that are vowels (narrow window, pooled ratio). Without it every token is read from the
272
- wide window and pooled, unless `pooled` marks which are. `energy` is the per-frame RMS the energy pools need;
273
- without it they fall back to the plain mean. `edges[i]`: see compute_token_gop.
274
- """
275
- cfg = cfg or GopConfig()
276
- if energy is not None:
277
- energy = spread_energy(np.asarray(energy)[:logp.shape[0]], cfg.energy_spread)
278
- return [compute_token_gop(logp, align.spans, i, tokens, blank, cfg, bool(vowels and vowels[i]), accept, n_real,
279
- named, energy, edges[i] if edges else None,
280
- (pooled[i] if pooled is not None else None if vowels is not None else True))
281
- for i in range(len(tokens))]
282
-
283
-
284
- def greedy_decode(logp: np.ndarray, blank: int) -> list[int]:
285
- ids = logp.argmax(1)
286
- out, prev = [], None
287
- for x in ids:
288
- x = int(x)
289
- if x != prev and x != blank:
290
- out.append(x)
291
- prev = x
292
- return out
293
-
294
-
295
- def edit_distance(a: list, b: list, same=None) -> int:
296
- same = same or (lambda x, y: x == y)
297
- d = list(range(len(b) + 1))
298
- for i, x in enumerate(a, 1):
299
- prev, d[0] = d[0], i
300
- for j, y in enumerate(b, 1):
301
- prev, d[j] = d[j], min(d[j] + 1, d[j - 1] + 1, prev + (not same(x, y)))
302
- return d[len(b)]
303
-
304
-
305
- def weighted_edit_distance(a: list, b: list, sub, dele, ins) -> float:
306
- """Edit distance of `a` (heard) from `b` (target) with a cost per edit: sub(x, j) for x heard in place of b[j],
307
- dele(j) for b[j] not heard, ins(x, j) for x heard after b[:j] (j == 0 or len(b): at an edge)."""
308
- n = len(b)
309
- d = [0.0] * (n + 1)
310
- for j in range(1, n + 1):
311
- d[j] = d[j - 1] + dele(j - 1)
312
- for x in a:
313
- prev, d[0] = d[0], d[0] + ins(x, 0)
314
- for j in range(1, n + 1):
315
- prev, d[j] = d[j], min(d[j] + ins(x, j), d[j - 1] + dele(j - 1), prev + sub(x, j - 1))
316
- return d[n]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/arabic_diacritics.py DELETED
@@ -1,134 +0,0 @@
1
- """Add Arabic vowel marks to an answer from the pronunciation stored beside it.
2
-
3
- The phrase bank deliberately keeps a plain Arabic spelling and an exact IPA reading.
4
- This module combines them for the learner-facing answer without changing the spelling
5
- used by the scorer or by text-to-speech.
6
- """
7
- from __future__ import annotations
8
-
9
- import re
10
- from functools import lru_cache
11
-
12
- from .archi import _LETTERS, _align, _letters, _units
13
- from .phones import base
14
-
15
- FATHA, DAMMA, KASRA, SUKUN, SHADDA = "\u064e", "\u064f", "\u0650", "\u0652", "\u0651"
16
- _MARKS = re.compile(r"[\u0610-\u061a\u064b-\u065f\u0670\u06d6-\u06ed]")
17
- _VOWELS = {"a", "i", "u", "e", "o", "ə"}
18
- _VOWEL_MARK = {"a": FATHA, "i": KASRA, "e": KASRA, "ə": KASRA, "u": DAMMA, "o": DAMMA}
19
-
20
-
21
- def _vowel(ph: str) -> str | None:
22
- sound = base(ph)
23
- return sound if sound in _VOWELS else None
24
-
25
-
26
- def _point_word(word: str, ipa_word: str) -> str:
27
- plain = _MARKS.sub("", word)
28
- chars = list(plain)
29
- letters = _letters(plain)
30
- phs = ipa_word.split()
31
- units = _units(phs)
32
- if not letters or not units:
33
- return plain
34
-
35
- matched, _cost = _align(letters, [sound for sound, _positions in units])
36
- unit_for_letter: dict[int, int] = {}
37
- for letter_i, hits in matched.items():
38
- real = [hit for hit in hits if hit >= 0]
39
- if len(hits) == 1 and len(real) == 1:
40
- unit_for_letter[letter_i] = real[0]
41
-
42
- if not unit_for_letter:
43
- return plain
44
-
45
- marks: dict[int, list[str]] = {}
46
- ordered = sorted(unit_for_letter.items(), key=lambda item: item[1])
47
- for order_i, (letter_i, unit_i) in enumerate(ordered):
48
- positions = units[unit_i][1]
49
- if len(positions) > 1:
50
- marks.setdefault(letter_i, []).append(SHADDA)
51
-
52
- end = positions[-1] + 1
53
- next_start = units[unit_i + 1][1][0] if unit_i + 1 < len(units) else len(phs)
54
- following = next((_vowel(phs[i]) for i in range(end, next_start) if _vowel(phs[i])), None)
55
- if following:
56
- target = letter_i
57
- next_letter_i = ordered[order_i + 1][0] if order_i + 1 < len(ordered) else len(letters)
58
- between = range(letter_i + 1, next_letter_i)
59
- # A final ta marbuta / h carries its spoken ending (qahwe, madrase).
60
- ending = next((i for i in between if letters[i] in "ةه" and i == len(letters) - 1), None)
61
- if ending is not None:
62
- target = ending
63
- marks.setdefault(target, []).append(_VOWEL_MARK[following])
64
- else:
65
- marks.setdefault(letter_i, []).append(SUKUN)
66
-
67
- # A word-initial vowel belongs on its alif/hamza, before the first aligned consonant.
68
- first_unit = ordered[0][1] if ordered else 0
69
- first_phone = units[first_unit][1][0]
70
- initial = next((_vowel(ph) for ph in phs[:first_phone] if _vowel(ph)), None)
71
- if initial:
72
- first_letter = ordered[0][0]
73
- carrier = next((i for i in range(first_letter + 1) if letters[i] in "اٱأإآء"), first_letter)
74
- marks.setdefault(carrier, []).append(_VOWEL_MARK[initial])
75
-
76
- # In a sun-letter article the lam is silent but is still explicitly vowelless.
77
- if plain.startswith("ال") and 1 not in unit_for_letter:
78
- marks.setdefault(1, []).append(SUKUN)
79
-
80
- output = []
81
- letter_i = 0
82
- for char in chars:
83
- output.append(char)
84
- if char in _LETTERS:
85
- own = marks.get(letter_i, [])
86
- output.extend(mark for mark in (SHADDA if SHADDA in own else "", next((m for m in own if m != SHADDA), "")) if mark)
87
- letter_i += 1
88
- return "".join(output)
89
-
90
-
91
- @lru_cache(maxsize=16384)
92
- def point_arabic(arabic: str, ipa: str) -> str:
93
- """Return the original Arabic spelling with marks for its IPA pronunciation."""
94
- ipa_words = [part.strip() for part in ipa.split("|")]
95
- word_i = 0
96
-
97
- def point(match: re.Match[str]) -> str:
98
- nonlocal word_i
99
- word = match.group(0)
100
- if word_i >= len(ipa_words):
101
- return word
102
- result = _point_word(word, ipa_words[word_i])
103
- word_i += 1
104
- return result
105
-
106
- return re.sub(r"[^\s]+", point, arabic) if len(arabic.split()) == len(ipa_words) else arabic
107
-
108
-
109
- # Spellings for the voice only, never shown: Sawtak reads the pointed هُوِّ / هِيِّ as "hu" / "hi". It reads these as
110
- # huwwe / hiyye: هوّة like the -e of قهوة, هِيِّي with the -e as a written yā'. Checked with the phoneme model
111
- # (the pronoun alone: 58 -> 95 and 65 -> 95).
112
- _TTS_RESPELL = {("هو", "h u w w e"): "هوّة", ("هي", "h i j j e"): "هِيِّي"}
113
-
114
-
115
- def tts_arabic(arabic: str, ipa: str) -> str:
116
- """The text sent to the Arabic voice: point_arabic, with the words it misreads respelled."""
117
- ipa_words = [part.strip() for part in ipa.split("|")]
118
- if len(arabic.split()) != len(ipa_words):
119
- return point_arabic(arabic, ipa)
120
- words = iter(ipa_words)
121
-
122
- def say(match: re.Match[str]) -> str:
123
- word, ipa_word = match.group(0), next(words)
124
- bare = _MARKS.sub("", word)
125
- tail = re.search(r"[؟?!.,،]*$", bare).group(0) # punctuation stuck to the word stays after it
126
- core = bare[:len(bare) - len(tail)] if tail else bare
127
- if core.startswith("و") and ipa_word.startswith("u "): # وهيّ "and she": the و, then the pronoun
128
- respelled = _TTS_RESPELL.get((core[1:], ipa_word[2:]))
129
- if respelled:
130
- return "وُ" + respelled + tail
131
- respelled = _TTS_RESPELL.get((core, ipa_word))
132
- return respelled + tail if respelled else _point_word(word, ipa_word)
133
-
134
- return re.sub(r"[^\s]+", say, arabic)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/archi.py DELETED
@@ -1,182 +0,0 @@
1
- """Abstract symbols for letters with more than one correct pronunciation (section 5 of pronunciation-rules.md).
2
-
3
- phrases.py writes the realisation its author chose (ق as ʔ in قديش, q in القدس). The scorer must accept
4
- the others too (kaddeesh is rural Palestinian), so before tokenising, archify() rewrites each phoneme that
5
- comes from such a letter as "SYMBOL:written" (Q:ʔ), and tokenize() scores it against every correct
6
- realisation at once. The written phoneme is kept for display, tips and calibration keys.
7
-
8
- The letter behind a phoneme is found by aligning the Arabic letters of the word with its IPA consonants.
9
- A phrase can also write a symbol itself: "Q", "Q:k", or with its own set of realisations, "TH{t,s}".
10
- """
11
- from __future__ import annotations
12
-
13
- import re
14
- from functools import lru_cache
15
-
16
- from .phones import base
17
-
18
- # letter -> the symbol its phoneme is rewritten to
19
- LETTER_SYMBOL = {"ق": "Q", "ك": "K", "ث": "TH", "ذ": "DH", "ظ": "ZH", "ج": "J"}
20
-
21
- # letter -> base() names of the phonemes it is said as in the phrase bank (dialects, MSA, careless spelling)
22
- _LETTERS = {
23
- "ب": {"b"}, "ت": {"t"}, "ث": {"t", "s", "θ"}, "ج": {"dʒ", "g"}, "ح": {"ħ"}, "خ": {"x"}, "د": {"d"},
24
- "ذ": {"d", "z", "ð"}, "ر": {"r"}, "ز": {"z"}, "س": {"s"}, "ش": {"ʃ"}, "ص": {"sˤ", "s"}, "ض": {"dˤ", "d"},
25
- "ط": {"tˤ", "t"}, "ظ": {"ðˤ", "dˤ", "zˤ", "z", "ð"}, "ع": {"ʕ"}, "غ": {"ɣ"}, "ف": {"f"}, "ق": {"ʔ", "q", "k", "g"},
26
- "ك": {"k", "tʃ"}, "ل": {"l"}, "م": {"m"}, "ن": {"n"}, "ه": {"h"}, "و": {"w"}, "ي": {"j"}, "ى": set(),
27
- "ء": {"ʔ"}, "أ": {"ʔ"}, "إ": {"ʔ"}, "آ": {"ʔ"}, "ؤ": {"ʔ"}, "ئ": {"ʔ"}, "ا": {"ʔ"}, "ة": {"t"},
28
- }
29
- # letters that are often not said as a consonant at all (vowel letters, hamza seats, the article's ل): cheap to skip
30
- _WEAK = set("اوىيةهءأإآؤئل")
31
- _VOWELS = {"a", "i", "u", "e", "o", "ə"}
32
-
33
- _TOKEN = re.compile(r"^([A-Z]+)(?:\{([^}]*)\})?(?::(.+))?$")
34
-
35
-
36
- def split_token(tok: str) -> tuple[str | None, list[str] | None, str]:
37
- """"Q:ʔ" -> ("Q", None, "ʔ"); "TH{t,s}" -> ("TH", ["t", "s"], "t"); "ʕ" -> (None, None, "ʕ").
38
-
39
- The phoneme of a symbol written without one is its first listed realisation, or "" (the caller picks)."""
40
- m = _TOKEN.match(tok)
41
- if not m:
42
- return None, None, tok
43
- sym, members, written = m.group(1), m.group(2), m.group(3)
44
- members = [p.strip() for p in members.split(",") if p.strip()] if members else None
45
- return sym, members, written or (members[0] if members else "")
46
-
47
-
48
- def written(tok: str) -> str:
49
- """The phoneme a token is written as in the phrase (Q:ʔ -> ʔ)."""
50
- return split_token(tok)[2] or tok
51
-
52
-
53
- def _letters(ar_word: str) -> list[str]:
54
- return [c for c in ar_word if c in _LETTERS]
55
-
56
-
57
- def _units(phs: list[str]) -> list[tuple[str, list[int]]]:
58
- """The consonants of an IPA word, a geminate run as one unit: (base name, its positions)."""
59
- out: list[tuple[str, list[int]]] = []
60
- for i, ph in enumerate(phs):
61
- b = base(ph)
62
- if b in _VOWELS:
63
- continue
64
- if out and out[-1][0] == b and out[-1][1][-1] == i - 1:
65
- out[-1][1].append(i)
66
- else:
67
- out.append((b, [i]))
68
- return out
69
-
70
-
71
- _SKIP_LETTER, _SKIP_WEAK, _SKIP_UNIT, _MISMATCH = 1.0, 0.3, 1.0, 1.6
72
-
73
-
74
- def _align(letters: list[str], units: list[str]) -> tuple[dict[int, set[int]], float]:
75
- """For each letter, the units it matches on some cheapest alignment of letters to consonants.
76
-
77
- A letter matched to different units on two cheapest alignments (or matched on one and skipped on
78
- another) is ambiguous; the caller leaves it alone."""
79
- n, m = len(letters), len(units)
80
- inf = float("inf")
81
-
82
- def cost(i, j):
83
- return 0.0 if units[j] in _LETTERS[letters[i]] else _MISMATCH
84
-
85
- def skip(i):
86
- return _SKIP_WEAK if letters[i] in _WEAK else _SKIP_LETTER
87
-
88
- f = [[inf] * (m + 1) for _ in range(n + 1)]
89
- f[0][0] = 0.0
90
- for i in range(n + 1):
91
- for j in range(m + 1):
92
- if i < n:
93
- f[i + 1][j] = min(f[i + 1][j], f[i][j] + skip(i))
94
- if j < m:
95
- f[i][j + 1] = min(f[i][j + 1], f[i][j] + _SKIP_UNIT)
96
- if i < n and j < m:
97
- f[i + 1][j + 1] = min(f[i + 1][j + 1], f[i][j] + cost(i, j))
98
- b = [[inf] * (m + 1) for _ in range(n + 1)]
99
- b[n][m] = 0.0
100
- for i in range(n, -1, -1):
101
- for j in range(m, -1, -1):
102
- if i < n:
103
- b[i][j] = min(b[i][j], b[i + 1][j] + skip(i))
104
- if j < m:
105
- b[i][j] = min(b[i][j], b[i][j + 1] + _SKIP_UNIT)
106
- if i < n and j < m:
107
- b[i][j] = min(b[i][j], b[i + 1][j + 1] + cost(i, j))
108
- best, eps = f[n][m], 1e-9
109
- matched: dict[int, set[int]] = {}
110
- for i in range(n):
111
- for j in range(m):
112
- if cost(i, j) == 0.0 and abs(f[i][j] + b[i + 1][j + 1] - best) < eps:
113
- matched.setdefault(i, set()).add(j)
114
- # skipping letter i on a cheapest alignment
115
- if any(abs(f[i][j] + skip(i) + b[i + 1][j] - best) < eps for j in range(m + 1)):
116
- matched.setdefault(i, set()).add(-1)
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
126
- letters, units = _letters(ar_word), _units(phs)
127
- matched, _ = _align(letters, [u for u, _ in units])
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:
135
- unresolved.append(letter)
136
- continue
137
- j = next(iter(hits))
138
- if j < 0:
139
- continue # not said at all (a dropped ق): nothing to rewrite
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]]:
161
- """(Arabic word, letter) for every many-ways letter archify() could not place: the phrase must mark it."""
162
- ipa_words, ar_words = ipa.split("|"), ar.split()
163
- if len(ipa_words) != len(ar_words):
164
- return []
165
- return [(a, letter) for w, a in zip(ipa_words, ar_words) for letter in archify_word(w.strip(), a)[1]]
166
-
167
-
168
- def diphthong_variants(ar: str, ipa: str) -> list[str]:
169
- """The same variant with eː said as a j and oː as a w where the Arabic writes ي / و (section 6.3 #8).
170
-
171
- A diphthong is two tokens against one, so it is a variant of its own rather than a class."""
172
- ipa_words, ar_words = ipa.split("|"), ar.split()
173
- if len(ipa_words) != len(ar_words):
174
- return []
175
- out = []
176
- for long, glide, letter in (("eː", "j", "ي"), ("oː", "w", "و")):
177
- words = [" ".join(f"a {glide}" if ph == long and letter in a else ph for ph in w.split())
178
- for w, a in zip(ipa_words, ar_words)]
179
- new = " | ".join(words)
180
- if new != " | ".join(w.strip() for w in ipa_words):
181
- out.append(new)
182
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/auth.py CHANGED
@@ -1,112 +1,20 @@
1
- """Sign-in: the page logs in through Supabase, with Google or with a link sent to the email, and sends the session token with each call.
2
-
3
- The token is checked by asking Supabase who it belongs to (GET /auth/v1/user), so no signing key is
4
- kept here and a session revoked in Supabase stops working within CACHE_TTL.
5
-
6
- Progress: each scored recording is added to the Supabase table `attempts` with the student's own
7
- token, so row-level security ties the row to them (the table's SQL is in the README).
8
-
9
- Teachers: the accounts in TEACHER_EMAILS also get the management panel, which shows every student's
10
- progress. Those rows are read with the project's secret key, which passes row-level security and
11
- never leaves the server.
12
-
13
- Classes: the teacher opens classes in the panel (a name, e.g. "October 2026", the lessons it studies, its
14
- students' emails and an optional nickname of each, and the homework: a topic of its lessons, with the day it is due),
15
- kept in the Supabase table `classes`. A student may be in several. Once there is a class, only the classes'
16
- students and the teachers are let in; before the first one (or without the secret key, which reads the table) any account is.
17
-
18
- Videos: the teacher adds lesson videos in the panel's content part (a lesson, a title and a Vimeo or YouTube link) and moves
19
- each to its place among the lesson's topics, kept in the Supabase table `videos`. A student's path shows them as
20
- topics of their lessons.
21
-
22
- Texts: the teacher also adds texts there (a story or a dialogue: a lesson, a title, its lines in Hebrew and in
23
- Arabic written in Hebrew letters, and a dialogue's two voices), kept in the Supabase table `texts` and placed among
24
- the lesson's topics like the videos. Each is a topic practised line by line (see app/texts.py).
25
-
26
- Daily videos: a library the teacher keeps in the content part, a level's videos each with a title and a Vimeo or
27
- YouTube link, in the Supabase table `dailies`. A class gives a video its day (the class's `dailies`); on that day its
28
- students get a reminder above the path to watch it.
29
-
30
- Library: other videos (a title and a link each), in the Supabase table `library`, one for every level. Every student
31
- browses them from the tab at the screen's foot, whatever their level and class.
32
-
33
- Current lessons: a student tells, once for each class, which lesson the class is on now; the focused home of the page
34
- starts from it. It is the student's own, kept by email and class id in the Supabase table `learner_class_state`,
35
- read and written with the secret key only (the server takes the email from the session, never from the request).
36
-
37
- Tutors: the teacher gives a student the tutor's rank in the panel, in the list of all the students above the classes' tabs. It is the student's,
38
- whatever classes they are in, so it is kept by email in the Supabase table `tutors`.
39
-
40
- Flags: a teacher, a tutor, and an account in TUTORS_EMAIL, gets a flag under each score, for a recording that was said right and scored
41
- wrong. A flag keeps what describes the case in the Supabase table `flags`, and the recording itself in the storage
42
- bucket `flags`, both written with the secret key: the material for changing the scoring, and later for fine-tuning.
43
-
44
- Lab: a teacher's recordings of the lab's diagnostic list (see lab.py), each labelled said right or with one planted
45
- mistake, in the Supabase table `lab_samples` and the storage bucket `lab`, written with the secret key: the
46
- benchmark set of the phoneme recognition.
47
-
48
- Writing: a lesson's written homework (see workbook.py) is kept in the Supabase table `writings`, a row per student
49
- and topic, written with the student's own token like the attempts, so it follows them to any device. The panel
50
- reads every student's with the secret key.
51
-
52
- Tutor: each conversation of the game יאללה נדבר (see tutor.py), what the student said and how the tutor answered, in the
53
- Supabase table `tutor_chats`, written with the secret key, for the teacher to read.
54
-
55
- Env: SUPABASE_URL (https://<project-ref>.supabase.co)
56
- SUPABASE_PUBLISHABLE_KEY (the publishable/anon key; it is public and also goes to the page)
57
- TEACHER_EMAILS (optional: the accounts with the management panel, separated by commas or spaces)
58
- TUTORS_EMAIL (optional: more accounts that flag a wrong score, as the teachers do; separated by commas or spaces)
59
- DEVELOPER_EMAILS (optional bootstrap accounts with full access, including the Lab)
60
- SUPABASE_SECRET_KEY (the secret/service-role key, needed by the panel, the classes and the flags; never public)
61
- Without them the service is open, as before.
62
- """
63
  from __future__ import annotations
64
 
65
  import json
66
- import hashlib
67
  import logging
68
- import mimetypes
69
  import os
70
  import re
71
- import tempfile
72
  import threading
73
  import time
74
  import urllib.error
75
  import urllib.parse
76
  import urllib.request
77
- from datetime import datetime, timezone
78
- from pathlib import Path
79
- from zoneinfo import ZoneInfo
80
 
81
- log = logging.getLogger("ihki.auth")
82
  TIMEOUT = 10
83
- CACHE_TTL = 300 # seconds a checked token is trusted without asking again
84
- CACHE_MAX = 2000
85
- PAGE = 1000 # rows per request: Supabase's default ceiling
86
- MAX_ATTEMPTS = 20_000 # of one student, read for their progress
87
- MAX_CLASS_ATTEMPTS = 200_000 # of all students, read for the teacher's panel
88
- MAX_MONITOR_ATTEMPTS = 5_000 # newest compact attempts sampled for pronunciation patterns; never audio
89
- MAX_DIFFICULTY_ATTEMPTS = 50_000 # bounded scoped metadata; refuse rather than rank a partial sample
90
- CLASSES_TTL = 60 # seconds the class list is trusted without reading it again (each sign-in check uses it)
91
- # One-time migration boundary requested on 24 Oct 2026: the then-current seven days remain in Supabase forever.
92
- DEFAULT_AUDIO_MIGRATION_CUTOFF = "2026-10-17T00:00:00+03:00"
93
- CREATE_LEARNER_CLASS_STATE = (
94
- "create table public.learner_class_state (email text not null, "
95
- "class_id bigint not null references public.classes (id) on delete cascade, current_lesson text not null, "
96
- "created_at timestamptz not null default now(), updated_at timestamptz not null default now(), "
97
- "primary key (email, class_id)); "
98
- "alter table public.learner_class_state enable row level security; "
99
- "revoke all on public.learner_class_state from anon, authenticated;")
100
-
101
- # Application-owned data. Supabase's auth users and managed schemas are deliberately not copied.
102
- BACKUP_TABLES = (
103
- "dailies", "library", "videos", "texts", "classes", "writings", "tutors", "flags",
104
- "lab_samples", "calibrations", "scoring_rules", "user_access", "access_audit", "app_secrets",
105
- "system_events", "tutor_chats", "model_settings",
106
- )
107
- BACKUP_NATURAL_KEYS = {"writings": "user_id,topic", "tutors": "email", "scoring_rules": "id",
108
- "user_access": "user_id", "app_secrets": "name", "tutor_chats": "id",
109
- "model_settings": "model"}
110
 
111
 
112
  class AuthUnavailable(RuntimeError):
@@ -114,1133 +22,127 @@ class AuthUnavailable(RuntimeError):
114
 
115
 
116
  class ClassConflict(ValueError):
117
- """Supabase refused a class: its name is taken."""
118
-
119
-
120
- class HFAudioStore:
121
- """Private durable audio storage in a Hugging Face Storage Bucket."""
122
-
123
- def __init__(self, bucket_id: str, prefix: str = "ihki-audio", token: str | None = None):
124
- self.bucket_id, self.prefix, self.token = bucket_id, prefix.strip("/"), token
125
-
126
- def path(self, bucket: str, name: str) -> str:
127
- rel = f"{bucket}/{name.lstrip('/')}"
128
- return f"{self.prefix}/{rel}" if self.prefix else rel
129
-
130
- def put(self, bucket: str, name: str, data: bytes) -> None:
131
- from huggingface_hub import batch_bucket_files
132
- batch_bucket_files(self.bucket_id, add=[(data, self.path(bucket, name))], token=self.token)
133
-
134
- def get(self, bucket: str, name: str) -> bytes:
135
- from huggingface_hub import download_bucket_files
136
- with tempfile.TemporaryDirectory(prefix="ihki-audio-") as directory:
137
- target = Path(directory) / Path(name).name
138
- download_bucket_files(self.bucket_id, [(self.path(bucket, name), target)],
139
- raise_on_missing_files=True, token=self.token)
140
- return target.read_bytes()
141
-
142
- def delete(self, bucket: str, names: list[str]) -> None:
143
- if not names:
144
- return
145
- from huggingface_hub import batch_bucket_files
146
- batch_bucket_files(self.bucket_id, delete=[self.path(bucket, name) for name in names], token=self.token)
147
 
148
 
149
  class SupabaseAuth:
150
  def __init__(self, url: str, key: str, teachers: frozenset[str] = frozenset(), secret: str = "",
151
- tutors: frozenset[str] = frozenset(), developers: frozenset[str] = frozenset(),
152
- audio_store: HFAudioStore | None = None, audio_migrate_before: str = ""):
153
- self.url, self.key = url.rstrip("/"), key
154
- self.teachers = teachers # lower-case emails of the accounts with the management panel
155
- self.tutors = tutors # lower-case emails of the accounts that flag a wrong score, besides the teachers
156
- self.developers = developers # bootstrap accounts; only developers may use the lab
157
- self.secret = secret # reads every student's rows and the classes; empty = the panel has no data
158
- self.audio_store = audio_store
159
- self.audio_migrate_before = audio_migrate_before
160
- self._cache: dict[str, tuple[float, dict]] = {}
161
  self._classes: tuple[float, list[dict]] | None = None
162
- self._videos: tuple[float, list[dict]] | None = None
163
- self._texts: tuple[float, list[dict]] | None = None
164
- self._ranked: tuple[float, frozenset[str] | None] | None = None
165
  self._access: dict[str, tuple[float, dict | None]] = {}
166
- self._level_videos: dict[str, tuple[float, list[dict]]] = {} # by table: the daily videos, the library
167
  self._lock = threading.Lock()
168
 
169
  def user(self, token: str) -> dict | None:
170
- """The signed-in user of this access token ({"id", "email"}), or None when it is not valid."""
171
  now = time.monotonic()
172
  with self._lock:
173
- hit = self._cache.get(token)
174
- if hit and hit[0] > now:
175
- return hit[1]
176
- req = urllib.request.Request(
177
- self.url + "/auth/v1/user",
178
- headers={"Authorization": f"Bearer {token}", "apikey": self.key, "User-Agent": "ihki/1.0"},
179
- )
180
  try:
181
- with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
182
- data = json.load(res)
183
- except urllib.error.HTTPError as e:
184
- if e.code in (400, 401, 403):
185
  return None
186
- raise AuthUnavailable(f"supabase answered {e.code}") from e
187
- except (urllib.error.URLError, TimeoutError, ValueError) as e:
188
- raise AuthUnavailable(str(e)) from e
189
  if not data.get("id"):
190
  return None
191
  user = {"id": data["id"], "email": data.get("email", "")}
192
  with self._lock:
193
- if len(self._cache) >= CACHE_MAX:
194
- self._cache.clear()
195
- self._cache[token] = (now + CACHE_TTL, user)
196
  return user
197
 
198
- def record(self, token: str, row: dict) -> None:
199
- """Adds one row to `attempts` as the student of this token. A failure is logged, never raised."""
200
- def request(values: dict):
201
- return urllib.request.Request(
202
- self.url + "/rest/v1/attempts", data=json.dumps(values).encode(), method="POST",
203
- headers={"Authorization": f"Bearer {token}", "apikey": self.key, "Content-Type": "application/json",
204
- "Prefer": "return=minimal", "User-Agent": "ihki/1.0"})
205
- try:
206
- urllib.request.urlopen(request(row), timeout=TIMEOUT).close()
207
- except urllib.error.HTTPError as e:
208
- if e.code == 400 and any(k in row for k in ("issues", "model", "version")):
209
- legacy = {k: v for k, v in row.items() if k not in ("issues", "model", "version")}
210
- try:
211
- urllib.request.urlopen(request(legacy), timeout=TIMEOUT).close()
212
- log.warning("attempt stored without issue diagnostics; migrate the attempts table (see README)")
213
- return
214
- except (urllib.error.URLError, TimeoutError) as retry:
215
- e = retry
216
- log.warning("attempt not recorded: %s", e)
217
- except (urllib.error.URLError, TimeoutError) as e: # HTTPError is a URLError
218
- log.warning("attempt not recorded: %s", e)
219
-
220
- def attempts(self, token: str) -> list[dict]:
221
- """The student's non-empty scored recordings, newest first: cat, phrase, score, unrelated, created_at.
222
- Row-level security limits them to the token's student. What cannot be read is logged."""
223
- rows: list[dict] = []
224
- while len(rows) < MAX_ATTEMPTS:
225
- query = urllib.parse.urlencode({"select": "cat,phrase,score,unrelated,created_at",
226
- "order": "created_at.desc", "limit": PAGE, "offset": len(rows)})
227
- try:
228
- batch = self._get(f"/rest/v1/attempts?{query}", token, self.key)
229
- except (urllib.error.URLError, TimeoutError, ValueError) as e:
230
- log.warning("attempts not read: %s", e)
231
- break
232
- rows += batch
233
- if len(batch) < PAGE:
234
- break
235
- return rows
236
-
237
- def writings(self, token: str) -> dict[str, list[str]]:
238
- """The student's written homework, {topic id: its lines}. Row-level security limits it to the token's student.
239
- Before the table is created there is none; any other failure is raised, so the page keeps its own copy."""
240
- try:
241
- rows = self._get("/rest/v1/writings?select=topic,lines", token, self.key)
242
- except urllib.error.HTTPError as e:
243
- if e.code == 404:
244
- log.warning("the table `writings` is missing (see the README): written homework stays on the device")
245
- raise AuthUnavailable("the table writings is missing") from e
246
- raise AuthUnavailable(f"supabase answered {e.code}") from e
247
- except (urllib.error.URLError, TimeoutError, ValueError) as e:
248
- raise AuthUnavailable(str(e)) from e
249
- return {r["topic"]: [str(x) for x in r["lines"]] for r in rows if isinstance(r.get("lines"), list)}
250
-
251
- def save_writing(self, token: str, user_id: str, topic: str, lines: list[str]) -> None:
252
- """Keeps a topic's lines as the student of this token, in place of what they wrote in it before."""
253
- row = {"user_id": user_id, "topic": topic, "lines": lines, "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
254
- req = urllib.request.Request(
255
- self.url + "/rest/v1/writings?on_conflict=user_id,topic", data=json.dumps(row).encode(), method="POST",
256
- headers={"Authorization": f"Bearer {token}", "apikey": self.key, "Content-Type": "application/json",
257
- "Prefer": "resolution=merge-duplicates,return=minimal", "User-Agent": "ihki/1.0"})
258
- try:
259
- urllib.request.urlopen(req, timeout=TIMEOUT).close()
260
- except urllib.error.HTTPError as e:
261
- raise AuthUnavailable(f"supabase answered {e.code}: {e.read().decode(errors='replace')[:300]}") from e
262
- except (urllib.error.URLError, TimeoutError) as e:
263
- raise AuthUnavailable(str(e)) from e
264
-
265
- def all_writings(self) -> list[dict] | None:
266
- """Every student's written homework, for the panel: user_id, topic, lines. Before the table is created it is
267
- None: nothing a student writes is kept, which the panel says."""
268
- try:
269
- return self._admin(f"/rest/v1/writings?select=user_id,topic,lines&limit={PAGE * 10}")
270
- except AuthUnavailable as e:
271
- if "answered 404" not in str(e):
272
- raise
273
- log.warning("the table `writings` is missing (see the README): the panel shows no written homework")
274
- return None
275
-
276
- def _get(self, path: str, token: str, key: str):
277
- req = urllib.request.Request(
278
- self.url + path, headers={"Authorization": f"Bearer {token}", "apikey": key, "User-Agent": "ihki/1.0"})
279
- with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
280
- return json.load(res)
281
-
282
- def _admin(self, path: str, method: str = "GET", body: dict | None = None, prefer: str = "return=representation",
283
- timeout: int = TIMEOUT):
284
- """A call with the secret key, for the teacher's panel. Unlike a student's own progress, a failure is raised."""
285
  if not self.secret:
286
  raise AuthUnavailable("SUPABASE_SECRET_KEY is not set")
287
- req = urllib.request.Request(
288
- self.url + path, method=method, data=json.dumps(body).encode() if body is not None else None,
289
- headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret, "Content-Type": "application/json",
290
- "Prefer": prefer, "User-Agent": "ihki/1.0"})
291
- try:
292
- with urllib.request.urlopen(req, timeout=timeout) as res:
293
- return json.loads(res.read() or b"null")
294
- except urllib.error.HTTPError as e:
295
- detail = e.read().decode(errors="replace")[:300]
296
- if e.code == 409:
297
- raise ClassConflict(detail) from e
298
- raise AuthUnavailable(f"supabase answered {e.code}: {detail}") from e
299
- except (urllib.error.URLError, TimeoutError, ValueError) as e:
300
- raise AuthUnavailable(str(e)) from e
301
-
302
- def backup_rows(self) -> dict[str, list[dict]]:
303
- """All application-owned table rows. Tables not installed in an older deployment are omitted."""
304
- result: dict[str, list[dict]] = {}
305
- for table in BACKUP_TABLES:
306
- rows = []
307
- try:
308
- for offset in range(0, 10_000_000, PAGE):
309
- path = f"/rest/v1/{table}?select=*&limit={PAGE}&offset={offset}"
310
- for attempt in range(3):
311
- try:
312
- batch = self._admin(path, timeout=60) or []
313
- break
314
- except AuthUnavailable as e:
315
- if attempt == 2 or not any(x in str(e).lower() for x in ("timed out", "timeout")):
316
- raise
317
- rows.extend(batch)
318
- if len(batch) < PAGE:
319
- break
320
- result[table] = rows
321
- except AuthUnavailable as e:
322
- if "answered 404" not in str(e):
323
- raise
324
- return result
325
-
326
- def final_scores(self) -> list[dict]:
327
- """One compact row per student and exercise: the highest score that drives the panel's final grades."""
328
- best: dict[tuple[str, str, str], dict] = {}
329
- for offset in range(0, MAX_CLASS_ATTEMPTS, PAGE):
330
- query = urllib.parse.urlencode({"select": "user_id,cat,phrase,score,created_at",
331
- "order": "id.asc", "limit": PAGE, "offset": offset})
332
- path = f"/rest/v1/attempts?{query}"
333
- for attempt in range(3):
334
- try:
335
- batch = self._admin(path, timeout=60) or []
336
- break
337
- except AuthUnavailable as e:
338
- if attempt == 2 or not any(x in str(e).lower() for x in ("timed out", "timeout")):
339
- raise
340
- for row in batch:
341
- key = (str(row["user_id"]), str(row["cat"]), str(row["phrase"]))
342
- if key not in best or int(row["score"]) > int(best[key]["score"]):
343
- best[key] = row
344
- if len(batch) < PAGE:
345
- break
346
- return list(best.values())
347
-
348
- def restore_final_scores(self, rows: list[dict]) -> int:
349
- """Restore only grades that improve the currently stored best score, without restoring attempt history."""
350
- current = {(r["user_id"], r["cat"], r["phrase"]): int(r["score"]) for r in self.final_scores()}
351
- add = [{**r, "unrelated": False} for r in rows
352
- if int(r.get("score", -1)) > current.get((r.get("user_id"), r.get("cat"), r.get("phrase")), -1)]
353
- for start in range(0, len(add), 200):
354
- self._admin("/rest/v1/attempts", "POST", add[start:start + 200], prefer="return=minimal", timeout=60)
355
- return len(add)
356
-
357
- def restore_rows(self, tables: dict[str, list[dict]]) -> dict[str, int]:
358
- """Merge a validated application backup. Existing rows are updated; unrelated current rows remain."""
359
- restored: dict[str, int] = {}
360
- text_ids: dict[int, int] = {}
361
- daily_ids: dict[int, int] = {}
362
- for table in BACKUP_TABLES:
363
- rows = [dict(r) for r in tables.get(table, [])]
364
- if not rows:
365
- continue
366
- if table == "classes":
367
- for row in rows:
368
- row["dailies"] = [{**d, "video": daily_ids.get(d.get("video"), d.get("video"))}
369
- for d in (row.get("dailies") or [])]
370
- for field in ("homework",):
371
- value = row.get(field, "")
372
- if isinstance(value, str) and value.startswith("t") and value[1:].isdigit():
373
- row[field] = "t" + str(text_ids.get(int(value[1:]), int(value[1:])))
374
- row["homeworks"] = [{**h, "topic": "t" + str(text_ids.get(int(h["topic"][1:]), int(h["topic"][1:])))
375
- if isinstance(h.get("topic"), str) and h["topic"].startswith("t") and h["topic"][1:].isdigit()
376
- else h.get("topic")} for h in (row.get("homeworks") or [])]
377
- if table == "writings":
378
- field = "topic"
379
- for row in rows:
380
- value = row.get(field, "")
381
- if isinstance(value, str) and value.startswith("t") and value[1:].isdigit():
382
- row[field] = "t" + str(text_ids.get(int(value[1:]), int(value[1:])))
383
- if table in BACKUP_NATURAL_KEYS:
384
- key = BACKUP_NATURAL_KEYS[table]
385
- for start in range(0, len(rows), 200):
386
- self._admin(f"/rest/v1/{table}?on_conflict={key}", "POST", rows[start:start + 200],
387
- prefer="resolution=merge-duplicates,return=minimal")
388
- restored[table] = len(rows)
389
- continue
390
- for original in rows:
391
- row = dict(original)
392
- old_id = row.get("id")
393
- if old_id is not None:
394
- patch = dict(row)
395
- patch.pop("id", None)
396
- existing = self._admin(f"/rest/v1/{table}?id=eq.{urllib.parse.quote(str(old_id))}", "PATCH", patch)
397
- if existing:
398
- new_id = old_id
399
- else:
400
- row.pop("id", None) # identity columns reject explicitly supplied values
401
- made = self._admin(f"/rest/v1/{table}", "POST", row)
402
- new_id = made[0].get("id") if made else None
403
- if table == "texts" and new_id is not None:
404
- text_ids[int(old_id)] = int(new_id)
405
- if table == "dailies" and new_id is not None:
406
- daily_ids[int(old_id)] = int(new_id)
407
- else:
408
- self._admin(f"/rest/v1/{table}", "POST", row, prefer="return=minimal")
409
- restored[table] = len(rows)
410
- self._classes = self._videos = self._texts = self._ranked = None
411
- self._level_videos.clear()
412
- self._access.clear()
413
- return restored
414
 
415
  def classes(self, fresh: bool = False) -> list[dict]:
416
- """Every class, newest first: id, name, emails (lower-case), nicknames ({email: nickname}), lessons (lesson ids;
417
- empty in a class saved before classes had lessons), homeworks ([{"topic": a topic id, "due": its ISO date or
418
- None}], one for each topic the class has as homework), homework and due (the first of them, as a class had
419
- before it could have several; a class saved then has only these), dailies ([{"video": the id of a daily
420
- video, "date": the ISO date the class watches it}]), created_at.
421
- Kept for CLASSES_TTL, as each sign-in
422
- check asks. Without the secret key, or before the table is created, there are none."""
423
  now = time.monotonic()
424
  with self._lock:
425
- hit = self._classes
426
- if hit and hit[0] > now and not fresh:
427
- return hit[1]
428
  rows: list[dict] = []
429
  if self.secret:
430
  try:
431
- # every column: a table made before the nicknames still lets its students in
432
- rows = self._admin("/rest/v1/classes?select=*&order=created_at.desc")
433
- for r in rows:
434
- r["nicknames"], r["lessons"] = r.get("nicknames") or {}, r.get("lessons") or []
435
- r["homework"], r["due"] = r.get("homework") or "", r.get("due") or None
436
- r["homeworks"], r["dailies"] = r.get("homeworks") or [], r.get("dailies") or []
437
- r["teachers"] = [str(e).lower() for e in (r.get("teachers") or [])]
438
- except AuthUnavailable as e:
439
- if "answered 404" not in str(e):
440
  raise
441
- log.warning("the table `classes` is missing (see the README): every account is let in")
 
 
 
 
 
 
 
442
  with self._lock:
443
  self._classes = (now + CLASSES_TTL, rows)
444
  return rows
445
 
446
- def database_health(self) -> bool:
447
- """Perform one bounded, read-only PostgREST probe without returning table data."""
448
- if not self.secret:
449
- return False
450
- try:
451
- self._admin("/rest/v1/classes?select=id&limit=1")
452
- except AuthUnavailable as exc:
453
- if "answered 404" not in str(exc):
454
- raise
455
- return True
456
-
457
- def save_class(self, id: int | None, name: str, emails: list[str], nicknames: dict[str, str],
458
- lessons: list[str], homeworks: list[dict] = (), dailies: list[dict] | None = None,
459
- subject: str = "", teachers: list[str] | None = None) -> None:
460
- """Adds a class (id None) or replaces one's name, emails, nicknames, lessons and homeworks ([{"topic", "due"}]),
461
- and its daily videos' days ([{"video", "date"}]; None leaves them as they are, so a table made before them
462
- still takes a class that has none). A name already taken raises ClassConflict."""
463
- first = homeworks[0] if homeworks else {"topic": "", "due": None} # the columns of a class with one homework
464
  body = {"name": name, "emails": emails, "nicknames": nicknames, "lessons": lessons,
465
- "homework": first["topic"], "due": first["due"], "homeworks": list(homeworks)}
466
- if dailies is not None:
467
- body["dailies"] = list(dailies)
468
- if teachers is not None:
469
- body["teachers"] = list(teachers)
470
- if id is None:
471
- body["subject"] = subject
472
  self._admin("/rest/v1/classes", "POST", body)
473
- elif not self._admin(f"/rest/v1/classes?id=eq.{int(id)}", "PATCH", body):
474
- raise KeyError(id)
475
  self._classes = None
476
 
477
- def delete_class(self, id: int) -> None:
478
- self._admin(f"/rest/v1/classes?id=eq.{int(id)}", "DELETE")
479
  self._classes = None
480
 
481
- def videos(self, fresh: bool = False) -> list[dict]:
482
- """Every lesson video, each lesson's in the order the teacher gave them: id, lesson (a lesson id), title, url
483
- (the Vimeo or YouTube link), pos (how many of the lesson's own topics stand before it; None in a table made before videos
484
- had places: after them all) and ord (its turn among the videos of one place).
485
- Kept for CLASSES_TTL, as the path asks. Without the secret key, or before the table is created, there are none."""
486
- now = time.monotonic()
487
- with self._lock:
488
- hit = self._videos
489
- if hit and hit[0] > now and not fresh:
490
- return hit[1]
491
- rows: list[dict] = []
492
- if self.secret:
493
- try:
494
- # every column: a table made before the places still shows its videos
495
- rows = self._admin("/rest/v1/videos?select=*&order=id.asc")
496
- for r in rows:
497
- r["pos"], r["ord"] = r.get("pos"), r.get("ord") or 0
498
- except AuthUnavailable as e:
499
- if "answered 404" not in str(e):
500
- raise
501
- log.warning("the table `videos` is missing (see the README): the lessons have no videos")
502
- with self._lock:
503
- self._videos = (now + CLASSES_TTL, rows)
504
- return rows
505
-
506
- def add_video(self, lesson: str, title: str, url: str, pos: int, ord: int) -> None:
507
- self._admin("/rest/v1/videos", "POST", {"lesson": lesson, "title": title, "url": url, "pos": pos, "ord": ord})
508
- self._videos = None
509
-
510
- def place(self, places: list[tuple[str, int, int, int]]) -> None:
511
- """Moves videos and texts: (table, id, pos, ord) of each; the table is "videos" or "texts"."""
512
- try:
513
- for table, id, pos, ord in places:
514
- assert table in ("videos", "texts")
515
- self._admin(f"/rest/v1/{table}?id=eq.{int(id)}", "PATCH", {"pos": pos, "ord": ord})
516
- finally:
517
- self._videos = self._texts = None
518
-
519
- def texts(self, fresh: bool = False) -> list[dict]:
520
- """Every text the teacher added: id, lesson (a lesson id), title, voices (a dialogue's two voices by name,
521
- the odd lines' and the even lines'; empty for a story), lines ([Hebrew, Arabic in Hebrew letters] each), and
522
- pos and ord as a video has them (see `videos`).
523
- Kept for CLASSES_TTL, as each score asks. Without the secret key, or before the table is created, there are none."""
524
- now = time.monotonic()
525
- with self._lock:
526
- hit = self._texts
527
- if hit and hit[0] > now and not fresh:
528
- return hit[1]
529
- rows: list[dict] = []
530
- if self.secret:
531
- try:
532
- rows = self._admin("/rest/v1/texts?select=*&order=id.asc")
533
- for r in rows:
534
- r["voices"], r["ord"] = r.get("voices") or [], r.get("ord") or 0
535
- except AuthUnavailable as e:
536
- if "answered 404" not in str(e):
537
- raise
538
- log.warning("the table `texts` is missing (see the README): the lessons have no texts")
539
- with self._lock:
540
- self._texts = (now + CLASSES_TTL, rows)
541
- return rows
542
-
543
- def add_text(self, lesson: str, title: str, voices: list[str], lines: list[list[str]], pos: int, ord: int) -> None:
544
- self._admin("/rest/v1/texts", "POST", {"lesson": lesson, "title": title, "voices": voices, "lines": lines,
545
- "pos": pos, "ord": ord})
546
- self._texts = None
547
-
548
- def delete_text(self, id: int) -> None:
549
- self._admin(f"/rest/v1/texts?id=eq.{int(id)}", "DELETE")
550
- self._texts = None
551
-
552
- def delete_video(self, id: int) -> None:
553
- self._admin(f"/rest/v1/videos?id=eq.{int(id)}", "DELETE")
554
- self._videos = None
555
-
556
- def level_videos(self, table: str, fresh: bool = False) -> list[dict]:
557
- """A level's videos that stand outside the path, in the order the teacher gave them (`ord`, see
558
- `order_level_videos`) and then, those never moved, in the order they were added: id, level (a level id), title,
559
- url (the Vimeo or YouTube link). `table` is "dailies" (the daily videos) or "library" (the video library,
560
- which is one for every level: its `level` is empty). Kept for CLASSES_TTL. Without the secret key, or before the table is created, there are none."""
561
- assert table in ("dailies", "library")
562
- now = time.monotonic()
563
- with self._lock:
564
- hit = self._level_videos.get(table)
565
- if hit and hit[0] > now and not fresh:
566
- return hit[1]
567
- rows: list[dict] = []
568
- if self.secret:
569
- try:
570
- rows = self._admin(f"/rest/v1/{table}?select=*&order=id.asc")
571
- rows.sort(key=lambda r: (r.get("ord") is None, r.get("ord") or 0, r["id"]))
572
- except AuthUnavailable as e:
573
- if "answered 404" not in str(e):
574
- raise
575
- log.warning("the table `%s` is missing (see the README): it has no videos", table)
576
- with self._lock:
577
- self._level_videos[table] = (now + CLASSES_TTL, rows)
578
- return rows
579
-
580
- def add_level_video(self, table: str, level: str, title: str, url: str) -> None:
581
- assert table in ("dailies", "library")
582
- self._admin(f"/rest/v1/{table}", "POST", {"level": level, "title": title, "url": url})
583
- self._level_videos.pop(table, None)
584
-
585
- def delete_level_video(self, table: str, id: int) -> None:
586
- assert table in ("dailies", "library")
587
- self._admin(f"/rest/v1/{table}?id=eq.{int(id)}", "DELETE")
588
- self._level_videos.pop(table, None)
589
-
590
- def rename_level_video(self, table: str, id: int, title: str) -> None:
591
- assert table in ("dailies", "library")
592
- try:
593
- self._admin(f"/rest/v1/{table}?id=eq.{int(id)}", "PATCH", {"title": title})
594
- finally:
595
- self._level_videos.pop(table, None)
596
-
597
- def order_level_videos(self, table: str, ids: list[int]) -> None:
598
- """Puts the table's videos in this order: each one's `ord` is its turn."""
599
- assert table in ("dailies", "library")
600
- try:
601
- for k, id in enumerate(ids):
602
- self._admin(f"/rest/v1/{table}?id=eq.{int(id)}", "PATCH", {"ord": k})
603
- finally:
604
- self._level_videos.pop(table, None)
605
-
606
- def ranked(self, fresh: bool = False) -> frozenset[str] | None:
607
- """The lower-case emails the teacher gave the tutor's rank (the table `tutors`). Kept for CLASSES_TTL, as each
608
- sign-in check asks. None before the table is created; without the secret key there are none."""
609
- now = time.monotonic()
610
- with self._lock:
611
- hit = self._ranked
612
- if hit and hit[0] > now and not fresh:
613
- return hit[1]
614
- emails: frozenset[str] | None = frozenset()
615
- if self.secret:
616
- try:
617
- emails = frozenset(r["email"].lower() for r in self._admin("/rest/v1/tutors?select=email"))
618
- except AuthUnavailable as e:
619
- if "answered 404" not in str(e):
620
- raise
621
- log.warning("the table `tutors` is missing (see the README): no student has the tutor's rank")
622
- emails = None
623
- with self._lock:
624
- self._ranked = (now + CLASSES_TTL, emails)
625
- return emails
626
-
627
- def set_tutor(self, email: str, on: bool, by: str) -> None:
628
- """Gives the account of this email the tutor's rank, or takes it back. `by` is the teacher who gave it."""
629
- email = email.lower()
630
- try:
631
- if on:
632
- self._admin("/rest/v1/tutors", "POST", {"email": email, "granted_by": by})
633
- else:
634
- self._admin(f"/rest/v1/tutors?email=eq.{urllib.parse.quote(email)}", "DELETE")
635
- except ClassConflict: # has it already
636
- pass
637
- finally:
638
- self._ranked = None
639
-
640
- def current_lessons(self, email: str) -> dict[int, str]:
641
- """The current lesson this account chose in each of its classes: {class id: lesson id}. Empty without the
642
- secret key or before the table is created (the page then keeps the choice on the device); other failures raise."""
643
- if not self.secret:
644
- return {}
645
- q = urllib.parse.urlencode({"select": "class_id,current_lesson", "email": f"eq.{email.strip().lower()}"})
646
- try:
647
- rows = self._admin(f"/rest/v1/learner_class_state?{q}") or []
648
- except AuthUnavailable as e:
649
- if "answered 404" not in str(e):
650
- raise
651
- log.warning("the table `learner_class_state` is missing (see the README): current lessons stay on the device")
652
- return {}
653
- return {int(r["class_id"]): str(r["current_lesson"]) for r in rows if r.get("current_lesson")}
654
-
655
- def set_current_lesson(self, email: str, class_id: int, lesson: str) -> None:
656
- """Keeps this account's current lesson in one class, replacing the one it had there. Only the account's own row:
657
- the caller passes the session's email. A missing table raises with the command that creates it."""
658
- row = {"email": email.strip().lower(), "class_id": int(class_id), "current_lesson": str(lesson).strip(),
659
- "updated_at": datetime.now(timezone.utc).isoformat()}
660
- try:
661
- self._admin("/rest/v1/learner_class_state?on_conflict=email,class_id", "POST", row,
662
- prefer="resolution=merge-duplicates,return=minimal")
663
- except AuthUnavailable as e:
664
- if "answered 404" in str(e):
665
- raise AuthUnavailable("the table learner_class_state is missing; run in Supabase's SQL editor: "
666
- f"{CREATE_LEARNER_CLASS_STATE}") from e
667
- raise
668
-
669
- def _put_object(self, bucket: str, name: str, data: bytes, mime: str) -> bool:
670
- """Stores a recording in a private storage bucket; False (logged) when it cannot be stored."""
671
- if not self.secret:
672
- return False
673
- req = urllib.request.Request(
674
- f"{self.url}/storage/v1/object/{bucket}/{urllib.parse.quote(name)}", data=data, method="POST",
675
- headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret, "Content-Type": mime,
676
- "User-Agent": "ihki/1.0"})
677
- try:
678
- urllib.request.urlopen(req, timeout=TIMEOUT).close()
679
- return True
680
- except (urllib.error.URLError, TimeoutError) as e: # HTTPError is a URLError
681
- log.warning("a recording not stored (is there a storage bucket `%s`? see the README): %s", bucket, e)
682
- return False
683
-
684
- def _delete_objects(self, bucket: str, names: list[str]) -> bool:
685
- """Removes recordings of a private storage bucket; False (logged) when some could not be removed."""
686
- ok = True
687
- for k in range(0, len(names), 1000): # the storage API takes up to 1000 names a call
688
- req = urllib.request.Request(
689
- f"{self.url}/storage/v1/object/{bucket}", method="DELETE",
690
- data=json.dumps({"prefixes": names[k:k + 1000]}).encode(),
691
- headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret,
692
- "Content-Type": "application/json", "User-Agent": "ihki/1.0"})
693
- try:
694
- urllib.request.urlopen(req, timeout=TIMEOUT).close()
695
- except (urllib.error.URLError, TimeoutError) as e:
696
- log.warning("recordings of the storage bucket `%s` not removed: %s", bucket, e)
697
- ok = False
698
- return ok
699
-
700
- def _get_object(self, bucket: str, name: str) -> tuple[bytes, str]:
701
- """A recording of a private storage bucket and its media type."""
702
- req = urllib.request.Request(
703
- f"{self.url}/storage/v1/object/{bucket}/{urllib.parse.quote(name)}",
704
- headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret, "User-Agent": "ihki/1.0"})
705
- try:
706
- with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
707
- data = res.read()
708
- media_type = res.headers.get_content_type()
709
- except (urllib.error.URLError, TimeoutError) as e:
710
- raise AuthUnavailable(f"{bucket} audio unavailable: {e}") from e
711
- if media_type == "application/octet-stream":
712
- media_type = mimetypes.guess_type(name)[0] or media_type
713
- return data, media_type
714
-
715
- def _put_audio(self, bucket: str, name: str, data: bytes, mime: str) -> bool:
716
- """Keep audio in HF when configured; Supabase is the legacy/default store only."""
717
- if self.audio_store:
718
- try:
719
- self.audio_store.put(bucket, name, data)
720
- return True
721
- except Exception as e:
722
- log.warning("%s audio not stored in Hugging Face bucket: %s", bucket, e)
723
- return False
724
- return self._put_object(bucket, name, data, mime)
725
-
726
- def _get_audio(self, bucket: str, name: str, created_at: str = "") -> tuple[bytes, str]:
727
- """Read new HF audio first, then fall back to legacy Supabase recordings."""
728
- sources = ("hf", "supabase") if self.audio_store else ("supabase",)
729
- errors = []
730
- for source in sources:
731
- if source == "hf":
732
- if not self.audio_store:
733
- continue
734
- try:
735
- return self.audio_store.get(bucket, name), mimetypes.guess_type(name)[0] or "application/octet-stream"
736
- except Exception as e:
737
- errors.append(f"Hugging Face: {e}")
738
- else:
739
- try:
740
- return self._get_object(bucket, name)
741
- except AuthUnavailable as e:
742
- errors.append(f"Supabase: {e}")
743
- raise AuthUnavailable(f"{bucket} audio unavailable ({'; '.join(errors)})")
744
-
745
- def migrate_legacy_audio(self) -> None:
746
- """One-way migration before a fixed cutoff; newer legacy Supabase files remain indefinitely."""
747
- if not self.audio_store or not self.secret or not self.audio_migrate_before:
748
- return
749
- threading.Thread(target=self._migrate_legacy_audio, name="ihki-audio-migration", daemon=True).start()
750
-
751
- def _migrate_legacy_audio(self) -> None:
752
- """Copy pre-cutoff audio to HF, then remove only the successfully copied Supabase objects."""
753
- cutoff = self.audio_migrate_before
754
- marker = hashlib.sha256(cutoff.encode()).hexdigest()[:16] + ".complete"
755
- try:
756
- self.audio_store.get("_migration", marker)
757
- return
758
- except Exception:
759
- pass
760
- complete = True
761
- try:
762
- for table, bucket in (("flags", "flags"), ("lab_samples", "lab")):
763
- rows = []
764
- for offset in range(0, 10_000_000, PAGE):
765
- query = urllib.parse.urlencode({"select": "audio,created_at", "audio": "not.is.null",
766
- "created_at": f"lt.{cutoff}", "order": "created_at.asc",
767
- "limit": PAGE, "offset": offset})
768
- batch = self._admin(f"/rest/v1/{table}?{query}") or []
769
- rows.extend(batch)
770
- if len(batch) < PAGE:
771
- break
772
- copied = []
773
- for row in rows:
774
- name = row.get("audio")
775
- if not name:
776
- continue
777
- try:
778
- data, _mime = self._get_object(bucket, name)
779
- self.audio_store.put(bucket, name, data)
780
- copied.append(name)
781
- except Exception as e:
782
- complete = False
783
- log.warning("old %s audio %s not migrated; Supabase copy retained: %s", bucket, name, e)
784
- if copied and self._delete_objects(bucket, copied):
785
- log.info("migrated %d old %s recordings to Hugging Face and removed Supabase copies",
786
- len(copied), bucket)
787
- if complete:
788
- self.audio_store.put("_migration", marker, cutoff.encode())
789
- except Exception as e:
790
- log.warning("legacy audio migration failed: %s", e)
791
-
792
- def ensure_backup_bucket(self) -> None:
793
- """Create the private application-backup bucket on first use."""
794
- try:
795
- self._admin("/storage/v1/bucket/backups")
796
- except AuthUnavailable as e:
797
- detail = str(e).lower()
798
- if not any(mark in detail for mark in ("answered 404", '"statuscode":"404"', "nosuchbucket", "bucket not found")):
799
- raise
800
- self._admin("/storage/v1/bucket", "POST", {"id": "backups", "name": "backups", "public": False},
801
- prefer="return=minimal")
802
-
803
- def save_backup(self, name: str, data: bytes) -> None:
804
- self.ensure_backup_bucket()
805
- req = urllib.request.Request(
806
- f"{self.url}/storage/v1/object/backups/{urllib.parse.quote(name)}", data=data, method="POST",
807
- headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret,
808
- "Content-Type": "application/zip", "User-Agent": "ihki/1.0"})
809
- try:
810
- urllib.request.urlopen(req, timeout=max(TIMEOUT, 60)).close()
811
- except urllib.error.HTTPError as e:
812
- raise AuthUnavailable(f"supabase storage answered {e.code}: {e.read().decode(errors='replace')[:300]}") from e
813
- except (urllib.error.URLError, TimeoutError) as e:
814
- raise AuthUnavailable(str(e)) from e
815
-
816
- def list_backups(self) -> list[dict]:
817
- self.ensure_backup_bucket()
818
- rows = self._admin("/storage/v1/object/list/backups", "POST",
819
- {"prefix": "", "limit": 100, "offset": 0, "sortBy": {"column": "name", "order": "desc"}})
820
- return [r for r in (rows or []) if str(r.get("name", "")).endswith(".zip")]
821
-
822
- def add_flag(self, row: dict, audio: bytes, mime: str, name: str) -> bool:
823
- """Keeps a flagged score and its recording in HF, or Supabase when HF is not configured."""
824
- kept = self._put_audio("flags", name, audio, mime)
825
- self._admin("/rest/v1/flags", "POST", {**row, "audio": name if kept else None})
826
- return kept
827
-
828
- def list_flags(self, status: str = "pending") -> list[dict]:
829
- """Flagged scores for the teacher's review, newest first."""
830
- query = urllib.parse.urlencode({"select": "*", "status": f"eq.{status}", "order": "created_at.desc"})
831
- return self._admin(f"/rest/v1/flags?{query}")
832
-
833
- def review_flag(self, flag_id: int, values: dict) -> None:
834
- """Records a teacher's decision about one flagged score."""
835
- self._admin(f"/rest/v1/flags?id=eq.{int(flag_id)}", "PATCH", values)
836
-
837
- def flag(self, flag_id: int) -> dict | None:
838
- """One flag's row, or None."""
839
- rows = self._admin(f"/rest/v1/flags?{urllib.parse.urlencode({'select': '*', 'id': f'eq.{int(flag_id)}', 'limit': 1})}")
840
- return rows[0] if rows else None
841
-
842
- def flag_audio(self, flag_id: int) -> tuple[bytes, str] | None:
843
- """Returns one private flag recording and its media type, or None when it was not stored."""
844
- query = urllib.parse.urlencode({"select": "audio,created_at", "id": f"eq.{int(flag_id)}", "limit": 1})
845
- rows = self._admin(f"/rest/v1/flags?{query}")
846
- name = rows[0].get("audio") if rows else None
847
- return self._get_audio("flags", name, rows[0].get("created_at", "")) if name else None
848
-
849
- def add_lab_sample(self, row: dict, audio: bytes, mime: str, name: str, attempts: int = 1) -> bool:
850
- """Keeps a lab recording (see lab.py): the recording in the storage bucket `lab`, the row in the table
851
- `lab_samples`. As with a flag, a recording that cannot be stored leaves the row without it (False).
852
- `attempts` retries the upload (a bulk import outlives the storage's transient 5xx errors)."""
853
- kept = False
854
- for attempt in range(max(1, attempts)):
855
- if attempt:
856
- time.sleep(2 * attempt)
857
- kept = self._put_audio("lab", name, audio, mime)
858
- if kept:
859
- break
860
- self._admin("/rest/v1/lab_samples", "POST", {**row, "audio": name if kept else None}, prefer="return=minimal")
861
- return kept
862
-
863
- def lab_samples(self, full: bool = False) -> list[dict]:
864
- """The lab's recordings, oldest first: what labels each one (and, with `full`, the scorer's whole result)."""
865
- cols = "*" if full else "id,item,speaker,label,mistake,score,heard_ipa,flagged,model,audio,created_at"
866
- # PostgREST caps a response (1000 rows by default), so the table is read in pages by id.
867
- rows, last, page = [], 0, 500 if full else 1000
868
- while True:
869
- query = urllib.parse.urlencode({"select": cols, "order": "id.asc", "id": f"gt.{last}", "limit": page})
870
- batch = self._admin(f"/rest/v1/lab_samples?{query}") or []
871
- rows.extend(batch)
872
- if len(batch) < page:
873
- return rows
874
- last = batch[-1]["id"]
875
-
876
- def lab_sample(self, sample_id: int) -> dict | None:
877
- """One lab recording's row, without the scorer's result, or None."""
878
- query = urllib.parse.urlencode({"select": "id,item,ar,ipa,speaker,label,mistake,audio,result", "id": f"eq.{int(sample_id)}",
879
- "limit": 1})
880
- rows = self._admin(f"/rest/v1/lab_samples?{query}")
881
- return rows[0] if rows else None
882
-
883
- def lab_audio(self, sample_id: int) -> tuple[bytes, str] | None:
884
- """One lab recording and its media type, or None when it was not stored."""
885
- row = self.lab_sample(sample_id)
886
- return self._get_audio("lab", row["audio"], row.get("created_at", "")) if row and row.get("audio") else None
887
-
888
- def update_lab_result(self, sample_id: int, result: dict) -> None:
889
- """Updates a lab result's review metadata (the original scoring fields remain in the same JSON document)."""
890
- self._admin(f"/rest/v1/lab_samples?id=eq.{int(sample_id)}", "PATCH", {"result": result})
891
-
892
- def delete_lab_samples(self, speaker: str | None = None) -> int:
893
- """Deletes the lab's recordings of one speaker ("": the unnamed), or of every speaker when None: the rows,
894
- then their files in the bucket `lab` (a file that cannot be removed is logged and left). Returns how many rows."""
895
- where = {"speaker": f"eq.{speaker}"} if speaker is not None else {"id": "gt.0"} # a DELETE needs a filter
896
- rows = self._admin(f"/rest/v1/lab_samples?{urllib.parse.urlencode({**where, 'select': 'id,audio'})}",
897
- "DELETE") or []
898
- names = [r["audio"] for r in rows if r.get("audio")]
899
- if names:
900
- self._delete_objects("lab", names)
901
- if self.audio_store:
902
- try:
903
- self.audio_store.delete("lab", names)
904
- except Exception as e:
905
- log.warning("lab recordings not deleted from Hugging Face bucket: %s", e)
906
- return len(rows)
907
-
908
- def calibrations(self, status: str | None = None) -> list[dict]:
909
- """Calibration proposals, optionally filtered by their workflow status."""
910
- params = {"select": "*", "order": "created_at.desc"}
911
- if status:
912
- params["status"] = f"eq.{status}"
913
- return self._admin(f"/rest/v1/calibrations?{urllib.parse.urlencode(params)}")
914
-
915
- def add_calibration(self, row: dict) -> dict:
916
- """Creates one draft calibration proposal and returns its stored row."""
917
- made = self._admin("/rest/v1/calibrations", "POST", row)
918
- return made[0] if made else row
919
-
920
- def update_calibration(self, calibration_id: int, values: dict) -> None:
921
- """Updates and optionally applies or rejects a calibration proposal."""
922
- self._admin(f"/rest/v1/calibrations?id=eq.{int(calibration_id)}", "PATCH", values)
923
-
924
- def delete_calibration(self, calibration_id: int) -> bool:
925
- """Deletes a calibration proposal or rule; returns whether it existed."""
926
- return bool(self._admin(f"/rest/v1/calibrations?id=eq.{int(calibration_id)}", "DELETE"))
927
-
928
- def scoring_rules(self) -> list[dict]:
929
- """The teacher's switches over the rules of pronunciation-rules.md: id, active, updated_by, updated_at.
930
- A rule with no row is on."""
931
- return self._admin("/rest/v1/scoring_rules?select=*") or []
932
-
933
- def set_scoring_rule(self, rule_id: str, active: bool, by: str) -> None:
934
- """Switches one rule on or off (an upsert on its id)."""
935
- self._admin("/rest/v1/scoring_rules", "POST", {"id": rule_id, "active": bool(active), "updated_by": by,
936
- "updated_at": datetime.now(timezone.utc).isoformat()},
937
- prefer="resolution=merge-duplicates,return=minimal")
938
-
939
- def model_settings(self) -> list[dict]:
940
- """The scorer settings tuned per model in the panel's "מודלים" tab: model, settings, updated_by, updated_at."""
941
- return self._admin("/rest/v1/model_settings?select=*") or []
942
-
943
- def set_model_settings(self, model: str, settings: dict, by: str) -> None:
944
- """Replaces one model's settings (an upsert on the model id)."""
945
- self._admin("/rest/v1/model_settings", "POST", {"model": model, "settings": settings, "updated_by": by,
946
- "updated_at": datetime.now(timezone.utc).isoformat()},
947
- prefer="resolution=merge-duplicates,return=minimal")
948
-
949
- def users(self) -> list[dict]:
950
- """Every account that has signed in: id, email, name (as Google gave it; empty for one who signed in by email)."""
951
- users: list[dict] = []
952
- for page in range(1, 51):
953
- batch = self._admin(f"/auth/v1/admin/users?page={page}&per_page={PAGE}").get("users", [])
954
- users += [{"id": u["id"], "email": u.get("email") or "",
955
- "name": (u.get("user_metadata") or {}).get("full_name") or ""} for u in batch]
956
- if len(batch) < PAGE:
957
- break
958
- return users
959
-
960
  def access_for(self, user_id: str, fresh: bool = False) -> dict | None:
961
- """The roles and student entitlements assigned to an account. None also means the migration table is absent."""
 
962
  now = time.monotonic()
963
  with self._lock:
964
- hit = self._access.get(user_id)
965
- if not fresh and hit and hit[0] > now:
966
- return hit[1]
967
  try:
968
- rows = self._admin("/rest/v1/user_access?" + urllib.parse.urlencode({
969
- "user_id": f"eq.{user_id}", "select": "user_id,email,roles,entitlements,subject,updated_at"}))
970
  value = rows[0] if rows else None
971
- except AuthUnavailable as e:
972
- if "subject" in str(e).lower():
973
- rows = self._admin("/rest/v1/user_access?" + urllib.parse.urlencode({
974
- "user_id": f"eq.{user_id}", "select": "user_id,email,roles,entitlements,updated_at"}))
975
- value = ({**rows[0], "subject": ""} if rows else None)
976
- elif "answered 404" not in str(e):
977
  raise
978
- else:
979
- value = None
980
  with self._lock:
981
  self._access[user_id] = (now + CLASSES_TTL, value)
982
  return value
983
 
984
- def access_rows(self) -> list[dict] | None:
985
- """All explicit access assignments, or None until the user_access table is installed."""
986
- try:
987
- return self._admin("/rest/v1/user_access?select=user_id,email,roles,entitlements,subject,updated_at") or []
988
- except AuthUnavailable as e:
989
- if "subject" in str(e).lower():
990
- return [{**r, "subject": ""} for r in
991
- (self._admin("/rest/v1/user_access?select=user_id,email,roles,entitlements,updated_at") or [])]
992
- if "answered 404" in str(e):
993
- return None
994
- raise
995
-
996
- def set_access(self, target: dict, roles: list[str], entitlements: list[str], actor: dict) -> None:
997
- """Upsert an assignment and append a best-effort audit record."""
998
- old = self.access_for(target["id"])
999
- row = {"user_id": target["id"], "email": target["email"].lower(), "roles": roles,
1000
- "entitlements": entitlements, "updated_at": datetime.now(timezone.utc).isoformat(),
1001
- "updated_by": actor["id"]}
1002
- self._admin("/rest/v1/user_access?on_conflict=user_id", "POST", row,
1003
- prefer="resolution=merge-duplicates,return=minimal")
1004
- with self._lock:
1005
- self._access.pop(target["id"], None)
1006
- try:
1007
- self._admin("/rest/v1/access_audit", "POST",
1008
- {"target_user_id": target["id"], "changed_by": actor["id"],
1009
- "old_access": old or {}, "new_access": row}, prefer="return=minimal")
1010
- except AuthUnavailable as e:
1011
- log.warning("access changed but audit row was not stored: %s", e)
1012
- self.log_event("permission", actor, target["email"], {"roles": roles, "entitlements": entitlements})
1013
-
1014
- def set_admin_subject(self, target: dict, subject: str, actor: dict) -> None:
1015
- """Set an admin's subject without changing their access assignment."""
1016
- old = self.access_for(target["id"]) or {}
1017
- row = {"user_id": target["id"], "email": target["email"].lower(),
1018
- "roles": old.get("roles") or ["student", "flagger"], "entitlements": old.get("entitlements") or [],
1019
- "subject": subject, "updated_at": datetime.now(timezone.utc).isoformat(),
1020
- "updated_by": actor["id"]}
1021
- self._admin("/rest/v1/user_access?on_conflict=user_id", "POST", row,
1022
- prefer="resolution=merge-duplicates,return=minimal")
1023
- with self._lock:
1024
- self._access.pop(target["id"], None)
1025
- self.log_event("permission", actor, target["email"], {"admin_subject": subject})
1026
-
1027
- def secret_rows(self) -> list[dict] | None:
1028
- """The API keys set in the panel, encrypted (see keystore.py), or None until the table `app_secrets` exists."""
1029
- try:
1030
- return self._admin("/rest/v1/app_secrets?select=name,ciphertext,last4,updated_by,updated_at") or []
1031
- except AuthUnavailable as e:
1032
- if "answered 404" in str(e):
1033
- return None
1034
- raise
1035
-
1036
- def save_secret(self, row: dict) -> None:
1037
- self._admin("/rest/v1/app_secrets?on_conflict=name", "POST",
1038
- {**row, "updated_at": datetime.now(timezone.utc).isoformat()},
1039
- prefer="resolution=merge-duplicates,return=minimal")
1040
-
1041
- def delete_secret(self, name: str) -> None:
1042
- self._admin("/rest/v1/app_secrets?" + urllib.parse.urlencode({"name": f"eq.{name}"}), "DELETE",
1043
- prefer="return=minimal")
1044
-
1045
- def all_attempts(self, limit: int = MAX_CLASS_ATTEMPTS) -> list[dict]:
1046
- """Every student's attempts, as `attempts` gives one student's, each with its user_id. "Different sentence"
1047
- rows remain marked as unrelated for diagnostics, but count toward progress like every non-empty recording."""
1048
- limit = max(1, min(int(limit), MAX_CLASS_ATTEMPTS))
1049
- rows: list[dict] = []
1050
- while len(rows) < limit:
1051
- page = min(PAGE, limit - len(rows))
1052
- query = urllib.parse.urlencode({"select": "user_id,cat,phrase,score,unrelated,created_at",
1053
- "order": "id.desc", "limit": page, "offset": len(rows)})
1054
- batch = self._admin(f"/rest/v1/attempts?{query}")
1055
- rows += batch
1056
- if len(batch) < page:
1057
- break
1058
- return rows
1059
-
1060
- def scoped_attempts(self, user_ids: set[str], since: datetime, until: datetime,
1061
- limit: int = MAX_DIFFICULTY_ATTEMPTS, *,
1062
- include_diagnostics: bool = False) -> tuple[list[dict], bool] | tuple[list[dict], bool, bool]:
1063
- """Bounded attempt metadata for authorized users and ``since <= created_at < until``.
1064
-
1065
- The caller gets at most ``limit`` rows plus a truncation signal. Audio, diagnostics,
1066
- answers, and attempts from other users are never requested.
1067
- """
1068
- ids = sorted({str(user_id) for user_id in user_ids if str(user_id)})
1069
- if not ids:
1070
- return ([], False, True) if include_diagnostics else ([], False)
1071
- limit = max(1, min(int(limit), MAX_DIFFICULTY_ATTEMPTS))
1072
- wanted, rows = limit + 1, []
1073
- while len(rows) < wanted:
1074
- page = min(PAGE, wanted - len(rows))
1075
- fields = "id,user_id,cat,phrase,score,unrelated,created_at"
1076
- if include_diagnostics:
1077
- fields += ",issues,model,version"
1078
- query = urllib.parse.urlencode([
1079
- ("select", fields),
1080
- ("user_id", f"in.({','.join(ids)})"),
1081
- ("created_at", f"gte.{since.astimezone(timezone.utc).isoformat()}"),
1082
- ("created_at", f"lt.{until.astimezone(timezone.utc).isoformat()}"),
1083
- ("order", "created_at.asc,id.asc"), ("limit", page), ("offset", len(rows)),
1084
- ])
1085
- try:
1086
- batch = self._admin(f"/rest/v1/attempts?{query}") or []
1087
- except AuthUnavailable as exc:
1088
- if include_diagnostics and any(name in str(exc) for name in ("issues", "model", "version")):
1089
- return [], False, False
1090
- raise
1091
- rows += batch
1092
- if len(batch) < page:
1093
- break
1094
- result = (rows[:limit], len(rows) > limit)
1095
- return (*result, True) if include_diagnostics else result
1096
-
1097
- def activity(self, hours: int = 24, bucket_minutes: int = 15) -> dict:
1098
- """Aggregated attempt counts for monitoring, without returning student identities.
1099
-
1100
- ``activity_summary`` supplies a distinct-student count for the whole window. Keep the buckets usable with
1101
- older Supabase installations until that small migration is applied.
1102
- """
1103
- buckets = self._admin("/rest/v1/rpc/activity_buckets", "POST",
1104
- {"p_hours": hours, "p_bucket_minutes": bucket_minutes})
1105
- summary = None
1106
- try:
1107
- rows = self._admin("/rest/v1/rpc/activity_summary", "POST", {"p_hours": hours}) or []
1108
- summary = rows[0] if rows else None
1109
- except AuthUnavailable as e:
1110
- if "answered 404" not in str(e):
1111
- raise
1112
- return {"buckets": buckets, "summary": summary}
1113
-
1114
- def scoped_activity(self, user_ids: set[str], since: datetime, bucket_minutes: int = 15) -> dict:
1115
- """Aggregated monitoring data for one admin's students, calculated in Postgres without downloading attempts."""
1116
- if not user_ids:
1117
- return {"buckets": [], "summary": {"attempts": 0, "active_students": 0,
1118
- "returning_students": 0, "active_days": 0}}
1119
- body = {"p_user_ids": sorted(user_ids), "p_since": since.isoformat()}
1120
- try:
1121
- buckets = self._admin("/rest/v1/rpc/activity_buckets_scoped", "POST",
1122
- {**body, "p_bucket_minutes": bucket_minutes})
1123
- rows = self._admin("/rest/v1/rpc/activity_summary_scoped", "POST", body) or []
1124
- return {"buckets": buckets or [], "summary": rows[0] if rows else None}
1125
- except AuthUnavailable as e:
1126
- if "answered 404" not in str(e):
1127
- raise
1128
- log.warning("scoped activity RPCs are missing; using the paged timestamp fallback")
1129
- return self._scoped_activity_fallback(user_ids, since, bucket_minutes)
1130
-
1131
- def _scoped_activity_fallback(self, user_ids: set[str], since: datetime, bucket_minutes: int) -> dict:
1132
- """Accurate compatibility path until the scoped aggregate RPC migration is installed."""
1133
- rows: list[dict] = []
1134
- for offset in range(0, MAX_CLASS_ATTEMPTS, PAGE):
1135
- query = urllib.parse.urlencode({"select": "user_id,created_at", "user_id": f"in.({','.join(sorted(user_ids))})",
1136
- "created_at": f"gte.{since.isoformat()}", "order": "created_at.desc",
1137
- "limit": PAGE, "offset": offset})
1138
- batch = self._admin(f"/rest/v1/attempts?{query}") or []
1139
- rows += batch
1140
- if len(batch) < PAGE:
1141
- break
1142
- seconds, buckets, per_user = max(1, min(bucket_minutes, 60)) * 60, {}, {}
1143
- local = ZoneInfo("Asia/Jerusalem")
1144
- for row in rows:
1145
- stamp = datetime.strptime(row["created_at"][:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
1146
- slot = int(stamp.timestamp()) // seconds * seconds
1147
- item = buckets.setdefault(slot, {"users": set(), "attempts": 0})
1148
- item["attempts"] += 1; item["users"].add(row["user_id"])
1149
- per_user.setdefault(row["user_id"], set()).add(stamp.astimezone(local).date())
1150
- return {"buckets": [{"bucket": datetime.fromtimestamp(slot, timezone.utc).isoformat(),
1151
- "attempts": item["attempts"], "active_students": len(item["users"])}
1152
- for slot, item in sorted(buckets.items())],
1153
- "summary": {"attempts": len(rows), "active_students": len(per_user),
1154
- "returning_students": sum(len(days) >= 2 for days in per_user.values()),
1155
- "active_days": len({day for days in per_user.values() for day in days})}}
1156
-
1157
- def log_event(self, kind: str, user: dict | None, label: str = "", details: dict | None = None) -> None:
1158
- """Best-effort append to the management activity feed; a missing audit table never blocks the action."""
1159
- try:
1160
- self._admin("/rest/v1/system_events", "POST", {
1161
- "kind": kind, "actor_id": user.get("id") if user else None,
1162
- "actor_email": (user or {}).get("email", ""), "label": label[:200], "details": details or {},
1163
- }, prefer="return=minimal")
1164
- except AuthUnavailable as e:
1165
- log.warning("system activity event not stored: %s", e)
1166
-
1167
- def recent_events(self, limit: int = 60) -> list[dict] | None:
1168
- """Newest audit events, or None when the optional table has not been installed yet."""
1169
- query = urllib.parse.urlencode({"select": "kind,actor_email,label,details,created_at",
1170
- "order": "created_at.desc", "limit": max(1, min(limit, 200))})
1171
- try:
1172
- return self._admin(f"/rest/v1/system_events?{query}") or []
1173
- except AuthUnavailable as e:
1174
- if "answered 404" in str(e):
1175
- return None
1176
- raise
1177
-
1178
- def save_tutor_chat(self, row: dict) -> None:
1179
- """Keeps a conversation with the tutor (see tutor.py), in place of what was kept of it before. A failure is
1180
- logged, never raised: the conversation goes on without it."""
1181
- try:
1182
- self._admin("/rest/v1/tutor_chats?on_conflict=id", "POST", row,
1183
- prefer="resolution=merge-duplicates,return=minimal")
1184
- except AuthUnavailable as e:
1185
- log.warning("a tutor conversation not kept%s: %s",
1186
- " (the table `tutor_chats` is missing, see tutor.CREATE_TUTOR_CHATS)" if "answered 404" in str(e) else "", e)
1187
-
1188
- def tutor_chats_since(self, user_id: str, since: str) -> int:
1189
- """How many conversations with the tutor the student started since this ISO time. 0 when they cannot be read."""
1190
- query = urllib.parse.urlencode({"select": "id", "user_id": f"eq.{user_id}", "created_at": f"gte.{since}"})
1191
- try:
1192
- return len(self._admin(f"/rest/v1/tutor_chats?{query}") or [])
1193
- except AuthUnavailable as e:
1194
- log.warning("tutor conversations not counted: %s", e)
1195
- return 0
1196
-
1197
- def all_attempt_issues(self) -> list[dict] | None:
1198
- """Compact phoneme issues from recent attempts. None means the diagnostic columns have not been migrated.
1199
-
1200
- Monitoring only needs a useful recent sample; bounding this query keeps opening the panel independent of the
1201
- full, ever-growing attempt history. Rows are requested newest first.
1202
- """
1203
- rows: list[dict] = []
1204
- while len(rows) < MAX_MONITOR_ATTEMPTS:
1205
- limit = min(PAGE, MAX_MONITOR_ATTEMPTS - len(rows))
1206
- query = urllib.parse.urlencode({"select": "user_id,phrase,score,issues,model,version",
1207
- "unrelated": "is.false", "order": "id.desc",
1208
- "limit": limit, "offset": len(rows)})
1209
- try:
1210
- batch = self._admin(f"/rest/v1/attempts?{query}")
1211
- except AuthUnavailable as e:
1212
- if any(name in str(e) for name in ("issues", "model", "version")):
1213
- return None
1214
- raise
1215
- rows += batch
1216
- if len(batch) < limit:
1217
- break
1218
- return rows
1219
-
1220
 
1221
  def load_auth() -> SupabaseAuth | None:
1222
  url = os.environ.get("SUPABASE_URL", "").strip()
1223
  key = os.environ.get("SUPABASE_PUBLISHABLE_KEY", "").strip()
1224
  if not url or not key:
1225
- missing = [n for n, v in (("SUPABASE_URL", url), ("SUPABASE_PUBLISHABLE_KEY", key)) if not v]
1226
- seen = sorted(k for k in os.environ if "SUPA" in k.upper()) # names only: shows a misspelt variable
1227
- log.info("no sign-in, the service is open: %s missing or empty (similar names in the environment: %s)",
1228
- " and ".join(missing), seen or "none")
1229
  return None
1230
  emails = lambda name: frozenset(re.split(r"[\s,;]+", os.environ.get(name, "").strip().lower())) - {""}
1231
- teachers, tutors, developers = emails("TEACHER_EMAILS"), emails("TUTORS_EMAIL"), emails("DEVELOPER_EMAILS")
1232
- secret = os.environ.get("SUPABASE_SECRET_KEY", "").strip()
1233
- log.info("sign-in on: %s", "the students of the classes" if secret else "any account (no SUPABASE_SECRET_KEY)")
1234
- if os.environ.get("ALLOWED_EMAILS", "").strip():
1235
- log.warning("ALLOWED_EMAILS is no longer read: the teacher lists the students in the panel's classes")
1236
- if teachers and not secret:
1237
- log.warning("TEACHER_EMAILS is set without SUPABASE_SECRET_KEY: the management panel has no data to show")
1238
- if tutors and not secret:
1239
- log.warning("TUTORS_EMAIL is set without SUPABASE_SECRET_KEY: a flag cannot be saved")
1240
- audio_store = None
1241
- if bucket := os.environ.get("AUDIO_BUCKET", "").strip():
1242
- audio_store = HFAudioStore(bucket, os.environ.get("AUDIO_BUCKET_PREFIX", "ihki-audio"),
1243
- os.environ.get("HF_TOKEN", "").strip() or None)
1244
- log.info("voice reports use Hugging Face bucket %s", bucket)
1245
- cutoff = os.environ.get("AUDIO_MIGRATE_BEFORE", DEFAULT_AUDIO_MIGRATION_CUTOFF).strip()
1246
- return SupabaseAuth(url, key, teachers, secret, tutors, developers, audio_store, cutoff)
 
1
+ """Optional Supabase sign-in and math-class storage."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from __future__ import annotations
3
 
4
  import json
 
5
  import logging
 
6
  import os
7
  import re
 
8
  import threading
9
  import time
10
  import urllib.error
11
  import urllib.parse
12
  import urllib.request
 
 
 
13
 
14
+ log = logging.getLogger("mathly.auth")
15
  TIMEOUT = 10
16
+ CACHE_TTL = 300
17
+ CLASSES_TTL = 60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
 
20
  class AuthUnavailable(RuntimeError):
 
22
 
23
 
24
  class ClassConflict(ValueError):
25
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
 
28
  class SupabaseAuth:
29
  def __init__(self, url: str, key: str, teachers: frozenset[str] = frozenset(), secret: str = "",
30
+ developers: frozenset[str] = frozenset()):
31
+ self.url, self.key, self.secret = url.rstrip("/"), key, secret
32
+ self.teachers, self.developers = teachers, developers
33
+ self._users: dict[str, tuple[float, dict]] = {}
 
 
 
 
 
 
34
  self._classes: tuple[float, list[dict]] | None = None
 
 
 
35
  self._access: dict[str, tuple[float, dict | None]] = {}
 
36
  self._lock = threading.Lock()
37
 
38
  def user(self, token: str) -> dict | None:
 
39
  now = time.monotonic()
40
  with self._lock:
41
+ cached = self._users.get(token)
42
+ if cached and cached[0] > now:
43
+ return cached[1]
44
+ request = urllib.request.Request(self.url + "/auth/v1/user",
45
+ headers={"Authorization": f"Bearer {token}", "apikey": self.key, "User-Agent": "mathly/1.0"})
 
 
46
  try:
47
+ with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
48
+ data = json.load(response)
49
+ except urllib.error.HTTPError as exc:
50
+ if exc.code in (400, 401, 403):
51
  return None
52
+ raise AuthUnavailable(f"supabase answered {exc.code}") from exc
53
+ except (urllib.error.URLError, TimeoutError, ValueError) as exc:
54
+ raise AuthUnavailable(str(exc)) from exc
55
  if not data.get("id"):
56
  return None
57
  user = {"id": data["id"], "email": data.get("email", "")}
58
  with self._lock:
59
+ self._users[token] = (now + CACHE_TTL, user)
 
 
60
  return user
61
 
62
+ def _admin(self, path: str, method: str = "GET", body=None, prefer: str = "return=representation"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  if not self.secret:
64
  raise AuthUnavailable("SUPABASE_SECRET_KEY is not set")
65
+ request = urllib.request.Request(self.url + path, method=method,
66
+ data=json.dumps(body).encode() if body is not None else None,
67
+ headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret,
68
+ "Content-Type": "application/json", "Prefer": prefer, "User-Agent": "mathly/1.0"})
69
+ try:
70
+ with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
71
+ return json.loads(response.read() or b"null")
72
+ except urllib.error.HTTPError as exc:
73
+ detail = exc.read().decode(errors="replace")[:300]
74
+ if exc.code == 409:
75
+ raise ClassConflict(detail) from exc
76
+ raise AuthUnavailable(f"supabase answered {exc.code}: {detail}") from exc
77
+ except (urllib.error.URLError, TimeoutError, ValueError) as exc:
78
+ raise AuthUnavailable(str(exc)) from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
  def classes(self, fresh: bool = False) -> list[dict]:
 
 
 
 
 
 
 
81
  now = time.monotonic()
82
  with self._lock:
83
+ cached = self._classes
84
+ if cached and cached[0] > now and not fresh:
85
+ return cached[1]
86
  rows: list[dict] = []
87
  if self.secret:
88
  try:
89
+ rows = self._admin("/rest/v1/classes?select=*&order=created_at.desc") or []
90
+ except AuthUnavailable as exc:
91
+ if "answered 404" not in str(exc):
 
 
 
 
 
 
92
  raise
93
+ log.warning("the classes table is missing; every account is allowed")
94
+ # During the database migration, ignore rows belonging to a removed product.
95
+ rows = [row for row in rows if not row.get("subject") or row.get("subject") == "מתמטיקה"]
96
+ for row in rows:
97
+ row["emails"] = [str(value).lower() for value in row.get("emails") or []]
98
+ row["teachers"] = [str(value).lower() for value in row.get("teachers") or []]
99
+ row["lessons"] = row.get("lessons") or []
100
+ row["nicknames"] = row.get("nicknames") or {}
101
  with self._lock:
102
  self._classes = (now + CLASSES_TTL, rows)
103
  return rows
104
 
105
+ def save_class(self, class_id: int | None, name: str, emails: list[str], nicknames: dict[str, str],
106
+ lessons: list[str], *, teachers: list[str] | None = None, **_ignored) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  body = {"name": name, "emails": emails, "nicknames": nicknames, "lessons": lessons,
108
+ "teachers": list(teachers or [])}
109
+ if class_id is None:
 
 
 
 
 
110
  self._admin("/rest/v1/classes", "POST", body)
111
+ elif not self._admin(f"/rest/v1/classes?id=eq.{int(class_id)}", "PATCH", body):
112
+ raise KeyError(class_id)
113
  self._classes = None
114
 
115
+ def delete_class(self, class_id: int) -> None:
116
+ self._admin(f"/rest/v1/classes?id=eq.{int(class_id)}", "DELETE")
117
  self._classes = None
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  def access_for(self, user_id: str, fresh: bool = False) -> dict | None:
120
+ if not self.secret:
121
+ return None
122
  now = time.monotonic()
123
  with self._lock:
124
+ cached = self._access.get(user_id)
125
+ if cached and cached[0] > now and not fresh:
126
+ return cached[1]
127
  try:
128
+ rows = self._admin("/rest/v1/user_access?" + urllib.parse.urlencode(
129
+ {"user_id": f"eq.{user_id}", "select": "user_id,email,roles,updated_at"}))
130
  value = rows[0] if rows else None
131
+ except AuthUnavailable as exc:
132
+ if "answered 404" not in str(exc):
 
 
 
 
133
  raise
134
+ value = None
 
135
  with self._lock:
136
  self._access[user_id] = (now + CLASSES_TTL, value)
137
  return value
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
  def load_auth() -> SupabaseAuth | None:
141
  url = os.environ.get("SUPABASE_URL", "").strip()
142
  key = os.environ.get("SUPABASE_PUBLISHABLE_KEY", "").strip()
143
  if not url or not key:
144
+ log.info("sign-in is disabled; the math path is public")
 
 
 
145
  return None
146
  emails = lambda name: frozenset(re.split(r"[\s,;]+", os.environ.get(name, "").strip().lower())) - {""}
147
+ return SupabaseAuth(url, key, emails("TEACHER_EMAILS"), os.environ.get("SUPABASE_SECRET_KEY", "").strip(),
148
+ emails("DEVELOPER_EMAILS"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/class_management.py DELETED
@@ -1,208 +0,0 @@
1
- """Shared, scope-aware class and roster operations for browser and MCP callers."""
2
- from __future__ import annotations
3
-
4
- import hashlib
5
- import json
6
- import re
7
- from typing import Any
8
-
9
- from .access import has_permission
10
- from .auth import AuthUnavailable, ClassConflict, SupabaseAuth
11
-
12
-
13
- MAX_CLASS_NAME = 80
14
- MAX_NICKNAME = 40
15
- EMAIL = re.compile(r"[^@\s,;<>]+@[^@\s,;<>]+\.[^@\s,;<>]+")
16
- OPERATIONS = frozenset({"create_class", "rename_class", "add_student", "remove_student"})
17
- PRESERVED = ("name", "emails", "nicknames", "lessons", "homeworks", "dailies", "subject", "teachers")
18
-
19
-
20
- class ClassManagementError(ValueError):
21
- """Safe error suitable for management API and MCP results."""
22
-
23
-
24
- class ClassStorageUnavailable(ClassManagementError):
25
- pass
26
-
27
-
28
- def normalize_class_name(value: object) -> str:
29
- name = " ".join(str(value or "").split())
30
- if not name or len(name) > MAX_CLASS_NAME:
31
- raise ClassManagementError("לכיתה צריך שם (עד 80 תווים)")
32
- return name
33
-
34
-
35
- def parse_roster(text: str) -> tuple[list[str], dict[str, str]]:
36
- """Parse the management UI's line-oriented ``email [nickname]`` format."""
37
- emails, nicknames = set(), {}
38
- for line in re.split(r"[\r\n]+", text):
39
- found = EMAIL.findall(line)
40
- rest = re.sub(r"\s+", " ", EMAIL.sub(" ", line)).strip(" ,;:-–<>()\t")
41
- if (not found and rest) or (len(found) > 1 and rest.strip(" ,;")):
42
- raise ValueError(line.strip())
43
- for email in found:
44
- emails.add(email.lower())
45
- if len(found) == 1 and rest:
46
- nicknames[found[0].lower()] = rest[:MAX_NICKNAME]
47
- return sorted(emails), nicknames
48
-
49
-
50
- def normalize_student(email: object, nickname: object = "") -> tuple[str, str]:
51
- email = str(email or "").strip().lower()
52
- if not EMAIL.fullmatch(email):
53
- raise ClassManagementError("כתובת המייל של התלמיד/ה אינה תקינה")
54
- nickname = " ".join(str(nickname or "").split())[:MAX_NICKNAME]
55
- return email, nickname
56
-
57
-
58
- def visible_classes(auth: SupabaseAuth, actor: dict | None, *, fresh: bool = False) -> list[dict]:
59
- try:
60
- rows = auth.classes(fresh=fresh)
61
- except AuthUnavailable as exc:
62
- raise ClassStorageUnavailable(f"כיתות אינן זמינות: {exc}") from exc
63
- if not actor or "developer" in actor.get("roles", ()) or not hasattr(auth, "access_for"):
64
- return rows
65
- subject = str(actor.get("subject") or "").strip()
66
- return [row for row in rows if subject and str(row.get("subject") or "").strip() == subject]
67
-
68
-
69
- def public_classes(auth: SupabaseAuth, actor: dict) -> list[dict]:
70
- return [{"id": row["id"], "name": row["name"], "subject": str(row.get("subject") or ""),
71
- "students": [{"email": email, **({"nickname": row.get("nicknames", {}).get(email)}
72
- if row.get("nicknames", {}).get(email) else {})}
73
- for email in row.get("emails", [])]}
74
- for row in visible_classes(auth, actor, fresh=True)]
75
-
76
-
77
- def fingerprint(row: dict) -> str:
78
- content = {key: row.get(key, [] if key in {"emails", "lessons", "homeworks", "dailies", "teachers"} else
79
- {} if key == "nicknames" else "") for key in PRESERVED}
80
- return hashlib.sha256(json.dumps(content, ensure_ascii=False, sort_keys=True,
81
- separators=(",", ":")).encode()).hexdigest()
82
-
83
-
84
- def _find(auth: SupabaseAuth, actor: dict, class_id: object) -> dict:
85
- if not isinstance(class_id, int) or isinstance(class_id, bool) or class_id <= 0:
86
- raise ClassManagementError("מזהה הכיתה אינו תקין")
87
- row = next((item for item in visible_classes(auth, actor, fresh=True) if item.get("id") == class_id), None)
88
- if row is None:
89
- raise ClassManagementError("הכיתה לא נמצאה")
90
- return row
91
-
92
-
93
- def _unique(rows: list[dict], name: str, except_id: int | None = None) -> None:
94
- key = name.casefold()
95
- if any(row.get("id") != except_id and " ".join(str(row.get("name") or "").split()).casefold() == key
96
- for row in rows):
97
- raise ClassManagementError("כבר יש כיתה בשם הזה")
98
-
99
-
100
- def prepare(auth: SupabaseAuth, actor: dict, operation: str, *, class_id: int | None = None,
101
- name: str = "", subject: str = "", email: str = "", nickname: str = "",
102
- students: list[dict[str, str]] | None = None,
103
- initial_lessons: dict[str, str]) -> dict[str, Any]:
104
- if operation not in OPERATIONS:
105
- raise ClassManagementError("פעולת הכיתה אינה מותרת")
106
- if not has_permission(actor, "management.access"):
107
- raise ClassManagementError("אין הרשאת ניהול")
108
- if operation == "create_class":
109
- clean_name = normalize_class_name(name)
110
- own_subject = str(actor.get("subject") or "").strip()
111
- clean_subject = own_subject if "developer" not in actor.get("roles", ()) else " ".join(subject.split())
112
- if clean_subject not in initial_lessons:
113
- raise ClassManagementError("יש לבחור מקצוע נתמך לכיתה")
114
- _unique(visible_classes(auth, actor, fresh=True), clean_name)
115
- emails, nicknames = [], {}
116
- for student in students or []:
117
- if not isinstance(student, dict):
118
- raise ClassManagementError("רשימת התלמידים אינה תקינה")
119
- student_email, student_nickname = normalize_student(student.get("email"), student.get("nickname"))
120
- if student_email in emails:
121
- raise ClassManagementError("אותו תלמיד מופיע פעמיים")
122
- emails.append(student_email)
123
- if student_nickname:
124
- nicknames[student_email] = student_nickname
125
- return {"operation": operation, "name": clean_name, "subject": clean_subject,
126
- "emails": sorted(emails), "nicknames": nicknames,
127
- "lesson": initial_lessons[clean_subject]}
128
-
129
- row = _find(auth, actor, class_id)
130
- plan: dict[str, Any] = {"operation": operation, "class_id": row["id"], "class_name": row["name"],
131
- "state": fingerprint(row)}
132
- if operation == "rename_class":
133
- clean_name = normalize_class_name(name)
134
- _unique(visible_classes(auth, actor, fresh=True), clean_name, int(row["id"]))
135
- if clean_name == row["name"]:
136
- raise ClassManagementError("זה כבר שם הכיתה")
137
- plan["name"] = clean_name
138
- else:
139
- clean_email, clean_nickname = normalize_student(email, nickname)
140
- enrolled = clean_email in row.get("emails", [])
141
- if operation == "add_student" and enrolled:
142
- raise ClassManagementError("התלמיד/ה כבר בכיתה")
143
- if operation == "remove_student" and not enrolled:
144
- raise ClassManagementError("התלמיד/ה אינו/ה בכיתה")
145
- if operation == "remove_student" and clean_email in (row.get("teachers") or []):
146
- raise ClassManagementError("אי אפשר להסיר מורה באמצעות פעולת תלמידים")
147
- plan.update(email=clean_email, nickname=clean_nickname)
148
- return plan
149
-
150
-
151
- def _save(auth: SupabaseAuth, row: dict) -> None:
152
- args = (int(row["id"]), row["name"], list(row.get("emails") or []), dict(row.get("nicknames") or {}),
153
- list(row.get("lessons") or []), list(row.get("homeworks") or []), list(row.get("dailies") or []),
154
- str(row.get("subject") or ""))
155
- try:
156
- auth.save_class(*args, teachers=list(row.get("teachers") or []))
157
- except TypeError:
158
- auth.save_class(*args)
159
-
160
-
161
- def apply(auth: SupabaseAuth, actor: dict, plan: dict, *, initial_lessons: dict[str, str]) -> dict[str, Any]:
162
- operation = str(plan.get("operation") or "")
163
- if not has_permission(actor, "management.access") or operation not in OPERATIONS:
164
- raise ClassManagementError("אין הרשאת ניהול")
165
- try:
166
- if operation == "create_class":
167
- name, subject = normalize_class_name(plan.get("name")), str(plan.get("subject") or "")
168
- own_subject = str(actor.get("subject") or "").strip()
169
- if subject not in initial_lessons or ("developer" not in actor.get("roles", ()) and subject != own_subject):
170
- raise ClassManagementError("אין הרשאה למקצוע של הכיתה")
171
- _unique(visible_classes(auth, actor, fresh=True), name)
172
- auth.save_class(None, name, list(plan.get("emails") or []), dict(plan.get("nicknames") or {}),
173
- [initial_lessons[subject]], [], None, subject)
174
- created = next((row for row in visible_classes(auth, actor, fresh=True)
175
- if " ".join(row.get("name", "").split()).casefold() == name.casefold()), None)
176
- return {"operation": operation, "class_id": created.get("id") if created else None,
177
- "class_name": name, "subject": subject}
178
-
179
- row = _find(auth, actor, plan.get("class_id"))
180
- if fingerprint(row) != plan.get("state"):
181
- raise ClassManagementError("הכיתה השתנתה מאז ההכנה. הכינו את הפעולה מחדש")
182
- if operation == "rename_class":
183
- name = normalize_class_name(plan.get("name"))
184
- _unique(visible_classes(auth, actor, fresh=True), name, int(row["id"]))
185
- row = {**row, "name": name}
186
- elif operation == "add_student":
187
- email, nickname = normalize_student(plan.get("email"), plan.get("nickname"))
188
- if email in row.get("emails", []):
189
- raise ClassManagementError("התלמיד/ה כבר בכיתה")
190
- emails, nicknames = sorted([*row.get("emails", []), email]), dict(row.get("nicknames") or {})
191
- if nickname:
192
- nicknames[email] = nickname
193
- row = {**row, "emails": emails, "nicknames": nicknames}
194
- else:
195
- email, _ = normalize_student(plan.get("email"))
196
- if email not in row.get("emails", []):
197
- raise ClassManagementError("התלמיד/ה אינו/ה בכיתה")
198
- if email in (row.get("teachers") or []):
199
- raise ClassManagementError("אי אפשר להסיר מורה באמצעות פעולת תלמידים")
200
- row = {**row, "emails": [item for item in row.get("emails", []) if item != email],
201
- "nicknames": {key: value for key, value in (row.get("nicknames") or {}).items() if key != email}}
202
- _save(auth, row)
203
- return {"operation": operation, "class_id": row["id"], "class_name": row["name"],
204
- **({"email": plan["email"]} if operation in {"add_student", "remove_student"} else {})}
205
- except (ClassManagementError, ClassConflict):
206
- raise
207
- except AuthUnavailable as exc:
208
- raise ClassStorageUnavailable(f"לא ניתן לשמור את הכיתה: {exc}") from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/conjugation.py DELETED
@@ -1,215 +0,0 @@
1
- """Stage A's verbs (שלב א') in the past and the future, every pronoun, for the "איתן" levels of פועל יוצא.
2
-
3
- The future's prefixes are the Hebrew ones, אותיות איתן: ا/א for "I", ت/ת for "you" and "she", ي/י for "he" and
4
- "they", ن/נ for "we"; "you" (f.) adds ـي/ـי and the plurals ـو/ـו, as in Hebrew. Each form is split into
5
- prefix, body and ending so the page can colour the parts, beside the Hebrew form split the same way.
6
-
7
- Arabic forms come from LEX, the app's source of truth. A future form that LEX lacks comes from future_draft.py and
8
- marks its verb a draft: only teachers see draft verbs.
9
- """
10
- from __future__ import annotations
11
-
12
- from .future_draft import FUTURE_DRAFT
13
- from .grammar import PRONOUNS
14
- from .workbook import LEX, VERBS_1, VERBS_3, VERBS_4, VERBS_5, VERBS_6, VERBS_8, VERBS_9, VERBS_10, _plain
15
-
16
- STAGE_A = [("1", VERBS_1), ("3", VERBS_3), ("4", VERBS_4), ("5", VERBS_5), ("6", VERBS_6), ("8", VERBS_8),
17
- ("9", VERBS_9), ("10", VERBS_10)]
18
-
19
- # The Hebrew future, unpointed: the "I" form, the body after the prefix, and the body before ـי / ـו (קנה -> תקני).
20
- HE_FUTURE = {
21
- "يفتح": ("אפתח", "פתח", "פתח"), "ياكل": ("אוכל", "אכל", "אכל"), "يسأل": ("אשאל", "שאל", "שאל"),
22
- "يقعد": ("אשב", "שב", "שב"), "يدخل": ("איכנס", "יכנס", "יכנס"), "يسبح": ("אשחה", "שחה", "שח"),
23
- "يطبخ": ("אבשל", "בשל", "בשל"), "ينقل": ("אעבור", "עבור", "עבר"), "يجي": ("אבוא", "בוא", "בוא"),
24
- "يحكي": ("אדבר", "דבר", "דבר"), "يعطي": ("אתן", "יתן", "יתנ"), "يشتري": ("אקנה", "קנה", "קנ"),
25
- "يروح": ("אלך", "לך", "לכ"), "يقول": ("אומר", "אמר", "אמר"), "يزور": ("אבקר", "בקר", "בקר"),
26
- "يكون": ("אהיה", "היה", "הי"), "يبوس": ("אנשק", "נשק", "נשק"), "يسوق": ("אנהג", "נהג", "נהג"),
27
- "يشوف": ("אראה", "ראה", "רא"), "يفوت": ("איכנס", "יכנס", "יכנס"), "ياخد": ("אקח", "יקח", "יקח"),
28
- "يشرب": ("אשתה", "שתה", "שת"), "يطلع": ("אצא", "צא", "צא"), "يسمع": ("אשמע", "שמע", "שמע"),
29
- "يفهم": ("אבין", "בין", "בינ"), "يرجع": ("אחזור", "חזור", "חזר"), "يشكر": ("אודה", "ודה", "וד"),
30
- "يتغدّى": ("אוכל", "אכל", "אכל"), "ينزل": ("ארד", "רד", "רד"), "يوصل": ("אגיע", "גיע", "גיע"),
31
- "يضحك": ("אצחק", "צחק", "צחק"), "يلبس": ("אלבש", "לבש", "לבש"), "يلعب": ("אשחק", "שחק", "שחק"),
32
- }
33
-
34
- FUTURE_PREFIX = "اتتيتنتي" # in PRONOUNS order
35
- FUTURE_SUFFIX = ["", "", "ي", "", "", "", "و", "و"]
36
- PAST_SUFFIX = ["ت", "ت", "تي", "", "ت", "نا", "تو", "و"]
37
- HE_PREFIX = "אתתיתנתי"
38
- HE_FUTURE_SUFFIX = ["", "", "י", "", "", "", "ו", "ו"]
39
- HE_PAST_SUFFIX = [("תי",), ("ת",), ("ת",), ("",), ("ה",), ("נו",), ("תם", "תן"), ("ו",)]
40
- # the tiles the student builds with: (Arabic, as the page shows it in Hebrew transliteration). A future prefix tile
41
- # shows the verb's own vowel (תֻ, תִ, תְ), filled in per question.
42
- PREFIX_TILES = [("ا", "א"), ("ت", "ת"), ("ي", "י"), ("ن", "נ")]
43
- FUTURE_SUFFIX_TILES = [("", ""), ("ي", "\u05B4י"), ("و", "וּ")]
44
- PAST_SUFFIX_TILES = [("", ""), ("ت", "ת"), ("تي", "תִי"), ("نا", "נַא"), ("تو", "תוּ"), ("و", "וּ")]
45
- # a past form's ending in transliteration, per pronoun (فتّي is written פֻתִّי)
46
- PAST_SUFFIX_TR = [("ת",), ("ת",), ("תִי", "תִّי"), ("",), ("ת",), ("נַא", "ّא"), ("תוּ", "תّוּ"), ("וּ",)]
47
- MARKS = set(chr(c) for c in range(0x0591, 0x05C8)) | {"'", "\u0651"}
48
- FINALS = dict(zip("כמנפצ", "ךםןףץ"))
49
- # Arabic forms the rule (prefix + the "he" form's body + ending) does not give
50
- IRREGULAR = {"يجي": ["اجي", "تيجي", "تيجي", "يجي", "تيجي", "نيجي", "تيجو", "يجو"]}
51
- ANA_IRREGULAR = {"ياكل": "آكل", "ياخد": "آخد"}
52
- SHADDA = "ّ"
53
-
54
-
55
- def plain(ar: str) -> str:
56
- """The form as the tiles spell it: without shadda (كنّا, فتّي)."""
57
- return ar.replace(SHADDA, "")
58
-
59
-
60
- def compose(prefix: str, body: str, suffix: str) -> str:
61
- """Prefix + body + ending, as the page builds it: ا + اكل is آكل, and a body ending in ي/ى gives way to the
62
- ending (تحكي + و -> تحكو, تتغدّى + ي -> تتغدّي)."""
63
- word = prefix + body
64
- if word.startswith("اا"):
65
- word = "آ" + word[2:]
66
- if suffix and body and body[-1] in "يى":
67
- return word[:-1] + suffix
68
- return word + suffix
69
-
70
-
71
- def _future_arabic(he: str) -> list[str]:
72
- if he in IRREGULAR:
73
- return IRREGULAR[he]
74
- body = he[1:]
75
- forms = [compose(pre, body, suf) for pre, suf in zip(FUTURE_PREFIX, FUTURE_SUFFIX)]
76
- forms[0] = ANA_IRREGULAR.get(he, forms[0])
77
- return forms
78
-
79
-
80
- def _lookup(ar: str, pronoun: int) -> tuple[str, list[str], bool] | None:
81
- """LEX's entry for a future form (checked), else the draft's; "I" takes the entry read אַ (اسمع#i, not اسمع)."""
82
- keys = [k for k in LEX if k.partition("#")[0] == ar and not k.endswith("#imp")]
83
- if pronoun == 0:
84
- keys.sort(key=lambda k: (not LEX[k][0].startswith(("אַ", "אֹ")), "#" in k))
85
- else:
86
- keys = [k for k in keys if not LEX[k][0].startswith("א")] or keys
87
- keys.sort(key=lambda k: "#" in k)
88
- if keys:
89
- tr, ipas = LEX[keys[0]]
90
- return tr, list(ipas), False
91
- if ar in FUTURE_DRAFT:
92
- tr, ipas = FUTURE_DRAFT[ar]
93
- return tr, list(ipas), True
94
- return None
95
-
96
-
97
- def _future_body(he: str, target: str, pronoun: int) -> str:
98
- """The body the page shows between the slots: the "he" form's, unless that does not build this form (تيجي)."""
99
- prefix, suffix = FUTURE_PREFIX[pronoun], FUTURE_SUFFIX[pronoun]
100
- rest = ("ا" + target[1:]) if target.startswith("آ") else target[1:]
101
- stripped = rest[:-1] if suffix and rest.endswith(suffix) else rest
102
- for body in (he[1:], rest, stripped + "ي", stripped):
103
- if plain(compose(prefix, body, suffix)) == plain(target):
104
- return body
105
- raise ValueError(f"no body builds {target}")
106
-
107
-
108
- def ar_parts(ar: str, tense: str, pronoun: int) -> dict[str, str]:
109
- """A form split for colouring: {pre, body, suf}."""
110
- if tense == "future":
111
- suf = FUTURE_SUFFIX[pronoun] if FUTURE_SUFFIX[pronoun] and ar.endswith(FUTURE_SUFFIX[pronoun]) else ""
112
- return {"pre": ar[0], "body": ar[1:len(ar) - len(suf)], "suf": suf}
113
- suf = PAST_SUFFIX[pronoun]
114
- if suf and ar.endswith(SHADDA + suf[-1]) and len(suf) == 2 and ar[:-2].endswith(suf[0]): # كنّا, فتّي
115
- return {"pre": "", "body": ar[:-3], "suf": ar[-3:]}
116
- if suf and ar.endswith(suf):
117
- return {"pre": "", "body": ar[:-len(suf)], "suf": suf}
118
- return {"pre": "", "body": ar, "suf": ""}
119
-
120
-
121
- def lead(tr: str) -> tuple[str, str]:
122
- """A transliteration's first letter with its marks (תֻ, גִ'), and the rest."""
123
- i = 1
124
- while i < len(tr) and tr[i] in MARKS:
125
- i += 1
126
- return tr[:i], tr[i:]
127
-
128
-
129
- def tr_parts(tr: str, tense: str, pronoun: int) -> dict[str, str]:
130
- """A transliteration split for colouring, as ar_parts splits the Arabic: {pre, body, suf}."""
131
- if tense == "past":
132
- for suf in PAST_SUFFIX_TR[pronoun]:
133
- if suf and tr.endswith(suf) and len(tr) > len(suf):
134
- return {"pre": "", "body": tr[:-len(suf)], "suf": suf}
135
- return {"pre": "", "body": tr, "suf": ""}
136
- pre, rest = lead(tr)
137
- if FUTURE_SUFFIX[pronoun] == "ي" and rest.endswith("י"):
138
- body = rest[:-1]
139
- cut = body.rfind("\u05B4")
140
- if cut >= len(body) - 3: # the ending's hiriq, on the last letter (before a geresh: חִ'י)
141
- body = body[:cut] + body[cut + 1:]
142
- return {"pre": pre, "body": body, "suf": "\u05B4י"}
143
- if FUTURE_SUFFIX[pronoun] == "و" and rest.endswith("וּ"):
144
- return {"pre": pre, "body": rest[:-2], "suf": "וּ"}
145
- return {"pre": pre, "body": rest, "suf": ""}
146
-
147
-
148
- def compose_tr(pre: str, body: str, suf: str) -> str:
149
- """The transliteration the tiles spell, as the page shows it while building: a final letter at the end only, and
150
- an ending's hiriq before a geresh (חִ'י)."""
151
- i = len(body)
152
- while i and body[i - 1] in MARKS:
153
- i -= 1
154
- letter = body[i - 1] if i else ""
155
- regular = {v: k for k, v in FINALS.items()}
156
- if suf and letter in regular:
157
- body = body[:i - 1] + regular[letter] + body[i:]
158
- elif not suf and letter in FINALS and i == len(body): # כּ at the end stays as the course writes it
159
- body = body[:i - 1] + FINALS[letter] + body[i:]
160
- if suf.startswith("\u05B4") and body.endswith("'"):
161
- return pre + body[:-1] + "\u05B4'" + suf[1:]
162
- return pre + body + suf
163
-
164
-
165
- def _he_past_parts(he: str, pronoun: int) -> dict[str, str]:
166
- for suf in HE_PAST_SUFFIX[pronoun]:
167
- if suf and he.endswith(suf):
168
- return {"pre": "", "body": he[:-len(suf)], "suf": suf}
169
- return {"pre": "", "body": he, "suf": ""}
170
-
171
-
172
- def _he_future_parts(spec: tuple[str, str, str], pronoun: int) -> dict[str, str]:
173
- ana, body, before_suffix = spec
174
- if pronoun == 0:
175
- return {"pre": ana[0], "body": ana[1:], "suf": ""}
176
- suf = HE_FUTURE_SUFFIX[pronoun]
177
- return {"pre": HE_PREFIX[pronoun], "body": before_suffix if suf else body, "suf": suf}
178
-
179
-
180
- def _verb(lesson: str, item: tuple) -> dict:
181
- he_past, keys, he = item[0].split(), item[1].split(), item[2]
182
- name = _plain(he_past[3]) + (f" {item[3]}" if len(item) > 3 else "")
183
- past = []
184
- for p, key in enumerate(keys):
185
- tr, ipas = LEX[key]
186
- ar = key.partition("#")[0]
187
- past.append({"ar": ar, "tr": tr, "ipas": list(ipas), "parts": ar_parts(ar, "past", p),
188
- "tr_parts": tr_parts(tr, "past", p),
189
- "he": _he_past_parts(_plain(he_past[p]), p), "draft": False,
190
- "body": ar_parts(plain(ar), "past", p)["body"]})
191
- future = []
192
- for p, ar in enumerate(_future_arabic(he)):
193
- found = _lookup(ar, p)
194
- if not found:
195
- raise ValueError(f"{he}: no entry for {ar}")
196
- tr, ipas, draft = found
197
- future.append({"ar": ar, "tr": tr, "ipas": ipas, "parts": ar_parts(ar, "future", p),
198
- "tr_parts": tr_parts(tr, "future", p),
199
- "he": _he_future_parts(HE_FUTURE[he], p), "draft": draft, "body": _future_body(he, ar, p)})
200
- return {"id": he, "lesson": lesson, "name": name, "arabic": f"{keys[3].partition('#')[0]} · {he}",
201
- "transliteration": f"{past[3]['tr']} · {future[3]['tr']}",
202
- "past": past, "future": future, "draft": any(f["draft"] for f in future)}
203
-
204
-
205
- def _stage_a() -> dict[str, dict]:
206
- out = {}
207
- for lesson, items in STAGE_A:
208
- for item in items:
209
- if item[2] not in out:
210
- out[item[2]] = _verb(lesson, item)
211
- return out
212
-
213
-
214
- STAGE_A_VERBS = _stage_a()
215
- assert all(len(v["past"]) == len(v["future"]) == len(PRONOUNS) for v in STAGE_A_VERBS.values())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/future_draft.py DELETED
@@ -1,94 +0,0 @@
1
- """Draft future forms of stage A's verbs (שלב א'): those that LEX does not have yet.
2
-
3
- Made by rule from the forms LEX has (another prefix letter on a known form, or ـي / ـو added), so they are NOT checked.
4
- The verb game shows a verb whose future is not all in LEX to teachers only, marked טיוטה. Once a teacher has checked
5
- a form, move it into LEX (workbook.py) and delete it here: the verb then reaches students when all eight are in LEX.
6
- The review list is docs/future-forms-draft.md.
7
- """
8
-
9
- FUTURE_DRAFT: dict[str, tuple[str, list[str]]] = {
10
- # يفتح
11
- "تفتح": ("תִפְתַח", ["t i f t a ħ"]),
12
- "تفتحي": ("תִפְתַחִי", ["t i f t a ħ i"]),
13
- "نفتح": ("נִפְתַח", ["n i f t a ħ"]),
14
- "تفتحو": ("תִפְתַחוּ", ["t i f t a ħ u"]),
15
- "يفتحو": ("יִפְתַחוּ", ["j i f t a ħ u"]),
16
- # ياكل
17
- "ياكلو": ("יֹאכְּלוּ", ["j oː k u l u", "j oː k i l u", "j aː k u l u", "j oː k l u", "j aː k l u"]),
18
- # يدخل
19
- "تدخلي": ("תֻדְחֹ'לִי", ["t u d x u l i"]),
20
- "تدخلو": ("תֻדְחֹ'לוּ", ["t u d x u l u"]),
21
- "يدخلو": ("יֻדְחֹ'לוּ", ["j u d x u l u"]),
22
- # يسبح
23
- "اسبح": ("אַסְבַּח", ["ʔ a s b a ħ"]),
24
- "تسبحي": ("תִסְבַּחִי", ["t i s b a ħ i"]),
25
- "تسبحو": ("תִסְבַּחוּ", ["t i s b a ħ u"]),
26
- "يسبحو": ("יִסְבַּחוּ", ["j i s b a ħ u"]),
27
- # يطبخ
28
- "تطبخو": ("תֻטְבֹּח'וּ", ["t u tˤ b u x u"]),
29
- "يطبخو": ("יֻטְבֹּח'וּ", ["j u tˤ b u x u"]),
30
- # ينقل
31
- "انقل": ("אַנְקֹל", ["ʔ a n ʔ u l", "ʔ a n q u l"]),
32
- "تنقلي": ("תֻנְקֹלִי", ["t u n ʔ u l i", "t u n q u l i"]),
33
- "ننقل": ("נֻנְקֹל", ["n u n ʔ u l", "n u n q u l"]),
34
- "تنقلو": ("תֻנְקֹלוּ", ["t u n ʔ u l u", "t u n q u l u"]),
35
- # يعطي
36
- "نعطي": ("נֵעַטִי", ["n a ʕ tˤ i", "n i ʕ tˤ i"]),
37
- "تعطو": ("תֵעַטוּ", ["t a ʕ tˤ u", "t i ʕ tˤ u"]),
38
- "يعطو": ("יֵעַטוּ", ["j a ʕ tˤ u", "j i ʕ tˤ u"]),
39
- # يشتري
40
- "تشترو": ("תִשְתְרוּ", ["t i ʃ t r u", "t i ʃ t i r u"]),
41
- "يشترو": ("יִשְתְרוּ", ["j i ʃ t r u", "j i ʃ t i r u"]),
42
- # يروح
43
- "تروحو": ("תְרוּחוּ", ["t r uː ħ u", "t i r uː ħ u"]),
44
- # يقول
45
- "تقولي": ("תְקוּלִי", ["t ʔ uː l i", "t q uː l i"]),
46
- "نقول": ("נְקוּל", ["n ʔ uː l", "n q uː l"]),
47
- "تقولو": ("תְקוּלוּ", ["t ʔ uː l u", "t q uː l u"]),
48
- "يقولو": ("יִקוּלוּ", ["j ʔ uː l u", "i ʔ uː l u", "j q uː l u", "j i ʔ uː l u"]),
49
- # يكون
50
- "تكونو": ("תְכּוּנוּ", ["t k uː n u"]),
51
- "يكونو": ("יִכּוּנוּ", ["j k uː n u", "i k uː n u", "j i k uː n u"]),
52
- # يبوس
53
- "تبوسي": ("תְבּוּסִי", ["t b uː s i"]),
54
- "نبوس": ("נְבּוּס", ["n b uː s"]),
55
- "تبوسو": ("תְבּוּסוּ", ["t b uː s u"]),
56
- "يبوسو": ("יִבּוּסוּ", ["j b uː s u", "i b uː s u", "j i b uː s u"]),
57
- # يسوق
58
- "نسوق": ("נְסוּק", ["n s uː ʔ", "n s uː q"]),
59
- "تسوقو": ("תְסוּקוּ", ["t s uː ʔ u", "t s uː q u"]),
60
- "يسوقو": ("יִסוּקוּ", ["j s uː ʔ u", "j s uː q u", "i s uː ʔ u", "j i s uː ʔ u"]),
61
- # يفوت
62
- "تفوتي": ("תְפוּתִי", ["t f uː t i"]),
63
- "نفوت": ("נְפוּת", ["n f uː t"]),
64
- "تفوتو": ("תְפוּתוּ", ["t f uː t u"]),
65
- "يفوتو": ("יִפוּתוּ", ["j f uː t u", "i f uː t u", "j i f uː t u"]),
66
- # ياخد
67
- "ناخد": ("נֹאחֵ'ד", ["n oː x i d", "n aː x u d"]),
68
- "تاخدو": ("תֹאחְ'דוּ", ["t oː x i d u", "t aː x u d u", "t oː x d u", "t aː x d u", "t oː x u d u"]),
69
- # يفهم
70
- "نفهم": ("נִפְהַם", ["n i f h a m"]),
71
- "تفهمو": ("תִפְהַמוּ", ["t i f h a m u"]),
72
- # يرجع
73
- "نرجع": ("נִרְגַ'ע", ["n i r dʒ a ʕ"]),
74
- # يشكر
75
- "اشكر": ("אַשְכֹּר", ["ʔ a ʃ k u r"]),
76
- "تشكر": ("תֻשְכֹּר", ["t u ʃ k u r"]),
77
- "تشكري": ("תֻשְכֹּרִי", ["t u ʃ k u r i"]),
78
- "تشكرو": ("תֻשְכֹּרוּ", ["t u ʃ k u r u"]),
79
- "يشكرو": ("יֻשְכֹּרוּ", ["j u ʃ k u r u"]),
80
- # ينزل
81
- "انزل": ("אַנְזֵל", ["ʔ a n z i l", "ʔ a n z a l"]),
82
- "تنزلي": ("תִנְזֵלִי", ["t i n z i l i", "t i n z a l i"]),
83
- "ننزل": ("נִנְזֵל", ["n i n z i l", "n i n z a l"]),
84
- "ينزلو": ("יִנְזְלוּ", ["j i n z i l u", "j i n z a l u", "j i n z l u"]),
85
- # يوصل
86
- "توصلو": ("תוֹצַלוּ", ["t oː sˤ a l u"]),
87
- # يضحك
88
- "يضحكو": ("יִדְֿחַכּוּ", ["j i dˤ ħ a k u"]),
89
- # يلبس
90
- "البس": ("אַלְבַּס", ["ʔ a l b a s"]),
91
- "نلبس": ("נִלְבַּס", ["n i l b a s"]),
92
- "تلبسو": ("תִלְבַּסוּ", ["t i l b a s u"]),
93
- "يلبسو": ("יִלְבַּסוּ", ["j i l b a s u"]),
94
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/grammar.py DELETED
@@ -1,184 +0,0 @@
1
- """Day 2 sentences as grammar, so the page can ask for the same sentence negated or with another subject.
2
-
3
- A sentence is a subject pronoun, one or two past-tense verbs and fixed words. From each sentence
4
- pattern this module builds every pronoun x {positive, negative} phrase in the format of phrases.py.
5
- Only the forms taught in the course are "listed" (shown in the tab); the rest are reached through
6
- the page's "שלילה" and "הטיית פעלים" buttons.
7
-
8
- Negation is ما before the verb (ما اكلت); the -ش suffix is not accepted yet.
9
- Transliterations follow the course material: אֵל for the article, (ל) for its silent ל, ّ for doubling.
10
- """
11
- from __future__ import annotations
12
-
13
- import itertools
14
-
15
- # subject pronouns, in the order used by every verb table below
16
- # key Hebrew course tr (arabic, ipa) readings, main one first
17
- PRONOUNS = [
18
- # The initial glottal stop is routinely absent in natural Palestinian speech. Keep both alignments so a weak
19
- # breathy onset is not forced against ʔ and reported as h, while the written and displayed form stays identical.
20
- ("ana", "אני", "אַנַא", [("انا", "ʔ a n a"), ("انا", "a n a")]),
21
- ("inte", "אתה", "אִנְתֵ", [("انت", "ʔ i n t e"), ("انت", "ʔ i n t a")]),
22
- ("inti", "את", "אִנְתִי", [("انتي", "ʔ i n t i")]),
23
- ("huwwe", "הוא", "הוּّ", [("هوّ", "h u w w e"), ("هو", "h uː")]),
24
- ("hiyye", "היא", "הִיֵّ", [("هيّ", "h i j j e"), ("هي", "h iː")]),
25
- ("ihna", "אנחנו", "אִחְנַא", [("احنا", "ʔ i ħ n a")]),
26
- ("intu", "אתם", "אִנְתוּ", [("انتو", "ʔ i n t u")]),
27
- ("humme", "הם", "הֹםّ", [("همّ", "h u m m e"), ("هم", "h o m")]),
28
- ]
29
- P = [p[0] for p in PRONOUNS]
30
-
31
- # past tense, one row per pronoun (PRONOUNS order): Hebrew, course tr, Arabic, IPA readings
32
- VERBS = {
33
- "akal": [
34
- ("אכלתי", "אַכַּלְת", "اكلت", ["ʔ a k a l t"]),
35
- ("אכלת", "אַכַּלְת", "اكلت", ["ʔ a k a l t"]),
36
- ("אכלת", "אַכַּלְתִי", "اكلتي", ["ʔ a k a l t i"]),
37
- ("אכל", "אַכַּל", "اكل", ["ʔ a k a l"]),
38
- ("אכלה", "אַכְּלַת", "اكلت", ["ʔ a k l a t"]),
39
- ("אכלנו", "אַכַּלְנַא", "اكلنا", ["ʔ a k a l n a"]),
40
- ("אכלתם", "אַכַּלְתוּ", "اكلتو", ["ʔ a k a l t u"]),
41
- ("אכלו", "אַכְּלוּ", "اكلو", ["ʔ a k l u", "ʔ a k a l u"]),
42
- ],
43
- "qaad": [
44
- ("ישבתי", "קַעַדְת", "قعدت", ["ʔ a ʕ a d t", "q a ʕ a d t"]),
45
- ("ישבת", "קַעַדְת", "قعدت", ["ʔ a ʕ a d t", "q a ʕ a d t"]),
46
- ("ישבת", "קַעַדְתִי", "قعدتي", ["ʔ a ʕ a d t i", "q a ʕ a d t i"]),
47
- ("ישב", "קַעַד", "قعد", ["ʔ a ʕ a d", "q a ʕ a d"]),
48
- ("ישבה", "קַעְדַת", "قعدت", ["ʔ a ʕ d a t", "q a ʕ d a t"]),
49
- ("ישבנו", "קַעַדְנַא", "قعدنا", ["ʔ a ʕ a d n a", "q a ʕ a d n a"]),
50
- ("ישבתם", "קַעַדְתוּ", "قعدتو", ["ʔ a ʕ a d t u", "q a ʕ a d t u"]),
51
- ("ישבו", "קַעַדוּ", "قعدو", ["ʔ a ʕ a d u", "q a ʕ a d u", "ʔ a ʕ d u", "q a ʕ d u"]),
52
- ],
53
- "fatah": [
54
- ("פתחתי", "פַתַחְת", "فتحت", ["f a t a ħ t"]),
55
- ("פתחת", "פַתַחְת", "فتحت", ["f a t a ħ t"]),
56
- ("פתחת", "פַתַחְתִי", "فتحتي", ["f a t a ħ t i"]),
57
- ("פתח", "פַתַח", "فتح", ["f a t a ħ"]),
58
- ("פתחה", "פַתְחַת", "فتحت", ["f a t ħ a t"]),
59
- ("פתחנו", "פַתַחְנַא", "فتحنا", ["f a t a ħ n a"]),
60
- ("פתחתם", "פַתַחְתוּ", "فتحتو", ["f a t a ħ t u"]),
61
- ("פתחו", "פַתַחוּ", "فتحو", ["f a t a ħ u", "f a t ħ u"]),
62
- ],
63
- "saal": [
64
- ("שאלתי", "סַאַלֵת", "سألت", ["s a ʔ a l t", "s a ʔ a l i t"]),
65
- ("שאלת", "סַאַלְת", "سألت", ["s a ʔ a l t", "s a ʔ a l i t"]),
66
- ("שאלת", "סַאַלְתִי", "سألتي", ["s a ʔ a l t i"]),
67
- ("שאל", "סַאַל", "سأل", ["s a ʔ a l"]),
68
- ("שאלה", "סַאְלַת", "سألت", ["s a ʔ l a t"]),
69
- ("שאלנו", "סַאַלְנַא", "سألنا", ["s a ʔ a l n a"]),
70
- ("שאלתם", "סַאַלְתוּ", "سألتو", ["s a ʔ a l t u"]),
71
- ("שאלו", "סַאַלוּ", "سألو", ["s a ʔ a l u", "s a ʔ l u"]),
72
- ],
73
- }
74
-
75
- # every other word: arabic -> (course tr, IPA readings)
76
- WORDS = {
77
- "بندورة": ("בַּנְדוֹרַה", ["b a n d oː r a"]), "على": ("עַלַא", ["ʕ a l a"]),
78
- "الطّاولة": ("אֵ(ל)טַّאוְלֵה", ["i tˤ tˤ aː w l e"]), "في": ("פִי", ["f i"]),
79
- "الحارة": ("אֵלְחַארַה", ["i l ħ aː r a"]), "الكرسي": ("אֵלְכֻּרְסִי", ["i l k u r s i"]),
80
- "فلافل": ("פַלַאפֵל", ["f a l aː f i l"]), "الشّبّاك": ("אֵ(ל)שֻّבַּّאכּ", ["i ʃ ʃ u b b aː k"]),
81
- "الدّكتور": ("אֵ(ל)דַّכְּתוֹר", ["i d d a k t oː r", "i d d u k t oː r"]), "الباب": ("אֵלְבַּאבּ", ["i l b aː b"]),
82
- "المعلّم": ("אֵלְמְעַלֵّם", ["i l m ʕ a l l i m"]), "الأكل": ("אֵלְאַכֵּל", ["i l ʔ a k i l", "i l ʔ a k l"]),
83
- "مع": ("מַע", ["m a ʕ"]), "اخوها": ("אַחַ'וּהַא", ["ʔ a x uː h a"]),
84
- "الصّاحب": ("אֵ(ל)צַّאחֵבּ", ["i sˤ sˤ aː ħ i b"]), "السّيّارة": ("אֵ(ל)סַّיַّארַה", ["i s s a j j aː r a"]),
85
- "البيت": ("אֵלְבֵּית", ["i l b eː t"]), "اليوم": ("אֵלְיוֹם", ["i l j oː m"]),
86
- "الطّيّارة": ("אֵ(ל)טַّיַّארַה", ["i tˤ tˤ a j j aː r a"]), "الولد": ("אֵלְוַלַד", ["i l w a l a d"]),
87
- "سؤال": ("סֻאַאל", ["s u ʔ aː l"]), "للكلب": ("לַלְכַּלְבּ", ["l a l k a l b"]),
88
- "الكلب": ("אֵלְכַּלְבּ", ["i l k a l b"]), "احمد": ("אַחְמַד", ["ʔ a ħ m a d"]),
89
- "الكبيرة": ("אֵלְכְּבִּירֵה", ["i l k b iː r e"]), "كبير": ("כְּבִּיר", ["k b iː r"]),
90
- "ابوي": ("אַבּוּי", ["ʔ a b uː j"]), "من": ("מִן", ["m i n"]),
91
- }
92
- NOT = ("ما", "מַא", "m a") # before a verb
93
- AND_NOT = ("وما", "וּמַא", "u m a") # before a second verb
94
-
95
- # The course's sentences: subject taught, Hebrew pattern, Arabic pattern.
96
- # S = subject pronoun, V:verb = conjugated verb, W:verb = "and" + conjugated verb ({S} {V} {W} in the Hebrew).
97
- SENTENCES = [
98
- ("inti", "{S} {V} עגבנייה ליד השולחן", "S V:akal بندورة على الطّاولة"),
99
- ("inte", "{S} {V} בשכונה", "S V:qaad في الحارة"),
100
- ("huwwe", "{S} {V} על הכיסא ו{W} פלאפל", "S V:qaad على الكرسي W:akal فلافل"),
101
- ("hiyye", "{S} {V} את החלון", "S V:fatah الشّبّاك"),
102
- ("ana", "{S} {V} את הרופא", "S V:saal الدّكتور"),
103
- ("ihna", "{S} {V} את הדלת", "S V:fatah الباب"),
104
- ("intu", "{S} {V} ליד השולחן", "S V:qaad على الطّاولة"),
105
- ("humme", "{S} {V} את המורה", "S V:saal المعلّم"),
106
- ("huwwe", "{S} {V} את האוכל על החלון", "S V:akal الأكل على الشّبّاك"),
107
- ("hiyye", "{S} {V} עם אחיה", "S V:qaad مع اخوها"),
108
- ("inti", "{S} {V} את החבר", "S V:saal الصّاحب"),
109
- ("humme", "{S} {V} את החלון", "S V:fatah الشّبّاك"),
110
- ("intu", "{S} {V} במכונית", "S V:akal في السّيّارة"),
111
- ("ana", "{S} {V} בבית היום", "S V:qaad في البيت اليوم"),
112
- ("inte", "{S} {V} את המטוס", "S V:fatah الطّيّارة"),
113
- ("ihna", "{S} {V} את הילד שאלה", "S V:saal الولد سؤال"),
114
- ("huwwe", "{S} {V} את הדלת לכלב", "S V:fatah الباب للكلب"),
115
- ("hiyye", "{S} {V} את הדלת ו{W} עם אחמד", "S V:fatah الباب W:akal مع احمد"),
116
- ("inti", "{S} {V} את השולחן הגדול", "S V:fatah الطّاولة الكبيرة"),
117
- ("humme", "{S} {V} את החבר שאלה גדולה", "S V:saal الصّاحب سؤال كبير"),
118
- ("inte", "{S} {V} פלאפל ליד השולחן", "S V:akal فلافل على الطّاولة"),
119
- ("intu", "{S} {V} את הדלת היום", "S V:fatah الباب اليوم"),
120
- ("hiyye", "{S} {V} את הרופא", "S V:saal الدّكتور"),
121
- ("ana", "{S} {V} עגבנייה עם אבא שלי", "S V:akal بندورة مع ابوي"),
122
- ("ihna", "{S} {V} בשכונה", "S V:qaad في الحارة"),
123
- ("huwwe", "{S} {V} את המורה שאלה", "S V:saal المعلّم سؤال"),
124
- ("inte", "{S} {V} עם הכלב", "S V:qaad مع الكلب"),
125
- ("hiyye", "{S} {V} את האוכל על החלון", "S V:akal الأكل على الشّبّاك"),
126
- ("inti", "{S} {V} את המטוס", "S V:fatah الطّيّارة"),
127
- ("ihna", "{S} {V} את החבר ו{W}", "S V:saal الصّاحب W:qaad"),
128
- ("humme", "{S} {V} עגבנייה מהשולחן", "S V:akal بندورة من الطّاولة"),
129
- ]
130
- MAX_VARIANTS = 16
131
-
132
-
133
- def _build(he_pattern: str, pattern: str, pron: int, neg: bool) -> dict:
134
- """One phrase: lists of alternative (arabic, ipa) per word, then every combination of them."""
135
- _, he_pron, tr_pron, pron_alts = PRONOUNS[pron]
136
- slots: list[list[tuple[str, str]]] = [] # per word: its (arabic, ipa) readings
137
- tr: list[str] = []
138
- he = {"S": he_pron}
139
- for tok in pattern.split():
140
- kind, _, verb = tok.partition(":")
141
- if tok == "S":
142
- slots.append(pron_alts)
143
- tr.append(tr_pron)
144
- elif kind in ("V", "W") and verb:
145
- he_verb, tr_verb, ar, ipas = VERBS[verb][pron]
146
- he[kind] = ("לא " if neg else "") + he_verb
147
- if kind == "W" and neg: # ... وما اكل
148
- slots.append([AND_NOT[::2]])
149
- tr.append(AND_NOT[1])
150
- elif kind == "W": # ... واكل: the و is part of the word
151
- ar, ipas, tr_verb = "و" + ar, ["u " + i for i in ipas], "וּ" + tr_verb
152
- elif neg: # ما اكل ...
153
- slots.append([NOT[::2]])
154
- tr.append(NOT[1])
155
- slots.append([(ar, i) for i in ipas])
156
- tr.append(tr_verb)
157
- else:
158
- tr_word, ipas = WORDS[tok]
159
- slots.append([(tok, i) for i in ipas])
160
- tr.append(tr_word)
161
- variants = [(" ".join(a for a, _ in combo), " | ".join(i for _, i in combo))
162
- for combo in itertools.islice(itertools.product(*slots), MAX_VARIANTS)]
163
- return dict(cat="day2", he=he_pattern.format(**he), tr=" ".join(tr), variants=variants)
164
-
165
-
166
- def day2_phrases() -> list[dict]:
167
- """Every pattern x pronoun x polarity. `grp`/`pron`/`neg` let the page move between them;
168
- `listed` marks the course's own sentences, which are the ones the tab starts from, and
169
- `seq` is their 1-based place in SENTENCES: the tab shows them in that order."""
170
- groups: dict[tuple[str, str], int] = {}
171
- taught: dict[tuple[int, int], int] = {}
172
- for seq, (pron, he_pattern, pattern) in enumerate(SENTENCES, 1):
173
- g = groups.setdefault((he_pattern, pattern), len(groups))
174
- taught.setdefault((g, P.index(pron)), seq)
175
- out = []
176
- for (he_pattern, pattern), g in groups.items():
177
- for pron in range(len(PRONOUNS)):
178
- for neg in (False, True):
179
- phrase = _build(he_pattern, pattern, pron, neg)
180
- phrase.update(grp=g, pron=pron, neg=neg, listed=(g, pron) in taught and not neg)
181
- if phrase["listed"]:
182
- phrase["seq"] = taught[g, pron]
183
- out.append(phrase)
184
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/grok_mcp.py DELETED
@@ -1,490 +0,0 @@
1
- """Restricted MCP connector through which Grok can manage library videos."""
2
- from __future__ import annotations
3
-
4
- import logging
5
- import os
6
- import secrets
7
- import threading
8
- import time
9
- from collections import deque
10
- from dataclasses import dataclass
11
- from typing import Any, Callable, Literal
12
-
13
- from mcp.server.auth.middleware.auth_context import get_access_token
14
- from mcp.server.auth.provider import AccessToken
15
- from mcp.server.auth.settings import AuthSettings
16
- from mcp.server.mcpserver import MCPServer
17
- from pydantic import AnyHttpUrl
18
-
19
- from . import library
20
- from . import class_management
21
- from . import monitoring
22
- from .access import has_permission, resolve_access
23
- from .auth import AuthUnavailable
24
-
25
-
26
- log = logging.getLogger("ihki.grok_mcp")
27
- APPROVAL_TTL = 10 * 60
28
- MAX_APPROVALS = 100
29
- MUTATION_LIMIT = 20
30
- MUTATION_WINDOW = 60
31
-
32
-
33
- @dataclass(frozen=True)
34
- class Approval:
35
- title: str
36
- url: str
37
- expires_at: float
38
-
39
-
40
- class ApprovalStore:
41
- def __init__(self, clock: Callable[[], float] = time.monotonic):
42
- self.clock = clock
43
- self._items: dict[str, Approval] = {}
44
- self._lock = threading.Lock()
45
-
46
- def _prune(self, now: float) -> None:
47
- self._items = {key: item for key, item in self._items.items() if item.expires_at > now}
48
- while len(self._items) >= MAX_APPROVALS:
49
- self._items.pop(next(iter(self._items)))
50
-
51
- def prepare(self, title: str, url: str) -> tuple[str, Approval]:
52
- now = self.clock()
53
- with self._lock:
54
- self._prune(now)
55
- approval_id = secrets.token_urlsafe(24)
56
- item = Approval(title, url, now + APPROVAL_TTL)
57
- self._items[approval_id] = item
58
- return approval_id, item
59
-
60
- def consume(self, approval_id: str) -> Approval | None:
61
- now = self.clock()
62
- with self._lock:
63
- item = self._items.pop(str(approval_id), None)
64
- self._prune(now)
65
- return item if item and item.expires_at > now else None
66
-
67
-
68
- @dataclass(frozen=True)
69
- class ClassApproval:
70
- actor_id: str
71
- plan: dict[str, Any]
72
- expires_at: float
73
-
74
-
75
- class ClassApprovalStore:
76
- def __init__(self, clock: Callable[[], float] = time.monotonic):
77
- self.clock = clock
78
- self._items: dict[str, ClassApproval] = {}
79
- self._lock = threading.Lock()
80
-
81
- def _prune(self, now: float) -> None:
82
- self._items = {key: item for key, item in self._items.items() if item.expires_at > now}
83
- while len(self._items) >= MAX_APPROVALS:
84
- self._items.pop(next(iter(self._items)))
85
-
86
- def prepare(self, actor_id: str, plan: dict[str, Any]) -> tuple[str, ClassApproval]:
87
- now = self.clock()
88
- with self._lock:
89
- self._prune(now)
90
- approval_id = secrets.token_urlsafe(24)
91
- item = ClassApproval(actor_id, plan, now + APPROVAL_TTL)
92
- self._items[approval_id] = item
93
- return approval_id, item
94
-
95
- def consume(self, approval_id: str, actor_id: str) -> ClassApproval | None:
96
- now = self.clock()
97
- with self._lock:
98
- self._prune(now)
99
- item = self._items.get(str(approval_id))
100
- if item is None or item.actor_id != actor_id:
101
- return None
102
- self._items.pop(str(approval_id), None)
103
- return item if item.expires_at > now else None
104
-
105
-
106
- class MutationLimiter:
107
- def __init__(self, clock: Callable[[], float] = time.monotonic):
108
- self.clock = clock
109
- self._uses: deque[float] = deque(maxlen=MUTATION_LIMIT)
110
- self._lock = threading.Lock()
111
-
112
- def allow(self) -> bool:
113
- now = self.clock()
114
- with self._lock:
115
- while self._uses and self._uses[0] <= now - MUTATION_WINDOW:
116
- self._uses.popleft()
117
- if len(self._uses) >= MUTATION_LIMIT:
118
- return False
119
- self._uses.append(now)
120
- return True
121
-
122
-
123
- def oauth_urls(app) -> tuple[str, str]:
124
- """Return the public MCP resource and Supabase OAuth issuer URLs."""
125
- auth = getattr(app.state, "auth", None)
126
- supabase = (getattr(auth, "url", "") or os.environ.get("SUPABASE_URL", "")).rstrip("/")
127
- public = os.environ.get("MCP_PUBLIC_URL", "").strip()
128
- if not public and os.environ.get("SPACE_HOST", "").strip():
129
- public = f"https://{os.environ['SPACE_HOST'].strip()}/mcp/"
130
- public = public or "http://localhost:8000/mcp/"
131
- return public.rstrip("/") + "/", (supabase + "/auth/v1" if supabase else "https://invalid.invalid/auth/v1")
132
-
133
-
134
- class SupabaseTokenVerifier:
135
- """Validate a Supabase OAuth token and its current ihki management permission."""
136
-
137
- def __init__(self, app):
138
- self.app = app
139
-
140
- async def verify_token(self, token: str) -> AccessToken | None:
141
- auth = getattr(self.app.state, "auth", None)
142
- if auth is None or not token:
143
- return None
144
- try:
145
- # SupabaseAuth is deliberately synchronous throughout ihki. Keeping the same
146
- # verifier path also avoids retaining a worker/thread beyond an MCP request.
147
- user = resolve_access(auth, token, fresh_permissions=True)
148
- except AuthUnavailable as exc:
149
- log.warning("MCP OAuth verification unavailable: %s", exc)
150
- return None
151
- if not user or user.get("denied") or not has_permission(user, "management.access"):
152
- return None
153
- _, issuer = oauth_urls(self.app)
154
- actor = {key: user.get(key) for key in ("id", "email", "roles", "subject", "permissions")}
155
- return AccessToken(token=token, client_id="supabase-oauth", scopes=["openid", "email"],
156
- subject=str(user["id"]), claims={"iss": issuer, "ihki_actor": actor})
157
-
158
-
159
- def build(app, *, approvals: ApprovalStore | None = None, limiter: MutationLimiter | None = None,
160
- class_approvals: ClassApprovalStore | None = None,
161
- actor_provider: Callable[[], dict[str, Any] | None] | None = None):
162
- """Return ``(server, authenticated ASGI app, raw Starlette app)``."""
163
- resource_url, issuer_url = oauth_urls(app)
164
- server = MCPServer(
165
- "ihki-digital-library",
166
- instructions=("Manage only the ihki digital video library and scoped class rosters, and provide read-only "
167
- "privacy-preserving monitoring. Always prepare a mutation, show its exact effect to the teacher, "
168
- "and publish only after explicit confirmation."),
169
- token_verifier=SupabaseTokenVerifier(app),
170
- auth=AuthSettings(issuer_url=AnyHttpUrl(issuer_url), resource_server_url=AnyHttpUrl(resource_url),
171
- validate_token_resource=False, required_scopes=[]),
172
- )
173
- approvals = approvals or ApprovalStore()
174
- class_approvals = class_approvals or ClassApprovalStore()
175
- limiter = limiter or MutationLimiter()
176
-
177
- def auth():
178
- return getattr(app.state, "auth", None)
179
-
180
- def actor() -> dict[str, Any] | None:
181
- if actor_provider:
182
- return actor_provider()
183
- access = get_access_token()
184
- value = (access.claims or {}).get("ihki_actor") if access else None
185
- return value if isinstance(value, dict) else None
186
-
187
- def initial_lessons() -> dict[str, str]:
188
- return dict(getattr(app.state, "initial_class_lessons", {}))
189
-
190
- def class_audit(action: str, current: dict[str, Any] | None, outcome: str,
191
- result: dict[str, Any] | None = None, reason: str = "") -> None:
192
- safe = result or {}
193
- log.info("Grok class action=%s outcome=%s class_id=%s email=%s reason=%s", action, outcome,
194
- safe.get("class_id"), safe.get("email"), reason)
195
- store = auth()
196
- if store is not None and hasattr(store, "log_event"):
197
- store.log_event("content", current or {"id": None, "email": ""},
198
- str(safe.get("class_name") or action),
199
- {"action": action, "outcome": outcome,
200
- **({"class_id": safe["class_id"]} if safe.get("class_id") is not None else {}),
201
- **({"email": safe["email"]} if safe.get("email") else {}),
202
- **({"reason": reason} if reason else {})})
203
-
204
- def result(action: str, operation: Callable[[], dict[str, Any]]) -> dict[str, Any]:
205
- if not limiter.allow():
206
- library.audit(auth(), action, action, "refused", reason="rate_limit")
207
- return {"ok": False, "error": "בוצעו יותר מדי פעולות. נסו שוב בעוד דקה."}
208
- try:
209
- answer = operation()
210
- library.audit(auth(), action, str(answer.get("title") or action), "success",
211
- video_id=answer.get("video_id"))
212
- return {"ok": True, **answer}
213
- except library.LibraryError as exc:
214
- library.audit(auth(), action, action, "refused", reason=type(exc).__name__)
215
- return {"ok": False, "error": str(exc)}
216
-
217
- @server.tool(description="List the digital-library videos in their current order. Returns no learner data.",
218
- structured_output=True)
219
- async def list_library_videos() -> dict[str, Any]:
220
- try:
221
- videos = library.rows(auth(), fresh=True)
222
- return {"ok": True, "videos": [
223
- {"id": video["id"], "title": video["title"], "url": video["url"], "position": at + 1}
224
- for at, video in enumerate(videos)
225
- ]}
226
- except library.LibraryError as exc:
227
- return {"ok": False, "error": str(exc), "videos": []}
228
-
229
- @server.tool(description="Validate a YouTube or Vimeo video and create a ten-minute approval. This does not publish it.",
230
- structured_output=True)
231
- async def prepare_library_video(title: str, url: str) -> dict[str, Any]:
232
- def prepare() -> dict[str, Any]:
233
- clean_title = library.normalize_title(title)
234
- clean_url = library.normalize_url(url, app.state.video_link)
235
- library.ensure_unique(auth(), clean_url, fresh=True)
236
- approval_id, item = approvals.prepare(clean_title, clean_url)
237
- return {"approval_id": approval_id, "title": item.title, "url": item.url,
238
- "expires_in_seconds": APPROVAL_TTL, "published": False}
239
- return result("prepare_library_video", prepare)
240
-
241
- @server.tool(description="Publish a previously prepared video after the teacher explicitly confirms the shown title and URL.",
242
- structured_output=True)
243
- async def publish_library_video(approval_id: str) -> dict[str, Any]:
244
- def publish() -> dict[str, Any]:
245
- item = approvals.consume(approval_id)
246
- if item is None:
247
- raise library.LibraryError("האישור אינו תקף, פג תוקף או כבר נוצל. הכינו את הסרטון מחדש.")
248
- library.add(auth(), item.title, item.url)
249
- created = next((video for video in library.rows(auth(), fresh=True) if video.get("url") == item.url), None)
250
- return {"video_id": created.get("id") if created else None, "title": item.title,
251
- "url": item.url, "published": True}
252
- return result("publish_library_video", publish)
253
-
254
- @server.tool(description="Rename an existing digital-library video without changing its URL or position.",
255
- structured_output=True)
256
- async def rename_library_video(video_id: int, title: str) -> dict[str, Any]:
257
- def rename() -> dict[str, Any]:
258
- clean_title = library.normalize_title(title)
259
- library.rename(auth(), video_id, clean_title)
260
- return {"video_id": video_id, "title": clean_title}
261
- return result("rename_library_video", rename)
262
-
263
- @server.tool(description="List only classes in the current manager's scope and their student emails/nicknames.",
264
- structured_output=True)
265
- async def list_classes() -> dict[str, Any]:
266
- current, store = actor(), auth()
267
- if current is None or store is None:
268
- return {"ok": False, "error": "אין הרשאת ניהול", "classes": []}
269
- try:
270
- return {"ok": True, "classes": class_management.public_classes(store, current)}
271
- except class_management.ClassManagementError as exc:
272
- return {"ok": False, "error": str(exc), "classes": []}
273
-
274
- @server.tool(description=("Prepare one class change for confirmation. Allowed operations: create_class, rename_class, "
275
- "add_student, remove_student. This does not mutate data."), structured_output=True)
276
- async def prepare_class_change(
277
- operation: Literal["create_class", "rename_class", "add_student", "remove_student"],
278
- class_id: int | None = None, name: str = "", subject: str = "", email: str = "",
279
- nickname: str = "", students: list[dict[str, str]] | None = None,
280
- ) -> dict[str, Any]:
281
- current, store = actor(), auth()
282
- if current is None or store is None:
283
- return {"ok": False, "error": "אין הרשאת ניהול"}
284
- if not limiter.allow():
285
- class_audit("prepare_class_change", current, "refused", reason="rate_limit")
286
- return {"ok": False, "error": "בוצעו יותר מדי פעולות. נסו שוב בעוד דקה."}
287
- try:
288
- plan = class_management.prepare(store, current, operation, class_id=class_id, name=name,
289
- subject=subject, email=email, nickname=nickname, students=students,
290
- initial_lessons=initial_lessons())
291
- approval_id, _ = class_approvals.prepare(str(current["id"]), plan)
292
- shown = {key: value for key, value in plan.items() if key not in {"state", "emails", "nicknames", "lesson"}}
293
- if operation == "create_class":
294
- shown["students"] = [{"email": item, **({"nickname": plan["nicknames"][item]}
295
- if item in plan["nicknames"] else {})}
296
- for item in plan["emails"]]
297
- class_audit("prepare_class_change", current, "success", shown)
298
- return {"ok": True, "approval_id": approval_id, **shown,
299
- "expires_in_seconds": APPROVAL_TTL, "published": False}
300
- except class_management.ClassManagementError as exc:
301
- class_audit("prepare_class_change", current, "refused", reason=type(exc).__name__)
302
- return {"ok": False, "error": str(exc)}
303
-
304
- @server.tool(description="Publish a prepared class change only after the teacher explicitly confirms its exact effect.",
305
- structured_output=True)
306
- async def publish_class_change(approval_id: str) -> dict[str, Any]:
307
- current, store = actor(), auth()
308
- if current is None or store is None:
309
- return {"ok": False, "error": "אין הרשאת ניהול"}
310
- if not limiter.allow():
311
- class_audit("publish_class_change", current, "refused", reason="rate_limit")
312
- return {"ok": False, "error": "בוצעו יותר מדי פעולות. נסו שוב בעוד דקה."}
313
- item = class_approvals.consume(approval_id, str(current["id"]))
314
- if item is None:
315
- class_audit("publish_class_change", current, "refused", reason="invalid_approval")
316
- return {"ok": False, "error": "האישור אינו תקף, פג תוקף, שייך למשתמש אחר או כבר נוצל."}
317
- try:
318
- answer = class_management.apply(store, current, item.plan, initial_lessons=initial_lessons())
319
- class_audit("publish_class_change", current, "success", answer)
320
- return {"ok": True, **answer, "published": True}
321
- except class_management.ClassManagementError as exc:
322
- class_audit("publish_class_change", current, "refused", reason=type(exc).__name__)
323
- return {"ok": False, "error": str(exc)}
324
-
325
- @server.tool(description=("Return a read-only daily or weekly aggregate activity series for one authorized class "
326
- "or all classes in the current manager's scope. Returns no learner identities."),
327
- structured_output=True)
328
- async def get_activity_timeseries(
329
- from_date: str, to_date: str, interval: Literal["day", "week"] = "day",
330
- class_id: int | None = None,
331
- ) -> dict[str, Any]:
332
- current, store = actor(), auth()
333
- if current is None or store is None:
334
- return {"ok": False, "error": "נתוני הפעילות אינם זמינים"}
335
- try:
336
- return {"ok": True, **monitoring.activity_timeseries(
337
- store, current, from_date=from_date, to_date=to_date, interval=interval, class_id=class_id)}
338
- except monitoring.MonitoringError as exc:
339
- log.info("MCP activity outcome=refused actor_id=%s class_id=%s range=%s..%s reason=%s",
340
- current.get("id"), class_id, from_date, to_date, type(exc).__name__)
341
- return {"ok": False, "error": str(exc)}
342
-
343
- @server.tool(description=("Return privacy-thresholded current activity for one authorized class or all visible "
344
- "classes. Recent persisted practice never implies that learners are online; current "
345
- "presence is available only for an active live-class heartbeat session."),
346
- structured_output=True)
347
- async def get_current_activity(
348
- window_minutes: Literal[5, 15] = 5, class_id: int | None = None,
349
- ) -> dict[str, Any]:
350
- current, store = actor(), auth()
351
- if current is None or store is None:
352
- return {"ok": False, "error": "נתוני הפעילות אינם זמינים"}
353
- try:
354
- answer = monitoring.current_activity(
355
- store, current, window_minutes=window_minutes, class_id=class_id,
356
- live_store=getattr(app.state, "live", None))
357
- log.info("MCP current activity outcome=%s actor_id=%s class_id=%s window=%s",
358
- answer["status"], current.get("id"), class_id, window_minutes)
359
- return {"ok": True, **answer}
360
- except monitoring.MonitoringError as exc:
361
- log.info("MCP current activity outcome=refused actor_id=%s class_id=%s window=%s reason=%s",
362
- current.get("id"), class_id, window_minutes, type(exc).__name__)
363
- return {"ok": False, "error": str(exc)}
364
-
365
- @server.tool(description=("Return a read-only coarse application health summary. Never returns logs, errors, "
366
- "credentials, internal addresses, or configuration values."), structured_output=True)
367
- async def get_system_health() -> dict[str, Any]:
368
- current = actor()
369
- if current is None:
370
- return {"ok": False, "error": "אין הרשאת ניהול"}
371
- try:
372
- return {"ok": True, **monitoring.system_health(app, current)}
373
- except monitoring.MonitoringError as exc:
374
- return {"ok": False, "error": str(exc)}
375
-
376
- @server.tool(description=("Return a read-only privacy-thresholded ranking of difficult exercises for one "
377
- "authorized class or all classes in the current manager's scope. Returns aggregates "
378
- "only and never identifies learners."), structured_output=True)
379
- async def get_exercise_difficulty(
380
- from_date: str, to_date: str, class_id: int | None = None,
381
- exercise_type: Literal["pronunciation", "verb", "game", "other"] | None = None,
382
- limit: int = 20, min_attempts: int = 10,
383
- ) -> dict[str, Any]:
384
- current, store = actor(), auth()
385
- if current is None or store is None:
386
- return {"ok": False, "error": "נתוני הקושי אינם זמינים"}
387
- try:
388
- answer = monitoring.exercise_difficulty(
389
- store, current, from_date=from_date, to_date=to_date, class_id=class_id,
390
- exercise_type=exercise_type, limit=limit, min_attempts=min_attempts)
391
- log.info("MCP difficulty outcome=success actor_id=%s class_id=%s range=%s..%s returned=%s",
392
- current.get("id"), class_id, from_date, to_date, answer["returned"])
393
- return {"ok": True, **answer}
394
- except monitoring.MonitoringError as exc:
395
- log.info("MCP difficulty outcome=refused actor_id=%s class_id=%s range=%s..%s reason=%s",
396
- current.get("id"), class_id, from_date, to_date, type(exc).__name__)
397
- return {"ok": False, "error": str(exc)}
398
-
399
- @server.tool(description=("Return measured pronunciation errors from stored phoneme diagnostics for one "
400
- "authorized class or all visible classes. Returns aggregates only and never infers "
401
- "linguistic difficulty when diagnostics are missing."), structured_output=True)
402
- async def get_pronunciation_difficulties(
403
- from_date: str, to_date: str, class_id: int | None = None,
404
- limit: int = 20, min_attempts: int = 5,
405
- ) -> dict[str, Any]:
406
- current, store = actor(), auth()
407
- if current is None or store is None:
408
- return {"ok": False, "error": "נתוני ההגייה אינם זמינים"}
409
- try:
410
- answer = monitoring.pronunciation_difficulties(
411
- store, current, from_date=from_date, to_date=to_date, class_id=class_id,
412
- limit=limit, min_attempts=min_attempts)
413
- log.info("MCP pronunciation outcome=%s actor_id=%s class_id=%s range=%s..%s returned=%s",
414
- answer["status"], current.get("id"), class_id, from_date, to_date, answer["returned"])
415
- return {"ok": True, **answer}
416
- except monitoring.MonitoringError as exc:
417
- log.info("MCP pronunciation outcome=refused actor_id=%s class_id=%s range=%s..%s reason=%s",
418
- current.get("id"), class_id, from_date, to_date, type(exc).__name__)
419
- return {"ok": False, "error": str(exc)}
420
-
421
- @server.tool(description=("Return a privacy-thresholded learning progress series for one authorized class or "
422
- "all visible classes, optionally restricted to a stable exercise identifier."),
423
- structured_output=True)
424
- async def get_learning_progress(
425
- from_date: str, to_date: str, interval: Literal["day", "week", "month"] = "week",
426
- class_id: int | None = None, exercise_id: str | None = None, min_attempts: int = 5,
427
- ) -> dict[str, Any]:
428
- current, store = actor(), auth()
429
- if current is None or store is None:
430
- return {"ok": False, "error": "נתוני הלמידה אינם זמינים"}
431
- try:
432
- answer = monitoring.learning_progress(
433
- store, current, from_date=from_date, to_date=to_date, interval=interval,
434
- class_id=class_id, exercise_id=exercise_id, min_attempts=min_attempts)
435
- log.info("MCP progress outcome=%s actor_id=%s class_id=%s range=%s..%s",
436
- answer["status"], current.get("id"), class_id, from_date, to_date)
437
- return {"ok": True, **answer}
438
- except monitoring.MonitoringError as exc:
439
- return {"ok": False, "error": str(exc)}
440
-
441
- @server.tool(description=("Compare privacy-thresholded learning difficulty across authorized classes using "
442
- "normalized participation and exercise-exposure metrics. Returns no learner identities."),
443
- structured_output=True)
444
- async def compare_class_difficulties(
445
- from_date: str, to_date: str, class_ids: list[int] | None = None,
446
- exercise_type: Literal["pronunciation", "verb", "game", "other"] | None = None,
447
- min_attempts: int = 5,
448
- ) -> dict[str, Any]:
449
- current, store = actor(), auth()
450
- if current is None or store is None:
451
- return {"ok": False, "error": "נתוני ההשוואה אינם זמינים"}
452
- try:
453
- answer = monitoring.compare_class_difficulties(
454
- store, current, from_date=from_date, to_date=to_date, class_ids=class_ids,
455
- exercise_type=exercise_type, min_attempts=min_attempts)
456
- log.info("MCP class comparison outcome=%s actor_id=%s classes=%s range=%s..%s",
457
- answer["status"], current.get("id"), len(answer["classes"]), from_date, to_date)
458
- return {"ok": True, **answer}
459
- except monitoring.MonitoringError as exc:
460
- return {"ok": False, "error": str(exc)}
461
-
462
- @server.tool(description=("Identify privacy-thresholded exercise and topic bottlenecks from repeated failures "
463
- "and measured improvement. Recommendations are labeled separately from observations."),
464
- structured_output=True)
465
- async def get_learning_bottlenecks(
466
- from_date: str, to_date: str, class_id: int | None = None,
467
- exercise_type: Literal["pronunciation", "verb", "game", "other"] | None = None,
468
- limit: int = 20, min_attempts: int = 10,
469
- ) -> dict[str, Any]:
470
- current, store = actor(), auth()
471
- if current is None or store is None:
472
- return {"ok": False, "error": "נתוני הלמידה אינם זמינים"}
473
- try:
474
- answer = monitoring.learning_bottlenecks(
475
- store, current, from_date=from_date, to_date=to_date, class_id=class_id,
476
- exercise_type=exercise_type, limit=limit, min_attempts=min_attempts)
477
- log.info("MCP bottlenecks outcome=%s actor_id=%s class_id=%s range=%s..%s returned=%s",
478
- answer["status"], current.get("id"), class_id, from_date, to_date, answer["returned"])
479
- return {"ok": True, **answer}
480
- except monitoring.MonitoringError as exc:
481
- return {"ok": False, "error": str(exc)}
482
-
483
- raw = server.streamable_http_app(
484
- streamable_http_path="/",
485
- stateless_http=True,
486
- json_response=True,
487
- max_request_body_size=64 * 1024,
488
- host="0.0.0.0",
489
- )
490
- return server, raw, raw
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/keystore.py DELETED
@@ -1,204 +0,0 @@
1
- """API keys a developer sets in the management panel (SAWTAK_API_KEY, ANTHROPIC_API_KEY), and the tutor's model
2
- (TUTOR_MODEL), so changing one needs no access to the host (the Hugging Face Space).
3
-
4
- Each key is kept in the Supabase table `app_secrets`, encrypted with Fernet under SECRETS_MASTER_KEY, which lives only in
5
- the host's environment: the table, a backup of it or a look at the Supabase dashboard shows no key. The table has
6
- row-level security on and no policy, so only the server's secret key reads it. A key never goes back to the page: the
7
- panel sees whether it is set, where from, and its last four characters. A new key is tried with its provider before it
8
- is kept, so a typo cannot switch the playback or the tutor off. TUTOR_MODEL is no secret: it is kept the same way, but
9
- the panel shows it, and a model is checked by asking Anthropic for it with the key in use.
10
-
11
- A key kept in the table takes the place of the environment's; without one (or without the table, the secret key or the
12
- master key) the environment's is used, as before.
13
-
14
- Env: SECRETS_MASTER_KEY (a Fernet key; make one with
15
- python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
16
- and keep it as a Space secret. Losing it loses the stored keys, not the environment's.)
17
- """
18
- from __future__ import annotations
19
-
20
- import logging
21
- import os
22
- import re
23
- import threading
24
- import urllib.error
25
- import urllib.request
26
-
27
- from .auth import AuthUnavailable
28
-
29
- log = logging.getLogger("ihki.keys")
30
- # the keys the panel may set, and how the panel names them; nothing else (the master key, the Supabase keys) is settable
31
- NAMES = {"SAWTAK_API_KEY": "Sawtak Arabi · השמעת התשובה", "ANTHROPIC_API_KEY": "Anthropic · המורה (יאללה נדבר)",
32
- "TUTOR_MODEL": "המודל של המורה (TUTOR_MODEL)"}
33
- FALLBACK = {"ANTHROPIC_API_KEY": ("ANTHROPIC_API_KEY", "CLAUDE_API_KEY"), "SAWTAK_API_KEY": ("SAWTAK_API_KEY",),
34
- "TUTOR_MODEL": ("TUTOR_MODEL",)}
35
- PUBLIC = frozenset({"TUTOR_MODEL"}) # settings the panel may show in full
36
- # the panel's list of tutor models (any other id can be typed); empty = the code's default (tutor.DEFAULT_MODEL)
37
- MODEL_CHOICES = [("claude-haiku-5-5", "Haiku 5.5 · ברירת המחדל, הזול ביותר"), ("claude-sonnet-5-5", "Sonnet 5.5"),
38
- ("claude-opus-5-5", "Opus 5.5"), ("claude-fable-5-1", "Fable 5.1 · היקר ביותר"), ("claude-haiku-4-5", "Haiku 4.5")]
39
- MODEL_ID = re.compile(r"^claude-[a-z0-9][a-z0-9.-]{2,60}$")
40
- MAX_LEN = 300
41
- TIMEOUT = 15
42
- CREATE_APP_SECRETS = ("create table public.app_secrets (name text primary key check (name in ('SAWTAK_API_KEY','ANTHROPIC_API_KEY','TUTOR_MODEL')), "
43
- "ciphertext text not null, last4 text not null default '', updated_by text not null default '', "
44
- "updated_at timestamptz not null default now()); "
45
- "alter table public.app_secrets enable row level security; "
46
- "revoke all on public.app_secrets from anon, authenticated;")
47
- # for a table made before TUTOR_MODEL could be kept in it
48
- MIGRATE_APP_SECRETS = ("alter table public.app_secrets drop constraint if exists app_secrets_name_check; "
49
- "alter table public.app_secrets add constraint app_secrets_name_check "
50
- "check (name in ('SAWTAK_API_KEY','ANTHROPIC_API_KEY','TUTOR_MODEL'));")
51
-
52
-
53
- class KeyRefused(ValueError):
54
- """The provider refused the key, or it could not be tried. The message is safe to show."""
55
-
56
-
57
- class KeyStore:
58
- def __init__(self, auth, master: str = ""):
59
- self.auth = auth
60
- self._fernet, self.why = None, "" # why: what keeps the panel from saving keys, in words for the page
61
- if master:
62
- try:
63
- from cryptography.fernet import Fernet
64
- self._fernet = Fernet(master.strip().encode())
65
- except ImportError:
66
- self.why = "החבילה cryptography לא מותקנת בשרת."
67
- except ValueError:
68
- log.error("SECRETS_MASTER_KEY is not a valid Fernet key: keys set in the panel are off")
69
- self.why = "SECRETS_MASTER_KEY בשרת אינו מפתח Fernet תקין."
70
- else:
71
- self.why = "חסר SECRETS_MASTER_KEY בסודות השרת, ולכן אי אפשר לשמור מפתחות מהפאנל."
72
- if not self.why and not getattr(auth, "secret", ""):
73
- self.why = "חסר SUPABASE_SECRET_KEY בשרת, ולכן אי אפשר לשמור מפתחות מהפאנל."
74
- self.missing = False # the table is not there yet (CREATE_APP_SECRETS)
75
- self._rows: dict[str, dict] = {} # name -> {"value", "last4", "updated_by", "updated_at"}
76
- self._lock = threading.Lock()
77
-
78
- @property
79
- def writable(self) -> bool:
80
- return not self.why and not self.missing
81
-
82
- def _seal(self, name: str, value: str) -> str:
83
- # the name goes inside the ciphertext, so one key's row copied into another's does not decrypt as it
84
- return self._fernet.encrypt(f"{name}\n{value}".encode()).decode()
85
-
86
- def _open(self, name: str, token: str) -> str | None:
87
- from cryptography.fernet import InvalidToken
88
- try:
89
- got, _, value = self._fernet.decrypt(token.encode()).decode().partition("\n")
90
- except (InvalidToken, ValueError):
91
- return None
92
- return value if got == name else None
93
-
94
- def load(self) -> None:
95
- """Reads the stored keys. A failure is logged and leaves the environment's keys in use."""
96
- if self.why or not hasattr(self.auth, "secret_rows"):
97
- return
98
- try:
99
- rows = self.auth.secret_rows()
100
- except AuthUnavailable as e:
101
- log.warning("stored API keys not read, the environment's are used: %s", e)
102
- return
103
- if rows is None:
104
- self.missing = True
105
- return
106
- found = {}
107
- for r in rows:
108
- value = self._open(r["name"], r.get("ciphertext") or "") if r.get("name") in NAMES else None
109
- if value is None:
110
- log.warning("stored key %s does not decrypt with this SECRETS_MASTER_KEY: the environment's is used", r.get("name"))
111
- continue
112
- found[r["name"]] = {"value": value, "last4": r.get("last4") or "", "updated_by": r.get("updated_by") or "",
113
- "updated_at": r.get("updated_at")}
114
- with self._lock:
115
- self._rows = found
116
-
117
- @staticmethod
118
- def _env(name: str) -> str:
119
- return next((v for v in (os.environ.get(n, "").strip() for n in FALLBACK[name]) if v), "")
120
-
121
- def get(self, name: str) -> str:
122
- """The key in use: the panel's, else the environment's, else empty."""
123
- with self._lock:
124
- row = self._rows.get(name)
125
- return row["value"] if row else self._env(name)
126
-
127
- def status(self) -> list[dict]:
128
- """What the panel shows of each key: never the key itself."""
129
- out = []
130
- for name, label in NAMES.items():
131
- with self._lock:
132
- row = self._rows.get(name)
133
- env = self._env(name)
134
- item = {"name": name, "label": label, "source": "db" if row else "env" if env else "none",
135
- "last4": "" if name in PUBLIC else row["last4"] if row else env[-4:] if len(env) >= 12 else "",
136
- "updated_by": row["updated_by"] if row else "", "updated_at": row["updated_at"] if row else None}
137
- if name in PUBLIC:
138
- item.update(value=row["value"] if row else env, choices=[{"id": i, "label": l} for i, l in MODEL_CHOICES])
139
- out.append(item)
140
- return out
141
-
142
- def set(self, name: str, value: str, by: str) -> None:
143
- """Encrypts and keeps a key that check() accepted."""
144
- row = {"name": name, "ciphertext": self._seal(name, value), "last4": value[-4:], "updated_by": by}
145
- self.auth.save_secret(row)
146
- with self._lock:
147
- self._rows[name] = {"value": value, "last4": row["last4"], "updated_by": by, "updated_at": None}
148
-
149
- def clear(self, name: str) -> None:
150
- """Forgets the panel's key; the environment's (if any) is used again."""
151
- self.auth.delete_secret(name)
152
- with self._lock:
153
- self._rows.pop(name, None)
154
-
155
-
156
- def check(name: str, value: str, anthropic_key: str = "") -> None:
157
- """Tries the key with its provider, with a call that costs nothing; a tutor model, by asking Anthropic for it with
158
- `anthropic_key` (the key in use). Raises KeyRefused."""
159
- if name == "TUTOR_MODEL":
160
- if not MODEL_ID.match(value):
161
- raise KeyRefused("מזהה מודל נראה כך: claude-haiku-5-5.")
162
- if not anthropic_key:
163
- raise KeyRefused("אין מפתח Anthropic, ולכן אי אפשר לבדוק את המודל. שמרו קודם מפתח.")
164
- import anthropic
165
- try:
166
- anthropic.Anthropic(api_key=anthropic_key, timeout=TIMEOUT, max_retries=0).models.retrieve(value)
167
- except anthropic.NotFoundError:
168
- raise KeyRefused(f"Anthropic לא מכיר את המודל {value}.") from None
169
- except (anthropic.AuthenticationError, anthropic.PermissionDeniedError):
170
- raise KeyRefused("מפתח Anthropic שבשימוש נדחה, ולכן אי אפשר לבדוק את המודל.") from None
171
- except anthropic.APIStatusError as e:
172
- raise KeyRefused(f"לא ניתן לבדוק את המודל מול Anthropic כרגע ({e.status_code}). נסו שוב.") from None
173
- except anthropic.APIConnectionError:
174
- raise KeyRefused("אין חיבור ל-Anthropic כרגע. נסו שוב.") from None
175
- return
176
- if name == "SAWTAK_API_KEY":
177
- req = urllib.request.Request("https://api.sawtakarabi.ai/v1/voices", headers={
178
- "Authorization": f"Bearer {value}", "User-Agent": "ihki/1.0"})
179
- try:
180
- with urllib.request.urlopen(req, timeout=TIMEOUT):
181
- return
182
- except urllib.error.HTTPError as e:
183
- if e.code == 403: # a valid key restricted to `tts` may not list voices; speech is all it is used for
184
- return
185
- if e.code == 401:
186
- raise KeyRefused("Sawtak דחה את המפתח.") from None
187
- raise KeyRefused(f"לא ניתן לבדוק את המפתח מול Sawtak כרגע ({e.code}). נסו שוב.") from None
188
- except OSError:
189
- raise KeyRefused("אין חיבור ל-Sawtak כרגע. נסו שוב.") from None
190
- import anthropic
191
- try:
192
- anthropic.Anthropic(api_key=value, timeout=TIMEOUT, max_retries=0).models.list(limit=1)
193
- except (anthropic.AuthenticationError, anthropic.PermissionDeniedError):
194
- raise KeyRefused("Anthropic דחה את המפתח.") from None
195
- except anthropic.APIStatusError as e:
196
- raise KeyRefused(f"לא ניתן לבדוק את המפתח מול Anthropic כרגע ({e.status_code}). נסו שוב.") from None
197
- except anthropic.APIConnectionError:
198
- raise KeyRefused("אין חיבור ל-Anthropic כרגע. נסו שוב.") from None
199
-
200
-
201
- def load_keys(auth) -> KeyStore:
202
- keys = KeyStore(auth, os.environ.get("SECRETS_MASTER_KEY", ""))
203
- keys.load()
204
- return keys
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/lab.py DELETED
@@ -1,378 +0,0 @@
1
- """The lab (מעבדה): a fixed diagnostic list for measuring the phoneme recognition, and its recordings.
2
-
3
- Every speaker reads the same short items once. Each item tests one sound (`focus`), so its answer is known and
4
- labelling a recording costs nothing: it was said right, or the speaker planted one mistake and marks which sound.
5
- Three groups:
6
- - syllables: the hard letter alone before a long a (حا, عا, صا ...), and its easy neighbour beside it
7
- - pairs: minimal pairs (or a three) that differ in that sound only (حال / هال / خال, صيف / سيف ...): can the
8
- model tell them apart
9
- - positions: the sound at the start, the middle and the end of a word
10
- - vowels: a short vowel beside a glide (ولد, يلّا, شويّ: the glide's colour on the transition frames, GLIDE_KIN),
11
- between consonants (كلب, بنت, كلّ: a spike from a neighbour's release), and short / long (من / مين).
12
- Planting a↔i or u↔a on these measures whether the GOP pooling and windows (docs/gop-pooling.md)
13
- still catch a real vowel substitution
14
-
15
- The recordings are kept in the Supabase table `lab_samples` and the storage bucket `lab` (see CREATE_LAB in main.py),
16
- the material for a per-sound benchmark: how often a sound said right is flagged, and how often a planted mistake is not.
17
- The IPA follows the bank's (phrases.py), and a word the bank has keeps the bank's reading.
18
- """
19
- from __future__ import annotations
20
-
21
- import json
22
- from functools import lru_cache
23
- from pathlib import Path
24
-
25
- from .align import POOLS
26
- from .scorer import LETTER_GOOD
27
- from .tips import VOWELS
28
- from .translit import to_hebrew
29
-
30
- GROUPS = {"syllables": "הברות", "pairs": "זוגות מינימליים", "positions": "מיקום במילה", "vowels": "תנועות"}
31
-
32
-
33
- def _item(id: str, group: str, focus: str, he: str, *variants: tuple[str, str], pair: str = "") -> dict:
34
- return {"id": id, "group": group, "focus": focus, "he": he, "variants": list(variants), "pair": pair}
35
-
36
-
37
- ITEMS = [
38
- # syllables: the hard letter, then the sound a Hebrew speaker puts in its place
39
- _item("syl-ħ", "syllables", "ħ", "ח גרונית", ("حا", "ħ aː")),
40
- _item("syl-x", "syllables", "x", "ח' (כמו ח בעברית)", ("خا", "x aː")),
41
- _item("syl-h", "syllables", "h", "ה", ("ها", "h aː")),
42
- _item("syl-ʕ", "syllables", "ʕ", "ע גרונית", ("عا", "ʕ aː")),
43
- _item("syl-ʔ", "syllables", "ʔ", "א", ("آ", "ʔ aː")),
44
- _item("syl-ɣ", "syllables", "ɣ", "ע' (ר גרונית)", ("غا", "ɣ aː")),
45
- _item("syl-r", "syllables", "r", "ר מתגלגלת", ("را", "r aː")),
46
- _item("syl-sˤ", "syllables", "sˤ", "צ' (ס נחצית)", ("صا", "sˤ aː")),
47
- _item("syl-s", "syllables", "s", "ס", ("سا", "s aː")),
48
- _item("syl-tˤ", "syllables", "tˤ", "ט נחצית", ("طا", "tˤ aː")),
49
- _item("syl-t", "syllables", "t", "ת", ("تا", "t aː")),
50
- _item("syl-dˤ", "syllables", "dˤ", "צ' (ד נחצית)", ("ضا", "dˤ aː")),
51
- _item("syl-d", "syllables", "d", "ד", ("دا", "d aː")),
52
- # minimal pairs: the two words of a pair differ in the focus sound only
53
- _item("pair-ħaal", "pairs", "ħ", "מצב", ("حال", "ħ aː l"), pair="ħ / h / x"),
54
- _item("pair-haal", "pairs", "h", "הל (תבלין)", ("هال", "h aː l"), pair="ħ / h / x"),
55
- _item("pair-xaal", "pairs", "x", "דוד (אח של אמא)", ("خال", "x aː l"), pair="ħ / h / x"),
56
- _item("pair-ʕalam", "pairs", "ʕ", "דגל", ("علم", "ʕ a l a m"), pair="ʕ / ʔ"),
57
- _item("pair-ʔalam", "pairs", "ʔ", "כאב", ("ألم", "ʔ a l a m"), pair="ʕ / ʔ"),
58
- _item("pair-ɣaali", "pairs", "ɣ", "יקר", ("غالي", "ɣ aː l i"), pair="ɣ / x"),
59
- _item("pair-xaali", "pairs", "x", "הדוד שלי", ("خالي", "x aː l i"), pair="ɣ / x"),
60
- _item("pair-sˤeef", "pairs", "sˤ", "קיץ", ("صيف", "sˤ eː f"), pair="sˤ / s"),
61
- _item("pair-seef", "pairs", "s", "חרב", ("سيف", "s eː f"), pair="sˤ / s"),
62
- _item("pair-tˤiin", "pairs", "tˤ", "בוץ", ("طين", "tˤ iː n"), pair="tˤ / t"),
63
- _item("pair-tiin", "pairs", "t", "תאנה", ("تين", "t iː n"), pair="tˤ / t"),
64
- _item("pair-dˤaar", "pairs", "dˤ", "מזיק", ("ضار", "dˤ aː r"), pair="dˤ / d"),
65
- _item("pair-daar", "pairs", "d", "בית", ("دار", "d aː r"), pair="dˤ / d"),
66
- # no ق / ك pair: ق is said ʔ, k or q (archi.py), so قلب said kalb is right and the pair tells nothing
67
- # positions: the focus sound at the start, the middle and the end of a word
68
- _item("pos-ħ-1", "positions", "ħ", "חלב", ("حليب", "ħ a l iː b")),
69
- _item("pos-ħ-2", "positions", "ħ", "בשר", ("لحم", "l a ħ m"), ("لحم", "l a ħ i m")),
70
- _item("pos-ħ-3", "positions", "ħ", "בוקר", ("صباح", "sˤ a b aː ħ")),
71
- _item("pos-ʕ-1", "positions", "ʕ", "עין", ("عين", "ʕ eː n")),
72
- _item("pos-ʕ-2", "positions", "ʕ", "רחוק", ("بعيد", "b ʕ iː d"), ("بعيد", "b a ʕ iː d")),
73
- _item("pos-ʕ-3", "positions", "ʕ", "שבע", ("سبع", "s a b ʕ"), ("سبع", "s a b i ʕ")),
74
- _item("pos-x-1", "positions", "x", "הדוד שלי", ("خالي", "x aː l i")),
75
- _item("pos-x-2", "positions", "x", "בסדר", ("بخير", "b i x eː r"), ("بخير", "b x eː r")),
76
- _item("pos-x-3", "positions", "x", "מטבח", ("مطبخ", "m a tˤ b a x")),
77
- _item("pos-ɣ-1", "positions", "ɣ", "חדר", ("غرفة", "ɣ u r f e")),
78
- _item("pos-ɣ-2", "positions", "ɣ", "קטן", ("صغير", "sˤ ɣ iː r")),
79
- _item("pos-sˤ-2", "positions", "sˤ", "נמוך", ("قصير", "ʔ a sˤ iː r"), ("قصير", "q a sˤ iː r")),
80
- _item("pos-sˤ-3", "positions", "sˤ", "זול", ("رخيص", "r x iː sˤ")),
81
- _item("pos-tˤ-1", "positions", "tˤ", "שולחן", ("طاولة", "tˤ aː w l e")),
82
- _item("pos-tˤ-2", "positions", "tˤ", "תפוחי אדמה", ("بطاطا", "b a tˤ aː tˤ a")),
83
- _item("pos-dˤ-2", "positions", "dˤ", "ירוק", ("اخضر", "ʔ a x dˤ a r")),
84
- _item("pos-dˤ-3", "positions", "dˤ", "לבן", ("ابيض", "ʔ a b j a dˤ")),
85
- _item("pos-ʔ-2", "positions", "ʔ", "שאלה", ("سؤال", "s u ʔ aː l")),
86
- _item("pos-h-2", "positions", "h", "חודש", ("شهر", "ʃ a h r"), ("شهر", "ʃ a h i r")),
87
- # vowels: the short vowel is the focus. Beside a glide: its i / u colour must not count against the vowel
88
- _item("vow-walad", "vowels", "a", "ילד", ("ولد", "w a l a d")),
89
- _item("vow-jalla", "vowels", "a", "יאללה", ("يلّا", "j a l l a")),
90
- _item("vow-ʃwajj", "vowels", "a", "קצת", ("شويّ", "ʃ w a j j")),
91
- _item("vow-majj", "vowels", "a", "מים", ("ميّ", "m a j j"), ("ميّ", "m a j j e")),
92
- # between consonants: a release or a burst beside the vowel must not count against it
93
- _item("vow-kalb", "vowels", "a", "כלב", ("كلب", "k a l b")),
94
- _item("vow-bint", "vowels", "i", "בת", ("بنت", "b i n t")),
95
- _item("vow-kull", "vowels", "u", "הכל", ("كلّ", "k u l l")),
96
- _item("vow-sukkar", "vowels", "u", "סוכר", ("سكّر", "s u k k a r")),
97
- _item("vow-bass", "vowels", "a", "רק / די", ("بسّ", "b a s s")),
98
- # short / long: the same consonants, the vowel's length only
99
- _item("vow-min", "vowels", "i", "מ־ (מן)", ("من", "m i n"), pair="i / iː"),
100
- _item("vow-miin", "vowels", "iː", "מי", ("مين", "m iː n"), pair="i / iː"),
101
- ]
102
- BY_ID = {item["id"]: item for item in ITEMS}
103
-
104
- # A deliberately small, single-take screen for comparing acoustic models with Hebrew speakers. Unlike the full
105
- # diagnostic list above, this is not saved as labelled benchmark data: the same take is scored by every comparison
106
- # model and shown side by side. It mixes the two pharyngeals, Hebrew ח' /x/, emphatics and short/long vowels. زمن
107
- # is intentional: Hebrew speakers commonly insert an alif-like long /a:/ and say zama:n although both vowels are
108
- # short. كتاب is the positive control, where the alif really should be long.
109
- QUICK_QUIZ = [
110
- _item("quick-halib", "quick", "ħ", "חַלִיב — ח גרונית", ("حليب", "ħ a l iː b")),
111
- _item("quick-baid", "quick", "ʕ", "בַּעִיד — ע גרונית", ("بعيد", "b a ʕ iː d")),
112
- _item("quick-khali", "quick", "x", "חַ'אלִי — ח׳", ("خالي", "x aː l i")),
113
- _item("quick-zaman", "quick", "a", "זַמַן — שתי תנועות קצרות (לא זַמַאן)", ("زمن", "z a m a n")),
114
- _item("quick-kitab", "quick", "aː", "כִּתַאבּ — אליף ארוכה", ("كتاب", "k i t aː b")),
115
- _item("quick-sabah", "quick", "ħ", "סַבַּאח — ח בסוף ואליף ארוכה", ("صباح", "sˤ a b aː ħ")),
116
- _item("quick-sual", "quick", "ʔ", "סֻאַאל — המזה ואליף ארוכה", ("سؤال", "s u ʔ aː l")),
117
- _item("quick-tawle", "quick", "tˤ", "טַאוְלֶה — ט נחצית", ("طاولة", "tˤ aː w l e")),
118
- _item("quick-zghir", "quick", "ɣ", "זְעִ'יר — ע׳", ("صغير", "sˤ ɣ iː r")),
119
- _item("quick-bint", "quick", "i", "בִּנְת — תנועה קצרה", ("بنت", "b i n t")),
120
- ]
121
- QUICK_BY_ID = {item["id"]: item for item in QUICK_QUIZ}
122
- MINERVA_METADATA = Path(__file__).resolve().parent.parent / "data" / "minerva_metadata.json"
123
-
124
-
125
- @lru_cache(maxsize=1)
126
- def minerva_metadata() -> tuple[dict, ...]:
127
- """The reviewed Minerva captions used by both the lab page and every rescore.
128
-
129
- Supabase owns the recording and its historical result; this file owns the sentence, timing and expected phones.
130
- Keeping that boundary explicit means an OCR correction takes effect on the next server start without reimporting
131
- audio or editing database rows.
132
- """
133
- raw = json.loads(MINERVA_METADATA.read_text(encoding="utf-8"))
134
- if not isinstance(raw, list):
135
- raise ValueError("Minerva metadata must be a JSON array")
136
- videos, seen = [], set()
137
- for video in raw:
138
- video_id = str(video.get("youtube_id") or "")
139
- segments = video.get("segments")
140
- if not video_id or video_id in seen or not isinstance(segments, list):
141
- raise ValueError(f"invalid Minerva video metadata: {video_id or '<missing id>'}")
142
- seen.add(video_id)
143
- lines = []
144
- for line, segment in enumerate(segments, 1):
145
- timing = segment.get("voice_time_seconds") or {}
146
- required = (segment.get("arabic"), segment.get("hebrew_transliteration"),
147
- segment.get("hebrew"), segment.get("expected_phonemes"))
148
- if not all(isinstance(value, str) and value.strip() for value in required):
149
- raise ValueError(f"incomplete Minerva metadata: {video_id}:{line:02d}")
150
- try:
151
- start, end = float(timing["start"]), float(timing["end"])
152
- except (KeyError, TypeError, ValueError) as exc:
153
- raise ValueError(f"invalid Minerva timing: {video_id}:{line:02d}") from exc
154
- if end <= start:
155
- raise ValueError(f"invalid Minerva timing: {video_id}:{line:02d}")
156
- lines.append({"item": f"minerva:{video_id}:{line:02d}", "line": line, "start": start, "end": end,
157
- "ar": required[0], "tr": required[1], "he": required[2], "ipa": required[3]})
158
- videos.append({"id": video_id, "title": str(video.get("title_hebrew") or video_id),
159
- "filename": str(video.get("file_name") or ""), "segments": tuple(lines)})
160
- return tuple(videos)
161
-
162
-
163
- def minerva_segments() -> dict[str, dict]:
164
- """Reviewed Minerva segments by their persisted lab item key."""
165
- return {segment["item"]: segment for video in minerva_metadata() for segment in video["segments"]}
166
-
167
-
168
- def phrase(item: dict) -> dict:
169
- """An item as the scorer takes a phrase."""
170
- return {"cat": "lab", "key": f"lab:{item['id']}", "he": item["he"], "variants": item["variants"]}
171
-
172
-
173
- def sample_phrase(sample: dict) -> dict | None:
174
- """The phrase of a saved recording, including imported lines outside the fixed diagnostic list.
175
-
176
- Fixed items keep their current variants. An importer stores the exact Arabic/IPA pair that was used for
177
- scoring, so later rescores, sweeps and traces do not depend on the importer's manifest still being present.
178
- """
179
- item = BY_ID.get(sample.get("item"))
180
- if item is not None:
181
- return phrase(item)
182
- segment = minerva_segments().get(sample.get("item"))
183
- if segment is not None:
184
- return {"cat": "lab", "key": segment["item"], "he": segment["he"],
185
- "variants": [(segment["ar"], segment["ipa"])]}
186
- ar, ipa = sample.get("ar"), sample.get("ipa")
187
- if not ar or not ipa:
188
- return None
189
- key = str(sample.get("item") or f"sample:{sample.get('id', '')}")
190
- return {"cat": "lab", "key": key, "he": sample.get("speaker") or key, "variants": [(ar, ipa)]}
191
-
192
-
193
- def public_items() -> list[dict]:
194
- """The list for the lab's page (teachers only): each item with its first reading's sounds, which a planted
195
- mistake is marked on, and that reading in Hebrew letters."""
196
- out = []
197
- for item in ITEMS:
198
- ar, ipa = item["variants"][0]
199
- out.append({"id": item["id"], "group": item["group"], "focus": item["focus"], "he": item["he"],
200
- "pair": item["pair"], "ar": ar, "tr": " ".join(to_hebrew(ipa, ar)),
201
- "sounds": [s for s in ipa.split() if s != "|"]})
202
- return out
203
-
204
-
205
- def public_quick_quiz() -> list[dict]:
206
- """The ten model-comparison prompts, without exposing any extra scorer internals."""
207
- return [{"id": item["id"], "number": number, "focus": item["focus"], "he": item["he"],
208
- "ar": item["variants"][0][0], "tr": " ".join(to_hebrew(item["variants"][0][1], item["variants"][0][0]))}
209
- for number, item in enumerate(QUICK_QUIZ, 1)]
210
-
211
-
212
- # ---------- the benchmark ----------
213
- # A recording's sounds are judged against its label: a sound said right should not be flagged (one that is, is a false
214
- # rejection), and the planted mistake should be (one that is not, is a miss). The numbers are per sound as written.
215
- GOOD_SCORE = 75 # a recording said right should score at least this (the card's "מצוין")
216
-
217
-
218
- def flag_phrase(row: dict) -> dict:
219
- """The bank phrase a flag was said for, or (a verb-game form, a teacher's text line) the variant it was scored on."""
220
- from .phrases import PHRASES
221
-
222
- key = row.get("phrase")
223
- for p in PHRASES:
224
- if p.get("key", p["he"]) == key:
225
- return p
226
- return {"variants": [(row["ar"], row["ipa"])], "key": key, "he": row.get("he", ""), "cat": row.get("cat", "")}
227
-
228
-
229
- def flagged_sounds(result: dict) -> list[str]:
230
- """The sounds a result marked down, as "expected>heard"."""
231
- return [f"{p.get('sound', p['ipa'])}>{p.get('heard') or '_'}" for w in result.get("words", [])
232
- for p in w.get("phonemes", []) if p.get("flagged")]
233
-
234
-
235
- def judged(sample: dict, result: dict | None = None) -> list[dict]:
236
- """The sounds of a lab recording, each {"sound", "planted", "flagged", "score", "heard"}: `planted` is the one the
237
- speaker said wrong on purpose (the first sound written as the label's; the scorer may have chosen another reading
238
- of the item, so it is found by sound and not by place). `result` replaces the stored one (a rescore)."""
239
- result = result or sample.get("result") or {}
240
- phones = [p for w in result.get("words", []) for p in w.get("phonemes", [])]
241
- want = (sample.get("mistake") or {}).get("expected") if sample.get("label") == "mistake" else None
242
- planted = next((k for k, p in enumerate(phones) if want in (p["ipa"], p.get("sound"))), None) if want else None
243
- return [{"sound": p["ipa"], "planted": k == planted, "flagged": bool(p.get("flagged")), "score": p["score"],
244
- "heard": p.get("heard") or "_"} for k, p in enumerate(phones)]
245
-
246
-
247
- def benchmark(samples: list[dict], results: dict | None = None, reports: list[dict] = ()) -> dict:
248
- """Per-sound numbers of the lab's recordings. `results` ({sample id: result}) replaces their stored results, for a
249
- rescore with the current scorer; `reports` are the confirmed flags, whose `false_issues` are false rejections a
250
- teacher found in practice (counted per sound, without a denominator: what was said right is not known there).
251
- {"sounds": [{"sound", "right", "false_flags", "frr", "planted", "caught", "detection", "mean", "confusions",
252
- "reports"}], "totals": {...}}, the sounds that went wrong most first."""
253
- results = results or {}
254
- rows: dict[str, dict] = {}
255
-
256
- def row(sound: str) -> dict:
257
- return rows.setdefault(sound, {"sound": sound, "right": 0, "false_flags": 0, "planted": 0, "caught": 0,
258
- "scores": [], "confusions": {}, "reports": 0})
259
-
260
- totals = {"recordings": 0, "correct": 0, "mistake": 0, "unrelated": 0, "good": 0, "right": 0, "false_flags": 0,
261
- "planted": 0, "caught": 0, "unplaced": 0, "said_right": 0}
262
- for s in samples:
263
- result = results.get(s.get("id")) or s.get("result") or {}
264
- if not result.get("words"):
265
- continue
266
- totals["recordings"] += 1
267
- totals[s["label"] if s.get("label") in ("correct", "mistake") else "correct"] += 1
268
- if result.get("unrelated"): # a different sentence: its sounds tell nothing
269
- totals["unrelated"] += 1
270
- continue
271
- if s.get("label") != "mistake":
272
- totals["said_right"] += 1
273
- totals["good"] += result.get("score", 0) >= GOOD_SCORE
274
- sounds = judged(s, result)
275
- if s.get("label") == "mistake" and not any(x["planted"] for x in sounds):
276
- totals["unplaced"] += 1
277
- for x in sounds:
278
- r = row(x["sound"])
279
- if x["planted"]:
280
- r["planted"] += 1; totals["planted"] += 1
281
- if x["flagged"]:
282
- r["caught"] += 1; totals["caught"] += 1
283
- continue
284
- r["right"] += 1; totals["right"] += 1
285
- r["scores"].append(x["score"])
286
- if x["flagged"]:
287
- r["false_flags"] += 1; totals["false_flags"] += 1
288
- r["confusions"][x["heard"]] = r["confusions"].get(x["heard"], 0) + 1
289
- for f in reports:
290
- for issue in f.get("false_issues") or []:
291
- if issue.get("expected"):
292
- row(issue["expected"])["reports"] += 1
293
-
294
- rate = lambda a, b: round(a / b, 3) if b else None
295
- out = []
296
- for r in rows.values():
297
- scores = r.pop("scores")
298
- r["mean"] = round(sum(scores) / len(scores), 1) if scores else None
299
- r["frr"], r["detection"] = rate(r["false_flags"], r["right"]), rate(r["caught"], r["planted"])
300
- r["confusions"] = dict(sorted(r["confusions"].items(), key=lambda kv: -kv[1]))
301
- out.append(r)
302
- out.sort(key=lambda r: (-(r["frr"] or 0), -(1 - r["detection"] if r["detection"] is not None else 0), -r["reports"],
303
- r["sound"]))
304
- totals.update(frr=rate(totals["false_flags"], totals["right"]), detection=rate(totals["caught"], totals["planted"]),
305
- good_share=rate(totals["good"], totals["said_right"]))
306
- return {"sounds": out, "totals": totals}
307
-
308
-
309
- def compact(result: dict) -> dict:
310
- """What the benchmark reads of a result (the sounds, their scores and flags), for the page to send back."""
311
- keep = ("ipa", "sound", "score", "flagged", "heard")
312
- return {"score": result.get("score"), "heard_ipa": result.get("heard_ipa", ""),
313
- "unrelated": bool(result.get("unrelated")),
314
- "words": [{**{k: w[k] for k in ("ar", "tr", "heard_tr", "score") if k in w},
315
- "phonemes": [{k: p[k] for k in keep if k in p} for p in w.get("phonemes", [])]}
316
- for w in result.get("words", [])]}
317
-
318
-
319
- def still_flagged(result: dict, issue: dict) -> bool:
320
- """Whether a sound a teacher marked as wrongly flagged (a confirmed flag's false issue: its word and written sound)
321
- is flagged again in this result."""
322
- words = result.get("words", [])
323
- if not 0 <= issue.get("word", -1) < len(words):
324
- return False
325
- return any(p.get("flagged") and p["ipa"] == issue.get("expected") for p in words[issue["word"]].get("phonemes", []))
326
-
327
-
328
- # ---------- the sweep over the GOP settings ----------
329
- # Every GOP_RATIO_POOL with every GOP_VOWEL_WINDOW that differs in practice (a larger pad reaches the wide window).
330
- WINDOWS = (None, 0, 1, 2)
331
- SWEEP = [(pool, window) for pool in POOLS for window in WINDOWS]
332
-
333
-
334
- def sweep_key(pool: str, window: int | None) -> str:
335
- """The name of a setting, as the env writes it: "gated|wide", "max|1"."""
336
- return f"{pool}|{'wide' if window is None else window}"
337
-
338
-
339
- SWEEP_KEYS = {sweep_key(p, w): (p, w) for p, w in SWEEP}
340
-
341
-
342
- def quality(totals: dict) -> float | None:
343
- """The pipeline's one number, 0..100: balanced accuracy over the judged sounds, the mean of
344
- (1 - false rejection rate) and the detection rate. Each half is a rate, so the many sounds said right do not
345
- drown the few planted mistakes, and a setting cannot win by flagging nothing (detection 0) or everything
346
- (false rejections 1). Without planted mistakes it is 1 - frr alone (see `quality_partial`). None: no sound judged."""
347
- frr, det = totals.get("frr"), totals.get("detection")
348
- if frr is None:
349
- return None
350
- return round(100 * ((1 - frr) + det) / 2, 1) if det is not None else round(100 * (1 - frr), 1)
351
-
352
-
353
- def quality_partial(totals: dict) -> bool:
354
- """True when `quality` could not weigh the detection (no planted mistake was judged)."""
355
- return totals.get("detection") is None
356
-
357
-
358
- def vowel_stats(samples: list[dict], results: dict | None = None) -> dict:
359
- """The vowels of the recordings, by score rather than flag: the scorer flags no vowel but the u of rule 3.11, so the
360
- flag counts of `benchmark` cannot see a GOP change on vowels. Over recordings of the item's sentence (not
361
- "unrelated"): {"right": vowels said right, "mean": their mean score, "weak": the share of them under LETTER_GOOD
362
- (shown to the learner as weak), "planted": planted vowel mistakes, "auc": the chance that a vowel said right
363
- scores above a planted wrong one (ties count half; None without planted vowels)}."""
364
- results = results or {}
365
- right, wrong = [], []
366
- for s in samples:
367
- result = results.get(s.get("id")) or s.get("result") or {}
368
- if not result.get("words") or result.get("unrelated"):
369
- continue
370
- for x in judged(s, result):
371
- if x["sound"] in VOWELS:
372
- (wrong if x["planted"] else right).append(x["score"])
373
- auc = None
374
- if right and wrong:
375
- auc = round(sum(1.0 if r > w else 0.5 if r == w else 0.0 for r in right for w in wrong) / (len(right) * len(wrong)), 3)
376
- return {"right": len(right), "mean": round(sum(right) / len(right), 1) if right else None,
377
- "weak": round(sum(x < LETTER_GOOD for x in right) / len(right), 3) if right else None,
378
- "planted": len(wrong), "auc": auc}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/library.py DELETED
@@ -1,78 +0,0 @@
1
- """Shared digital-library validation and persistence operations."""
2
- from __future__ import annotations
3
-
4
- import logging
5
- from typing import Callable
6
-
7
- from .auth import AuthUnavailable, SupabaseAuth
8
-
9
-
10
- MAX_TITLE = 80
11
- log = logging.getLogger("ihki.library")
12
-
13
-
14
- class LibraryError(ValueError):
15
- """A safe, user-facing library operation error."""
16
-
17
-
18
- class LibraryUnavailable(LibraryError):
19
- """The optional persistent content store cannot be used."""
20
-
21
-
22
- def normalize_title(value: object) -> str:
23
- title = " ".join(str(value or "").split())
24
- if not title or len(title) > MAX_TITLE:
25
- raise LibraryError(f"לסרטון צריך שם (עד {MAX_TITLE} תווים)")
26
- return title
27
-
28
-
29
- def normalize_url(value: object, parse: Callable[[str], str | None]) -> str:
30
- url = parse(str(value or ""))
31
- if not url:
32
- raise LibraryError("זה לא קישור לסרטון ב-Vimeo או ב-YouTube")
33
- return url
34
-
35
-
36
- def rows(auth: SupabaseAuth | None, *, fresh: bool = False) -> list[dict]:
37
- if auth is None:
38
- raise LibraryUnavailable("ספריית הסרטונים אינה זמינה: Supabase אינו מוגדר")
39
- try:
40
- return auth.level_videos("library", fresh=fresh)
41
- except AuthUnavailable as exc:
42
- raise LibraryUnavailable(f"ספריית הסרטונים אינה זמינה: {exc}") from exc
43
-
44
-
45
- def ensure_unique(auth: SupabaseAuth | None, url: str, *, fresh: bool = True) -> None:
46
- if any(video.get("url") == url for video in rows(auth, fresh=fresh)):
47
- raise LibraryError("הסרטון כבר נמצא בספרייה הדיגיטלית")
48
-
49
-
50
- def add(auth: SupabaseAuth | None, title: str, url: str, *, reject_duplicate: bool = True) -> None:
51
- if reject_duplicate:
52
- ensure_unique(auth, url, fresh=True)
53
- try:
54
- assert auth is not None
55
- auth.add_level_video("library", "", title, url)
56
- except AuthUnavailable as exc:
57
- raise LibraryUnavailable(f"לא ניתן להוסיף לספרייה: {exc}") from exc
58
-
59
-
60
- def rename(auth: SupabaseAuth | None, video_id: int, title: str) -> None:
61
- if not isinstance(video_id, int) or isinstance(video_id, bool) or video_id <= 0:
62
- raise LibraryError("מזהה הסרטון אינו תקין")
63
- if all(video.get("id") != video_id for video in rows(auth, fresh=True)):
64
- raise LibraryError("הסרטון אינו נמצא בספרייה")
65
- try:
66
- assert auth is not None
67
- auth.rename_level_video("library", video_id, title)
68
- except AuthUnavailable as exc:
69
- raise LibraryUnavailable(f"לא ניתן לשנות את שם הסרטון: {exc}") from exc
70
-
71
-
72
- def audit(auth: SupabaseAuth | None, action: str, label: str, outcome: str, **details: object) -> None:
73
- """Best-effort audit using the existing management activity feed."""
74
- log.info("Grok library action=%s outcome=%s label=%s details=%s", action, outcome, label[:200], details)
75
- if auth is None or not hasattr(auth, "log_event"):
76
- return
77
- auth.log_event("integration", {"id": None, "email": "grok-library"}, label,
78
- {"action": action, "outcome": outcome, **details})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/live.py DELETED
@@ -1,181 +0,0 @@
1
- """Temporary live-class state.
2
-
3
- Live sessions deliberately live in process memory: class/teacher assignments are durable in
4
- Supabase, while attendance, questions and answers disappear when a session ends or the service
5
- restarts. The browser polls this store; no recording bytes are retained here.
6
- """
7
- from __future__ import annotations
8
-
9
- import secrets
10
- import threading
11
- import time
12
- from datetime import datetime, timezone
13
-
14
-
15
- SESSION_TTL = 2 * 60 * 60
16
- PRESENCE_TTL = 20
17
-
18
-
19
- def _now_iso() -> str:
20
- return datetime.now(timezone.utc).isoformat()
21
-
22
-
23
- class LiveConflict(ValueError):
24
- pass
25
-
26
-
27
- class LiveStore:
28
- def __init__(self):
29
- self._lock = threading.Lock()
30
- self._sessions: dict[str, dict] = {}
31
-
32
- def _prune(self, now: float) -> None:
33
- for code, session in list(self._sessions.items()):
34
- if session["ended"] or now - session["teacher_seen"] > SESSION_TTL:
35
- self._sessions.pop(code, None)
36
-
37
- def _new_code(self) -> str:
38
- for _ in range(100):
39
- code = f"{secrets.randbelow(1_000_000):06d}"
40
- if code not in self._sessions:
41
- return code
42
- raise RuntimeError("could not allocate a live-session code")
43
-
44
- def start(self, course: dict, teacher: dict) -> dict:
45
- now = time.monotonic()
46
- with self._lock:
47
- self._prune(now)
48
- existing = next((s for s in self._sessions.values() if s["class_id"] == course["id"]), None)
49
- if existing:
50
- existing["teacher_seen"] = now
51
- return self._public(existing)
52
- code = self._new_code()
53
- roster = {email: (course.get("nicknames") or {}).get(email) or email.split("@", 1)[0]
54
- for email in course.get("emails", [])}
55
- session = {"code": code, "class_id": course["id"], "class_name": course["name"],
56
- "teacher_id": teacher["id"], "teacher_email": teacher["email"].lower(),
57
- "started_at": _now_iso(), "teacher_seen": now, "ended": False,
58
- "question": None, "participants": {}, "roster": roster, "answers": {}, "question_no": 0}
59
- self._sessions[code] = session
60
- return self._public(session)
61
-
62
- def list_for(self, class_ids: set[int], user_id: str) -> list[dict]:
63
- now = time.monotonic()
64
- with self._lock:
65
- self._prune(now)
66
- return [self._public(s) for s in self._sessions.values()
67
- if s["class_id"] in class_ids or user_id in s["participants"]]
68
-
69
- def get(self, code: str) -> dict | None:
70
- now = time.monotonic()
71
- with self._lock:
72
- self._prune(now)
73
- return self._sessions.get(code)
74
-
75
- def presence_summary(self, class_ids: set[int], *, now: float | None = None) -> dict:
76
- """Return identity-free presence for active sessions in the selected classes."""
77
- instant = time.monotonic() if now is None else float(now)
78
- selected = {int(class_id) for class_id in class_ids}
79
- with self._lock:
80
- self._prune(instant)
81
- sessions = [session for session in self._sessions.values() if session["class_id"] in selected]
82
- active = {user_id for session in sessions for user_id, person in session["participants"].items()
83
- if instant - person["last_seen"] <= PRESENCE_TTL}
84
- return {"active_session": bool(sessions), "active_users": len(active),
85
- "heartbeat_ttl_seconds": PRESENCE_TTL, "ephemeral": True}
86
-
87
- def join(self, code: str, user: dict, name: str = "") -> dict:
88
- now = time.monotonic()
89
- with self._lock:
90
- self._prune(now)
91
- session = self._sessions.get(code)
92
- if not session:
93
- raise KeyError(code)
94
- session["participants"][user["id"]] = {"email": user["email"].lower(),
95
- "name": name or user["email"].split("@", 1)[0],
96
- "last_seen": now}
97
- return self._public(session)
98
-
99
- def heartbeat(self, code: str, user: dict, teacher: bool = False) -> dict:
100
- now = time.monotonic()
101
- with self._lock:
102
- self._prune(now)
103
- session = self._sessions.get(code)
104
- if not session:
105
- raise KeyError(code)
106
- if teacher:
107
- session["teacher_seen"] = now
108
- elif user["id"] in session["participants"]:
109
- session["participants"][user["id"]]["last_seen"] = now
110
- else:
111
- raise PermissionError(code)
112
- return self._detail(session, user["id"], teacher, now)
113
-
114
- def ask(self, code: str, phrase: dict) -> dict:
115
- now = time.monotonic()
116
- with self._lock:
117
- session = self._sessions.get(code)
118
- if not session:
119
- raise KeyError(code)
120
- variants = phrase.get("variants") or []
121
- arabic = phrase.get("ar") or (variants[0][0] if variants else "")
122
- session["question_no"] += 1
123
- session["question"] = {"no": session["question_no"], "phrase_id": phrase["id"],
124
- "he": phrase["he"], "ar": arabic, "tr": phrase["tr"],
125
- "started_at": _now_iso()}
126
- session["answers"] = {}
127
- session["teacher_seen"] = now
128
- return self._public(session)
129
-
130
- def begin_answer(self, code: str, user_id: str) -> dict:
131
- with self._lock:
132
- session = self._sessions.get(code)
133
- if not session or not session["question"]:
134
- raise KeyError(code)
135
- if user_id not in session["participants"]:
136
- raise PermissionError(code)
137
- if user_id in session["answers"]:
138
- raise LiveConflict("one attempt only")
139
- session["answers"][user_id] = {"pending": True}
140
- return dict(session["question"])
141
-
142
- def cancel_answer(self, code: str, user_id: str) -> None:
143
- with self._lock:
144
- session = self._sessions.get(code)
145
- if session and session["answers"].get(user_id, {}).get("pending"):
146
- session["answers"].pop(user_id, None)
147
-
148
- def finish_answer(self, code: str, user_id: str, answer: dict) -> None:
149
- with self._lock:
150
- session = self._sessions.get(code)
151
- if not session or user_id not in session["answers"]:
152
- raise KeyError(code)
153
- session["answers"][user_id] = answer
154
-
155
- def end(self, code: str) -> None:
156
- with self._lock:
157
- if code not in self._sessions:
158
- raise KeyError(code)
159
- self._sessions.pop(code, None)
160
-
161
- @staticmethod
162
- def _public(session: dict) -> dict:
163
- return {k: session[k] for k in ("code", "class_id", "class_name", "started_at", "question")}
164
-
165
- def _detail(self, session: dict, user_id: str, teacher: bool, now: float) -> dict:
166
- base = self._public(session)
167
- if teacher:
168
- participants = []
169
- joined_emails = set()
170
- for uid, person in session["participants"].items():
171
- joined_emails.add(person["email"])
172
- answer = session["answers"].get(uid)
173
- participants.append({"id": uid, "name": person["name"], "email": person["email"],
174
- "online": now - person["last_seen"] <= PRESENCE_TTL,
175
- "answer": None if not answer or answer.get("pending") else answer})
176
- participants += [{"id": "", "name": name, "email": email, "online": False, "answer": None}
177
- for email, name in session["roster"].items() if email not in joined_emails]
178
- return {**base, "participants": participants}
179
- answer = session["answers"].get(user_id)
180
- return {**base, "answered": user_id in session["answers"],
181
- "answer": None if not answer or answer.get("pending") else answer}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/main.py CHANGED
The diff for this file is too large to render. See raw diff
 
app/math_photo.py CHANGED
@@ -5,8 +5,8 @@ the problem as it read it (so the student can check the reading), the solution a
5
  not give the step away and the step itself, the final answer, and the path's skill the problem practises. The page
6
  shows the hints one by one, and the steps and the answer only when the student asks for them.
7
 
8
- Runs on the server's Anthropic key (the tutor's, ANTHROPIC_API_KEY or the panel's), on MATH_MODEL (default
9
- DEFAULT_MODEL). The photo is sent to the API and not kept.
10
  """
11
  from __future__ import annotations
12
 
 
5
  not give the step away and the step itself, the final answer, and the path's skill the problem practises. The page
6
  shows the hints one by one, and the steps and the answer only when the student asks for them.
7
 
8
+ Runs on the server's ``ANTHROPIC_API_KEY``, using ``MATH_MODEL`` (or ``DEFAULT_MODEL``).
9
+ The photo is sent to the API and is not retained.
10
  """
11
  from __future__ import annotations
12
 
app/model_settings.py DELETED
@@ -1,159 +0,0 @@
1
- """The scorer's settings a developer tunes per model from the panel's "מודלים" tab (stored in model_settings).
2
-
3
- A model starts from ScorerConfig's defaults and FLAG_BELOW_BY_MODEL (scorer.py). A stored row overrides some of them:
4
- {"flag_below": 45, "gop.leniency": 0.4, "flag_below_sound": {"ħ": 30, "ʕ": null}}
5
- `null` for a sound means "the general threshold", which also clears a built-in one (the base model's ħ at 20).
6
- Settings belong to the model they were tuned on: a threshold means something else under another model's scores.
7
- """
8
- from __future__ import annotations
9
-
10
- from dataclasses import replace
11
-
12
- from .align import POOLS
13
- from .scorer import ScorerConfig, scorer_config
14
-
15
- CREATE_MODEL_SETTINGS = ("create table public.model_settings (model text primary key, "
16
- "settings jsonb not null default '{}'::jsonb, updated_by text not null default '', "
17
- "updated_at timestamptz not null default now()); "
18
- "alter table public.model_settings enable row level security;")
19
-
20
- # key: a ScorerConfig field, or gop.<GopConfig field>. kind: number | int | choice | window (an int, or None: wide)
21
- FIELDS: list[dict] = [
22
- {"key": "flag_below", "group": "flag", "kind": "number", "min": 0, "max": 100, "step": 1,
23
- "label": "סף סימון כללי",
24
- "help": "הגה שקיבל ציון מתחת לסף מסומן כטעות ומקבל טיפ. הגים עם סף משלהם (למטה) משתמשים בו במקום זה."},
25
- {"key": "issue_below", "group": "flag", "kind": "number", "min": 0, "max": 100, "step": 1,
26
- "label": "סף \"לא מדויק\"",
27
- "help": "הגה מתחת לסף נרשם ברשימת ההגים כלא מדויק, גם בלי טיפ. גם תנועה ארוכה שנאמרה קצרה נמדדת מולו."},
28
- {"key": "max_issues", "group": "flag", "kind": "int", "min": 1, "max": 20, "step": 1,
29
- "label": "הכי הרבה הגים ברשימה", "help": "כמה הגים לא מדויקים מוצגים לתלמיד לכל היותר בכל הקלטה."},
30
- {"key": "unrelated_ratio", "group": "unrelated", "kind": "number", "min": 0.1, "max": 1, "step": 0.05,
31
- "label": "סף \"משפט אחר\"",
32
- "help": "כמה רחוק מה שנשמע מהמשפט הצפוי עד שהמערכת אומרת שנאמר משפט אחר. גבוה יותר: סלחני יותר."},
33
- {"key": "unrelated_veto_share", "group": "unrelated", "kind": "number", "min": 0, "max": 1, "step": 0.05,
34
- "label": "וטו: חלק העיצורים שנמצאו",
35
- "help": "אם לפחות החלק הזה של העיצורים נמצא בבירור ביישור, המשפט לא ייחשב אחר גם כשהמרחק גדול."},
36
- {"key": "unrelated_veto_mean", "group": "unrelated", "kind": "number", "min": 0, "max": 100, "step": 1,
37
- "label": "וטו: ציון ממוצע מינימלי", "help": "וטו \"משפט אחר\" חל רק כשהציון הממוצע של ההגים לפחות כזה."},
38
- {"key": "near_vowel_floor", "group": "floors", "kind": "number", "min": 0, "max": 100, "step": 1,
39
- "label": "תנועה שנשמעה כשכנתה",
40
- "help": "ציון מינימום לתנועה שנשמעה כתנועה קרובה (i/e, u/o, קצרה/ארוכה): הבדל שלא כדאי להעניש עליו."},
41
- {"key": "ayin_deleted_floor", "group": "floors", "kind": "number", "min": 0, "max": 100, "step": 1,
42
- "label": "ע שלא נשמעה",
43
- "help": "ציון מינימום ל־ע שהמודל לא שמע בכלל: המודל המקורי מפיל לעתים ע שנאמרה נכון."},
44
- {"key": "onset_transient_floor", "group": "floors", "kind": "number", "min": 0, "max": 100, "step": 1,
45
- "label": "עיצור ראשון מול רעש פתיחה", "help": "ציון מינימום לעיצור ראשון שהפסיד לקפיצה של פריים אחד בתחילת ההקלטה."},
46
- {"key": "ref_noise_floor", "group": "floors", "kind": "number", "min": 0, "max": 100, "step": 1,
47
- "label": "הגה שהמודל לא מודד במשפט",
48
- "help": "אם הקול הילידי (TTS) קיבל על הגה ציון מתחת לזה, ההגה לא נמדד במשפט הזה והתלמיד מקבל עליו ציון מלא."},
49
- {"key": "ref_min_mean", "group": "floors", "kind": "number", "min": 0, "max": 100, "step": 1,
50
- "label": "אמינות הקול הילידי",
51
- "help": "קול ילידי שהממוצע שלו מתחת לזה לא משמש בסיס להשוואה."},
52
- {"key": "word_min_weight", "group": "floors", "kind": "number", "min": 0, "max": 1, "step": 0.05,
53
- "label": "משקל ההגה החלש במילה", "help": "כמה הגה אחד חלש מוריד את ציון המילה כולה (0: לא משפיע, 1: קובע לבד)."},
54
- {"key": "gop.leniency", "group": "gop", "kind": "number", "min": 0, "max": 1, "step": 0.05,
55
- "label": "רכות הציון",
56
- "help": "כמה הציון סלחני כשהמוד�� מתלבט בין ההגה הנכון לאחר. 0: ליניארי וקשוח; גבוה יותר: מתחרה כפול עדיין מקבל ציון סביר."},
57
- {"key": "gop.confident_peak", "group": "gop", "kind": "number", "min": 0.05, "max": 0.95, "step": 0.05,
58
- "label": "ביטחון מספיק", "help": "מאיזו הסתברות של ההגה הנכון השיא כבר לא מוריד את הציון."},
59
- {"key": "gop.ratio_pool", "group": "gop", "kind": "choice", "choices": list(POOLS),
60
- "label": "איחוד הפריימים", "help": "איך מצרפים את הפריימים של הגה לציון אחד (docs/gop-pooling.md). gated: רק הפריימים הרועשים."},
61
- {"key": "gop.energy_gate", "group": "gop", "kind": "number", "min": 0, "max": 1, "step": 0.05,
62
- "label": "סף עוצמה (gated)", "help": "פריימים שקטים מהחלק הזה של הפריים החזק ביותר לא נספרים."},
63
- {"key": "gop.vowel_window", "group": "gop", "kind": "window", "min": 0, "max": 10,
64
- "label": "חלון התנועה", "help": "מכמה פריימים סביב תנועה קוראים את המתחרים שלה. רחב: כמו עיצורים."},
65
- {"key": "gop.consonant_window", "group": "gop", "kind": "window", "min": 1, "max": 12,
66
- "label": "חלון העיצור", "help": "כמה פריימים של שקט סביב עיצור נכללים בקריאה שלו. רחב: עד ההגה הבא."},
67
- ]
68
- GROUPS = {"flag": "סימון טעויות", "unrelated": "זיהוי \"משפט אחר\"", "floors": "רצפות והקלות",
69
- "gop": "ניקוד ההגה (מתקדם)"}
70
- FIELD = {f["key"]: f for f in FIELDS}
71
-
72
- # Per-sound thresholds: the sounds tokens stand for (TargetToken.sound), grouped as a Hebrew speaker meets them.
73
- SOUND_GROUPS = [
74
- ("גרוניים", [("ħ", "ח (ح)"), ("ʕ", "ע (ع)"), ("h", "ה (ه)"), ("ʔ", "א (ء)"), ("x", "ח׳ (خ)"), ("ɣ", "ע׳ (غ)")]),
75
- ("מודגשים", [("tˤ", "ט (ط)"), ("sˤ", "צ (ص)"), ("dˤ", "ד׳ (ض)"), ("ZH", "ט׳ (ظ)")]),
76
- ("אותיות עם כמה הגיות", [("Q", "ק (ق)"), ("K", "כ (ك)"), ("J", "ג׳ (ج)"), ("TH", "ת׳ (ث)"), ("DH", "ד״ (ذ)")]),
77
- ("עיצורים נוספים", [("r", "ר (ر)"), ("l", "ל (ل)"), ("ʃ", "ש (ش)"), ("s", "ס (س)"), ("z", "ז (ز)"), ("t", "ת (ت)"),
78
- ("d", "ד (د)"), ("k", "כ (ك)"), ("b", "ב (ب)"), ("f", "פ (ف)"), ("m", "מ (م)"), ("n", "נ (ن)"),
79
- ("w", "ו (و)"), ("j", "י (ي)")]),
80
- ]
81
- SOUNDS = {s for _, group in SOUND_GROUPS for s, _ in group}
82
-
83
-
84
- def _get(cfg: ScorerConfig, key: str):
85
- return getattr(cfg.gop, key[4:]) if key.startswith("gop.") else getattr(cfg, key)
86
-
87
-
88
- def defaults(model_id: str) -> dict:
89
- """What a model runs with before any stored setting: the fields, and the sounds with a built-in threshold."""
90
- cfg = scorer_config(model_id)
91
- return {"fields": {f["key"]: _get(cfg, f["key"]) for f in FIELDS}, "sounds": dict(cfg.flag_below_sound)}
92
-
93
-
94
- def clean(raw: dict) -> dict:
95
- """A stored or submitted settings object, checked field by field. Raises ValueError (in Hebrew) on a bad one."""
96
- if not isinstance(raw, dict):
97
- raise ValueError("ההגדרות צריכות להיות אובייקט")
98
- out: dict = {}
99
- for key, value in raw.items():
100
- if key == "flag_below_sound":
101
- if not isinstance(value, dict):
102
- raise ValueError("ספי ההגים צריכים להיות אובייקט")
103
- sounds = {}
104
- for sound, t in value.items():
105
- if sound not in SOUNDS:
106
- raise ValueError(f"הגה לא מוכר: {sound}")
107
- if t is not None and (isinstance(t, bool) or not isinstance(t, (int, float)) or not 0 <= t <= 100):
108
- raise ValueError(f"סף ל־{sound} צריך להיות בין 0 ל־100")
109
- sounds[sound] = None if t is None else float(t)
110
- out[key] = sounds
111
- continue
112
- f = FIELD.get(key)
113
- if f is None:
114
- raise ValueError(f"הגדרה לא מוכרת: {key}")
115
- kind = f["kind"]
116
- if kind == "choice":
117
- if value not in f["choices"]:
118
- raise ValueError(f"{f['label']}: ערך לא מוכר")
119
- elif kind == "window":
120
- if value is not None and (isinstance(value, bool) or not isinstance(value, int)
121
- or not f["min"] <= value <= f["max"]):
122
- raise ValueError(f"{f['label']}: רחב, או מספר פריימים בין {f['min']} ל־{f['max']}")
123
- else:
124
- if isinstance(value, bool) or not isinstance(value, (int, float)) or not f["min"] <= value <= f["max"]:
125
- raise ValueError(f"{f['label']}: ערך בין {f['min']} ל־{f['max']}")
126
- if kind == "int" and int(value) != value:
127
- raise ValueError(f"{f['label']}: מספר שלם")
128
- value = int(value) if kind == "int" else float(value)
129
- out[key] = value
130
- return out
131
-
132
-
133
- def build(model_id: str, settings: dict | None) -> ScorerConfig:
134
- """The model's ScorerConfig with its stored settings applied."""
135
- cfg = scorer_config(model_id)
136
- settings = clean(settings or {})
137
- gop = {k[4:]: v for k, v in settings.items() if k.startswith("gop.")}
138
- top = {k: v for k, v in settings.items() if not k.startswith("gop.") and k != "flag_below_sound"}
139
- sounds = dict(cfg.flag_below_sound)
140
- for sound, t in settings.get("flag_below_sound", {}).items():
141
- if t is None:
142
- sounds.pop(sound, None)
143
- else:
144
- sounds[sound] = t
145
- return replace(cfg, **top, flag_below_sound=sounds, gop=replace(cfg.gop, **gop))
146
-
147
-
148
- def describe(model_id: str, settings: dict | None, sound_counts: dict[str, int]) -> dict:
149
- """Everything the tab draws: each field with its default and value, each sound with its threshold."""
150
- base, cfg = defaults(model_id), build(model_id, settings)
151
- stored = clean(settings or {})
152
- fields = [{**f, "group_label": GROUPS[f["group"]], "default": base["fields"][f["key"]], "value": _get(cfg, f["key"]),
153
- "changed": f["key"] in stored} for f in FIELDS]
154
- stored_sounds = stored.get("flag_below_sound", {})
155
- sounds = [{"group": name, "sounds": [{"sound": s, "label": label, "count": sound_counts.get(s, 0),
156
- "default": base["sounds"].get(s), "value": cfg.flag_below_sound.get(s),
157
- "changed": s in stored_sounds} for s, label in group]}
158
- for name, group in SOUND_GROUPS]
159
- return {"fields": fields, "sounds": sounds, "general": cfg.flag_below}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/models.py DELETED
@@ -1,107 +0,0 @@
1
- """The phoneme models the management panel's "מודלים" tab offers, with what each was measured to do.
2
-
3
- Numbers: Minerva test split (255 native lines), greedy CTC mapped onto the labels' phonemes, measured 2026-10-25
4
- with scripts/zero_shot_phone_models.py (docs/zero-shot-phone-models.md). Speeds: one 2.7 s recording, on this
5
- project's laptop GPU and on 2 CPU threads (about what the free Space has). POWSM runs padded to 8 s (app/powsm.py):
6
- its PER, consonant PER and long vowels below are measured that way; its other sounds at the 20 s they were first
7
- measured at (8 s changed PER by 0.4 points). The short-padding fine-tune runs unpadded, measured that way throughout.
8
- """
9
- from __future__ import annotations
10
-
11
- from pathlib import Path
12
-
13
- import os
14
-
15
- from .powsm import DEPS, ONNX_PREFIX, parse_model_id
16
-
17
- # the Hub repo the exported POWSM is uploaded to (scripts/export_powsm_onnx.py; README "POWSM on the Space")
18
- POWSM_ONNX_REPO = os.environ.get("POWSM_ONNX_REPO", "wzender/ihki-powsm-ft").strip()
19
-
20
- ROOT = Path(__file__).resolve().parent.parent
21
-
22
- # Per model: recall of each sound (None: the model has no symbol for it), PER and consonant PER.
23
- CATALOG: list[dict] = [
24
- {"id": "facebook/wav2vec2-xlsr-53-espeak-cv-ft", "name": "Facebook (מקורי)", "family": "wav2vec2",
25
- "summary": "המודל הרב־לשוני שהאתר נבנה עליו. לא אומן על ערבית פלסטינית: כמעט לא שומע ח ו־ע.",
26
- "trained": False, "free_space": True,
27
- "metrics": {"per": .38, "consonant_per": .29, "ħ": .09, "ʕ": .02, "tˤ": None, "sˤ": None, "dˤ": 0.0,
28
- "long_vowels": .45},
29
- "speed": {"gpu": .05, "cpu": .8}, "fps": 50},
30
- {"id": "checkpoints/ihki-ft-minerva", "name": "wav2vec2 מאומן", "family": "wav2vec2",
31
- "summary": "אותו מודל, מאומן על הקלטות Minerva. מחליף ישיר: אותו גודל ואותה מהירות, גם על השרת החינמי.",
32
- "trained": True, "free_space": True,
33
- "metrics": {"per": .23, "consonant_per": .15, "ħ": .94, "ʕ": .69, "tˤ": None, "sˤ": None, "dˤ": .04,
34
- "long_vowels": .74},
35
- "speed": {"gpu": .05, "cpu": .8}, "fps": 50},
36
- {"id": "checkpoints/powsm-ft-minerva/powsm_ft.pt", "name": "POWSM מאומן", "family": "POWSM-CTC",
37
- "summary": "מודל פונטי חדש (2026), מאומן על הקלטות Minerva. הכי מדויק, והיחיד ששומע את העיצורים המודגשים (ط ص ض).",
38
- "trained": True, "free_space": False,
39
- "metrics": {"per": .16, "consonant_per": .10, "ħ": .94, "ʕ": .89, "tˤ": .82, "sˤ": .77, "dˤ": .46,
40
- "long_vowels": .68},
41
- "speed": {"gpu": .29, "cpu": 1.8}, "fps": 25},
42
- {"id": "checkpoints/powsm-ft-shortpad/powsm_ft.pt", "name": "POWSM מאומן ומהיר", "family": "POWSM-CTC",
43
- "summary": "אותו POWSM מאומן, שאומן שוב גם על הקלטות קצרות בלי ריפוד: מדויק כמו המקורי ומהיר פי 5 במעבד.",
44
- "trained": True, "free_space": True,
45
- "metrics": {"per": .16, "consonant_per": .105, "ħ": .92, "ʕ": .90, "tˤ": .81, "sˤ": .68, "dˤ": .64,
46
- "long_vowels": .69},
47
- "speed": {"gpu": .06, "cpu": .8}, "fps": 25},
48
- {"id": f"{ONNX_PREFIX}{POWSM_ONNX_REPO}", "name": "POWSM מאומן ומהיר (לשרת)", "family": "POWSM-CTC · ONNX",
49
- "summary": "אותו מודל, מיוצא ל־ONNX: רץ בלי ESPnet, גם על השרת החינמי, ומהיר מהמודל הנוכחי. נטען מ־Hugging Face.",
50
- "trained": True, "free_space": True,
51
- "metrics": {"per": .165, "consonant_per": .105, "ħ": .92, "ʕ": .90, "tˤ": .81, "sˤ": .68, "dˤ": .64,
52
- "long_vowels": .69},
53
- "speed": {"gpu": None, "cpu": .57}, "fps": 25},
54
- {"id": "exports/powsm-ft-shortpad", "name": "POWSM מאומן ומהיר (ONNX מקומי)", "family": "POWSM-CTC · ONNX",
55
- "summary": "הייצוא ל־ONNX מהתיקייה המקומית, לפני ההעלאה ל־Hugging Face.",
56
- "trained": True, "free_space": True,
57
- "metrics": {"per": .165, "consonant_per": .105, "ħ": .92, "ʕ": .90, "tˤ": .81, "sˤ": .68, "dˤ": .64,
58
- "long_vowels": .69},
59
- "speed": {"gpu": None, "cpu": .57}, "fps": 25},
60
- {"id": "powsm:<ara>", "name": "POWSM (מקורי)", "family": "POWSM-CTC",
61
- "summary": "POWSM כפי שפורסם, עם הנחיית ערבית. שומע ח ו־ע היטב בלי אימון, אבל לא כותב תנועות ארוכות.",
62
- "trained": False, "free_space": False,
63
- "metrics": {"per": .37, "consonant_per": .22, "ħ": .87, "ʕ": .85, "tˤ": .66, "sˤ": .72, "dˤ": .54,
64
- "long_vowels": 0.0},
65
- "speed": {"gpu": .29, "cpu": 1.8}, "fps": 25},
66
- ]
67
-
68
-
69
- def availability(model_id: str) -> tuple[bool, str]:
70
- """(can this server load it, why not). A Hub id counts as loadable: it downloads on first use."""
71
- if model_id.startswith(ONNX_PREFIX) or (Path(model_id) / "powsm_onnx.json").is_file():
72
- try:
73
- import onnxruntime # noqa: F401
74
- except ImportError:
75
- return False, "חסרה החבילה onnxruntime בשרת (requirements.txt)"
76
- return True, ""
77
- if model_id.startswith("exports/"):
78
- return False, "הייצוא ל־ONNX לא נמצא בשרת (scripts/export_powsm_onnx.py)"
79
- powsm = parse_model_id(model_id)
80
- if powsm:
81
- if not (DEPS / "pkgs").exists():
82
- return False, "חסרות חבילות ההרצה של POWSM בשרת (scripts/setup_zero_shot_deps.sh)"
83
- if powsm[1] and not Path(powsm[1]).is_file():
84
- return False, "קובץ המודל לא נמצא בשרת"
85
- return True, ""
86
- local = Path(model_id)
87
- if model_id.startswith(("checkpoints/", "./", "/")) or local.exists():
88
- if not (local / "config.json").is_file():
89
- return False, "תיקיית המודל לא נמצאה בשרת"
90
- return True, ""
91
- return True, ""
92
-
93
-
94
- def catalog(active: str) -> list[dict]:
95
- """Every catalog model, plus the active one when it isn't in the catalog (a MODEL_ID of its own)."""
96
- rows = [dict(m) for m in CATALOG]
97
- if not any(m["id"] == active for m in rows):
98
- rows.insert(0, {"id": active, "name": "מודל מותאם", "family": "", "summary": "הוגדר ב־MODEL_ID; לא נמדד כאן.",
99
- "trained": None, "free_space": None, "metrics": None, "speed": None, "fps": None})
100
- for m in rows:
101
- m["available"], m["why"] = availability(m["id"])
102
- m["active"] = m["id"] == active
103
- return rows
104
-
105
-
106
- def known(model_id: str) -> bool:
107
- return any(m["id"] == model_id for m in CATALOG)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/monitoring.py DELETED
@@ -1,718 +0,0 @@
1
- """Read-only, privacy-preserving monitoring shared by MCP callers."""
2
- from __future__ import annotations
3
-
4
- import hashlib
5
- import math
6
- from collections import defaultdict
7
- from datetime import date, datetime, time, timedelta, timezone
8
- from typing import Any, Literal
9
- from zoneinfo import ZoneInfo
10
-
11
- from .access import has_permission
12
- from .auth import AuthUnavailable
13
- from .class_management import ClassManagementError, visible_classes
14
-
15
-
16
- LOCAL_TZ = ZoneInfo("Asia/Jerusalem")
17
- MAX_DAYS = 90
18
- INTERVALS = frozenset({"day", "week", "month"})
19
- STATUSES = frozenset({"healthy", "degraded", "unavailable", "not_configured"})
20
- SUCCESS_SCORE = 75
21
- MIN_DIFFICULTY_STUDENTS = 3
22
- MIN_DIFFICULTY_ATTEMPTS = 5
23
- MAX_DIFFICULTY_RESULTS = 20
24
- MAX_ANALYTICS_RESULTS = 50
25
- EXERCISE_TYPES = frozenset({"pronunciation", "verb", "game", "other"})
26
- CURRENT_ACTIVITY_WINDOWS = frozenset({5, 15})
27
-
28
-
29
- class MonitoringError(ValueError):
30
- """A safe monitoring error suitable for returning through MCP."""
31
-
32
-
33
- class MonitoringUnavailable(MonitoringError):
34
- pass
35
-
36
-
37
- def _date(value: object, label: str) -> date:
38
- if not isinstance(value, str):
39
- raise MonitoringError(f"{label} חייב להיות תאריך בפורמט YYYY-MM-DD")
40
- try:
41
- parsed = date.fromisoformat(value)
42
- except ValueError as exc:
43
- raise MonitoringError(f"{label} חייב להיות תאריך בפורמט YYYY-MM-DD") from exc
44
- if parsed.isoformat() != value:
45
- raise MonitoringError(f"{label} חייב להיות תאריך בפורמט YYYY-MM-DD")
46
- return parsed
47
-
48
-
49
- def validate_range(from_date: object, to_date: object, interval: object,
50
- *, today: date | None = None) -> tuple[date, date, str]:
51
- start, end = _date(from_date, "from_date"), _date(to_date, "to_date")
52
- current = today or datetime.now(LOCAL_TZ).date()
53
- if interval not in INTERVALS:
54
- raise MonitoringError("interval חייב להיות day, week או month")
55
- if start > end:
56
- raise MonitoringError("from_date חייב להיות לפני או שווה ל-to_date")
57
- if start > current or end > current:
58
- raise MonitoringError("אי אפשר לבקש טווח עתידי")
59
- if (end - start).days + 1 > MAX_DAYS:
60
- raise MonitoringError("אפשר לבקש עד 90 ימים")
61
- return start, end, str(interval)
62
-
63
-
64
- def _scope(auth: Any, actor: dict[str, Any], class_id: int | None) -> tuple[list[dict], dict[str, Any]]:
65
- if not has_permission(actor, "management.access"):
66
- raise MonitoringError("אין הרשאת ניהול")
67
- try:
68
- classes = visible_classes(auth, actor, fresh=True)
69
- except ClassManagementError as exc:
70
- raise MonitoringUnavailable("נתוני הפעילות אינם זמינים") from exc
71
- if class_id is not None:
72
- if not isinstance(class_id, int) or isinstance(class_id, bool) or class_id <= 0:
73
- raise MonitoringError("מזהה הכיתה אינו תקין")
74
- selected = [row for row in classes if row.get("id") == class_id]
75
- if not selected:
76
- raise MonitoringError("הכיתה אינה זמינה")
77
- row = selected[0]
78
- return selected, {"class_id": row["id"], "class_name": row.get("name", "")}
79
- return classes, {"class_id": None, "class_name": "כל הכיתות המורשות"}
80
-
81
-
82
- def _scoped_user_ids(auth: Any, classes: list[dict]) -> set[str]:
83
- emails = {str(email).strip().lower() for row in classes for email in row.get("emails", ()) if email}
84
- if not emails:
85
- return set()
86
- users = auth.users()
87
- return {str(user["id"]) for user in users
88
- if str(user.get("email") or "").strip().lower() in emails and user.get("id")}
89
-
90
-
91
- def _stamp(value: object) -> datetime:
92
- text = str(value or "").replace("Z", "+00:00")
93
- parsed = datetime.fromisoformat(text)
94
- return (parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)).astimezone(LOCAL_TZ)
95
-
96
-
97
- def _period_key(day: date, interval: str) -> date:
98
- if interval == "day":
99
- return day
100
- if interval == "week":
101
- return day - timedelta(days=day.weekday())
102
- return day.replace(day=1)
103
-
104
-
105
- def activity_timeseries(auth: Any, actor: dict[str, Any], *, from_date: object, to_date: object,
106
- interval: object = "day", class_id: int | None = None,
107
- today: date | None = None) -> dict[str, Any]:
108
- start, end, clean_interval = validate_range(from_date, to_date, interval, today=today)
109
- classes, scope = _scope(auth, actor, class_id)
110
- try:
111
- user_ids = _scoped_user_ids(auth, classes)
112
- since = datetime.combine(start, time.min, LOCAL_TZ).astimezone(timezone.utc)
113
- raw = auth.scoped_activity(user_ids, since, 60)
114
- except (AuthUnavailable, AttributeError, KeyError, TypeError) as exc:
115
- raise MonitoringUnavailable("נתוני הפעילות אינם זמינים") from exc
116
-
117
- keys: list[date] = []
118
- cursor = start
119
- while cursor <= end:
120
- key = _period_key(cursor, clean_interval)
121
- if not keys or keys[-1] != key:
122
- keys.append(key)
123
- cursor += timedelta(days=1)
124
- grouped = {key: {"date": key.isoformat(), "attempts": 0, "peak_active_students": 0} for key in keys}
125
- for item in raw.get("buckets", ()) if isinstance(raw, dict) else ():
126
- try:
127
- local_day = _stamp(item.get("bucket")).date()
128
- if not start <= local_day <= end:
129
- continue
130
- bucket = grouped.get(_period_key(local_day, clean_interval))
131
- if bucket is None:
132
- continue
133
- bucket["attempts"] += max(0, int(item.get("attempts") or 0))
134
- bucket["peak_active_students"] = max(
135
- bucket["peak_active_students"], max(0, int(item.get("active_students") or 0)))
136
- except (TypeError, ValueError, OverflowError):
137
- continue
138
-
139
- summary = raw.get("summary") if isinstance(raw, dict) and isinstance(raw.get("summary"), dict) else {}
140
- safe_summary = {key: max(0, int(summary.get(key) or 0))
141
- for key in ("attempts", "active_students", "returning_students", "active_days")}
142
- return {**scope, "from_date": start.isoformat(), "to_date": end.isoformat(),
143
- "interval": clean_interval, "series": list(grouped.values()), "summary": safe_summary}
144
-
145
-
146
- def _utc_iso(value: datetime) -> str:
147
- return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
148
-
149
-
150
- def _activity_stamp(value: object) -> datetime | None:
151
- try:
152
- return _stamp(value).astimezone(timezone.utc)
153
- except (TypeError, ValueError, OverflowError):
154
- return None
155
-
156
-
157
- def current_activity(auth: Any, actor: dict[str, Any], *, window_minutes: object = 5,
158
- class_id: int | None = None, checked_at: datetime | None = None,
159
- live_store: Any = None) -> dict[str, Any]:
160
- """Return scoped aggregate recency without treating recent attempts as presence."""
161
- if (not isinstance(window_minutes, int) or isinstance(window_minutes, bool)
162
- or window_minutes not in CURRENT_ACTIVITY_WINDOWS):
163
- raise MonitoringError("window_minutes חייב להיות 5 או 15")
164
- now = checked_at or datetime.now(timezone.utc)
165
- if now.tzinfo is None:
166
- now = now.replace(tzinfo=timezone.utc)
167
- now = now.astimezone(timezone.utc)
168
- classes, scope = _scope(auth, actor, class_id)
169
- try:
170
- user_ids = _scoped_user_ids(auth, classes)
171
- since = now - timedelta(minutes=window_minutes)
172
- rows, truncated = auth.scoped_attempts(user_ids, since, now + timedelta(microseconds=1))
173
- except (AuthUnavailable, AttributeError, KeyError, TypeError) as exc:
174
- raise MonitoringUnavailable("נתוני הפעילות אינם זמינים") from exc
175
- if truncated:
176
- raise MonitoringError("יש יותר מדי אירועים בחלון. נסו חלון קצר יותר.")
177
-
178
- events: dict[str, tuple[str, datetime]] = {}
179
- for index, row in enumerate(rows if isinstance(rows, list) else []):
180
- user_id = str(row.get("user_id") or "") if isinstance(row, dict) else ""
181
- stamp = _activity_stamp(row.get("created_at")) if isinstance(row, dict) else None
182
- if not user_id or user_id not in user_ids or stamp is None or not since <= stamp <= now:
183
- continue
184
- event_id = str(row.get("id") or f"row-{index}")
185
- events.setdefault(event_id, (user_id, stamp))
186
-
187
- attempts = len(events)
188
- learners = len({user_id for user_id, _ in events.values()})
189
- checked_iso = _utc_iso(now)
190
- threshold = {"minimum_students": MIN_DIFFICULTY_STUDENTS,
191
- "minimum_attempts": MIN_DIFFICULTY_ATTEMPTS}
192
- recent: dict[str, Any] = {
193
- "source": "persisted_attempts", "window_minutes": window_minutes,
194
- "checked_at": checked_iso, "collection_delay_seconds": None,
195
- "update_semantics": "current_as_of_database_read",
196
- "presence_claim": False,
197
- }
198
- last_practice: dict[str, Any] = {"source": "persisted_attempts", "checked_at": checked_iso}
199
- if not events:
200
- recent["status"] = "no_activity"
201
- last_practice.update(status="no_data", reason_code="no_observation_in_window")
202
- elif learners < MIN_DIFFICULTY_STUDENTS or attempts < MIN_DIFFICULTY_ATTEMPTS:
203
- recent["status"] = "suppressed"
204
- last_practice.update(status="suppressed", reason_code="privacy_threshold")
205
- else:
206
- latest = max(stamp for _, stamp in events.values())
207
- recent.update(status="complete", students_with_activity=learners, attempts=attempts)
208
- last_practice.update(
209
- status="complete", event_type="practice_attempt", timestamp=_utc_iso(latest),
210
- local_timestamp=latest.astimezone(LOCAL_TZ).isoformat(), timezone="Asia/Jerusalem",
211
- elapsed_seconds=max(0, int((now - latest).total_seconds())),
212
- )
213
- live_presence: dict[str, Any] = {
214
- "status": "unavailable", "reason_code": "no_active_live_session",
215
- "source": "live_session_heartbeat", "checked_at": checked_iso,
216
- }
217
- if live_store is not None:
218
- try:
219
- summary = live_store.presence_summary({int(row["id"]) for row in classes if row.get("id") is not None})
220
- if summary.get("active_session"):
221
- active = max(0, int(summary.get("active_users") or 0))
222
- live_presence = {
223
- "status": "suppressed" if len(user_ids) < MIN_DIFFICULTY_STUDENTS or active in {1, 2}
224
- else "complete",
225
- "source": "live_session_heartbeat", "checked_at": checked_iso,
226
- "heartbeat_ttl_seconds": max(1, int(summary.get("heartbeat_ttl_seconds") or 20)),
227
- "ephemeral": True,
228
- "definition": "heartbeat_received_within_ttl",
229
- }
230
- if live_presence["status"] == "complete":
231
- live_presence["active_users"] = active
232
- else:
233
- live_presence["reason_code"] = "privacy_threshold"
234
- except (AttributeError, KeyError, TypeError, ValueError):
235
- live_presence = {
236
- "status": "unavailable", "reason_code": "live_source_unavailable",
237
- "source": "live_session_heartbeat", "checked_at": checked_iso,
238
- }
239
- return {
240
- **scope, "status": recent["status"], "checked_at": checked_iso,
241
- "timezone": "Asia/Jerusalem", "privacy_threshold": threshold,
242
- "recent_practice": recent,
243
- "live_presence": live_presence,
244
- "last_practice_activity": last_practice,
245
- "last_application_activity": {
246
- "status": "unavailable", "reason_code": "not_collected",
247
- "checked_at": checked_iso,
248
- "note": "General application activity is not stored; missing practice does not imply no app use.",
249
- },
250
- }
251
-
252
-
253
- def _exercise_id(category: str, phrase: str) -> str:
254
- return hashlib.sha256(f"{category}\0{phrase}".encode()).hexdigest()[:12]
255
-
256
-
257
- def _catalog() -> dict[str, dict[str, Any]]:
258
- try:
259
- from .phrases import CATEGORIES, GAMES
260
- categories = {str(row["id"]): row for row in [*CATEGORIES, *GAMES]}
261
- except (ImportError, KeyError, TypeError):
262
- categories = {}
263
- return categories
264
-
265
-
266
- def _metadata(category: str, phrase: str) -> dict[str, Any]:
267
- row = _catalog().get(category, {})
268
- unit = str(row.get("unit") or "")
269
- if category == "verb-game" or "פועל" in unit:
270
- kind = "verb"
271
- elif row.get("bubble") or category.startswith("game-"):
272
- kind = "game"
273
- elif row:
274
- kind = "pronunciation"
275
- else:
276
- kind = "other"
277
- return {"exercise_id": _exercise_id(category, phrase), "exercise_name": phrase,
278
- "exercise_type": kind, "topic": row.get("name"), "lesson": row.get("lesson")}
279
-
280
-
281
- def normalize_attempts(rows: list[dict]) -> list[dict[str, Any]]:
282
- normalized = []
283
- for row in rows:
284
- try:
285
- user_id, category = str(row.get("user_id") or "").strip(), str(row.get("cat") or "").strip()
286
- phrase = " ".join(str(row.get("phrase") or "").split())
287
- score = float(row.get("score"))
288
- stamp = _stamp(row.get("created_at"))
289
- if not user_id or not category or not phrase or not math.isfinite(score):
290
- continue
291
- score = max(0.0, min(100.0, score))
292
- issues = []
293
- for issue in row.get("issues") or []:
294
- if not isinstance(issue, dict):
295
- continue
296
- expected = str(issue.get("expected") or "").strip()[:20]
297
- heard = str(issue.get("heard") or "").strip()[:20]
298
- if not expected:
299
- continue
300
- word = issue.get("word")
301
- issues.append({"expected": expected, "heard": heard,
302
- "word": word if isinstance(word, int) and not isinstance(word, bool) else None,
303
- "score": issue.get("score")})
304
- normalized.append({"id": int(row.get("id") or 0), "user_id": user_id,
305
- "category": category, "phrase": phrase, "score": score,
306
- "unrelated": bool(row.get("unrelated")), "stamp": stamp,
307
- "local_date": stamp.date(), "issues": issues,
308
- "model": str(row.get("model") or ""),
309
- "version": str(row.get("version") or ""),
310
- **_metadata(category, phrase)})
311
- except (TypeError, ValueError, OverflowError):
312
- continue
313
- return normalized
314
-
315
-
316
- def _threshold(value: object, *, default: int = MIN_DIFFICULTY_ATTEMPTS) -> int:
317
- if value is None:
318
- return max(MIN_DIFFICULTY_ATTEMPTS, default)
319
- if not isinstance(value, int) or isinstance(value, bool) or value < 1 or value > 10_000:
320
- raise MonitoringError("min_attempts חייב להיות מספר בין 1 ל-10000")
321
- return max(MIN_DIFFICULTY_ATTEMPTS, value if value is not None else default)
322
-
323
-
324
- def _exercise_metrics(items: list[dict[str, Any]]) -> dict[str, Any]:
325
- students: dict[str, list[dict[str, Any]]] = defaultdict(list)
326
- for item in items:
327
- students[item["user_id"]].append(item)
328
- successes = sum(not item["unrelated"] and item["score"] >= SUCCESS_SCORE for item in items)
329
- first_success, never, changes = [], 0, []
330
- for learner_items in students.values():
331
- ordered = sorted(learner_items, key=lambda item: (item["stamp"], item["id"]))
332
- succeeded = [at for at, item in enumerate(ordered, 1)
333
- if not item["unrelated"] and item["score"] >= SUCCESS_SCORE]
334
- if succeeded:
335
- first_success.append(succeeded[0])
336
- else:
337
- never += 1
338
- if len(ordered) >= 2:
339
- changes.append(ordered[-1]["score"] - ordered[0]["score"])
340
- count, learners = len(items), len(students)
341
- return {"total_attempts": count, "unique_students": learners,
342
- "successful_attempts": successes,
343
- "success_rate": round(successes * 100 / count, 1) if count else None,
344
- "average_attempts_per_student": round(count / learners, 2) if learners else None,
345
- "average_score": round(sum(item["score"] for item in items) / count, 1) if count else None,
346
- "average_attempts_to_first_success": round(sum(first_success) / len(first_success), 2)
347
- if first_success else None, "never_succeeded_students": never,
348
- "average_score_change": round(sum(changes) / len(changes), 1) if changes else None}
349
-
350
-
351
- def aggregate_exercise_difficulty(rows: list[dict], *, limit: int,
352
- min_attempts: int = MIN_DIFFICULTY_ATTEMPTS,
353
- exercise_type: str | None = None) -> list[dict[str, Any]]:
354
- grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
355
- for item in normalize_attempts(rows):
356
- if exercise_type and item["exercise_type"] != exercise_type:
357
- continue
358
- grouped[item["exercise_id"]].append(item)
359
- ranked = []
360
- for exercise_id, items in grouped.items():
361
- metrics = _exercise_metrics(items)
362
- if metrics["unique_students"] < MIN_DIFFICULTY_STUDENTS or metrics["total_attempts"] < min_attempts:
363
- continue
364
- first = items[0]
365
- ranked.append({"exercise_id": exercise_id, "exercise_name": first["exercise_name"],
366
- "exercise_type": first["exercise_type"], "topic": first["topic"],
367
- "category": first["category"], "exercise": first["phrase"], **metrics,
368
- "attempt_count": metrics["total_attempts"],
369
- "student_count": metrics["unique_students"]})
370
- ranked.sort(key=lambda item: (item["success_rate"],
371
- -(item["average_attempts_to_first_success"] or math.inf),
372
- -item["total_attempts"], item["exercise_id"]))
373
- for at, item in enumerate(ranked[:limit], 1):
374
- item["difficulty_rank"] = at
375
- return ranked[:limit]
376
-
377
-
378
- def exercise_difficulty(auth: Any, actor: dict[str, Any], *, from_date: object, to_date: object,
379
- class_id: int | None = None, limit: object = 20,
380
- exercise_type: str | None = None, min_attempts: object = None,
381
- today: date | None = None) -> dict[str, Any]:
382
- start, end, _ = validate_range(from_date, to_date, "day", today=today)
383
- if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= MAX_DIFFICULTY_RESULTS:
384
- raise MonitoringError("limit חייב להיות מספר בין 1 ל-20")
385
- if exercise_type is not None and exercise_type not in EXERCISE_TYPES:
386
- raise MonitoringError("exercise_type אינו מוכר")
387
- effective = _threshold(min_attempts, default=MIN_DIFFICULTY_ATTEMPTS)
388
- classes, scope = _scope(auth, actor, class_id)
389
- try:
390
- user_ids = _scoped_user_ids(auth, classes)
391
- since = datetime.combine(start, time.min, LOCAL_TZ).astimezone(timezone.utc)
392
- until = datetime.combine(end + timedelta(days=1), time.min, LOCAL_TZ).astimezone(timezone.utc)
393
- rows, truncated = auth.scoped_attempts(user_ids, since, until)
394
- except (AuthUnavailable, AttributeError, KeyError, TypeError) as exc:
395
- raise MonitoringUnavailable("נתוני הקושי אינם זמינים") from exc
396
- if truncated:
397
- raise MonitoringError("יש יותר מדי ניסיונות לניתוח מלא. צמצמו את טווח התאריכים או בחרו כיתה אחת.")
398
- exercises = aggregate_exercise_difficulty(rows, limit=limit, min_attempts=effective,
399
- exercise_type=exercise_type)
400
- return {**scope, "from_date": start.isoformat(), "to_date": end.isoformat(),
401
- "status": "complete" if exercises else "insufficient_data", "success_score": SUCCESS_SCORE,
402
- "exercise_type": exercise_type,
403
- "privacy_threshold": {"minimum_students": MIN_DIFFICULTY_STUDENTS,
404
- "minimum_attempts": effective},
405
- "definitions": {"success": "score >= 75 and unrelated = false",
406
- "first_attempt": "first observed attempt in requested range"},
407
- "exercises": exercises, "returned": len(exercises)}
408
-
409
-
410
- def pronunciation_difficulties(auth: Any, actor: dict[str, Any], *, from_date: object, to_date: object,
411
- class_id: int | None = None, limit: object = 20,
412
- min_attempts: object = 5, today: date | None = None) -> dict[str, Any]:
413
- start, end, _ = validate_range(from_date, to_date, "day", today=today)
414
- if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= MAX_ANALYTICS_RESULTS:
415
- raise MonitoringError("limit חייב להיות מספר בין 1 ל-50")
416
- effective = _threshold(min_attempts)
417
- classes, scope = _scope(auth, actor, class_id)
418
- try:
419
- user_ids = _scoped_user_ids(auth, classes)
420
- since = datetime.combine(start, time.min, LOCAL_TZ).astimezone(timezone.utc)
421
- until = datetime.combine(end + timedelta(days=1), time.min, LOCAL_TZ).astimezone(timezone.utc)
422
- result = auth.scoped_attempts(user_ids, since, until, include_diagnostics=True)
423
- rows, truncated, diagnostics = result
424
- except (AuthUnavailable, AttributeError, KeyError, TypeError, ValueError) as exc:
425
- raise MonitoringUnavailable("נתוני ההגייה אינם זמינים") from exc
426
- if truncated:
427
- raise MonitoringError("יש יותר מדי ניסיונות לניתוח מלא. צמצמו את הטווח או בחרו כיתה אחת.")
428
- base = {**scope, "from_date": start.isoformat(), "to_date": end.isoformat(),
429
- "privacy_threshold": {"minimum_students": MIN_DIFFICULTY_STUDENTS,
430
- "minimum_attempts": effective}}
431
- if not diagnostics:
432
- return {**base, "status": "insufficient_data", "diagnostics_available": False,
433
- "phonemes": [], "returned": 0}
434
- attempts = normalize_attempts(rows)
435
- evaluated = [item for item in attempts if not item["unrelated"] and item["model"]]
436
- groups: dict[str, dict[str, Any]] = defaultdict(lambda: {
437
- "attempts": set(), "students": set(), "scores": [], "examples": set(), "dates": defaultdict(int)})
438
- for item in evaluated:
439
- words = item["phrase"].split()
440
- for issue in item["issues"]:
441
- group = groups[issue["expected"]]
442
- group["attempts"].add((item["user_id"], item["id"]))
443
- group["students"].add(item["user_id"])
444
- group["dates"][_period_key(item["local_date"], "week")] += 1
445
- try:
446
- score = float(issue["score"])
447
- if math.isfinite(score):
448
- group["scores"].append(max(0.0, min(100.0, score)))
449
- except (TypeError, ValueError):
450
- pass
451
- word = issue["word"]
452
- if word is not None and 0 <= word < len(words):
453
- group["examples"].add(words[word])
454
- phonemes = []
455
- for phoneme, group in groups.items():
456
- count, learners = len(group["attempts"]), len(group["students"])
457
- if count < effective or learners < MIN_DIFFICULTY_STUDENTS:
458
- continue
459
- dates = sorted(group["dates"])
460
- trend = (group["dates"][dates[-1]] - group["dates"][dates[0]]) if len(dates) >= 2 else None
461
- phonemes.append({"phoneme": phoneme, "example_words": sorted(group["examples"])[:5],
462
- "total_evaluated_attempts": len(evaluated), "error_attempts": count,
463
- "pronunciation_error_rate": None, "affected_students": learners,
464
- "average_pronunciation_score": round(sum(group["scores"]) / len(group["scores"]), 1)
465
- if group["scores"] else None,
466
- "trend": {"interval": "week", "error_attempt_change": trend}})
467
- phonemes.sort(key=lambda item: (-item["affected_students"], -item["error_attempts"], item["phoneme"]))
468
- phonemes = phonemes[:limit]
469
- return {**base, "status": "complete" if phonemes else "insufficient_data",
470
- "diagnostics_available": True,
471
- "diagnostic_coverage": {"evaluated_attempts": len(evaluated), "scored_attempts": len(attempts)},
472
- "rate_note": "null: stored diagnostics contain flagged errors but not correct phoneme exposure",
473
- "phonemes": phonemes, "returned": len(phonemes)}
474
-
475
-
476
- def _read_attempts(auth: Any, classes: list[dict], start: date, end: date) -> list[dict[str, Any]]:
477
- try:
478
- user_ids = _scoped_user_ids(auth, classes)
479
- since = datetime.combine(start, time.min, LOCAL_TZ).astimezone(timezone.utc)
480
- until = datetime.combine(end + timedelta(days=1), time.min, LOCAL_TZ).astimezone(timezone.utc)
481
- rows, truncated = auth.scoped_attempts(user_ids, since, until)
482
- except (AuthUnavailable, AttributeError, KeyError, TypeError, ValueError) as exc:
483
- raise MonitoringUnavailable("נתוני הלמידה אינם זמינים") from exc
484
- if truncated:
485
- raise MonitoringError("יש יותר מדי ניסיונות לניתוח מלא. צמצמו את הטווח או בחרו כיתה אחת.")
486
- return normalize_attempts(rows)
487
-
488
-
489
- def _rate(numerator: int, denominator: int) -> float | None:
490
- return round(numerator * 100 / denominator, 1) if denominator else None
491
-
492
-
493
- def learning_progress(auth: Any, actor: dict[str, Any], *, from_date: object, to_date: object,
494
- class_id: int | None = None, exercise_id: str | None = None,
495
- interval: object = "week", min_attempts: object = 5,
496
- today: date | None = None) -> dict[str, Any]:
497
- start, end, clean_interval = validate_range(from_date, to_date, interval, today=today)
498
- effective = _threshold(min_attempts)
499
- classes, scope = _scope(auth, actor, class_id)
500
- attempts = _read_attempts(auth, classes, start, end)
501
- if exercise_id is not None:
502
- if not isinstance(exercise_id, str) or len(exercise_id) != 12:
503
- raise MonitoringError("התרגיל אינו זמין")
504
- attempts = [item for item in attempts if item["exercise_id"] == exercise_id]
505
- if not attempts:
506
- raise MonitoringError("התרגיל אינו זמין")
507
-
508
- ordered = sorted(attempts, key=lambda item: (item["stamp"], item["id"]))
509
- seen: set[tuple[str, str]] = set()
510
- for item in ordered:
511
- key = item["user_id"], item["exercise_id"]
512
- item["retry"] = key in seen
513
- seen.add(key)
514
- keys, cursor = [], start
515
- while cursor <= end:
516
- key = _period_key(cursor, clean_interval)
517
- if not keys or keys[-1] != key:
518
- keys.append(key)
519
- cursor += timedelta(days=1)
520
- grouped: dict[date, list[dict[str, Any]]] = {key: [] for key in keys}
521
- for item in ordered:
522
- key = _period_key(item["local_date"], clean_interval)
523
- if key in grouped:
524
- grouped[key].append(item)
525
- series = []
526
- for key, items in grouped.items():
527
- learners = {item["user_id"] for item in items}
528
- if len(items) < effective or len(learners) < MIN_DIFFICULTY_STUDENTS:
529
- series.append({"date": key.isoformat(), "status": "insufficient_data", "attempts": 0,
530
- "unique_students": 0, "success_rate": None, "average_score": None,
531
- "first_attempt_success_rate": None, "repeated_attempt_success_rate": None})
532
- continue
533
- first = [item for item in items if not item["retry"]]
534
- retries = [item for item in items if item["retry"]]
535
- success = lambda item: not item["unrelated"] and item["score"] >= SUCCESS_SCORE
536
- series.append({"date": key.isoformat(), "status": "complete", "attempts": len(items),
537
- "unique_students": len(learners),
538
- "success_rate": _rate(sum(success(item) for item in items), len(items)),
539
- "average_score": round(sum(item["score"] for item in items) / len(items), 1),
540
- "first_attempt_success_rate": _rate(sum(success(item) for item in first), len(first)),
541
- "repeated_attempt_success_rate": _rate(sum(success(item) for item in retries), len(retries))})
542
- measured = [item for item in series if item["status"] == "complete"]
543
- trend = (round(measured[-1]["average_score"] - measured[0]["average_score"], 1)
544
- if len(measured) >= 2 else None)
545
- return {**scope, "from_date": start.isoformat(), "to_date": end.isoformat(),
546
- "interval": clean_interval, "exercise_id": exercise_id,
547
- "status": "complete" if measured else "insufficient_data",
548
- "privacy_threshold": {"minimum_students": MIN_DIFFICULTY_STUDENTS,
549
- "minimum_attempts": effective},
550
- "definitions": {"success": "score >= 75 and unrelated = false",
551
- "first_attempt": "first observed attempt in requested range"},
552
- "series": series, "improvement_trend": trend}
553
-
554
-
555
- def compare_class_difficulties(auth: Any, actor: dict[str, Any], *, from_date: object, to_date: object,
556
- class_ids: list[int] | None = None, exercise_type: str | None = None,
557
- min_attempts: object = 5, today: date | None = None) -> dict[str, Any]:
558
- start, end, _ = validate_range(from_date, to_date, "day", today=today)
559
- effective = _threshold(min_attempts)
560
- if exercise_type is not None and exercise_type not in EXERCISE_TYPES:
561
- raise MonitoringError("exercise_type אינו מוכר")
562
- visible, _ = _scope(auth, actor, None)
563
- by_id = {row.get("id"): row for row in visible}
564
- if class_ids is None:
565
- selected = visible
566
- else:
567
- if (not isinstance(class_ids, list) or not class_ids or
568
- any(not isinstance(item, int) or isinstance(item, bool) or item <= 0 for item in class_ids)):
569
- raise MonitoringError("class_ids אינה רשימה תקינה")
570
- if len(set(class_ids)) != len(class_ids) or any(item not in by_id for item in class_ids):
571
- raise MonitoringError("אחת הכיתות אינה זמינה")
572
- selected = [by_id[item] for item in class_ids]
573
- user_sets = {int(row["id"]): _scoped_user_ids(auth, [row]) for row in selected}
574
- overlap = any(user_sets[a] & user_sets[b] for at, a in enumerate(user_sets) for b in list(user_sets)[at + 1:])
575
- results = []
576
- for row in sorted(selected, key=lambda item: int(item["id"])):
577
- attempts = _read_attempts(auth, [row], start, end)
578
- if exercise_type:
579
- attempts = [item for item in attempts if item["exercise_type"] == exercise_type]
580
- learners, exercises = {item["user_id"] for item in attempts}, {item["exercise_id"] for item in attempts}
581
- if len(attempts) < effective or len(learners) < MIN_DIFFICULTY_STUDENTS:
582
- results.append({"class_id": row["id"], "class_name": row.get("name", ""),
583
- "status": "insufficient_data"})
584
- continue
585
- successes = sum(not item["unrelated"] and item["score"] >= SUCCESS_SCORE for item in attempts)
586
- results.append({"class_id": row["id"], "class_name": row.get("name", ""), "status": "complete",
587
- "total_attempts": len(attempts), "participating_students": len(learners),
588
- "observed_exercises": len(exercises), "success_rate": _rate(successes, len(attempts)),
589
- "average_score": round(sum(item["score"] for item in attempts) / len(attempts), 1),
590
- "attempts_per_participant": round(len(attempts) / len(learners), 2),
591
- "attempts_per_student_exercise_exposure":
592
- round(len(attempts) / (len(learners) * len(exercises)), 2) if exercises else None})
593
- return {"from_date": start.isoformat(), "to_date": end.isoformat(), "exercise_type": exercise_type,
594
- "status": "complete" if any(row["status"] == "complete" for row in results) else "insufficient_data",
595
- "privacy_threshold": {"minimum_students": MIN_DIFFICULTY_STUDENTS,
596
- "minimum_attempts": effective},
597
- "overlapping_enrollment": overlap,
598
- "overlap_note": "class populations are not mutually exclusive" if overlap else None,
599
- "classes": results, "returned": len(results)}
600
-
601
-
602
- def _bottleneck_item(items: list[dict[str, Any]], *, label: str, identifier: str,
603
- level: str) -> dict[str, Any]:
604
- students: dict[str, list[dict[str, Any]]] = defaultdict(list)
605
- for item in items:
606
- students[item["user_id"]].append(item)
607
- repeated_failures, first_success, changes, improved = 0, [], [], 0
608
- for learner_items in students.values():
609
- ordered = sorted(learner_items, key=lambda item: (item["stamp"], item["id"]))
610
- failures = [item for item in ordered if item["unrelated"] or item["score"] < SUCCESS_SCORE]
611
- if len(failures) >= 2:
612
- repeated_failures += 1
613
- successes = [at for at, item in enumerate(ordered, 1)
614
- if not item["unrelated"] and item["score"] >= SUCCESS_SCORE]
615
- if successes:
616
- first_success.append(successes[0])
617
- if len(ordered) >= 2:
618
- change = ordered[-1]["score"] - ordered[0]["score"]
619
- changes.append(change)
620
- improved += change > 0
621
- affected, attempts = len(students), len(items)
622
- repeated_rate = _rate(repeated_failures, affected)
623
- improvement_rate = _rate(improved, len(changes))
624
- average_until = round(sum(first_success) / len(first_success), 2) if first_success else None
625
- basis = []
626
- if repeated_rate is not None and repeated_rate >= 60:
627
- basis.append("high_repeated_failure_rate")
628
- if improvement_rate is not None and improvement_rate < 40:
629
- basis.append("low_improvement_rate")
630
- if average_until is None or average_until >= 3:
631
- basis.append("slow_or_absent_success")
632
- severity = "high" if len(basis) >= 2 else "medium" if basis else "low"
633
- suggestion = ("לפרק את התרגיל לשלבים קצרים, להדגים שוב ולבצע תרגול מונחה"
634
- if severity == "high" else "לשלב חזרה ממוקדת ומשוב מיידי")
635
- return {"level": level, "id": identifier, "name": label,
636
- "observed_metrics": {"affected_students": affected, "total_attempts": attempts,
637
- "repeated_failure_rate": repeated_rate,
638
- "average_attempts_until_success": average_until,
639
- "improvement_rate": improvement_rate,
640
- "average_score_change": round(sum(changes) / len(changes), 1) if changes else None},
641
- "severity": severity,
642
- "suggested_intervention": {"type": "recommendation", "text": suggestion, "basis": basis}}
643
-
644
-
645
- def learning_bottlenecks(auth: Any, actor: dict[str, Any], *, from_date: object, to_date: object,
646
- class_id: int | None = None, exercise_type: str | None = None,
647
- limit: object = 20, min_attempts: object = 10,
648
- today: date | None = None) -> dict[str, Any]:
649
- start, end, _ = validate_range(from_date, to_date, "day", today=today)
650
- if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= MAX_ANALYTICS_RESULTS:
651
- raise MonitoringError("limit חייב להיות מספר בין 1 ל-50")
652
- if exercise_type is not None and exercise_type not in EXERCISE_TYPES:
653
- raise MonitoringError("exercise_type אינו מוכר")
654
- effective = _threshold(min_attempts, default=10)
655
- classes, scope = _scope(auth, actor, class_id)
656
- attempts = _read_attempts(auth, classes, start, end)
657
- if exercise_type:
658
- attempts = [item for item in attempts if item["exercise_type"] == exercise_type]
659
- exercise_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
660
- topic_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
661
- for item in attempts:
662
- exercise_groups[item["exercise_id"]].append(item)
663
- if item["topic"]:
664
- topic_groups[str(item["topic"])].append(item)
665
- results = []
666
- for exercise_id, items in exercise_groups.items():
667
- if len(items) >= effective and len({item["user_id"] for item in items}) >= MIN_DIFFICULTY_STUDENTS:
668
- results.append(_bottleneck_item(items, label=items[0]["exercise_name"],
669
- identifier=exercise_id, level="exercise"))
670
- for topic, items in topic_groups.items():
671
- if len(items) >= effective and len({item["user_id"] for item in items}) >= MIN_DIFFICULTY_STUDENTS:
672
- results.append(_bottleneck_item(items, label=topic,
673
- identifier=hashlib.sha256(topic.encode()).hexdigest()[:12],
674
- level="topic"))
675
- results = [item for item in results if item["severity"] != "low"]
676
- severity_order = {"high": 0, "medium": 1}
677
- results.sort(key=lambda item: (severity_order[item["severity"]],
678
- -(item["observed_metrics"]["repeated_failure_rate"] or 0), item["id"]))
679
- results = results[:limit]
680
- return {**scope, "from_date": start.isoformat(), "to_date": end.isoformat(),
681
- "exercise_type": exercise_type, "status": "complete" if results else "insufficient_data",
682
- "privacy_threshold": {"minimum_students": MIN_DIFFICULTY_STUDENTS,
683
- "minimum_attempts": effective},
684
- "bottlenecks": results, "returned": len(results)}
685
-
686
-
687
- def system_health(app: Any, actor: dict[str, Any]) -> dict[str, Any]:
688
- if not has_permission(actor, "management.access"):
689
- raise MonitoringError("אין הרשאת ניהול")
690
- state = app.state
691
- auth = getattr(state, "auth", None)
692
- database = "not_configured" if auth is None else "healthy"
693
- if auth is not None:
694
- try:
695
- if hasattr(auth, "database_health"):
696
- if not auth.database_health():
697
- database = "unavailable"
698
- else:
699
- auth.classes(fresh=True)
700
- except Exception: # The response deliberately never carries infrastructure exception text.
701
- database = "unavailable"
702
- components = {
703
- "application": "healthy",
704
- "authentication": "healthy" if auth is not None else "not_configured",
705
- "database": database,
706
- "model": "healthy" if getattr(state, "scorer", None) is not None else "unavailable",
707
- "text_to_speech": "healthy" if getattr(state, "tts", None) is not None else "not_configured",
708
- }
709
- required = (components["application"], components["authentication"], components["database"], components["model"])
710
- overall = "healthy" if all(value == "healthy" for value in required) else "degraded"
711
- answer: dict[str, Any] = {"status": overall,
712
- "checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
713
- "components": components}
714
- if "developer" in actor.get("roles", ()):
715
- info = getattr(state, "mcp_build_info", {})
716
- if isinstance(info, dict):
717
- answer["build"] = {key: str(info[key])[:100] for key in ("version", "revision") if info.get(key)}
718
- return answer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/nakdan.py DELETED
@@ -1,113 +0,0 @@
1
- """Adds niqqud to Hebrew before it is read aloud: the TTS voices misread many unpointed words.
2
-
3
- Uses Dicta's free Nakdan service (no key). A word that already has niqqud (hand pointed, as in `he_say`)
4
- is kept as written; every other word takes Nakdan's first choice. Results are cached on disk, so each
5
- sentence is sent once. When the service cannot be reached, the text is returned as it was.
6
-
7
- Env: HEBREW_NIQQUD "0" turns it off (the tests do)
8
- NAKDAN_URL optional; default Dicta's public endpoint
9
- TTS_CACHE_DIR optional; default ~/.cache/ihki-tts (the cache is niqqud.json inside it)
10
- """
11
- from __future__ import annotations
12
-
13
- import json
14
- import logging
15
- import os
16
- import re
17
- import threading
18
- from pathlib import Path
19
-
20
- log = logging.getLogger("ihki.nakdan")
21
- NIQQUD = re.compile(r"[֑-ׇ]")
22
- HEBREW = re.compile(r"[א-ת]")
23
- DEFAULT_URL = "https://nakdan-5-3.loadbalancer.dicta.org.il/api"
24
- _lock = threading.Lock()
25
- _cache: dict[str, str] | None = None
26
-
27
-
28
- def _cache_path() -> Path:
29
- return Path(os.environ.get("TTS_CACHE_DIR", "").strip() or Path.home() / ".cache" / "ihki-tts") / "niqqud.json"
30
-
31
-
32
- def _load() -> dict[str, str]:
33
- global _cache
34
- if _cache is None:
35
- try:
36
- _cache = json.loads(_cache_path().read_text("utf-8"))
37
- except (OSError, ValueError):
38
- _cache = {}
39
- return _cache
40
-
41
-
42
- def _save() -> None:
43
- path = _cache_path()
44
- try:
45
- path.parent.mkdir(parents=True, exist_ok=True)
46
- tmp = path.with_suffix(".tmp")
47
- tmp.write_text(json.dumps(_cache, ensure_ascii=False), "utf-8")
48
- tmp.replace(path)
49
- except OSError as e:
50
- log.warning("niqqud cache write failed: %s: %s", type(e).__name__, e)
51
-
52
-
53
- def _fetch(text: str) -> list[dict]:
54
- import requests
55
-
56
- r = requests.post(os.environ.get("NAKDAN_URL", "").strip() or DEFAULT_URL, timeout=8, json={
57
- "task": "nakdan", "data": text, "genre": "modern", "addmorph": False, "keepqq": False,
58
- "nodageshdefmem": False, "patachma": False, "keepmetagim": False})
59
- r.raise_for_status()
60
- return r.json()
61
-
62
-
63
- # second person endings, (said to a woman, said to a man): שְׁלוֹמֵךְ / שְׁלוֹמְךָ, שָׁבַרְתְּ / שָׁבַרְתָּ
64
- ENDINGS = (("\u05b5\u05da\u05b0", "\u05b0\u05da\u05b8"), ("\u05ea\u05bc\u05b0", "\u05ea\u05bc\u05b8"))
65
-
66
-
67
- def _choose(options: list[str], gender: str | None) -> str:
68
- """Nakdan's first choice, unless the sentence is said to a man ("m") or a woman ("f") and it says otherwise."""
69
- first = options[0]
70
- if gender in ("m", "f"):
71
- for fem, male in ENDINGS:
72
- wrong, right = (fem, male) if gender == "m" else (male, fem)
73
- if first.endswith(wrong):
74
- return next((o for o in options if o.endswith(right)), first)
75
- return first
76
-
77
-
78
- def _join(words: list[dict], gender: str | None = None) -> str:
79
- out = []
80
- for w in words:
81
- word = w.get("word", "")
82
- options = w.get("options") or []
83
- if w.get("sep") or not options or NIQQUD.search(word) or not HEBREW.search(word):
84
- out.append(word) # spaces, punctuation, numbers, and words already pointed
85
- else:
86
- best = _choose(options, gender).replace("|", "") # "בְּ|בַיִת": the bar marks a prefix
87
- out.append(best if NIQQUD.sub("", best) == word else word) # never a different spelling
88
- return "".join(out)
89
-
90
-
91
- def point(text: str, gender: str | None = None) -> tuple[str, bool]:
92
- """(text with niqqud, final). Final is False only when Nakdan failed: the text is unchanged and worth retrying.
93
-
94
- gender: "m" or "f" when the sentence is said to a man or a woman (its note, "(לגבר)" or "(לאישה)").
95
- """
96
- if os.environ.get("HEBREW_NIQQUD", "").strip() == "0" or not HEBREW.search(text):
97
- return text, True
98
- with _lock:
99
- cache = _load()
100
- key = f"{gender}|{text}" if gender else text
101
- if key in cache:
102
- return cache[key], True
103
- try:
104
- pointed = _join(_fetch(text), gender)
105
- except Exception as e: # network, rate limit, odd reply: read it unpointed this time
106
- log.warning("nakdan failed: %s: %s", type(e).__name__, e)
107
- return text, False
108
- if NIQQUD.sub("", pointed) != NIQQUD.sub("", text):
109
- log.warning("nakdan changed more than niqqud, ignored: %r -> %r", text, pointed)
110
- return text, False
111
- cache[key] = pointed
112
- _save()
113
- return pointed, True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/phones.py DELETED
@@ -1,19 +0,0 @@
1
- """The sound a model symbol stands for.
2
-
3
- The model's vocab is multilingual (392 symbols) and spells one sound several ways: t / t̪ / tʰ / tː,
4
- r / ɾ, a / æ / ɐ, x / χ. Left alone, a correctly said t loses to its own spelling variant t̪.
5
- base() names the sound a symbol stands for; Vocab.merge() (scorer.py) pools the posteriors of each sound,
6
- and rules.py writes its rules in the same names.
7
- """
8
- _STRIP = set("ːʰʲ.^[:\"̪̩̝̞̊̃") # length, aspiration, palatalisation, dental, syllabic, nasal ...
9
- _SAME = {"æ": "a", "ɐ": "a", "ɑ": "a", "ä": "a", "ʌ": "a", "ɪ": "i", "ʊ": "u", "ɛ": "e", "ɔ": "o",
10
- "ɾ": "r", "ɫ": "l", "ɡ": "g", "χ": "x", "ʁ": "ɣ", "ʒ": "dʒ", "dZ": "dʒ", "tS": "tʃ", "S": "ʃ",
11
- "d͡ʒ": "dʒ", "t͡ʃ": "tʃ", "t͡s": "ts"}
12
-
13
-
14
- def base(symbol: str) -> str:
15
- """The sound a model symbol stands for, ignoring length and spelling variants. Emphatics stay distinct.
16
- Mandarin tone numbers go too (i5, iː1, a2: 85 symbols in the espeak vocab): a correct ي heard as i5 otherwise
17
- lost to its own tone variant (هيّ scored 36 on its vowel). A diphthong (ai5) stays a diphthong."""
18
- s = "".join(c for c in symbol if c not in _STRIP).rstrip("0123456789")
19
- return _SAME.get(s, s) or symbol
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/phrases.py DELETED
@@ -1,154 +0,0 @@
1
- """Phrase bank.
2
-
3
- Each phrase: Hebrew prompt, Hebrew transliteration hint, category, and accepted variants.
4
- A variant is (arabic, ipa). IPA phonemes are space-separated and words are separated by " | ".
5
- The Arabic text and the IPA must have the same number of words (checked in tests).
6
-
7
- Conventions (Palestinian urban speech as the main form):
8
- ق -> ʔ (urban); a variant with q or k is listed where the rural form is common.
9
- ث -> t, ذ -> d (normal in the dialect).
10
- ج -> dʒ.
11
- Emphatics are written with ˤ (sˤ tˤ dˤ ðˤ), long vowels with ː.
12
- Geminates are written doubled; the tokenizer collapses them for CTC.
13
- """
14
- from .arabic_diacritics import point_arabic
15
- from .grammar import day2_phrases
16
- from .workbook import CATEGORIES as WORKBOOK_CATEGORIES, GAMES as WORKBOOK_GAMES, LESSON_TABS, LEVEL_TABS, workbook_phrases
17
-
18
- PHRASES = [
19
- # ---- היכרות ----
20
- dict(cat="meet", he="שלום", tr="מַרְחַבַּא", variants=[
21
- ("مرحبا", "m a r ħ a b a"),
22
- ("اهلا", "ʔ a h l a"),
23
- ("اهلين", "ʔ a h l eː n"),
24
- ]),
25
- dict(cat="meet", he="מה שלומך? (לגבר)", tr="כִּיפַכּ", variants=[
26
- ("كيفك", "k iː f a k"),
27
- ("كيف حالك", "k iː f | ħ aː l a k"),
28
- ]),
29
- dict(cat="meet", he="אני בסדר, ברוך השם", tr="מְנִיח, אִלְחַמְדִלַّה", variants=[
30
- ("منيح الحمد للّه", "m n iː ħ | i l ħ a m d | i l l a"),
31
- ("منيح والحمد للّه", "m n iː ħ | w i l ħ a m d | i l l a"),
32
- ("تمام الحمد للّه", "t a m aː m | i l ħ a m d | i l l a"),
33
- ]),
34
- dict(cat="meet", he="איך קוראים לך? (לגבר)", tr="שוּ אִסְמַכּ", variants=[
35
- ("شو اسمك", "ʃ u | ʔ i s m a k"),
36
- ("شو اسمك", "ʃ u | s m a k"),
37
- ("ايش اسمك", "ʔ eː ʃ | ʔ i s m a k"),
38
- ]),
39
- dict(cat="meet", he="אני מירושלים", tr="אַנַא מִן אִלְקֻדְס", variants=[
40
- ("انا من القدس", "ʔ a n a | m i n | i l ʔ u d s"),
41
- ("انا من القدس", "ʔ a n a | m i n | i l q u d s"),
42
- ]),
43
- dict(cat="meet", he="בוקר טוב", tr="צַבַּאח אִלְח'ֵיר", variants=[
44
- ("صباح الخير", "sˤ a b aː ħ | i l x eː r"),
45
- ]),
46
- dict(cat="meet", he="לילה טוב (לגבר)", tr="תִצְבַּח עַלַא ח'ֵיר", variants=[
47
- ("تصبح على خير", "t i sˤ b a ħ | ʕ a l a | x eː r"),
48
- ]),
49
- dict(cat="meet", he="תודה רבה", tr="שֻכְּרַן כְּתִיר", variants=[
50
- ("شكرا كتير", "ʃ u k r a n | k t iː r"),
51
- ("مشكور كتير", "m a ʃ k uː r | k t iː r"),
52
- ]),
53
- dict(cat="meet", he="להתראות", tr="מַע אִ(ל)סַّלַאמֶה", variants=[
54
- ("مع السّلامة", "m a ʕ | i s s a l aː m e"),
55
- ("يلّا باي", "j a l l a | b aː j"),
56
- ]),
57
- dict(cat="meet", he="סליחה, תסלח לי", tr="סַאמְחְנִי", variants=[
58
- ("سامحني", "s aː m ħ i n i"),
59
- ("اسف", "ʔ aː s i f"),
60
- ]),
61
- # ---- שיחה ----
62
- dict(cat="talk", he="מה אתה עושה? (עכשיו)", tr="שוּ בְּתִעְמַל", variants=[
63
- ("شو بتعمل", "ʃ u | b t i ʕ m a l"),
64
- ("شو بتسوّي", "ʃ u | b t s a w w i"),
65
- ]),
66
- dict(cat="talk", he="אני עובד בבית חולים", tr="בַּשְתְע'ִל בִּלְמֻסְתַשְפַא", variants=[
67
- ("بشتغل بالمستشفى", "b a ʃ t ɣ i l | b i l m u s t a ʃ f a"),
68
- ("بشتغل في المستشفى", "b a ʃ t ɣ i l | f i | l m u s t a ʃ f a"),
69
- ]),
70
- dict(cat="talk", he="אני לא מבין (גבר)", tr="מִש פַאהֵם", variants=[
71
- ("مش فاهم", "m i ʃ | f aː h i m"),
72
- ("ما فهمت", "m aː | f h i m t"),
73
- ]),
74
- dict(cat="talk", he="אתה יכול לדבר לאט?", tr="מֻמְכִּן תִחְכִּי שְוַיّ שְוַיّ", variants=[
75
- ("ممكن تحكي شويّ شويّ", "m u m k i n | t i ħ k i | ʃ w a j j | ʃ w a j j"),
76
- ("ممكن تحكي شويّ", "m u m k i n | t i ħ k i | ʃ w a j j"),
77
- ]),
78
- dict(cat="talk", he="אין בעיה", tr="מִש מֻשְכִּלֶה", variants=[
79
- ("مش مشكلة", "m i ʃ | m u ʃ k i l e"),
80
- ("ما في مشكلة", "m aː | f i | m u ʃ k i l e"),
81
- ]),
82
- dict(cat="talk", he="נתראה מחר", tr="בְּנְשוּף בַּעְד' בֻּכְּרַה", variants=[
83
- ("بنشوف بعض بكرة", "b n ʃ uː f | b a ʕ dˤ | b u k r a"),
84
- ("منشوف بعض بكرة", "m n ʃ uː f | b a ʕ dˤ | b u k r a"),
85
- ("بنشوفك بكرة", "b n ʃ uː f a k | b u k r a"),
86
- ]),
87
- dict(cat="talk", he="היום חם מאוד", tr="אִלְיוֹם שוֹבּ כְּתִיר", variants=[
88
- ("اليوم شوب كتير", "i l j oː m | ʃ oː b | k t iː r"),
89
- ("اليوم حرّ كتير", "i l j oː m | ħ a r r | k t iː r"),
90
- ("الجوّ حرّ كتير", "i l dʒ a w w | ħ a r r | k t iː r"),
91
- ]),
92
- # ---- בעיר ----
93
- dict(cat="city", he="איפה התחנה?", tr="וֵין אִלְמַחַטַّה", variants=[
94
- ("وين المحطّة", "w eː n | i l m a ħ a tˤ tˤ a"),
95
- ]),
96
- dict(cat="city", he="איפה השירותים?", tr="וֵין אִלְחַמַّאם", variants=[
97
- ("وين الحمّام", "w eː n | i l ħ a m m aː m"),
98
- ]),
99
- dict(cat="city", he="מה השעה?", tr="קַדֵّיש אִ(ל)סַّאעַה", variants=[
100
- ("قدّيش السّاعة", "ʔ a d d eː ʃ | i s s aː ʕ a"),
101
- ("قدّيش السّاعة", "q a d d eː ʃ | i s s aː ʕ a"),
102
- ("كم السّاعة", "k a m | i s s aː ʕ a"),
103
- ]),
104
- # ---- אוכל וקניות ----
105
- dict(cat="food", he="אני רוצה כוס קפה", tr="בִּדִّי פִנְג'אן קַהְוֶה", variants=[
106
- ("بدّي فنجان قهوة", "b i d d i | f i n dʒ aː n | ʔ a h w e"),
107
- ("بدّي فنجان قهوة", "b i d d i | f i n dʒ aː n | q a h w e"),
108
- ("بدّي كاسة قهوة", "b i d d i | k aː s e t | ʔ a h w e"),
109
- ]),
110
- dict(cat="food", he="תן לי מים, בבקשה", tr="אַעְטִינִי מַיّ לַוּ סַמַחְת", variants=[
111
- ("اعطيني ميّ لو سمحت", "ʔ a ʕ tˤ iː n i | m a j j | l a w | s a m a ħ t"),
112
- ("عطيني ميّ لو سمحت", "ʕ tˤ iː n i | m a j j | l a w | s a m a ħ t"),
113
- ]),
114
- dict(cat="food", he="אני אוהב אוכל ערבי", tr="בַּחִבּّ אִלְאַכְּל אִלְעַרַבִּי", variants=[
115
- ("بحبّ الأكل العربي", "b a ħ i b b | i l ʔ a k l | i l ʕ a r a b i"),
116
- ]),
117
- dict(cat="food", he="כמה זה עולה?", tr="קַדֵّיש חַקّוֹ", variants=[
118
- ("قدّيش حقّه", "ʔ a d d eː ʃ | ħ a ʔ ʔ o"),
119
- ("قدّيش حقّه", "q a d d eː ʃ | ħ a q q o"),
120
- ("بقدّيش", "b i ʔ a d d eː ʃ"),
121
- ]),
122
- dict(cat="food", he="החשבון, בבקשה", tr="אִלְחְסַאבּ לַוּ סַמַחְת", variants=[
123
- ("الحساب لو سمحت", "i l ħ s aː b | l a w | s a m a ħ t"),
124
- ("الحساب لو سمحت", "i l ħ i s aː b | l a w | s a m a ħ t"),
125
- ]),
126
- ]
127
- # ---- יום 2 ---- generated: every sentence pattern x pronoun x positive/negative (see grammar.py)
128
- PHRASES += day2_phrases()
129
- # ---- שיעורים ---- the workbook's translation exercises, lesson by lesson (see workbook.py)
130
- PHRASES += workbook_phrases()
131
-
132
- # The page asks for a lesson first, then one of its categories: the course's lessons (workbook.py).
133
- # The phrases that belong to no lesson (meet, talk, city, food, day2) have no tab. They stay in the
134
- # bank, so phrase ids and the attempts recorded on them hold; public_phrases leaves them out.
135
- LEVELS = [*LEVEL_TABS]
136
- LESSONS = [*LESSON_TABS] # every planned lesson; one not written yet is `soon` and has no categories
137
- CATEGORIES = [*WORKBOOK_CATEGORIES]
138
- GAMES = [*WORKBOOK_GAMES]
139
-
140
-
141
- def public_phrases():
142
- """What the frontend needs: no IPA, first variant's Arabic as the model answer.
143
-
144
- Grammar-built phrases also carry grp / pron / neg / listed / seq (see grammar.py); the page lists a
145
- category's `listed` phrases, in `seq` order, and reaches the others with its negation and
146
- conjugation buttons. Workbook phrases carry seq and read (see workbook.py).
147
- """
148
- shown = {c["id"] for c in CATEGORIES + GAMES}
149
- return [
150
- {"id": i, "cat": p["cat"], "he": p["he"], "tr": p["tr"], "ar": p["variants"][0][0],
151
- "ar_pointed": point_arabic(*p["variants"][0]),
152
- **{k: p[k] for k in ("grp", "pron", "neg", "listed", "seq", "read", "unlock", "binyan", "gizra", "speaker", "he_say") if k in p}}
153
- for i, p in enumerate(PHRASES) if p["cat"] in shown
154
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/powsm.py DELETED
@@ -1,237 +0,0 @@
1
- """POWSM-CTC (espnet/powsm_ctc) as the app's phoneme model: an Emitter like HFEmitter.
2
-
3
- Serve a fine-tune with MODEL_ID=checkpoints/powsm-ft-minerva/powsm_ft.pt (train/finetune_powsm.py), or the
4
- published model with MODEL_ID=powsm:<ara>. It needs the extra packages from scripts/setup_zero_shot_deps.sh
5
- (.zero-shot-deps/), which are not in requirements.txt: this is for trying the model locally, not yet for the Space.
6
-
7
- What the adapter changes so the scorer can read POWSM's output unchanged:
8
- - columns: only POWSM's phone tokens (6,405 of 40,002; the rest are text pieces and task tags) and the blank,
9
- renormalised. Phone names lose their slashes and tie bars (/d͡ʒ/ -> dʒ), the spelling the scorer's rules use.
10
- - frames: the language/task prompt's two positions and the padding after the speech are dropped, and every frame
11
- is repeated once. POWSM runs at 25 frames/s; the scorer's frame counts (vowel length, alignment padding) were
12
- tuned at wav2vec2's 50.
13
- """
14
- from __future__ import annotations
15
-
16
- import glob
17
- import os
18
- import sys
19
- from pathlib import Path
20
-
21
- import numpy as np
22
-
23
- ROOT = Path(__file__).resolve().parent.parent
24
- DEPS = ROOT / ".zero-shot-deps"
25
- SAMPLE_RATE = 16_000
26
- # POWSM was trained on 20 s inputs (its model card says pad to 20 s) and loses accuracy without padding (test PER
27
- # 16% -> 21%). 8 s keeps it (16.2%) at 1.8 s a recording on 2 CPU threads instead of 4.4 s. A fine-tune trained on
28
- # random padding (train/finetune_powsm.py --pad random) needs none: unpadded it scores 16.2% at ~0.8 s.
29
- # POWSM_PAD_SECONDS, when set, overrides both (0: no padding).
30
- SECONDS = 8.0
31
-
32
-
33
- def pad_seconds(checkpoint: str | None) -> float:
34
- """The padding a model runs with: POWSM_PAD_SECONDS if set, none for a short-padding fine-tune, else SECONDS."""
35
- env = os.environ.get("POWSM_PAD_SECONDS", "").strip()
36
- if env:
37
- return float(env)
38
- if checkpoint:
39
- import torch
40
- args = torch.load(checkpoint, map_location="cpu").get("args") or {}
41
- if args.get("pad") == "random":
42
- return 0.0
43
- return SECONDS
44
- PROMPT = 2 # the language and task tokens the encoder prepends to its output
45
- UPSAMPLE = 2 # 25 frames/s -> the 50 the scorer was tuned at
46
-
47
-
48
- def use_extra_deps() -> None:
49
- """Make .zero-shot-deps importable, after the app's own packages so its torch/numpy always win."""
50
- if not (DEPS / "pkgs").exists():
51
- raise RuntimeError("POWSM needs extra packages: run scripts/setup_zero_shot_deps.sh first")
52
- for sub in ("pkgs", "stubs"):
53
- if str(DEPS / sub) not in sys.path:
54
- sys.path.append(str(DEPS / sub))
55
-
56
-
57
- def patch_espnet_for_powsm_ctc() -> None:
58
- """ESPnet 202511 (the newest release for Python 3.10) can't run POWSM-CTC as published: the encoder hands
59
- Conv2dSubsampling the language/task prompt, which that release's layer doesn't accept. Same logic as ESPnet
60
- master: linear -> prepend the prompt embeddings -> positional encoding."""
61
- import torch
62
- from espnet.nets.pytorch_backend.transformer import subsampling
63
-
64
- def forward(self, x, x_mask, prefix_embeds=None):
65
- x = self.conv(x.unsqueeze(1))
66
- b, c, t, f = x.size()
67
- linear, pos_enc = self.out[0], self.out[1]
68
- x = linear(x.transpose(1, 2).contiguous().view(b, t, c * f))
69
- if x_mask is not None:
70
- x_mask = x_mask[:, :, :-2:2][:, :, :-2:2]
71
- if prefix_embeds is not None:
72
- x = torch.cat([prefix_embeds, x], dim=1)
73
- if x_mask is not None:
74
- ones = torch.ones(x_mask.shape[0], 1, prefix_embeds.size(1), dtype=x_mask.dtype, device=x_mask.device)
75
- x_mask = torch.cat([ones, x_mask], dim=-1)
76
- return pos_enc(x), x_mask
77
-
78
- subsampling.Conv2dSubsampling.forward = forward
79
-
80
-
81
- def load_powsm(lang: str = "<ara>", checkpoint: str | None = None, device: str | None = None):
82
- """espnet/powsm_ctc as ESPnet's greedy-CTC Speech2Text, with an optional fine-tune's weights on top."""
83
- use_extra_deps()
84
- import torch
85
- from huggingface_hub import snapshot_download
86
- patch_espnet_for_powsm_ctc()
87
- from espnet2.bin.s2t_inference_ctc import Speech2TextGreedySearch
88
- device = device or ("cuda" if torch.cuda.is_available() else "cpu")
89
- s2t = Speech2TextGreedySearch.from_pretrained("espnet/powsm_ctc", device=device, lang_sym=lang, task_sym="<pr>")
90
- model = s2t.s2t_model
91
- # The checkpoint stores the subsampling linear layer as `embed.out.*` (master's layout); the release expects
92
- # `embed.out.0.*` and silently leaves it random. Copy it in.
93
- ckpt = glob.glob(f"{snapshot_download('espnet/powsm_ctc')}/exp/s2t_train*/*.pth")[0]
94
- state = torch.load(ckpt, map_location="cpu")
95
- lin = model.encoder.embed.out[0]
96
- with torch.no_grad():
97
- lin.weight.copy_(state["encoder.embed.out.weight"])
98
- lin.bias.copy_(state["encoder.embed.out.bias"])
99
- if checkpoint: # train/finetune_powsm.py saves only the weights it trained
100
- saved = torch.load(checkpoint, map_location="cpu")
101
- missing = set(saved["state"]) - set(model.state_dict())
102
- if missing:
103
- raise KeyError(f"{checkpoint} has weights this model lacks: {sorted(missing)[:5]}")
104
- model.load_state_dict(saved["state"], strict=False)
105
- model.eval()
106
- return s2t
107
-
108
-
109
- def parse_model_id(model_id: str) -> tuple[str, str | None] | None:
110
- """(language token, checkpoint) when MODEL_ID names a POWSM model, else None.
111
- 'powsm' or 'powsm:<ara>' is the published model; a path to a .pt file is a fine-tune (always <ara>)."""
112
- if model_id == "powsm" or model_id.startswith("powsm:"):
113
- return (model_id.split(":", 1)[1] if ":" in model_id else "<ara>"), None
114
- if model_id.endswith(".pt"):
115
- return "<ara>", model_id
116
- return None
117
-
118
-
119
- class PowsmEmitter:
120
- """POWSM-CTC -> (T, V) log-posteriors over its phone tokens, at 50 frames/s (see the module docstring)."""
121
-
122
- def __init__(self, lang: str = "<ara>", checkpoint: str | None = None, device: str | None = None):
123
- import torch
124
- self.torch = torch
125
- self.s2t = load_powsm(lang, checkpoint, device)
126
- self.pad = pad_seconds(checkpoint)
127
- model = self.s2t.s2t_model
128
- tokens = model.token_list
129
- cols, names = [model.blank_id], {"<blank>": 0}
130
- for i, t in enumerate(tokens):
131
- if len(t) > 2 and t[0] == "/" and t[-1] == "/":
132
- name = t[1:-1].replace("͡", "")
133
- if name not in names: # a tie-barred spelling that collides with a plain one keeps the plain one
134
- names[name] = len(cols)
135
- cols.append(i)
136
- self.cols = torch.tensor(cols)
137
- self.token_to_id: dict[str, int] = names
138
- self.blank_token = "<blank>"
139
- self.lang_id = self.s2t.converter.token2id[lang]
140
- self.task_id = self.s2t.converter.token2id["<pr>"]
141
-
142
- def __call__(self, wav: np.ndarray) -> np.ndarray:
143
- torch = self.torch
144
- model, device = self.s2t.s2t_model, self.s2t.device
145
- n = int(self.pad * SAMPLE_RATE)
146
- x = np.pad(wav, (0, max(0, n - len(wav)))).astype(np.float32) # longer recordings run as they are
147
- speech = torch.from_numpy(x)[None].to(device)
148
- prefix = torch.tensor([[self.lang_id, self.task_id]], device=device)
149
- prev = torch.tensor([[model.na]], device=device)
150
- with torch.inference_mode():
151
- enc, _ = model.encode(speech, torch.tensor([speech.shape[1]], device=device), prev,
152
- torch.tensor([1], device=device), prefix, torch.tensor([2], device=device))
153
- if isinstance(enc, tuple):
154
- enc = enc[0]
155
- enc = enc[0, PROMPT:]
156
- keep = max(1, round(enc.shape[0] * len(wav) / len(x))) # frames that cover the speech
157
- logits = model.ctc.ctc_lo(enc[:keep].float())[:, self.cols.to(device)]
158
- logp = torch.log_softmax(logits, -1).cpu().numpy()
159
- return np.repeat(logp, UPSAMPLE, axis=0)
160
-
161
-
162
- # ---- the exported model (scripts/export_powsm_onnx.py): what the Space runs, with onnxruntime and torch only ----
163
-
164
- ONNX_PREFIX = "powsm-onnx:"
165
-
166
-
167
- def onnx_model_dir(model_id: str, download: bool = True) -> Path | None:
168
- """The folder of an exported POWSM when MODEL_ID names one, else None: powsm-onnx:<Hub repo> (downloaded once,
169
- then from the Hugging Face cache) or a local folder with powsm_onnx.json."""
170
- if model_id.startswith(ONNX_PREFIX):
171
- repo = model_id[len(ONNX_PREFIX):]
172
- if not download:
173
- return Path(repo)
174
- from huggingface_hub import snapshot_download
175
- return Path(snapshot_download(repo, allow_patterns=["powsm_onnx.json", "frontend.npz", onnx_precision_file()]))
176
- local = Path(model_id)
177
- return local if (local / "powsm_onnx.json").is_file() else None
178
-
179
-
180
- def onnx_precision_file(meta: dict | None = None) -> str:
181
- """The weights file POWSM_ONNX_PRECISION picks: fp32 (the default) or int8. On the Minerva test lines, 2 CPU
182
- threads: fp32 PER 16.5% at 0.57 s a recording; int8 16.6% at 0.38 s, but ط 81% -> 76% and ص 68% -> 62%."""
183
- precision = os.environ.get("POWSM_ONNX_PRECISION", "fp32").strip().lower() or "fp32"
184
- files = (meta or {}).get("files") or {"fp32": "model.onnx", "int8": "model.int8.onnx"}
185
- return files.get(precision) or files["fp32"]
186
-
187
-
188
- class PowsmOnnxEmitter:
189
- """An exported POWSM-CTC -> (T, V) log-posteriors over its phone tokens at 50 frames/s, like PowsmEmitter but
190
- without ESPnet. The front end (STFT, mel, log, global mean/variance) runs in torch from frontend.npz; the encoder
191
- and CTC head in onnxruntime. The prompt (<ara>, <pr>) is baked into the export."""
192
-
193
- def __init__(self, folder: str, precision: str | None = None):
194
- import json
195
-
196
- import onnxruntime as ort
197
- import torch
198
- self.torch, self.folder = torch, Path(folder)
199
- self.meta = json.loads((self.folder / "powsm_onnx.json").read_text(encoding="utf-8"))
200
- weights = (self.meta["files"].get(precision) or self.meta["files"]["fp32"]) if precision \
201
- else onnx_precision_file(self.meta)
202
- opts = ort.SessionOptions()
203
- opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
204
- # a container can see more cores than it may use (the free Space has 2): POWSM_ONNX_THREADS caps them
205
- threads = int(os.environ.get("POWSM_ONNX_THREADS", "0") or 0)
206
- if threads > 0:
207
- opts.intra_op_num_threads, opts.inter_op_num_threads = threads, 1
208
- self.session = ort.InferenceSession(str(self.folder / weights), opts, providers=["CPUExecutionProvider"])
209
- fe = np.load(self.folder / "frontend.npz")
210
- self.window = torch.from_numpy(fe["window"].astype(np.float32))
211
- self.melmat = torch.from_numpy(fe["melmat"].astype(np.float32))
212
- self.mean = torch.from_numpy(fe["mean"].astype(np.float32))
213
- self.std = torch.from_numpy(fe["std"].astype(np.float32))
214
- self.n_fft, self.win, self.hop = int(fe["n_fft"]), int(fe["win_length"]), int(fe["hop_length"])
215
- self.token_to_id: dict[str, int] = self.meta["vocab"]
216
- self.blank_token = "<blank>"
217
- env = os.environ.get("POWSM_PAD_SECONDS", "").strip()
218
- self.pad = float(env) if env else float(self.meta.get("pad_seconds", SECONDS))
219
- self.weights = weights
220
-
221
- def features(self, x: np.ndarray):
222
- """ESPnet's DefaultFrontend + GlobalMVN: centred STFT, power, 80 mel bands, natural log, (x - mean) / std."""
223
- torch = self.torch
224
- spec = torch.stft(torch.from_numpy(x), self.n_fft, hop_length=self.hop, win_length=self.win,
225
- window=self.window, center=True, pad_mode="reflect", normalized=False, onesided=True,
226
- return_complex=True)
227
- power = spec.real ** 2 + spec.imag ** 2 # (F, T)
228
- mel = torch.clamp(power.transpose(0, 1) @ self.melmat, min=1e-10).log()
229
- return ((mel - self.mean) / self.std)[None].numpy()
230
-
231
- def __call__(self, wav: np.ndarray) -> np.ndarray:
232
- n = int(self.pad * SAMPLE_RATE)
233
- x = np.pad(wav, (0, max(0, n - len(wav)))).astype(np.float32) # a longer recording runs as it is
234
- feats = self.features(x)
235
- logp = self.session.run(None, {"feats": feats, "feats_lens": np.array([feats.shape[1]], dtype=np.int64)})[0][0]
236
- keep = max(1, round(logp.shape[0] * len(wav) / len(x))) # frames that cover the speech
237
- return np.repeat(logp[:keep], UPSAMPLE, axis=0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/prefetch.py DELETED
@@ -1,35 +0,0 @@
1
- """Downloads MODEL_ID's files while the Docker image is built, so a cold start doesn't (see the Dockerfile).
2
-
3
- MODEL_ID=powsm-onnx:<repo> python -m app.prefetch # an exported POWSM (scripts/export_powsm_onnx.py)
4
- MODEL_ID=facebook/wav2vec2-xlsr-53-espeak-cv-ft python -m app.prefetch
5
-
6
- A private repo needs HF_TOKEN (the Dockerfile passes the Space secret of that name to this step only).
7
- """
8
- from __future__ import annotations
9
-
10
- import os
11
- import sys
12
-
13
- from .powsm import ONNX_PREFIX, onnx_model_dir, parse_model_id
14
-
15
- DEFAULT = "facebook/wav2vec2-xlsr-53-espeak-cv-ft"
16
-
17
-
18
- def main() -> None:
19
- model_id = os.environ.get("MODEL_ID", "").strip() or DEFAULT
20
- if model_id.startswith(ONNX_PREFIX):
21
- print("exported POWSM:", onnx_model_dir(model_id))
22
- return
23
- if parse_model_id(model_id):
24
- sys.exit(f"MODEL_ID={model_id} needs ESPnet, which the image doesn't have: export it with "
25
- f"scripts/export_powsm_onnx.py and use MODEL_ID={ONNX_PREFIX}<repo>")
26
- from huggingface_hub import hf_hub_download
27
- from transformers import AutoFeatureExtractor, Wav2Vec2ForCTC
28
- AutoFeatureExtractor.from_pretrained(model_id)
29
- Wav2Vec2ForCTC.from_pretrained(model_id)
30
- hf_hub_download(model_id, "vocab.json")
31
- print("wav2vec2:", model_id)
32
-
33
-
34
- if __name__ == "__main__":
35
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/prompt_tts.py DELETED
@@ -1,102 +0,0 @@
1
- """Reads the Hebrew prompt aloud with a free TTS engine: edge-tts (Microsoft Edge's online voices)
2
- or gTTS (Google Translate's voice). The page asks for edge-tts in Edge and gTTS elsewhere.
3
-
4
- Before it is read, the text gets niqqud (nakdan.py): unpointed Hebrew is often misread.
5
-
6
- No API key. Both engines call an unofficial public endpoint, so either can fail or be rate limited:
7
- the other one is tried before giving up. Each prompt is generated once per engine and cached on disk.
8
-
9
- Env: HEBREW_VOICE edge-tts voice (default he-IL-HilaNeural; he-IL-AvriNeural is the male one)
10
- TTS_CACHE_DIR optional; default ~/.cache/ihki-tts
11
- """
12
- from __future__ import annotations
13
-
14
- import asyncio
15
- import hashlib
16
- import io
17
- import logging
18
- import os
19
- import re
20
- import threading
21
- from pathlib import Path
22
-
23
- from .nakdan import point
24
-
25
- log = logging.getLogger("ihki.prompt_tts")
26
- ENGINES = ("edge", "gtts")
27
- _lock = threading.Lock()
28
-
29
-
30
- class PromptTTSUnavailable(Exception):
31
- pass
32
-
33
-
34
- def _edge(text: str) -> bytes:
35
- import edge_tts
36
-
37
- voice = os.environ.get("HEBREW_VOICE", "").strip() or "he-IL-HilaNeural"
38
-
39
- async def run() -> bytes:
40
- out = b""
41
- async for chunk in edge_tts.Communicate(text, voice).stream():
42
- if chunk["type"] == "audio":
43
- out += chunk["data"]
44
- return out
45
-
46
- return asyncio.run(run())
47
-
48
-
49
- def _gtts(text: str) -> bytes:
50
- from gtts import gTTS
51
-
52
- buf = io.BytesIO()
53
- gTTS(text, lang="iw").write_to_fp(buf) # "iw" is Google's code for Hebrew
54
- return buf.getvalue()
55
-
56
-
57
- _SYNTH = {"edge": _edge, "gtts": _gtts}
58
-
59
-
60
- def spoken(he: str) -> str:
61
- """The prompt as it should be read: without bracketed notes such as "(לגבר)"."""
62
- return re.sub(r"\s*\([^)]*\)", "", he).strip()
63
-
64
-
65
- def gender_of(he: str) -> str | None:
66
- """"m" or "f" when the note says who the sentence is said to: "(לגבר)", "(לאישה)"."""
67
- return "m" if "(לגבר)" in he else "f" if "(לאישה)" in he else None
68
-
69
-
70
- def hebrew_mp3(he: str, engine: str, cache_it: bool = True) -> tuple[bytes, str]:
71
- """(MP3, engine that produced it) for a Hebrew text; `engine` is tried first, then the other.
72
-
73
- Prompts are cached; one-off texts (the spoken assessment of driving mode) pass cache_it=False.
74
- A text that could not get niqqud is read as it is and not cached, so it is pointed next time.
75
- """
76
- text, final = point(spoken(he), gender_of(he))
77
- cache_it = cache_it and final
78
- cache = Path(os.environ.get("TTS_CACHE_DIR", "").strip() or Path.home() / ".cache" / "ihki-tts") / "he"
79
- order = [engine] + [e for e in ENGINES if e != engine]
80
- with _lock:
81
- for name in order:
82
- path = cache / f"{name}-{hashlib.sha1(text.encode()).hexdigest()}.mp3"
83
- if cache_it and path.exists():
84
- return path.read_bytes(), name
85
- try:
86
- data = _SYNTH[name](text)
87
- except Exception as e: # network, rate limit, missing package: fall through to the other engine
88
- log.warning("hebrew tts %s failed: %s: %s", name, type(e).__name__, e)
89
- continue
90
- if not data:
91
- continue
92
- if not cache_it:
93
- return data, name
94
- try:
95
- cache.mkdir(parents=True, exist_ok=True)
96
- tmp = path.with_suffix(".tmp")
97
- tmp.write_bytes(data)
98
- tmp.replace(path)
99
- except OSError as e: # read-only or full cache dir: the audio is still good, it just isn't kept
100
- log.warning("hebrew tts cache write failed: %s: %s", type(e).__name__, e)
101
- return data, name
102
- raise PromptTTSUnavailable("hebrew tts unavailable")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/push.py DELETED
@@ -1,210 +0,0 @@
1
- """Push notifications: each morning, a reminder of the day's daily video (see "Daily videos" in auth.py).
2
-
3
- A student turns them on from the daily videos' screen. The browser gives a subscription (an address of its push service
4
- and two keys), kept in the Supabase table `push_subs` with the account's email; only the server's secret key reads it.
5
- Every few minutes the server looks whether it is PUSH_HOUR or later in Jerusalem; if so, each subscription whose account
6
- has a daily video today (a class's student or teacher, or, for a staff account, any class's) and was not reminded today
7
- gets one notification. A subscription the push service no longer knows (404/410) is deleted.
8
-
9
- On an iPhone the browser sends them only from the app added to the home screen (iOS 16.4 and later).
10
-
11
- Env: VAPID_PRIVATE_KEY (the server's push key; make one with python -m app.push and keep it as a Space secret.
12
- Changing it drops every subscription: each device has to turn the reminders on again.)
13
- VAPID_SUBJECT (a contact the push services may write to, "mailto:..."; default: the first developer's email)
14
- PUSH_HOUR (the hour of the reminder in Jerusalem, 0-23; default 8)
15
- """
16
- from __future__ import annotations
17
-
18
- import asyncio
19
- import base64
20
- import json
21
- import logging
22
- import os
23
- import urllib.parse
24
- from datetime import date, datetime
25
- from typing import Callable
26
-
27
- from .auth import AuthUnavailable
28
-
29
- log = logging.getLogger("ihki.push")
30
- CHECK_EVERY = 300 # seconds between looks at the clock
31
- MAX_ENDPOINT = 1000
32
- CREATE_PUSH_SUBS = ("create table public.push_subs (endpoint text primary key, email text not null, p256dh text not null, "
33
- "auth text not null, every_class boolean not null default false, last_daily date, "
34
- "created_at timestamptz not null default now()); "
35
- "alter table public.push_subs enable row level security; "
36
- "revoke all on public.push_subs from anon, authenticated;")
37
-
38
-
39
- def _b64(raw: bytes) -> str:
40
- return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
41
-
42
-
43
- def new_private_key() -> str:
44
- """A new VAPID private key, as VAPID_PRIVATE_KEY keeps it: the raw 32 bytes, base64url."""
45
- from cryptography.hazmat.primitives.asymmetric import ec
46
- return _b64(ec.generate_private_key(ec.SECP256R1()).private_numbers().private_value.to_bytes(32, "big"))
47
-
48
-
49
- def public_key(private: str) -> str:
50
- """The browser's applicationServerKey for this private key: the uncompressed point, base64url."""
51
- from cryptography.hazmat.primitives import serialization
52
- from cryptography.hazmat.primitives.asymmetric import ec
53
- raw = base64.urlsafe_b64decode(private + "=" * (-len(private) % 4))
54
- key = ec.derive_private_key(int.from_bytes(raw, "big"), ec.SECP256R1())
55
- return _b64(key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint))
56
-
57
-
58
- class Push:
59
- def __init__(self, auth, private: str = "", subject: str = "", hour: int = 8):
60
- self.auth, self.private, self.subject, self.hour = auth, private.strip(), subject, hour
61
- self.public, self.why = "", "" # why: what keeps the reminders off, in words for the page
62
- if not self.private:
63
- self.why = "VAPID_PRIVATE_KEY is not set"
64
- elif not getattr(auth, "secret", ""):
65
- self.why = "SUPABASE_SECRET_KEY is not set"
66
- else:
67
- try:
68
- self.public = public_key(self.private)
69
- except ValueError as e:
70
- log.error("VAPID_PRIVATE_KEY is not a valid key: push reminders are off (%s)", e)
71
- self.why = "VAPID_PRIVATE_KEY is not a valid key"
72
-
73
- @property
74
- def on(self) -> bool:
75
- return not self.why
76
-
77
- # the table
78
- def _rows(self, query: str = "") -> list[dict]:
79
- try:
80
- return self.auth._admin("/rest/v1/push_subs?select=*" + query) or []
81
- except AuthUnavailable as e:
82
- if "answered 404" in str(e):
83
- raise AuthUnavailable(f"the table push_subs is missing; run in Supabase's SQL editor: {CREATE_PUSH_SUBS}") from e
84
- raise
85
-
86
- def save(self, email: str, sub: dict, every_class: bool) -> None:
87
- """Keeps a browser's subscription, raising ValueError when it is not one."""
88
- endpoint, keys = str(sub.get("endpoint") or ""), sub.get("keys") or {}
89
- if not endpoint.startswith("https://") or len(endpoint) > MAX_ENDPOINT or not isinstance(keys, dict):
90
- raise ValueError("not a push subscription")
91
- p256dh, auth = str(keys.get("p256dh") or ""), str(keys.get("auth") or "")
92
- if not (0 < len(p256dh) <= 200 and 0 < len(auth) <= 100):
93
- raise ValueError("not a push subscription")
94
- self._rows("&limit=0") # a missing table is told in words that say how to make it
95
- self.auth._admin("/rest/v1/push_subs?on_conflict=endpoint", "POST",
96
- {"endpoint": endpoint, "email": email.lower(), "p256dh": p256dh, "auth": auth,
97
- "every_class": every_class}, prefer="resolution=merge-duplicates,return=minimal")
98
-
99
- def delete(self, endpoint: str, email: str | None = None) -> None:
100
- q = {"endpoint": f"eq.{endpoint}", **({"email": f"eq.{email.lower()}"} if email else {})}
101
- self.auth._admin("/rest/v1/push_subs?" + urllib.parse.urlencode(q), "DELETE", prefer="return=minimal")
102
-
103
- def _sent(self, endpoint: str, day: str) -> None:
104
- self.auth._admin("/rest/v1/push_subs?" + urllib.parse.urlencode({"endpoint": f"eq.{endpoint}"}), "PATCH",
105
- {"last_daily": day}, prefer="return=minimal")
106
-
107
- # sending
108
- def send(self, row: dict, payload: dict) -> bool:
109
- """One notification; False when it did not go (a subscription that is gone is deleted)."""
110
- from pywebpush import WebPushException, webpush
111
- try:
112
- webpush({"endpoint": row["endpoint"], "keys": {"p256dh": row["p256dh"], "auth": row["auth"]}},
113
- json.dumps(payload, ensure_ascii=False), vapid_private_key=self.private,
114
- vapid_claims={"sub": self.subject}, ttl=12 * 3600, timeout=15)
115
- return True
116
- except WebPushException as e:
117
- status = getattr(e.response, "status_code", None)
118
- if status in (404, 410):
119
- log.info("push subscription of %s is gone: deleted", row.get("email"))
120
- try:
121
- self.delete(row["endpoint"])
122
- except AuthUnavailable as e2:
123
- log.warning("push subscription not deleted: %s", e2)
124
- else:
125
- log.warning("push to %s failed: %s", row.get("email"), e)
126
- except Exception as e: # a network failure: tried again on the next look
127
- log.warning("push to %s failed: %s", row.get("email"), e)
128
- return False
129
-
130
- def every(self) -> list[dict]:
131
- """Every subscription (an admin's message goes to some of them)."""
132
- return self._rows()
133
-
134
- def send_many(self, rows: list[dict], message: dict) -> int:
135
- """One notification to each, a few at a time; how many went."""
136
- from concurrent.futures import ThreadPoolExecutor
137
- if not rows:
138
- return 0
139
- with ThreadPoolExecutor(max_workers=min(8, len(rows))) as pool:
140
- return sum(pool.map(lambda r: self.send(r, message), rows))
141
-
142
- def mine(self, email: str) -> list[dict]:
143
- return self._rows("&" + urllib.parse.urlencode({"email": f"eq.{email.lower()}"}))
144
-
145
- def remind(self, today_of: Callable[[str, bool], list[dict]], day: str) -> int:
146
- """Sends the day's reminder to each subscription not reminded on `day`; how many went.
147
- `today_of(email, every_class)` gives the daily videos that account has on that day."""
148
- sent = 0
149
- for row in self._rows("&" + urllib.parse.urlencode({"or": f"(last_daily.is.null,last_daily.lt.{day})"})):
150
- videos = today_of(row["email"], bool(row.get("every_class")))
151
- if not videos:
152
- continue
153
- if self.send(row, payload(videos)):
154
- sent += 1
155
- self._sent(row["endpoint"], day)
156
- return sent
157
-
158
-
159
- MAX_TITLE, MAX_BODY = 60, 240
160
-
161
-
162
- def message(title: str, body: str) -> dict:
163
- """An admin's notification; ValueError when it is empty or too long. Each has its own tag, so one does not replace another."""
164
- title, body = " ".join(title.split()), body.strip()
165
- if not title or len(title) > MAX_TITLE or len(body) > MAX_BODY:
166
- raise ValueError(f"a title of 1-{MAX_TITLE} characters, and a message of up to {MAX_BODY}")
167
- return {"title": title, "body": body, "url": "/", "tag": f"msg-{datetime.now().timestamp():.0f}"}
168
-
169
-
170
- def payload(videos: list[dict]) -> dict:
171
- """What the service worker shows: the day's video (or how many there are)."""
172
- body = videos[0]["title"] if len(videos) == 1 else f"{len(videos)} סרטונים חדשים: " + " · ".join(v["title"] for v in videos)
173
- return {"title": "🎬 הסרטון היומי מחכה לכם", "body": body, "url": "/?view=daily", "tag": "daily-video"}
174
-
175
-
176
- async def loop(push: Push, today_of: Callable[[str, bool], list[dict]], now: Callable[[], datetime]) -> None:
177
- """Looks at the clock every CHECK_EVERY seconds and sends the day's reminders from PUSH_HOUR on."""
178
- while True:
179
- try:
180
- t = now()
181
- if t.hour >= push.hour:
182
- n = await asyncio.to_thread(push.remind, today_of, t.date().isoformat())
183
- if n:
184
- log.info("daily video reminders sent: %d", n)
185
- except AuthUnavailable as e:
186
- log.warning("daily video reminders not sent: %s", e)
187
- except Exception:
188
- log.exception("daily video reminders failed")
189
- await asyncio.sleep(CHECK_EVERY)
190
-
191
-
192
- def load_push(auth) -> Push | None:
193
- if auth is None:
194
- return None
195
- subject = os.environ.get("VAPID_SUBJECT", "").strip()
196
- if not subject:
197
- dev = sorted(getattr(auth, "developers", ()) or getattr(auth, "teachers", ()))
198
- subject = f"mailto:{dev[0]}" if dev else "mailto:admin@example.com"
199
- try:
200
- hour = max(0, min(23, int(os.environ.get("PUSH_HOUR", "8"))))
201
- except ValueError:
202
- hour = 8
203
- push = Push(auth, os.environ.get("VAPID_PRIVATE_KEY", ""), subject, hour)
204
- log.info("daily video reminders: %s", f"on, at {hour}:00" if push.on else f"off ({push.why})")
205
- return push
206
-
207
-
208
- if __name__ == "__main__":
209
- key = new_private_key()
210
- print(f"VAPID_PRIVATE_KEY={key}\n# its public key (the page reads it from the server): {public_key(key)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/reference.py DELETED
@@ -1,89 +0,0 @@
1
- """Native baseline for the scores: the playback voices saying the phrase, scored by the same model.
2
-
3
- The phoneme model mishears some sounds even from a native (ʕ in على as dropped, l as r), and a
4
- learner should not be marked down or get a tip for those. So each practised phrase is also scored
5
- on its TTS model answer, and Scorer measures the learner against that (see Scorer.score_logp).
6
-
7
- One voice is generated per phrase (the first of the list); the other voices are used only when
8
- the page has already played them, so calibration costs at most one TTS call per phrase.
9
- References are built in the background and kept in memory; a score waits a few seconds for them
10
- and is given without calibration if they are not ready by then.
11
- """
12
- from __future__ import annotations
13
-
14
- import logging
15
- import threading
16
- import time
17
- from dataclasses import replace
18
- from concurrent.futures import Future, ThreadPoolExecutor, wait
19
-
20
- from .arabic_diacritics import tts_arabic
21
- from .scorer import Reference, Scorer, decode_audio
22
- from .tts import voice_name
23
-
24
- log = logging.getLogger("ihki.reference")
25
-
26
-
27
- class References:
28
- def __init__(self, scorer: Scorer, tts, voices: list[str], wait_s: float = 8.0, retry_s: float = 300.0):
29
- self.scorer, self.tts, self.voices = scorer, tts, voices
30
- self.wait_s, self.retry_s = wait_s, retry_s
31
- self._pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ihki-ref")
32
- self._jobs: dict[tuple[int, str], Future] = {}
33
- self._retry_at: dict[tuple[int, str], float] = {} # failed builds are not retried before this time
34
- self._lock = threading.Lock()
35
-
36
- def _build(self, phrase: dict, text: str, voice: str) -> Reference | None:
37
- # wav() would turn a voice it does not offer for playback (فدوى) into Raef's
38
- data, _ = self.tts.wav_for_voice(text, voice, source="baseline", upload=True)
39
- ref = self.scorer.reference(decode_audio(data), phrase)
40
- if ref is None:
41
- log.info("reference not usable: %r by voice %s", text, voice)
42
- return None
43
- return replace(ref, voice=voice_name(voice))
44
-
45
- def get(self, phrase_id: int, phrase: dict) -> list[Reference]:
46
- """The references of a phrase that are ready within the wait."""
47
- text = tts_arabic(*phrase["variants"][0])
48
- jobs: list[tuple[tuple[int, str], Future]] = []
49
- with self._lock:
50
- for n, voice in enumerate(self.voices):
51
- key = (phrase_id, voice)
52
- job = self._jobs.get(key)
53
- if (job is None and time.monotonic() >= self._retry_at.get(key, 0)
54
- and (n == 0 or self.tts.cached(text, voice))):
55
- job = self._jobs[key] = self._pool.submit(self._build, phrase, text, voice)
56
- if job is not None:
57
- jobs.append((key, job))
58
- done, _ = wait([job for _, job in jobs], timeout=self.wait_s)
59
- refs = []
60
- for key, job in jobs:
61
- if job not in done:
62
- continue
63
- try:
64
- ref = job.result()
65
- except Exception as e: # provider down, undecodable audio: score without this voice
66
- log.warning("reference for phrase %s failed: %s", key[0], e)
67
- with self._lock:
68
- if self._jobs.get(key) is job:
69
- del self._jobs[key]
70
- self._retry_at[key] = time.monotonic() + self.retry_s
71
- continue
72
- if ref is not None:
73
- refs.append(ref)
74
- return refs
75
-
76
- def invalidate(self, phrase_id: int | None = None, voice: str | None = None) -> int:
77
- """Forget matching in-memory baselines so the next score rebuilds them from the current WAV."""
78
- with self._lock:
79
- keys = [key for key in self._jobs
80
- if (phrase_id is None or key[0] == phrase_id) and (voice is None or key[1] == voice)]
81
- for key in keys:
82
- job = self._jobs.pop(key)
83
- job.cancel() # harmless when generation or analysis has already started
84
- self._retry_at.pop(key, None)
85
- retry_keys = [key for key in self._retry_at
86
- if (phrase_id is None or key[0] == phrase_id) and (voice is None or key[1] == voice)]
87
- for key in retry_keys:
88
- self._retry_at.pop(key, None)
89
- return len(keys)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/rules.py DELETED
@@ -1,433 +0,0 @@
1
- """Pronunciation rules for Hebrew-speaking learners, read from the yaml blocks of pronunciation-rules.md.
2
-
3
- The document is written for two audiences: its student sections are served as a guide, and its scorer
4
- sections (the ```yaml blocks) say what the scorer accepts and how it judges errors:
5
-
6
- archiphonemes letters said differently in the urban and the rural dialect, and their correct realisations
7
- checks per rule: (target, heard) -> severity and the Hebrew feedback
8
- scoring margins per (target, heard) pair, max_tips, ...
9
- heard_labels the Hebrew name of every heard symbol
10
-
11
- Every symbol is stored by its base() name (phones.py), the names the scorer compares in, so a rule written
12
- with ʁ matches a model that heard ɣ. check_detectable() tells which rules a model can catch at all.
13
- """
14
- from __future__ import annotations
15
-
16
- import re
17
- import math
18
- from dataclasses import dataclass, field
19
- from functools import lru_cache
20
- from pathlib import Path
21
- from typing import Callable
22
-
23
- import yaml
24
-
25
- from .phones import base
26
-
27
- RULES_PATH = Path(__file__).resolve().parent.parent / "static" / "assets" / "pronunciation-rules.md"
28
-
29
- # Realisations correct only in learned words (ثقافة as saqaafe): accepted where the phrase writes them, not elsewhere.
30
- LEARNED_ONLY = {"TH": {"s"}, "DH": {"z"}}
31
- # Classes set by position, not by letter (section 6.3 #5 and #6): the vowel of a final ة, the vowel of a pronoun ending.
32
- POSITIONAL = {"E": ["a", "e", "i"], "O": ["o", "u"]}
33
-
34
-
35
- def norm(symbol: str) -> str:
36
- """A rule symbol in the scorer's names. "_" (nothing heard) and the abstract symbols stay as they are."""
37
- return symbol if symbol == "_" or symbol.isupper() else base(symbol)
38
-
39
-
40
- @dataclass(frozen=True)
41
- class Check:
42
- rule: str
43
- target: str
44
- heard: str
45
- severity: int
46
- feedback: str
47
-
48
-
49
- @dataclass
50
- class Rules:
51
- archiphonemes: dict[str, dict[str, list[str]]]
52
- dialects: list[str]
53
- optional: bool
54
- scoring: dict
55
- checks: dict[tuple[str, str], Check]
56
- titles: dict[str, str] # rule id -> title
57
- heard_labels: dict[str, str]
58
- margins: dict[tuple[str, str], float] = field(default_factory=dict)
59
- # a vowel heard between two consonants of a word (3.10): rule, severity, min_peak, min_frames, score_cap, feedback
60
- insertion: dict | None = None
61
- # short vowels a rule flags although short vowels are not flagged (6.3 #7): vowel -> rule id (3.11: u)
62
- flag_vowels: dict[str, str] = field(default_factory=dict)
63
- # a long vowel said short, judged by its duration (3.8): rule, severity, min_ratio, prior_frames, ..., feedback
64
- length: dict | None = None
65
- # (target, heard) pairs a native reference never forgives (scoring.baseline_protected, 6.4)
66
- baseline_protected: set[tuple[str, str]] = field(default_factory=set)
67
-
68
- def members(self, symbol: str, written: str | None = None) -> list[str]:
69
- """The correct realisations of an abstract symbol, in base() names, first the dialects' own.
70
-
71
- A learned-only realisation (TH as s) counts only when the phrase writes it (`written`)."""
72
- if symbol in POSITIONAL:
73
- return list(POSITIONAL[symbol])
74
- spec = self.archiphonemes.get(symbol)
75
- if spec is None:
76
- return []
77
- out: list[str] = []
78
- keys = [*self.dialects, "also_ok", *(["optional"] if self.optional else [])]
79
- for key in keys:
80
- for ph in spec.get(key, []):
81
- ph = base(ph)
82
- if ph not in out and (ph not in LEARNED_ONLY.get(symbol, ()) or ph == written):
83
- out.append(ph)
84
- return out
85
-
86
- def dialects_of(self, symbol: str, phone: str) -> set[str]:
87
- """The dialects in which `phone` is a correct realisation of `symbol` (empty: in none, or not dialectal)."""
88
- spec = self.archiphonemes.get(symbol, {})
89
- return {d for d in ("urban", "rural") if phone in {base(p) for p in spec.get(d, [])}}
90
-
91
- def check(self, targets: str | list[str], heard: str | None) -> Check | None:
92
- """The rule for hearing `heard` instead of the target: the first of `targets` that has one."""
93
- if not heard:
94
- return None
95
- for t in [targets] if isinstance(targets, str) else targets:
96
- c = self.checks.get((norm(t), norm(heard)))
97
- if c is not None:
98
- return c
99
- return None
100
-
101
- def margin(self, targets: str | list[str], heard: str | None) -> float:
102
- """How much likelier than the target `heard` must be before it is a mistake (natural log). The first
103
- of `targets` (the abstract symbol, then the written phoneme) with a margin of its own decides."""
104
- default = float(self.scoring.get("margin_default", 0.0))
105
- for t in [targets] if isinstance(targets, str) else targets:
106
- m = self.margins.get((norm(t), norm(heard or "_")))
107
- if m is not None:
108
- return m
109
- return default
110
-
111
- def protected(self, targets: str | list[str], heard: str | None) -> bool:
112
- """Is (target, heard) a pair a native reference never forgives? The target as an abstract symbol or written."""
113
- return heard is not None and any((norm(t), norm(heard)) in self.baseline_protected
114
- for t in ([targets] if isinstance(targets, str) else targets))
115
-
116
- def label(self, heard: str | None) -> str | None:
117
- """The Hebrew name of a heard symbol, as section 8 gives it."""
118
- if heard is None:
119
- return None
120
- return self.heard_labels.get(heard) or self.heard_labels.get(norm(heard))
121
-
122
- def interference(self) -> set[str]:
123
- """Every symbol some rule expects to hear instead of the target: the Hebrew sounds worth naming."""
124
- return {h for (_, h) in self.checks if h != "_"}
125
-
126
-
127
- def _blocks(text: str) -> list[dict]:
128
- out = []
129
- for block in re.findall(r"```yaml\n(.*?)```", text, re.S):
130
- for doc in re.split(r"^---$", block, flags=re.M):
131
- if doc.strip():
132
- out.append(yaml.safe_load(doc))
133
- return out
134
-
135
-
136
- def parse(text: str) -> Rules:
137
- archi, dialects, optional, scoring, labels = {}, ["urban", "rural"], False, {}, {}
138
- checks: dict[tuple[str, str], Check] = {}
139
- titles: dict[str, str] = {}
140
- insertion, flag_vowels, length = None, {}, None
141
- for doc in _blocks(text):
142
- if "duration" in doc:
143
- length = {"rule": doc["id"], **doc["duration"]}
144
- if "insertion" in doc:
145
- titles[doc["id"]] = doc.get("title", doc["id"])
146
- insertion = {"rule": doc["id"], **doc["insertion"]}
147
- for v in doc.get("flag_vowels", []):
148
- flag_vowels[str(v)] = doc["id"]
149
- if "archiphonemes" in doc:
150
- archi = {k: {d: list(v.get(d, [])) for d in ("urban", "rural", "also_ok", "optional")}
151
- for k, v in doc["archiphonemes"].items()}
152
- dialects = list(doc.get("dialects_enabled", dialects))
153
- optional = bool(doc.get("optional_realisations_enabled", False))
154
- if "scoring" in doc:
155
- scoring = dict(doc["scoring"])
156
- if "heard_labels" in doc:
157
- labels = {str(k): str(v) for k, v in doc["heard_labels"].items()}
158
- if "checks" in doc:
159
- titles[doc["id"]] = doc.get("title", doc["id"])
160
- for c in doc["checks"]:
161
- for heard in c["heard"]:
162
- key = (norm(str(c["target"])), norm(str(heard)))
163
- # two spellings of one sound (ʁ and ɣ) meet here: the first rule written wins
164
- checks.setdefault(key, Check(doc["id"], key[0], key[1], int(c["severity"]), str(c["feedback"])))
165
- rules = Rules(archi, dialects, optional, scoring, checks, titles, labels, insertion=insertion, flag_vowels=flag_vowels,
166
- length=length)
167
- for m in scoring.get("margins", []):
168
- # base() drops vowel length, so a margin for long vowels would land on quality pairs (a / i) it was not meant for
169
- for t in (t for t in m["target"] if "ː" not in str(t)):
170
- for h in m["heard"]:
171
- rules.margins[(norm(str(t)), norm(str(h)))] = float(m["margin"])
172
- rules.baseline_protected = {(norm(str(p["target"])), norm(str(p["heard"]))) for p in scoring.get("baseline_protected", [])}
173
- return rules
174
-
175
-
176
- @lru_cache(maxsize=1)
177
- def load(path: str = str(RULES_PATH)) -> Rules:
178
- return parse(Path(path).read_text(encoding="utf-8"))
179
-
180
-
181
- def undetectable(check: Check, rules: Rules, in_model: Callable[[str], bool],
182
- heard_as: Callable[[str], object] | None = None) -> str | None:
183
- """Why the model cannot catch this mistake, or None if it can.
184
-
185
- `heard_as` names the model symbol a sound is heard as (stand-ins included: ðˤ as z); two sounds heard
186
- as the same symbol cannot be told apart."""
187
- same = (lambda a, b: a == b or (heard_as(a) is not None and heard_as(a) == heard_as(b))) if heard_as \
188
- else (lambda a, b: a == b)
189
- if rules.length and check.rule == rules.length["rule"]:
190
- return None # a long vowel said short is measured by its duration, not its symbol
191
- if same(check.target, check.heard):
192
- return "same_sound" # the two collapse to one sound (aː / a: length is not modelled)
193
- accepted = rules.members(check.target) if check.target.isupper() else [check.target]
194
- if any(same(check.heard, p) for p in accepted):
195
- return "accepted" # the 'mistake' is a correct realisation of the target
196
- if not any(in_model(p) for p in accepted):
197
- return "target_not_in_model"
198
- if check.heard != "_" and not in_model(check.heard):
199
- return "heard_not_in_model"
200
- return None
201
-
202
-
203
- def check_detectable(rules: Rules, in_model: Callable[[str], bool],
204
- heard_as: Callable[[str], object] | None = None) -> dict[str, dict]:
205
- """Per rule: whether at least one of its checks can fire, and why each of the others cannot."""
206
- out: dict[str, dict] = {}
207
- for c in rules.checks.values():
208
- r = out.setdefault(c.rule, {"title": rules.titles.get(c.rule, c.rule), "detectable": False, "missing": []})
209
- why = undetectable(c, rules, in_model, heard_as)
210
- if why is None:
211
- r["detectable"] = True
212
- else:
213
- r["missing"].append({"target": c.target, "heard": c.heard, "why": why})
214
- return out
215
-
216
-
217
- # ---------- exemptions (section 6.3): what the model hears as a mistake but is ordinary speech ----------
218
-
219
- VOWELS = {"a", "i", "u", "e", "o", "aː", "iː", "uː", "eː", "oː"}
220
- _VOWEL_GROUPS = [set("iɪeɛ"), set("uʊoɔ"), set("aæɐɑəɛe")] # a raised towards e is ordinary Palestinian speech
221
-
222
-
223
- def near_vowels(expected: str, heard: str | None) -> bool:
224
- """Same vowel with another length, or the neighbouring quality (i/e, u/o, a/ə, a/e)."""
225
- if expected not in VOWELS or not heard:
226
- return False
227
- e, h = expected.rstrip("ː"), heard.rstrip("ː")
228
- return any(e in g and h in g for g in _VOWEL_GROUPS)
229
-
230
-
231
- def elided_article(toks, i: int, ar_words: list[str]) -> bool:
232
- """Is token i the vowel of the article ال right after a word that ends in a vowel?
233
-
234
- There it is not said (على الطّاولة is ʕ a l a | tˤ tˤ aː w l e), so its absence is not an error.
235
- """
236
- t = toks[i]
237
- return (i > 0 and t.phoneme in VOWELS and toks[i - 1].word == t.word - 1 and toks[i - 1].phoneme in VOWELS
238
- and t.word < len(ar_words) and ar_words[t.word].startswith("ال"))
239
-
240
-
241
- # A glottal stop that is simply not there is how natives say it too (انا, اهلا; ق between vowels): #1, #2.
242
- # A glottal stop replaced by another consonant still counts, unless the class accepts it (ق said as k).
243
- # At utterance onset, though, one weak competitor frame is commonly a breath or microphone transient. Treat that
244
- # as an omission regardless of the phone the multilingual model names; two frames (~40 ms) remain a substitution.
245
- def _glottal_dropped(toks, i, sc, heard, ar_words):
246
- if toks[i].sound not in ("ʔ", "Q"):
247
- return False
248
- transient_onset = i == 0 and sc.peak < math.log(0.05) and sc.comp_frames < 2
249
- return sc.deleted or transient_onset
250
-
251
-
252
- # Rule 31: the same onset transient in front of any other first consonant. The multilingual model hears a
253
- # word-initial stop (طاولتنا: closure silence, then a short burst) weakly, and a single breath or microphone frame
254
- # above deleted_comp then outscores it under an arbitrary name (tˤ heard as j). The target must still be there
255
- # (not deleted, peak at least deleted_peak); an omission, or a competitor held two frames (~40 ms), still counts.
256
- def _onset_transient(toks, i, sc, heard, ar_words):
257
- t = toks[i]
258
- return (i == 0 and t.phoneme not in VOWELS and t.sound not in ("ʔ", "Q") and not sc.deleted
259
- and sc.comp_frames == 1 and sc.peak >= math.log(0.05))
260
-
261
-
262
- def _article_vowel_elided(toks, i, sc, heard, ar_words): # #3
263
- return sc.deleted and elided_article(toks, i, ar_words)
264
-
265
-
266
- # Neighbouring vowel qualities and vowel length vary between speakers and towns (il / el),
267
- # and the model confuses them on its own: not worth marking a word down for (#7).
268
- def _near_vowel(toks, i, sc, heard, ar_words):
269
- return near_vowels(toks[i].phoneme, heard)
270
-
271
-
272
- # Short vowels are deliberately not judged by the learner policy, except u after a consonant inside the same word
273
- # (rule 3.11). Their acoustic score must follow that decision: an unflagged 1/100 must not collapse the word score.
274
- def _unmarked_short_vowel(toks, i, sc, heard, ar_words):
275
- t = toks[i]
276
- checked_u = (t.phoneme == "u" and i > 0 and toks[i - 1].word == t.word
277
- and toks[i - 1].phoneme not in VOWELS)
278
- return t.phoneme in {"a", "i", "u", "e", "o"} and not checked_u
279
-
280
-
281
- # Rule 28: a native final /b/ can lose its acoustic voicing before a voiceless consonant across a word boundary.
282
- # The multilingual CTC then strongly labels that closure /p/ (ħaliib tˤaare), although Arabic has no phonemic /p/.
283
- # Keep this narrow: a learner's /p/ inside a word, or before a voiced sound, must not be hidden.
284
- _VOICELESS_ONSETS = {"t", "tˤ", "k", "q", "ʔ", "s", "sˤ", "ʃ", "f", "x", "ħ", "h", "θ"}
285
-
286
-
287
- def _final_b_devoicing(toks, i, sc, heard, ar_words):
288
- if base(toks[i].phoneme) != "b" or base(heard or "") != "p" or i + 1 >= len(toks):
289
- return False
290
- following = toks[i + 1]
291
- return following.word == toks[i].word + 1 and base(following.phoneme) in _VOICELESS_ONSETS
292
-
293
-
294
- # Rule 32: plain t heard as d, or d heard as t. Hebrew has the same contrast (ת / ד), so a learner hardly ever swaps
295
- # them, while the multilingual model often does: a t after n (انتي) or between vowels (فتح) gets some voicing in
296
- # natural speech and is labelled d. Emphatics (tˤ, dˤ) are not covered: their plain counterparts are rule 3.3.
297
- _VOICING_PAIR = {("t", "d"), ("d", "t")}
298
-
299
-
300
- def _t_d_voicing(toks, i, sc, heard, ar_words):
301
- return not sc.deleted and (base(toks[i].phoneme), base(heard or "")) in _VOICING_PAIR
302
-
303
-
304
- # Rule 29: in connected speech, a word-final short vowel flows into a vowel-initial next word. The next word is
305
- # commonly represented with an initial ʔ token, so accept either V or ʔ+V as the hiatus context. CTC may assign the
306
- # transition to only one side and call the final vowel deleted even in native speech (على إيدك).
307
- def _word_final_vowel_linking(toks, i, sc, heard, ar_words):
308
- t = toks[i]
309
- if not sc.deleted or t.phoneme not in {"a", "i", "u", "e", "o"} or i + 1 >= len(toks):
310
- return False
311
- following = toks[i + 1]
312
- if following.word != t.word + 1:
313
- return False
314
- if following.phoneme in VOWELS:
315
- return True
316
- return (following.sound in {"ʔ", "Q"} and i + 2 < len(toks)
317
- and toks[i + 2].word == following.word and toks[i + 2].phoneme in VOWELS)
318
-
319
-
320
- # (name, applies(tokens, index, TokenScore, heard symbol, Arabic words), score it gets: None = full marks,
321
- # else the name of the ScorerConfig floor). The first that applies wins.
322
- EXEMPTIONS = [
323
- ("glottal_dropped", _glottal_dropped, None),
324
- ("onset_transient", _onset_transient, "onset_transient_floor"),
325
- ("article_vowel_elided", _article_vowel_elided, None),
326
- ("near_vowel", _near_vowel, "near_vowel_floor"),
327
- ("final_b_devoicing", _final_b_devoicing, None),
328
- ("t_d_voicing", _t_d_voicing, None),
329
- ("word_final_vowel_linking", _word_final_vowel_linking, "near_vowel_floor"),
330
- ("unmarked_short_vowel", _unmarked_short_vowel, "near_vowel_floor"),
331
- ]
332
-
333
-
334
- # ---------- the rules a teacher can switch off (ניטור): every behaviour taken from pronunciation-rules.md ----------
335
-
336
- @dataclass(frozen=True)
337
- class Toggle:
338
- id: str
339
- group: str # "accept": what counts as correct · "judge": how a mistake is judged · "feedback": what is said
340
- section: str # where pronunciation-rules.md has it
341
- title: str
342
- description: str # what the scorer does while the rule is on; off is the behaviour before the document
343
-
344
-
345
- def _toggles() -> list[Toggle]:
346
- letters = [
347
- ("Q", "ق", "ʔ (עירוני), k (כפרי) או q", "אַהְוֶה, כַּהְוֶה וקַהְוֶה נכונות שלושתן."),
348
- ("K", "ك", "k או tʃ (כפרי)", "כַּלְבּ וצ'ַלְבּ נכונות שתיהן."),
349
- ("TH", "ث", "t או θ (כפרי), ו־s במילים ספרותיות כשהמשפט כותב s", "תְלַאתֶה ות'לַאת'ֶה נכונות שתיהן."),
350
- ("DH", "ذ", "d או ð (כפרי), ו־z במילים ספרותיות כשהמשפט כותב z", "דַהַבּ וד״ַהַבּ נכונות שתיהן."),
351
- ("ZH", "ظ", "dˤ, zˤ או ðˤ", "ד'ֻהְר וט'ֻהְר נכונות שתיהן."),
352
- ("J", "ج", "dʒ או ʒ", "ג' כמו בג'ינס או ז' כמו בז'קט."),
353
- ]
354
- out = [Toggle(f"dialect_{s}", "accept", "5", f"{ar}: {said}",
355
- f"כל המימושים נכונים, וההגה נמדד מול סכום ההסתברויות שלהם (6.1). {ex} "
356
- f"כבוי: נבדק רק המימוש שכתוב במשפט.") for s, ar, said, ex in letters]
357
- out += [
358
- Toggle("final_vowel", "accept", "6.3 #5", "תנועה סופית של ة: a, e או i",
359
- "אַהְוֶה, אַהְוַה ואַהְוִי נכונות. כבוי: נבדקת התנועה שכתובה במשפט."),
360
- Toggle("diphthong", "accept", "6.3 #8", "eː גם כ־aj, ו־oː גם כ־aw",
361
- "בֵּית ובַּיְת נכונות שתיהן (נבדק כווריאנט נוסף). כבוי: רק התנועה הארוכה."),
362
- Toggle("glottal_dropped", "judge", "6.3 #1–2", "עצירה סותמית שהושמטה אינה טעות",
363
- "ʔ (ء, ו־ق עירונית) שלא נשמעה מקבלת ציון מלא, כמו אצל דוברים ילידיים (אַנַא ← אנה)."),
364
- Toggle("onset_transient", "judge", "6.3 #31", "קפיצה של פריים אחד לפני העיצור הראשון",
365
- "עיצור שפותח את המשפט, שנשמע אך חלש, ומתחרה שהופיע בפריים אחד בלבד (נשימה או רעש מיקרופון) מקבל "
366
- "לפחות 90 ולא מסומן. השמטה, או מתחרה בשני פריימים ומעלה, עדיין נבדקים."),
367
- Toggle("article_vowel_elided", "judge", "6.3 #3", "תנועת ה־ال אחרי תנועה",
368
- "ה־i של ال שלא נאמרה אחרי מילה שמסתיימת בתנועה (עַלַא לְ...) אינה טעות."),
369
- Toggle("near_vowel", "judge", "6.3 #7", "תנועות קרובות",
370
- "תנועה שנשמעה כשכנתה (i/e, u/o, a/e) או באורך אחר מקבלת לפחות 90."),
371
- Toggle("unmarked_short_vowel", "judge", "6.3 #30", "תנועה קצרה שאינה נבדקת",
372
- "תנועה קצרה שהמדיניות אינה מסמנת מקבלת לפחות 90, כדי שלא תפיל את ציון המילה. "
373
- "u אחר�� עיצור בתוך אותה מילה עדיין נבדקת לפי כלל הקובוץ."),
374
- Toggle("final_b_devoicing", "judge", "6.3 #28", "b סופית לפני עיצור אטום",
375
- "b בסוף מילה שהמודל שמע כ־p לפני עיצור אטום במילה הבאה מקבלת ציון מלא; "
376
- "באמצע מילה או לפני עיצור קולי אין פטור."),
377
- Toggle("t_d_voicing", "judge", "6.3 #32", "t ו־d לא מתחלפות",
378
- "t שהמודל שמע כ־d, או d שנשמעה כ־t, מקבלת ציון מלא: דוברי עברית מבחינים בין ת ל־ד, "
379
- "והמודל טועה בזוג הזה (אחרי n, בין תנועות). עיצורים מודגשים (ط, ض) נבדקים כרגיל."),
380
- Toggle("word_final_vowel_linking", "judge", "6.3 #29", "תנועה סופית שמתחברת לתנועה הבאה",
381
- "תנועה קצרה בסוף מילה שהמודל רואה כמחוקה לפני מילה שמתחילה בתנועה מקבלת לפחות 90; "
382
- "באמצע מילה או לפני עיצור אין פטור."),
383
- Toggle("margins", "judge", "6.2", "מרווח ביטחון לזוגות שהמודל מבלבל",
384
- "עיצור מודגש נחשב שגוי רק אם הרגיל סביר ממנו פי 2 בערך, ħ מול h פי 1.5, "
385
- "ו־h נחשבת שנבלעה רק אם היא חלשה פי 2 מסף ההשמטה. "
386
- "כבוי: כל מתחרה שמנצח מסמן טעות."),
387
- Toggle("unnamed_sounds", "judge", "7", "צליל שאינו בערבית או בעברית לא נקרא בשמו",
388
- "כשהמודל הרב־לשוני שומע צליל זר (נקישה, צליל רטרופלקסי) הוא מדווח כ'לא ברור'. כבוי: נאמר שם הסמל."),
389
- Toggle("tips_by_severity", "feedback", "6.2", "סדר הטיפים לפי חומרה, עד 3",
390
- "טעות שמשנה משמעות (ח, ע) מוצגת לפני מבטא, ואחריה הציון הנמוך. כבוי: לפי הציון בלבד, עד 4."),
391
- ]
392
- own = {
393
- "vowel_length": "המודל לא מבחין בין a ל־aː, ולכן האורך נמדד בזמן: תנועה ארוכה בין שני עיצורים שקצרה בבירור "
394
- "ביחס לתנועות הקצרות של אותו דובר יורדת לכל היותר ל־60, עם טיפ בחומרה 1. "
395
- "כבוי: אורך התנועה לא נבדק.",
396
- "cluster": "תנועה שהמודל שומע בבירור בין שני עיצורים של אותה מילה (katiir במקום ktiir) מורידה את העיצור השני "
397
- "לכל היותר ל־60, עם טיפ בחומרה 1. לא בצרור שפותח את המשפט, ולא ליד w או j. כבוי: תנועה שנוספה לא נבדקת.",
398
- "kubutz": "u אחרי עיצור היא התנועה הקצרה היחידה שמסומנת: כשנשמטה, או נשמעה כ־e, i או שווא. "
399
- "כ־o היא עדיין תנועה קרובה. כבוי: כמו שאר התנועות הקצרות, לא מסומנת.",
400
- }
401
- for rule_id, title, section in (("pharyngeal_h", "ח לועית", "3.1"), ("ayin", "ע לועית", "3.2"),
402
- ("emphatics", "עיצורים מודגשים", "3.3"), ("r_tap", "ר מתגלגלת", "3.4"),
403
- ("ghain", "ע' היא ה־ר העברית", "3.5"), ("h", "ה נהגית תמיד", "3.6"),
404
- ("w", "ו היא w", "3.7"), ("b", "ב היא תמיד b", "3.7"), ("f", "פ היא תמיד f", "3.7"),
405
- ("vowel_length", "תנועה ארוכה", "3.8"), ("jim", "ג' ולא g", "3.9"),
406
- ("cluster", "שני עיצורים צמודים", "3.10"), ("kubutz", "קובוץ ברור", "3.11")):
407
- out.append(Toggle(f"rule_{rule_id}", "judge" if rule_id in own else "feedback", section, title,
408
- own.get(rule_id) or
409
- f"הטיפ והחומרה (בסוגריים) מהמסמך כשנשמע: {_confusions(rule_id)}. "
410
- "כבוי: טיפ כללי בחומרה בינונית; ההחלטה אם לסמן לא משתנה."))
411
- return out
412
-
413
-
414
- def _confusions(rule_id: str) -> str:
415
- """'ח ← ח עברית (3), ה (2), נבלעת (2)': the substitutions a section-3 rule speaks for, in Hebrew labels."""
416
- rules = load()
417
- by_target: dict[str, list[str]] = {}
418
- for c in rules.checks.values():
419
- if c.rule == rule_id:
420
- heard = rules.label(c.heard) or c.heard
421
- item = f"{'נבלעת' if c.heard == '_' else heard} ({c.severity})"
422
- if item not in by_target.setdefault(c.target, []):
423
- by_target[c.target].append(item)
424
- return "; ".join(f"{_LETTER.get(t, t)} ← {', '.join(items)}" for t, items in by_target.items())
425
-
426
-
427
- # a target sound by the letter of the transliteration (section 1), for the teacher's list
428
- _LETTER = {"ħ": "ח", "ʕ": "ע", "sˤ": "צ", "tˤ": "ט", "dˤ": "ד'", "ZH": "ט'", "r": "ר", "ɣ": "ע'", "h": "ה", "w": "ו",
429
- "b": "ב", "f": "פ", "J": "ג'", "a": "אַא", "i": "ִי", "u": "וּ", "e": "ֵי", "o": "וֹ"}
430
-
431
-
432
- TOGGLES = _toggles()
433
- TOGGLE_IDS = frozenset(t.id for t in TOGGLES)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/scorer.py DELETED
@@ -1,1150 +0,0 @@
1
- """Pronunciation scorer: phoneme CTC model + forced alignment against the expected phonemes."""
2
- from __future__ import annotations
3
-
4
- import copy
5
- import json
6
- import logging
7
- import os
8
- import subprocess
9
- from dataclasses import dataclass, field, replace
10
- from difflib import SequenceMatcher
11
- from typing import Callable, Protocol
12
-
13
- import numpy as np
14
-
15
- from .align import (Alignment, GopConfig, TokenScore, ctc_viterbi, edit_distance, greedy_decode, score_tokens,
16
- weighted_edit_distance)
17
- from .archi import archify, diphthong_variants, split_token, written
18
- from .phones import base
19
- from .rules import EXEMPTIONS, TOGGLE_IDS, Check, Rules, elided_article, near_vowels, norm, load as load_rules # noqa: F401 (re-exported)
20
- from .tips import VOWELS, letter_reason, sound_name, tip_for
21
- from .translit import CONSONANTS as _HEB_C, HEARD_ONLY as _HEB_HEARD, VOWELS as _HEB_V, to_hebrew, word_letters, word_to_hebrew
22
-
23
- HEBREW_SOUNDS = set(_HEB_C) | set(_HEB_V) | set(_HEB_HEARD)
24
- # a vowel gliding into (or out of) j or w passes through these: on the frames between the two, coarticulation
25
- GLIDE_KIN = {"j": ("i", "iː", "e", "eː"), "w": ("u", "uː", "o", "oː")}
26
-
27
- log = logging.getLogger("ihki.scorer")
28
-
29
- SAMPLE_RATE = 16_000
30
- MAX_SECONDS = 32 # the recording includes the time to read the sentence and think (the page stops at 30 s)
31
-
32
- # Fallbacks when the model vocab lacks a phoneme. The first symbol found in the vocab wins.
33
- FALLBACKS = {
34
- "sˤ": ["sˤ", "sˠ", "s"], "tˤ": ["tˤ", "tˠ", "t"], "dˤ": ["dˤ", "dˠ", "d"], "zˤ": ["zˤ", "zˠ", "z"],
35
- "ðˤ": ["ðˤ", "ðˠ", "zˤ", "z"], "dʒ": ["dʒ", "d͡ʒ", "ʒ"],
36
- "aː": ["aː", "a"], "iː": ["iː", "i"], "uː": ["uː", "u"], "eː": ["eː", "e"], "oː": ["oː", "o"],
37
- "x": ["x", "χ"], "ɣ": ["ɣ", "ʁ"], "q": ["q", "k"], "r": ["r", "ɾ"], "g": ["ɡ", "g"],
38
- }
39
-
40
-
41
- class Emitter(Protocol):
42
- """Anything that turns 16 kHz mono float32 audio into (T, V) log-posteriors."""
43
- def __call__(self, wav: np.ndarray) -> np.ndarray: ...
44
-
45
-
46
- class Vocab:
47
- def __init__(self, token_to_id: dict[str, int], blank_token: str = "<pad>", rules: Rules | None = None):
48
- self.t2i = token_to_id
49
- self.i2t = {i: t for t, i in token_to_id.items()}
50
- self.blank = token_to_id[blank_token]
51
- self.rules = rules or load_rules()
52
- self.downgraded: dict[str, str] = {}
53
- # sound classes: every id points at one representative id (the plain symbol when the vocab has it)
54
- groups: dict[str, list[int]] = {}
55
- for t, i in token_to_id.items():
56
- if i != self.blank and not t.startswith("<"):
57
- groups.setdefault(base(t), []).append(i)
58
- self.canon = {i: i for i in self.i2t}
59
- self._groups: list[tuple[int, list[int]]] = []
60
- self.rep_of: dict[str, int] = {} # base() name -> representative id
61
- for name, ids in groups.items():
62
- rep = token_to_id.get(name, ids[0]) if token_to_id.get(name) in ids else ids[0]
63
- self.rep_of[name] = rep
64
- for i in ids:
65
- self.canon[i] = rep
66
- if len(ids) > 1:
67
- self._groups.append((rep, ids))
68
- # Letters with several correct realisations (archi.py): one virtual id per (symbol, realisations),
69
- # numbered after the real vocab, whose column extend() fills with the pooled posterior of its members.
70
- self.n_real = max(self.i2t) + 1
71
- self.members: dict[int, set[int]] = {}
72
- self._classes: dict[tuple[str, frozenset[int]], int] = {}
73
- self.class_missing: dict[str, set[str]] = {} # symbol -> realisations the model has no symbol for
74
-
75
- def merge(self, logp: np.ndarray) -> np.ndarray:
76
- """Pool each sound's spelling variants into its representative column; the other columns go silent."""
77
- out = logp.copy()
78
- for rep, ids in self._groups:
79
- out[:, ids] = -1e9
80
- out[:, rep] = np.logaddexp.reduce(logp[:, ids], axis=1)
81
- return out
82
-
83
- def sound_id(self, phone: str) -> int | None:
84
- """The representative id of a sound (any spelling of it), or None if the model has no symbol for it."""
85
- return self.rep_of.get(base(phone))
86
-
87
- def model_id(self, phone: str) -> int | None:
88
- """The id the model hears `phone` as: its own, else its first FALLBACKS stand-in (ðˤ -> z), else None."""
89
- for cand in [phone, *FALLBACKS.get(phone, [])]:
90
- i = self.sound_id(cand)
91
- if i is not None:
92
- return i
93
- return None
94
-
95
- def add_class(self, symbol: str, phones: list[str]) -> int:
96
- """The id that stands for 'any of `phones`': a virtual id, or the real one when only one is in the model.
97
- A realisation the model lacks is heard as its stand-in, as a plain phoneme would be (zˤ as z)."""
98
- ids: list[int] = []
99
- for ph in phones:
100
- i = self.model_id(ph)
101
- if i is None:
102
- self.class_missing.setdefault(symbol, set()).add(ph)
103
- continue
104
- if i != self.sound_id(ph):
105
- self.downgraded[ph] = self.i2t[i]
106
- if i not in ids:
107
- ids.append(i)
108
- if not ids:
109
- raise KeyError(f"no realisation of {symbol} ({phones}) is in the model vocab")
110
- if len(ids) == 1:
111
- return ids[0]
112
- key = (symbol, frozenset(ids))
113
- if key not in self._classes:
114
- vid = self.n_real + len(self._classes)
115
- self._classes[key], self.members[vid] = vid, set(ids)
116
- self.i2t[vid], self.canon[vid] = symbol, vid
117
- return self._classes[key]
118
-
119
- def extend(self, logp: np.ndarray) -> np.ndarray:
120
- """Merged posteriors with one more column per class: the pooled posterior of its realisations."""
121
- logp = logp[:, :self.n_real]
122
- if not self.members:
123
- return logp
124
- out = np.full((logp.shape[0], self.n_real + len(self.members)), -1e9, dtype=logp.dtype)
125
- out[:, :logp.shape[1]] = logp
126
- for vid, ids in self.members.items():
127
- out[:, vid] = np.logaddexp.reduce(logp[:, sorted(ids)], axis=1)
128
- return out
129
-
130
- def resolve(self, phoneme: str) -> list[int]:
131
- for cand in FALLBACKS.get(phoneme, [phoneme]):
132
- if cand in self.t2i:
133
- if cand != phoneme:
134
- self.downgraded[phoneme] = cand
135
- return [self.t2i[cand]]
136
- # last resort: greedy longest-match split into vocab symbols
137
- ids, rest = [], phoneme
138
- while rest:
139
- for k in range(len(rest), 0, -1):
140
- if rest[:k] in self.t2i:
141
- ids.append(self.t2i[rest[:k]])
142
- rest = rest[k:]
143
- break
144
- else:
145
- raise KeyError(f"phoneme {phoneme!r} cannot be expressed in the model vocab")
146
- self.downgraded[phoneme] = " ".join(self.i2t[i] for i in ids)
147
- return ids
148
-
149
- def token(self, tok: str) -> tuple[list[int], str, str]:
150
- """A token of an IPA string (a phoneme, or an archi.py symbol "Q:ʔ") -> (model ids, phoneme as written, sound).
151
-
152
- The sound is the symbol for a many-ways letter, the phoneme otherwise."""
153
- sym, members, ph = split_token(tok)
154
- if sym is None:
155
- return self.resolve(tok), tok, tok
156
- phones = members or self.rules.members(sym, written=base(ph) if ph else None)
157
- ph = ph or (phones[0] if phones else sym)
158
- try:
159
- return [self.add_class(sym, phones)], ph, sym
160
- except KeyError:
161
- return self.resolve(ph), ph, sym
162
-
163
-
164
- LETTER_GOOD = 75 # a letter scoring under this is coloured on the page (`band` in static/index.html)
165
-
166
-
167
- @dataclass
168
- class TargetToken:
169
- phoneme: str # phoneme as written in phrases.py
170
- id: int # model token id (a virtual id for a letter with several correct realisations)
171
- word: int # word index in the variant
172
- sound: str = "" # what the token stands for: the archi.py symbol (Q) or the phoneme itself
173
-
174
- def __post_init__(self):
175
- self.sound = self.sound or self.phoneme
176
-
177
-
178
- def tokenize(ipa: str, vocab: Vocab) -> list[TargetToken]:
179
- """IPA string -> model tokens. Geminates (and any repeated id) collapse to one CTC token."""
180
- out: list[TargetToken] = []
181
- for w, word in enumerate(ipa.split("|")):
182
- for tok in word.split():
183
- ids, ph, sound = vocab.token(tok)
184
- for tid in ids:
185
- if out and out[-1].id == tid:
186
- continue
187
- out.append(TargetToken(ph, tid, w, sound))
188
- return out
189
-
190
-
191
- @dataclass
192
- class Reference:
193
- """How the model scored a native voice saying a phrase: the baseline a learner is measured against."""
194
- sounds: list[tuple[int, str]] # (word, sound) of each sound of the variant the voice said (Q, not ʔ / k)
195
- scores: list[float] # their scores, same order
196
- voice: str = "" # who said it (the TTS voice's name), for the trace dashboard
197
- # per sound, what took its place in the voice (Scorer._heard): "_" nothing, a symbol, None kept.
198
- # Empty for a reference built before this was recorded: its failures are then unknown.
199
- heard: list[str | None] = field(default_factory=list)
200
-
201
-
202
- @dataclass
203
- class ScorerConfig:
204
- flag_below: float = 50.0 # phoneme score under which a consonant gets a tip
205
- flag_below_sound: dict[str, float] = field(default_factory=dict) # per sound, in place of flag_below (see
206
- # FLAG_BELOW_BY_MODEL: it depends on the model)
207
- # weighted edit distance (Scorer._distance) over the weight of the sounds that can be wrong, above which we say
208
- # "different sentence". 0.65 with plain edit distance over all sounds; the weighted ratio runs lower
209
- unrelated_ratio: float = 0.5
210
- # ... unless the forced alignment clearly found the sentence: this share of its consonants (at least 2) peak
211
- # at unrelated_veto_peak or more, and the mean GOP score (before the exemptions) is at least unrelated_veto_mean
212
- unrelated_veto_share: float = 0.6
213
- unrelated_veto_peak: float = 0.5
214
- unrelated_veto_mean: float = 60.0
215
- issue_below: float = 90.0 # phoneme score under which it is listed as imprecise
216
- max_issues: int = 8
217
- max_tips: int | None = None # None: as many as the rules document says (scoring.max_tips)
218
- near_vowel_floor: float = 90.0 # a vowel heard as its neighbour (i/e, u/o, a/ə, short/long) scores at least this
219
- word_min_weight: float = 0.35 # one weak phoneme matters, without dominating half of the whole word
220
- ref_min_mean: float = 60.0 # a native reference whose mean phoneme score is under this is not trusted
221
- # a sound the native reference scored under this, the model cannot measure in this phrase (ħ in رحنا: 0.2): the
222
- # learner's score on it is noise too, so it is given back in full and the unrelated gate does not count it
223
- ref_noise_floor: float = 10.0
224
- ayin_deleted_floor: float = 60.0 # the base model often drops a correctly pronounced pharyngeal ʕ
225
- onset_transient_floor: float = 90.0 # a first consonant outscored by a one-frame onset spike (rule 31)
226
- # The learner-facing pipeline is more encouraging than the generic GOP primitive: a competitor twice as likely
227
- # scores about 78, while a clearly dominant competitor can still pull the phoneme below the feedback threshold.
228
- gop: GopConfig = field(default_factory=lambda: GopConfig(leniency=0.35))
229
-
230
-
231
- def frame_energy(wav: np.ndarray | None, frames: int) -> np.ndarray | None:
232
- """RMS energy of the audio under each of the model's `frames` (they split the recording evenly, ~20 ms each)."""
233
- if wav is None or frames <= 0 or len(wav) == 0:
234
- return None
235
- edges = np.linspace(0, len(wav), frames + 1).astype(int)
236
- sq = np.concatenate([[0.0], np.cumsum(np.asarray(wav, dtype=np.float64) ** 2)])
237
- n = np.maximum(edges[1:] - edges[:-1], 1)
238
- return np.sqrt((sq[edges[1:]] - sq[edges[:-1]]) / n)
239
-
240
-
241
- @dataclass
242
- class Fit:
243
- """The accepted variant the audio fits best, and how each of its sounds scored."""
244
- ar: str
245
- ipa: str # as written in the phrase bank (display, transliteration)
246
- aipa: str # the same with archi.py symbols, one token per phoneme of `ipa`
247
- toks: list[TargetToken]
248
- al: Alignment
249
- scores: list[TokenScore]
250
- heard_ids: list[int] # greedy decode, real symbols only
251
- unrelated: bool
252
- logp: np.ndarray # merged posteriors with the class columns (Vocab.extend)
253
- # for the teacher's stage-by-stage view (Scorer._trace)
254
- candidates: list[tuple[str, str, float | None]] = field(default_factory=list) # (ar, ipa, path log-prob)
255
- gop_scores: list[TokenScore] = field(default_factory=list) # before the exemptions
256
- exempted: list[str | None] = field(default_factory=list) # per token, the exemption that applied
257
- exempt_scores: list[TokenScore] = field(default_factory=list) # after the exemptions, before the inserted vowels
258
- inserted: dict[int, str] = field(default_factory=dict) # token -> the vowel heard before it (rules 3.10)
259
- insert_scores: list[TokenScore] = field(default_factory=list) # after the inserted vowels, before vowel length
260
- shortened: dict[int, dict] = field(default_factory=dict) # token -> how short its long vowel was (rules 3.8)
261
- distance: float = 0.0 # weighted edit distance of the greedy decode from the chosen variant (_distance)
262
- raw_distance: int = 0 # the same, every edit costing 1
263
- distance_weight: float = 1.0 # what `distance` is divided by: the weight of the sounds that can be wrong
264
- veto: bool = False # the alignment found the sentence, so it is not called unrelated (_veto)
265
- energy: np.ndarray | None = None # per frame RMS (frame_energy), when the audio was given
266
- paths: list[list[str]] = field(default_factory=list) # each candidate's tokens (archi symbols), as `candidates`
267
-
268
-
269
- class Scorer:
270
- def __init__(self, emit: Emitter, vocab: Vocab, cfg: ScorerConfig | None = None):
271
- self.emit, self.vocab, self.cfg = emit, vocab, cfg or ScorerConfig()
272
- self.rules = vocab.rules
273
- self.calibrations: dict[tuple[str, str, str], float] = {}
274
- # the sounds a competitor is named by: Arabic, the Hebrew sounds the rules expect instead, and their labels;
275
- # anything else the multilingual model offers (clicks, retroflexes) is reported as "not clear"
276
- names = (set(HEBREW_SOUNDS) | self.rules.interference() | set(self.rules.heard_labels)
277
- | {p for s in self.rules.archiphonemes for p in self.rules.members(s)})
278
- self.named = {i for i in (vocab.sound_id(n) for n in names) if i is not None}
279
- self.disabled: frozenset[str] = frozenset() # rules.TOGGLES the teacher switched off (ניטור)
280
-
281
- def with_gop(self, **changes) -> "Scorer":
282
- """This scorer with some GopConfig fields changed (ratio_pool, vowel_window ...), sharing its model, vocab and
283
- rules: for the lab's sweep over the settings. Nothing of this scorer changes."""
284
- other = copy.copy(self)
285
- other.cfg = replace(self.cfg, gop=replace(self.cfg.gop, **changes))
286
- return other
287
-
288
- def set_disabled(self, ids) -> None:
289
- """Atomically replaces the switched-off rules; unknown ids are ignored."""
290
- self.disabled = frozenset(ids) & TOGGLE_IDS
291
-
292
- def _on(self, toggle: str) -> bool:
293
- return toggle not in self.disabled
294
-
295
- def _off_symbols(self) -> frozenset[str]:
296
- """The archi.py symbols not to use: letters whose dialect rule is off, and E for the final-vowel rule."""
297
- off = {t.removeprefix("dialect_") for t in self.disabled if t.startswith("dialect_")}
298
- return frozenset(off | ({"E"} if "final_vowel" in self.disabled else set()))
299
-
300
- def _check(self, tok: TargetToken, heard: str | None):
301
- """The rule of pronunciation-rules.md for this substitution, unless the teacher switched it off.
302
- A rule for a short vowel (3.11: u) speaks for that vowel only, not for its long form (base() drops length)."""
303
- check = self.rules.check([tok.sound, tok.phoneme], heard)
304
- if check and check.target in self.rules.flag_vowels and tok.phoneme != check.target:
305
- return None
306
- return check if check and self._on(f"rule_{check.rule}") else None
307
-
308
- def _flag_vowel(self, toks: list[TargetToken], i: int) -> bool:
309
- """Is token i a short vowel a rule flags (3.11: u after a consonant of the same word, not the و of 'and')?"""
310
- t = toks[i]
311
- rule = self.rules.flag_vowels.get(t.phoneme)
312
- return bool(rule and self._on(f"rule_{rule}") and i > 0 and toks[i - 1].word == t.word
313
- and toks[i - 1].phoneme not in VOWELS)
314
-
315
- _GLIDES = {"w", "j"} # the way into or out of a glide sounds like a vowel in anyone's speech
316
-
317
- def _glide_edges(self, toks: list[TargetToken]) -> list[tuple[set[int], set[int]] | None]:
318
- """For a vowel beside j or w: the vowels that glide is made of (GLIDE_KIN), no competitor on the frames
319
- between the vowel and that glide."""
320
- kin = lambda k: {x for p in GLIDE_KIN.get(toks[k].phoneme, ()) if (x := self.vocab.sound_id(p)) is not None}
321
- return [(kin(i - 1) if i else set(), kin(i + 1) if i + 1 < len(toks) else set())
322
- if t.phoneme in VOWELS else None for i, t in enumerate(toks)]
323
-
324
- def _insertions(self, logp: np.ndarray, toks: list[TargetToken], al: Alignment) -> dict[int, str]:
325
- """Rules 3.10: token -> the vowel the model clearly heard between it and the consonant before it, in one word
326
- (katiir for ktiir). The cluster that opens the sentence is spared (6.3 #16), and so are pairs with w or j."""
327
- spec = self.rules.insertion
328
- if not spec or not self._on(f"rule_{spec['rule']}"):
329
- return {}
330
- vowels = {i: base(self.vocab.i2t[i]) for i in (self.vocab.sound_id(v) for v in ("a", "e", "i", "o", "u", "ə"))
331
- if i is not None}
332
- real = logp[:, :self.vocab.n_real]
333
- greedy = real.argmax(1)
334
- min_peak, min_frames = float(np.log(spec.get("min_peak", 0.5))), int(spec.get("min_frames", 2))
335
- out: dict[int, str] = {}
336
- for j in range(2, len(toks)): # j == 1 is the cluster that opens the sentence
337
- a, b = toks[j - 1], toks[j]
338
- if (a.word != b.word or a.phoneme in VOWELS or b.phoneme in VOWELS
339
- or {base(a.phoneme), base(b.phoneme)} & self._GLIDES):
340
- continue
341
- frames = [t for t in range(al.spans[j - 1][0], al.spans[j][1])
342
- if int(greedy[t]) in vowels and real[t, greedy[t]] >= min_peak]
343
- if len(frames) >= min_frames:
344
- out[j] = vowels[int(greedy[frames[0]])]
345
- return out
346
-
347
- _SHORT = {"a", "i", "u", "e", "o"}
348
-
349
- def _shortened(self, toks: list[TargetToken], al: Alignment, scores: list[TokenScore],
350
- energy: np.ndarray | None = None) -> dict[int, dict]:
351
- """Rules 3.8: token -> how short its long vowel was, for each long vowel said short.
352
-
353
- The model pools a and aː (base() drops length), so length is read from time: the frames between the
354
- sounds on either side of the vowel, against the same span around this speaker's short vowels (with a
355
- prior for sentences that have few). With the audio, only the frames at least half as loud as the loudest
356
- of the span count, so a held nasal or a pause after the vowel does not make it long. Only between two
357
- consonants: at the edges of the recording, or beside another vowel, the span says nothing about this one."""
358
- spec = self.rules.length
359
- if not spec or not self._on(f"rule_{spec['rule']}"):
360
- return {}
361
-
362
- def span(k: int) -> int | None:
363
- if not 0 < k < len(toks) - 1 or toks[k - 1].phoneme in VOWELS or toks[k + 1].phoneme in VOWELS \
364
- or scores[k].deleted:
365
- return None
366
- a, b = al.spans[k - 1][1], al.spans[k + 1][0]
367
- if energy is None:
368
- return b - a
369
- seg = energy[a:b]
370
- return int((seg >= 0.5 * seg.max()).sum()) if seg.size and seg.max() > 0 else 0
371
-
372
- shorts = [g for k, t in enumerate(toks) if t.phoneme in self._SHORT and (g := span(k)) is not None]
373
- prior = float(spec["prior_frames"] if energy is not None else spec["prior_frames_plain"])
374
- weight = float(spec["prior_weight"])
375
- ref = (sum(shorts) + prior * weight) / (len(shorts) + weight)
376
- targets = {str(t): float(r) for t, r in spec["min_ratio"].items()}
377
- out: dict[int, dict] = {}
378
- for k, t in enumerate(toks):
379
- g = span(k) if t.phoneme in targets else None
380
- if g is not None and g / ref < targets[t.phoneme]:
381
- out[k] = {"frames": g, "short_frames": round(ref, 2), "ratio": round(g / ref, 2),
382
- "min_ratio": targets[t.phoneme]}
383
- return out
384
-
385
- def set_calibrations(self, rows: list[dict]) -> None:
386
- """Atomically replaces applied score floors. A blank phrase is a global rule."""
387
- rules = {}
388
- for row in rows:
389
- expected, heard = str(row.get("expected", "")), str(row.get("heard", ""))
390
- phrase = str(row.get("phrase", "")) if row.get("scope") == "phrase" else ""
391
- if expected and heard:
392
- rules[(expected, heard, phrase)] = max(0.0, min(100.0, float(row.get("floor", 0))))
393
- self.calibrations = rules
394
-
395
- def score(self, wav: np.ndarray, phrase: dict, refs: list[Reference] = (), diagnostics: bool = False) -> dict:
396
- logp = self.emit(wav)
397
- duration_s = len(wav) / SAMPLE_RATE if wav is not None else None
398
- return self.score_logp(logp, phrase, refs, diagnostics, duration_s, frame_energy(wav, len(logp)))
399
-
400
- def reference(self, wav: np.ndarray, phrase: dict) -> Reference | None:
401
- """The baseline from a native recording of the phrase, or None when the model does not
402
- recognise the phrase in it (then it says nothing about single sounds)."""
403
- logp = self.emit(wav)
404
- return self.reference_logp(logp, phrase, frame_energy(wav, len(logp)))
405
-
406
- def reference_logp(self, logp: np.ndarray, phrase: dict, energy: np.ndarray | None = None) -> Reference | None:
407
- """Scorer.reference from the model's posteriors of the native recording (`emit`), and its frame energy."""
408
- fit = self._fit(self.vocab.merge(logp), phrase, energy)
409
- if fit is None:
410
- return None
411
- if fit.unrelated or float(np.mean([sc.score for sc in fit.scores])) < self.cfg.ref_min_mean:
412
- return None
413
- return Reference([(t.word, t.sound) for t in fit.toks], [sc.score for sc in fit.scores],
414
- heard=[self._heard(t, sc) for t, sc in zip(fit.toks, fit.scores)])
415
-
416
- def _baseline(self, toks: list[TargetToken], scores: list[TokenScore],
417
- refs: list[Reference]) -> tuple[list[float | None], list[str | None]]:
418
- """Per target token, the lowest score a native reference got on the same sound, counting only a reference
419
- that is evidence about the learner's realisation (None: none counts), and why compensation was refused.
420
-
421
- A reference counts when the learner kept the sound (a weak but correct sound), or when the reference lost it
422
- too, however (the model's limitation in this phrase, as ʕ heard as ʔ). It does not count when it kept the
423
- sound ("kept"), nor for a pair the rules protect, which a TTS voice can say the learner's way ("protected":
424
- ħ heard as x).
425
- References may have said another accepted variant: only the sounds the two variants share are matched.
426
- Sounds are compared by symbol, so a voice that said ق as q is a baseline for a learner who says ʔ.
427
- """
428
- out: list[float | None] = [None] * len(toks)
429
- why: list[str | None] = [None] * len(toks)
430
- learner = [self._heard(t, sc) for t, sc in zip(toks, scores)]
431
- rows = self._ref_rows(toks, refs)
432
- for i, tok in enumerate(toks):
433
- lh = learner[i]
434
- have = [cell for row in rows if (cell := row[i]) is not None]
435
- if not have:
436
- continue
437
- if lh is not None and self.rules.protected([tok.sound, tok.phoneme], lh):
438
- why[i] = "protected"
439
- continue
440
- counting = [score for score, heard, known in have if lh is None or not known or heard is not None]
441
- if counting:
442
- out[i] = min(counting)
443
- else:
444
- why[i] = "kept"
445
- return out, why
446
-
447
- @staticmethod
448
- def _ref_rows(toks: list[TargetToken], refs: list[Reference]) -> list[list[tuple[float, str | None, bool] | None]]:
449
- """Per reference, per target token: (its score, what took the sound's place, whether that is known), or None
450
- when the reference does not have the sound."""
451
- sounds = [(t.word, t.sound) for t in toks]
452
- out = []
453
- for ref in refs:
454
- known = len(ref.heard) == len(ref.sounds)
455
- row: list[tuple[float, str | None, bool] | None] = [None] * len(toks)
456
- for a, b, n in SequenceMatcher(None, ref.sounds, sounds, autojunk=False).get_matching_blocks():
457
- for k in range(n):
458
- row[b + k] = (ref.scores[a + k], ref.heard[a + k] if known else None, known)
459
- out.append(row)
460
- return out
461
-
462
- @staticmethod
463
- def _ref_scores(toks: list[TargetToken], refs: list[Reference]) -> list[list[float | None]]:
464
- """Per reference, per target token, the score that reference got on the same sound (None: it does not have it)."""
465
- return [[None if cell is None else cell[0] for cell in row] for row in Scorer._ref_rows(toks, refs)]
466
-
467
- def _carriers(self, ipa: str) -> list[list[list[int]]]:
468
- """For each word, for each of its phonemes, the indexes in _targets(ipa) of the tokens that carry it.
469
- A phoneme that collapsed into its neighbour (a geminate, a merged sound) shares the neighbour's token."""
470
- out, n, last = [], -1, None
471
- for word in ipa.split("|"):
472
- row = []
473
- for ph in word.split():
474
- idx = []
475
- for tid in self.vocab.token(ph)[0]:
476
- tid = self.vocab.canon[tid]
477
- if tid != last:
478
- n, last = n + 1, tid
479
- idx.append(n)
480
- row.append(sorted(set(idx)))
481
- out.append(row)
482
- return out
483
-
484
- def _targets(self, ipa: str) -> list[TargetToken]:
485
- """Target tokens on the merged sound classes (two neighbours that became one sound collapse, as in CTC)."""
486
- out: list[TargetToken] = []
487
- for t in tokenize(ipa, self.vocab):
488
- tid = self.vocab.canon[t.id]
489
- if not out or out[-1].id != tid:
490
- out.append(replace(t, id=tid))
491
- return out
492
-
493
- @staticmethod
494
- def _spellings(ipa: str, toks: list[TargetToken]) -> list[str]:
495
- """How each target token is written in the phrase bank, geminates included ("m m")."""
496
- runs: list[tuple[int, str, int]] = [] # (word, phoneme, repeat)
497
- for w, word in enumerate(ipa.split("|")):
498
- for ph in map(written, word.split()):
499
- if runs and runs[-1][:2] == (w, ph):
500
- runs[-1] = (w, ph, runs[-1][2] + 1)
501
- else:
502
- runs.append((w, ph, 1))
503
- if [(w, ph) for w, ph, _ in runs] != [(t.word, t.phoneme) for t in toks]:
504
- return [t.phoneme for t in toks] # tokens merged across a boundary: no geminate info
505
- return [" ".join([ph] * n) for _, ph, n in runs]
506
-
507
- def _heard_hebrew(self, logp: np.ndarray, toks: list[TargetToken], al, ipa: str, ar_words: list[str]) -> list[str]:
508
- """What the model heard, in Hebrew letters, cut at the times where the expected words begin.
509
-
510
- A sound heard as expected is written the way the answer writes it (length, gemination, emphasis:
511
- things the scorer does not tell apart), so the two lines differ only where the scorer heard a difference.
512
- A letter with several correct realisations is written as the one said (כּ for a rural ق), not coloured.
513
- """
514
- n_words = len(ipa.split("|"))
515
- starts = [None] * n_words # first frame of each expected word
516
- expected: dict[int, tuple[int, str]] = {} # frame -> (token id, its spelling)
517
- for tok, (a, b), spell in zip(toks, al.spans, self._spellings(ipa, toks)):
518
- if starts[tok.word] is None:
519
- starts[tok.word] = a
520
- for t in range(a, b):
521
- expected[t] = (tok.id, spell)
522
- heard: list[list[str]] = [[] for _ in range(n_words)]
523
- prev, w = None, 0
524
- for t, i in enumerate(logp[:, :self.vocab.n_real].argmax(1)):
525
- i = int(i)
526
- if i != prev and i != self.vocab.blank:
527
- while w + 1 < n_words and starts[w + 1] is not None and t >= starts[w + 1]:
528
- w += 1
529
- ph = base(self.vocab.i2t[i])
530
- tid, spell = expected.get(t, (None, ""))
531
- if tid == i:
532
- heard[w].append(spell)
533
- elif i in self.vocab.members.get(tid, ()):
534
- heard[w].append(spell if base(spell.split()[0]) == ph else ph)
535
- elif ph in HEBREW_SOUNDS:
536
- heard[w].append(ph)
537
- prev = i
538
- # the Arabic spelling is passed so the heard line follows the answer's conventions (ק for ق, silent ל)
539
- return [word_to_hebrew(" ".join(phs), ar_words[w] if w < len(ar_words) else "") for w, phs in enumerate(heard)]
540
-
541
- def _variants(self, phrase: dict) -> list[tuple[str, str, str, list[TargetToken]]]:
542
- """(arabic, ipa, ipa with symbols, tokens) for each accepted variant and its diphthong forms. Variants
543
- that differ only in how a many-ways letter is said (ʔ / q / k for ق) become one: the first written.
544
- A diphthong form the model has no symbols for is left out."""
545
- out, seen, off = [], set(), self._off_symbols()
546
- for ar, ipa in phrase["variants"]:
547
- forms = (ipa, *diphthong_variants(ar, ipa)) if self._on("diphthong") else (ipa,)
548
- for n, form in enumerate(forms):
549
- aipa = archify(ar, form, off)
550
- if aipa in seen:
551
- continue
552
- seen.add(aipa)
553
- try:
554
- out.append((ar, form, aipa, self._targets(aipa)))
555
- except KeyError:
556
- if n == 0:
557
- raise
558
- return out
559
-
560
- def _fit(self, logp: np.ndarray, phrase: dict, energy: np.ndarray | None = None) -> Fit | None:
561
- """The accepted variant the audio fits best and the score of each of its sounds, from merged
562
- posteriors, or None if the recording is too short for every variant."""
563
- blank, vocab = self.vocab.blank, self.vocab
564
- heard_ids = greedy_decode(logp[:, :vocab.n_real], blank)
565
- cands = self._variants(phrase)
566
- ext = vocab.extend(logp) # after tokenising: a variant may have brought a new class
567
- best, tried, paths = None, [], []
568
- for ar, ipa, aipa, toks in cands:
569
- al = ctc_viterbi(ext, [t.id for t in toks], blank)
570
- tried.append((ar, ipa, round(al.total_logp, 2) if al else None))
571
- paths.append([t.sound for t in toks])
572
- if al and (best is None or al.total_logp > best[4].total_logp):
573
- best = (ar, ipa, aipa, toks, al)
574
- if best is None:
575
- return None
576
-
577
- ar, ipa, aipa, toks, al = best
578
- ids = [t.id for t in toks]
579
- ar_words = ar.split()
580
- scores = score_tokens(ext, al, ids, blank, self.cfg.gop, [vocab.members.get(i) for i in ids],
581
- vocab.n_real, self.named if self._on("unnamed_sounds") else None, energy,
582
- vowels=[t.phoneme in VOWELS for t in toks], edges=self._glide_edges(toks))
583
- gop_scores, exempted = list(scores), [None] * len(scores)
584
- for i, (t, sc) in enumerate(zip(toks, scores)):
585
- heard = vocab.i2t.get(sc.competitor) if sc.competitor is not None else None
586
- for name, applies, floor in EXEMPTIONS:
587
- if self._on(name) and applies(toks, i, sc, heard, ar_words):
588
- scores[i] = replace(sc, score=max(sc.score, 100.0 if floor is None else getattr(self.cfg, floor)))
589
- exempted[i] = name
590
- break
591
- exempt_scores = list(scores)
592
- inserted = self._insertions(logp, toks, al)
593
- cap = float((self.rules.insertion or {}).get("score_cap", 60))
594
- for j in inserted:
595
- scores[j] = replace(scores[j], score=min(scores[j].score, cap))
596
- insert_scores = list(scores)
597
- shortened = self._shortened(toks, al, scores, energy)
598
- cap = float((self.rules.length or {}).get("score_cap", 60))
599
- for j in shortened:
600
- scores[j] = replace(scores[j], score=min(scores[j].score, cap))
601
- same = lambda h, t: h == t or h in vocab.members.get(t, ())
602
- raw = edit_distance(heard_ids, ids, same)
603
- distance, weight = self._distance(heard_ids, toks, gop_scores, exempted, same)
604
- veto = self._veto(toks, gop_scores, exempted)
605
- unrelated = bool(distance / weight > self.cfg.unrelated_ratio) and not veto
606
- return Fit(ar, ipa, aipa, toks, al, scores, heard_ids, unrelated, ext, candidates=tried, gop_scores=gop_scores,
607
- exempted=exempted, exempt_scores=exempt_scores, inserted=inserted, insert_scores=insert_scores,
608
- shortened=shortened, distance=distance, energy=energy,
609
- paths=paths, raw_distance=raw, distance_weight=weight, veto=veto)
610
-
611
- # heard at the very start or end of the recording, these are usually breath or noise, not a sound of another word
612
- _EDGE_NOISE = {"h", "θ", "f", "s", "x", "χ"}
613
- _ANY_VOWEL = {"a", "e", "i", "o", "u", "ə", "ɪ", "ʊ", "ɛ", "ɔ", "æ", "ɐ", "ɑ"}
614
- _FREE_DROPS = {"glottal_dropped", "article_vowel_elided"}
615
- _SHORT_VOWELS = {"a", "e", "i", "o", "u"}
616
-
617
- def _distance(self, heard_ids: list[int], toks: list[TargetToken], gop_scores: list[TokenScore],
618
- exempted: list[str | None], same, lost: frozenset[int] = frozenset()) -> float:
619
- """How far the free decode is from the chosen variant, forgiving only what the exemptions mean:
620
- - a vowel heard as another vowel: 0 when it is the competitor an exemption forgave, else 0.25 (a consonant
621
- for a vowel: 1);
622
- - a consonant heard as the competitor its exemption forgave (b/p, t/d): 0. The exemptions that forgive a
623
- dropped sound (_FREE_DROPS) do not forgive a replaced one;
624
- - a deletion: 0 for _FREE_DROPS, 0.25 for another exempted short vowel, else 1;
625
- - edge noise (_EDGE_NOISE before the first or after the last sound): 0.25;
626
- - a sound in `lost` (the native reference lost it too, under ref_noise_floor) replaced or dropped: 0.25.
627
- Returns (distance, weight): weight is the sum of the deletion costs, the sounds that can be wrong at all, and
628
- at least half the sounds, so a short word does not turn unrelated on one stray sound."""
629
- i2t, n = self.vocab.i2t, len(toks)
630
- name = lambda h: base(i2t.get(h, ""))
631
- comp = [name(sc.competitor) if sc.competitor is not None else None for sc in gop_scores]
632
-
633
- def sub(h, j):
634
- if same(h, toks[j].id):
635
- return 0.0
636
- if j in lost:
637
- return 0.25
638
- heard = name(h)
639
- if toks[j].phoneme in VOWELS:
640
- return (0.0 if exempted[j] and comp[j] == heard else 0.25) if heard in self._ANY_VOWEL else 1.0
641
- if (exempted[j] and exempted[j] not in self._FREE_DROPS and comp[j] == heard
642
- and heard not in self._ANY_VOWEL):
643
- return 0.0
644
- return 1.0
645
-
646
- def dele(j):
647
- if exempted[j] in self._FREE_DROPS:
648
- return 0.0
649
- return 0.25 if exempted[j] and toks[j].phoneme in self._SHORT_VOWELS else 1.0
650
-
651
- def dele_cost(j):
652
- return min(dele(j), 0.25) if j in lost else dele(j)
653
-
654
- def ins(h, j):
655
- return 0.25 if j in (0, n) and name(h) in self._EDGE_NOISE else 1.0
656
-
657
- weight = max(1.0, n / 2, sum(dele(j) for j in range(n))) # the same with or without `lost`
658
- return weighted_edit_distance(heard_ids, [t.id for t in toks], sub, dele_cost, ins), weight
659
-
660
- def _veto(self, toks: list[TargetToken], gop_scores: list[TokenScore], exempted: list[str | None],
661
- lost: frozenset[int] = frozenset()) -> bool:
662
- """The forced alignment clearly found the sentence's consonants (ScorerConfig.unrelated_veto_*).
663
- A consonant that says nothing either way is left out: one an exemption lets drop (ʔ), and one the native
664
- reference lost too (`lost`)."""
665
- cfg = self.cfg
666
- mute = lambda i: i in lost or exempted[i] in self._FREE_DROPS
667
- cons = [sc for i, (t, sc) in enumerate(zip(toks, gop_scores)) if t.phoneme not in VOWELS and not mute(i)]
668
- rest = [sc.score for i, sc in enumerate(gop_scores) if not mute(i)]
669
- if not cons or not rest:
670
- return False
671
- sure = sum(float(np.exp(sc.peak)) >= cfg.unrelated_veto_peak for sc in cons)
672
- return (sure >= 2 and sure / len(cons) >= cfg.unrelated_veto_share
673
- and float(np.mean(rest)) >= cfg.unrelated_veto_mean)
674
-
675
- def _heatmap(self, logp: np.ndarray, toks: list[TargetToken], alignment, scores,
676
- duration_s: float | None) -> dict:
677
- """A compact posterior heatmap for teacher diagnostics: expected sounds, strongest competitors and blank.
678
-
679
- Max-pooling preserves short phoneme peaks while capping the response at 180 time columns.
680
- A letter with several correct realisations is one row, the pooled posterior, labelled "ʔ/k/q".
681
- """
682
- expected_ids = list(dict.fromkeys(t.id for t in toks))
683
- competitor_ids = list(dict.fromkeys(sc.competitor for sc in scores
684
- if sc.competitor is not None and sc.competitor not in expected_ids))[:6]
685
- ids = expected_ids + competitor_ids + ([self.vocab.blank] if self.vocab.blank not in expected_ids else [])
686
- frames, bins = len(logp), min(len(logp), 180)
687
- edges = np.linspace(0, frames, bins + 1, dtype=int)
688
- labels = {t.id: ("/".join(self.vocab.i2t[m] for m in sorted(self.vocab.members[t.id]))
689
- if t.id in self.vocab.members else t.phoneme) for t in toks}
690
- rows = []
691
- for token_id in ids:
692
- probability = np.exp(logp[:, token_id])
693
- values = [round(100 * float(probability[edges[i]:edges[i + 1]].max())) for i in range(bins)]
694
- rows.append({"phoneme": labels.get(token_id, "blank" if token_id == self.vocab.blank
695
- else self.vocab.i2t.get(token_id, "?")),
696
- "expected": token_id in expected_ids, "values": values})
697
- greedy = logp[:, :self.vocab.n_real].argmax(1)
698
- heard = []
699
- start = 0
700
- for end in range(1, frames + 1):
701
- if end < frames and greedy[end] == greedy[start]:
702
- continue
703
- token_id = int(greedy[start])
704
- if token_id != self.vocab.blank:
705
- heard.append({"phoneme": self.vocab.i2t.get(token_id, "?"), "start": start, "end": end})
706
- start = end
707
- expected = [{"phoneme": tok.phoneme, "start": span[0], "end": span[1]}
708
- for tok, span in zip(toks, alignment.spans)]
709
- return {"duration_ms": round(1000 * duration_s) if duration_s is not None else None,
710
- "frames": frames, "frame_bins": bins, "rows": rows,
711
- "tracks": {"expected": expected, "heard": heard}}
712
-
713
- def _max_tips(self) -> int:
714
- """As many tips as the rules document says (3), or the 4 of before when its severity order is off."""
715
- if self.cfg.max_tips:
716
- return self.cfg.max_tips
717
- return int(self.rules.scoring.get("max_tips", 4)) if self._on("tips_by_severity") else 4
718
-
719
- def _beyond_margin(self, tok: TargetToken, sc: TokenScore, heard: str | None) -> bool:
720
- """Is the competitor likelier than the target by more than the pair's margin? Pairs the model confuses
721
- on natives too (emphatics, a dropped h) need a clearer win before they count as a mistake."""
722
- targets = [tok.sound, tok.phoneme]
723
- if sc.deleted:
724
- return sc.peak <= self.cfg.gop.deleted_peak - self._margin(targets, "_")
725
- if heard is None:
726
- return True
727
- return -sc.gop >= self._margin(targets, heard)
728
-
729
- def _evidenced(self, sc: TokenScore) -> bool:
730
- """Did the competitor show up in the audio: win at least one frame, or reach the confident posterior?
731
- A competitor barely likelier than a weak target, and winning no frame, is no finding (a tip needs this)."""
732
- return sc.comp_frames >= 1 or sc.comp_peak >= float(np.log(self.cfg.gop.confident_peak))
733
-
734
- def _heard(self, tok: TargetToken, sc: TokenScore) -> str | None:
735
- """What took the sound's place, the same way for a learner and a native reference: "_" when the sound is
736
- missing, the competitor's symbol when it won beyond the pair's margin with evidence, None when it was kept."""
737
- if sc.deleted:
738
- return "_"
739
- sym = self.vocab.i2t.get(sc.competitor) if sc.competitor is not None else None
740
- if sym is None or sc.gop >= 0 or not self._evidenced(sc) or not self._beyond_margin(tok, sc, sym):
741
- return None # no competitor, the target won, no evidence, or not by the pair's margin
742
- return sym
743
-
744
- def _margin(self, targets: list[str], heard: str | None) -> float:
745
- """The pair's margin (rules 6.2), or the default for every pair when the teacher switched margins off."""
746
- return self.rules.margin(targets if self._on("margins") else [], heard)
747
-
748
- def _dialect(self, toks: list[TargetToken], scores: list[TokenScore]) -> dict:
749
- """Which dialect the accepted realisations point to: {"dialect": urban | rural} when they agree,
750
- {"mixed_dialect": True} when one is urban-only and another rural-only. Never part of the score."""
751
- seen: set[str] = set()
752
- for t, sc in zip(toks, scores):
753
- if sc.realised is None or sc.score < self.cfg.issue_below:
754
- continue
755
- ds = self.rules.dialects_of(t.sound, base(self.vocab.i2t[sc.realised]))
756
- if len(ds) == 1:
757
- seen |= ds
758
- if len(seen) > 1:
759
- return {"mixed_dialect": True}
760
- return {"dialect": next(iter(seen))} if seen else {}
761
-
762
- def score_logp(self, logp: np.ndarray, phrase: dict, refs: list[Reference] = (), diagnostics: bool = False,
763
- duration_s: float | None = None, energy: np.ndarray | None = None) -> dict:
764
- logp = self.vocab.merge(logp)
765
- fit = self._fit(logp, phrase, energy)
766
- if fit is None:
767
- return {"error": "too_short", "message": "ההקלטה קצרה מדי. החזיקו את הכפתור ואמרו את כל המשפט."}
768
- ar, ipa, toks, al, scores, unrelated = fit.ar, fit.ipa, fit.toks, fit.al, fit.scores, fit.unrelated
769
- i2t = self.vocab.i2t
770
- heard = " ".join(i2t[i] for i in fit.heard_ids)
771
- # Measure against what the model gives a native on the same sound: a sound it misses in the
772
- # reference voice too (ʕ in على heard as dropped) is the model's weakness, not the learner's.
773
- heatmap = self._heatmap(fit.logp, toks, al, scores, duration_s) if diagnostics else None
774
- raw, (bases, refused) = scores, self._baseline(toks, scores, refs)
775
- floor_ = self.cfg.ref_noise_floor
776
- # under the noise floor the ratio of two near-zero scores is noise (0.3 / 0.2 was capped to 30): give it back
777
- scores = [sc if b is None or b >= 100
778
- else replace(sc, score=100.0) if b < floor_
779
- else replace(sc, score=round(min(100.0, 100.0 * sc.score / b), 1))
780
- for sc, b in zip(raw, bases)]
781
- after_baseline = scores
782
- # the unrelated gate ran before the references were known: a sound they lost too is the model's limitation
783
- # in this phrase, not a sign of another sentence, so the gate is decided again without counting it
784
- lost = frozenset(i for i, b in enumerate(bases) if b is not None and b < floor_)
785
- if lost and unrelated:
786
- same = lambda h, t: h == t or h in self.vocab.members.get(t, ())
787
- distance, weight = self._distance(fit.heard_ids, toks, fit.gop_scores, fit.exempted, same, lost)
788
- veto = self._veto(toks, fit.gop_scores, fit.exempted, lost)
789
- unrelated = bool(distance / weight > self.cfg.unrelated_ratio) and not veto
790
- fit = replace(fit, distance=distance, distance_weight=weight, veto=veto, unrelated=unrelated)
791
- # Teacher floors take priority. The base model has a recurring false negative for pharyngeal ʕ;
792
- # when it reports only silence there, keep a modest penalty instead of claiming it was certainly absent.
793
- phrase_key, calibrated = str(phrase.get("key", phrase.get("he", ""))), []
794
- adjusted, floors = [], []
795
- for tok, sc in zip(toks, scores):
796
- heard_ph = "_" if sc.deleted else (i2t.get(sc.competitor) if sc.competitor is not None else "")
797
- floor = next((self.calibrations[k] for k in ((tok.sound, heard_ph, phrase_key), (tok.sound, heard_ph, ""),
798
- (tok.phoneme, heard_ph, phrase_key), (tok.phoneme, heard_ph, ""))
799
- if k in self.calibrations), None)
800
- if floor is None and tok.sound == "ʕ" and heard_ph == "_":
801
- floor = self.cfg.ayin_deleted_floor
802
- floors.append(floor)
803
- if floor is not None and sc.score < floor:
804
- calibrated.append({"expected": tok.phoneme, "heard": heard_ph, "from": round(sc.score), "to": round(floor)})
805
- sc = replace(sc, score=floor)
806
- adjusted.append(sc)
807
- scores = adjusted
808
- forgiven = {t.word for t, sc, r in zip(toks, scores, raw) if sc.score > r.score}
809
-
810
- phonemes, tips, seen = [], [], set()
811
- heard_as = [] # per phoneme, what took its place ("_": nothing), flagged or not: the reason a letter gives
812
- checks = [] # per phoneme, the rule for what took its place (None: no rule)
813
- beyond = [] # per phoneme, whether what took its place won by the pair's margin
814
- ar_words = ar.split()
815
- tr_words = to_hebrew(ipa, ar)
816
- for idx, (tok, sc) in enumerate(zip(toks, scores)):
817
- heard_ph = "_" if sc.deleted else (i2t.get(sc.competitor) if sc.competitor is not None else None)
818
- # a competitor with no acoustic evidence (won no frame, never confident) is not named: no substitution
819
- # tip and no "heard as" for its letter, whose score stays as it is
820
- unevidenced = heard_ph is not None and not sc.deleted and not self._evidenced(sc)
821
- if unevidenced:
822
- heard_ph = None
823
- check = self._check(tok, heard_ph)
824
- beyond.append(self._beyond_margin(tok, sc, heard_ph))
825
- flagged = (bool(sc.score < self.cfg.flag_below_sound.get(tok.sound, self.cfg.flag_below)) and beyond[-1]
826
- and not unevidenced and (tok.phoneme not in VOWELS or self._flag_vowel(toks, idx)))
827
- # a long vowel said short (3.8): heard as its short vowel, with the rule's tip, unless the native
828
- # reference was as short (the baseline gave the score back)
829
- short = idx in fit.shortened and sc.score < self.cfg.issue_below
830
- if short:
831
- spec, heard_ph, flagged, beyond[-1] = self.rules.length, tok.phoneme.rstrip("ː"), True, True
832
- check = Check(spec["rule"], tok.phoneme, heard_ph, int(spec["severity"]), str(spec["feedback"]))
833
- if idx in fit.inserted: # a vowel before this consonant (3.10): the reason its letter gives
834
- spec = self.rules.insertion
835
- heard_as.append("+" + fit.inserted[idx])
836
- checks.append(Check(spec["rule"], tok.phoneme, "+", int(spec["severity"]), str(spec["feedback"])))
837
- else:
838
- heard_as.append(heard_ph)
839
- checks.append(check)
840
- p = {"ipa": tok.phoneme, "word": tok.word, "score": sc.score,
841
- "flagged": flagged, "heard": heard_ph if flagged else None}
842
- if idx in fit.inserted:
843
- p["inserted"] = fit.inserted[idx]
844
- if short:
845
- p["shortened"] = fit.shortened[idx]["ratio"]
846
- if tok.sound != tok.phoneme:
847
- p["sound"] = tok.sound
848
- if sc.realised is not None:
849
- p["realised"] = base(i2t[sc.realised])
850
- phonemes.append(p)
851
- if short and not unrelated:
852
- if check.rule not in seen:
853
- seen.add(check.rule)
854
- tips.append({"title": self.rules.titles.get(check.rule, check.rule), "text": check.feedback,
855
- "key": check.rule, "severity": check.severity,
856
- "word": ar_words[tok.word] if tok.word < len(ar_words) else "",
857
- "word_tr": tr_words[tok.word], "idx": idx, "score": round(sc.score)})
858
- elif flagged and not unrelated:
859
- tip = tip_for(tok.sound, heard_ph, check)
860
- if tip and tip["key"] not in seen:
861
- seen.add(tip["key"])
862
- tip["word"] = ar_words[tok.word] if tok.word < len(ar_words) else ""
863
- tip["word_tr"] = tr_words[tok.word]
864
- tip["idx"] = idx
865
- tip["score"] = round(sc.score)
866
- tips.append(tip)
867
- if idx in fit.inserted and not unrelated and "cluster" not in seen:
868
- seen.add("cluster")
869
- spec = self.rules.insertion
870
- tips.append({"title": self.rules.titles.get(spec["rule"], spec["rule"]), "text": str(spec["feedback"]),
871
- "key": spec["rule"], "severity": int(spec["severity"]),
872
- "word": ar_words[tok.word] if tok.word < len(ar_words) else "",
873
- "word_tr": tr_words[tok.word], "idx": idx, "score": round(sc.score)})
874
-
875
- n_words = len(ipa.split("|"))
876
- heard_words = self._heard_hebrew(logp, toks, al, fit.aipa, ar_words)
877
- words, k = [], self.cfg.word_min_weight
878
- carriers, ipa_words = self._carriers(fit.aipa), ipa.split("|")
879
- for w in range(n_words):
880
- ws = [p["score"] for p in phonemes if p["word"] == w]
881
- # the transliteration letter by letter, each with the weakest score among its sounds; a letter the
882
- # page colours (under LETTER_GOOD) also says why: what that sound was heard as, and how to say it
883
- letters = []
884
- for text, at in word_letters(ipa_words[w], ar_words[w] if w < len(ar_words) else ""):
885
- got = [i for j in at for i in carriers[w][j] if 0 <= i < len(phonemes)]
886
- worst = min(got, key=lambda i: phonemes[i]["score"]) if got else None
887
- letter = {"t": text, "score": phonemes[worst]["score"] if got else None}
888
- if got and letter["score"] < LETTER_GOOD and not unrelated:
889
- letter.update(letter_reason(toks[worst].sound, heard_as[worst], checks[worst]))
890
- letters.append(letter)
891
- if w in forgiven and min(ws) >= self.cfg.issue_below:
892
- heard_words[w] = tr_words[w] # what differs from the answer here is what a native voice gets too
893
- words.append({"ar": ar_words[w] if w < len(ar_words) else "", "tr": tr_words[w], "heard_tr": heard_words[w],
894
- "score": round(k * min(ws) + (1 - k) * float(np.mean(ws)), 1) if ws else 0.0,
895
- "letters": letters, "phonemes": [p for p in phonemes if p["word"] == w]})
896
-
897
- weights = np.array([0.5 if p["ipa"] in VOWELS else 1.0 for p in phonemes])
898
- phone_mean = float(np.dot(weights, [p["score"] for p in phonemes]) / weights.sum())
899
- word_mean = float(np.mean([w["score"] for w in words]))
900
- overall = 0.5 * phone_mean + 0.5 * word_mean # one wrong consonant must pull the sentence down
901
- if unrelated:
902
- overall = min(overall, 30.0)
903
- # most crucial first: the mistakes that change meaning (severity 3), then the worst-scoring sounds
904
- max_tips = self._max_tips()
905
- tips = (sorted(tips, key=lambda t: (-t["severity"], t["score"])) if self._on("tips_by_severity")
906
- else sorted(tips, key=lambda t: t["score"]))[:max_tips]
907
- tip_idx, issue_idx = {t["idx"] for t in tips}, []
908
-
909
- # Anything short of 100 must say why: every imprecise sound that did not get a full tip,
910
- # weakest first. If nothing is clearly off, name the weakest few.
911
- issues = []
912
- if round(overall) < 100 and not unrelated:
913
- tipped = {t.pop("idx") for t in tips}
914
- rest = sorted((i for i in range(len(toks)) if i not in tipped and scores[i].score < 100),
915
- key=lambda i: scores[i].score)
916
- picked = [i for i in rest if scores[i].score < self.cfg.issue_below and beyond[i]]
917
- if not picked and not tips:
918
- picked = rest[:3]
919
- issue_idx = picked[:self.cfg.max_issues]
920
- for i in issue_idx:
921
- tok, sc = toks[i], scores[i]
922
- # a one-frame onset spike (rule 31) is no sound the learner said: listed, but not named
923
- comp = (i2t.get(sc.competitor) if sc.competitor is not None and fit.exempted[i] != "onset_transient"
924
- else None)
925
- if i in fit.inserted:
926
- issues.append({"sound": "תנועה מיותרת לפני " + (sound_name(tok.phoneme) or tok.phoneme),
927
- "word_tr": tr_words[tok.word], "score": round(sc.score), "missing": False,
928
- "heard": sound_name(fit.inserted[i])})
929
- continue
930
- if phonemes[i].get("shortened") is not None:
931
- issues.append({"sound": sound_name(tok.phoneme) or tok.phoneme, "word_tr": tr_words[tok.word],
932
- "score": round(sc.score), "missing": False,
933
- "heard": sound_name(tok.phoneme.rstrip("ː"))})
934
- continue
935
- issues.append({"sound": sound_name(tok.sound) or sound_name(tok.phoneme) or tok.phoneme,
936
- "word_tr": tr_words[tok.word], "score": round(sc.score), "missing": bool(sc.deleted),
937
- "heard": None if sc.deleted else sound_name(comp)})
938
- for t in tips:
939
- t.pop("idx", None)
940
- result = {"score": round(overall), "variant": {"ar": ar, "tr": " ".join(tr_words), "ipa": ipa}, "heard_ipa": heard, "heard_tr": " ".join(h for h in heard_words if h),
941
- "unrelated": unrelated, "words": words, "tips": tips, "issues": issues, "references": len(refs),
942
- **self._dialect(toks, scores)}
943
- if calibrated:
944
- result["calibrated"] = calibrated
945
- if heatmap is not None:
946
- result["phoneme_heatmap"] = heatmap
947
- if diagnostics:
948
- result["trace"] = self._trace(fit, refs, bases, after_baseline, floors, scores, phonemes, checks, beyond,
949
- words, tips, tip_idx, set(issue_idx), phone_mean, word_mean, overall, heard,
950
- duration_s, refused)
951
- return result
952
-
953
- def _trace(self, fit: Fit, refs, bases, after_baseline, floors, scores, phonemes, checks, beyond,
954
- words, tips, tip_idx, issue_idx, phone_mean, word_mean, overall, heard,
955
- duration_s: float | None = None, refused: list[str | None] | None = None) -> dict:
956
- """Every stage of the classification with its numbers, weights and thresholds, for the teacher's view
957
- (the 'stages' part of the phoneme analysis). Nothing here changes the result."""
958
- cfg, g, i2t = self.cfg, self.cfg.gop, self.vocab.i2t
959
- rows = self._ref_rows(fit.toks, refs)
960
- per_ref = [[None if c is None else c[0] for c in row] for row in rows]
961
- per_heard = [[None if c is None else c[1] for c in row] for row in rows] # what took the sound's place in each voice
962
- refused = refused or [None] * len(fit.toks)
963
- p = lambda lp: round(float(np.exp(lp)), 4) if lp > -1e20 else 0.0
964
- max_tips = self._max_tips()
965
- n, T = len(fit.toks), len(fit.logp)
966
- pr = lambda col, a, b: [round(float(x), 3) for x in np.exp(fit.logp[a:b, col])] if col is not None else None
967
- tokens = []
968
- for i, (tok, gsc, sc) in enumerate(zip(fit.toks, fit.gop_scores, scores)):
969
- # the frames the curves show: the sound's window (score_tokens) and a few around it
970
- ca, cb = max(0, gsc.window[0] - 4), min(T, gsc.window[1] + 4)
971
- curves = {"start": ca, "target": pr(tok.id, ca, cb), "competitor": pr(gsc.competitor, ca, cb),
972
- "prev": pr(fit.toks[i - 1].id, ca, cb) if i else None,
973
- "next": pr(fit.toks[i + 1].id, ca, cb) if i + 1 < n else None,
974
- "blank": pr(self.vocab.blank, ca, cb)}
975
- heard_ph = "_" if gsc.deleted else (i2t.get(gsc.competitor) if gsc.competitor is not None else None)
976
- members = self.vocab.members.get(tok.id)
977
- targets = [tok.sound, tok.phoneme]
978
- confidence = min(1.0, p(gsc.peak) / g.confident_peak)
979
- check = checks[i]
980
- b = bases[i]
981
- stages = [
982
- {"stage": "gop", "score": gsc.score},
983
- {"stage": "exemption", "score": fit.exempt_scores[i].score, "applied": fit.exempted[i]},
984
- {"stage": "insertion", "score": fit.insert_scores[i].score, "applied": fit.inserted.get(i)},
985
- {"stage": "length", "score": fit.scores[i].score, "applied": fit.shortened.get(i)},
986
- {"stage": "baseline", "score": after_baseline[i].score, "ref": b,
987
- "refs": [row[i] for row in per_ref], "heard": [None if h is None else norm(h) for h in (row[i] for row in per_heard)],
988
- "refused": refused[i], "applied": b is not None and b < 100},
989
- {"stage": "calibration", "score": sc.score, "floor": floors[i],
990
- "applied": floors[i] is not None and after_baseline[i].score < floors[i]},
991
- ]
992
- tokens.append({
993
- "i": i, "word": tok.word, "ipa": tok.phoneme, "sound": tok.sound,
994
- "class": sorted(base(i2t[m]) for m in members) if members else None,
995
- "realised": base(i2t[gsc.realised]) if gsc.realised is not None else None,
996
- "span": list(fit.al.spans[i]), "window": list(gsc.window), "curves": curves,
997
- "p_target": p(gsc.peak), "competitor": heard_ph if not gsc.deleted else None,
998
- "competitor_other": gsc.other, "p_competitor": p(gsc.comp_peak),
999
- "competitor_frames": gsc.comp_frames,
1000
- "gop": gsc.gop, "confidence": round(confidence, 3),
1001
- "gop_factor": round(float(np.exp(gsc.gop * g.leniency)), 3),
1002
- "confidence_factor": round(float(np.sqrt(confidence)), 3),
1003
- "deleted": gsc.deleted, "stages": stages, "final": sc.score,
1004
- "margin": {"needed": self._margin(targets, "_" if gsc.deleted else heard_ph),
1005
- "got": (round(g.deleted_peak - gsc.peak, 3) if gsc.deleted else round(-gsc.gop, 3)),
1006
- "beyond": beyond[i]},
1007
- "vowel": tok.phoneme in VOWELS, "weight": 0.5 if tok.phoneme in VOWELS else 1.0,
1008
- "flagged": phonemes[i]["flagged"],
1009
- "rule": {"id": check.rule, "title": self.rules.titles.get(check.rule, check.rule),
1010
- "severity": check.severity} if check else None,
1011
- "tip": i in tip_idx, "issue": i in issue_idx,
1012
- })
1013
- return {
1014
- "config": {
1015
- "leniency": g.leniency, "confident_peak": g.confident_peak,
1016
- "consonant_window": g.consonant_window,
1017
- "deleted_peak": round(float(np.exp(g.deleted_peak)), 3),
1018
- "deleted_comp": round(float(np.exp(g.deleted_comp)), 3),
1019
- "flag_below": cfg.flag_below, "flag_below_sound": dict(cfg.flag_below_sound), "issue_below": cfg.issue_below, "near_vowel_floor": cfg.near_vowel_floor,
1020
- "word_min_weight": cfg.word_min_weight, "vowel_weight": 0.5, "unrelated_ratio": cfg.unrelated_ratio,
1021
- "unrelated_veto": {"share": cfg.unrelated_veto_share, "peak": cfg.unrelated_veto_peak,
1022
- "mean": cfg.unrelated_veto_mean},
1023
- "unrelated_cap": 30.0, "letter_good": LETTER_GOOD, "max_tips": max_tips, "max_issues": cfg.max_issues,
1024
- "ref_min_mean": cfg.ref_min_mean, "ref_noise_floor": cfg.ref_noise_floor, "margin_default": float(self.rules.scoring.get("margin_default", 0.0)),
1025
- "ayin_deleted_floor": cfg.ayin_deleted_floor, "onset_transient_floor": cfg.onset_transient_floor,
1026
- "margins": [{"target": t, "heard": h, "margin": m} for (t, h), m in sorted(self.rules.margins.items())]
1027
- if self._on("margins") else [],
1028
- "exemptions": [name for name, _, _ in EXEMPTIONS if self._on(name)],
1029
- "insertion": ({k: self.rules.insertion[k] for k in ("score_cap", "min_peak", "min_frames")}
1030
- if self.rules.insertion and self._on(f"rule_{self.rules.insertion['rule']}") else None),
1031
- "vowel_length": ({k: v for k, v in self.rules.length.items() if k not in ("rule", "feedback")}
1032
- if self.rules.length and self._on(f"rule_{self.rules.length['rule']}") else None),
1033
- "disabled": sorted(self.disabled),
1034
- },
1035
- "variants": [{"ar": ar, "ipa": ipa, "logp": lp, "chosen": ar == fit.ar and ipa == fit.ipa,
1036
- "tokens": fit.paths[k] if k < len(fit.paths) else []}
1037
- for k, (ar, ipa, lp) in enumerate(fit.candidates)],
1038
- "frames": {"count": T, "ms": round(1000 * duration_s / T, 2) if duration_s and T else 20.0,
1039
- "energy": ([round(float(e), 3) for e in fit.energy / max(float(fit.energy.max()), 1e-9)]
1040
- if fit.energy is not None else None)},
1041
- "aipa": fit.aipa,
1042
- "unrelated": {"heard": heard, "distance": round(fit.distance, 2), "raw_distance": fit.raw_distance,
1043
- "tokens": n, "weight": round(fit.distance_weight, 2),
1044
- "ratio": round(fit.distance / fit.distance_weight, 3), "threshold": cfg.unrelated_ratio,
1045
- "veto": fit.veto, "unrelated": fit.unrelated},
1046
- "references": [{"voice": r.voice or f"קול {k + 1}", "mean": round(float(np.mean(r.scores)), 1),
1047
- "sounds": len(r.sounds)} for k, r in enumerate(refs)],
1048
- "tokens": tokens,
1049
- "words": [{"tr": w["tr"], "scores": [ph["score"] for ph in w["phonemes"]],
1050
- "min": min((ph["score"] for ph in w["phonemes"]), default=0.0),
1051
- "mean": round(float(np.mean([ph["score"] for ph in w["phonemes"]])), 1) if w["phonemes"] else 0.0,
1052
- "score": w["score"]} for w in words],
1053
- "sentence": {"phone_mean": round(phone_mean, 1), "word_mean": round(word_mean, 1),
1054
- "phone_weight": 0.5, "word_weight": 0.5,
1055
- "before_cap": round(0.5 * phone_mean + 0.5 * word_mean, 1), "final": round(overall),
1056
- "capped": fit.unrelated},
1057
- "tips": [{"key": t["key"], "severity": t["severity"], "score": t["score"]} for t in tips],
1058
- }
1059
-
1060
-
1061
- # ---------- real model ----------
1062
-
1063
- class HFEmitter:
1064
- """wav2vec2 phoneme CTC model from the Hugging Face hub (or a local fine-tuned checkpoint)."""
1065
-
1066
- def __init__(self, model_id: str, device: str | None = None):
1067
- import torch
1068
- from huggingface_hub import hf_hub_download
1069
- from transformers import AutoFeatureExtractor, Wav2Vec2ForCTC
1070
-
1071
- self.torch = torch
1072
- self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
1073
- self.fe = AutoFeatureExtractor.from_pretrained(model_id)
1074
- self.model = Wav2Vec2ForCTC.from_pretrained(model_id).to(self.device).eval()
1075
- vocab_path = (os.path.join(model_id, "vocab.json") if os.path.isdir(model_id)
1076
- else hf_hub_download(model_id, "vocab.json"))
1077
- with open(vocab_path, encoding="utf-8") as f:
1078
- self.token_to_id: dict[str, int] = json.load(f)
1079
-
1080
- def __call__(self, wav: np.ndarray) -> np.ndarray:
1081
- inputs = self.fe(wav, sampling_rate=SAMPLE_RATE, return_tensors="pt")
1082
- with self.torch.inference_mode():
1083
- logits = self.model(inputs.input_values.to(self.device)).logits[0]
1084
- return self.torch.log_softmax(logits.float(), -1).cpu().numpy()
1085
-
1086
-
1087
- def decode_audio_with_timing(data: bytes) -> tuple[np.ndarray, int]:
1088
- """Decodes audio and returns (trimmed 16 kHz samples, samples removed from its start)."""
1089
- proc = subprocess.run(
1090
- ["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", "pipe:0",
1091
- "-t", str(MAX_SECONDS), "-ac", "1", "-ar", str(SAMPLE_RATE), "-f", "f32le", "pipe:1"],
1092
- input=data, capture_output=True, check=False)
1093
- if proc.returncode != 0:
1094
- raise ValueError(proc.stderr.decode(errors="replace")[:300])
1095
- wav = np.frombuffer(proc.stdout, dtype=np.float32)
1096
- trim_start = 0
1097
- # trim leading/trailing silence so CTC isn't fed seconds of blank
1098
- if wav.size:
1099
- frame = 320
1100
- energy = np.sqrt(np.convolve(wav ** 2, np.ones(frame) / frame, mode="same"))
1101
- voiced = np.where(energy > max(0.01, energy.max() * 0.05))[0]
1102
- if voiced.size:
1103
- pad = SAMPLE_RATE // 5
1104
- trim_start = max(0, int(voiced[0]) - pad)
1105
- wav = wav[trim_start:voiced[-1] + pad]
1106
- return wav, trim_start
1107
-
1108
-
1109
- def decode_audio(data: bytes) -> np.ndarray:
1110
- """Any browser recording (webm/opus, ogg, mp4/aac, wav) -> trimmed 16 kHz mono float32."""
1111
- return decode_audio_with_timing(data)[0]
1112
-
1113
-
1114
- # Per-sound flag thresholds, chosen per model on the Minerva dev split and checked on its test split
1115
- # (scripts/minerva_benchmark.py --sweep / --compare, with the TTS baselines). A threshold belongs to the model it was
1116
- # measured on: another model (a fine-tuned checkpoint) starts from the global flag_below until it is measured too.
1117
- FLAG_BELOW_BY_MODEL: dict[str, dict[str, float]] = {
1118
- # The base model hears a correct ح as ه/خ with scores in the 20s-40s: at 50 it flagged 12.7% of natives' ح;
1119
- # at 20, 5.6%, still catching 69% of خ and 71% of ه said for ح (was 76% / 75%).
1120
- "facebook/wav2vec2-xlsr-53-espeak-cv-ft": {"ħ": 20.0},
1121
- }
1122
-
1123
-
1124
- def scorer_config(model_id: str) -> ScorerConfig:
1125
- return ScorerConfig(flag_below_sound=dict(FLAG_BELOW_BY_MODEL.get(model_id, {})))
1126
-
1127
-
1128
- def load_scorer(model_id: str) -> Scorer:
1129
- from .powsm import PowsmEmitter, PowsmOnnxEmitter, onnx_model_dir, parse_model_id
1130
- powsm, exported = parse_model_id(model_id), onnx_model_dir(model_id)
1131
- if exported: # an exported POWSM-CTC (scripts/export_powsm_onnx.py): onnxruntime only, what the Space runs
1132
- emitter = PowsmOnnxEmitter(str(exported))
1133
- vocab = Vocab(emitter.token_to_id, blank_token=emitter.blank_token)
1134
- elif powsm: # POWSM-CTC through ESPnet (app/powsm.py): a local trial, needs .zero-shot-deps
1135
- emitter = PowsmEmitter(*powsm)
1136
- vocab = Vocab(emitter.token_to_id, blank_token=emitter.blank_token)
1137
- else:
1138
- emitter = HFEmitter(model_id)
1139
- vocab = Vocab(emitter.token_to_id)
1140
- scorer = Scorer(emitter, vocab, scorer_config(model_id))
1141
- # resolve every phoneme once at startup so missing symbols show up in the logs and /api/health
1142
- from .phrases import PHRASES
1143
- for p in PHRASES:
1144
- for ar, ipa in p["variants"]:
1145
- tokenize(archify(ar, ipa), vocab)
1146
- if vocab.downgraded:
1147
- log.warning("phonemes mapped to fallbacks: %s", vocab.downgraded)
1148
- if vocab.class_missing:
1149
- log.warning("realisations the model cannot hear, left out of their letter's class: %s", vocab.class_missing)
1150
- return scorer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/speech_evaluation.py DELETED
@@ -1,28 +0,0 @@
1
- """Replaceable speech policy for spoken conjugation exercises.
2
-
3
- The acoustic scorer is Palestinian-Arabic aware at the phoneme level, but it is not
4
- treated as a general dialect ASR. In particular, an unrelated/low-confidence result
5
- asks for a retry instead of becoming a grammatical error.
6
- """
7
- from __future__ import annotations
8
-
9
- from typing import Any
10
-
11
-
12
- class SpeechEvaluationService:
13
- def __init__(self, scorer: Any, pass_score: int = 75):
14
- self.scorer = scorer
15
- self.pass_score = pass_score
16
-
17
- def evaluate(self, wav, phrase: dict, diagnostics: bool = False) -> dict:
18
- result = self.scorer.score(wav, phrase, diagnostics=diagnostics)
19
- if result.get("error"):
20
- return {**result, "outcome": "uncertain", "counts_as_attempt": False}
21
- if result.get("unrelated"):
22
- return {**result, "outcome": "uncertain", "counts_as_attempt": False,
23
- "message": "לא הצלחתי לזהות בביטחון את ההטיה. האזינו להקלטה ונסו שוב."}
24
- correct = int(result.get("score", 0)) >= self.pass_score
25
- return {**result, "outcome": "correct" if correct else "needs_work",
26
- "error_kind": None if correct else "undetermined",
27
- "correct": correct, "counts_as_attempt": True,
28
- "message": "נכון" if correct else "לא הצלחתי לאשר את התשובה. הקשיבו להקלטה ולתשובה ונסו שוב."}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/texts.py DELETED
@@ -1,189 +0,0 @@
1
- """Texts the teacher adds in the panel: a story or a dialogue, practised line by line like the course's conversation.
2
-
3
- The teacher gives each line twice: in Hebrew, and in Arabic written in Hebrew letters, as the course writes it
4
- (pointed, geresh for the sounds Hebrew lacks, ّ for doubling: כִּיף חַאלַכּ). The scorer needs the line's phonemes
5
- and the answer's voice needs it in Arabic letters, so each word is read here:
6
- a word of the course (LEX in workbook.py), found by its transliteration, gets the course's own Arabic spelling and
7
- accepted pronunciations. Dagesh, shva and the final letters' forms are not compared, and a word written without
8
- points is found by its letters alone;
9
- any other word is read by rule, letter by letter (`_guess`). Short vowels come from the points, so an unpointed
10
- word that the course does not have is read badly. Such words are reported back to the teacher (`guessed`).
11
- """
12
- from __future__ import annotations
13
-
14
- import re
15
-
16
- from .workbook import LEX, _variants
17
-
18
- SHVA, DAGESH, SHADDA = "\u05b0", "\u05bc", "\u0651"
19
- MARKS = re.compile("[\u05b0-\u05bc\u05c1\u05c2\u05c7\u0651]")
20
- GERESH = "'\u05f3\u2019\u2018`\u00b4"
21
- FINALS = {"ך": "כ", "ם": "מ", "ן": "נ", "ף": "פ", "ץ": "צ"}
22
- VOWEL_OF = {"\u05b7": "a", "\u05b8": "a", "\u05b2": "a", "\u05b4": "i", "\u05bb": "u", "\u05b6": "e", "\u05b1": "e",
23
- "\u05b5": "E", "\u05b9": "o", "\u05ba": "o", "\u05b3": "o", "\u05c7": "o"} # E: tsere, i alone and eː before י
24
- # letter -> (phoneme, Arabic letter); ק is the urban ʔ, with q as a second reading
25
- CONS = {"א": ("ʔ", "ا"), "ב": ("b", "ب"), "ג": ("g", "ج"), "ד": ("d", "د"), "ה": ("h", "ه"), "ו": ("w", "و"),
26
- "ז": ("z", "ز"), "ח": ("ħ", "ح"), "ט": ("tˤ", "ط"), "י": ("j", "ي"), "כ": ("k", "ك"), "ל": ("l", "ل"),
27
- "מ": ("m", "م"), "נ": ("n", "ن"), "ס": ("s", "س"), "ע": ("ʕ", "ع"), "פ": ("f", "ف"), "צ": ("sˤ", "ص"),
28
- "ק": ("ʔ", "ق"), "ר": ("r", "ر"), "ש": ("ʃ", "ش"), "ת": ("t", "ت")}
29
- GERESHED = {"ג": ("dʒ", "ج"), "ח": ("x", "خ"), "ע": ("ɣ", "غ"), "ד": ("dˤ", "ض"), "ט": ("ðˤ", "ظ"), "ת": ("t", "ث"),
30
- "ז": ("dʒ", "ج"), "צ": ("sˤ", "ص")}
31
- PUNCT_AR = {"?": "؟", ",": "،", ";": "؛"}
32
- Cluster = tuple[str, bool, str] # a letter, whether it has a geresh, its points
33
-
34
-
35
- def _clusters(word: str) -> list[Cluster]:
36
- """The word's letters, each with its geresh and points. A letter in brackets, the article's silent (ל), is left out."""
37
- out: list[list] = []
38
- for ch in re.sub(r"\([^)]*\)", "", word):
39
- if "א" <= ch <= "ת":
40
- out.append([FINALS.get(ch, ch), False, ""])
41
- elif out and ch in GERESH:
42
- out[-1][1] = True
43
- elif out and MARKS.fullmatch(ch):
44
- out[-1][2] += ch
45
- return [(l, g, m) for l, g, m in out]
46
-
47
-
48
- def _key(cl: list[Cluster], points: bool = True) -> str:
49
- """What two spellings of one word share: the letters and their vowels, without dagesh (but the vav's) and shva.
50
- The article's vowel is one, hiriq or tsere."""
51
- def marks(k: int, letter: str, m: str) -> str:
52
- keep = {"\u05b7" if c == "\u05b8" else c for c in m if c != SHVA and (c != DAGESH or letter == "ו")}
53
- if k == 0 and letter == "א" and "\u05b4" in keep:
54
- keep = keep - {"\u05b4"} | {"\u05b5"}
55
- return "".join(sorted(keep))
56
- return "".join(l + "'" * g + (marks(k, l, m) if points else "") for k, (l, g, m) in enumerate(cl))
57
-
58
-
59
- def _lexicon() -> tuple[dict, dict]:
60
- pointed, plain = {}, {}
61
- for ar, (tr, ipas) in LEX.items():
62
- cl = _clusters(tr)
63
- entry = (ar.partition("#")[0], ipas)
64
- pointed.setdefault(_key(cl), entry)
65
- plain.setdefault(_key(cl, False), entry)
66
- return pointed, plain
67
-
68
-
69
- POINTED, PLAIN = _lexicon()
70
-
71
-
72
- def _guess(cl: list[Cluster], silent_l: bool = False) -> tuple[str, list[str]]:
73
- """(Arabic spelling, readings) of a word the course does not have, from its letters and points. `silent_l`: the
74
- word was written with the article's (ל), so its first vowel is the article."""
75
- phs: list[str] = []
76
- ar = ""
77
- n, i = len(cl), 0
78
- bare = lambda c: not c[1] and not any(m in VOWEL_OF or m == SHADDA for m in c[2]) # no vowel of its own
79
- pointed = any(m in VOWEL_OF for _, _, marks in cl for m in marks)
80
- if n > 1 and cl[0][0] == "ו" and bare(cl[0]) and (DAGESH in cl[0][2] or SHVA in cl[0][2]): # "and" on the word
81
- phs, ar, i = ["u"], "و", 1
82
- start, prev = i, None # prev: the vowel the letter before ended in
83
- while i < n:
84
- letter, geresh, marks = cl[i]
85
- last, vowel = i == n - 1, next((VOWEL_OF[m] for m in marks if m in VOWEL_OF), None)
86
- double = SHADDA in marks
87
- i += 1
88
- if bare((letter, geresh, marks)) and i - 1 > start: # a letter that only lengthens the vowel before it
89
- long = None
90
- if letter == "א":
91
- long, ar = ("a" if last else "aː"), ar + "ا"
92
- if prev:
93
- phs.pop()
94
- elif letter == "י" and (prev in ("i", "E") or not prev and last or not pointed and not last):
95
- long, ar = ("eː" if prev == "E" else "i" if last else "iː"), ar + "ي"
96
- if prev:
97
- phs.pop()
98
- elif letter == "ו" and not prev and (DAGESH in marks or not pointed and not last):
99
- long, ar = ("u" if last else "uː"), ar + "و"
100
- elif letter == "ה" and last and (prev in ("a", "e", "E") or not pointed): # the feminine ending
101
- ar += "ة"
102
- if not prev:
103
- phs.append("a")
104
- elif prev == "E":
105
- phs[-1] = "e"
106
- break
107
- if long:
108
- phs.append(long)
109
- prev = None
110
- continue
111
- if letter == "ו" and DAGESH in marks and double and not vowel and not prev: # וּّ
112
- phs += ["u", "w", "w"]
113
- ar, prev = ar + "وّ", None
114
- continue
115
- if letter == "ו" and vowel == "o" and not double and not prev and i - 1 > start: # וֹ
116
- phs.append("o" if last else "oː")
117
- ar, prev = ar + "و", None
118
- continue
119
- ph, ar_letter = (GERESHED if geresh and letter in GERESHED else CONS)[letter]
120
- if letter == "א" and i - 1 == start and vowel in ("i", "a", "E") and i < n and (
121
- cl[i][0] == "ל" and bare(cl[i]) and i + 1 < n or silent_l): # the article: אִלְ, or אִ(ל) before a doubled letter
122
- phs.append("i")
123
- ar += "ال"
124
- if cl[i][0] == "ל" and bare(cl[i]):
125
- phs.append("l")
126
- i += 1
127
- prev = None
128
- continue
129
- phs += [ph, ph] if double else [ph]
130
- ar += ("ا" if i - 1 == start else "أ") if letter == "א" else ar_letter + (SHADDA if double else "")
131
- prev = vowel
132
- if vowel:
133
- phs.append(("e" if last else "i") if vowel == "E" else vowel) # a tsere is i inside the word, e at its end
134
- main = " ".join(phs)
135
- return ar, [main] + ([main.replace("ʔ", "q") if not cl[start][0] == "א" else "ʔ" + main[1:].replace("ʔ", "q")]
136
- if any(l == "ק" for l, _, _ in cl) else [])
137
-
138
-
139
- def _word(token: str) -> tuple[str, list[str], bool] | None:
140
- """(Arabic, readings, whether the course has the word) of one written word; None when it has no letters."""
141
- cl = _clusters(token)
142
- if not cl:
143
- return None
144
- tail = "".join(PUNCT_AR.get(c, c) for c in re.search(r"[?!.,;:]*$", token)[0])
145
- pointed = any(m in VOWEL_OF for _, _, marks in cl for m in marks)
146
- for cut, pre_ar, pre_ipa in ((0, "", ""), (1, "و", "u ")): # the word itself, then "and" written on it
147
- if cut and not (len(cl) > 1 and cl[0][0] == "ו" and not cl[0][1] and not any(m in VOWEL_OF for m in cl[0][2])):
148
- break
149
- hit = POINTED.get(_key(cl[cut:])) if pointed else PLAIN.get(_key(cl[cut:], False))
150
- if hit:
151
- return pre_ar + hit[0] + tail, [pre_ipa + ipa for ipa in hit[1]], True
152
- rest = cl[2:] if len(cl) > 2 and cl[0][0] == "א" and cl[1][0] == "ל" and not any(m in VOWEL_OF for m in cl[1][2]) else \
153
- cl[1:] if len(cl) > 1 and cl[0][0] == "א" and "(ל)" in token else None
154
- hit = rest and (POINTED.get(_key(rest)) if pointed else PLAIN.get(_key(rest, False)))
155
- if hit and not hit[0].startswith("ال"): # the article on a word of the course: אִלְבֵּית, אִ(ל)סַّיַּארַה
156
- said = "i l " if rest is cl[2:] or len(rest) == len(cl) - 2 else "i "
157
- double = lambda ipa: ipa if said == "i l " else f"{ipa.split()[0]} {ipa}"
158
- return "ال" + hit[0] + tail, [said + double(ipa) for ipa in hit[1]], True
159
- ar, ipas = _guess(cl, "(ל)" in token)
160
- return (ar + tail, ipas, False) if ipas[0] else None
161
-
162
-
163
- def read_line(tr: str) -> tuple[list[tuple[str, str, list[str]]], list[str]]:
164
- """A line of Arabic in Hebrew letters as the words of a phrase, (Arabic, transliteration, readings) each, and
165
- the words that were read by rule, as written."""
166
- words, guessed = [], []
167
- for token in tr.split():
168
- word = _word(token)
169
- if word is None: # a dash, a number
170
- continue
171
- ar, ipas, known = word
172
- words.append((ar, token, ipas))
173
- if not known:
174
- guessed.append(token.strip("?!.,;:\"()"))
175
- return words, guessed
176
-
177
-
178
- def text_phrases(cat: str, lines: list[list[str]]) -> tuple[list[dict], list[str]]:
179
- """A text's lines ([Hebrew, transliteration] each) as phrases in the format of phrases.py, in the text's order,
180
- and its words that were read by rule. `key` is what a recording of the line is kept under: the Hebrew, numbered
181
- from its second appearance in the text ("כן (2)"), so that two lines with the same Hebrew stay apart."""
182
- out, guessed, seen = [], [], {}
183
- for seq, (he, tr) in enumerate(lines, 1):
184
- words, new = read_line(tr)
185
- guessed += [w for w in new if w not in guessed]
186
- seen[he] = seen.get(he, 0) + 1
187
- out.append(dict(cat=cat, he=he, tr=tr, variants=_variants(words), seq=seq,
188
- key=he if seen[he] == 1 else f"{he} ({seen[he]})"))
189
- return out, guessed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/tips.py DELETED
@@ -1,125 +0,0 @@
1
- """Hebrew feedback for the substitutions Hebrew speakers typically make.
2
-
3
- Keys are (expected phoneme, heard phoneme); "_" means the expected phoneme was missing.
4
- Phonemes are in the IPA used by phrases.py; HEARD_ALIASES maps model outputs onto those symbols.
5
- """
6
- import re
7
-
8
- from .rules import load as load_rules
9
-
10
- NAMES = {
11
- "ħ": "ח (ح)", "ʕ": "ע (ع)", "x": "ח' (خ)", "ɣ": "ע' (غ)", "r": "ר (ر)",
12
- "q": "ק (ق)", "ʔ": "עצירה סותמית (ء / ق)", "h": "ה (ه)",
13
- "sˤ": "צ (ص)", "tˤ": "ט (ط)", "dˤ": "ד' (ض)", "ðˤ": "ט' (ظ)",
14
- "k": "כ (ك)", "s": "ס (س)", "t": "ת (ت)", "d": "ד (د)", "z": "ז (ز)",
15
- "ʃ": "ש (ش)", "dʒ": "ג' (ج)", "b": "ב (ب)", "f": "פ (ف)", "m": "מ (م)",
16
- "n": "נ (ن)", "l": "ל (ل)", "w": "ו (و)", "j": "י (ي)",
17
- "tʃ": "צ' (tʃ)", "θ": "ת' (th)", "ð": "ד״ (th)",
18
- # letters with several correct realisations (archi.py): named by the letter, not by one way of saying it
19
- "Q": "ק (ق)", "K": "כ (ك)", "TH": "ת' (ث)", "DH": "ד״ (ذ)", "ZH": "ט' (ظ)", "J": "ג' (ج)",
20
- }
21
-
22
- # model output symbol -> symbol used in TIPS keys
23
- HEARD_ALIASES = {"χ": "x", "ɦ": "h", "ɡ": "g", "ʁ": "ʁ"}
24
-
25
- _EMPH = "גב הלשון נסוג לאחור והלסת יורדת מעט; התנועות סביב העיצור נשמעות כהות יותר."
26
-
27
- TIPS = {
28
- ("ħ", "x"): "נהגתה כמו ח' (خ), החי\"ת של העברית המודרנית. ح נוצרת בלוע ולא בחך: מכווצים את הגרון ונושפים, בלי רעש גירוד.",
29
- ("ħ", "h"): "נהגתה כמו ה. צריך יותר כיווץ בלוע: הצליל יבש ומתוח, כמו לחישה חזקה מגרון צר.",
30
- ("ħ", "_"): "לא נשמעה. ح היא עיצור לועי: מכווצים את שרירי הגרון ונושפים.",
31
- ("ʕ", "ʔ"): "נהגתה כמו עצירה (א). ع היא עיצור לועי קולי: מכווצים את הגרון תוך כדי השמעת קול, בלי לסגור אותו.",
32
- ("ʕ", "a"): "נבלעה לתוך התנועה. ع דורשת כיווץ ברור של הלוע לפני התנועה.",
33
- ("ʕ", "_"): "נבלעה. ע (ع) נהגית תמיד, גם באמצע מילה.",
34
- ("x", "ħ"): "נהגתה רכה מדי, כמו ح. خ היא חיכוך בחך הרך, כמו ח' בעברית.",
35
- ("x", "h"): "נהגתה כמו ה. خ דורשת חיכוך בחך הרך, כמו ח' בעברית.",
36
- ("x", "k"): "נהגתה כעצירה (כ). خ היא חיכוך רציף, בלי סגירה.",
37
- ("ɣ", "x"): "נהגתה בלי קול, כמו خ. غ קולית, קרובה מאוד לר' הגרונית של העברית.",
38
- ("ɣ", "r"): "נהגתה כמו ר מתגלגלת. غ קרובה לר' העברית הגרונית.",
39
- ("ɣ", "g"): "נהגתה כעצירה (ג). غ היא חיכוך רציף ולא סגירה.",
40
- ("r", "ʁ"): "ר (ر) נהגתה גרונית, כמו ר' עברית. ر מתגלגלת בקצה הלשון מאחורי השיניים העליונות.",
41
- ("r", "ɣ"): "ר (ر) נהגתה גרונית, כמו ר' עברית, וזוהתה כ־غ. ر מתגלגלת בקצה הלשון.",
42
- ("r", "x"): "ר (ر) נהגתה גרונית ובלי קול. ر מתגלגלת בקצה הלשון מאחורי השיניים.",
43
- ("q", "k"): "ق נהגתה כמו כ/ק עברית. במבטא הכפרי זה מקובל, אבל ق נוצרת עמוק יותר, בענבל.",
44
- ("ʔ", "k"): "ق נהגתה כמו כ/ק עברית. בדיבור העירוני ق נהגית כעצירה קצרה בגרון (\"אַהְוֶה\", לא \"קַהְוֶה\").",
45
- ("h", "ħ"): "נהגתה חזקה מדי, כמו ح. ه היא נשיפה רכה בלבד.",
46
- ("h", "x"): "נהגתה כמו ח' עברית. ه היא נשיפה רכה בלבד, בלי חיכוך.",
47
- ("h", "_"): "נבלעה. בערבית ה נהגית תמיד, גם לפני עיצור (קַהְוֶה, פַאהֵם).",
48
- ("sˤ", "s"): "נהגתה כ־ס רגילה. ص היא ס מודגשת: " + _EMPH,
49
- ("tˤ", "t"): "נהגתה כ־ת רגילה. ط היא ת מודגשת: " + _EMPH,
50
- ("dˤ", "d"): "נהגתה כ־ד רגילה. ض היא ד מודגשת: " + _EMPH,
51
- ("ðˤ", "z"): "נהגתה כ־ז רגילה. ظ היא ז מודגשת: " + _EMPH,
52
- ("ðˤ", "ð"): "נהגתה בלי הדגשה. ظ היא עיצור מודגש: " + _EMPH,
53
- }
54
-
55
- # bare Arabic letter in a tip text -> "Hebrew (Arabic)", so Arabic never appears without a Hebrew reading
56
- _PAIRED = {name[name.index("(") + 1]: name for name in NAMES.values() if "/" not in name}
57
- DEFAULT_SEVERITY = 2 # a tip no rule in pronunciation-rules.md grades
58
- _BARE_AR = re.compile(r"(?<!\()[\u0621-\u064a](?![\u0621-\u064a])")
59
-
60
-
61
- def with_hebrew(text: str) -> str:
62
- return _BARE_AR.sub(lambda m: _PAIRED.get(m.group(), m.group()), text)
63
-
64
-
65
- VOWEL_NAMES = {
66
- "a": "תנועת אַ", "i": "תנועת אִ", "u": "תנועת אֻ", "e": "תנועת אֶ", "o": "תנועת אוֹ",
67
- "aː": "תנועה ארוכה אַא", "iː": "תנועה ארוכה אִי", "uː": "תנועה ארוכה אוּ",
68
- "eː": "תנועה ארוכה אֵי", "oː": "תנועה ארוכה אוֹ",
69
- }
70
-
71
-
72
- def sound_name(phoneme: str | None) -> str | None:
73
- """Hebrew name of a phoneme (expected or heard), or None if we have no name for it.
74
- Sounds Arabic does not have (p, v, ts) are named by the labels of pronunciation-rules.md."""
75
- if phoneme is None:
76
- return None
77
- phoneme = HEARD_ALIASES.get(phoneme, phoneme)
78
- return NAMES.get(phoneme) or VOWEL_NAMES.get(phoneme) or load_rules().label(phoneme)
79
-
80
-
81
- def letter_reason(expected: str, heard: str | None, check=None) -> dict:
82
- """Why a letter of the answer is coloured: {"why": the sound and what was heard in its place ("_": nothing was),
83
- "tip": how to say it, from the rule for that substitution (`check`, rules.Check) or TIPS}."""
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}"
93
- else:
94
- why = f"{name} לא נשמעה בבירור"
95
- text = check.feedback if check else TIPS.get((expected, heard))
96
- return {"why": why, **({"tip": with_hebrew(text)} if text else {})}
97
-
98
-
99
- VOWELS = {"a", "i", "u", "e", "o", "aː", "iː", "uː", "eː", "oː"}
100
- # the glottal stop (ء, and ق said the urban way) is often dropped by natives too; don't nag about it
101
- NO_DELETION_TIP = {"ʔ", "Q"}
102
-
103
-
104
- def tip_for(expected: str, heard: str | None, check=None) -> dict | None:
105
- """Feedback for one flagged phoneme, or None if there is nothing useful to say.
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:
113
- return None
114
- name = NAMES.get(expected, expected)
115
- key = f"{expected}>{heard}"
116
- if check:
117
- return {"title": name, "text": with_hebrew(check.feedback), "key": key, "severity": check.severity}
118
- text = TIPS.get((expected, heard))
119
- if text:
120
- return {"title": name, "text": with_hebrew(text), "key": key, "severity": DEFAULT_SEVERITY}
121
- if heard == "_":
122
- return {"title": name, "text": "לא נשמעה בבירור.", "key": key, "severity": DEFAULT_SEVERITY}
123
- other = sound_name(heard)
124
- text = f"נשמעה כמו {other}." if other else "נשמעה אחרת מהצפוי."
125
- return {"title": name, "text": text, "key": key, "severity": DEFAULT_SEVERITY}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/translit.py DELETED
@@ -1,89 +0,0 @@
1
- """Hebrew transliteration generated from the IPA in phrases.py.
2
-
3
- Used wherever Arabic is shown for a specific variant or word, so every variant gets a reading
4
- without hand-writing one. Follows the conventions of the hand-written `tr` hints: pointed Hebrew,
5
- geresh for sounds Hebrew lacks (ח' ע' ג' ד' ט'), and the Arabic shadda (ّ, the small w above
6
- the letter) for geminates, as the course material writes it. A dagesh only marks the stops בּ כּ.
7
- """
8
-
9
- CONSONANTS = {
10
- "ʔ": "א", "b": "בּ", "t": "ת", "dʒ": "ג'", "ħ": "ח", "x": "ח'", "d": "ד", "r": "ר", "z": "ז",
11
- "s": "ס", "ʃ": "ש", "sˤ": "צ", "dˤ": "ד'", "tˤ": "ט", "ðˤ": "ט'", "ʕ": "ע", "ɣ": "ע'", "f": "פ",
12
- "q": "ק", "k": "כּ", "g": "ג", "l": "ל", "m": "מ", "n": "נ", "h": "ה", "w": "ו", "j": "י",
13
- # rural realisations (pronunciation-rules.md, section 1)
14
- "tʃ": "צ'", "θ": "ת'", "ð": "ד״",
15
- }
16
- # Sounds Arabic does not have, written only in the line of what was heard, never in an answer: a Hebrew
17
- # speaker's p for ف, v for و / ب, and Hebrew צ (ts) for ص. Without them the heard line would drop the letter.
18
- HEARD_ONLY = {"p": "פּ", "v": "ב", "β": "ב", "ʋ": "ב", "ts": "טס"}
19
- # vowel -> ((mark on the consonant, letter after it) mid-word, (same) word-finally)
20
- VOWELS = {
21
- "a": (("ַ", ""), ("ַ", "א")), "i": (("ִ", ""), ("ִ", "י")), "u": (("ֻ", ""), ("", "וּ")),
22
- "e": (("ֶ", ""), ("ֶ", "ה")), "o": (("", "וֹ"),) * 2,
23
- "aː": (("ַ", "א"),) * 2, "iː": (("ִ", "י"),) * 2, "uː": (("", "וּ"),) * 2,
24
- "eː": (("ֵ", "י"),) * 2, "oː": (("", "וֹ"),) * 2,
25
- }
26
- FINALS = {"מ": "ם", "נ": "ן", "צ": "ץ", "פ": "ף"}
27
- SHADDA, SHVA = "\u0651", "\u05b0"
28
-
29
-
30
- def _letter(base: str, marks: str) -> str:
31
- """Points go on the letter itself, before a geresh (or gershayim), and on the first letter of טס."""
32
- return base[0] + marks + base[1:] if len(base) > 1 and base[1] != "\u05bc" else base + marks
33
-
34
-
35
- def word_letters(ipa_word: str, ar_word: str = "") -> list[tuple[str, list[int]]]:
36
- """The word letter by letter: each Hebrew letter (with its points) and the positions, in the IPA word,
37
- of the phonemes it stands for, so that a score per phoneme can colour the letters. The bracketed
38
- silent ל stands for none.
39
-
40
- The Arabic spelling, when given, settles what the IPA cannot: an urban ʔ that is written ق
41
- stays ק (as in the hand-written hints), a final a written ة/ه ends in ה, and the ل of the
42
- article ال that is swallowed by the next consonant is shown in brackets: אִ(ל)דַّכְּתוֹר."""
43
- phs = ipa_word.split()
44
- if "ق" in ar_word:
45
- phs = ["q" if ph == "ʔ" else ph for ph in phs]
46
- final_a = "ה" if ar_word.endswith(("ة", "ه")) else "א"
47
- # written ال, said as a vowel + doubled consonant: the ل is silent
48
- silent_l = (ar_word.startswith("ال") and len(phs) > 2 and phs[0] in VOWELS
49
- and phs[1] == phs[2] and phs[1] != "l")
50
- out: list[tuple[str, list[int]]] = []
51
- i = 0
52
- if len(phs) > 1 and phs[0] == "u": # the conjunction و before a word: וּ, not an alef
53
- out, i = [("וּ", [0])], 1
54
- while i < len(phs):
55
- ph, start = phs[i], i
56
- if ph in VOWELS: # vowel with no consonant before it (word-initial): carry it on an alef
57
- base, shadda = "א", ""
58
- else:
59
- base, shadda = CONSONANTS.get(ph) or HEARD_ONLY.get(ph, ph), ""
60
- i += 1
61
- if i < len(phs) and phs[i] == ph: # geminate
62
- shadda = SHADDA
63
- i += 1
64
- if i < len(phs) and phs[i] in VOWELS:
65
- vowel = phs[i]
66
- i += 1
67
- mark, after = VOWELS[vowel][i == len(phs)]
68
- if vowel == "a" and after:
69
- after = final_a
70
- out.append((_letter(base, mark + shadda) + after, list(range(start, i))))
71
- if silent_l and i == 1:
72
- out.append(("(ל)", []))
73
- elif i < len(phs):
74
- out.append((_letter(base, SHVA + shadda), list(range(start, i))))
75
- else:
76
- out.append((_letter(FINALS.get(base, base), shadda), list(range(start, i))))
77
- return out
78
-
79
-
80
- def word_to_hebrew(ipa_word: str, ar_word: str = "") -> str:
81
- return "".join(text for text, _ in word_letters(ipa_word, ar_word))
82
-
83
-
84
- def to_hebrew(ipa: str, ar: str = "") -> list[str]:
85
- """One Hebrew word per IPA word (words are separated by "|")."""
86
- ipa_words, ar_words = ipa.split("|"), ar.split()
87
- if len(ar_words) != len(ipa_words):
88
- ar_words = [""] * len(ipa_words)
89
- return [word_to_hebrew(w, a) for w, a in zip(ipa_words, ar_words)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/tts.py DELETED
@@ -1,492 +0,0 @@
1
- """Model-answer playback through the Sawtak Arabi TTS API (https://sawtakarabi.ai/docs).
2
-
3
- The API key stays on the server: the browser asks for a phrase by id and gets a WAV back.
4
- Only phrases from the phrase bank can be synthesised, and each one is generated once and cached on disk.
5
-
6
- Env: SAWTAK_API_KEY required; without it /api/tts answers 503 and the page falls back to the device voice
7
- SAWTAK_VOICE_ID optional voice used to read sentences; default is Raef
8
- TTS_CACHE_DIR optional; default ~/.cache/ihki-tts
9
- TTS_BUCKET optional Hugging Face Storage Bucket id (e.g. "user/ihki-storage"); the cache-warm sweep
10
- uploads every cached WAV to it through the Bucket API (needs HF_TOKEN with write access)
11
- TTS_BUCKET_PREFIX folder inside the bucket; default "ihki-tts"
12
- TTS_STAGING_DIR with TTS_BUCKET: a fast local folder new recordings are written to before their upload;
13
- default <tmp>/ihki-tts-staging. A bucket mounted at /data takes ~10 s per written file.
14
- """
15
- from __future__ import annotations
16
-
17
- import hashlib
18
- import itertools
19
- from collections import deque
20
- import http.client
21
- import io
22
- import json
23
- import logging
24
- import os
25
- import tempfile
26
- import threading
27
- import time
28
- import urllib.error
29
- import urllib.request
30
- import wave
31
- from pathlib import Path
32
-
33
- log = logging.getLogger("ihki.tts")
34
- BASE_URL = "https://api.sawtakarabi.ai/v1"
35
- MODEL = "arabic-tts-1"
36
- CACHE_VERSION = "raef-pointed-enhanced-provider-default-v1"
37
- TIMEOUT = 60 # per socket operation: a response that keeps trickling never hits it, hence the two caps below
38
- MAX_SECONDS = 90 # one whole generation, download included
39
- MAX_AUDIO_SECONDS = 60 # the longest phrase is ~240 characters; Sawtak's broken answer is minutes of silence
40
- # The account allows 60 requests a minute and 5 at once (GET /v1/me). Request starts are spaced to at most 45 a minute
41
- # for this server, and at most 3 are in flight, so student playback and other clients keep some room. 429s are retried.
42
- MIN_REQUEST_INTERVAL = 60 / 45
43
- MAX_CONCURRENT = 3
44
- # The default Sawtak voice used throughout the application.
45
- RAEF_VOICE_ID = "273d81b2-991b-5b76-8525-eeacc6a3d759"
46
- VOICES = {"רַאאִף": RAEF_VOICE_ID}
47
- # A female voice, only for the lines a dialogue gives her (workbook.LINE_VOICES); never offered on the page.
48
- LINE_VOICES = {"חנאן": "e41781f5-127c-5eb0-b83c-50bf52a64caf"}
49
- FADWA_VOICE_ID = "39a7e0e0-087e-52b3-afff-5203e430fc39" # فدوى: Palestinian, female, conversational
50
- # The native voices the scores are measured against (reference.py), first one first: only it is generated for
51
- # the baseline; the others count when their recording is already cached (the cache-warm sweep made them).
52
- BASELINE_VOICES = {"ראאף": RAEF_VOICE_ID, "פדוא": FADWA_VOICE_ID}
53
-
54
-
55
- def playback_voices() -> dict[str, str]:
56
- """The playback voices by name: VOICES, plus the one SAWTAK_VOICE_ID sets, named by SAWTAK_VOICE_NAME (אמיר).
57
- Read when called, after main.py has loaded .env. Playback only: the baseline stays BASELINE_VOICES."""
58
- out = dict(VOICES)
59
- voice = os.environ.get("SAWTAK_VOICE_ID", "").strip()
60
- if voice and voice not in out.values():
61
- out[os.environ.get("SAWTAK_VOICE_NAME", "").strip() or "אַמִיר"] = voice
62
- return out
63
-
64
-
65
- def voice_name(voice_id: str) -> str:
66
- """A voice id's display name, without vowel points (ראאף, חנאן); the id itself when it is not one of ours."""
67
- names = {v: k for k, v in {**playback_voices(), **LINE_VOICES, **BASELINE_VOICES}.items()}
68
- return names.get(voice_id, voice_id).translate({c: None for c in range(0x591, 0x5c8)})
69
-
70
- class TTSUnavailable(Exception):
71
- """No key configured, or the provider refused / failed. The message is safe to show."""
72
-
73
- def __init__(self, message: str, status: int | None = None, retry_after: float | None = None,
74
- transient: bool = False):
75
- super().__init__(message)
76
- self.status, self.retry_after = status, retry_after
77
- # worth retrying: rate limited, a provider-side 5xx, or the connection dropped mid-request
78
- self.transient = transient or status == 429 or (status is not None and status >= 500)
79
-
80
-
81
- class ActivityLog:
82
- """What the voice cache is doing, for the monitoring panel: requests in flight and the latest results.
83
-
84
- Holds phrase-bank text only (no user data), kept in memory for the life of the process."""
85
-
86
- def __init__(self, size: int = 80):
87
- self._lock = threading.Lock()
88
- self._events: deque[dict] = deque(maxlen=size)
89
- self._active: dict[int, dict] = {}
90
- self._ids = itertools.count(1)
91
-
92
- def begin(self, **info) -> int:
93
- with self._lock:
94
- n = next(self._ids)
95
- self._active[n] = {**info, "id": n, "started": time.time()}
96
- return n
97
-
98
- def end(self, n: int, **result) -> None:
99
- with self._lock:
100
- event = self._active.pop(n, None) or {"started": time.time()}
101
- now = time.time()
102
- event.update(result, at=now, ms=round((now - event["started"]) * 1000))
103
- self._events.appendleft(event)
104
-
105
- def snapshot(self) -> dict:
106
- with self._lock:
107
- now = time.time()
108
- active = [{**a, "elapsed_ms": round((now - a["started"]) * 1000)} for a in self._active.values()]
109
- return {"now": now, "active": sorted(active, key=lambda a: a["started"]), "events": list(self._events)}
110
-
111
-
112
- class BucketMirror:
113
- """Copies cached WAVs into a Hugging Face Storage Bucket through its API.
114
-
115
- Writing to a mounted /data folder is not enough on its own: files written there have been seen to never reach
116
- the bucket. The sweep therefore uploads explicitly, comparing sizes so files already there are not sent again."""
117
-
118
- def __init__(self, bucket_id: str, prefix: str = "ihki-tts", token: str | None = None):
119
- self.bucket_id, self.prefix, self.token = bucket_id, prefix.strip("/"), token
120
- self.activity: ActivityLog | None = None # set when attached to a SawtakTTS
121
- self._stats_lock = threading.Lock()
122
- self._stats: tuple[float, int, int] | None = None
123
-
124
- def remote_path(self, rel: str) -> str:
125
- return f"{self.prefix}/{rel}" if self.prefix else rel
126
-
127
- def remote_sizes(self) -> dict[str, int]:
128
- """{bucket path: size} of every file under the prefix."""
129
- from huggingface_hub import list_bucket_tree
130
- out = {}
131
- for item in list_bucket_tree(self.bucket_id, prefix=f"{self.prefix}/" if self.prefix else None,
132
- recursive=True, token=self.token):
133
- size = getattr(item, "size", None)
134
- if size is not None:
135
- out[item.path] = size
136
- return out
137
-
138
- def cache_stats(self, max_age: float = 60) -> tuple[int, int] | None:
139
- """Cached WAV count/size in the shared bucket; refresh without holding up the monitoring request."""
140
- stats = self._stats
141
- if (stats is None or time.monotonic() - stats[0] > max_age) and self._stats_lock.acquire(blocking=False):
142
- def scan() -> None:
143
- try:
144
- sizes = self.remote_sizes()
145
- wavs = [size for path, size in sizes.items() if path.endswith(".wav")]
146
- self._stats = (time.monotonic(), len(wavs), sum(wavs))
147
- except Exception as e:
148
- log.warning("could not inspect TTS bucket: %s", e)
149
- finally:
150
- self._stats_lock.release()
151
- threading.Thread(target=scan, name="ihki-tts-bucket-count", daemon=True).start()
152
- return (stats[1], stats[2]) if stats else None
153
-
154
- def upload(self, files: list[tuple[Path | bytes, str]], source: str = "") -> None:
155
- """Upload (local path or bytes, bucket path) pairs in one batch."""
156
- from huggingface_hub import batch_bucket_files
157
- size = sum(len(src) if isinstance(src, bytes) else src.stat().st_size for src, _dest in files)
158
- n = self.activity.begin(kind="upload", source=source, files=len(files), bytes=size,
159
- text=files[0][1].rsplit("/", 1)[-1] if len(files) == 1 else "") if self.activity else 0
160
- try:
161
- batch_bucket_files(self.bucket_id, add=[(str(src) if isinstance(src, Path) else src, dest)
162
- for src, dest in files], token=self.token)
163
- except Exception as e:
164
- if self.activity:
165
- self.activity.end(n, status="error", message=str(e)[:200])
166
- raise
167
- if self.activity:
168
- self.activity.end(n, status="ok")
169
-
170
-
171
- class SawtakTTS:
172
- def __init__(self, api_key: str, voice_id: str = RAEF_VOICE_ID, cache_dir: Path | None = None):
173
- self.api_key, self.voice_id = api_key, voice_id.strip() or RAEF_VOICE_ID
174
- self.voice_name = voice_name(self.voice_id)
175
- self.cache_dir = cache_dir or Path.home() / ".cache" / "ihki-tts"
176
- # Separate locks, so nothing slow holds up a generation: walking a bucket-mounted cache folder for the
177
- # panel's file count takes tens of seconds, and it once stalled every sweep worker behind it.
178
- self._lock = threading.Lock() # clearing the cache
179
- self._counts = threading.Lock() # the hit and miss counters
180
- self._keys_guard = threading.Lock() # the per-recording locks
181
- self._stats_lock = threading.Lock() # one disk walk at a time for cache_stats
182
- self._disk: tuple[float, int, int] | None = None # (when, files, bytes) of the last walk
183
- self._pace = threading.Lock() # hands out request start times, MIN_REQUEST_INTERVAL apart
184
- self._next_start = 0.0
185
- self._in_flight = threading.Semaphore(MAX_CONCURRENT)
186
- self._key_locks: dict[str, threading.Lock] = {} # one generation per recording at a time
187
- self._cache_hits = self._cache_misses = 0
188
- self.activity = ActivityLog() # Sawtak requests and bucket uploads, for the monitoring panel
189
- self._mirror: BucketMirror | None = None # set by load_tts when TTS_BUCKET is configured
190
- # With a mirror, new recordings are written here (local disk) and uploaded through the Bucket API, instead
191
- # of into cache_dir: a bucket mounted there only flushes a few files every ~30 s, so each write crawled.
192
- self.staging_dir: Path | None = None
193
-
194
- @property
195
- def mirror(self) -> BucketMirror | None:
196
- return self._mirror
197
-
198
- @mirror.setter
199
- def mirror(self, mirror: BucketMirror | None) -> None:
200
- self._mirror = mirror
201
- if mirror is not None:
202
- mirror.activity = self.activity
203
-
204
- def _request(self, path: str, body: dict | None = None):
205
- req = urllib.request.Request(
206
- BASE_URL + path,
207
- data=json.dumps(body).encode() if body is not None else None,
208
- # explicit User-Agent: the provider's CDN answers Python's default one with 403 (error 1010)
209
- headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json",
210
- "User-Agent": "ihki/1.0"},
211
- )
212
- try:
213
- return urllib.request.urlopen(req, timeout=TIMEOUT)
214
- except urllib.error.HTTPError as e:
215
- try:
216
- err = json.load(e).get("error", {})
217
- except ValueError:
218
- err = {}
219
- log.warning("sawtak %s -> %s %s (request %s)", path, e.code, err.get("code"), err.get("request_id"))
220
- try:
221
- retry_after = float(e.headers.get("Retry-After", ""))
222
- except (TypeError, ValueError):
223
- retry_after = None
224
- raise TTSUnavailable(f"tts provider error {e.code}: {err.get('message', 'request failed')}",
225
- e.code, retry_after) from None
226
- except OSError as e:
227
- log.warning("sawtak %s unreachable: %s", path, e)
228
- raise TTSUnavailable("tts provider unreachable", transient=True) from None
229
-
230
- def _path(self, text: str, voice: str) -> Path:
231
- name = hashlib.sha1(f"{CACHE_VERSION}|{MODEL}|{voice}|{text}".encode()).hexdigest() + ".wav"
232
- return self.cache_dir / voice / name
233
-
234
- def _legacy_path(self, path: Path) -> Path:
235
- """The flat cache layout used before recordings were grouped by voice id."""
236
- return self.cache_dir / path.name
237
-
238
- def cache_key(self, text: str, voice: str) -> str:
239
- """The recording's path relative to the cache folder ("<voice>/<sha1>.wav"), also its path in the bucket."""
240
- return self._path(text, voice).relative_to(self.cache_dir).as_posix()
241
-
242
- def _candidates(self, text: str, voice: str) -> list[Path]:
243
- """Where a recording may be: the local staging folder first (fast), then the cache folder, then its
244
- legacy flat layout."""
245
- path = self._path(text, voice)
246
- staged = [self.staging_dir / self.cache_key(text, voice)] if self.staging_dir else []
247
- return staged + [path, self._legacy_path(path)]
248
-
249
- def cache_file(self, text: str, voice: str) -> Path | None:
250
- """The cached WAV on disk, wherever it is; None when not cached."""
251
- return next((c for c in self._candidates(text, voice) if c.is_file()), None)
252
-
253
- def cached_files(self) -> list[tuple[Path, str]]:
254
- """Every cached WAV on disk with its path relative to its folder; a staged copy wins over the cache's."""
255
- found: dict[str, Path] = {}
256
- for root in (self.cache_dir, self.staging_dir): # staging last, so it overrides
257
- if root is not None and root.is_dir():
258
- for p in root.rglob("*.wav"):
259
- if p.is_file():
260
- found[p.relative_to(root).as_posix()] = p
261
- return [(found[rel], rel) for rel in sorted(found)]
262
-
263
- def store(self, rel: str, data: bytes) -> Path:
264
- """Keep a recording from an offline copy at `rel` ("<voice>/<sha1>.wav" or a legacy "<sha1>.wav"): in the
265
- staging folder with a mirror (the slow mount is not written), else in the cache folder."""
266
- path = (self.staging_dir or self.cache_dir) / rel
267
- path.parent.mkdir(parents=True, exist_ok=True)
268
- tmp = path.with_suffix(".tmp")
269
- tmp.write_bytes(data)
270
- tmp.replace(path)
271
- return path
272
-
273
- def has(self, rel: str) -> Path | None:
274
- """The cached file at `rel`, staged or in the cache folder; None when neither has it."""
275
- for root in (self.staging_dir, self.cache_dir):
276
- if root is not None and (root / rel).is_file():
277
- return root / rel
278
- return None
279
-
280
- def cached(self, text: str, voice: str) -> bool:
281
- """Has this voice said `text` before? Then wav() answers from disk, without the provider."""
282
- return any(c.exists() for c in self._candidates(text, voice))
283
-
284
- def hold(self, seconds: float) -> None:
285
- """No new request starts for `seconds` (after a 429, so the other requests in flight back off too)."""
286
- with self._pace:
287
- self._next_start = max(self._next_start, time.monotonic() + seconds)
288
-
289
- def _key_lock(self, key: str) -> threading.Lock:
290
- with self._keys_guard:
291
- return self._key_locks.setdefault(key, threading.Lock())
292
-
293
- def _generate(self, text: str, voice: str, source: str = "") -> bytes:
294
- """Generate one uncached WAV recording: paced, and at most MAX_CONCURRENT at once."""
295
- queued = time.monotonic()
296
- with self._pace:
297
- start = max(time.monotonic(), self._next_start)
298
- self._next_start = start + MIN_REQUEST_INTERVAL
299
- if start > time.monotonic():
300
- time.sleep(start - time.monotonic())
301
- with self._in_flight:
302
- n = self.activity.begin(kind="sawtak", source=source, text=text[:80], voice=voice,
303
- waited_ms=round((time.monotonic() - queued) * 1000))
304
- try:
305
- data = self._generate_now(text, voice)
306
- except TTSUnavailable as e:
307
- self.activity.end(n, status=e.status or "error", message=str(e)[:200])
308
- raise
309
- except Exception as e:
310
- self.activity.end(n, status="error", message=f"{type(e).__name__}: {e}"[:200])
311
- raise
312
- try:
313
- with wave.open(io.BytesIO(data)) as w:
314
- audio = round(w.getnframes() / w.getframerate(), 2)
315
- except (wave.Error, EOFError):
316
- audio = None
317
- self.activity.end(n, status=200, bytes=len(data), audio_s=audio)
318
- return data
319
-
320
- def _generate_now(self, text: str, voice: str) -> bytes:
321
- # Sawtak currently returns 320 seconds of silence whenever `temperature` is present,
322
- # including for 0 and 0.1. Omitting it uses the provider's deterministic supported path.
323
- with self._request("/audio/speech", {"model": MODEL, "voice": voice, "input": text,
324
- "response_format": "pcm", "enhance_pronunciation": True,
325
- }) as res:
326
- rate = int(res.headers.get("X-Sample-Rate", 24000))
327
- started, parts, size = time.monotonic(), [], 0
328
- try:
329
- while chunk := res.read(65536):
330
- parts.append(chunk)
331
- size += len(chunk)
332
- if size > rate * 2 * MAX_AUDIO_SECONDS or time.monotonic() - started > MAX_SECONDS:
333
- log.warning("sawtak audio for %r abandoned: %d bytes after %.0fs", text[:40], size,
334
- time.monotonic() - started)
335
- raise TTSUnavailable("tts provider sent overlong or stalled audio", transient=True)
336
- except (http.client.HTTPException, OSError) as e: # e.g. IncompleteRead: the audio arrived cut off
337
- log.warning("sawtak audio download broke: %s: %s", type(e).__name__, e)
338
- raise TTSUnavailable("tts audio download broke off", transient=True) from None
339
- pcm = b"".join(parts)
340
- if len(pcm) < rate // 5:
341
- raise TTSUnavailable("tts provider returned no audio")
342
- buf = io.BytesIO()
343
- with wave.open(buf, "wb") as w:
344
- w.setnchannels(1)
345
- w.setsampwidth(2)
346
- w.setframerate(rate)
347
- w.writeframes(pcm[: len(pcm) // 2 * 2])
348
- return buf.getvalue()
349
-
350
- def clear_cache(self) -> int:
351
- """Delete only generated audio files from this instance's dedicated cache directory."""
352
- with self._lock:
353
- removed = 0
354
- roots = [r for r in (self.cache_dir, self.staging_dir) if r is not None and r.is_dir()]
355
- for path in (p for root in roots for p in root.rglob("*")):
356
- if path.is_file() and (path.suffix == ".wav" or path.name.endswith(".tmp")):
357
- try:
358
- path.unlink()
359
- removed += 1
360
- except FileNotFoundError:
361
- pass
362
- except OSError as e:
363
- raise TTSUnavailable(f"could not clear voice cache: {e}") from None
364
- with self._counts:
365
- self._cache_hits = self._cache_misses = 0
366
- self._disk = None
367
- return removed
368
-
369
- def cache_stats(self, max_age: float = 30, wait: bool = False) -> dict:
370
- """Anonymous disk and process-lifetime cache usage for the monitoring panel. Counting the files on a bucket
371
- mount takes minutes, so a stale count is refreshed in a background thread and the call returns at once with
372
- the previous count (files and bytes are None before the first count ends). `wait`: count now instead."""
373
- disk = self._disk
374
- if disk is None or time.monotonic() - disk[0] > max_age:
375
- if wait:
376
- with self._stats_lock:
377
- self._count_files()
378
- elif self._stats_lock.acquire(blocking=False):
379
- def walk() -> None:
380
- try:
381
- self._count_files()
382
- except TTSUnavailable as e:
383
- log.warning("%s", e)
384
- finally:
385
- self._stats_lock.release()
386
- threading.Thread(target=walk, name="ihki-tts-count", daemon=True).start()
387
- disk = self._disk
388
- with self._counts:
389
- hits, misses = self._cache_hits, self._cache_misses
390
- requests = hits + misses
391
- shared = self.mirror.cache_stats() if self.mirror is not None and hasattr(self.mirror, "cache_stats") else None
392
- if self.mirror is not None and hasattr(self.mirror, "cache_stats"):
393
- files, size = shared if shared is not None else (None, None)
394
- else:
395
- files, size = (disk[1], disk[2]) if disk else (None, None)
396
- return {"files": files, "bytes": size, "hits": hits,
397
- "misses": misses, "hit_rate": round(hits / requests, 4) if requests else None}
398
-
399
- def _count_files(self) -> None:
400
- """Walk the cache folder; the caller holds _stats_lock."""
401
- files = size = 0
402
- try:
403
- for path, _rel in self.cached_files():
404
- files += 1
405
- size += path.stat().st_size
406
- except OSError as e:
407
- raise TTSUnavailable(f"could not inspect voice cache: {e}") from None
408
- self._disk = (time.monotonic(), files, size)
409
-
410
- def _wav(self, text: str, voice: str, upload: bool = False, source: str = "") -> tuple[bytes, str]:
411
- """Generate or read `text` for an already validated voice id. `upload`: send a new recording to the bucket
412
- in the background (playback); the cache-warm sweep uploads its own in batches. Different recordings can be
413
- generated at the same time (see _generate); the same one waits for the first request and then reads it."""
414
- with self._key_lock(self.cache_key(text, voice)):
415
- found = self.cache_file(text, voice) if self.staging_dir else None
416
- if found is not None: # with a mirror the cache folder may be a slow mount: no migration there
417
- if source == "playback": self._count(hit=True)
418
- return found.read_bytes(), voice
419
- path = self._path(text, voice)
420
- legacy = self._legacy_path(path)
421
- if self.staging_dir is None and not path.exists() and legacy.exists():
422
- try:
423
- path.parent.mkdir(parents=True, exist_ok=True)
424
- legacy.replace(path)
425
- except OSError as e:
426
- log.warning("tts cache migration failed: %s: %s", type(e).__name__, e)
427
- path = legacy
428
- if path.exists():
429
- if source == "playback": self._count(hit=True)
430
- return path.read_bytes(), voice
431
- if source == "playback": self._count(hit=False)
432
- # The caller supplies the phrase's Palestinian vowel marks. Sawtak preserves existing
433
- # marks; enhancement fills remaining pronunciation cues using the selected voice's dialect.
434
- data = self._generate(text, voice, source)
435
- if self.staging_dir is not None:
436
- path = self.staging_dir / self.cache_key(text, voice)
437
- try:
438
- path.parent.mkdir(parents=True, exist_ok=True)
439
- tmp = path.with_suffix(".tmp")
440
- tmp.write_bytes(data)
441
- tmp.replace(path)
442
- except OSError as e: # read-only or full cache dir: the audio is still good, it just isn't kept
443
- log.warning("tts cache write failed: %s: %s", type(e).__name__, e)
444
- if upload and self.mirror is not None:
445
- self._upload_soon(data, self.cache_key(text, voice))
446
- return data, voice
447
-
448
- def _count(self, hit: bool) -> None:
449
- with self._counts:
450
- if hit:
451
- self._cache_hits += 1
452
- else:
453
- self._cache_misses += 1
454
-
455
- def _upload_soon(self, data: bytes, rel: str) -> None:
456
- """One playback recording to the bucket, off the request: the student does not wait for it."""
457
- mirror = self.mirror
458
-
459
- def go() -> None:
460
- try:
461
- mirror.upload([(data, mirror.remote_path(rel))], source="playback")
462
- except Exception as e: # the next cache-warm sweep uploads what is still missing
463
- log.warning("tts bucket upload of %s failed: %s", rel, e)
464
- threading.Thread(target=go, name="ihki-tts-upload", daemon=True).start()
465
-
466
- def wav(self, text: str, voice: str = "") -> tuple[bytes, str]:
467
- """(WAV, voice id) in the configured voice, or a dialogue line's own voice."""
468
- voice = voice if voice in LINE_VOICES.values() else self.voice_id
469
- return self._wav(text, voice, upload=True, source="playback")
470
-
471
- def wav_for_voice(self, text: str, voice: str, source: str = "sweep", upload: bool = False) -> tuple[bytes, str]:
472
- """(WAV, voice id) for a teacher-selected, validated Sawtak voice. Unlike wav(), any voice id is kept.
473
- `upload`: a new recording also goes to the bucket in the background."""
474
- return self._wav(text, voice, upload=upload, source=source)
475
-
476
-
477
- def load_tts(key: str | None = None) -> SawtakTTS | None:
478
- """`key`: the one the panel set (see keystore.py); None reads the environment's."""
479
- key = (os.environ.get("SAWTAK_API_KEY", "") if key is None else key).strip()
480
- if not key:
481
- log.info("SAWTAK_API_KEY not set: playback uses the device voice")
482
- return None
483
- cache = os.environ.get("TTS_CACHE_DIR", "").strip()
484
- voice = os.environ.get("SAWTAK_VOICE_ID", RAEF_VOICE_ID).strip() or RAEF_VOICE_ID
485
- tts = SawtakTTS(key, voice, Path(cache) if cache else None)
486
- bucket = os.environ.get("TTS_BUCKET", "").strip()
487
- if bucket:
488
- tts.mirror = BucketMirror(bucket, os.environ.get("TTS_BUCKET_PREFIX", "ihki-tts"),
489
- os.environ.get("HF_TOKEN", "").strip() or None)
490
- staging = os.environ.get("TTS_STAGING_DIR", "").strip()
491
- tts.staging_dir = Path(staging) if staging else Path(tempfile.gettempdir()) / "ihki-tts-staging"
492
- return tts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/tutor.py DELETED
@@ -1,534 +0,0 @@
1
- """יאללה נדבר: short spoken conversations with an AI tutor, on scenario cards, one for each lesson of the course.
2
-
3
- The tutor writes; the student talks. The page turns the student's speech into text (the browser's own recognition)
4
- and sends the text; the tutor's answer comes back as text too, so the student sees what the tutor understood.
5
- The students do not read Arabic script, so what the page shows is Arabic in Hebrew letters as the course writes it:
6
- the tutor's message, and the student's own sentence as the tutor read it. The Arabic script is kept for the teacher.
7
-
8
- Each scenario belongs to a lesson (SCENARIOS) and plays out that lesson's theme: its story, its words and its grammar.
9
- The tutor speaks with the words of that lesson and of the lessons before it (`vocabulary`); the page shows each word
10
- of the tutor's message with its Hebrew translation, and marks a word the student has not learnt yet (`is_known`).
11
- A sentence of the student's that is wrong in grammar or wording gets a correction, explained in Hebrew.
12
- A conversation lasts MAX_TURNS sentences of the student's and ends with a short summary in Hebrew.
13
-
14
- The model is Claude Haiku (DEFAULT_MODEL, or TUTOR_MODEL), called with the server's key; the browser never sees it. A conversation lives in
15
- this process's memory while it runs (a restart ends it) and is kept, turn by turn, in the Supabase table
16
- `tutor_chats` (CREATE_TUTOR_CHATS), so the teacher can read what was said and how the tutor answered.
17
-
18
- Env: ANTHROPIC_API_KEY (or CLAUDE_API_KEY) required; without it the game is not offered
19
- Access is granted per account in the management panel; an open service remains open.
20
- TUTOR_MODEL optional: the Claude model the tutor runs on (default DEFAULT_MODEL), e.g. claude-sonnet-5-5
21
- (request_options() fits the request to it: Haiku 5.5/4.5, Sonnet 5.5, Opus 5.5 and Fable 5.1 all run)
22
- """
23
- from __future__ import annotations
24
-
25
- import functools
26
- import json
27
- import logging
28
- import os
29
- import re
30
- import threading
31
- import time
32
- import uuid
33
- from dataclasses import dataclass, field
34
-
35
- from .phrases import CATEGORIES, LESSONS, PHRASES
36
- from .workbook import LEX
37
-
38
- log = logging.getLogger("ihki.tutor")
39
- DEFAULT_MODEL = "claude-haiku-5-5"
40
- NO_FIX = re.compile(r"אין (?:כאן )?(?:צורך|תיקון|טעות)|(?:המשפט|הדוגמה|התשובה) (?:שלך |שלכם )?נכונ") # a correction whose note says there is none
41
- MAX_TURNS = 10 # the student's sentences in one conversation
42
- DAILY_LIMIT = 5 # conversations per signed-in account per day; developers are exempt in main.py
43
- MAX_TEXT = 300 # characters of one sentence of the student's
44
- TTL = 2 * 3600 # seconds a conversation nobody continues is kept in memory
45
- MAX_LIVE = 2000 # conversations in memory at once
46
- CREATE_TUTOR_CHATS = ("create table public.tutor_chats (id uuid primary key, user_id uuid, email text not null default '', "
47
- "scenario text not null, gender text not null default '', messages jsonb not null default '[]'::jsonb, "
48
- "turns int not null default 0, finished boolean not null default false, model text not null default '', "
49
- "input_tokens int not null default 0, output_tokens int not null default 0, "
50
- "created_at timestamptz not null default now(), updated_at timestamptz not null default now()); "
51
- "alter table public.tutor_chats enable row level security;")
52
-
53
- # A scenario for each lesson the path shows: who the tutor plays (`who`, `ar`: the name on the chat's avatar, `she`:
54
- # the character is a woman), the card (`title`, `desc`), and for the model the conversation's aim (`goal`) and the
55
- # lesson's grammar to bring in (`focus`). `story`: the lesson's story or dialogue, the model's example of its words.
56
- SCENARIOS = [
57
- {"id": "l1", "lesson": "1", "who": "ראמי", "ar": "رامي", "she": False, "story": "l1-say",
58
- "title": "מי, איפה ולמה?", "desc": "ראמי שואל אתכם מה עשיתם אתמול: מי, איפה, מתי ולמה",
59
- "goal": "a friend asks the student simple questions about yesterday: who sat in the car, what they ate, who "
60
- "opened the door, where their friend lives, and asks why and when",
61
- "focus": "the question words مين، وين، ليش، ايمتى and the past tense of سأل، فتح، اكل، قعد"},
62
- {"id": "l2", "lesson": "2", "who": "ח'ליל", "ar": "خليل", "she": False, "story": "l2-talk",
63
- "title": "היכרות", "desc": "ח'ליל פוגש אתכם בפעם הראשונה: שם, גיל, מאיפה אתם, משפחה ועבודה",
64
- "goal": "Khalil, from Tur'an in the north, now living in Jerusalem, meets the student for the first time and "
65
- "gets to know them: their name, how they are, their age, where they are from and live, whether they are "
66
- "married and have children, and their work",
67
- "focus": "greetings and the possessive endings (اسمي، اسمك، بيتي)"},
68
- {"id": "l3", "lesson": "3", "who": "רובה", "ar": "روبا", "she": True, "story": "l3-story",
69
- "title": "השכנה רובה", "desc": "רובה עברה לשכונה שלכם: מספר הבית, הטלפון, הגיל והמשפחה",
70
- "goal": "Ruba, eighteen, has just moved to the student's neighbourhood. She asks for their house number and "
71
- "phone number, their age, and how many brothers and sisters they have, and tells about her own family",
72
- "focus": "numbers (house numbers, phone numbers, ages) and the past tense"},
73
- {"id": "l4", "lesson": "4", "who": "סמיר", "ar": "سمير", "she": False, "story": "l4-story",
74
- "title": "השכן החדש", "desc": "באתם עם עוגה לשכן החדש סמיר. הוא מציע קפה או תה",
75
- "goal": "the student visits Samir Atallah, the new neighbour, bringing him a cake. Samir welcomes them, offers "
76
- "coffee or tea (with sugar or without), asks whether they are tired or hungry, and they talk about "
77
- "their families",
78
- "focus": "بدّي (I want), بلا، كل، and the past tense"},
79
- {"id": "l5", "lesson": "5", "who": "אבו מחמד", "ar": "ابو محمد", "she": False, "story": "l5-story",
80
- "title": "בשוק", "desc": "קונים ירקות ופירות אצל אבו מחמד: מחיר, קילו וחצי",
81
- "goal": "Abu Mohammad sells vegetables and fruit in the market. The student buys: he asks what they want, "
82
- "tells prices in shekels per kilo, and they bargain a little",
83
- "focus": "prices and quantities (كيلو، نصّ، شيكل) and the negation ما"},
84
- {"id": "l6", "lesson": "6", "who": "קפטן איוב", "ar": "ايّوب", "she": False, "story": "l6-story",
85
- "title": "במונית של קפטן איוב", "desc": "נוסעים במונית של קפטן איוב, והוא שואל מה תעשו מחר",
86
- "goal": "Captain Ayyub, a talkative taxi driver, drives the student. He asks where they are going, what they "
87
- "will do tomorrow, whether they are going to a party, and talks about himself",
88
- "focus": "the future tense (بروح، باكل) next to the past"},
89
- {"id": "l7", "lesson": "7", "who": "הדודה אם סמיר", "ar": "امّ سمير", "she": True, "story": "l7-say",
90
- "title": "ביקור אצל הדודה", "desc": "הדודה אם סמיר מארחת אתכם ושואלת על הילדים, האחים והמכוניות",
91
- "goal": "the student visits their aunt Umm Samir. She serves maqluba and asks how many children, brothers, "
92
- "sisters and cars they have, and how old everyone is",
93
- "focus": "masculine and feminine, the dual (ولدين، بنتين) and numbers with nouns"},
94
- {"id": "l8", "lesson": "8", "who": "סוהא", "ar": "سهى", "she": True, "story": "l8-story",
95
- "title": "בחנות הבגדים", "desc": "סוהא המוכרת עוזרת לכם לבחור מכנסיים ושמלה: איזה צבע וכמה זה עולה",
96
- "goal": "Suha works in a clothes shop. She asks what the student is looking for, which colour they like, "
97
- "and tells how much each item costs; they ask how many there are",
98
- "focus": "colours, قدّيش (how much) and كم (how many)"},
99
- {"id": "l9", "lesson": "9", "who": "אבו נדאל", "ar": "ابو نضال", "she": False, "story": "l9-story",
100
- "title": "קפה אצל אבו נדאל", "desc": "אבו נדאל הבדואי מארח אתכם לקפה ושואל מה תרצו לעשות בשבוע הבא",
101
- "goal": "Abu Nidal, a Bedouin near Arad, welcomes the student, serves coffee (bitter, without sugar or milk), "
102
- "asks about their family and work, and what they want to do next week",
103
- "focus": "the future with بدّ (بدّي، بدّك) and hospitality phrases"},
104
- {"id": "l10", "lesson": "10", "who": "הדוד סעיד", "ar": "سعيد", "she": False, "story": "l10-story",
105
- "title": "הדוד סעיד בא לבקר", "desc": "הדוד סעיד הגיע לבקר. משווים: מה יותר גדול, יותר יפה, הכי טוב",
106
- "goal": "uncle Said has come to visit. He asks the student about their life, football, the beach and the "
107
- "train, and they compare things: which is bigger, nicer, the best",
108
- "focus": "the comparative and superlative (أكبر، أحلى، أحسن) and هاد، هاي، هدول"},
109
- {"id": "l2.1", "lesson": "2.1", "who": "אבו חסן", "ar": "ابو حسن", "she": False, "story": "l2.1-story",
110
- "title": "במכולת של אבו חסן", "desc": "אורחים באים היום: קונים במכולת של אבו חסן לפי הרשימה",
111
- "goal": "Abu Hassan runs the village grocery. Guests are coming to the student today; they buy what is on "
112
- "their list. He asks what they need, who is coming and what they will cook",
113
- "focus": "the future with رح and must with لازم"},
114
- {"id": "l2.2", "lesson": "2.2", "who": "עלי", "ar": "علي", "she": False, "story": "l2.2-story",
115
- "title": "בדוכן הפלאפל", "desc": "מזמינים מנת פלאפל אצל עלי, ומספרים מה אתם עושים בדרך כלל",
116
- "goal": "Ali owns a falafel restaurant. He takes the student's order (which salads and sauces in the pita) and "
117
- "chats about what they usually do on each day of the week",
118
- "focus": "the present tense with بـ (باكل، بتروح) and the days of the week"},
119
- {"id": "l2.3", "lesson": "2.3", "who": "אבו ג'מיל", "ar": "ابو جميل", "she": False, "story": "l2.3-talk",
120
- "title": "סליחה, מה השעה?", "desc": "אבו ג'מיל ברחוב שואל מה השעה, ומספר על יום ההולדת של הערב",
121
- "goal": "Abu Jamil stops the student in the street to ask the time, because his phone is broken. They talk "
122
- "about the time of tonight's birthday party and how to get there",
123
- "focus": "telling the time (ربع، ثلث، نصّ) and form II verbs (فعّل)"},
124
- {"id": "l2.4", "lesson": "2.4", "who": "שאדי", "ar": "شادي", "she": False, "story": "l2.4-talk2",
125
- "title": "שיחת טלפון", "desc": "שאדי מתקשר: קובעים להיפגש מחר, באיזו שעה ואיפה",
126
- "goal": "Shadi phones the student. He asks how they are and arranges to meet tomorrow: at what time, where, "
127
- "and asks for a phone number",
128
- "focus": "phone phrases (ألو، مين معي) and the relative words اللي، انّو"},
129
- {"id": "l2.5", "lesson": "2.5", "who": "אבו ויסאם", "ar": "ابو وسام", "she": False, "story": "l2.5-story",
130
- "title": "במוסך", "desc": "האוטו עושה רעש: אבו ויסאם במוסך שואל מה קרה",
131
- "goal": "Abu Wisam owns a garage. The student's car is making a noise and the engine light is red. He asks "
132
- "what happened and when, checks the oil, and tells them how long it will take",
133
- "focus": "صار (happened, became) and verbs like جاب (brought)"},
134
- {"id": "l2.6", "lesson": "2.6", "who": "פואד", "ar": "فؤاد", "she": False, "story": "l2.6-story",
135
- "title": "הכד העתיק", "desc": "מצאתם כד עתיק. מר פואד רוצה לקנות אותו: יקר או זול?",
136
- "goal": "the student found an old jar and brings it to Mr Fuad Arafat, who buys antiques. He asks where they "
137
- "found it, and they bargain over the price in dinars: expensive or cheap",
138
- "focus": "form III verbs (ساعد، لاقى) and the object suffix after a vowel"},
139
- {"id": "l2.7", "lesson": "2.7", "who": "יוסף", "ar": "يوسف", "she": False, "story": "l2.7-story",
140
- "title": "בתחנת האוטובוס", "desc": "התחנה מלאה. יוסף מחכה לקו לטבריה ונותן לכם עצות",
141
- "goal": "Yusuf is waiting at a crowded bus stop at the Furaydis junction. He talks with the student about "
142
- "which line goes to Tiberias and when it comes, and tells them what to do",
143
- "focus": "the imperative (استنّى، تعال، قول) next to the past, present and future"},
144
- {"id": "l2.8", "lesson": "2.8", "who": "ד\"ר לילא", "ar": "ليلى", "she": True, "story": "l2.8-story",
145
- "title": "אצל הרופאה", "desc": "הרופאה לילא שואלת מה כואב ומה אתם עושים עכשיו",
146
- "goal": "Dr Layla, a doctor, asks the student what hurts, since when, and what they are doing for it right now, "
147
- "and gives advice (rest, aspirin, health)",
148
- "focus": "عم for an action happening now and doubled verbs (حبّ، ضلّ)"},
149
- {"id": "l2.9", "lesson": "2.9", "who": "המוכתר", "ar": "المختار", "she": False, "story": "l2.9-story",
150
- "title": "המוכתר של הכפר", "desc": "המוכתר פוגש אתכם בכפר: לאן הולכים, מאיפה באים ומה אתם חושבים",
151
- "goal": "the village mukhtar meets the student on the road and asks where they are going and coming from, "
152
- "whether they live here, and tells the story of the father, the son and the donkey",
153
- "focus": "the active participle (رايح، جاي، ساكن، عارف)"},
154
- ]
155
- BY_ID = {s["id"]: s for s in SCENARIOS}
156
- READY = [l["id"] for l in LESSONS if not l.get("soon")]
157
- LESSON_OF = {c["id"]: c["lesson"] for c in CATEGORIES}
158
- LEARN = {c["id"] for c in CATEGORIES if c.get("learn")}
159
- TASHKEEL = re.compile(r"[ً-ْٰـ]")
160
- PUNCT = "،؟!.,:;\"'()«»…-"
161
- ARABIC = re.compile(r"[ء-ي]")
162
- PREFIXES = ("وال", "بال", "فال", "لل", "ال", "و", "ب", "ف", "ل", "ع", "م")
163
- SUFFIXES = ("كم", "هم", "ها", "نا", "ني", "تي", "ين", "ات", "ي", "ك", "ه", "و", "ت", "ة")
164
-
165
-
166
- def norm(word: str) -> str:
167
- """An Arabic word as it is compared: no tag, vowel signs or punctuation; one alef; ى as ي and ة as ه."""
168
- w = TASHKEEL.sub("", word.split("#")[0]).strip(PUNCT)
169
- return w.translate(str.maketrans("أإآٱى", "ااااي")).replace("ة", "ه")
170
-
171
-
172
- TR_OF = {norm(k): v[0] for k, v in LEX.items()} # a word's reading in Hebrew letters, as the course writes it
173
-
174
-
175
- @functools.lru_cache(maxsize=None)
176
- def vocabulary(lesson: str) -> tuple[list[tuple[str, str, str]], frozenset[str]]:
177
- """What a student knows by the end of this lesson: the new words of it and of the lessons before it, as
178
- (Arabic, Hebrew letters, Hebrew), and every word form their sentences used (normalised, see `norm`)."""
179
- upto = set(READY[:READY.index(lesson) + 1])
180
- learnt, forms = [], set()
181
- for p in PHRASES:
182
- if LESSON_OF.get(p["cat"]) not in upto:
183
- continue
184
- if p["cat"] in LEARN:
185
- learnt.append((p["variants"][0][0], p["tr"], p["he"]))
186
- for ar, _ in p["variants"]:
187
- forms.update(n for n in map(norm, ar.split()) if n)
188
- return learnt, frozenset(forms)
189
-
190
-
191
- def is_known(word: str, forms: frozenset[str]) -> bool:
192
- """Whether the student has met this word: as it is, or without a prefix (و، ال، بـ...) or an ending (ي، ك، ها...)
193
- that they learnt as such. Lenient on purpose: a word marked new must be one the lessons never had."""
194
- w = norm(word)
195
- if not w or w.isdigit() or not re.search(r"[ء-ي]", w):
196
- return True
197
- stems = {w} | {w[len(p):] for p in PREFIXES if w.startswith(p) and len(w) - len(p) >= 2}
198
- stems |= {s[:-len(x)] for s in list(stems) for x in SUFFIXES if s.endswith(x) and len(s) - len(x) >= 2}
199
- return any(s in forms or s + "ه" in forms for s in stems)
200
-
201
-
202
- def _lines(cat: str) -> list[str]:
203
- """A topic of the course, line by line (Arabic | Hebrew letters | Hebrew): the model's example of the lesson."""
204
- return [f"{p['variants'][0][0]} | {p['tr']} | {p['he']}" for p in PHRASES if p["cat"] == cat]
205
-
206
-
207
- # The tutor's every answer, as JSON (structured output). heard_tr: the student's sentence as the tutor read it, in
208
- # Hebrew letters; words: the tutor's message word by word; fix_*: the student's last sentence said right, empty when
209
- # it was right; summary: only in the last answer.
210
- WORD_SCHEMA = {
211
- "type": "object",
212
- "properties": {"ar": {"type": "string"}, "tr": {"type": "string"}, "he": {"type": "string"},
213
- "name": {"type": "boolean"}},
214
- "required": ["ar", "tr", "he", "name"],
215
- "additionalProperties": False,
216
- }
217
- REPLY_SCHEMA = {
218
- "type": "object",
219
- "properties": {
220
- "heard_tr": {"type": "string"},
221
- "words": {"type": "array", "items": WORD_SCHEMA},
222
- "he": {"type": "string"},
223
- "fix_ar": {"type": "string"}, "fix_tr": {"type": "string"}, "fix_note": {"type": "string"},
224
- "summary": {"type": "string"},
225
- },
226
- "required": ["heard_tr", "words", "he", "fix_ar", "fix_tr", "fix_note", "summary"],
227
- "additionalProperties": False,
228
- }
229
- OPEN = "(The student has opened the chat. Start the conversation: greet them and begin the scenario.)"
230
-
231
-
232
- def system_prompt(sc: dict, gender: str) -> str:
233
- """The tutor's instructions: the character, the scenario, the words it may use, the answer's fields."""
234
- student = "a woman: speak to her in the feminine (حالِك، اسمِك، انتِ، بتشتغلي)" if gender == "f" else \
235
- "a man: speak to him in the masculine (حالَك، اسمَك، انتَ، بتشتغل)"
236
- who = f"{sc['ar']}, a Palestinian {'woman' if sc['she'] else 'man'} (speak of yourself in the " \
237
- f"{'feminine' if sc['she'] else 'masculine'})"
238
- learnt, forms = vocabulary(sc["lesson"])
239
- words = "\n".join(f"{a} | {t} | {h}" for a, t, h in learnt)
240
- lesson = next(l["name"] for l in LESSONS if l["id"] == sc["lesson"])
241
- return f"""You play {who} in a short conversation with a Hebrew-speaking beginner who is learning spoken Palestinian
242
- Arabic. The student is {student}.
243
-
244
- The scenario: {sc["goal"]}. Ask one thing at a time, answer the student's questions in your character, and keep
245
- the conversation going. Read each message to its end before you answer: never ask for something the student has
246
- already told you. If the student goes off the topic, answer in one short sentence and bring the talk back.
247
-
248
- The student has studied up to {lesson} of the course. This lesson's grammar, to bring in naturally: {sc["focus"]}.
249
- Speak ONLY with the words the student knows: the word list and the word forms below (with their usual prefixes and
250
- endings: و، ال، بـ، the possessive and verb endings). Prefer the words of this lesson's story. Use a word outside the
251
- lists only when there is no natural way to say it with known words, and then keep it simple.
252
-
253
- The student cannot read Arabic script: they read Arabic only in Hebrew letters. They mostly speak, and the browser's
254
- speech recognition writes down what it heard in Arabic script, so a message may be misspelled, missing words or partly
255
- wrong; a student who types writes the Arabic in Hebrew letters. Answer what you understood. If you understood nothing,
256
- say so kindly and ask again in simpler words. Never use Modern Standard Arabic: speak the spoken Palestinian dialect
257
- of the course (شو، وين، كيف، قدّيش، بدّي، كتير، هلّق). Keep each message to one or two short sentences.
258
-
259
- Arabic in Hebrew letters ("Hebrew letters" below) is written exactly as the course writes it, as in the lesson lines
260
- below: pointed Hebrew; ח' for خ, ע' for غ, ג' for ج, ד' for ض, ט' for ظ, ת' for ث; ح is ח, ع is ע, ص is צ, ط is ט,
261
- ق is ק; the shadda ّ after a doubled letter; a hyphen before a pronoun suffix (אִסְמַ-כּ, חַאלֵ-כּ, עִנְד-י).
262
-
263
- Every answer has these fields:
264
- - heard_tr: the student's last message exactly as you understood it, in Hebrew letters. Write what they said, mistakes
265
- and all: do not correct it here (corrections go in fix_*). Empty when there is no student message yet.
266
- - words: your message, word by word in order. For each word: ar (the word in Arabic script, as the course writes it,
267
- no vowel signs except shadda ّ), tr (the word in Hebrew letters, with the punctuation that follows it, e.g. "חַאלַ-כּ?"),
268
- he (its meaning here in Hebrew, one to three words), name (true only for a person's or a place's name).
269
- - he: a natural Hebrew translation of your whole message.
270
- - fix_ar, fix_tr, fix_note: only for a clear mistake in the student's last sentence: a wrong verb form, gender or
271
- person, a wrong word, or Modern Standard Arabic or Hebrew where the dialect has its own word (ثلاثون for تلاتين).
272
- Then give the sentence as they should have said it (in Arabic script and in Hebrew letters), and in fix_note one
273
- short, simple line in Hebrew naming exactly what changed (e.g. "בניב אומרים תַלַאתִין ולא ת'לַאת'וּן"). Most
274
- sentences of a student are fine: then leave all three empty. Leave them empty too when you are not sure, when the
275
- difference is only spelling or vowels (that came from the speech recognition, not from the student), or when the
276
- sentence is a correct way to say it even if you would say it differently. Never correct a correct sentence.
277
- - summary: empty, except in your very last message (you will be told when it is). Then write in Hebrew, addressed to
278
- the student, two or three sentences on what went well and the two or three things most worth practising.
279
- fix_note and summary are in Hebrew, and any Arabic word in them is written in Hebrew letters, never in Arabic script.
280
-
281
- Lines of this lesson (Arabic | Hebrew letters | Hebrew):
282
- {chr(10).join(_lines(sc["story"]))}
283
-
284
- The words the student has learnt (Arabic | Hebrew letters | Hebrew):
285
- {words}
286
-
287
- Every word form the lessons so far have used:
288
- {" ".join(sorted(forms))}
289
-
290
- YOUR ROLE NOW: you are {sc["ar"]}. {sc["goal"]}. From your very first message, be this character in this situation
291
- and lead the conversation toward it; every answer of yours moves the scenario on. Speak to the student as
292
- {student.split(":")[0]}."""
293
-
294
-
295
- class TutorUnavailable(Exception):
296
- """No key, or the model failed. The message is safe to show the student (Hebrew)."""
297
-
298
-
299
- class TutorLimit(Exception):
300
- """The student has started their conversations for today."""
301
-
302
-
303
- @dataclass
304
- class Chat:
305
- id: str
306
- scenario: str
307
- gender: str
308
- user_id: str | None
309
- email: str
310
- system: str
311
- messages: list[dict] = field(default_factory=list) # as the model reads them: user text, assistant JSON
312
- log: list[dict] = field(default_factory=list) # as the page and the teacher read them
313
- turns: int = 0
314
- done: bool = False
315
- seen: float = field(default_factory=time.monotonic)
316
- input_tokens: int = 0
317
- output_tokens: int = 0
318
- model: str = DEFAULT_MODEL
319
-
320
- def row(self) -> dict:
321
- """The conversation as the table `tutor_chats` keeps it."""
322
- return {"id": self.id, "user_id": self.user_id, "email": self.email, "scenario": self.scenario,
323
- "gender": self.gender, "messages": self.log, "turns": self.turns, "finished": self.done,
324
- "model": self.model, "input_tokens": self.input_tokens, "output_tokens": self.output_tokens,
325
- "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
326
-
327
-
328
- # models that take a `role: "system"` message in the middle of `messages` (the others answer 400)
329
- SYSTEM_MESSAGES = ("claude-opus-5", "claude-opus-4-8", "claude-fable-5", "claude-mythos-5", "claude-sonnet-5-5", "claude-haiku-5-5")
330
-
331
-
332
- def thinking_for(model: str) -> dict | None:
333
- """As little thinking as each model allows for a chat answer. Sonnet 5.5 refuses "disabled" and turns thinking off
334
- with "between_tools"; Opus 5.5 and Fable 5.x cannot turn it off at all (None: the parameter is left out, so it is
335
- adaptive, and effort "low" keeps it short); the others take "disabled". Only the answer's text goes back into the
336
- history, never a thinking block, so the history stays append-only and nothing is checked against it."""
337
- if model.startswith("claude-sonnet-5-5"):
338
- return {"type": "between_tools"}
339
- if model.startswith(("claude-opus-5-5", "claude-fable-5", "claude-mythos-5")):
340
- return None
341
- return {"type": "disabled"}
342
-
343
-
344
- def request_options(model: str) -> dict:
345
- """The arguments of a chat answer that differ by model: thinking (see thinking_for), effort (Haiku 4.5 has none),
346
- and room for a thinking model to think before it answers."""
347
- thinking = thinking_for(model)
348
- config = {"format": {"type": "json_schema", "schema": REPLY_SCHEMA}}
349
- if not model.startswith("claude-haiku-4-5"):
350
- config["effort"] = "low"
351
- return {"max_tokens": 4000 if thinking else 8000, "output_config": config, **({"thinking": thinking} if thinking else {})}
352
-
353
-
354
- def last_turn(model: str, messages: list[dict], note: str) -> list[dict]:
355
- """Tells the model this is the last sentence: as a system message where the model takes one, else after the
356
- student's sentence in the same user turn."""
357
- if model.startswith(SYSTEM_MESSAGES):
358
- return messages + [{"role": "system", "content": note}]
359
- *before, student = messages
360
- return before + [{"role": "user", "content": [{"type": "text", "text": student["content"]},
361
- {"type": "text", "text": f"(Instruction: {note})"}]}]
362
-
363
-
364
- class Tutor:
365
- def __init__(self, client, model: str = DEFAULT_MODEL):
366
- self.client = client # an anthropic.Anthropic
367
- self.model = model
368
- self._chats: dict[str, Chat] = {}
369
- self._started: dict[tuple[str, str], int] = {} # (student, day): conversations started
370
- self._lock = threading.Lock()
371
-
372
- def started(self, student: str, day: str, seed=None) -> int:
373
- """How many conversations the student started on this day. `seed` reads the number kept in Supabase the first
374
- time it is asked (a restart forgets the count in memory)."""
375
- key = (student, day)
376
- with self._lock:
377
- if key in self._started:
378
- return self._started[key]
379
- n = seed() if seed else 0
380
- with self._lock:
381
- return self._started.setdefault(key, n)
382
-
383
- def start(self, scenario: str, gender: str, user_id: str | None, email: str, student: str, day: str,
384
- limit: int | None, seed=None) -> tuple[Chat, dict]:
385
- """A new conversation and the tutor's opening message (see `message`). Raises TutorLimit past the day's
386
- `limit` (None: none), and TutorUnavailable when the model fails (the conversation is then not counted)."""
387
- sc = BY_ID[scenario]
388
- if limit is not None and self.started(student, day, seed) >= limit:
389
- raise TutorLimit()
390
- chat = Chat(id=str(uuid.uuid4()), scenario=scenario, gender=gender, user_id=user_id, email=email,
391
- system=system_prompt(sc, gender), model=self.model)
392
- messages = [{"role": "user", "content": OPEN}]
393
- data = self._answer(chat, messages)
394
- opening = self.message(data, sc)
395
- chat.log.append({"from": "tutor", **opening})
396
- with self._lock:
397
- self._started[(student, day)] = self._started.get((student, day), 0) + 1
398
- self._sweep()
399
- self._chats[chat.id] = chat
400
- return chat, opening
401
-
402
- def get(self, chat_id: str, user_id: str | None) -> Chat | None:
403
- with self._lock:
404
- chat = self._chats.get(chat_id)
405
- return chat if chat and chat.user_id == user_id else None
406
-
407
- @staticmethod
408
- def message(data: dict, sc: dict) -> dict:
409
- """The tutor's message as the page shows it: {"ar", "tr", "he", "words": [{tr, he, new}]}. A word is new when
410
- the lessons up to the scenario's never had it (a name never is)."""
411
- _, forms = vocabulary(sc["lesson"])
412
- words: list[dict] = []
413
- for w in data["words"]:
414
- tr, ar = w["tr"].strip(), w["ar"].strip()
415
- if not tr:
416
- continue
417
- if not re.search(r"[א-ת]", tr): # punctuation the model gave as a word of its own
418
- if words:
419
- words[-1]["tr"] += tr.strip()
420
- words[-1]["ar"] += ar.strip()
421
- continue
422
- if ARABIC.search(tr): # Arabic letters in the Hebrew ones: the course's own reading of the word, if it has it
423
- tr = TR_OF.get(norm(ar), re.sub(r"[؀-ۿ]+", "", tr).strip() or "…") + tr[len(tr.rstrip(PUNCT + "?")):]
424
- words.append({"ar": ar, "tr": tr, "he": w["he"].strip(), "new": not w["name"] and not is_known(ar, forms)})
425
- return {"ar": " ".join(w["ar"] for w in words), "tr": " ".join(w["tr"] for w in words),
426
- "he": data["he"].strip(), "words": [{k: w[k] for k in ("tr", "he", "new")} for w in words]}
427
-
428
- def turn(self, chat: Chat, text: str) -> dict:
429
- """The student's sentence, and the tutor's answer to it: {"heard": the sentence as the tutor read it, in Hebrew
430
- letters, "reply": the tutor's message (see `message`), "fix": {ar, tr, note} or None, "summary": text or None,
431
- "turn", "turns", "done"}. Raises TutorUnavailable when the model fails; the student's sentence is then not
432
- counted, and can be said again."""
433
- if chat.done:
434
- raise ValueError("the conversation has ended")
435
- last = chat.turns + 1 >= MAX_TURNS
436
- messages = chat.messages + [{"role": "user", "content": text}]
437
- if last:
438
- messages = last_turn(self.model, messages, "This is the student's last sentence. Answer it and say "
439
- "goodbye warmly, and write the summary.")
440
- data = self._answer(chat, messages)
441
- chat.turns += 1
442
- chat.done = last
443
- fix = {"ar": data["fix_ar"].strip(), "tr": data["fix_tr"].strip(), "note": data["fix_note"].strip()}
444
- if not fix["ar"] or not fix["note"] or NO_FIX.search(fix["note"]) or \
445
- [norm(w) for w in fix["ar"].split()] == [norm(w) for w in text.split()]:
446
- fix = None # no correction, or one that changes nothing: the model is not to correct a correct sentence
447
- reply = self.message(data, BY_ID[chat.scenario])
448
- summary = data["summary"].strip() if last else ""
449
- heard = data["heard_tr"].strip() or text
450
- chat.log.append({"from": "student", "text": text, "tr": heard, **({"fix": fix} if fix else {})})
451
- chat.log.append({"from": "tutor", **reply, **({"summary": summary} if summary else {})})
452
- return {"heard": heard, "reply": reply, "fix": fix, "summary": summary or None, "turn": chat.turns,
453
- "turns": MAX_TURNS, "done": chat.done}
454
-
455
- def _answer(self, chat: Chat, messages: list[dict]) -> dict:
456
- """Asks the model and keeps the exchange in the conversation's history (only once it has answered)."""
457
- data, usage = self._ask(chat.system, messages)
458
- chat.model = self.model # the panel may have changed it since the conversation began
459
- chat.messages = messages + [{"role": "assistant", "content": json.dumps(data, ensure_ascii=False)}]
460
- chat.seen = time.monotonic()
461
- chat.input_tokens += usage[0]
462
- chat.output_tokens += usage[1]
463
- return data
464
-
465
- def _ask(self, system: str, messages: list[dict]) -> tuple[dict, tuple[int, int]]:
466
- import anthropic
467
- try:
468
- res = self.client.messages.create(
469
- model=self.model, system=system, messages=messages, cache_control={"type": "ephemeral"},
470
- **request_options(self.model),
471
- )
472
- except anthropic.RateLimitError as e:
473
- log.warning("tutor rate limited: %s", e)
474
- raise TutorUnavailable("המורה עמוס כרגע. נסו שוב בעוד דקה.") from e
475
- except anthropic.AuthenticationError as e:
476
- log.error("tutor: the API key was refused: %s", e)
477
- raise TutorUnavailable("המורה לא זמין כרגע (מפתח ה-API לא תקין).") from e
478
- except anthropic.APIStatusError as e:
479
- log.warning("tutor: the API answered %s: %s", e.status_code, e.message)
480
- raise TutorUnavailable("המורה לא זמין כרגע. נסו שוב.") from e
481
- except anthropic.APIConnectionError as e:
482
- log.warning("tutor: no connection: %s", e)
483
- raise TutorUnavailable("אין חיבור למורה כרגע. נסו שוב.") from e
484
- if res.stop_reason == "refusal":
485
- log.warning("tutor: the model declined (%s)", res.stop_details)
486
- raise TutorUnavailable("המורה לא יכול לענות על זה. נסו לומר משהו אחר.")
487
- if res.stop_reason == "max_tokens":
488
- raise TutorUnavailable("התשובה נקטעה. נסו שוב.")
489
- text = next((b.text for b in res.content if b.type == "text"), "")
490
- try:
491
- data = json.loads(text)
492
- except ValueError as e:
493
- log.warning("tutor: an answer that is not JSON: %r", text[:300])
494
- raise TutorUnavailable("המורה לא הצליח לענות. נסו שוב.") from e
495
- return data, (res.usage.input_tokens + (res.usage.cache_read_input_tokens or 0)
496
- + (res.usage.cache_creation_input_tokens or 0), res.usage.output_tokens)
497
-
498
- def _sweep(self) -> None:
499
- """Drops conversations nobody continued for TTL, and the oldest past MAX_LIVE. Called with the lock held."""
500
- now = time.monotonic()
501
- for k in [k for k, c in self._chats.items() if now - c.seen > TTL]:
502
- del self._chats[k]
503
- while len(self._chats) >= MAX_LIVE:
504
- del self._chats[min(self._chats, key=lambda k: self._chats[k].seen)]
505
- if len(self._started) > 10 * MAX_LIVE:
506
- self._started.clear()
507
-
508
-
509
- def tutor_client(key: str):
510
- import anthropic
511
- return anthropic.Anthropic(api_key=key, timeout=30.0, max_retries=2)
512
-
513
-
514
- def load_tutor(key: str | None = None, model: str | None = None) -> Tutor | None:
515
- """`key`, `model`: the ones the panel set (see keystore.py); None reads the environment's."""
516
- if key is None:
517
- key = os.environ.get("ANTHROPIC_API_KEY", "") or os.environ.get("CLAUDE_API_KEY", "")
518
- key = key.strip()
519
- if not key:
520
- log.info("no ANTHROPIC_API_KEY: the tutor game (יאללה נדבר) is off")
521
- return None
522
- try:
523
- import anthropic
524
- except ImportError:
525
- log.warning("ANTHROPIC_API_KEY is set but the anthropic package is not installed: the tutor game is off")
526
- return None
527
- model = (os.environ.get("TUTOR_MODEL", "") if model is None else model).strip() or DEFAULT_MODEL
528
- log.info("the tutor game (יאללה נדבר) runs on %s", model)
529
- return Tutor(tutor_client(key), model)
530
-
531
-
532
- def tutor_open() -> bool:
533
- """Whether every student gets the game, and not only the teachers."""
534
- return os.environ.get("TUTOR_OPEN", "").strip().lower() in ("1", "true", "yes", "on")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/verb_game.py DELETED
@@ -1,411 +0,0 @@
1
- """Exercise generation and server-side checking for "פועל יוצא" levels 1--5.
2
-
3
- The grammar tables are deliberately the only linguistic source used here. A verb
4
- without a complete table is not eligible for the game: guessing a missing form is
5
- worse than offering a smaller, accurate exercise pool.
6
- """
7
- from __future__ import annotations
8
-
9
- import random
10
- import secrets
11
- import time
12
- from dataclasses import dataclass, field
13
- from typing import Any
14
-
15
- from .conjugation import (FUTURE_PREFIX, FUTURE_SUFFIX, FUTURE_SUFFIX_TILES, PAST_SUFFIX, PAST_SUFFIX_TILES,
16
- PAST_SUFFIX_TR, PREFIX_TILES, STAGE_A_VERBS, compose, lead, plain)
17
- from .grammar import PRONOUNS, VERBS
18
- from .workbook import LEX
19
-
20
-
21
- VERB_META = {
22
- "akal": {"name": "אכל", "unlock": "1"},
23
- "qaad": {"name": "ישב", "unlock": "1"},
24
- "fatah": {"name": "פתח", "unlock": "1"},
25
- "saal": {"name": "שאל", "unlock": "1"},
26
- "tabakh": {"name": "בישל", "unlock": "3"},
27
- "aja": {"name": "בא", "unlock": "4"},
28
- "raah": {"name": "הלך", "unlock": "5"},
29
- "faat": {"name": "נכנס / עבר", "unlock": "6"},
30
- "fihem": {"name": "הבין", "unlock": "8"},
31
- "laeb": {"name": "שיחק", "unlock": "10"},
32
- "libes": {"name": "לבש", "unlock": "2.3"},
33
- "labbas": {"name": "הלביש", "unlock": "2.3"},
34
- "jaab": {"name": "הביא", "unlock": "2.5"},
35
- "saaad": {"name": "עזר", "unlock": "2.6"},
36
- "rakad": {"name": "רץ", "unlock": "2.7"},
37
- "dall": {"name": "נשאר / המשיך", "unlock": "2.8"},
38
- }
39
- LEVELS = {
40
- 1: {"name": "בחירת ההטיה", "choices": 4, "window_ms": 12000},
41
- 2: {"name": "התאמת הגופים", "pairs": 6},
42
- 3: {"name": "החלפה מהירה", "choices": 3, "window_ms": 6000},
43
- 4: {"name": "אומרים בקול"},
44
- 5: {"name": "תגובה בשיחה"},
45
- }
46
-
47
- # "בונים עם איתן": stage A's verbs, past and future together, built from tiles (אותיות איתן and the endings).
48
- # 6 shows the Hebrew beside the slots, 7 only after the answer; 8 shows a form and asks every pronoun it can be.
49
- EITAN_LEVELS = {
50
- 6: {"name": "בונים עם עברית"},
51
- 7: {"name": "בונים לבד"},
52
- 8: {"name": "מי אמר?"},
53
- }
54
- TENSE_NAMES = {"past": "עבר", "future": "עתיד"}
55
-
56
- # Complete, filled past-tense tables in the parsed book. Order is PRONOUNS order.
57
- # Arabic is not copied or inferred from transliteration: each form must resolve back
58
- # to the application's existing LEX source of truth, otherwise the paradigm is dropped.
59
- BOOK_PAST_TRANSLITERATIONS = {
60
- "tabakh": ["טַבַּחֵ'ת", "טַבַּחֵ'ת", "טַבַּחְ'תִי", "טַבַּח'", "טַבְּחַ'ת", "טַבַּחְ'נַא", "טַבַּחְ'תוּ", "טַבַּח'וּ"],
61
- "aja": ["אַגֵ'ית", "אַגֵ'ית", "אַגֵ'יתִי", "אַגַ'א", "אַגַ'ת", "אַגֵ'ינַא", "אַגֵ'יתוּ", "אַג'וּ"],
62
- "raah": ["רֻחֵת", "רֻחֵת", "רֻחְתִי", "רַאח", "רַאחַת", "רֻחְנַא", "רֻחְתוּ", "רַאחוּ"],
63
- "faat": ["פֻתֵת", "פֻתֵת", "פֻתִّי", "פַאת", "פַאתַת", "פֻתְנַא", "פֻתّוּ", "פַאתוּ"],
64
- "fihem": ["פְהִמֵת", "פְהִמֵת", "פְהִמְתִי", "פִהֵם", "פִהְמַת", "פְהִמְנַא", "פְהִמְתוּ", "פִהְמוּ"],
65
- "laeb": ["לְעִבֵּת", "לְעִבֵּת", "לְעִבְּתִי", "לִעֵבּ", "לִעְבַּת", "לְעִבְּנַא", "לְעִבְּתוּ", "לִעְבּוּ"],
66
- "libes": ["לְבִּסֵת", "לְבִּסֵת", "לְבִּסְתִי", "לִבֵּס", "לִבְּסַת", "לְבִּסְנַא", "לְבִּסְתוּ", "לִבְּסוּ"],
67
- "labbas": ["לַבַּّסֵת", "לַבַּّסֵת", "לַבַּّסְתִי", "לַבַּّס", "לַבַּّסַת", "לַבַּّסְנַא", "לַבַּّסְתוּ", "לַבַּّסוּ"],
68
- "jaab": ["גִ'בֵּת", "גִ'בֵּת", "גִ'בְּתִי", "גַ'אבּ", "גַ'אבַּת", "גִ'בְּנַא", "גִ'בְּתוּ", "גַ'אבּוּ"],
69
- "saaad": ["סַאעַדֵת", "סַאעַדֵת", "סַאעַדְתִי", "סַאעַד", "סַאעַדַת", "סַאעַדְנַא", "סַאעַדְתוּ", "סַאעַדוּ"],
70
- "rakad": ["רַכַּדֵֿת", "רַכַּדֵֿת", "רַכַּדְֿתִי", "רַכַּדֿ", "רַכְּדַֿת", "רַכַּדְֿנַא", "רַכַּדְֿתוּ", "רַכַּדֿוּ"],
71
- "dall": ["דַֿלֵّית", "דַֿלֵّית", "דַֿלֵّיתִי", "דַֿלّ", "דַֿלַّת", "דַֿלֵّינַא", "דַֿלֵّיתוּ", "דַֿלّוּ"],
72
- }
73
-
74
-
75
- def _book_paradigms() -> dict[str, list[tuple[str, str]]]:
76
- by_tr: dict[str, tuple[str, str]] = {}
77
- for key, (tr, _ipas) in LEX.items():
78
- by_tr.setdefault(tr, (key.partition("#")[0], tr, list(_ipas)))
79
- return {verb: [by_tr[tr] for tr in forms] for verb, forms in BOOK_PAST_TRANSLITERATIONS.items()
80
- if all(tr in by_tr for tr in forms)}
81
-
82
-
83
- BOOK_VERBS = _book_paradigms()
84
-
85
-
86
- def _form(verb: str, pronoun: int) -> dict[str, Any]:
87
- if verb in VERBS:
88
- he, tr, arabic, ipas = VERBS[verb][pronoun]
89
- return {"hebrew": he, "transliteration": tr, "arabic": arabic, "ipas": list(ipas)}
90
- arabic, tr, ipas = BOOK_VERBS[verb][pronoun]
91
- return {"hebrew": "", "transliteration": tr, "arabic": arabic, "ipas": ipas}
92
-
93
-
94
- def _paradigm(verb: str) -> list:
95
- return VERBS[verb] if verb in VERBS else BOOK_VERBS[verb]
96
-
97
-
98
- def _distance(a: str, b: str) -> int:
99
- """Small edit distance, used only to rank real database forms as distractors."""
100
- row = list(range(len(b) + 1))
101
- for i, x in enumerate(a, 1):
102
- old = row[0]
103
- row[0] = i
104
- for j, y in enumerate(b, 1):
105
- above = row[j]
106
- row[j] = min(row[j] + 1, row[j - 1] + 1, old + (x != y))
107
- old = above
108
- return row[-1]
109
-
110
-
111
- @dataclass
112
- class StoredQuestion:
113
- mode: int
114
- verb: str
115
- correct: dict[str, str]
116
- made_at: float
117
- forms: dict[str, tuple[str, str]] = field(default_factory=dict)
118
- phrase: dict[str, Any] | None = None
119
-
120
-
121
- class PracticeSessionManager:
122
- """Short-lived opaque questions prevent clients from grading themselves."""
123
-
124
- def __init__(self, ttl_seconds: int = 1800, rng: random.Random | None = None):
125
- self.ttl_seconds = ttl_seconds
126
- self.rng = rng or random.Random()
127
- self.questions: dict[str, StoredQuestion] = {}
128
-
129
- def put(self, question: StoredQuestion) -> str:
130
- now = time.monotonic()
131
- self.questions = {k: v for k, v in self.questions.items() if now - v.made_at < self.ttl_seconds}
132
- key = secrets.token_urlsafe(18)
133
- self.questions[key] = question
134
- return key
135
-
136
- def take(self, key: str) -> StoredQuestion | None:
137
- question = self.questions.pop(key, None)
138
- if question and time.monotonic() - question.made_at < self.ttl_seconds:
139
- return question
140
- return None
141
-
142
- def get(self, key: str) -> StoredQuestion | None:
143
- question = self.questions.get(key)
144
- if question and time.monotonic() - question.made_at < self.ttl_seconds:
145
- return question
146
- self.questions.pop(key, None)
147
- return None
148
-
149
- def finish(self, key: str) -> None:
150
- self.questions.pop(key, None)
151
-
152
-
153
- class VerbExerciseGenerator:
154
- def __init__(self, sessions: PracticeSessionManager | None = None):
155
- self.sessions = sessions or PracticeSessionManager()
156
- self.rng = self.sessions.rng
157
-
158
- @staticmethod
159
- def available(lessons: list[str]) -> list[str]:
160
- allowed = set(lessons)
161
- return [v for v in VERB_META
162
- if (v in VERBS or v in BOOK_VERBS)
163
- and len(_paradigm(v)) == len(PRONOUNS)
164
- and VERB_META[v]["unlock"] in allowed]
165
-
166
- def metadata(self, lessons: list[str]) -> dict[str, Any]:
167
- return {
168
- "levels": [{"id": n, **cfg} for n, cfg in LEVELS.items()],
169
- "verbs": [{"id": v, "name": VERB_META[v]["name"], "unlock": VERB_META[v]["unlock"]}
170
- for v in self.available(lessons)],
171
- "tenses": [{"id": "past", "name": "עבר"}],
172
- "eitan_levels": [{"id": n, **cfg} for n, cfg in EITAN_LEVELS.items()],
173
- "eitan_verbs": [{"id": v, "name": STAGE_A_VERBS[v]["name"], "draft": STAGE_A_VERBS[v]["draft"]}
174
- for v in self.eitan_available(lessons, drafts=True)],
175
- }
176
-
177
- def _target(self, verbs: list[str], mastery: dict, recent: list[str]) -> tuple[str, int]:
178
- candidates = [(v, p) for v in verbs for p in range(len(PRONOUNS))]
179
- def priority(item: tuple[str, int]) -> float:
180
- v, p = item
181
- key = f"{v}:past:{PRONOUNS[p][0]}"
182
- m = mastery.get(key, {}) if isinstance(mastery, dict) else {}
183
- attempts = max(0, int(m.get("attempts", 0) or 0))
184
- accuracy = float(m.get("correct", 0) or 0) / attempts if attempts else 0.0
185
- slow = min(float(m.get("median_ms", 0) or 0) / 4000, 1.5)
186
- recency_penalty = 5 if key in recent[-3:] else 0
187
- # Incorrect, slow, and unseen forms rise; a little noise avoids a fixed drill order.
188
- return (3 if attempts and accuracy < .7 else 0) + slow + (2 if not attempts else 0) - recency_penalty + self.rng.random()
189
- return max(candidates, key=priority)
190
-
191
- @staticmethod
192
- def eitan_available(lessons: list[str], drafts: bool = False) -> list[str]:
193
- """Stage A's verbs of the lessons given; a verb with draft future forms only when drafts are wanted (teachers)."""
194
- allowed = set(lessons)
195
- return [v for v, verb in STAGE_A_VERBS.items() if verb["lesson"] in allowed and (drafts or not verb["draft"])]
196
-
197
- def question(self, level: int, lessons: list[str], mastery: dict | None = None,
198
- recent: list[str] | None = None, drafts: bool = False) -> dict[str, Any]:
199
- if level in EITAN_LEVELS:
200
- return self._eitan(level, lessons, mastery or {}, recent or [], drafts)
201
- if level not in LEVELS:
202
- raise ValueError("unsupported level")
203
- verbs = self.available(lessons)
204
- if not verbs:
205
- raise LookupError("no complete verb paradigms are unlocked")
206
- if level == 2:
207
- return self._matching(verbs, mastery or {}, recent or [])
208
- if level in (4, 5):
209
- return self._spoken(level, verbs, mastery or {}, recent or [])
210
- verb, pron = self._target(verbs, mastery or {}, recent or [])
211
- right = _form(verb, pron)
212
- count = LEVELS[level]["choices"]
213
- pool = []
214
- for other_pron in range(len(PRONOUNS)):
215
- if other_pron != pron:
216
- pool.append((verb, other_pron, _form(verb, other_pron)))
217
- if level == 3 and len(verbs) > 1:
218
- for other in verbs:
219
- if other != verb:
220
- pool.append((other, pron, _form(other, pron)))
221
- pool.sort(key=lambda x: (_distance(right["transliteration"], x[2]["transliteration"]), self.rng.random()))
222
- unique, seen = [], {(right["transliteration"], right["arabic"])}
223
- for _, _, form in pool:
224
- signature = (form["transliteration"], form["arabic"])
225
- if signature not in seen:
226
- seen.add(signature)
227
- unique.append(form)
228
- if len(unique) == count - 1:
229
- break
230
- choices = [{"id": secrets.token_urlsafe(7), "transliteration": f["transliteration"], "arabic": f["arabic"]}
231
- for f in [right, *unique]]
232
- self.rng.shuffle(choices)
233
- correct_id = next(c["id"] for c in choices if (c["transliteration"], c["arabic"]) ==
234
- (right["transliteration"], right["arabic"]))
235
- qid = self.sessions.put(StoredQuestion(level, verb, {"choice": correct_id}, time.monotonic()))
236
- pronoun = PRONOUNS[pron]
237
- return {
238
- "question_id": qid, "level": level, "tense": {"id": "past", "name": "עבר"},
239
- "verb": {"id": verb, "name": VERB_META[verb]["name"], "arabic": _form(verb, 3)["arabic"]},
240
- "pronoun": {"id": pronoun[0], "hebrew": pronoun[1], "transliteration": pronoun[2]},
241
- "choices": choices, "time_limit_ms": LEVELS[level]["window_ms"],
242
- "mastery_key": f"{verb}:past:{pronoun[0]}",
243
- }
244
-
245
- def _matching(self, verbs: list[str], mastery: dict, recent: list[str]) -> dict[str, Any]:
246
- verb, _ = self._target(verbs, mastery, recent)
247
- count = min(LEVELS[2]["pairs"], len(PRONOUNS))
248
- indices = self.rng.sample(range(len(PRONOUNS)), count)
249
- left, right, correct, forms = [], [], {}, {}
250
- for p in indices:
251
- pron = PRONOUNS[p]
252
- form = _form(verb, p)
253
- pid, fid = secrets.token_urlsafe(6), secrets.token_urlsafe(6)
254
- left.append({"id": pid, "hebrew": pron[1], "transliteration": pron[2]})
255
- right.append({"id": fid, "transliteration": form["transliteration"], "arabic": form["arabic"]})
256
- correct[pid] = fid
257
- forms[fid] = (form["transliteration"], form["arabic"])
258
- self.rng.shuffle(left)
259
- self.rng.shuffle(right)
260
- qid = self.sessions.put(StoredQuestion(2, verb, correct, time.monotonic(), forms))
261
- return {"question_id": qid, "level": 2, "tense": {"id": "past", "name": "עבר"},
262
- "verb": {"id": verb, "name": VERB_META[verb]["name"], "arabic": _form(verb, 3)["arabic"]},
263
- "pronouns": left, "forms": right, "pair_count": count,
264
- "mastery_key": f"{verb}:past:matching"}
265
-
266
- def _spoken(self, level: int, verbs: list[str], mastery: dict, recent: list[str]) -> dict[str, Any]:
267
- if level == 5:
268
- # A controlled first conversation: the learner answers about themself. Keeping
269
- # the target to ana makes the prompt natural and the grammar target unambiguous.
270
- scored = [(v, 0) for v in verbs]
271
- verb, pron = max(scored, key=lambda x: self.rng.random() +
272
- (2 if not mastery.get(f"{x[0]}:past:ana") else 0))
273
- else:
274
- verb, pron = self._target(verbs, mastery, recent)
275
- form = _form(verb, pron)
276
- p = PRONOUNS[pron]
277
- variants = [(form["arabic"], ipa) for ipa in form["ipas"]]
278
- if level == 5:
279
- pron_ar, pron_ipa = PRONOUNS[0][3][0]
280
- variants += [(f"{pron_ar} {form['arabic']}", f"{pron_ipa} | {ipa}") for ipa in form["ipas"]]
281
- phrase = {"variants": variants, "cat": "verb-game", "key": f"{verb}:past:{p[0]}:spoken",
282
- "he": f"{p[1]} · {VERB_META[verb]['name']} (עבר)"}
283
- qid = self.sessions.put(StoredQuestion(level, verb, {}, time.monotonic(), phrase=phrase))
284
- result = {
285
- "question_id": qid, "level": level, "tense": {"id": "past", "name": "עבר"},
286
- "verb": {"id": verb, "name": VERB_META[verb]["name"], "arabic": _form(verb, 3)["arabic"]},
287
- "pronoun": {"id": p[0], "hebrew": p[1], "transliteration": p[2]},
288
- "mastery_key": f"{verb}:past:{p[0]}:spoken",
289
- }
290
- if level == 5:
291
- result["conversation"] = {"arabic": "شو عملت مبارح؟", "transliteration": "שוּ עְמֵלְת אִמְבַּארֵח?",
292
- "hebrew": "מה עשית אתמול?"}
293
- return result
294
-
295
- def _pick(self, keys: list[tuple[str, Any]], mastery: dict, recent: list[str]) -> tuple[str, Any]:
296
- """(mastery key, item): the weakest, slowest or unseen first, never one of the last three."""
297
- def priority(entry: tuple[str, Any]) -> float:
298
- m = mastery.get(entry[0], {}) if isinstance(mastery, dict) else {}
299
- m = m if isinstance(m, dict) else {}
300
- attempts = max(0, int(m.get("attempts", 0) or 0))
301
- accuracy = float(m.get("correct", 0) or 0) / attempts if attempts else 0.0
302
- slow = min(float(m.get("median_ms", 0) or 0) / 6000, 1.5)
303
- return ((3 if attempts and accuracy < .7 else 0) + slow + (2 if not attempts else 0)
304
- - (5 if entry[0] in recent[-3:] else 0) + self.rng.random())
305
- return max(keys, key=priority)
306
-
307
- def _eitan(self, level: int, lessons: list[str], mastery: dict, recent: list[str], drafts: bool) -> dict[str, Any]:
308
- verbs = self.eitan_available(lessons, drafts)
309
- if not verbs:
310
- raise LookupError("no stage A verbs with a checked future are unlocked")
311
- if level == 8:
312
- entries = [(f"{v}:{t}:who:{p}", (v, t, p)) for v in verbs for t in ("past", "future") for p in range(len(PRONOUNS))]
313
- key, (verb_id, tense, pron) = self._pick(entries, mastery, recent)
314
- verb, form = STAGE_A_VERBS[verb_id], STAGE_A_VERBS[verb_id][tense][pron]
315
- same = lambda f: plain(f["ar"]) == plain(form["ar"]) and f["tr"] == form["tr"]
316
- who = [PRONOUNS[p][0] for p, f in enumerate(verb[tense]) if same(f)]
317
- qid = self.sessions.put(StoredQuestion(level, verb_id, {"who": who, "tense": tense, "pronoun": pron},
318
- time.monotonic()))
319
- return {"question_id": qid, "level": level, "tense": {"id": tense, "name": TENSE_NAMES[tense]},
320
- "verb": {"id": verb_id, "name": verb["name"], "transliteration": verb["transliteration"],
321
- "draft": verb["draft"]},
322
- "form": {"arabic": form["ar"], "transliteration": form["tr"], "parts": form["tr_parts"]},
323
- "pronouns": [{"id": p[0], "hebrew": p[1], "transliteration": p[2]} for p in PRONOUNS],
324
- "mastery_key": key}
325
- entries = [(f"{v}:pf:{PRONOUNS[p][0]}", (v, p)) for v in verbs for p in range(len(PRONOUNS))]
326
- key, (verb_id, pron) = self._pick(entries, mastery, recent)
327
- verb, p = STAGE_A_VERBS[verb_id], PRONOUNS[pron]
328
- past, future = verb["past"][pron], verb["future"][pron]
329
- # a tile: its Arabic (what is sent back and graded) and its transliteration (what the page shows)
330
- tiles = lambda pairs: [{"arabic": a, "label": h} for a, h in pairs]
331
- leads = {FUTURE_PREFIX[i]: lead(verb["future"][i]["tr"])[0] for i in (0, 1, 3, 5)} # this verb's vowels
332
- question = {
333
- "question_id": self.sessions.put(StoredQuestion(level, verb_id, {"pronoun": pron}, time.monotonic())),
334
- "level": level, "tense": {"id": "both", "name": "עבר ועתיד"},
335
- "verb": {"id": verb_id, "name": verb["name"], "transliteration": verb["transliteration"],
336
- "draft": verb["draft"]},
337
- "pronoun": {"id": p[0], "hebrew": p[1], "transliteration": p[2]},
338
- "past": {"body": past["tr_parts"]["body"], "tiles": tiles(PAST_SUFFIX_TILES)},
339
- "future": {"body": future["tr_parts"]["body"],
340
- "prefix_tiles": tiles([(a, leads[a]) for a, _ in PREFIX_TILES]),
341
- "suffix_tiles": tiles(FUTURE_SUFFIX_TILES)},
342
- "mastery_key": key,
343
- }
344
- if level == 6:
345
- question["past"]["hebrew"], question["future"]["hebrew"] = past["he"], future["he"]
346
- return question
347
-
348
- @staticmethod
349
- def _shown(form: dict) -> dict[str, Any]:
350
- return {"arabic": form["ar"], "transliteration": form["tr"], "parts": form["tr_parts"], "hebrew": form["he"],
351
- "draft": form["draft"]}
352
-
353
- def _eitan_answer(self, q: StoredQuestion, answer: Any, response_ms: int) -> dict[str, Any]:
354
- verb = STAGE_A_VERBS[q.verb]
355
- supplied = answer if isinstance(answer, dict) else {}
356
- if q.mode == 8:
357
- chosen = supplied.get("who") if isinstance(supplied.get("who"), list) else []
358
- chosen = sorted({str(c) for c in chosen})
359
- ok = chosen == sorted(q.correct["who"])
360
- tense = q.correct["tense"]
361
- explain = ("בעתיד התחילית אומרת מי: א=אני, ת=אתה/את/היא/אתם, י=הוא/הם, נ=אנחנו, כמו בעברית. "
362
- "ת לבדה היא גם אתה וגם היא." if tense == "future" else
363
- "בעבר הסיומת אומרת מי. ת בסוף היא אני, אתה וגם היא, ורק התנועות מבדילות ביניהם.")
364
- return {"correct": ok, "who": q.correct["who"], "response_ms": response_ms,
365
- "forms": [{"pronoun": PRONOUNS[i][0], **self._shown(f)} for i, f in enumerate(verb[tense])],
366
- **({} if ok else {"explanation": explain})}
367
- pron = q.correct["pronoun"]
368
- past, future = verb["past"][pron], verb["future"][pron]
369
- text = lambda k: str(supplied.get(k) or "")
370
- past_ok = plain(past["body"] + text("past")) == plain(past["ar"])
371
- future_ok = plain(compose(text("prefix"), future["body"], text("suffix"))) == plain(future["ar"])
372
- result = {"correct": past_ok and future_ok, "past_correct": past_ok, "future_correct": future_ok,
373
- "past": self._shown(past), "future": self._shown(future), "response_ms": response_ms}
374
- if not future_ok:
375
- he, parts = "".join(future["he"].values()), future["tr_parts"]
376
- ending = f"…{parts['suf'].lstrip(chr(0x05B4))}" if parts["suf"] else "" # …י, not a hiriq on the dots
377
- result["explanation"] = f"בעתיד: {parts['pre']}{ending}, כמו {he} בעברית."
378
- elif not past_ok:
379
- ending = PAST_SUFFIX_TR[pron][0]
380
- result["explanation"] = f"בעבר הסיומת של {PRONOUNS[pron][1]} היא {'…' + ending if ending else 'בלי סיומת'}."
381
- return result
382
-
383
- def speech_phrase(self, question_id: str) -> tuple[StoredQuestion, dict[str, Any]]:
384
- question = self.sessions.get(question_id)
385
- if not question or question.mode not in (4, 5) or not question.phrase:
386
- raise LookupError("spoken question expired")
387
- return question, question.phrase
388
-
389
- def answer(self, question_id: str, answer: Any, response_ms: int) -> dict[str, Any]:
390
- q = self.sessions.take(question_id)
391
- if not q:
392
- raise LookupError("question expired or already answered")
393
- response_ms = max(0, min(int(response_ms), 300_000))
394
- if q.mode in EITAN_LEVELS:
395
- return self._eitan_answer(q, answer, response_ms)
396
- if q.mode == 2:
397
- supplied = answer if isinstance(answer, dict) else {}
398
- # A duplicated written/spoken form is interchangeable with its identical twin.
399
- pair_results = {pid: supplied.get(pid) in q.forms and q.forms[supplied[pid]] == q.forms[expected]
400
- for pid, expected in q.correct.items()}
401
- correct = sum(pair_results.values())
402
- total = len(q.correct)
403
- return {"correct": correct == total, "correct_pairs": correct, "total_pairs": total,
404
- "pair_results": pair_results, "response_ms": response_ms}
405
- chosen = str(answer)
406
- ok = chosen == q.correct["choice"]
407
- result = {"correct": ok, "response_ms": response_ms}
408
- if not ok:
409
- result["explanation"] = "הסיומת משתנה לפי הגוף. התאימו את ההטיה לכינוי שמופיע בשאלה."
410
- result["correct_choice_id"] = q.correct["choice"]
411
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/workbook.py DELETED
The diff for this file is too large to render. See raw diff
 
arabic_splash/README.md DELETED
@@ -1,12 +0,0 @@
1
- # Animated splash for FastAPI / HTML
2
-
3
- Run locally:
4
-
5
- ```bash
6
- pip install -r requirements.txt
7
- uvicorn app:app --host 0.0.0.0 --port 7860
8
- ```
9
-
10
- For Hugging Face Spaces Docker, run uvicorn on port 7860 and expose port 7860 in your Dockerfile.
11
-
12
- To integrate into an existing project: copy static/logo.png, splash.css, splash.js; add the stylesheet to your HTML head, the `#app-splash` div from index.html at the beginning of body, and the JS script at the end of body. Ensure your FastAPI app serves /static. The demo uses three clipped copies of the original image, preserving the logo exactly. It is a reveal animation, not true vector stroke tracing.
 
 
 
 
 
 
 
 
 
 
 
 
 
arabic_splash/app.py DELETED
@@ -1,12 +0,0 @@
1
- from pathlib import Path
2
- from fastapi import FastAPI
3
- from fastapi.responses import FileResponse
4
- from fastapi.staticfiles import StaticFiles
5
-
6
- BASE = Path(__file__).resolve().parent
7
- app = FastAPI()
8
- app.mount('/static', StaticFiles(directory=BASE / 'static'), name='static')
9
-
10
- @app.get('/')
11
- def home():
12
- return FileResponse(BASE / 'index.html')
 
 
 
 
 
 
 
 
 
 
 
 
 
arabic_splash/index.html DELETED
@@ -1,20 +0,0 @@
1
- <!doctype html>
2
- <html lang="he" dir="rtl">
3
- <head>
4
- <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
5
- <title>Arabic School — Splash Demo</title>
6
- <link rel="stylesheet" href="/static/splash.css">
7
- <style>body{margin:0;font-family:system-ui,sans-serif;background:#faf9f6;color:#252525}main{max-width:720px;margin:12vh auto;padding:24px;text-align:center}button{padding:12px 20px;border:1px solid #333;background:white;border-radius:8px;cursor:pointer}</style>
8
- </head>
9
- <body>
10
- <div id="app-splash" role="status" aria-label="האפליקציה נטענת">
11
- <div class="splash-art" aria-hidden="true">
12
- <img class="splash-layer splash-symbol" src="/static/logo.png" alt="">
13
- <img class="splash-layer splash-arabic" src="/static/logo.png" alt="">
14
- <img class="splash-layer splash-hebrew" src="/static/logo.png" alt="">
15
- </div>
16
- </div>
17
- <main><h1>ברוכים הבאים</h1><p>זהו מסך הדגמה. החליפו את התוכן הזה בממשק האפליקציה שלכם.</p><button onclick="location.reload()">הפעל את האנימציה שוב</button></main>
18
- <script src="/static/splash.js" defer></script>
19
- </body>
20
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
arabic_splash/requirements.txt DELETED
@@ -1,2 +0,0 @@
1
- fastapi
2
- uvicorn
 
 
 
arabic_splash/static/logo.png DELETED

Git LFS Details

  • SHA256: 35ce45096bf91d4a8ba094ea8a9857ed558736a54a8eb299eefd6e8fcbc82600
  • Pointer size: 130 Bytes
  • Size of remote file: 49.3 kB
arabic_splash/static/splash.css DELETED
@@ -1,12 +0,0 @@
1
- :root { --splash-bg: #fff; --splash-duration: 3100ms; }
2
- #app-splash { position:fixed; inset:0; z-index:99999; display:grid; place-items:center; background:var(--splash-bg); transition:opacity .45s ease, visibility .45s ease; }
3
- #app-splash.splash-done { opacity:0; visibility:hidden; pointer-events:none; }
4
- .splash-art { position:relative; width:min(88vw, 560px); aspect-ratio:489/225; }
5
- .splash-layer { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; }
6
- .splash-symbol { clip-path:polygon(64% 0,100% 0,100% 100%,64% 100%); animation:symbol-in .95s ease-out both; transform-origin:82% 48%; }
7
- .splash-arabic { clip-path:polygon(0 0,65% 0,65% 72%,0 72%); animation:arabic-in 1.05s .65s ease-out both; }
8
- .splash-hebrew { clip-path:polygon(0 69%,65% 69%,65% 100%,0 100%); animation:hebrew-in .65s 1.6s ease-out both; }
9
- @keyframes symbol-in { from {opacity:0; transform:scale(.78) rotate(-18deg)} to {opacity:1; transform:scale(1) rotate(0)} }
10
- @keyframes arabic-in { from {opacity:0; clip-path:polygon(65% 0,65% 0,65% 72%,65% 72%)} to {opacity:1; clip-path:polygon(0 0,65% 0,65% 72%,0 72%)} }
11
- @keyframes hebrew-in { from {opacity:0; transform:translateY(10px)} to {opacity:1; transform:translateY(0)} }
12
- @media (prefers-reduced-motion:reduce) { .splash-layer {animation:none!important} #app-splash {transition:none} }
 
 
 
 
 
 
 
 
 
 
 
 
 
arabic_splash/static/splash.js DELETED
@@ -1,9 +0,0 @@
1
- (() => {
2
- const splash = document.getElementById('app-splash');
3
- if (!splash) return;
4
- const hide = () => { splash.classList.add('splash-done'); window.setTimeout(() => splash.remove(), 500); };
5
- const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
6
- const start = () => window.setTimeout(hide, reduced ? 300 : 3100);
7
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once:true });
8
- else start();
9
- })();
 
 
 
 
 
 
 
 
 
 
data/example.csv DELETED
@@ -1,4 +0,0 @@
1
- audio,ipa
2
- native/toilet_01.webm,w eː n | i l ħ a m m aː m
3
- learners/toilet_dana_03.webm,w eː n | i l χ a m m aː m
4
- native/coffee_01.webm,b i d d i | f i n dʒ aː n | ʔ a h w e
 
 
 
 
 
data/minerva_metadata.json DELETED
The diff for this file is too large to render. See raw diff
 
docs/future-forms-draft.md DELETED
@@ -1,293 +0,0 @@
1
- # טיוטת העתיד של פעלי שלב א' — לבדיקה
2
-
3
- הטבלה מציגה את צורות העתיד של כל פועל משלב א' שחסרה לו לפחות צורה אחת במאגר (LEX). ✓ = כבר במאגר ונבדקה. ✎ = טיוטה שנבנתה לפי הכלל, וצריך לבדוק אותה. אחרי הבדיקה, הצורות הנכונות עוברות ל־LEX (`app/workbook.py`) ונמחקות מ־`app/future_draft.py`. פועל שכל שמונה הצורות שלו נמצאות ב־LEX מגיע לתלמידים במשחק "בונים פועל עם איתן". עד אז רק מורים רואים אותו, עם התווית טיוטה.
4
-
5
- **כבר מוכנים לתלמידים (11):** שאל (يسأل), ישב (يقعد), בא (يجي), דיבר (يحكي), ביקר (يزور), ראה (يشوف), שתה (يشرب), יצא ועלה (يطلع), שמע (يسمع), אכל צהריים (يتغدّى), שיחק (يلعب)
6
-
7
- מה לבדוק בכל ✎: הכתיב בערבית, התעתיק (בעיקר התנועה של התחילית: יִ / יֻ / יְ) וההגייה. השמטת התנועה ב־ـي/ـו (למשל תֹאכְּלִי) נבנתה רק במקומות שבהם היא כבר מופיעה במאגר.
8
-
9
- ## פתח · فتح · يفتح (שיעור 1) — 6 לבדיקה
10
-
11
- | | גוף | ערבית | תעתיק | הגייה |
12
- |---|---|---|---|---|
13
- | ✓ | אני | افتح | אַפְתַח | `ʔ a f t a ħ` |
14
- | ✎ | אתה | تفتح | תִפְתַח | `t i f t a ħ` |
15
- | ✎ | את | تفتحي | תִפְתַחִי | `t i f t a ħ i` |
16
- | ✓ | הוא | يفتح | יִפְתַח | `j i f t a ħ` |
17
- | ✎ | היא | تفتح | תִפְתַח | `t i f t a ħ` |
18
- | ✎ | אנחנו | نفتح | נִפְתַח | `n i f t a ħ` |
19
- | ✎ | אתם | تفتحو | תִפְתַחוּ | `t i f t a ħ u` |
20
- | ✎ | הם | يفتحو | יִפְתַחוּ | `j i f t a ħ u` |
21
-
22
- ## אכל · اكل · ياكل (שיעור 1) — 1 לבדיקה
23
-
24
- | | גוף | ערבית | תעתיק | הגייה |
25
- |---|---|---|---|---|
26
- | ✓ | אני | آكل | אַאכֹּל | `ʔ aː k u l / ʔ aː k i l` |
27
- | ✓ | אתה | تاكل | תֹאכֹּל | `t oː k u l / t oː k i l / t aː k u l` |
28
- | ✓ | את | تاكلي | תֹאכְּלִי | `t oː k l i / t aː k l i / t oː k i l i` |
29
- | ✓ | הוא | ياكل | יֹאכֹּל | `j oː k u l / j oː k i l / j aː k u l` |
30
- | ✓ | היא | تاكل | תֹאכֹּל | `t oː k u l / t oː k i l / t aː k u l` |
31
- | ✓ | אנחנו | ناكل | נֹאכֹּל | `n oː k u l / n oː k i l / n aː k u l` |
32
- | ✓ | אתם | تاكلو | תֹאכְּלוּ | `t oː k l u / t aː k l u / t oː k u l u` |
33
- | ✎ | הם | ياكلو | יֹאכְּלוּ | `j oː k u l u / j oː k i l u / j aː k u l u / j oː k l u / j aː k l u` |
34
-
35
- ## נכנס · دخل · يدخل (שיעור 3) — 3 לבדיקה
36
-
37
- | | גוף | ערבית | תעתיק | הגייה |
38
- |---|---|---|---|---|
39
- | ✓ | אני | ادخل | אַדְחֹ'ל | `ʔ a d x u l` |
40
- | ✓ | אתה | تدخل | תֻדְחֹ'ל | `t u d x u l` |
41
- | ✎ | את | تدخلي | תֻדְחֹ'לִי | `t u d x u l i` |
42
- | ✓ | הוא | يدخل | יֻדְחֹ'ל | `j u d x u l` |
43
- | ✓ | היא | تدخل | תֻדְחֹ'ל | `t u d x u l` |
44
- | ✓ | אנחנו | ندخل | נֻדְחֹ'ל | `n u d x u l` |
45
- | ✎ | אתם | تدخلو | תֻדְחֹ'לוּ | `t u d x u l u` |
46
- | ✎ | הם | يدخلو | יֻדְחֹ'לוּ | `j u d x u l u` |
47
-
48
- ## שחה · سبح · يسبح (שיעור 3) — 4 לבדיקה
49
-
50
- | | גוף | ערבית | תעתיק | הגייה |
51
- |---|---|---|---|---|
52
- | ✎ | אני | اسبح | אַסְבַּח | `ʔ a s b a ħ` |
53
- | ✓ | אתה | تسبح | תִסְבַּח | `t i s b a ħ` |
54
- | ✎ | את | تسبحي | תִסְבַּחִי | `t i s b a ħ i` |
55
- | ✓ | הוא | يسبح | יִסְבַּח | `j i s b a ħ` |
56
- | ✓ | היא | تسبح | תִסְבַּח | `t i s b a ħ` |
57
- | ✓ | אנחנו | نسبح | נִסְבַּח | `n i s b a ħ` |
58
- | ✎ | אתם | تسبحو | תִסְבַּחוּ | `t i s b a ħ u` |
59
- | ✎ | הם | يسبحو | יִסְבַּחוּ | `j i s b a ħ u` |
60
-
61
- ## בישל · طبخ · يطبخ (שיעור 3) — 2 לבדיקה
62
-
63
- | | גוף | ערבית | תעתיק | הגייה |
64
- |---|---|---|---|---|
65
- | ✓ | אני | اطبخ | אַטְבֹּח' | `ʔ a tˤ b u x` |
66
- | ✓ | אתה | تطبخ | תֻטְבֹּח' | `t u tˤ b u x` |
67
- | ✓ | את | تطبخي | תֻטְבֹּחִ'י | `t u tˤ b u x i` |
68
- | ✓ | הוא | يطبخ | יֻטְבֹּח' | `j u tˤ b u x` |
69
- | ✓ | היא | تطبخ | תֻטְבֹּח' | `t u tˤ b u x` |
70
- | ✓ | אנחנו | نطبخ | נֻטְבֹּח' | `n u tˤ b u x` |
71
- | ✎ | אתם | تطبخو | תֻטְבֹּח'וּ | `t u tˤ b u x u` |
72
- | ✎ | הם | يطبخو | יֻטְבֹּח'וּ | `j u tˤ b u x u` |
73
-
74
- ## עבר דירה · نقل · ينقل (שיעור 3) — 4 לבדיקה
75
-
76
- | | גוף | ערבית | תעתיק | הגייה |
77
- |---|---|---|---|---|
78
- | ✎ | אני | انقل | אַנְקֹל | `ʔ a n ʔ u l / ʔ a n q u l` |
79
- | ✓ | אתה | تنقل | תֻנְקֹל | `t u n ʔ u l / t u n q u l` |
80
- | ✎ | את | تنقلي | תֻנְקֹלִי | `t u n ʔ u l i / t u n q u l i` |
81
- | ✓ | הוא | ينقل | יֻנְקֹל | `j u n ʔ u l / j u n q u l` |
82
- | ✓ | היא | تنقل | תֻנְקֹל | `t u n ʔ u l / t u n q u l` |
83
- | ✎ | אנחנו | ننقل | נֻנְקֹל | `n u n ʔ u l / n u n q u l` |
84
- | ✎ | אתם | تنقلو | תֻנְקֹלוּ | `t u n ʔ u l u / t u n q u l u` |
85
- | ✓ | הם | ينقلو | יֻנְקֹלוּ | `j u n ʔ u l u / j u n q u l u` |
86
-
87
- ## נתן · اعطى · يعطي (שיעור 4) — 3 לבדיקה
88
-
89
- | | גוף | ערבית | תעתיק | הגייה |
90
- |---|---|---|---|---|
91
- | ✓ | אני | اعطي | אַעַטִי | `ʔ a ʕ tˤ i / ʔ a ʕ a tˤ i` |
92
- | ✓ | אתה | تعطي | תֵעַטִי | `t a ʕ tˤ i / t i ʕ tˤ i` |
93
- | ✓ | את | تعطي | תֵעַטִי | `t a ʕ tˤ i / t i ʕ tˤ i` |
94
- | ✓ | הוא | يعطي | יֵעַטִי | `j a ʕ tˤ i / j i ʕ tˤ i` |
95
- | ✓ | היא | تعطي | תֵעַטִי | `t a ʕ tˤ i / t i ʕ tˤ i` |
96
- | ✎ | אנחנו | نعطي | נֵעַטִי | `n a ʕ tˤ i / n i ʕ tˤ i` |
97
- | ✎ | אתם | تعطو | תֵעַטוּ | `t a ʕ tˤ u / t i ʕ tˤ u` |
98
- | ✎ | הם | يعطو | יֵעַטוּ | `j a ʕ tˤ u / j i ʕ tˤ u` |
99
-
100
- ## קנה · اشترى · يشتري (שיעור 4) — 2 לבדיקה
101
-
102
- | | גוף | ערבית | תעתיק | הגייה |
103
- |---|---|---|---|---|
104
- | ✓ | אני | اشتري | אַשְתְרִי | `ʔ a ʃ t r i / ʔ a ʃ t i r i` |
105
- | ✓ | אתה | تشتري | תִשְתְרִי | `t i ʃ t r i / t i ʃ t i r i` |
106
- | ✓ | את | تشتري | תִשְתְרִי | `t i ʃ t r i / t i ʃ t i r i` |
107
- | ✓ | הוא | يشتري | יִשְתְרִי | `j i ʃ t r i / j i ʃ t i r i` |
108
- | ✓ | היא | تشتري | תִשְתְרִי | `t i ʃ t r i / t i ʃ t i r i` |
109
- | ✓ | אנחנו | نشتري | נִשְתְרִי | `n i ʃ t r i / n i ʃ t i r i` |
110
- | ✎ | אתם | تشترو | תִשְתְרוּ | `t i ʃ t r u / t i ʃ t i r u` |
111
- | ✎ | הם | يشترو | יִשְתְרוּ | `j i ʃ t r u / j i ʃ t i r u` |
112
-
113
- ## הלך · راح · يروح (שיעור 5) — 1 לבדיקה
114
-
115
- | | גוף | ערבית | תעתיק | הגייה |
116
- |---|---|---|---|---|
117
- | ✓ | אני | اروح | אַרוּח | `ʔ a r uː ħ` |
118
- | ✓ | אתה | تروح | תְרוּח | `t r uː ħ` |
119
- | ✓ | את | تروحي | תְרוּחִי | `t r uː ħ i` |
120
- | ✓ | הוא | يروح | יִרוּח | `j r uː ħ / i r uː ħ / j i r uː ħ` |
121
- | ✓ | היא | تروح | תְרוּח | `t r uː ħ` |
122
- | ✓ | אנחנו | نروح | נְרוּח | `n r uː ħ` |
123
- | ✎ | אתם | تروحو | תְרוּחוּ | `t r uː ħ u / t i r uː ħ u` |
124
- | ✓ | הם | يروحو | יִרוּחוּ | `j r uː ħ u / i r uː ħ u / j i r uː ħ u` |
125
-
126
- ## אמר · قال · يقول (שיעור 5) — 4 לבדיקה
127
-
128
- | | גוף | ערבית | תעתיק | הגייה |
129
- |---|---|---|---|---|
130
- | ✓ | אני | اقول | אַקוּל | `ʔ a ʔ uː l / ʔ a q uː l` |
131
- | ✓ | אתה | تقول | תְקוּל | `t ʔ uː l / t q uː l` |
132
- | ✎ | את | تقولي | תְקוּלִי | `t ʔ uː l i / t q uː l i` |
133
- | ✓ | הוא | يقول | יִקוּל | `j ʔ uː l / i ʔ uː l / j q uː l / j i ʔ uː l` |
134
- | ✓ | היא | تقول | תְקוּל | `t ʔ uː l / t q uː l` |
135
- | ✎ | אנחנו | نقول | נְקוּל | `n ʔ uː l / n q uː l` |
136
- | ✎ | אתם | تقولو | תְקוּלוּ | `t ʔ uː l u / t q uː l u` |
137
- | ✎ | הם | يقولو | יִקוּלוּ | `j ʔ uː l u / i ʔ uː l u / j q uː l u / j i ʔ uː l u` |
138
-
139
- ## היה · كان · يكون (שיעור 5) — 2 לבדיקה
140
-
141
- | | גוף | ערבית | תעתיק | הגייה |
142
- |---|---|---|---|---|
143
- | ✓ | אני | اكون | אַכּוּן | `ʔ a k uː n` |
144
- | ✓ | אתה | تكون | תְכּוּן | `t k uː n` |
145
- | ✓ | את | تكوني | תְכּוּנִי | `t k uː n i` |
146
- | ✓ | הוא | يكون | יִכּוּן | `j k uː n / i k uː n / j i k uː n` |
147
- | ✓ | היא | تكون | תְכּוּן | `t k uː n` |
148
- | ✓ | אנחנו | نكون | נְכּוּן | `n k uː n` |
149
- | ✎ | אתם | تكونو | תְכּוּנוּ | `t k uː n u` |
150
- | ✎ | הם | يكونو | יִכּוּנוּ | `j k uː n u / i k uː n u / j i k uː n u` |
151
-
152
- ## נישק · باس · يبوس (שיעור 6) — 4 לבדיקה
153
-
154
- | | גוף | ערבית | תעתיק | הגייה |
155
- |---|---|---|---|---|
156
- | ✓ | אני | ابوس | אַבּוּס | `ʔ a b uː s` |
157
- | ✓ | אתה | تبوس | תְבּוּס | `t b uː s` |
158
- | ✎ | את | تبوسي | תְבּוּסִי | `t b uː s i` |
159
- | ✓ | הוא | يبوس | יִבּוּס | `j b uː s / i b uː s / j i b uː s` |
160
- | ✓ | היא | تبوس | תְבּוּס | `t b uː s` |
161
- | ✎ | אנחנו | نبوس | נְבּוּס | `n b uː s` |
162
- | ✎ | אתם | تبوسو | תְבּוּסוּ | `t b uː s u` |
163
- | ✎ | הם | يبوسو | יִבּוּסוּ | `j b uː s u / i b uː s u / j i b uː s u` |
164
-
165
- ## נהג · ساق · يسوق (שיעור 6) — 3 לבדיקה
166
-
167
- | | גוף | ערבית | תעתיק | הגייה |
168
- |---|---|---|---|---|
169
- | ✓ | אני | اسوق | אַסוּק | `ʔ a s uː ʔ / ʔ a s uː q` |
170
- | ✓ | אתה | تسوق | תְסוּק | `t s uː ʔ / t s uː q` |
171
- | ✓ | את | تسوقي | תְסוּקִי | `t s uː ʔ i / t s uː q i` |
172
- | ✓ | הוא | يسوق | יִסוּק | `j s uː ʔ / j s uː q / i s uː ʔ / j i s uː ʔ` |
173
- | ✓ | היא | تسوق | תְסוּק | `t s uː ʔ / t s uː q` |
174
- | ✎ | אנחנו | نسوق | נְסוּק | `n s uː ʔ / n s uː q` |
175
- | ✎ | אתם | تسوقو | תְסוּקוּ | `t s uː ʔ u / t s uː q u` |
176
- | ✎ | הם | يسوقو | יִסוּקוּ | `j s uː ʔ u / j s uː q u / i s uː ʔ u / j i s uː ʔ u` |
177
-
178
- ## נכנס ועבר · فات · يفوت (שיעור 6) — 4 לבדיקה
179
-
180
- | | גוף | ערבית | תעתיק | הגייה |
181
- |---|---|---|---|---|
182
- | ✓ | אני | افوت | אַפוּת | `ʔ a f uː t` |
183
- | ✓ | אתה | تفوت | תְפוּת | `t f uː t` |
184
- | ✎ | את | تفوتي | תְפוּתִי | `t f uː t i` |
185
- | ✓ | הוא | يفوت | יִפוּת | `j f uː t / i f uː t / j i f uː t` |
186
- | ✓ | היא | تفوت | תְפוּת | `t f uː t` |
187
- | ✎ | אנחנו | نفوت | נְפוּת | `n f uː t` |
188
- | ✎ | אתם | تفوتو | תְפוּתוּ | `t f uː t u` |
189
- | ✎ | הם | يفوتو | יִפוּתוּ | `j f uː t u / i f uː t u / j i f uː t u` |
190
-
191
- ## לקח · اخد · ياخد (שיעור 6) — 2 לבדיקה
192
-
193
- | | גוף | ערבית | תעתיק | הגייה |
194
- |---|---|---|---|---|
195
- | ✓ | אני | آخد | אַאחֹ'ד | `ʔ aː x u d / ʔ aː x i d` |
196
- | ✓ | אתה | تاخد | תֹאחֵ'ד | `t oː x i d / t aː x u d` |
197
- | ✓ | את | تاخدي | תֹאחְ'דִי | `t oː x d i / t aː x d i / t oː x u d i` |
198
- | ✓ | הוא | ياخد | יֹאחֵ'ד | `j oː x i d / j aː x u d / j oː x u d` |
199
- | ✓ | היא | تاخد | תֹאחֵ'ד | `t oː x i d / t aː x u d` |
200
- | ✎ | אנחנו | ناخد | נֹאחֵ'ד | `n oː x i d / n aː x u d` |
201
- | ✎ | אתם | تاخدو | תֹאחְ'דוּ | `t oː x i d u / t aː x u d u / t oː x d u / t aː x d u / t oː x u d u` |
202
- | ✓ | הם | ياخدو | יֹאחְ'דוּ | `j oː x d u / j aː x d u / j oː x u d u` |
203
-
204
- ## הבין · فهم · يفهم (שיעור 8) — 2 לבדיקה
205
-
206
- | | גוף | ערבית | תעתיק | הגייה |
207
- |---|---|---|---|---|
208
- | ✓ | אני | افهم | אַפְהַם | `ʔ a f h a m` |
209
- | ✓ | אתה | تفهم | תִפְהַם | `t i f h a m` |
210
- | ✓ | את | تفهمي | תִפְהַמִי | `t i f h a m i` |
211
- | ✓ | הוא | يفهم | יִפְהַם | `j i f h a m` |
212
- | ✓ | היא | تفهم | תִפְהַם | `t i f h a m` |
213
- | ✎ | אנחנו | نفهم | נִפְהַם | `n i f h a m` |
214
- | ✎ | אתם | تفهمو | תִפְהַמוּ | `t i f h a m u` |
215
- | ✓ | הם | يفهمو | יִפְהַמוּ | `j i f h a m u` |
216
-
217
- ## חזר · رجع · يرجع (שיעור 8) — 1 לבדיקה
218
-
219
- | | גוף | ערבית | תעתיק | הגייה |
220
- |---|---|---|---|---|
221
- | ✓ | אני | ارجع | אַרְגַ'ע | `ʔ a r dʒ a ʕ` |
222
- | ✓ | אתה | ترجع | תִרְגַ'ע | `t i r dʒ a ʕ` |
223
- | ✓ | את | ترجعي | תִרְגַ'עִי | `t i r dʒ a ʕ i` |
224
- | ✓ | הוא | يرجع | יִרְגַ'ע | `j i r dʒ a ʕ` |
225
- | ✓ | היא | ترجع | תִרְגַ'ע | `t i r dʒ a ʕ` |
226
- | ✎ | אנחנו | نرجع | נִרְגַ'ע | `n i r dʒ a ʕ` |
227
- | ✓ | אתם | ترجعو | תִרְגַ'עוּ | `t i r dʒ a ʕ u` |
228
- | ✓ | הם | يرجعو | יִרְגַ'עוּ | `j i r dʒ a ʕ u` |
229
-
230
- ## הודה · شكر · يشكر (שיעור 9) — 6 לבדיקה
231
-
232
- | | גוף | ערבית | תעתיק | הגייה |
233
- |---|---|---|---|---|
234
- | ✎ | אני | اشكر | אַשְכֹּר | `ʔ a ʃ k u r` |
235
- | ✎ | אתה | تشكر | תֻשְכֹּר | `t u ʃ k u r` |
236
- | ✎ | את | تشكري | תֻשְכֹּרִי | `t u ʃ k u r i` |
237
- | ✓ | הוא | يشكر | יֻשְכֹּר | `j u ʃ k u r` |
238
- | ✎ | היא | تشكر | תֻשְכֹּר | `t u ʃ k u r` |
239
- | ✓ | אנחנו | نشكر | נֻשְכֹּר | `n u ʃ k u r` |
240
- | ✎ | אתם | تشكرو | תֻשְכֹּרוּ | `t u ʃ k u r u` |
241
- | ✎ | הם | يشكرو | יֻשְכֹּרוּ | `j u ʃ k u r u` |
242
-
243
- ## ירד · نزل · ينزل (שיעור 10) — 4 לבדיקה
244
-
245
- | | גוף | ערבית | תעתיק | הגייה |
246
- |---|---|---|---|---|
247
- | ✎ | אני | انزل | אַנְזֵל | `ʔ a n z i l / ʔ a n z a l` |
248
- | ✓ | אתה | تنزل | תִנְזֵל | `t i n z i l / t i n z a l` |
249
- | ✎ | את | تنزلي | תִנְזֵלִי | `t i n z i l i / t i n z a l i` |
250
- | ✓ | הוא | ينزل | יִנְזֵל | `j i n z i l / j i n z a l` |
251
- | ✓ | היא | تنزل | תִנְזֵל | `t i n z i l / t i n z a l` |
252
- | ✎ | אנחנו | ننزل | נִנְזֵל | `n i n z i l / n i n z a l` |
253
- | ✓ | אתם | تنزلو | תִנְזְלוּ | `t i n z l u / t i n z i l u / t i n z a l u` |
254
- | ✎ | הם | ينزلو | יִנְזְלוּ | `j i n z i l u / j i n z a l u / j i n z l u` |
255
-
256
- ## הגיע · وصل · يوصل (שיעור 10) — 1 לבדיקה
257
-
258
- | | גוף | ערבית | תעתיק | הגייה |
259
- |---|---|---|---|---|
260
- | ✓ | אני | اوصل | אַוְצַל | `ʔ a w sˤ a l / ʔ oː sˤ a l` |
261
- | ✓ | אתה | توصل | תוֹצַל | `t oː sˤ a l` |
262
- | ✓ | את | توصلي | תוֹצַלִי | `t oː sˤ a l i` |
263
- | ✓ | הוא | يوصل | יוֹצַל | `j oː sˤ a l` |
264
- | ✓ | היא | توصل | תוֹצַל | `t oː sˤ a l` |
265
- | ✓ | אנחנו | نوصل | נוֹצַל | `n oː sˤ a l` |
266
- | ✎ | אתם | توصلو | תוֹצַלוּ | `t oː sˤ a l u` |
267
- | ✓ | הם | يوصلو | יוֹצַלוּ | `j oː sˤ a l u` |
268
-
269
- ## צחק וחייך · ضحك · يضحك (שיעור 10) — 1 לבדיקה
270
-
271
- | | גוף | ערבית | תעתיק | הגייה |
272
- |---|---|---|---|---|
273
- | ✓ | אני | اضحك | אַדְֿחַכּ | `ʔ a dˤ ħ a k` |
274
- | ✓ | אתה | تضحك | תִדְֿחַכּ | `t i dˤ ħ a k` |
275
- | ✓ | את | تضحكي | תִדְֿחַכִּי | `t i dˤ ħ a k i` |
276
- | ✓ | הוא | يضحك | יִדְֿחַכּ | `j i dˤ ħ a k` |
277
- | ✓ | היא | تضحك | תִדְֿחַכּ | `t i dˤ ħ a k` |
278
- | ✓ | אנחנו | نضحك | נִדְֿחַכּ | `n i dˤ ħ a k` |
279
- | ✓ | אתם | تضحكو | תִדְֿחַכּוּ | `t i dˤ ħ a k u` |
280
- | ✎ | הם | يضحكو | יִדְֿחַכּוּ | `j i dˤ ħ a k u` |
281
-
282
- ## לבש · لبس · يلبس (שיעור 10) — 4 לבדיקה
283
-
284
- | | גוף | ערבית | תעתיק | הגייה |
285
- |---|---|---|---|---|
286
- | ✎ | אני | البس | אַלְבַּס | `ʔ a l b a s` |
287
- | ✓ | אתה | تلبس | תִלְבַּס | `t i l b a s` |
288
- | ✓ | את | تلبسي | תִלְבַּסִי | `t i l b a s i` |
289
- | ✓ | הוא | يلبس | יִלְבַּס | `j i l b a s` |
290
- | ✓ | היא | تلبس | תִלְבַּס | `t i l b a s` |
291
- | ✎ | אנחנו | نلبس | נִלְבַּס | `n i l b a s` |
292
- | ✎ | אתם | تلبسو | תִלְבַּסוּ | `t i l b a s u` |
293
- | ✎ | הם | يلبسو | יִלְבַּסוּ | `j i l b a s u` |