Remove obsolete test files and training scripts for phoneme CTC model on Palestinian Arabic recordings
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +6 -80
- Dockerfile +0 -12
- README.md +12 -973
- app/access.py +20 -56
- app/align.py +0 -316
- app/arabic_diacritics.py +0 -134
- app/archi.py +0 -182
- app/auth.py +72 -1170
- app/class_management.py +0 -208
- app/conjugation.py +0 -215
- app/future_draft.py +0 -94
- app/grammar.py +0 -184
- app/grok_mcp.py +0 -490
- app/keystore.py +0 -204
- app/lab.py +0 -378
- app/library.py +0 -78
- app/live.py +0 -181
- app/main.py +0 -0
- app/math_photo.py +2 -2
- app/model_settings.py +0 -159
- app/models.py +0 -107
- app/monitoring.py +0 -718
- app/nakdan.py +0 -113
- app/phones.py +0 -19
- app/phrases.py +0 -154
- app/powsm.py +0 -237
- app/prefetch.py +0 -35
- app/prompt_tts.py +0 -102
- app/push.py +0 -210
- app/reference.py +0 -89
- app/rules.py +0 -433
- app/scorer.py +0 -1150
- app/speech_evaluation.py +0 -28
- app/texts.py +0 -189
- app/tips.py +0 -125
- app/translit.py +0 -89
- app/tts.py +0 -492
- app/tutor.py +0 -534
- app/verb_game.py +0 -411
- app/workbook.py +0 -0
- arabic_splash/README.md +0 -12
- arabic_splash/app.py +0 -12
- arabic_splash/index.html +0 -20
- arabic_splash/requirements.txt +0 -2
- arabic_splash/static/logo.png +0 -3
- arabic_splash/static/splash.css +0 -12
- arabic_splash/static/splash.js +0 -9
- data/example.csv +0 -4
- data/minerva_metadata.json +0 -0
- docs/future-forms-draft.md +0 -293
.env.example
CHANGED
|
@@ -1,86 +1,12 @@
|
|
| 1 |
-
#
|
| 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 |
-
#
|
| 41 |
-
|
| 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 |
-
#
|
| 50 |
-
|
| 51 |
-
#
|
| 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 |
-
|
| 11 |
|
| 12 |
-
|
| 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 |
-
|
| 49 |
-
|
| 50 |
-
|
| 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.
|
| 56 |
|
| 57 |
-
|
| 58 |
|
| 59 |
-
|
| 60 |
-
```bash
|
| 61 |
-
pip install -r requirements-dev.txt && pytest -q
|
| 62 |
-
```
|
| 63 |
|
| 64 |
-
|
| 65 |
|
| 66 |
-
|
| 67 |
|
| 68 |
```bash
|
| 69 |
-
|
| 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 |
-
"""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
-
from .auth import
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 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({"
|
| 14 |
-
"
|
| 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 |
-
|
| 28 |
-
|
| 29 |
-
"""
|
| 30 |
-
user = a.user(token)
|
| 31 |
if user is None:
|
| 32 |
return None
|
| 33 |
email = user["email"].lower()
|
| 34 |
-
classes =
|
| 35 |
-
mine = [
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 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 |
-
|
| 54 |
-
if classes and not mine and not privileged and not entitlements:
|
| 55 |
return {**user, "denied": True, "permissions": []}
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 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 |
-
"""
|
| 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("
|
| 82 |
TIMEOUT = 10
|
| 83 |
-
CACHE_TTL = 300
|
| 84 |
-
|
| 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 |
-
|
| 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 |
-
|
| 152 |
-
|
| 153 |
-
self.
|
| 154 |
-
self.
|
| 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 |
-
|
| 174 |
-
if
|
| 175 |
-
return
|
| 176 |
-
|
| 177 |
-
self.
|
| 178 |
-
headers={"Authorization": f"Bearer {token}", "apikey": self.key, "User-Agent": "ihki/1.0"},
|
| 179 |
-
)
|
| 180 |
try:
|
| 181 |
-
with urllib.request.urlopen(
|
| 182 |
-
data = json.load(
|
| 183 |
-
except urllib.error.HTTPError as
|
| 184 |
-
if
|
| 185 |
return None
|
| 186 |
-
raise AuthUnavailable(f"supabase answered {
|
| 187 |
-
except (urllib.error.URLError, TimeoutError, ValueError) as
|
| 188 |
-
raise AuthUnavailable(str(
|
| 189 |
if not data.get("id"):
|
| 190 |
return None
|
| 191 |
user = {"id": data["id"], "email": data.get("email", "")}
|
| 192 |
with self._lock:
|
| 193 |
-
|
| 194 |
-
self._cache.clear()
|
| 195 |
-
self._cache[token] = (now + CACHE_TTL, user)
|
| 196 |
return user
|
| 197 |
|
| 198 |
-
def
|
| 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 |
-
|
| 288 |
-
|
| 289 |
-
headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret,
|
| 290 |
-
"Prefer": prefer, "User-Agent": "
|
| 291 |
-
try:
|
| 292 |
-
with urllib.request.urlopen(
|
| 293 |
-
return json.loads(
|
| 294 |
-
except urllib.error.HTTPError as
|
| 295 |
-
detail =
|
| 296 |
-
if
|
| 297 |
-
raise ClassConflict(detail) from
|
| 298 |
-
raise AuthUnavailable(f"supabase answered {
|
| 299 |
-
except (urllib.error.URLError, TimeoutError, ValueError) as
|
| 300 |
-
raise AuthUnavailable(str(
|
| 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 |
-
|
| 426 |
-
if
|
| 427 |
-
return
|
| 428 |
rows: list[dict] = []
|
| 429 |
if self.secret:
|
| 430 |
try:
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
with self._lock:
|
| 443 |
self._classes = (now + CLASSES_TTL, rows)
|
| 444 |
return rows
|
| 445 |
|
| 446 |
-
def
|
| 447 |
-
|
| 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 |
-
"
|
| 466 |
-
if
|
| 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(
|
| 474 |
-
raise KeyError(
|
| 475 |
self._classes = None
|
| 476 |
|
| 477 |
-
def delete_class(self,
|
| 478 |
-
self._admin(f"/rest/v1/classes?id=eq.{int(
|
| 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 |
-
|
|
|
|
| 962 |
now = time.monotonic()
|
| 963 |
with self._lock:
|
| 964 |
-
|
| 965 |
-
if
|
| 966 |
-
return
|
| 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,
|
| 970 |
value = rows[0] if rows else None
|
| 971 |
-
except AuthUnavailable as
|
| 972 |
-
if "
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 1232 |
-
|
| 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
|
| 9 |
-
|
| 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
|
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` |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|