Download app/main.py from wzender/mathly: direct link, hf CLI and curl.
- Browser
- Download file 167 kB
-
https://huggingface.co/spaces/wzender/mathly/resolve/main/app/main.py
- Command line
-
hf download hf://spaces/wzender/mathly/app/main.py
-
curl -L -o main.py https://huggingface.co/spaces/wzender/mathly/resolve/main/app/main.py
167 kB
| """HTTP service: serves the practice page and scores recordings. | |
| Run: uvicorn app.main:app --host 0.0.0.0 --port 7860 | |
| Env: MODEL_ID (default facebook/wav2vec2-xlsr-53-espeak-cv-ft, or a path to a fine-tuned checkpoint) | |
| SAWTAK_API_KEY (model-answer playback with the Raef voice, see app/tts.py) | |
| SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY (Google sign-in, see app/auth.py) | |
| TEACHER_EMAILS, SUPABASE_SECRET_KEY (the teacher's management panel, its classes and the lesson videos, see app/auth.py) | |
| TUTORS_EMAIL (more accounts that, like the teachers, flag a recording scored wrong, see app/auth.py) | |
| ANTHROPIC_API_KEY, TUTOR_MODEL (the permission-controlled AI tutor game יאללה נדבר, see app/tutor.py) | |
| SECRETS_MASTER_KEY (lets a developer replace the two API keys from the panel, see app/keystore.py) | |
| A local .env file is loaded if present; real environment variables win over it. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import io | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import subprocess | |
| import time | |
| import urllib.parse | |
| import uuid | |
| import zipfile | |
| from contextlib import asynccontextmanager | |
| from datetime import date, datetime, timedelta, timezone | |
| from pathlib import Path | |
| from zoneinfo import ZoneInfo | |
| from dotenv import load_dotenv | |
| from fastapi import BackgroundTasks, Body, Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile | |
| from fastapi.responses import HTMLResponse, Response | |
| from fastapi.staticfiles import StaticFiles | |
| from .arabic_diacritics import point_arabic | |
| from . import lab | |
| from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth | |
| from .keystore import (CREATE_APP_SECRETS, MAX_LEN as MAX_KEY_LEN, MIGRATE_APP_SECRETS, NAMES as KEY_NAMES, KeyRefused, | |
| check as check_key, load_keys) | |
| from .live import LiveConflict, LiveStore | |
| from .math_photo import (DAILY_PHOTOS, MAX_PHOTO, MEDIA_TYPES as PHOTO_TYPES, PhotoSolver, PhotoUnavailable, math_model, | |
| photo_client) | |
| from .math_skills import algebra9 | |
| from .phrases import CATEGORIES, GAMES, LESSONS, LEVELS, PHRASES, public_phrases | |
| from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3 | |
| from .reference import References | |
| from .rules import TOGGLES, check_detectable | |
| from .scorer import SAMPLE_RATE, Scorer, decode_audio, decode_audio_with_timing, frame_energy, load_scorer | |
| from .speech_evaluation import SpeechEvaluationService | |
| from .texts import read_line, text_phrases | |
| from .tts import LINE_VOICES, RAEF_VOICE_ID, VOICES, SawtakTTS, TTSUnavailable, load_tts | |
| from .tutor import (BY_ID, DAILY_LIMIT, MAX_TEXT, MAX_TURNS, SCENARIOS, Tutor, TutorLimit, | |
| DEFAULT_MODEL as DEFAULT_TUTOR_MODEL, TutorUnavailable, load_tutor, tutor_client) | |
| from .verb_game import VerbExerciseGenerator | |
| logging.basicConfig(level=logging.INFO) | |
| log = logging.getLogger("ihki") | |
| STATIC = Path(__file__).resolve().parent.parent / "static" | |
| load_dotenv(STATIC.parent / ".env") | |
| # A class topic's opening screen, by the path it is served at; "/" is Arabic. | |
| SUBJECTS = { | |
| "arabic": {"__TITLE__": "אהלא וסהלא", "__SPLASH_LOGO__": "/static/logo-intro.svg", | |
| "__SPLASH_ALT__": "אהלא וסהלא, בית הספר ללימוד השפה הערבית", "__START_LABEL__": "יאללה נבלש", | |
| "__APP_NAME__": "אהלא וסהלא", "__THEME_COLOR__": "#4B6A2C", "__MANIFEST__": "/static/manifest.webmanifest"}, | |
| "math": {"__TITLE__": "מתמטיקה בכיף", "__SPLASH_LOGO__": "/static/logo-math.svg", | |
| "__SPLASH_ALT__": "מתמטיקה בכיף, המורה הפרטי למתמטיקה", "__START_LABEL__": "יאללה נתחיל", | |
| "__APP_NAME__": "מתמטיקה בכיף", "__THEME_COLOR__": "#000000", "__MANIFEST__": "/math/manifest.webmanifest"}, | |
| } | |
| # A subject as an admin and a class keep it (in Hebrew, as the panel shows it), by the path its pages are served at. | |
| SUBJECT_LABELS = {"arabic": "ערבית", "math": "מתמטיקה"} | |
| # What the installed app of a subject other than Arabic changes in /static/manifest.webmanifest: its own name and | |
| # start page, so a math student's home-screen icon opens /math. The scope stays "/", the service worker's. | |
| MANIFESTS = {"math": {"id": "/math", "name": "מתמטיקה בכיף", "short_name": "מתמטיקה בכיף", "description": "תרגול מתמטיקה", | |
| "start_url": "/math", "theme_color": "#000000", "background_color": "#000000"}} | |
| def subject_code(label: object) -> str: | |
| """The path of a stored subject ("ערבית" -> "arabic"); "" for none, and for one that is not in SUBJECT_LABELS.""" | |
| label = " ".join(str(label or "").split()) | |
| return next((code for code, name in SUBJECT_LABELS.items() if name == label), "") | |
| MODEL_ID = os.environ.get("MODEL_ID", "facebook/wav2vec2-xlsr-53-espeak-cv-ft") | |
| LOCAL_TZ = ZoneInfo("Asia/Jerusalem") # a day of practice is the students' calendar day | |
| MAX_UPLOAD = 2_000_000 # bytes; 15 s of opus is ~100 KB | |
| MAX_BACKUP_UPLOAD = 250_000_000 | |
| EMAIL = re.compile(r"[^@\s,;<>]+@[^@\s,;<>]+\.[^@\s,;<>]+") | |
| MAX_NICKNAME = 40 | |
| ALL_LESSONS = [l["id"] for l in LESSONS] # with the lessons still to come: a class may take them ahead | |
| READY_LESSONS = [l["id"] for l in LESSONS if not l.get("soon")] # the lessons the path shows: a video is added to one of them | |
| MAX_TITLE = 80 | |
| # a Vimeo link: the video's number and, for an unlisted one, its hash (vimeo.com/1233611297/d46ac373c8?share=copy, | |
| # or the player's own player.vimeo.com/video/1233611297?h=d46ac373c8) | |
| VIMEO = re.compile(r"(?:https?://)?(?:www\.)?(?:vimeo\.com/(?:video/)?|player\.vimeo\.com/video/)(\d{6,12})" | |
| r"(?:/([0-9a-f]{6,20})(?![0-9a-z]))?(?:[/?#]\S*)?", re.I) | |
| # a YouTube link: the video's 11 characters, from any of the forms its links take (youtube.com/watch?v=dQw4w9WgXcQ&t=5, | |
| # youtu.be/dQw4w9WgXcQ?si=..., youtube.com/shorts/..., /live/..., /embed/...) | |
| YOUTUBE = re.compile(r"(?:https?://)?(?:(?:www\.|m\.|music\.)?youtube\.com/(?:watch/?\?(?:\S*?&)?v=|(?:shorts|live|embed|v)/)" | |
| r"|(?:www\.)?youtube-nocookie\.com/embed/|youtu\.be/)([\w-]{11})(?![\w-])(?:[/?#&]\S*)?", re.I) | |
| YOUTUBE_KEPT = "https://www.youtube.com/watch?v=" | |
| CREATE_VIDEOS = ("create table public.videos (id bigint generated always as identity primary key, lesson text not null, " | |
| "title text not null, url text not null, pos int, ord int not null default 0, " | |
| "created_at timestamptz not null default now()); alter table public.videos enable row level security;") | |
| MIGRATE_VIDEOS = "alter table public.videos add column if not exists pos int, add column if not exists ord int not null default 0;" | |
| # The daily videos: a library by level; a class gives a video its day (the class's `dailies`: [{"video", "date"}]) | |
| # The library: other videos, which every student browses, whatever the level and the class (its `level` is empty) | |
| # The library's videos stand in the order the teacher gave them (`ord`; one never moved has none, and comes after those) | |
| CREATE_LEVEL_VIDEOS = {t: (f"create table public.{t} (id bigint generated always as identity primary key, level text not null, " | |
| f"title text not null, url text not null, {'ord int, ' if t == 'library' else ''}created_at timestamptz not null default now()); " | |
| f"alter table public.{t} enable row level security;") for t in ("dailies", "library")} | |
| MIGRATE_LIBRARY = "alter table public.library add column if not exists ord int;" | |
| LEVEL_IDS = [v["id"] for v in LEVELS] | |
| LESSON_LEVEL = {l["id"]: l["level"] for l in LESSONS} | |
| MAX_DAILIES = 400 | |
| LESSON_TOPICS = {l: sum(c["lesson"] == l for c in CATEGORIES) for l in READY_LESSONS} # a video's place is 0..this many | |
| def placed(rows: list[dict], lesson: str) -> list[dict]: | |
| """A lesson's videos, or its texts, in their order on the path, each with its place: `pos` of the lesson's own | |
| topics stand before it (one without a place, or past the topics the lesson has now, stands after them all), and | |
| `ord` is its turn among the videos and texts of that place.""" | |
| n = LESSON_TOPICS[lesson] | |
| mine = [{**v, "pos": n if v.get("pos") is None else max(0, min(n, v["pos"])), "ord": v.get("ord") or 0} | |
| for v in rows if v["lesson"] == lesson] | |
| return sorted(mine, key=lambda v: (v["pos"], v["ord"], v["id"])) | |
| # A text the teacher added (see texts.py) is a topic, "t<its id>", and each of its lines a phrase whose id is past the | |
| # bank's: TEXT_BASE + the text's id * TEXT_SPAN + the line's number, so it holds as texts come and go. | |
| TEXT_BASE, TEXT_SPAN = 1_000_000, 1000 | |
| MAX_LINES, MAX_LINE = 200, 300 | |
| # Historical dialogues retain their speaker names even though every line is now played by Raef. | |
| VOICE_NAMES = ["אמיר", "ראאף", "חנאן"] | |
| CREATE_TEXTS = ("create table public.texts (id bigint generated always as identity primary key, lesson text not null, " | |
| "title text not null, voices text[] not null default '{}', lines jsonb not null, pos int, " | |
| "ord int not null default 0, created_at timestamptz not null default now()); " | |
| "alter table public.texts enable row level security;") | |
| _BUILT: dict[tuple[int, int], tuple[list[dict], list[str]]] = {} | |
| def built(row: dict) -> tuple[list[dict], list[str]]: | |
| """A text's lines as phrases, and its words that were read by rule (see `texts.text_phrases`); built once.""" | |
| key = (row["id"], hash(str(row["lines"]))) | |
| if key not in _BUILT: | |
| _BUILT[key] = text_phrases(f"t{row['id']}", row["lines"]) | |
| return _BUILT[key] | |
| def text_bank(rows: list[dict]) -> dict[int, dict]: | |
| """{phrase id: phrase} of the texts' lines.""" | |
| return {TEXT_BASE + r["id"] * TEXT_SPAN + k: p for r in rows for k, p in enumerate(built(r)[0])} | |
| # The topics the student writes in (see workbook.py): how many sentences each asks for. What is written is kept in | |
| # the Supabase table `writings`, a row per student and topic. | |
| WRITE_TOPICS = {c["id"]: c["write"]["lines"] for c in CATEGORIES if "write" in c} | |
| CREATE_WRITINGS = ("create table public.writings (user_id uuid not null default auth.uid() references auth.users (id) on delete cascade, " | |
| "topic text not null, lines jsonb not null, updated_at timestamptz not null default now(), primary key (user_id, topic)); " | |
| "alter table public.writings enable row level security; grant select, insert, update on public.writings to authenticated; " | |
| 'create policy "students read their own writings" on public.writings for select to authenticated using (user_id = auth.uid()); ' | |
| 'create policy "students add their own writings" on public.writings for insert to authenticated with check (user_id = auth.uid()); ' | |
| 'create policy "students change their own writings" on public.writings for update to authenticated ' | |
| "using (user_id = auth.uid()) with check (user_id = auth.uid());") | |
| # A tutor's flags (see auth.py): a recording that was said right and scored wrong, with what the scorer made of it. | |
| # `ipa` is what was said, so `audio,ipa` of these rows is training data as train/finetune.py reads it. | |
| CREATE_FLAGS = ("create table public.flags (id bigint generated always as identity primary key, email text not null, " | |
| "cat text not null, phrase text not null, he text not null, ar text not null, ipa text not null, heard_ipa text not null, " | |
| "score int not null, unrelated boolean not null default false, note text not null default '', result jsonb not null, " | |
| "model text not null, version text not null default '', audio text, status text not null default 'pending', " | |
| "category text, reviewed_by text, reviewed_at timestamptz, review_note text not null default '', " | |
| "false_issues jsonb not null default '[]'::jsonb, created_at timestamptz not null default now()); " | |
| "alter table public.flags enable row level security; " | |
| "insert into storage.buckets (id, name) values ('flags', 'flags') on conflict (id) do nothing;") | |
| MIGRATE_FLAGS = ("alter table public.flags add column if not exists status text not null default 'pending', " | |
| "add column if not exists category text, add column if not exists reviewed_by text, " | |
| "add column if not exists reviewed_at timestamptz, add column if not exists review_note text not null default '', " | |
| "add column if not exists false_issues jsonb not null default '[]'::jsonb;") | |
| FLAG_STATUSES = frozenset({"pending", "confirmed", "rejected", "fixed"}) | |
| FLAG_CATEGORIES = frozenset({"recognition", "valid_variant", "alternative_answer", "noise", "other"}) | |
| CREATE_CALIBRATIONS = ("create table public.calibrations (id bigint generated always as identity primary key, " | |
| "expected text not null, heard text not null, floor numeric not null default 70, " | |
| "scope text not null default 'global', phrase text not null default '', evidence jsonb not null default '{}', " | |
| "model text not null default '', status text not null default 'draft', proposed_by text not null, " | |
| "reviewed_by text, reviewed_at timestamptz, created_at timestamptz not null default now()); " | |
| "alter table public.calibrations enable row level security;") | |
| CALIBRATION_STATUSES = frozenset({"draft", "applied", "rejected"}) | |
| # The teacher's switches over the rules of pronunciation-rules.md (rules.TOGGLES): a rule with no row is on. | |
| CREATE_SCORING_RULES = ("create table public.scoring_rules (id text primary key, active boolean not null default true, " | |
| "updated_by text not null default '', updated_at timestamptz not null default now()); " | |
| "alter table public.scoring_rules enable row level security;") | |
| # The students the teacher gave the tutor's rank (see auth.py): theirs in every class, so it is kept by email. | |
| CREATE_TUTORS = ("create table public.tutors (email text primary key, granted_by text not null default '', " | |
| "created_at timestamptz not null default now()); alter table public.tutors enable row level security;") | |
| CREATE_USER_ACCESS = ("create table public.user_access (user_id uuid primary key references auth.users(id) on delete cascade, " | |
| "email text not null, roles text[] not null default array['student']::text[], " | |
| "entitlements text[] not null default '{}'::text[], subject text not null default '', " | |
| "updated_at timestamptz not null default now(), " | |
| "updated_by uuid references auth.users(id), " | |
| "constraint valid_roles check (roles <@ array['student','teacher','flagger','admin','developer']::text[]), " | |
| "constraint valid_entitlements check (entitlements <@ array['practice','games','tutor','path','library']::text[])); " | |
| "create unique index user_access_email_idx on public.user_access (lower(email)); " | |
| "alter table public.user_access enable row level security; " | |
| "create table public.access_audit (id bigint generated always as identity primary key, " | |
| "target_user_id uuid not null, changed_by uuid not null, old_access jsonb not null, " | |
| "new_access jsonb not null, created_at timestamptz not null default now()); " | |
| "alter table public.access_audit enable row level security;") | |
| ROLES = frozenset({"student", "teacher", "flagger", "admin", "developer"}) | |
| CREATE_SYSTEM_EVENTS = ("create table public.system_events (id bigint generated always as identity primary key, " | |
| "kind text not null check (kind in ('exercise','game','permission','content')), " | |
| "actor_id uuid references auth.users(id) on delete set null, actor_email text not null default '', " | |
| "label text not null default '', details jsonb not null default '{}'::jsonb, " | |
| "created_at timestamptz not null default now()); " | |
| "create index system_events_created_at_idx on public.system_events (created_at desc); " | |
| "alter table public.system_events enable row level security;") | |
| MIGRATE_USER_ACCESS = ("alter table public.user_access add column if not exists subject text not null default ''; " | |
| "alter table public.user_access drop constraint if exists valid_entitlements; " | |
| "update public.user_access set entitlements = array_append(entitlements, 'practice') " | |
| "where 'path' = any(entitlements) and not ('practice' = any(entitlements)); " | |
| "alter table public.user_access add constraint valid_entitlements check " | |
| "(entitlements <@ array['practice','games','tutor','path','library']::text[]);") | |
| ENTITLEMENTS = frozenset({"practice", "games", "tutor", "path", "library"}) | |
| DEFAULT_ENTITLEMENTS = ENTITLEMENTS - {"tutor"} # the paid AI tutor is always an explicit grant | |
| ROLE_PERMISSIONS = { | |
| "student": frozenset(), | |
| "teacher": frozenset({"exercises.use_all", "students.view", "games.use", "path.use", "exercises.use", "library.view"}), | |
| "flagger": frozenset({"flags.create"}), | |
| "admin": frozenset({"exercises.use_all", "students.view", "management.access", "users.manage", | |
| "classes.manage", "content.manage", "flags.review", "games.use", "path.use", | |
| "exercises.use", "library.view"}), | |
| "developer": frozenset({"*"}), | |
| } | |
| ENTITLEMENT_PERMISSIONS = {"practice": {"practice.use", "exercises.use"}, "games": {"games.use"}, | |
| "tutor": {"tutor.use"}, "path": {"path.use"}, "library": {"library.view"}} | |
| MAX_NOTE = 500 | |
| MIGRATE_ATTEMPT_ISSUES = ("alter table public.attempts add column if not exists issues jsonb not null default '[]'::jsonb, " | |
| "add column if not exists model text not null default '', " | |
| "add column if not exists version text not null default '';" ) | |
| # The lab's recordings (see lab.py): an item of the diagnostic list, who said it, and whether it was said right | |
| # (`label` "correct") or with one planted mistake (`label` "mistake", `mistake` {"index", "expected"}: the sound of the | |
| # item's first reading that was said wrong). `flagged` is the sounds the scorer marked down, as "expected>heard". | |
| CREATE_LAB = ("create table public.lab_samples (id bigint generated always as identity primary key, email text not null, " | |
| "item text not null, ar text not null, ipa text not null, speaker text not null default '', label text not null, " | |
| "mistake jsonb, device text not null default '', score int not null, heard_ipa text not null, " | |
| "flagged jsonb not null default '[]'::jsonb, result jsonb not null, model text not null, " | |
| "version text not null default '', audio text, created_at timestamptz not null default now()); " | |
| "alter table public.lab_samples enable row level security; " | |
| "insert into storage.buckets (id, name) values ('lab', 'lab') on conflict (id) do nothing;") | |
| MAX_SPEAKER = 254 # an email address, the longest a speaker is | |
| # The lab's automatic test: the list said by the TTS voices, each kept as a speaker of its own ("TTS ראאף") | |
| LAB_TTS_VOICES = {**{name.translate({c: None for c in range(0x591, 0x5c8)}): v for name, v in VOICES.items()}, **LINE_VOICES} | |
| AUDIO_EXT = {"webm": "webm", "ogg": "ogg", "mp4": "m4a", "mpeg": "mp3", "wav": "wav", "x-wav": "wav"} # by the upload's type | |
| TOPIC_LESSON = {c["id"]: c["lesson"] for c in CATEGORIES} # a topic's lesson: homework is a topic of the class's lessons | |
| MIGRATE_CLASSES = ("alter table public.classes add column if not exists nicknames jsonb not null default '{}', " | |
| "add column if not exists lessons text[] not null default '{}', " | |
| "add column if not exists homework text not null default '', add column if not exists due date, " | |
| "add column if not exists homeworks jsonb not null default '[]', " | |
| "add column if not exists dailies jsonb not null default '[]', " | |
| "add column if not exists subject text not null default 'ערבית', " | |
| "add column if not exists teachers text[] not null default '{}'; " | |
| "update public.classes set subject = 'ערבית' where subject = '';") | |
| MAX_HOMEWORKS = 30 | |
| def is_math(c: dict) -> bool: | |
| return subject_code(c.get("subject")) == "math" | |
| def lessons_of(c: dict) -> list[str]: | |
| """A class's Arabic lessons in the course's order; a class saved before classes had lessons has every one. | |
| A math class has none: its `lessons` column holds the math units it studies (see `units_of`).""" | |
| if is_math(c): | |
| return [] | |
| return [l for l in ALL_LESSONS if l in c["lessons"]] or ALL_LESSONS | |
| def units_of(c: dict) -> list[str]: | |
| """A math class's units of the path (app/math_algebra9.json), in the path's order; none chosen means every one.""" | |
| ids = [u["id"] for u in algebra9()["units"]] | |
| return [u for u in ids if u in (c.get("lessons") or [])] or ids | |
| def class_lessons(c: dict) -> list[str]: | |
| """What a class studies, as the page gets it: math units for a math class, Arabic lessons for any other.""" | |
| return units_of(c) if is_math(c) else lessons_of(c) | |
| def homework_of(c: dict, topics: dict[str, str] = TOPIC_LESSON) -> dict: | |
| """A class's homework: the topic it practises now (a category id, e.g. the conversation "l2-talk"; "" when none | |
| is set) and the day it is due (ISO date, or None). A topic of a lesson the class no longer studies, or one the | |
| course no longer has, is no homework. `topics` is every topic's lesson: the course's, and the teacher's texts'. | |
| A class may have several, one topic each: "homeworks" is all of them, [{"topic", "due"}], in the order of the | |
| class's lessons, and "homework" and "due" the first (a class saved before it could have several has only that one).""" | |
| mine = lessons_of(c) | |
| listed = c.get("homeworks") or ([{"topic": c["homework"], "due": c.get("due")}] if c.get("homework") else []) | |
| ok = [{"topic": h["topic"], "due": h.get("due") or None} for h in listed | |
| if isinstance(h, dict) and topics.get(h.get("topic")) in mine] | |
| ok.sort(key=lambda h: mine.index(topics[h["topic"]])) | |
| first = ok[0] if ok else {"topic": "", "due": None} | |
| return {"homework": first["topic"], "due": first["due"], "homeworks": ok} | |
| def dailies_of(c: dict, library: list[dict]) -> list[dict]: | |
| """A class's daily videos, by their days: [{"id", "title", "embed": the player's address, "date": ISO}]. A video | |
| the library no longer has, or one of a level the class does not study, is left out.""" | |
| levels = {LESSON_LEVEL[l] for l in lessons_of(c)} | |
| by = {v["id"]: v for v in library if v["level"] in levels} | |
| mine = [{"id": v["id"], "title": v["title"], "embed": video_embed(v["url"]), "date": d["date"]} | |
| for d in c.get("dailies") or [] if isinstance(d, dict) and d.get("date") and (v := by.get(d.get("video")))] | |
| return sorted(mine, key=lambda d: (d["date"], d["id"])) | |
| def vimeo_link(text: str) -> str | None: | |
| """A Vimeo link as it is kept: "https://vimeo.com/<number>", with "/<hash>" for an unlisted video (the sharing | |
| link's tracking parameters are dropped). None when the text is not a link to a Vimeo video.""" | |
| m = VIMEO.fullmatch(text.strip()) | |
| if not m: | |
| return None | |
| h = m[2] or next(iter(urllib.parse.parse_qs(urllib.parse.urlsplit(text.strip()).query).get("h", [])), "") | |
| return f"https://vimeo.com/{m[1]}" + (f"/{h.lower()}" if re.fullmatch(r"[0-9a-fA-F]{6,20}", h) else "") | |
| def youtube_link(text: str) -> str | None: | |
| """A YouTube link as it is kept: "https://www.youtube.com/watch?v=<id>" (the playlist, the time and the sharing | |
| parameters are dropped). None when the text is not a link to a YouTube video.""" | |
| m = YOUTUBE.fullmatch(text.strip()) | |
| return YOUTUBE_KEPT + m[1] if m else None | |
| def video_link(text: str) -> str | None: | |
| """A link to a video as it is kept, of Vimeo or of YouTube. None when the text is neither.""" | |
| return vimeo_link(text) or youtube_link(text) | |
| def video_embed(url: str) -> str: | |
| """The player's address of a kept link (see `video_link`), for the page's frame.""" | |
| if url.startswith(YOUTUBE_KEPT): # without cookies until the video is played, and with no other channels' videos at its end | |
| return f"https://www.youtube-nocookie.com/embed/{url.removeprefix(YOUTUBE_KEPT)}?rel=0" | |
| number, _, h = url.removeprefix("https://vimeo.com/").partition("/") | |
| return f"https://player.vimeo.com/video/{number}?" + urllib.parse.urlencode({ | |
| **({"h": h} if h else {}), "dnt": 1, | |
| "vimeo_logo": 0, "title": 0, "byline": 0, "portrait": 0, | |
| }) | |
| def class_members(text: str) -> tuple[list[str], dict[str, str]]: | |
| """The panel's student list: a line per student, "email nickname" (the nickname is optional). A line with | |
| several addresses and nothing else, e.g. pasted "a@x.com, b@x.com", is those students without nicknames. | |
| Gives the emails (lower-case, sorted) and {email: nickname}; text that is not an address raises ValueError.""" | |
| emails, nicks = set(), {} | |
| for line in re.split(r"[\r\n]+", text): | |
| found = EMAIL.findall(line) | |
| rest = re.sub(r"\s+", " ", EMAIL.sub(" ", line)).strip(" ,;:-–<>()\t") | |
| if not found and rest or len(found) > 1 and rest.strip(" ,;"): | |
| raise ValueError(line.strip()) | |
| for e in found: | |
| emails.add(e.lower()) | |
| if len(found) == 1 and rest: | |
| nicks[found[0].lower()] = rest[:MAX_NICKNAME] | |
| return sorted(emails), nicks | |
| def build_version() -> str: | |
| """Debug build stamp: short content hash of the served code + newest file time (UTC). The hash also stamps the | |
| page's own scripts and styles (?v=), so a change to one of them reaches the service worker's cache-first copy.""" | |
| files = (sorted(Path(__file__).resolve().parent.glob("*.py")) + [STATIC / "index.html"] | |
| + sorted(STATIC.glob("*.js")) + sorted(STATIC.glob("*.css"))) | |
| h = hashlib.sha1() | |
| for f in files: | |
| h.update(f.read_bytes()) | |
| newest = max(f.stat().st_mtime for f in files) | |
| return f"{h.hexdigest()[:7]} · {time.strftime('%Y-%m-%d %H:%M', time.gmtime(newest))} UTC" | |
| def build_metadata() -> dict[str, str]: | |
| """Deployment details for the teacher panel, with useful local-development fallbacks.""" | |
| files = sorted(Path(__file__).resolve().parent.glob("*.py")) + [STATIC / "index.html"] | |
| h = hashlib.sha1() | |
| for f in files: | |
| h.update(f.read_bytes()) | |
| newest = max(f.stat().st_mtime for f in files) | |
| commit = next((os.environ.get(name, "").strip() for name in | |
| ("GIT_COMMIT", "COMMIT_SHA", "SOURCE_VERSION", "SPACE_REVISION") | |
| if os.environ.get(name, "").strip()), "") | |
| message = os.environ.get("GIT_COMMIT_MESSAGE", "").strip() | |
| if not commit or not message: | |
| try: | |
| git = subprocess.run( | |
| ["git", "log", "-1", "--format=%H%n%s"], cwd=STATIC.parent, check=True, | |
| capture_output=True, text=True, timeout=2, | |
| ).stdout.splitlines() | |
| if not commit and git: | |
| commit = git[0].strip() | |
| if not message and len(git) > 1: | |
| message = git[1].strip() | |
| except (OSError, subprocess.SubprocessError): | |
| pass | |
| return { | |
| "version": os.environ.get("APP_VERSION", "").strip() or h.hexdigest()[:7], | |
| "updated_at": os.environ.get("BUILD_DATE", "").strip() | |
| or datetime.fromtimestamp(newest, timezone.utc).isoformat(timespec="seconds"), | |
| "commit": commit or "unknown", | |
| "commit_message": message, | |
| } | |
| def utc(stamp: str) -> datetime: | |
| """A Supabase timestamp. The seconds part only: the fraction's length varies, which Python 3.10 does not parse.""" | |
| return datetime.strptime(stamp[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc) | |
| def summary(rows: list[dict], texts: dict[int, dict] | None = None) -> dict: | |
| """One student's attempts summed up: best score and tries per phrase id, recordings (all, and of the last | |
| 7 days), days practised, time of the last one. Every non-empty scored recording is a try and completes its | |
| exercise, including one the recognizer labels unrelated. Those are still counted per phrase id in "missed" | |
| and all together in "unmatched" as diagnostics.""" | |
| shown = {c["id"] for c in CATEGORIES + GAMES} | |
| ids = {(p["cat"], p["he"]): i for i, p in enumerate(PHRASES) if p["cat"] in shown} | |
| ids.update({(p["cat"], p["key"]): i for i, p in (texts or {}).items()}) # the lines of the teacher's texts (see `text_bank`) | |
| best: dict[int, int] = {} | |
| tries: dict[int, int] = {} | |
| missed: dict[int, int] = {} | |
| unmatched = 0 | |
| days, week, last = set(), 0, None | |
| since = datetime.now(timezone.utc) - timedelta(days=7) | |
| for r in rows: | |
| at = utc(r["created_at"]) | |
| days.add(at.astimezone(LOCAL_TZ).date()) | |
| week += at >= since | |
| last = max(last, at) if last else at | |
| i = ids.get((r["cat"], r["phrase"])) | |
| if r.get("unrelated"): | |
| unmatched += 1 | |
| if i is not None: | |
| missed[i] = missed.get(i, 0) + 1 | |
| if i is not None: # a sentence the page no longer lists still counts as a recording | |
| best[i] = max(best.get(i, 0), r["score"]) | |
| tries[i] = tries.get(i, 0) + 1 | |
| return {"best": best, "tries": tries, "missed": missed, "unmatched": unmatched, "recordings": len(rows), "days": len(days), "week": week, | |
| "last": last.isoformat() if last else None} | |
| def weekly_practice_days(rows: list[dict]) -> int: | |
| """Distinct Jerusalem calendar days practised since Monday of the current week.""" | |
| today = datetime.now(LOCAL_TZ).date() | |
| monday = today - timedelta(days=today.weekday()) | |
| return len({utc(r["created_at"]).astimezone(LOCAL_TZ).date() for r in rows | |
| if monday <= utc(r["created_at"]).astimezone(LOCAL_TZ).date() <= today}) | |
| def practice_streak(rows: list[dict]) -> dict: | |
| """Current and longest runs of consecutive Jerusalem calendar days. | |
| Yesterday may anchor the current run: a student does not lose the visible streak | |
| before they have had a chance to practise today. | |
| """ | |
| days = sorted({utc(r["created_at"]).astimezone(LOCAL_TZ).date() for r in rows}) | |
| if not days: | |
| return {"current": 0, "best": 0, "last": None} | |
| longest = run = 1 | |
| for previous, day in zip(days, days[1:]): | |
| run = run + 1 if day == previous + timedelta(days=1) else 1 | |
| longest = max(longest, run) | |
| today = datetime.now(LOCAL_TZ).date() | |
| current = 0 | |
| if days[-1] >= today - timedelta(days=1): | |
| current = 1 | |
| for i in range(len(days) - 1, 0, -1): | |
| if days[i - 1] != days[i] - timedelta(days=1): | |
| break | |
| current += 1 | |
| return {"current": current, "best": longest, "last": days[-1].isoformat()} | |
| def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None, | |
| auth: SupabaseAuth | None = None, tutor: Tutor | None = None) -> FastAPI: | |
| async def lifespan(app: FastAPI): | |
| app.state.scorer = scorer or load_scorer(MODEL_ID) | |
| app.state.auth = auth or load_auth() | |
| app.state.keys = load_keys(app.state.auth) # the API keys a developer set in the panel, else the environment's | |
| app.state.tts = tts or load_tts(app.state.keys.get("SAWTAK_API_KEY")) | |
| app.state.tutor = tutor or load_tutor(app.state.keys.get("ANTHROPIC_API_KEY"), app.state.keys.get("TUTOR_MODEL")) | |
| # the playback voices double as the native baseline of the scores (see reference.py) | |
| app.state.refs = References(app.state.scorer, app.state.tts, list(VOICES.values())) if app.state.tts else None | |
| if app.state.auth and hasattr(app.state.auth, "calibrations"): | |
| try: | |
| rows = app.state.auth.calibrations("applied") | |
| app.state.scorer.set_calibrations([r for r in rows if not r.get("model") or r.get("model") == MODEL_ID]) | |
| except AuthUnavailable as e: | |
| log.warning("calibrations not loaded: %s", e) | |
| if app.state.auth and hasattr(app.state.auth, "scoring_rules"): | |
| try: | |
| reload_scoring_rules(app.state.auth) | |
| except AuthUnavailable as e: # no table yet: every rule stays on | |
| log.warning("scoring rule switches not loaded: %s", e) | |
| yield | |
| app = FastAPI(title="ihki", lifespan=lifespan) | |
| app.state.verb_game = VerbExerciseGenerator() | |
| app.state.live = LiveStore() | |
| app.mount("/static", StaticFiles(directory=STATIC), name="static") | |
| def signed_in(request: Request) -> dict | None: | |
| """The caller's user; 401 when sign-in is configured and the call carries no valid session token.""" | |
| a: SupabaseAuth | None = app.state.auth | |
| if a is None: | |
| return None | |
| scheme, _, token = request.headers.get("authorization", "").partition(" ") | |
| try: | |
| user = a.user(token.strip()) if scheme.lower() == "bearer" and token.strip() else None | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"sign-in check failed: {e}") | |
| if user is None: | |
| raise HTTPException(401, "sign in required") | |
| email = user["email"].lower() | |
| try: | |
| classes = a.classes() | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"class list not available: {e}") | |
| mine = [c for c in classes if email in c["emails"]] # a student may be in several classes | |
| teaches = [c for c in classes if email in (c.get("teachers") or [])] | |
| roles, entitlements, assigned = {"student"}, set(DEFAULT_ENTITLEMENTS), None | |
| if hasattr(a, "access_for"): | |
| try: | |
| assigned = a.access_for(user["id"]) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"permissions not available: {e}") | |
| if assigned: | |
| roles = set(assigned.get("roles") or ["student"]) & ROLES | |
| entitlements = set(assigned.get("entitlements") or []) & ENTITLEMENTS | |
| # Legacy environment assignments retain their old abilities while deployments move to user_access. | |
| if email in getattr(a, "teachers", ()): | |
| roles.update({"teacher", "admin", "flagger"}) | |
| if email in getattr(a, "tutors", ()): | |
| roles.add("flagger") | |
| if email in getattr(a, "developers", ()): | |
| roles.add("developer") | |
| if "flagger" not in roles: | |
| try: | |
| if email in (a.ranked() or ()): | |
| roles.add("flagger") | |
| except AuthUnavailable as e: | |
| log.warning("tutors not read: %s", e) | |
| privileged = roles - {"student"} | |
| if classes and not mine and not privileged and not entitlements: | |
| raise HTTPException(403, "this account is not on the list") | |
| permissions = set().union(*(ROLE_PERMISSIONS[r] for r in roles)) | |
| permissions.update(*[ENTITLEMENT_PERMISSIONS[e] for e in entitlements]) | |
| return {**user, "token": token.strip(), "roles": sorted(roles), "entitlements": sorted(entitlements), | |
| "subject": str((assigned or {}).get("subject") or ""), | |
| "permissions": sorted(permissions), "teacher": bool(roles & {"teacher", "admin", "developer"}), | |
| "tutor": "flags.create" in permissions or "*" in permissions, "classes": mine, "teaches": teaches} | |
| def require(permission: str): | |
| def allowed(user: dict | None = Depends(signed_in)) -> SupabaseAuth: | |
| if app.state.auth is None: # authentication is optional in local/open deployments | |
| return app.state.auth | |
| permissions = set(user.get("permissions", ())) if user else set() | |
| if "*" not in permissions and permission not in permissions: | |
| raise HTTPException(403, f"missing permission: {permission}") | |
| return app.state.auth | |
| return allowed | |
| admin_only = require("management.access") | |
| lab_only = require("lab.access") | |
| teacher_only = admin_only # compatibility name for the management endpoints below | |
| members = [Depends(signed_in)] | |
| def managed_classes(a: SupabaseAuth, actor: dict | None, fresh: bool = False) -> list[dict]: | |
| """Classes visible in management: developers see all; admins only their own subject.""" | |
| classes = a.classes(fresh=fresh) | |
| if not actor or "developer" in actor.get("roles", ()) or not hasattr(a, "access_for"): | |
| return classes | |
| subject = str(actor.get("subject") or "").strip() | |
| return [c for c in classes if subject and str(c.get("subject") or "").strip() == subject] | |
| def text_rows() -> list[dict]: | |
| """The teacher's texts in the lessons the path shows. None without sign-in, or when they cannot be read.""" | |
| a: SupabaseAuth | None = app.state.auth | |
| if a is None: | |
| return [] | |
| try: | |
| return [r for r in a.texts() if r["lesson"] in READY_LESSONS] | |
| except AuthUnavailable as e: | |
| log.warning("texts not read: %s", e) | |
| return [] | |
| def topics() -> dict[str, str]: | |
| """Every topic's lesson: the course's topics, and the teacher's texts.""" | |
| return {**TOPIC_LESSON, **{f"t{r['id']}": r["lesson"] for r in text_rows()}} | |
| def daily_rows() -> list[dict]: | |
| """The library of daily videos. None without sign-in, or when it cannot be read.""" | |
| a: SupabaseAuth | None = app.state.auth | |
| if a is None: | |
| return [] | |
| try: | |
| return a.level_videos("dailies") | |
| except AuthUnavailable as e: | |
| log.warning("daily videos not read: %s", e) | |
| return [] | |
| def phrase_of(phrase_id: int) -> dict: | |
| """A phrase of the bank, or a line of a teacher's text; 404 for any other id.""" | |
| if 0 <= phrase_id < len(PHRASES): | |
| return PHRASES[phrase_id] | |
| phrase = text_bank(text_rows()).get(phrase_id) if phrase_id >= TEXT_BASE else None | |
| if phrase is None: | |
| raise HTTPException(404, "unknown phrase") | |
| return phrase | |
| def page(subject: str) -> HTMLResponse: | |
| html = (STATIC / "index.html").read_text(encoding="utf-8") | |
| a: SupabaseAuth | None = app.state.auth | |
| version = build_version() | |
| marks = {"__VERSION__": version, "__ASSET_V__": version.split(" ")[0], "__SUPABASE_URL__": a.url if a else "", | |
| "__SUPABASE_KEY__": a.key if a else "", "__SUBJECT__": subject, "__HOME__": home, **SUBJECTS[subject]} | |
| for mark, value in marks.items(): | |
| html = html.replace(mark, value) | |
| return HTMLResponse(html, headers={"Cache-Control": "no-cache"}) | |
| # the subject "/" opens: Arabic, or on a Space of its own another one (HOME_SUBJECT=math on mathly); each also has its own path | |
| home = os.environ.get("HOME_SUBJECT", "").strip() or "arabic" | |
| if home not in SUBJECTS: | |
| raise RuntimeError(f"HOME_SUBJECT must be one of: {', '.join(SUBJECTS)}") | |
| def index(): | |
| return page(home) | |
| for subject in SUBJECTS: # the same app under a class topic's own opening screen: /arabic, /math | |
| app.get(f"/{subject}", include_in_schema=False)(lambda subject=subject: page(subject)) | |
| def manifest(subject: str) -> Response: | |
| base = json.loads((STATIC / "manifest.webmanifest").read_text(encoding="utf-8")) | |
| return Response(json.dumps({**base, **MANIFESTS[subject]}, ensure_ascii=False), media_type="application/manifest+json", | |
| headers={"Cache-Control": "no-cache"}) | |
| for subject in MANIFESTS: | |
| app.get(f"/{subject}/manifest.webmanifest", include_in_schema=False)(lambda subject=subject: manifest(subject)) | |
| def service_worker(): | |
| return Response( | |
| (STATIC / "service-worker.js").read_text(encoding="utf-8"), | |
| media_type="application/javascript", | |
| headers={"Cache-Control": "no-cache", "Service-Worker-Allowed": "/"}, | |
| ) | |
| def me(user: dict | None = Depends(signed_in)): | |
| """Who is signed in, their classes (each with its lessons, its homework, see `homework_of`, and its daily | |
| videos with their days, see `dailies_of`: a student in | |
| several chooses one to practise in), for a teacher "dailies" (the daily videos of every class), and the lessons of all of them together (every lesson for a teacher, and for an account in no class). The page | |
| asks this right after sign-in to learn whether the account is let in.""" | |
| mine = user["classes"] if user else [] | |
| can_all = bool(user and ({"*", "exercises.use_all"} & set(user.get("permissions", ())))) | |
| lessons = ALL_LESSONS if not mine or can_all else \ | |
| [l for l in ALL_LESSONS if any(l in lessons_of(c) for c in mine)] | |
| # a teacher is in no class, and sees the daily videos of every class, each once by its earliest day | |
| every: dict[int, dict] = {} | |
| can_manage = bool(user and ({"*", "management.access"} & set(user.get("permissions", ())))) | |
| for d in [d for c in app.state.auth.classes() for d in dailies_of(c, daily_rows())] if can_manage else []: | |
| if d["id"] not in every or d["date"] < every[d["id"]]["date"]: | |
| every[d["id"]] = d | |
| # the subject whose pages the account belongs on: an admin's own, else their class's (a student has one) | |
| subject = subject_code(user.get("subject")) if user else "" | |
| subject = subject or next((s for c in mine if (s := subject_code(c.get("subject")))), "") | |
| return {"email": user["email"] if user else "", "teacher": bool(user and user["teacher"]), | |
| **({"subject": subject} if subject else {}), | |
| **({"tutor": True} if user and user["tutor"] else {}), # the page shows them the flag under a score | |
| "roles": user.get("roles", []) if user else [], "entitlements": user.get("entitlements", []) if user else [], | |
| "permissions": user.get("permissions", []) if user else ["*"], | |
| **({"dailies": sorted(every.values(), key=lambda d: (d["date"], d["id"]))} if can_all else {}), | |
| "classes": [{"name": c["name"], "lessons": class_lessons(c), **homework_of(c, topics()), | |
| "dailies": dailies_of(c, daily_rows()), | |
| **({"subject": s} if (s := subject_code(c.get("subject"))) else {})} for c in mine], | |
| **({"teaches": [{"id": c["id"], "name": c["name"], "lessons": class_lessons(c)} | |
| for c in user.get("teaches", [])]} if user and user.get("teaches") else {}), | |
| "lessons": lessons} | |
| def live_course(class_id: int) -> dict: | |
| course = next((c for c in app.state.auth.classes() if c["id"] == class_id), None) | |
| if not course: | |
| raise HTTPException(404, "הכיתה לא נמצאה") | |
| return course | |
| def live_teacher(course: dict, user: dict | None) -> None: | |
| if not user or user["email"].lower() not in (course.get("teachers") or []): | |
| raise HTTPException(403, "רק מורה של הכיתה יכול/ה לנהל את המפגש") | |
| def live_list(response: Response, user: dict | None = Depends(signed_in)): | |
| response.headers["Cache-Control"] = "no-store" | |
| if not user: | |
| return {"sessions": [], "teaches": []} | |
| mine = {c["id"] for c in user.get("classes", [])} | |
| teaches = {c["id"] for c in user.get("teaches", [])} | |
| sessions = app.state.live.list_for(mine | teaches, user["id"]) | |
| return {"sessions": [{**s, "teacher": s["class_id"] in teaches} for s in sessions], | |
| "teaches": [{"id": c["id"], "name": c["name"], "lessons": class_lessons(c)} | |
| for c in user.get("teaches", [])]} | |
| def live_start(body: dict = Body(...), user: dict | None = Depends(signed_in)): | |
| try: | |
| class_id = int(body.get("class_id")) | |
| except (TypeError, ValueError): | |
| raise HTTPException(400, "צריך לבחור כיתה") | |
| course = live_course(class_id) | |
| live_teacher(course, user) | |
| return {"session": app.state.live.start(course, user)} | |
| def live_join(body: dict = Body(...), user: dict | None = Depends(signed_in)): | |
| code = re.sub(r"\D", "", str(body.get("code", ""))) | |
| if len(code) != 6: | |
| raise HTTPException(400, "קוד המפגש מכיל 6 ספרות") | |
| session = app.state.live.get(code) | |
| if not session: | |
| raise HTTPException(404, "לא נמצא מפגש פעיל בקוד הזה") | |
| course = live_course(session["class_id"]) | |
| email = user["email"].lower() | |
| name = (course.get("nicknames") or {}).get(email, "") | |
| try: | |
| joined = app.state.live.join(code, user, name) | |
| except KeyError: | |
| raise HTTPException(404, "המפגש הסתיים") | |
| return {"session": joined} | |
| def live_session_teacher(code: str, user: dict | None) -> tuple[dict, dict]: | |
| session = app.state.live.get(code) | |
| if not session: | |
| raise HTTPException(404, "המפגש הסתיים") | |
| course = live_course(session["class_id"]) | |
| live_teacher(course, user) | |
| return session, course | |
| def live_status(code: str, response: Response, user: dict | None = Depends(signed_in)): | |
| response.headers["Cache-Control"] = "no-store" | |
| session = app.state.live.get(code) | |
| if not session: | |
| raise HTTPException(404, "המפגש הסתיים") | |
| course = live_course(session["class_id"]) | |
| teacher = bool(user and user["email"].lower() in (course.get("teachers") or [])) | |
| try: | |
| return {"session": app.state.live.heartbeat(code, user, teacher)} | |
| except PermissionError: | |
| raise HTTPException(403, "יש להצטרף למפגש תחילה") | |
| except KeyError: | |
| raise HTTPException(404, "המפגש הסתיים") | |
| def live_question(code: str, body: dict = Body(...), user: dict | None = Depends(signed_in)): | |
| _session, course = live_session_teacher(code, user) | |
| phrase_id = body.get("phrase_id") | |
| if not isinstance(phrase_id, int): | |
| raise HTTPException(400, "צריך לבחור משפט מהמאגר") | |
| phrase = phrase_of(phrase_id) | |
| if topics().get(phrase["cat"]) not in lessons_of(course): | |
| raise HTTPException(403, "המשפט אינו בשיעורים של הכיתה") | |
| return {"session": app.state.live.ask(code, {**phrase, "id": phrase_id})} | |
| def live_answer(code: str, audio: UploadFile = File(...), user: dict | None = Depends(signed_in)): | |
| try: | |
| question = app.state.live.begin_answer(code, user["id"]) | |
| except LiveConflict: | |
| raise HTTPException(409, "כבר ענית על השאלה הזו") | |
| except PermissionError: | |
| raise HTTPException(403, "יש להצטרף למפגש תחילה") | |
| except KeyError: | |
| raise HTTPException(404, "אין כרגע שאלה פעילה") | |
| try: | |
| data = audio.file.read(MAX_UPLOAD + 1) | |
| if len(data) > MAX_UPLOAD: | |
| raise HTTPException(413, "recording too long") | |
| try: | |
| wav = decode_audio(data) | |
| except ValueError: | |
| raise HTTPException(400, "could not decode audio") | |
| if wav.size < SAMPLE_RATE * .3: | |
| raise HTTPException(400, "לא נשמע מספיק דיבור. נסו שוב.") | |
| phrase = phrase_of(question["phrase_id"]) | |
| refs = app.state.refs.get(question["phrase_id"], phrase) if app.state.refs else [] | |
| result = app.state.scorer.score(wav, phrase, refs) | |
| if result.get("error"): | |
| raise HTTPException(400, result.get("message") or "ההקלטה לא זוהתה") | |
| compact = {"score": round(result["score"]), "unrelated": bool(result.get("unrelated")), | |
| "expected_tr": result.get("variant", {}).get("tr") or phrase.get("tr", ""), | |
| "heard_tr": " ".join(w.get("heard_tr") or "–" for w in result.get("words", [])), | |
| "issues": result.get("issues", [])[:8]} | |
| app.state.live.finish_answer(code, user["id"], compact) | |
| return {"answer": compact, "result": result} | |
| except Exception: | |
| app.state.live.cancel_answer(code, user["id"]) | |
| raise | |
| def live_end(code: str, user: dict | None = Depends(signed_in)): | |
| live_session_teacher(code, user) | |
| try: | |
| app.state.live.end(code) | |
| except KeyError: | |
| raise HTTPException(404, "המפגש הסתיים") | |
| return {"ok": True} | |
| def progress(user: dict | None = Depends(signed_in)): | |
| """The student's best scores and compact practice totals, including this week's active days.""" | |
| rows = app.state.auth.attempts(user["token"]) if user else [] | |
| s = summary(rows, text_bank(text_rows())) | |
| return {**{k: s[k] for k in ("best", "tries", "recordings", "days")}, | |
| "week_days": weekly_practice_days(rows), "streak": practice_streak(rows)} | |
| def writing(user: dict | None = Depends(signed_in)): | |
| """The signed-in student's written homework, {topic id: its lines}. 503 when it cannot be read (or without | |
| sign-in): the page then shows the copy it keeps on the device.""" | |
| if not user: | |
| raise HTTPException(503, "no sign-in: written homework stays on the device") | |
| try: | |
| mine = app.state.auth.writings(user["token"]) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"written homework not available: {e}") | |
| return {"writing": {t: lines[:WRITE_TOPICS[t]] for t, lines in mine.items() if t in WRITE_TOPICS}} | |
| def write(topic: str, body: dict = Body(...), user: dict | None = Depends(signed_in)): | |
| """Keeps what the student wrote in a writing topic: {"lines": a string per sentence, an empty one for a | |
| sentence not written yet}, in place of what was kept before.""" | |
| if topic not in WRITE_TOPICS: | |
| raise HTTPException(404, "unknown topic") | |
| lines = body.get("lines") | |
| if not isinstance(lines, list) or len(lines) > WRITE_TOPICS[topic] or not all(isinstance(x, str) and len(x) <= MAX_LINE for x in lines): | |
| raise HTTPException(400, f"up to {WRITE_TOPICS[topic]} sentences, each up to {MAX_LINE} characters") | |
| if not user: | |
| raise HTTPException(503, "no sign-in: written homework stays on the device") | |
| try: | |
| app.state.auth.save_writing(user["token"], user["id"], topic, [" ".join(x.split()) for x in lines]) | |
| except AuthUnavailable as e: | |
| if "answered 404" in str(e): | |
| log.warning("the table `writings` is missing. Run in the Supabase SQL editor: %s", CREATE_WRITINGS) | |
| raise HTTPException(503, f"written homework not kept: {e}") | |
| return {"ok": True} | |
| def class_progress(a: SupabaseAuth = Depends(teacher_only), actor: dict | None = Depends(signed_in)): | |
| """The teacher's panel: the classes (with their lessons and homework), and every student's summary (see `summary`) with | |
| "written", their written homework ({topic id: its lines}), with | |
| "tutor" (the teacher gave them the tutor's rank, see `set_tutor`; "tutor_fixed" when TEACHER_EMAILS or TUTORS_EMAIL gives it, which the panel cannot change), | |
| their classes ({name: nickname}) and, as "since", the same summary for each class ({name: summary}) of the attempts | |
| made since the class was opened: a new class starts clean, whatever its students practised before. The students are the classes' accounts, or every account that signed in | |
| when there is no class yet; one that never signed in has joined = false.""" | |
| try: | |
| classes, users, rows, wrote = managed_classes(a, actor, fresh=True), a.users(), a.all_attempts(), a.all_writings() | |
| ranked = a.ranked(fresh=True) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, str(e)) | |
| by: dict[str, list[dict]] = {} | |
| for r in rows: | |
| by.setdefault(r["user_id"], []).append(r) | |
| written: dict[str, dict[str, list[str]]] = {} # user id -> {topic: lines} | |
| for w in wrote or []: | |
| if w["topic"] in WRITE_TOPICS and isinstance(w.get("lines"), list): | |
| written.setdefault(w["user_id"], {})[w["topic"]] = [str(x) for x in w["lines"]] | |
| member: dict[str, dict[str, str]] = {} # email -> {class name: nickname} | |
| for c in classes: | |
| for e in c["emails"]: | |
| member.setdefault(e, {})[c["name"]] = c["nicknames"].get(e, "") | |
| opened = {c["name"]: utc(c["created_at"]) for c in classes if c.get("created_at")} | |
| bank, lesson_of = text_bank(text_rows()), topics() | |
| def student(email: str, name: str, joined: bool, mine: list[dict], wrote: dict | None = None) -> dict: | |
| into = member.get(email.lower(), {}) | |
| since = {n: summary([r for r in mine if n not in opened or utc(r["created_at"]) >= opened[n]], bank) for n in into} | |
| fixed = email.lower() in a.teachers | a.tutors | |
| return {"email": email, "name": name, "joined": joined, "classes": into, "since": since, "written": wrote or {}, | |
| "tutor": fixed or email.lower() in (ranked or ()), **({"tutor_fixed": True} if fixed else {}), | |
| **summary(mine, bank)} | |
| subject_scoped = bool(actor and "developer" not in actor.get("roles", ()) and hasattr(a, "access_for")) | |
| students = [student(u["email"], u["name"], True, by.get(u["id"], []), written.get(u["id"])) | |
| for u in users if (not classes and not subject_scoped) or u["email"].lower() in member] | |
| missing = member.keys() - {s["email"].lower() for s in students} | |
| students += [student(e, "", False, []) for e in sorted(missing)] | |
| # "setup": what the teacher still has to run in Supabase, in words and as SQL; without it the panel would | |
| # show written homework as not done, though the students wrote it (it stays on their devices) | |
| setup = [{"what": "הטבלה writings לא קיימת ב-Supabase, ולכן שיעורי הבית בכתיבה לא נשמרים: מה שהתלמידים כתבו נשאר במכשיר שלהם " | |
| "והפאנל מראה שלא כתבו. אחרי יצירת הטבלה, המשפטים נשלחים בכניסה הבאה של כל תלמיד.", | |
| "sql": CREATE_WRITINGS}] if wrote is None else [] | |
| if ranked is None: | |
| setup.append({"what": "הטבלה tutors לא קיימת ב-Supabase, ולכן אי אפשר לתת לתלמיד דרגת מדווח/ת (דגל 🚩 מתחת לציון).", | |
| "sql": CREATE_TUTORS}) | |
| return {"classes": [{**c, "lessons": class_lessons(c), **homework_of(c, lesson_of)} for c in classes], "students": students, | |
| "setup": setup} | |
| def activity(hours: int = Query(24, ge=1, le=168), bucket_minutes: int = Query(15, ge=1, le=60), | |
| window_minutes: int | None = Query(None, ge=1, le=60), | |
| a: SupabaseAuth = Depends(teacher_only), actor: dict | None = Depends(signed_in)): | |
| """Anonymous, aggregated practice activity for the teacher's monitoring graph.""" | |
| try: | |
| scoped = bool(actor and "developer" not in actor.get("roles", ()) and hasattr(a, "access_for")) | |
| if scoped or window_minutes is not None: | |
| ids = None | |
| if scoped: | |
| classes, users = managed_classes(a, actor, fresh=True), a.users() | |
| emails = {email for c in classes for email in c.get("emails", ())} | |
| ids = {u["id"] for u in users if u["email"].lower() in emails} | |
| start = datetime.now(timezone.utc) - (timedelta(minutes=window_minutes) if window_minutes else timedelta(hours=hours)) | |
| rows = [r for r in a.all_attempts() | |
| if (ids is None or r.get("user_id") in ids) and utc(r["created_at"]) >= start] | |
| seconds, buckets = bucket_minutes * 60, {} | |
| for row in rows: | |
| stamp = int(utc(row["created_at"]).timestamp()) // seconds * seconds | |
| item = buckets.setdefault(stamp, {"users": set(), "attempts": 0}) | |
| item["attempts"] += 1 | |
| item["users"].add(row["user_id"]) | |
| per_user = {} | |
| for row in rows: | |
| per_user.setdefault(row["user_id"], set()).add(utc(row["created_at"]).astimezone(LOCAL_TZ).date()) | |
| data = {"buckets": [{"bucket": datetime.fromtimestamp(stamp, timezone.utc).isoformat(), | |
| "attempts": item["attempts"], "active_students": len(item["users"])} | |
| for stamp, item in sorted(buckets.items())], | |
| "summary": {"attempts": len(rows), "active_students": len(per_user), | |
| "returning_students": sum(len(days) >= 2 for days in per_user.values()), | |
| "active_days": len({day for days in per_user.values() for day in days})}} | |
| else: | |
| data = a.activity(hours, bucket_minutes) | |
| # Accept the former list return shape from lightweight test doubles and older integrations. | |
| if isinstance(data, list): | |
| data = {"buckets": data} | |
| elif data.get("summary") is None: | |
| data = {"buckets": data["buckets"]} | |
| return {"hours": window_minutes / 60 if window_minutes else hours, | |
| **({"window_minutes": window_minutes} if window_minutes else {}), | |
| "bucket_minutes": bucket_minutes, **data} | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, str(e)) | |
| def system_activity(limit: int = Query(60, ge=1, le=200), a: SupabaseAuth = Depends(teacher_only)): | |
| """Recent product activity for managers; no recording or exercise answer is returned.""" | |
| try: | |
| events = a.recent_events(limit) | |
| return {"events": events or [], **({"setup": CREATE_SYSTEM_EVENTS} if events is None else {})} | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, str(e)) | |
| def set_tutor(body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only), user: dict | None = Depends(signed_in)): | |
| """Gives a student the tutor's rank, or takes it back: {"email", "tutor": true or false}. The rank is the | |
| student's in every class: they get the flag under each score (see `flag`).""" | |
| email = str(body.get("email", "")).strip().lower() | |
| if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email) or not isinstance(body.get("tutor"), bool): | |
| raise HTTPException(400, "צריך כתובת אימייל של תלמיד/ה ו-tutor: true או false") | |
| try: | |
| a.set_tutor(email, body["tutor"], user["email"].lower()) | |
| except AuthUnavailable as e: | |
| if "answered 404" in str(e): | |
| raise HTTPException(503, "הטבלה tutors לא קיימת ב-Supabase. הריצו ב-SQL Editor: " | |
| f"{CREATE_TUTORS} אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';") | |
| raise HTTPException(503, f"Supabase לא זמין: {e}") | |
| return {"ok": True} | |
| def access_users(a: SupabaseAuth = Depends(require("users.manage")), | |
| actor: dict | None = Depends(signed_in)): | |
| """All accounts and their explicit roles/entitlements for the permission editor.""" | |
| if not hasattr(a, "access_rows"): | |
| return {"users": [], "setup": CREATE_USER_ACCESS, "migration": MIGRATE_USER_ACCESS, | |
| "me": actor["id"] if actor else ""} | |
| try: | |
| users, rows = a.users(), a.access_rows() | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"Supabase לא זמין: {e}") | |
| if rows is None: | |
| return {"users": [], "setup": CREATE_USER_ACCESS, "migration": MIGRATE_USER_ACCESS, | |
| "me": actor["id"] if actor else ""} | |
| assigned = {r["user_id"]: r for r in rows} | |
| result = [] | |
| for account in users: | |
| row = assigned.get(account["id"], {}) | |
| email = account["email"].lower() | |
| roles = set(row.get("roles") or ["student"]) | |
| fixed = [] | |
| if email in getattr(a, "teachers", ()): | |
| roles.update({"teacher", "admin", "flagger"}); fixed += ["teacher", "admin", "flagger"] | |
| if email in getattr(a, "tutors", ()): | |
| roles.add("flagger"); fixed.append("flagger") | |
| if email in getattr(a, "developers", ()): | |
| roles.add("developer"); fixed.append("developer") | |
| entitlements = (set(row.get("entitlements") or []) if row else set(DEFAULT_ENTITLEMENTS)) & ENTITLEMENTS | |
| effective = set().union(*(ROLE_PERMISSIONS[r] for r in roles & ROLES)) | |
| effective.update(*[ENTITLEMENT_PERMISSIONS[e] for e in entitlements]) | |
| result.append({**account, "roles": sorted(roles & ROLES), | |
| "entitlements": sorted(entitlements), "permissions": sorted(effective), | |
| "fixed_roles": sorted(set(fixed))}) | |
| # A successful read cannot tell us which CHECK expression PostgreSQL has, and showing the | |
| # migration unconditionally makes it look unfinished even after it was run. If an old | |
| # constraint rejects a newly selected entitlement, update_access returns the migration there. | |
| return {"users": result, "setup": "", "migration": "", | |
| "me": actor["id"] if actor else ""} | |
| def update_access(target_id: str, body: dict = Body(...), | |
| a: SupabaseAuth = Depends(require("users.manage")), | |
| actor: dict | None = Depends(signed_in)): | |
| roles, entitlements = body.get("roles"), body.get("entitlements") | |
| if not isinstance(roles, list) or not roles or not all(isinstance(x, str) for x in roles): | |
| raise HTTPException(400, "roles must be a non-empty list") | |
| if not isinstance(entitlements, list) or not all(isinstance(x, str) for x in entitlements): | |
| raise HTTPException(400, "entitlements must be a list") | |
| roles, entitlements = set(roles), set(entitlements) | |
| if not roles <= ROLES or not entitlements <= ENTITLEMENTS: | |
| raise HTTPException(400, "unknown role or entitlement") | |
| if not actor or target_id == actor["id"]: | |
| raise HTTPException(403, "you cannot change your own access") | |
| is_developer = "*" in actor.get("permissions", ()) | |
| try: | |
| users = a.users() | |
| target = next((u for u in users if u["id"] == target_id), None) | |
| if not target: | |
| raise HTTPException(404, "account not found") | |
| old = a.access_for(target_id) if hasattr(a, "access_for") else None | |
| old_roles = set((old or {}).get("roles") or ["student"]) | |
| if (("developer" in roles) != ("developer" in old_roles)) and not is_developer: | |
| raise HTTPException(403, "only developers can change developer access") | |
| if target["email"].lower() in getattr(a, "developers", ()) and "developer" not in roles: | |
| raise HTTPException(403, "this developer is configured by the server") | |
| if "developer" in old_roles and "developer" not in roles: | |
| rows = a.access_rows() or [] | |
| remaining = sum("developer" in (r.get("roles") or []) and r.get("user_id") != target_id for r in rows) | |
| remaining += sum(e != target["email"].lower() for e in getattr(a, "developers", ())) | |
| if not remaining: | |
| raise HTTPException(409, "the last developer cannot be removed") | |
| a.set_access(target, sorted(roles), sorted(entitlements), actor) | |
| except HTTPException: | |
| raise | |
| except AuthUnavailable as e: | |
| if "answered 404" in str(e): | |
| raise HTTPException(503, f"permissions table is missing; run: {CREATE_USER_ACCESS}") | |
| if any(mark in str(e).lower() for mark in ("valid_entitlements", "check constraint", "23514")): | |
| raise HTTPException(409, f"permissions table needs upgrading; run: {MIGRATE_USER_ACCESS}") | |
| raise HTTPException(503, f"Supabase לא זמין: {e}") | |
| return {"ok": True} | |
| def admin_subjects(a: SupabaseAuth = Depends(require("*")), actor: dict | None = Depends(signed_in)): | |
| """Developer-only data for the dedicated subject editor in Monitoring.""" | |
| if not actor or "*" not in actor.get("permissions", ()): | |
| raise HTTPException(403, "developers only") | |
| try: | |
| users, rows = a.users(), a.access_rows() | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"Supabase לא זמין: {e}") | |
| subjects = [{"code": code, "label": label} for code, label in SUBJECT_LABELS.items()] | |
| if rows is None: | |
| return {"admins": [], "subjects": subjects, "setup": CREATE_USER_ACCESS, "migration": MIGRATE_USER_ACCESS} | |
| assigned = {r["user_id"]: r for r in rows} | |
| out = [] | |
| for account in users: | |
| row, email = assigned.get(account["id"], {}), account["email"].lower() | |
| roles = set(row.get("roles") or []) | |
| if email in getattr(a, "teachers", ()): | |
| roles.add("admin") | |
| if "admin" in roles: | |
| out.append({"id": account["id"], "email": account["email"], "name": account.get("name", ""), | |
| "subject": str(row.get("subject") or "")}) | |
| return {"admins": out, "subjects": subjects, "setup": "", "migration": ""} | |
| def update_admin_subject(target_id: str, body: dict = Body(...), | |
| a: SupabaseAuth = Depends(require("*")), actor: dict | None = Depends(signed_in)): | |
| if not actor or "*" not in actor.get("permissions", ()): | |
| raise HTTPException(403, "developers only") | |
| subject = " ".join(str(body.get("subject", "")).split()) | |
| if not subject_code(subject): # a fixed list: a class matches its admin's subject only when both are spelled the same | |
| raise HTTPException(400, "subject must be one of: " + ", ".join(SUBJECT_LABELS.values())) | |
| try: | |
| target = next((u for u in a.users() if u["id"] == target_id), None) | |
| if not target: | |
| raise HTTPException(404, "account not found") | |
| old = a.access_for(target_id) or {} | |
| roles = set(old.get("roles") or []) | |
| if target["email"].lower() in getattr(a, "teachers", ()): | |
| roles.add("admin") | |
| if "admin" not in roles: | |
| raise HTTPException(400, "the account is not an admin") | |
| a.set_admin_subject(target, subject, actor) | |
| except HTTPException: | |
| raise | |
| except AuthUnavailable as e: | |
| if "subject" in str(e).lower(): | |
| raise HTTPException(503, f"permissions table needs upgrading; run: {MIGRATE_USER_ACCESS}") | |
| raise HTTPException(503, f"Supabase לא זמין: {e}") | |
| return {"ok": True, "subject": subject} | |
| def make_backup_archive(a: SupabaseAuth) -> tuple[str, bytes]: | |
| tables, archive = a.backup_rows(), io.BytesIO() | |
| tables["final_scores"] = a.final_scores() | |
| with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as out: | |
| manifest = {"format": "ihki-backup", "version": 1, | |
| "created_at": datetime.now(timezone.utc).isoformat(), "tables": tables, "files": []} | |
| out.writestr("backup.json", json.dumps(manifest, ensure_ascii=False, separators=(",", ":"))) | |
| stamp = datetime.now(LOCAL_TZ).strftime("%Y-%m-%d_%H-%M-%S") | |
| return f"ihki-backup-{stamp}.zip", archive.getvalue() | |
| def apply_backup_archive(raw: bytes, a: SupabaseAuth) -> dict[str, int]: | |
| with zipfile.ZipFile(io.BytesIO(raw)) as src: | |
| if sum(x.file_size for x in src.infolist()) > MAX_BACKUP_UPLOAD * 2: | |
| raise ValueError("הארכיון גדול מדי לאחר פתיחה") | |
| manifest = json.loads(src.read("backup.json")) | |
| if manifest.get("format") != "ihki-backup" or manifest.get("version") != 1: | |
| raise ValueError("זה אינו קובץ גיבוי נתמך של המערכת") | |
| tables, files = manifest.get("tables"), manifest.get("files") | |
| if not isinstance(tables, dict) or not isinstance(files, list) or \ | |
| any(not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows) | |
| for rows in tables.values()): | |
| raise ValueError("מבנה הנתונים בגיבוי אינו תקין") | |
| restored = a.restore_rows(tables) | |
| if "final_scores" in tables: | |
| restored["final_scores"] = a.restore_final_scores(tables["final_scores"]) | |
| return restored | |
| def create_saved_backup(a: SupabaseAuth = Depends(require("*")), actor: dict | None = Depends(signed_in)): | |
| """Create a timestamped database-only archive in the private backups bucket.""" | |
| if not actor or "*" not in actor.get("permissions", ()): | |
| raise HTTPException(403, "developers only") | |
| try: | |
| name, data = make_backup_archive(a) | |
| a.save_backup(name, data) | |
| return {"ok": True, "name": name} | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"הגיבוי נכשל: {e}") | |
| def saved_backups(a: SupabaseAuth = Depends(require("*")), actor: dict | None = Depends(signed_in)): | |
| if not actor or "*" not in actor.get("permissions", ()): | |
| raise HTTPException(403, "developers only") | |
| try: | |
| return {"backups": a.list_backups()} | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"טעינת הגיבויים נכשלה: {e}") | |
| def download_saved_backup(name: str = Query(...), a: SupabaseAuth = Depends(require("*")), | |
| actor: dict | None = Depends(signed_in)): | |
| if not actor or "*" not in actor.get("permissions", ()): | |
| raise HTTPException(403, "developers only") | |
| if not re.fullmatch(r"ihki-backup-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.zip", name): | |
| raise HTTPException(400, "שם גיבוי לא תקין") | |
| try: | |
| data, _ = a._get_object("backups", name) | |
| return Response(data, media_type="application/zip", headers={ | |
| "Content-Disposition": f'attachment; filename="{name}"', "Cache-Control": "no-store"}) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"הורדת הגיבוי נכשלה: {e}") | |
| def download_backup(a: SupabaseAuth = Depends(require("*")), actor: dict | None = Depends(signed_in)): | |
| """Download a fresh archive directly; retained for manual off-site copies.""" | |
| if not actor or "*" not in actor.get("permissions", ()): | |
| raise HTTPException(403, "developers only") | |
| try: | |
| name, data = make_backup_archive(a) | |
| return Response(data, media_type="application/zip", headers={ | |
| "Content-Disposition": f'attachment; filename="{name}"', "Cache-Control": "no-store"}) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"הגיבוי נכשל: {e}") | |
| def restore_saved_backup(body: dict = Body(...), a: SupabaseAuth = Depends(require("*")), | |
| actor: dict | None = Depends(signed_in)): | |
| if not actor or "*" not in actor.get("permissions", ()): | |
| raise HTTPException(403, "developers only") | |
| name = str(body.get("name", "")) | |
| if not re.fullmatch(r"ihki-backup-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.zip", name): | |
| raise HTTPException(400, "שם גיבוי לא תקין") | |
| try: | |
| raw, _ = a._get_object("backups", name) | |
| restored = apply_backup_archive(raw, a) | |
| return {"ok": True, "tables": restored, "name": name} | |
| except (ValueError, KeyError, json.JSONDecodeError, zipfile.BadZipFile) as e: | |
| raise HTTPException(400, f"קובץ הגיבוי אינו תקין: {e}") | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"השחזור נעצר: {e}") | |
| async def restore_backup(backup: UploadFile = File(...), a: SupabaseAuth = Depends(require("*")), | |
| actor: dict | None = Depends(signed_in)): | |
| """Merge a portable app backup. It updates matching rows but never deletes unrelated current data.""" | |
| if not actor or "*" not in actor.get("permissions", ()): | |
| raise HTTPException(403, "developers only") | |
| raw = await backup.read(MAX_BACKUP_UPLOAD + 1) | |
| if len(raw) > MAX_BACKUP_UPLOAD: | |
| raise HTTPException(413, "קובץ הגיבוי גדול מדי (עד 250 MB)") | |
| try: | |
| restored = apply_backup_archive(raw, a) | |
| return {"ok": True, "tables": restored} | |
| except (ValueError, KeyError, json.JSONDecodeError, zipfile.BadZipFile) as e: | |
| raise HTTPException(400, f"קובץ הגיבוי אינו תקין: {e}") | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"השחזור נעצר: {e}") | |
| def flags(status: str = Query("pending"), a: SupabaseAuth = Depends(teacher_only)): | |
| """The score reports awaiting a teacher's decision (or one reviewed state).""" | |
| if status not in FLAG_STATUSES: | |
| raise HTTPException(400, "invalid flag status") | |
| try: | |
| return {"flags": a.list_flags(status)} | |
| except AuthUnavailable as e: | |
| detail = str(e) | |
| if "status" in detail or "false_issues" in detail: | |
| raise HTTPException(503, f"לטבלה flags חסרות עמודות הבדיקה. הריצו ב-SQL Editor: {MIGRATE_FLAGS}") | |
| raise HTTPException(503, f"Supabase לא זמין: {e}") | |
| def review_flag(flag_id: int, body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only), | |
| user: dict | None = Depends(signed_in)): | |
| """Confirms or rejects a report and records which displayed sound judgements were false.""" | |
| status, category = body.get("status"), body.get("category") | |
| issues = body.get("false_issues", []) | |
| if status not in FLAG_STATUSES - {"pending"} or category not in FLAG_CATEGORIES: | |
| raise HTTPException(400, "invalid flag review") | |
| if not isinstance(issues, list) or len(issues) > 30: | |
| raise HTTPException(400, "invalid false_issues") | |
| clean = [] | |
| for issue in issues: | |
| if not isinstance(issue, dict) or not isinstance(issue.get("word"), int): | |
| raise HTTPException(400, "invalid false_issues") | |
| expected, heard = str(issue.get("expected", ""))[:20], str(issue.get("heard", ""))[:20] | |
| if not expected: | |
| raise HTTPException(400, "invalid false_issues") | |
| clean.append({"word": max(0, issue["word"]), "expected": expected, "heard": heard}) | |
| values = {"status": status, "category": category, "false_issues": clean, | |
| "review_note": " ".join(str(body.get("review_note", "")).split())[:MAX_NOTE], | |
| "reviewed_by": user["email"].lower(), "reviewed_at": datetime.now(timezone.utc).isoformat()} | |
| try: | |
| a.review_flag(flag_id, values) | |
| except AuthUnavailable as e: | |
| detail = str(e) | |
| if any(column in detail for column in ("status", "category", "false_issues", "reviewed_by")): | |
| raise HTTPException(503, f"לטבלה flags חסרות עמודות הבדיקה. הריצו ב-SQL Editor: {MIGRATE_FLAGS}") | |
| raise HTTPException(503, f"Supabase לא זמין: {e}") | |
| return {"ok": True} | |
| def flag_audio(flag_id: int, a: SupabaseAuth = Depends(teacher_only)): | |
| """Streams a private flagged recording to a teacher without exposing the storage bucket or secret key.""" | |
| try: | |
| found = a.flag_audio(flag_id) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"ההקלטה לא זמינה כרגע: {e}") | |
| if found is None: | |
| raise HTTPException(404, "לדיווח הזה אין הקלטה שמורה") | |
| data, media_type = found | |
| return Response(data, media_type=media_type, headers={"Cache-Control": "private, no-store"}) | |
| def lab_error(e: AuthUnavailable) -> HTTPException: | |
| if "answered 404" in str(e): | |
| return HTTPException(503, f"הטבלה lab_samples לא קיימת. הריצו ב-SQL Editor: {CREATE_LAB} " | |
| "אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';") | |
| return HTTPException(503, f"Supabase לא זמין: {e}") | |
| def lab_items(speaker: str = Query(""), a: SupabaseAuth = Depends(lab_only), | |
| user: dict | None = Depends(signed_in)): | |
| """The lab's diagnostic list, with how many recordings each item has (`counts`) and how many of them are | |
| `speaker`'s, by default the signed-in teacher's (`mine`: the lab's test marks an item submitted by it; the | |
| automatic test asks for its "TTS <voice>"). `setup` is the SQL to run while the table is missing: the list | |
| still shows and scores, but nothing is kept.""" | |
| try: | |
| rows, setup = a.lab_samples(), "" | |
| except AuthUnavailable as e: | |
| if "answered 404" not in str(e): | |
| raise lab_error(e) | |
| rows, setup = [], CREATE_LAB | |
| counts: dict[str, dict[str, int]] = {} | |
| mine: dict[str, dict[str, int]] = {} | |
| who = " ".join(speaker.split())[:MAX_SPEAKER] or user["email"].lower() | |
| for r in rows: | |
| label = r["label"] if r["label"] in ("correct", "mistake") else "correct" | |
| for tally in (counts, mine) if who and (r.get("speaker") or "") == who else (counts,): | |
| tally.setdefault(r["item"], {"correct": 0, "mistake": 0})[label] += 1 | |
| return {"items": lab.public_items(), "groups": lab.GROUPS, "counts": counts, "mine": mine, "setup": setup} | |
| def lab_score(request: Request, item: str = Form(...), audio: UploadFile = File(...), | |
| mistake: int = Form(-1), save: bool = Form(True), a: SupabaseAuth = Depends(lab_only), | |
| user: dict | None = Depends(signed_in)): | |
| """Scores a recording of a lab item as a practice recording is scored (with the native baseline), with the | |
| teacher's stage-by-stage trace, and keeps it with its label unless `save` is off, as said by the signed-in | |
| teacher (the speaker is their email, never a name typed in the page). `mistake` is the index of the | |
| planted mistake among the item's sounds (-1: said right). {"result", "saved": whether the row was kept, | |
| "audio": whether the recording was kept too, "error": why the row was not kept}.""" | |
| found = lab.BY_ID.get(item) | |
| if found is None: | |
| raise HTTPException(404, "no such lab item") | |
| sounds = [x for x in found["variants"][0][1].split() if x != "|"] | |
| if not -1 <= mistake < len(sounds): | |
| raise HTTPException(400, "invalid mistake") | |
| data = audio.file.read(MAX_UPLOAD + 1) | |
| if len(data) > MAX_UPLOAD: | |
| raise HTTPException(413, "recording too long") | |
| try: | |
| wav = decode_audio(data) | |
| except ValueError: | |
| raise HTTPException(400, "could not decode audio") | |
| if wav.size < 16_000 * 0.3: | |
| return {"result": {"error": "too_short", "message": "לא נשמע דיבור. נסו שוב, קרוב יותר למיקרופון."}, "saved": False} | |
| phrase = lab.phrase(found) | |
| refs = app.state.refs.get(phrase["key"], phrase) if app.state.refs else [] | |
| result = app.state.scorer.score(wav, phrase, refs, diagnostics=True) | |
| if not save: | |
| return {"result": result, "saved": False} | |
| mime = (audio.content_type or "application/octet-stream").lower() | |
| ext = AUDIO_EXT.get(mime.partition(";")[0].strip().rpartition("/")[2], "bin") | |
| flagged = [f"{p.get('sound', p['ipa'])}>{p.get('heard') or '_'}" for w in result.get("words", []) | |
| for p in w.get("phonemes", []) if p.get("flagged")] | |
| row = {"email": user["email"].lower(), "item": item, "ar": result["variant"]["ar"], "ipa": result["variant"]["ipa"], | |
| "speaker": user["email"].lower(), "label": "correct" if mistake < 0 else "mistake", | |
| "mistake": None if mistake < 0 else {"index": mistake, "expected": sounds[mistake]}, | |
| "device": request.headers.get("user-agent", "")[:200], "score": result["score"], | |
| "heard_ipa": result["heard_ipa"], "flagged": flagged, "result": result, "model": MODEL_ID, | |
| "version": build_version()} | |
| try: | |
| kept = a.add_lab_sample(row, data, mime, f"{date.today():%Y-%m}/{uuid.uuid4().hex}.{ext}") | |
| except AuthUnavailable as e: | |
| return {"result": result, "saved": False, "error": lab_error(e).detail} | |
| return {"result": result, "saved": True, "audio": kept} | |
| def lab_tts_audio(item: str, voice: str) -> tuple[dict, bytes]: | |
| """The lab item and its first reading said by a TTS voice (by name, LAB_TTS_VOICES), as a WAV.""" | |
| found = lab.BY_ID.get(item) | |
| if found is None: | |
| raise HTTPException(404, "no such lab item") | |
| if voice not in LAB_TTS_VOICES: | |
| raise HTTPException(400, "unknown voice") | |
| if app.state.tts is None: | |
| raise HTTPException(503, "אין TTS בשרת הזה (SAWTAK_API_KEY לא מוגדר)") | |
| try: | |
| data, _ = app.state.tts.wav(point_arabic(*found["variants"][0]), LAB_TTS_VOICES[voice]) | |
| except TTSUnavailable as e: | |
| raise HTTPException(503, f"ה-TTS לא זמין: {e}") | |
| return found, data | |
| def lab_tts_voices(a: SupabaseAuth = Depends(lab_only)): | |
| """The voices the automatic test can use (none without TTS on this server).""" | |
| return {"voices": list(LAB_TTS_VOICES) if app.state.tts else []} | |
| def lab_tts_listen(item: str, voice: str = Query(...), a: SupabaseAuth = Depends(lab_only)): | |
| """What the automatic test sent for an item: the voice's WAV (cached, so no new synthesis).""" | |
| return Response(lab_tts_audio(item, voice)[1], media_type="audio/wav", headers={"Cache-Control": "private, no-store"}) | |
| def lab_tts_score(item: str = Form(...), voice: str = Form(...), save: bool = Form(True), | |
| a: SupabaseAuth = Depends(lab_only), user: dict | None = Depends(signed_in)): | |
| """The automatic test, one item: a TTS voice says it, and it is scored and kept as said right by the speaker | |
| "TTS <voice>", like an approved take of the test. Scored without the native baseline, which is made of the same | |
| voice and would measure every sound against itself. {"result", "saved", "audio", "speaker", "error"?}.""" | |
| found, data = lab_tts_audio(item, voice) | |
| try: | |
| wav = decode_audio(data) | |
| except ValueError: | |
| raise HTTPException(502, "the TTS audio could not be decoded") | |
| phrase = lab.phrase(found) | |
| result = app.state.scorer.score(wav, phrase, [], diagnostics=True) | |
| speaker = f"TTS {voice}" | |
| if result.get("error") or not save: | |
| return {"result": result, "saved": False, "speaker": speaker} | |
| row = {"email": user["email"].lower(), "item": item, "ar": result["variant"]["ar"], "ipa": result["variant"]["ipa"], | |
| "speaker": speaker, "label": "correct", "mistake": None, "device": f"tts:{LAB_TTS_VOICES[voice]}", | |
| "score": result["score"], "heard_ipa": result["heard_ipa"], "flagged": lab.flagged_sounds(result), | |
| "result": result, "model": MODEL_ID, "version": build_version()} | |
| try: | |
| kept = a.add_lab_sample(row, data, "audio/wav", f"{date.today():%Y-%m}/tts-{uuid.uuid4().hex}.wav") | |
| except AuthUnavailable as e: | |
| return {"result": result, "saved": False, "speaker": speaker, "error": lab_error(e).detail} | |
| return {"result": result, "saved": True, "audio": kept, "speaker": speaker} | |
| def lab_results(speaker: str = Query(""), a: SupabaseAuth = Depends(lab_only)): | |
| """The benchmark of the lab's recordings as they were scored (see lab.benchmark), of one speaker or of all; | |
| with the confirmed flags' false issues beside each sound. scripts/bench.py rescores them with the current scorer.""" | |
| try: | |
| samples = a.lab_samples(full=True) | |
| except AuthUnavailable as e: | |
| raise lab_error(e) | |
| try: | |
| reports = a.list_flags("confirmed") | |
| except AuthUnavailable as e: # the reports are a side column: the benchmark stands without them | |
| log.warning("confirmed flags not read for the lab: %s", e) | |
| reports = [] | |
| speakers = sorted({s.get("speaker") or "" for s in samples}) | |
| if speaker: | |
| samples = [s for s in samples if (s.get("speaker") or "") == speaker] | |
| # the recordings themselves, newest first, for the trace dashboard's picker | |
| recordings = [{"id": s["id"], "item": s["item"], "speaker": s.get("speaker") or "", "label": s.get("label"), | |
| "mistake": s.get("mistake"), "score": s.get("score"), "flagged": s.get("flagged") or [], | |
| "unrelated": bool((s.get("result") or {}).get("unrelated")), "created_at": s.get("created_at")} | |
| for s in reversed(samples) if s.get("audio")] | |
| return {**lab.benchmark(samples, reports=reports), "speakers": speakers, "model": MODEL_ID, | |
| "version": build_version(), "samples": [s["id"] for s in samples if s.get("audio")], | |
| "recordings": recordings} | |
| def lab_minerva(a: SupabaseAuth = Depends(lab_only)): | |
| """The imported Minerva audio joined with its reviewed JSON sentence metadata.""" | |
| try: | |
| samples = a.lab_samples(full=True) | |
| except AuthUnavailable as e: | |
| raise lab_error(e) | |
| manifest = lab.minerva_metadata() | |
| definitions = {segment["item"]: (video, segment) for video in manifest for segment in video["segments"]} | |
| videos: dict[str, dict] = {} | |
| for s in samples: | |
| definition = definitions.get(s.get("item")) | |
| if (s.get("speaker") or "") != "מינרווה" or definition is None: | |
| continue | |
| meta_video, segment = definition | |
| video_id = meta_video["id"] | |
| stored = s.get("result") or {} | |
| excluded = stored.get("_lab_excluded_words") or {} | |
| if not isinstance(excluded, dict): | |
| excluded = {} | |
| video = videos.setdefault(video_id, {"id": video_id, "title": meta_video["title"], | |
| "filename": meta_video["filename"], "samples": []}) | |
| video["samples"].append({"id": s["id"], "item": s["item"], "line": segment["line"], | |
| "start": segment["start"], "end": segment["end"], | |
| "ar": segment["ar"], "tr": segment["tr"], "he": segment["he"], | |
| "ipa": segment["ipa"], | |
| "score": s.get("score"), "heard_ipa": s.get("heard_ipa") or "", | |
| "flagged": s.get("flagged") or [], | |
| "unrelated": bool(stored.get("unrelated")), "audio": bool(s.get("audio")), | |
| "excluded_words": {str(k): str(v) for k, v in excluded.items()}}) | |
| order = [video["id"] for video in manifest] | |
| out = list(videos.values()) | |
| out.sort(key=lambda v: order.index(v["id"]) if v["id"] in order else len(order)) | |
| for video in out: | |
| video["samples"].sort(key=lambda s: s["line"]) | |
| return {"videos": out, "model": MODEL_ID, "version": build_version()} | |
| def lab_delete_samples(speaker: str | None = Query(None), everyone: bool = Query(False), | |
| a: SupabaseAuth = Depends(lab_only)): | |
| """Deletes the lab's recordings, rows and files, to record the list afresh: one `speaker`'s ("" the unnamed), | |
| or with `everyone` every speaker's. One of the two is required, so a missing parameter never deletes all. | |
| {"deleted": how many recordings}.""" | |
| if (speaker is None) == (not everyone): | |
| raise HTTPException(400, "give a speaker, or everyone=true") | |
| try: | |
| n = a.delete_lab_samples(None if everyone else " ".join(speaker.split())[:MAX_SPEAKER]) | |
| except AuthUnavailable as e: | |
| raise lab_error(e) | |
| log.info("lab: %d recordings deleted (%s)", n, "every speaker" if everyone else f"speaker {speaker!r}") | |
| return {"deleted": n} | |
| def lab_sample_audio(sample_id: int, a: SupabaseAuth = Depends(lab_only)): | |
| """Streams a private saved lab recording to a teacher, including imported Minerva segments.""" | |
| try: | |
| found = a.lab_audio(sample_id) | |
| except AuthUnavailable as e: | |
| raise lab_error(e) | |
| if found is None: | |
| raise HTTPException(404, "להקלטה הזו אין קובץ שמור") | |
| data, media_type = found | |
| return Response(data, media_type=media_type, headers={"Cache-Control": "private, no-store"}) | |
| def lab_exclude_word(sample_id: int, body: dict = Body(...), a: SupabaseAuth = Depends(lab_only)): | |
| """Includes or excludes one word from Minerva evaluation metrics, without changing the pipeline's score.""" | |
| word, excluded = body.get("word"), body.get("excluded") | |
| if not isinstance(word, int) or word < 0 or not isinstance(excluded, bool): | |
| raise HTTPException(400, "invalid word exclusion") | |
| try: | |
| row = next((s for s in a.lab_samples(full=True) if s.get("id") == sample_id), None) | |
| except AuthUnavailable as e: | |
| raise lab_error(e) | |
| if row is None: | |
| raise HTTPException(404, "no such lab recording") | |
| result = row.get("result") or {} | |
| if word >= len(result.get("words") or []): | |
| raise HTTPException(400, "invalid word index") | |
| saved_marks = result.get("_lab_excluded_words") or {} | |
| marks = {str(k): str(v) for k, v in saved_marks.items()} if isinstance(saved_marks, dict) else {} | |
| if excluded: | |
| reason = " ".join(str(body.get("reason") or "").split())[:200] | |
| marks[str(word)] = reason or "לא עבר אימות" | |
| else: | |
| marks.pop(str(word), None) | |
| result["_lab_excluded_words"] = marks | |
| try: | |
| a.update_lab_result(sample_id, result) | |
| except AuthUnavailable as e: | |
| raise lab_error(e) | |
| return {"id": sample_id, "excluded_words": marks} | |
| def lab_rescore_sample(sample_id: int, trace: bool = Query(False), setting: str = Query(""), | |
| a: SupabaseAuth = Depends(lab_only)): | |
| """A lab recording scored again by the current scorer (as practice scores, with the native baseline), nothing | |
| kept: {"id", "result": what the benchmark reads of it (lab.compact)}. The page rescores the recordings one by | |
| one and sends the results to POST /api/lab/results. | |
| `trace`: the whole result with the teacher's stage-by-stage trace instead, for the trace dashboard. `setting` | |
| (a lab.SWEEP key, "gated|wide"): scored under that GOP setting, its native baseline rebuilt under it too.""" | |
| if setting and setting not in lab.SWEEP_KEYS: | |
| raise HTTPException(400, "unknown setting") | |
| try: | |
| row = a.lab_sample(sample_id) | |
| found = a.lab_audio(sample_id) if row else None | |
| except AuthUnavailable as e: | |
| raise lab_error(e) | |
| phrase = lab.sample_phrase(row) if row else None | |
| if phrase is None: | |
| raise HTTPException(404, "no such lab recording") | |
| if found is None: | |
| raise HTTPException(404, "להקלטה הזו אין קובץ שמור") | |
| try: | |
| wav, trim_start = decode_audio_with_timing(found[0]) | |
| except ValueError: | |
| raise HTTPException(400, "could not decode audio") | |
| scorer = app.state.scorer | |
| if setting: | |
| pool, window = lab.SWEEP_KEYS[setting] | |
| scorer = scorer.with_gop(ratio_pool=pool, vowel_window=window) | |
| refs = sweep_references(scorer, phrase, setting) | |
| else: | |
| refs = app.state.refs.get(phrase["key"], phrase) if app.state.refs else [] | |
| result = scorer.score(wav, phrase, refs, diagnostics=trace) | |
| if trace and result.get("trace"): | |
| result["trace"]["audio"] = {"trim_start_ms": round(1000 * trim_start / SAMPLE_RATE, 1)} | |
| if not trace: | |
| return {"id": sample_id, "result": lab.compact(result)} | |
| g = scorer.cfg.gop | |
| excluded = (row.get("result") or {}).get("_lab_excluded_words") or {} | |
| return {"id": sample_id, "result": result, "setting": lab.sweep_key(g.ratio_pool, g.vowel_window), | |
| "sample": {**{k: row.get(k) for k in ("id", "item", "speaker", "label", "mistake")}, | |
| "excluded_words": excluded if isinstance(excluded, dict) else {}}} | |
| # The sweep (POST /api/lab/samples/{id}/sweep) rebuilds each native baseline under every setting: the voices' | |
| # posteriors are kept by (voice, wav hash), their References by setting too, for the life of the server. | |
| sweep_emits: dict[tuple[str, str], tuple] = {} | |
| sweep_refs: dict[tuple, object] = {} | |
| def sweep_references(scorer: Scorer, phrase: dict, setting: str) -> list: | |
| """The native baseline of `phrase` under `scorer`'s setting, from the TTS voices whose WAV is cached (the first | |
| voice's is made on the way, as a rescore makes it). Empty without TTS.""" | |
| refs = app.state.refs | |
| if refs is None: | |
| return [] | |
| refs.get(phrase["key"], phrase) | |
| text, out = point_arabic(*phrase["variants"][0]), [] | |
| for voice in refs.voices: | |
| try: | |
| if not refs.tts.cached(text, voice): | |
| continue | |
| data, _ = refs.tts.wav(text, voice) | |
| except Exception as e: # provider down: score without this voice, as References does | |
| log.warning("sweep: no %s voice for %r: %s", voice, text, e) | |
| continue | |
| emitted = (voice, hashlib.sha1(data).hexdigest()) | |
| if emitted not in sweep_emits: | |
| if len(sweep_emits) > 400: | |
| sweep_emits.clear(); sweep_refs.clear() | |
| wav = decode_audio(data) | |
| logp = app.state.scorer.emit(wav) | |
| sweep_emits[emitted] = (logp, frame_energy(wav, len(logp))) | |
| key = (*emitted, phrase["key"], setting, scorer.disabled) | |
| if key not in sweep_refs: | |
| logp, energy = sweep_emits[emitted] | |
| sweep_refs[key] = scorer.reference_logp(logp, phrase, energy) | |
| if sweep_refs[key] is not None: | |
| out.append(sweep_refs[key]) | |
| return out | |
| def lab_sweep_sample(sample_id: int, a: SupabaseAuth = Depends(lab_only)): | |
| """A lab recording scored under each GOP setting of lab.SWEEP (GOP_RATIO_POOL x GOP_VOWEL_WINDOW), nothing kept. | |
| The model runs once; each setting scores its posteriors against the native baseline rebuilt under it. | |
| {"id", "results": {setting: what the benchmark reads (lab.compact)}}; the page sends them to POST /api/lab/sweep.""" | |
| try: | |
| row = a.lab_sample(sample_id) | |
| found = a.lab_audio(sample_id) if row else None | |
| except AuthUnavailable as e: | |
| raise lab_error(e) | |
| phrase = lab.sample_phrase(row) if row else None | |
| if phrase is None: | |
| raise HTTPException(404, "no such lab recording") | |
| if found is None: | |
| raise HTTPException(404, "להקלטה הזו אין קובץ שמור") | |
| try: | |
| wav = decode_audio(found[0]) | |
| except ValueError: | |
| raise HTTPException(400, "could not decode audio") | |
| base = app.state.scorer | |
| logp = base.emit(wav) | |
| energy, duration = frame_energy(wav, len(logp)), len(wav) / SAMPLE_RATE | |
| results = {} | |
| for setting, (pool, window) in lab.SWEEP_KEYS.items(): | |
| scorer = base.with_gop(ratio_pool=pool, vowel_window=window) | |
| refs = sweep_references(scorer, phrase, setting) | |
| results[setting] = lab.compact(scorer.score_logp(logp, phrase, refs, False, duration, energy)) | |
| return {"id": sample_id, "results": results} | |
| def lab_sweep_results(body: dict = Body(...), a: SupabaseAuth = Depends(lab_only)): | |
| """The benchmark of every setting from the sweeps of the recordings ({"results": {sample id: {setting: result}}}): | |
| {"settings": [{"key", "pool", "window", "quality", "partial", "totals", "vowels" (lab.vowel_stats), | |
| "changed": how many sound scores differ from max|wide's, "sounds"}] best first, "current": the server's own | |
| setting, "recordings", "model", "version"}. `quality` is lab.quality (balanced accuracy).""" | |
| given = body.get("results") | |
| if not isinstance(given, dict) or len(given) > 5000: | |
| raise HTTPException(400, "invalid results") | |
| try: | |
| results = {int(k): v for k, v in given.items() if isinstance(v, dict)} | |
| except ValueError: | |
| raise HTTPException(400, "invalid results") | |
| try: | |
| samples = [s for s in a.lab_samples(full=True) if s["id"] in results] | |
| except AuthUnavailable as e: | |
| raise lab_error(e) | |
| keep = ("recordings", "unrelated", "right", "false_flags", "planted", "caught", "frr", "detection", "good_share") | |
| ref = lab.sweep_key("max", None) # the scoring before pooling and windows: what each setting changed of it | |
| scores = lambda r: [p.get("score") for w in r.get("words", []) for p in w.get("phonemes", [])] | |
| settings = [] | |
| for setting, (pool, window) in lab.SWEEP_KEYS.items(): | |
| per = {i: r[setting] for i, r in results.items() if isinstance(r.get(setting), dict)} | |
| mine = [s for s in samples if s["id"] in per] | |
| totals = lab.benchmark(mine, per)["totals"] | |
| settings.append({"key": setting, "pool": pool, "window": "wide" if window is None else window, | |
| "quality": lab.quality(totals), "partial": lab.quality_partial(totals), | |
| "totals": {k: totals[k] for k in keep}, "vowels": lab.vowel_stats(mine, per), | |
| # the sounds this setting scored otherwise than `ref`, of the sounds both scored | |
| "changed": sum(a != b for i, r in results.items() if isinstance(r.get(ref), dict) | |
| and i in per for a, b in zip(scores(per[i]), scores(r[ref]))), | |
| "sounds": sum(len(scores(per[i])) for i in per)}) | |
| # by quality; its ties (the flags rarely see a vowel) by how well the vowels separate, then how few are weak | |
| settings.sort(key=lambda r: (r["quality"] is None, -(r["quality"] or 0), -(r["vowels"]["auc"] or 0), | |
| r["vowels"]["weak"] if r["vowels"]["weak"] is not None else 1, | |
| -(r["vowels"]["mean"] or 0))) | |
| g = app.state.scorer.cfg.gop | |
| return {"settings": settings, "current": lab.sweep_key(g.ratio_pool, g.vowel_window), "recordings": len(samples), | |
| "model": MODEL_ID, "version": build_version()} | |
| def lab_rescored_results(body: dict = Body(...), a: SupabaseAuth = Depends(lab_only)): | |
| """The benchmark of rescored recordings ({"results": {sample id: result}}, from the rescore above), beside the | |
| benchmark of the same recordings as they were stored: {"now", "stored", "model", "version"}.""" | |
| given = body.get("results") | |
| if not isinstance(given, dict) or len(given) > 5000: | |
| raise HTTPException(400, "invalid results") | |
| try: | |
| results = {int(k): v for k, v in given.items() if isinstance(v, dict)} | |
| except ValueError: | |
| raise HTTPException(400, "invalid results") | |
| try: | |
| samples = [s for s in a.lab_samples(full=True) if s["id"] in results] | |
| except AuthUnavailable as e: | |
| raise lab_error(e) | |
| try: | |
| reports = a.list_flags("confirmed") | |
| except AuthUnavailable as e: | |
| log.warning("confirmed flags not read for the lab: %s", e) | |
| reports = [] | |
| cfg = getattr(app.state.scorer, "cfg", None) # the env, not the code, picks the pool: the version cannot tell | |
| return {"now": lab.benchmark(samples, results, reports), "stored": lab.benchmark(samples, reports=reports), | |
| "model": MODEL_ID, "version": build_version(), | |
| "pool": cfg.gop.ratio_pool if cfg else None, | |
| "vowel_window": ("wide" if cfg.gop.vowel_window is None else f"±{cfg.gop.vowel_window}") if cfg else None} | |
| def lab_rescore_flag(flag_id: int, a: SupabaseAuth = Depends(lab_only)): | |
| """A flagged recording scored again by the current scorer, against what was kept with the flag: {"old", | |
| "new": score and flagged sounds, "issues": the sounds a teacher marked as wrongly flagged, each with | |
| `still` (flagged again), "result": the new result with its trace}.""" | |
| try: | |
| row = a.flag(flag_id) | |
| found = a.flag_audio(flag_id) if row else None | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"Supabase לא זמין: {e}") | |
| if row is None: | |
| raise HTTPException(404, "no such flag") | |
| if found is None: | |
| raise HTTPException(404, "לדיווח הזה אין הקלטה שמורה") | |
| try: | |
| wav = decode_audio(found[0]) | |
| except ValueError: | |
| raise HTTPException(400, "could not decode audio") | |
| phrase = lab.flag_phrase(row) | |
| key = next((i for i, p in enumerate(PHRASES) if p is phrase), ("flag", phrase.get("key"))) | |
| refs = app.state.refs.get(key, phrase) if app.state.refs else [] | |
| result = app.state.scorer.score(wav, phrase, refs, diagnostics=True) | |
| if result.get("error"): | |
| raise HTTPException(400, result["message"]) | |
| old = row.get("result") or {} | |
| return {"old": {"score": row.get("score"), "flagged": lab.flagged_sounds(old)}, | |
| "new": {"score": result["score"], "flagged": lab.flagged_sounds(result)}, | |
| "issues": [{**i, "still": lab.still_flagged(result, i)} for i in row.get("false_issues") or []], | |
| "result": result} | |
| def calibration_error(e: AuthUnavailable) -> HTTPException: | |
| if "answered 404" in str(e): | |
| return HTTPException(503, f"הטבלה calibrations לא קיימת. הריצו ב-SQL Editor: {CREATE_CALIBRATIONS}") | |
| return HTTPException(503, f"כללי הכיול לא זמינים: {e}") | |
| def reload_calibrations(a: SupabaseAuth) -> None: | |
| rows = a.calibrations("applied") | |
| app.state.scorer.set_calibrations([r for r in rows if not r.get("model") or r.get("model") == MODEL_ID]) | |
| def reload_scoring_rules(a: SupabaseAuth) -> None: | |
| app.state.scorer.set_disabled(r["id"] for r in a.scoring_rules() if not r.get("active", True)) | |
| def scoring_rules_error(e: AuthUnavailable) -> HTTPException: | |
| if "answered 404" in str(e): | |
| return HTTPException(503, f"הטבלה scoring_rules לא קיימת. הריצו ב-SQL Editor: {CREATE_SCORING_RULES}") | |
| return HTTPException(503, f"כללי ההגייה לא זמינים: {e}") | |
| def scoring_rules(a: SupabaseAuth = Depends(teacher_only)): | |
| """Every rule taken from pronunciation-rules.md, with whether it is on. All are on until a teacher says otherwise. | |
| `stored` is False while the table is missing: the list still shows, but cannot be changed.""" | |
| s: Scorer = app.state.scorer | |
| stored, rows = True, {} | |
| try: | |
| rows = {r["id"]: r for r in a.scoring_rules()} | |
| except AuthUnavailable as e: | |
| if "answered 404" not in str(e): | |
| raise scoring_rules_error(e) | |
| stored = False | |
| detectable = check_detectable(s.rules, lambda ph: s.vocab.sound_id(ph) is not None, s.vocab.model_id) | |
| return {"stored": stored, "create_sql": None if stored else CREATE_SCORING_RULES, "rules": [ | |
| {"id": t.id, "group": t.group, "section": t.section, "title": t.title, "description": t.description, | |
| "active": t.id not in s.disabled, | |
| "updated_by": rows.get(t.id, {}).get("updated_by", ""), "updated_at": rows.get(t.id, {}).get("updated_at"), | |
| # a section-3 rule this model cannot hear at all (vowel length): switching it changes nothing | |
| "detectable": detectable.get(t.id.removeprefix("rule_"), {}).get("detectable", True)} | |
| for t in TOGGLES]} | |
| def switch_scoring_rule(rule_id: str, body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only), | |
| user: dict | None = Depends(signed_in)): | |
| if rule_id not in {t.id for t in TOGGLES} or not isinstance(body.get("active"), bool): | |
| raise HTTPException(400, "unknown rule or invalid switch") | |
| try: | |
| a.set_scoring_rule(rule_id, body["active"], user["email"].lower()) | |
| reload_scoring_rules(a) | |
| except AuthUnavailable as e: | |
| raise scoring_rules_error(e) | |
| return {"ok": True, "disabled": sorted(app.state.scorer.disabled)} | |
| def calibration_proposals(a: SupabaseAuth = Depends(teacher_only)): | |
| try: | |
| return {"calibrations": a.calibrations()} | |
| except AuthUnavailable as e: | |
| raise calibration_error(e) | |
| def attempt_patterns(a: SupabaseAuth = Depends(teacher_only)): | |
| """Repeated phoneme judgements among students, aggregated without returning identities or recordings.""" | |
| try: | |
| rows = a.all_attempt_issues() | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"דפוסי התלמידים לא זמינים: {e}") | |
| if rows is None: | |
| raise HTTPException(503, f"לטבלה attempts חסרות עמודות האבחון. הריצו ב-SQL Editor: {MIGRATE_ATTEMPT_ISSUES}") | |
| phrase_students: dict[str, set[str]] = {} | |
| patterns: dict[tuple[str, str, str, str, str], dict] = {} | |
| for row in rows: | |
| if row.get("model") and row.get("model") != MODEL_ID: | |
| continue | |
| phrase, student = str(row.get("phrase", "")), str(row.get("user_id", "")) | |
| phrase_students.setdefault(phrase, set()).add(student) | |
| seen = set() | |
| for issue in row.get("issues") or []: | |
| if not isinstance(issue, dict): | |
| continue | |
| expected, heard = str(issue.get("expected", ""))[:20], str(issue.get("heard", ""))[:20] | |
| pair = (expected, heard) | |
| if not expected or not heard or pair in seen: | |
| continue | |
| seen.add(pair) | |
| key = (phrase, expected, heard, str(row.get("model", "")), str(row.get("version", ""))) | |
| p = patterns.setdefault(key, {"phrase": phrase, "expected": expected, "heard": heard, | |
| "model": key[3], "version": key[4], "attempts": 0, | |
| "students": set(), "scores": []}) | |
| p["attempts"] += 1 | |
| p["students"].add(student) | |
| p["scores"].append(row.get("score", 0)) | |
| result = [] | |
| for p in patterns.values(): | |
| students = len(p.pop("students")); scores = p.pop("scores"); total = len(phrase_students.get(p["phrase"], ())) | |
| result.append({**p, "students": students, "student_rate": round(students / total, 2) if total else 0, | |
| "average_score": round(sum(scores) / len(scores)) if scores else 0}) | |
| result.sort(key=lambda p: (p["students"], p["attempts"]), reverse=True) | |
| return {"patterns": result} | |
| def propose_calibration(body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only), | |
| user: dict | None = Depends(signed_in)): | |
| expected, heard = str(body.get("expected", ""))[:20], str(body.get("heard", ""))[:20] | |
| evidence = body.get("evidence", {}) | |
| if not expected or not heard or not isinstance(evidence, dict): | |
| raise HTTPException(400, "invalid calibration proposal") | |
| row = {"expected": expected, "heard": heard, "floor": 70, "scope": "global", "phrase": "", | |
| "evidence": evidence, "model": MODEL_ID, "status": "draft", "proposed_by": user["email"].lower()} | |
| try: | |
| return {"calibration": a.add_calibration(row)} | |
| except AuthUnavailable as e: | |
| raise calibration_error(e) | |
| def decide_calibration(calibration_id: int, body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only), | |
| user: dict | None = Depends(signed_in)): | |
| status, scope, phrase = body.get("status"), body.get("scope", "global"), str(body.get("phrase", ""))[:200] | |
| floor = body.get("floor", 70) | |
| if status not in CALIBRATION_STATUSES or scope not in {"global", "phrase"} or isinstance(floor, bool): | |
| raise HTTPException(400, "invalid calibration decision") | |
| try: | |
| floor = float(floor) | |
| except (TypeError, ValueError): | |
| raise HTTPException(400, "invalid calibration floor") | |
| if not 0 <= floor <= 100 or scope == "phrase" and not phrase: | |
| raise HTTPException(400, "invalid calibration scope or floor") | |
| values = {"status": status, "scope": scope, "phrase": phrase if scope == "phrase" else "", "floor": floor, | |
| "reviewed_by": user["email"].lower(), "reviewed_at": datetime.now(timezone.utc).isoformat()} | |
| try: | |
| a.update_calibration(calibration_id, values) | |
| reload_calibrations(a) | |
| except AuthUnavailable as e: | |
| raise calibration_error(e) | |
| return {"ok": True, "active": len(app.state.scorer.calibrations)} | |
| def delete_calibration(calibration_id: int, a: SupabaseAuth = Depends(teacher_only)): | |
| """Deletes a draft or applied calibration rule and immediately refreshes the scorer's active rules.""" | |
| try: | |
| if not a.delete_calibration(calibration_id): | |
| raise HTTPException(404, "calibration proposal not found") | |
| reload_calibrations(a) | |
| except AuthUnavailable as e: | |
| raise calibration_error(e) | |
| return {"ok": True, "active": len(app.state.scorer.calibrations)} | |
| def class_store_error(e: AuthUnavailable) -> str: | |
| """The teacher's message when a class cannot be written; a 404 means Supabase has no `classes` table.""" | |
| if "answered 404" in str(e): | |
| return ("הטבלה classes לא קיימת ב-Supabase. צרו אותה ב-SQL Editor (הפקודה ב-README); " | |
| "אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';") | |
| if any(col in str(e) for col in ("nicknames", "lessons", "homework", "'due'", "dailies", "subject", "teachers")): # a table made before them | |
| return f"לטבלה classes חסרות עמודות. הריצו ב-SQL Editor של Supabase: {MIGRATE_CLASSES}" | |
| return f"Supabase לא זמין: {e}" | |
| def class_subject(a: SupabaseAuth, id: int | None, body: dict, actor: dict | None) -> str: | |
| """The subject of a class being saved: a stored class keeps its own; a new one takes its admin's, and one | |
| opened by a developer (who has none) the subject the page asks for ("subject", as SUBJECT_LABELS spells it).""" | |
| if id is not None: | |
| try: | |
| stored = next((c for c in managed_classes(a, actor, fresh=True) if c["id"] == id), None) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, class_store_error(e)) | |
| return str((stored or {}).get("subject") or "").strip() | |
| own = str((actor or {}).get("subject") or "").strip() | |
| asked = " ".join(str(body.get("subject") or "").split()) | |
| return own or (asked if subject_code(asked) else "") | |
| def save_class(a: SupabaseAuth, id: int | None, body: dict, actor: dict | None = None) -> dict: | |
| """Checks a class from the panel ({"name", "emails": the student list, see `class_members`, "lessons": lesson | |
| ids, "homeworks": [{"topic": a topic of one of them, "due": its ISO date, or ""}], each topic once; or, as | |
| before a class could have several, "homework" and "due" for a single one; "dailies": [{"video": the id of | |
| a daily video, "date": the ISO date the class watches it, or "" for none}], each video once; without it | |
| the class's daily videos stay as they are) and stores it. A student may be in several classes, e.g. a beginners' class and an advanced one.""" | |
| name = " ".join(str(body.get("name", "")).split()) # one space between words, none around | |
| if not name or len(name) > 80: | |
| raise HTTPException(400, "לכיתה צריך שם (עד 80 תווים)") | |
| try: | |
| emails, nicks = class_members(str(body.get("emails", ""))) | |
| except ValueError as e: | |
| raise HTTPException(400, f"בשורה הזו אין כתובת מייל: {e}") | |
| subject = class_subject(a, id, body, actor) | |
| math = subject_code(subject) == "math" # a math class studies units of the math path, and has no homework or daily videos yet | |
| known = [u["id"] for u in algebra9()["units"]] if math else ALL_LESSONS | |
| lessons = body.get("lessons") | |
| if not isinstance(lessons, list) or not lessons or not set(map(str, lessons)) <= set(known): | |
| raise HTTPException(400, "בחרו לפחות יחידה אחת לכיתה" if math else "בחרו לפחות שיעור אחד לכיתה") | |
| lessons = [l for l in known if l in map(str, lessons)] | |
| teachers = body.get("teachers") | |
| if teachers is not None: | |
| if not isinstance(teachers, list) or not all(isinstance(e, str) for e in teachers): | |
| raise HTTPException(400, "רשימת המורים אינה תקינה") | |
| teachers = list(dict.fromkeys(e.strip().lower() for e in teachers if e.strip())) | |
| if not set(teachers) <= set(emails): | |
| raise HTTPException(400, "מורה של הכיתה צריך להיות אחד מהתלמידים הרשומים בה") | |
| listed = [] if math else body.get("homeworks") | |
| if listed is None: | |
| listed = [{"topic": body.get("homework"), "due": body.get("due")}] | |
| if not isinstance(listed, list) or len(listed) > MAX_HOMEWORKS or not all(isinstance(h, dict) for h in listed): | |
| raise HTTPException(400, "שיעורי הבית לא תקינים") | |
| homeworks, lesson_of = [], topics() | |
| for h in listed: | |
| topic, due = str(h.get("topic") or ""), str(h.get("due") or "") | |
| if not topic: # no date without homework | |
| continue | |
| if lesson_of.get(topic) not in lessons: | |
| raise HTTPException(400, "שיעורי הבית הם נושא מאחד משיעורי הכיתה") | |
| if any(topic == was["topic"] for was in homeworks): | |
| raise HTTPException(400, "אותו נושא מופיע פעמיים בשיעורי הבית") | |
| try: | |
| homeworks.append({"topic": topic, "due": date.fromisoformat(due).isoformat() if due else None}) | |
| except ValueError: | |
| raise HTTPException(400, "התאריך של שיעורי הבית לא תקין") | |
| dailies = None if math else body.get("dailies") | |
| if dailies is not None: | |
| if not isinstance(dailies, list) or len(dailies) > MAX_DAILIES or not all(isinstance(d, dict) for d in dailies): | |
| raise HTTPException(400, "הסרטונים היומיים לא תקינים") | |
| known, days = {v["id"] for v in daily_rows()}, {} | |
| for d in dailies: | |
| day = str(d.get("date") or "") | |
| if not day: # a video without a day is not shown to the class | |
| continue | |
| if not isinstance(d.get("video"), int) or d["video"] not in known or d["video"] in days: | |
| raise HTTPException(400, "סרטון יומי שאינו בספרייה, או שמופיע פעמיים. רעננו ונסו שוב") | |
| try: | |
| days[d["video"]] = date.fromisoformat(day).isoformat() | |
| except ValueError: | |
| raise HTTPException(400, "התאריך של הסרטון היומי לא תקין") | |
| dailies = [{"video": v, "date": day} for v, day in days.items()] | |
| try: | |
| # a class's name is its own: the same words in other letter case or spacing are the same name | |
| same = lambda other: " ".join(other.split()).casefold() == name.casefold() | |
| visible = managed_classes(a, actor, fresh=True) | |
| if id is not None and not any(c["id"] == id for c in visible): | |
| raise HTTPException(404, "הכיתה לא נמצאה") | |
| if taken := next((c["name"] for c in visible if c["id"] != id and same(c["name"])), None): | |
| raise HTTPException(409, f"כבר יש כיתה בשם {taken}") | |
| # a class that has no daily videos, and had none, is saved without them: a table made before them takes it | |
| had = any(c["id"] == id and c.get("dailies") for c in visible) | |
| if (id is None and hasattr(a, "access_for") and actor and "admin" in actor.get("roles", ()) | |
| and "developer" not in actor.get("roles", ()) and not subject): | |
| raise HTTPException(409, "לא הוגדר מקצוע למנהל/ת. מפתח/ת צריך להגדיר אותו בניטור.") | |
| args = (id, name, emails, nicks, lessons, homeworks, dailies if dailies or had else None, subject) | |
| if teachers is None: | |
| a.save_class(*args) | |
| else: | |
| a.save_class(*args, teachers=teachers) | |
| except ClassConflict: | |
| raise HTTPException(409, f"כבר יש כיתה בשם {name}") | |
| except KeyError: | |
| raise HTTPException(404, "הכיתה לא נמצאה") | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, class_store_error(e)) | |
| return {"ok": True} | |
| def add_class(body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only), actor: dict | None = Depends(signed_in)): | |
| return save_class(a, None, body, actor) | |
| def edit_class(class_id: int, body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only), actor: dict | None = Depends(signed_in)): | |
| return save_class(a, class_id, body, actor) | |
| def delete_class(class_id: int, a: SupabaseAuth = Depends(teacher_only), actor: dict | None = Depends(signed_in)): | |
| try: | |
| if not any(c["id"] == class_id for c in managed_classes(a, actor, fresh=True)): | |
| raise HTTPException(404, "הכיתה לא נמצאה") | |
| a.delete_class(class_id) | |
| except HTTPException: | |
| raise | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, class_store_error(e)) | |
| return {"ok": True} | |
| def videos(user: dict | None = Depends(signed_in)): | |
| """The lesson videos, each lesson's in its order on the path: id, lesson, title, url (the Vimeo or YouTube link, for a teacher only), embed | |
| (its player, for the page's frame) and pos (how many of the lesson's own topics stand before it, see | |
| `placed`) and ord. A student gets those of their classes' lessons; a teacher, and an account in no | |
| class, every one. One whose lesson the course no longer has is left out.""" | |
| if not user: | |
| return {"videos": []} | |
| mine = user["classes"] | |
| lessons = READY_LESSONS if not mine or user["teacher"] else \ | |
| [l for l in READY_LESSONS if any(l in lessons_of(c) for c in mine)] | |
| try: | |
| rows = app.state.auth.videos() | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"videos not available: {e}") | |
| # the link itself goes to teachers only: a student's page gets the player's address, which it cannot do without | |
| return {"videos": [{"id": v["id"], "lesson": v["lesson"], "title": v["title"], | |
| **({"url": v["url"]} if user["teacher"] else {}), | |
| "embed": video_embed(v["url"]), "pos": v["pos"], "ord": v["ord"]} | |
| for l in lessons for v in placed(rows, l)]} | |
| def video_store_error(e: AuthUnavailable) -> str: | |
| """The teacher's message when a video cannot be written; a 404 means Supabase has no `videos` table.""" | |
| if "answered 404" in str(e): | |
| return ("הטבלה videos לא קיימת ב-Supabase. הריצו ב-SQL Editor: " | |
| f"{CREATE_VIDEOS} אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';") | |
| if "'pos'" in str(e) or "'ord'" in str(e) or "pos" in str(e) and "column" in str(e): # a table made before the places | |
| return f"לטבלה videos חסרות עמודות. הריצו ב-SQL Editor של Supabase: {MIGRATE_VIDEOS}" | |
| return f"Supabase לא זמין: {e}" | |
| def add_video(body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only), | |
| user: dict | None = Depends(signed_in)): | |
| """Adds a video to a lesson, after its topics and the videos it has: {"lesson": the id of a ready lesson, | |
| "title", "url": a Vimeo or YouTube link}. The teacher then moves it to its place (see `order_videos`).""" | |
| lesson, title = str(body.get("lesson", "")), " ".join(str(body.get("title", "")).split()) | |
| if lesson not in READY_LESSONS: | |
| raise HTTPException(400, "סרטון מוסיפים לשיעור מוכן") | |
| if not title or len(title) > MAX_TITLE: | |
| raise HTTPException(400, f"לסרטון צריך שם (עד {MAX_TITLE} תווים)") | |
| url = video_link(str(body.get("url", ""))) | |
| if not url: | |
| raise HTTPException(400, "זה לא קישור לסרטון ב-Vimeo או ב-YouTube. הדביקו קישור כמו https://vimeo.com/1233611297/d46ac373c8 או https://youtu.be/dQw4w9WgXcQ") | |
| try: | |
| a.add_video(lesson, title, url, LESSON_TOPICS[lesson], next_ord(a, lesson)) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, video_store_error(e)) | |
| a.log_event("content", user, title, {"type": "video", "lesson": lesson}) | |
| return {"ok": True} | |
| def next_ord(a: SupabaseAuth, lesson: str) -> int: | |
| """The turn of a new video or text: after the lesson's topics and whatever was added to it before.""" | |
| return max((r.get("ord") or 0 for r in a.videos(fresh=True) + a.texts(fresh=True) if r["lesson"] == lesson), default=0) + 1 | |
| def order_content(body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only)): | |
| """Places what the teacher added to a lesson among its topics: {"lesson", "order": [{"kind": "video" or | |
| "text", "id", "pos"}]}, every video and text of the lesson once, in their order on the path; `pos` is how | |
| many of the lesson's own topics stand before it.""" | |
| lesson, order = str(body.get("lesson", "")), body.get("order") | |
| if lesson not in READY_LESSONS or not isinstance(order, list): | |
| raise HTTPException(400, "unknown lesson") | |
| try: | |
| places = [(str(o["kind"]), int(o["id"]), int(o["pos"])) for o in order] | |
| except (KeyError, TypeError, ValueError): | |
| raise HTTPException(400, "each one needs a kind, an id and a place") | |
| try: | |
| mine = {("video", v["id"]) for v in a.videos(fresh=True) if v["lesson"] == lesson} | \ | |
| {("text", t["id"]) for t in a.texts(fresh=True) if t["lesson"] == lesson} | |
| if len(places) != len(mine) or {(kind, i) for kind, i, _ in places} != mine: # added or deleted meanwhile, in another tab | |
| raise HTTPException(409, "תוכן השיעור השתנה. רעננו ונסו שוב") | |
| if any(not 0 <= pos <= LESSON_TOPICS[lesson] for _, _, pos in places): | |
| raise HTTPException(400, "no such place in the lesson") | |
| a.place([(kind + "s", i, pos, k) for k, (kind, i, pos) in enumerate(places)]) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, text_store_error(e) if "texts" in str(e) else video_store_error(e)) | |
| return {"ok": True} | |
| def texts(user: dict | None = Depends(signed_in)): | |
| """The teacher's texts as the page takes topics and phrases (see `/api/phrases`), for the lessons a video is | |
| given for (see `videos`). "categories": a topic per text, laid out as a story: id ("t<n>"), name, lesson, story, | |
| text (the text's own id), voices (a dialogue's two, by name), and pos and ord as a video has them; a teacher | |
| also gets "guessed", the words that were read by rule. "phrases": the lines, in each text's order.""" | |
| if not user: | |
| return {"categories": [], "phrases": []} | |
| mine = user["classes"] | |
| lessons = READY_LESSONS if not mine or user["teacher"] else \ | |
| [l for l in READY_LESSONS if any(l in lessons_of(c) for c in mine)] | |
| rows = [r for l in lessons for r in placed(text_rows(), l)] | |
| return {"categories": [{"id": f"t{r['id']}", "name": r["title"], "lesson": r["lesson"], "story": True, "text": r["id"], | |
| **({"voices": r["voices"]} if r["voices"] else {}), "pos": r["pos"], "ord": r["ord"], | |
| **({"guessed": built(r)[1]} if user["teacher"] else {})} for r in rows], | |
| "phrases": [{"id": i, "cat": p["cat"], "he": p["he"], "tr": p["tr"], "ar": p["variants"][0][0], | |
| "ar_pointed": point_arabic(*p["variants"][0]), "seq": p["seq"]} | |
| for i, p in text_bank(rows).items()]} | |
| def text_store_error(e: AuthUnavailable) -> str: | |
| """The teacher's message when a text cannot be written; a 404 means Supabase has no `texts` table.""" | |
| if "answered 404" in str(e): | |
| return ("הטבלה texts לא קיימת ב-Supabase. הריצו ב-SQL Editor: " | |
| f"{CREATE_TEXTS} אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';") | |
| return f"Supabase לא זמין: {e}" | |
| def add_text(body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only), | |
| user: dict | None = Depends(signed_in)): | |
| """Adds a text to a lesson, after its topics and what was added before: {"lesson": the id of a ready lesson, | |
| "title", "he": its lines in Hebrew, "tr": the same lines in Arabic written in Hebrew letters, "voices": a | |
| dialogue's two voices by name (the odd lines', the even lines'); none for a story, which the student's own | |
| voice reads}. Answers with "guessed": the words the course does not have, which were read by rule.""" | |
| lesson, title = str(body.get("lesson", "")), " ".join(str(body.get("title", "")).split()) | |
| if lesson not in READY_LESSONS: | |
| raise HTTPException(400, "טקסט מוסיפים לשיעור מוכן") | |
| if not title or len(title) > MAX_TITLE: | |
| raise HTTPException(400, f"לטקסט צריך שם (עד {MAX_TITLE} תווים)") | |
| voices = body.get("voices") or [] | |
| if not isinstance(voices, list) or voices and (len(voices) != 2 or not set(map(str, voices)) <= set(VOICE_NAMES)): | |
| raise HTTPException(400, "לשיחה בוחרים שני קולות מהרשימה") | |
| he, tr = ([" ".join(str(x).split()) for x in body.get(k) or []] if isinstance(body.get(k), list) else [] for k in ("he", "tr")) | |
| if not he or not tr: | |
| raise HTTPException(400, "צריך את השורות פעמיים: בעברית, ובערבית בתעתיק עברי") | |
| if len(he) != len(tr): | |
| raise HTTPException(400, f"מספר השורות לא זהה: {len(he)} בעברית ו-{len(tr)} בערבית") | |
| if len(he) > MAX_LINES or any(len(x) > MAX_LINE for x in he + tr): | |
| raise HTTPException(400, f"טקסט הוא עד {MAX_LINES} שורות, ושורה עד {MAX_LINE} תווים") | |
| for n, (h, t) in enumerate(zip(he, tr), 1): | |
| if not h or not read_line(t)[0]: | |
| raise HTTPException(400, f"בשורה {n} חסר טקסט, או שבערבית שלה אין אותיות עבריות") | |
| try: | |
| a.add_text(lesson, title, list(map(str, voices)), [[h, t] for h, t in zip(he, tr)], LESSON_TOPICS[lesson], next_ord(a, lesson)) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, text_store_error(e)) | |
| a.log_event("content", user, title, {"type": "text", "lesson": lesson}) | |
| return {"ok": True, "guessed": text_phrases("", [[h, t] for h, t in zip(he, tr)])[1]} | |
| def delete_text(text_id: int, a: SupabaseAuth = Depends(teacher_only)): | |
| try: | |
| a.delete_text(text_id) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, text_store_error(e)) | |
| return {"ok": True} | |
| def delete_video(video_id: int, a: SupabaseAuth = Depends(teacher_only)): | |
| try: | |
| a.delete_video(video_id) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, video_store_error(e)) | |
| return {"ok": True} | |
| def level_store_error(table: str, e: AuthUnavailable) -> str: | |
| """The teacher's message when a daily or library video cannot be written; a 404 means Supabase has no such table.""" | |
| if "answered 404" in str(e): | |
| return (f"הטבלה {table} לא קיימת ב-Supabase. הריצו ב-SQL Editor: " | |
| f"{CREATE_LEVEL_VIDEOS[table]} אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';") | |
| if "'ord'" in str(e) or "ord" in str(e) and "column" in str(e): # a library made before its videos could be moved | |
| return f"לטבלה {table} חסרה עמודה. הריצו ב-SQL Editor של Supabase: {MIGRATE_LIBRARY} notify pgrst, 'reload schema';" | |
| return f"Supabase לא זמין: {e}" | |
| def dailies(a: SupabaseAuth = Depends(teacher_only)): | |
| """The library of daily videos, for the panel: id, level, title, url and embed (its player) of each.""" | |
| try: | |
| rows = a.level_videos("dailies") | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"daily videos not available: {e}") | |
| return {"dailies": [{"id": v["id"], "level": v["level"], "title": v["title"], "url": v["url"], | |
| "embed": video_embed(v["url"])} for v in rows if v["level"] in LEVEL_IDS]} | |
| def library(user: dict | None = Depends(signed_in)): | |
| """The video library, which stands outside the path and is one for every level and class: id, title, url | |
| (for a teacher only) and embed (its player) of each, in the teacher's order (see `order_library`).""" | |
| if not user: | |
| return {"library": []} | |
| try: | |
| rows = app.state.auth.level_videos("library") | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"library not available: {e}") | |
| return {"library": [{"id": v["id"], "title": v["title"], **({"url": v["url"]} if user["teacher"] else {}), | |
| "embed": video_embed(v["url"])} for v in rows]} | |
| def add_level_video(table: str, body: dict, a: SupabaseAuth, user: dict | None) -> dict: | |
| # a daily video is a level's; the library is one for every level (its `level` is empty) | |
| level, title = str(body.get("level", "")) if table == "dailies" else "", " ".join(str(body.get("title", "")).split()) | |
| if table == "dailies" and level not in LEVEL_IDS: | |
| raise HTTPException(400, "unknown level") | |
| if not title or len(title) > MAX_TITLE: | |
| raise HTTPException(400, f"לסרטון צריך שם (עד {MAX_TITLE} תווים)") | |
| url = video_link(str(body.get("url", ""))) | |
| if not url: | |
| raise HTTPException(400, "זה לא קישור לסרטון ב-Vimeo או ב-YouTube. הדביקו קישור כמו https://vimeo.com/1233611297/d46ac373c8 או https://youtu.be/dQw4w9WgXcQ") | |
| try: | |
| a.add_level_video(table, level, title, url) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, level_store_error(table, e)) | |
| a.log_event("content", user, title, {"type": table, "level": level}) | |
| return {"ok": True} | |
| def delete_level_video(table: str, id: int, a: SupabaseAuth) -> dict: | |
| try: | |
| a.delete_level_video(table, id) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, level_store_error(table, e)) | |
| return {"ok": True} | |
| def add_daily(body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only), | |
| user: dict | None = Depends(signed_in)): | |
| """Adds a video to a level's daily videos: {"level": a level id, "title", "url": a Vimeo or YouTube link}. | |
| A class then gives it its day (see `save_class`).""" | |
| return add_level_video("dailies", body, a, user) | |
| def delete_daily(daily_id: int, a: SupabaseAuth = Depends(teacher_only)): | |
| return delete_level_video("dailies", daily_id, a) | |
| def add_library(body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only), | |
| user: dict | None = Depends(signed_in)): | |
| """Adds a video to the library: {"title", "url": a Vimeo or YouTube link}.""" | |
| return add_level_video("library", body, a, user) | |
| def delete_library(video_id: int, a: SupabaseAuth = Depends(teacher_only)): | |
| return delete_level_video("library", video_id, a) | |
| def order_library(body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only)): | |
| """Puts the library's videos in the teacher's order: {"order": [id]}, every video of the library once.""" | |
| order = body.get("order") | |
| if not isinstance(order, list) or not all(isinstance(i, int) and not isinstance(i, bool) for i in order): | |
| raise HTTPException(400, "the order is a list of video ids") | |
| try: | |
| if len(set(order)) != len(order) or set(order) != {v["id"] for v in a.level_videos("library", fresh=True)}: # added or deleted meanwhile, in another tab | |
| raise HTTPException(409, "הספריה השתנתה. רעננו ונסו שוב") | |
| a.order_level_videos("library", order) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, level_store_error("library", e)) | |
| return {"ok": True} | |
| def rename_library(video_id: int, body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only)): | |
| """Gives a video of the library another name: {"title"}.""" | |
| title = " ".join(str(body.get("title", "")).split()) | |
| if not title or len(title) > MAX_TITLE: | |
| raise HTTPException(400, f"לסרטון צריך שם (עד {MAX_TITLE} תווים)") | |
| try: | |
| if all(v["id"] != video_id for v in a.level_videos("library", fresh=True)): | |
| raise HTTPException(404, "הסרטון כבר לא בספריה. רעננו ונסו שוב") | |
| a.rename_level_video("library", video_id, title) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, level_store_error("library", e)) | |
| return {"ok": True} | |
| def classmates(user: dict | None = Depends(signed_in)): | |
| """The signed-in student's classes: each one's name and its students, each by the nickname the teacher gave | |
| in that class, or else the name Google gave (one with neither, who has not signed in yet, is only counted). | |
| No addresses.""" | |
| if not user or not user["classes"]: | |
| return {"classes": []} | |
| try: | |
| google = {u["email"].lower(): u["name"] for u in app.state.auth.users()} | |
| except AuthUnavailable as e: # the nicknames are still known | |
| log.warning("classmates' names not read: %s", e) | |
| google = {} | |
| me, out = user["email"].lower(), [] | |
| for c in user["classes"]: | |
| named = [{"name": c["nicknames"].get(e) or google.get(e, ""), "me": e == me} for e in c["emails"]] | |
| students = sorted((s for s in named if s["name"]), key=lambda s: s["name"]) | |
| out.append({"name": c["name"], "students": students, "unnamed": len(named) - len(students)}) | |
| return {"classes": out} | |
| def math_skills(): | |
| """The math path's skill graph (see app/math_skills.py). Public, as the phrase list is.""" | |
| return algebra9() | |
| photo_counts: dict[tuple[str, str], int] = {} # (account, day) -> photos solved; in memory, so a restart resets it | |
| def photo_solver() -> PhotoSolver: | |
| """The solver on the key in use now (the panel's, else the environment's): a key changed in the panel applies.""" | |
| key = (app.state.keys.get("ANTHROPIC_API_KEY") or "").strip() | |
| if not key: | |
| raise HTTPException(503, "פתרון מתמונה לא מוגדר בשרת (חסר ANTHROPIC_API_KEY)") | |
| if getattr(app.state, "photo_key", None) != key: | |
| app.state.photo, app.state.photo_key = PhotoSolver(photo_client(key), math_model(), algebra9()["skills"]), key | |
| return app.state.photo | |
| def math_photo(photo: UploadFile = File(...), user: dict | None = Depends(signed_in)): | |
| """A photographed problem, solved by Claude (see app/math_photo.py): {readable, problem, steps: [{hint, work}], | |
| answer, skill, note, left}. `left` is how many photos the account has left today (None: no limit). 429 past | |
| DAILY_PHOTOS a day (developers and an open service are exempt), 502 when the model did not solve it.""" | |
| solver = photo_solver() | |
| day = datetime.now(LOCAL_TZ).date().isoformat() | |
| who = (user["id"], day) if user and "developer" not in user.get("roles", ()) else None | |
| if who and photo_counts.get(who, 0) >= DAILY_PHOTOS: | |
| raise HTTPException(429, f"צילמתם {DAILY_PHOTOS} תרגילים היום. נתראה מחר!") | |
| if photo.content_type not in PHOTO_TYPES: | |
| raise HTTPException(415, "אפשר לשלוח תמונה מסוג JPEG, PNG או WebP.") | |
| image = photo.file.read(MAX_PHOTO + 1) | |
| if not image: | |
| raise HTTPException(400, "התמונה ריקה.") | |
| if len(image) > MAX_PHOTO: | |
| raise HTTPException(413, "התמונה גדולה מדי.") | |
| try: | |
| out = solver.solve(image, photo.content_type) | |
| except PhotoUnavailable as e: | |
| raise HTTPException(502, str(e)) | |
| if who and out.get("readable"): # an unreadable photo does not count against the day | |
| photo_counts[who] = photo_counts.get(who, 0) + 1 | |
| return {**out, "left": DAILY_PHOTOS - photo_counts.get(who, 0) if who else None} | |
| def phrases(): | |
| return {"levels": LEVELS, "lessons": LESSONS, "categories": CATEGORIES, "games": GAMES, "phrases": public_phrases(), | |
| "voices": [{"id": i, "name": n} for n, i in VOICES.items()]} | |
| def verb_lessons(user: dict | None) -> list[str]: | |
| """Lessons available to this learner, using the existing class rules.""" | |
| if not user or user["teacher"] or not user["classes"]: | |
| return READY_LESSONS | |
| return [lesson for lesson in READY_LESSONS | |
| if any(lesson in lessons_of(course) for course in user["classes"])] | |
| def verb_game(user: dict | None = Depends(signed_in)): | |
| """Configuration for "פועל יוצא" and the complete paradigms currently unlocked.""" | |
| return app.state.verb_game.metadata(verb_lessons(user)) | |
| def verb_question(body: dict = Body(...), user: dict | None = Depends(signed_in)): | |
| """Create a Level 1--5 exercise; answers remain server-side.""" | |
| try: | |
| level = int(body.get("level", 1)) | |
| mastery = body.get("mastery") if isinstance(body.get("mastery"), dict) else {} | |
| recent = body.get("recent") if isinstance(body.get("recent"), list) else [] | |
| return app.state.verb_game.question(level, verb_lessons(user), mastery, list(map(str, recent[-10:]))) | |
| except (TypeError, ValueError) as e: | |
| raise HTTPException(400, str(e)) | |
| except LookupError as e: | |
| raise HTTPException(404, str(e)) | |
| def verb_answer(tasks: BackgroundTasks, body: dict = Body(...), user: dict | None = Depends(signed_in)): | |
| """Grade one opaque question once and return feedback plus measured client response time.""" | |
| try: | |
| result = app.state.verb_game.answer(str(body.get("question_id", "")), body.get("answer"), | |
| int(body.get("response_ms", 0))) | |
| if user: | |
| tasks.add_task(app.state.auth.log_event, "game", user, "פועל יוצא", {"level": result.get("level")}) | |
| return result | |
| except (TypeError, ValueError) as e: | |
| raise HTTPException(400, str(e)) | |
| except LookupError as e: | |
| raise HTTPException(410, str(e)) | |
| def verb_speech(audio: UploadFile = File(...), question_id: str = Form(...), | |
| user: dict | None = Depends(signed_in)): | |
| """Evaluate Levels 4–5 audio without turning uncertain recognition into an error.""" | |
| try: | |
| question, phrase = app.state.verb_game.speech_phrase(question_id) | |
| except LookupError as e: | |
| raise HTTPException(410, str(e)) | |
| data = audio.file.read(MAX_UPLOAD + 1) | |
| if len(data) > MAX_UPLOAD: | |
| raise HTTPException(413, "recording too long") | |
| try: | |
| wav = decode_audio(data) | |
| except ValueError: | |
| raise HTTPException(400, "could not decode audio") | |
| if wav.size < 16_000 * .3: | |
| return {"outcome": "uncertain", "counts_as_attempt": False, | |
| "message": "לא נשמע מספיק דיבור. האזינו להקלטה ונסו שוב."} | |
| result = SpeechEvaluationService(app.state.scorer).evaluate( | |
| wav, phrase, diagnostics=bool(user and user["teacher"])) | |
| result["level"] = question.mode | |
| return result | |
| def verb_flag(question_id: str = Form(...), audio: UploadFile = File(...), note: str = Form(""), | |
| user: dict | None = Depends(signed_in)): | |
| """A tutor reports an incorrect judgement of a dynamic spoken verb answer.""" | |
| if not user or not user["tutor"]: | |
| raise HTTPException(403, "tutors only") | |
| try: | |
| _question, phrase = app.state.verb_game.speech_phrase(question_id) | |
| except LookupError as e: | |
| raise HTTPException(410, str(e)) | |
| data = audio.file.read(MAX_UPLOAD + 1) | |
| if len(data) > MAX_UPLOAD: | |
| raise HTTPException(413, "recording too long") | |
| try: | |
| wav = decode_audio(data) | |
| except ValueError: | |
| raise HTTPException(400, "could not decode audio") | |
| result = app.state.scorer.score(wav, phrase, [], diagnostics=True) | |
| if result.get("error"): | |
| raise HTTPException(400, result["message"]) | |
| result.pop("phoneme_heatmap", None) | |
| return save_flag(phrase, result, note, user, data, audio.content_type or "application/octet-stream") | |
| def tutor_for(user: dict | None) -> Tutor: | |
| """The paid tutor game, after the tutor.use dependency authorized the account (or on an open service).""" | |
| if app.state.tutor is None: | |
| raise HTTPException(503, "המורה לא מוגדר בשרת (חסר ANTHROPIC_API_KEY)") | |
| return app.state.tutor | |
| def tutor_day(user: dict | None) -> tuple[str, str, int | None, object]: | |
| """The account's key for the day's count, the day, its limit (None only without sign-in) and how to read | |
| the count Supabase keeps.""" | |
| now = datetime.now(LOCAL_TZ) | |
| day = now.date().isoformat() | |
| if not user or "developer" in user.get("roles", ()): | |
| return "open", day, None, None | |
| since = now.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc).isoformat() | |
| return user["id"], day, DAILY_LIMIT, lambda: app.state.auth.tutor_chats_since(user["id"], since) | |
| def tutor_info(user: dict | None = Depends(signed_in)): | |
| """יאללה נדבר: whether this account may play, its scenario cards (one for each lesson, in the course's order; the | |
| page shows those of the lessons the student has started), and how many conversations are left today (None: no | |
| limit).""" | |
| try: | |
| t = tutor_for(user) | |
| except HTTPException: | |
| return {"available": False} | |
| student, day, limit, seed = tutor_day(user) | |
| return {"available": True, "turns": MAX_TURNS, | |
| "daily_limit": limit, | |
| "left": None if limit is None else max(0, limit - t.started(student, day, seed)), | |
| "scenarios": [{k: s[k] for k in ("id", "lesson", "title", "desc", "who", "ar", "she")} for s in SCENARIOS]} | |
| def tutor_start(tasks: BackgroundTasks, body: dict = Body(...), user: dict | None = Depends(signed_in)): | |
| """A new conversation on a scenario card: {"scenario", "gender": "m" or "f"}. Gives its id, the tutor's opening | |
| message (see Tutor.message) and how many conversations are left today; 429 when none are, 502 when the model | |
| did not answer.""" | |
| t = tutor_for(user) | |
| scenario, gender = body.get("scenario"), body.get("gender") | |
| if scenario not in BY_ID or gender not in ("m", "f"): | |
| raise HTTPException(400, "unknown scenario or gender") | |
| student, day, limit, seed = tutor_day(user) | |
| try: | |
| chat, opening = t.start(scenario, gender, user["id"] if user else None, | |
| user["email"].lower() if user else "", student, day, limit, seed) | |
| except TutorLimit: | |
| raise HTTPException(429, f"סיימתם את {DAILY_LIMIT} השיחות של היום. נתראה מחר!") | |
| except TutorUnavailable as e: | |
| raise HTTPException(502, str(e)) | |
| if user and app.state.auth: | |
| tasks.add_task(app.state.auth.save_tutor_chat, chat.row()) | |
| return {"id": chat.id, "message": opening, "turns": MAX_TURNS, | |
| "left": None if limit is None else max(0, limit - t.started(student, day))} | |
| def tutor_turn(tasks: BackgroundTasks, body: dict = Body(...), user: dict | None = Depends(signed_in)): | |
| """The student's sentence, as the page heard it: {"id", "text"}. Gives the tutor's answer (see Tutor.turn).""" | |
| t = tutor_for(user) | |
| text = " ".join(str(body.get("text", "")).split()) | |
| if not text or len(text) > MAX_TEXT: | |
| raise HTTPException(400, f"a sentence of 1 to {MAX_TEXT} characters") | |
| chat = t.get(str(body.get("id", "")), user["id"] if user else None) | |
| if chat is None: | |
| raise HTTPException(410, "השיחה הזו כבר לא פעילה. התחילו שיחה חדשה.") | |
| if chat.done: | |
| raise HTTPException(409, "השיחה הסתיימה") | |
| try: | |
| result = t.turn(chat, text) | |
| except TutorUnavailable as e: | |
| raise HTTPException(502, str(e)) | |
| if user and app.state.auth: | |
| tasks.add_task(app.state.auth.save_tutor_chat, chat.row()) | |
| return result | |
| def health(): | |
| s: Scorer = app.state.scorer | |
| return {"version": build_version(), "build": build_metadata(), "model": MODEL_ID, "tts": app.state.tts is not None, | |
| "references": app.state.refs is not None, "sign_in": app.state.auth is not None, | |
| "phoneme_fallbacks": s.vocab.downgraded, | |
| # per rule of pronunciation-rules.md: can this model catch it at all (and if not, why) | |
| "rules": check_detectable(s.rules, lambda ph: s.vocab.sound_id(ph) is not None, s.vocab.model_id), | |
| "dialect_realisations_missing": {k: sorted(v) for k, v in s.vocab.class_missing.items()}} | |
| def tts_phrase(phrase_id: int): | |
| """The model answer spoken by Raef, or by a dialogue line's own voice. 503 tells the page to use the device voice.""" | |
| names = {i: n for n, i in {**VOICES, **LINE_VOICES}.items()} | |
| phrase = phrase_of(phrase_id) | |
| text = point_arabic(*phrase["variants"][0]) | |
| if app.state.tts is None: | |
| raise HTTPException(503, "tts not configured") | |
| try: | |
| data, used = app.state.tts.wav(text, LINE_VOICES.get(phrase.get("voice"), RAEF_VOICE_ID)) | |
| except TTSUnavailable as e: | |
| raise HTTPException(503, str(e)) | |
| tts = app.state.tts # debug: lets the page show which voice spoke | |
| return Response(data, media_type="audio/wav", headers={ | |
| "Cache-Control": "private, no-store", "X-TTS-Provider": "sawtakarabi.ai", | |
| "X-TTS-Voice-Id": urllib.parse.quote(used), | |
| "X-TTS-Voice-Name": urllib.parse.quote(names.get(used) or getattr(tts, "voice_name", ""))}) | |
| def tts_cache_usage(_a: SupabaseAuth = Depends(teacher_only)): | |
| """Cache size and process-lifetime hit rate for Monitoring; contains no phrase or user data.""" | |
| if app.state.tts is None: | |
| return {"available": False, "files": 0, "bytes": 0, "hits": 0, "misses": 0, "hit_rate": None} | |
| try: | |
| return {"available": True, **app.state.tts.cache_stats()} | |
| except TTSUnavailable as e: | |
| raise HTTPException(503, str(e)) | |
| def clear_tts_cache(_a: SupabaseAuth = Depends(teacher_only)): | |
| """Delete generated Sawtak recordings; the next playback generates them again.""" | |
| if app.state.tts is None: | |
| raise HTTPException(503, "tts not configured") | |
| try: | |
| removed = app.state.tts.clear_cache() | |
| except TTSUnavailable as e: | |
| raise HTTPException(503, str(e)) | |
| if app.state.refs: | |
| app.state.refs.invalidate() | |
| return {"ok": True, "removed": removed} | |
| def keys_only(user: dict | None = Depends(signed_in)) -> SupabaseAuth: | |
| """Only developers ("*"): a key decides where the bills go. Unlike require(), an open deployment (no sign-in) | |
| is refused too.""" | |
| permissions = set((user or {}).get("permissions", ())) | |
| if app.state.auth is None or ("*" not in permissions and "keys.manage" not in permissions): | |
| raise HTTPException(403, "missing permission: keys.manage") | |
| return app.state.auth | |
| def keys_status(response: Response) -> dict: | |
| response.headers["Cache-Control"] = "no-store" | |
| keys = app.state.keys | |
| return {"keys": keys.status(), "writable": keys.writable, "why": keys.why, | |
| "setup": CREATE_APP_SECRETS if keys.missing else ""} | |
| def use_key(name: str) -> None: | |
| """Puts the key now in force to work, without a restart. The voice cache, the scores' native baseline and the | |
| tutor's open conversations stay.""" | |
| value = app.state.keys.get(name) | |
| if name == "TUTOR_MODEL": # open conversations go on with the new model from their next sentence | |
| if app.state.tutor is not None: | |
| app.state.tutor.model = value or DEFAULT_TUTOR_MODEL | |
| elif name == "SAWTAK_API_KEY": | |
| if app.state.tts is not None and value: | |
| app.state.tts.api_key = value | |
| else: | |
| app.state.tts = load_tts(value) | |
| app.state.refs = References(app.state.scorer, app.state.tts, list(VOICES.values())) if app.state.tts else None | |
| elif app.state.tutor is not None and value: | |
| app.state.tutor.client = tutor_client(value) | |
| else: | |
| app.state.tutor = load_tutor(value, app.state.keys.get("TUTOR_MODEL")) | |
| def key_name(name: str) -> str: | |
| if name not in KEY_NAMES: | |
| raise HTTPException(404, "unknown key") | |
| return name | |
| def api_keys(response: Response, _a: SupabaseAuth = Depends(keys_only)): | |
| """Which API keys are set and where from (the panel, the server's environment, or none); never the keys.""" | |
| return keys_status(response) | |
| def set_api_key(name: str, response: Response, body: dict = Body(...), _a: SupabaseAuth = Depends(keys_only), | |
| user: dict | None = Depends(signed_in)): | |
| """Tries a new key with its provider, then keeps it encrypted and puts it to work. The key is never logged.""" | |
| name, value = key_name(name), body.get("value") | |
| if not isinstance(value, str) or not value.strip() or len(value.strip()) > MAX_KEY_LEN or any(c.isspace() for c in value.strip()): | |
| raise HTTPException(400, "a key is one word of up to 300 characters") | |
| if not app.state.keys.writable: | |
| raise HTTPException(503, app.state.keys.why or f"the table app_secrets is missing; run: {CREATE_APP_SECRETS}") | |
| value = value.strip() | |
| try: | |
| check_key(name, value, app.state.keys.get("ANTHROPIC_API_KEY")) | |
| except KeyRefused as e: | |
| raise HTTPException(400, str(e)) | |
| try: | |
| app.state.keys.set(name, value, (user or {}).get("email", "")) | |
| except AuthUnavailable as e: | |
| if any(mark in str(e).lower() for mark in ("check constraint", "23514")): # a table made before TUTOR_MODEL | |
| raise HTTPException(409, f"הטבלה app_secrets צריכה עדכון; הריצו ב-Supabase: {MIGRATE_APP_SECRETS}") | |
| raise HTTPException(503, f"Supabase לא זמין: {e}") | |
| use_key(name) | |
| log.info("API key %s replaced by %s", name, (user or {}).get("email", "?")) | |
| if app.state.auth: | |
| app.state.auth.log_event("permission", user, f"מפתח API: {name}", {"key": name, "action": "set"}) | |
| return keys_status(response) | |
| def clear_api_key(name: str, response: Response, _a: SupabaseAuth = Depends(keys_only), | |
| user: dict | None = Depends(signed_in)): | |
| """Forgets the panel's key: the server's environment key (if any) is used again.""" | |
| name = key_name(name) | |
| if not app.state.keys.writable: | |
| raise HTTPException(503, app.state.keys.why or "the table app_secrets is missing") | |
| try: | |
| app.state.keys.clear(name) | |
| except AuthUnavailable as e: | |
| raise HTTPException(503, f"Supabase לא זמין: {e}") | |
| use_key(name) | |
| log.info("API key %s cleared by %s", name, (user or {}).get("email", "?")) | |
| if app.state.auth: | |
| app.state.auth.log_event("permission", user, f"מפתח API: {name}", {"key": name, "action": "clear"}) | |
| return keys_status(response) | |
| def prompt_audio(phrase_id: int, engine: str = "gtts"): | |
| """The Hebrew prompt read aloud (edge-tts or gTTS). 503 tells the page to stay silent.""" | |
| phrase = phrase_of(phrase_id) | |
| if engine not in ENGINES: | |
| raise HTTPException(400, "unknown engine") | |
| try: | |
| data, used = hebrew_mp3(phrase.get("he_say") or phrase["he"], engine) # pointed where the plain text is misread | |
| except PromptTTSUnavailable as e: | |
| raise HTTPException(503, str(e)) | |
| return Response(data, media_type="audio/mpeg", headers={ | |
| "Cache-Control": "private, max-age=86400", "X-TTS-Engine": used}) | |
| def say(text: str = Query(..., min_length=1, max_length=400), engine: str = "gtts"): | |
| """A short Hebrew text read aloud: driving mode's spoken messages.""" | |
| if engine not in ENGINES: | |
| raise HTTPException(400, "unknown engine") | |
| if not re.search(r"[\u05d0-\u05ea]", text) or re.search(r"[A-Za-z\x00-\x1f]", text): | |
| raise HTTPException(400, "hebrew text only") | |
| try: | |
| data, used = hebrew_mp3(text, engine, cache_it=False) | |
| except PromptTTSUnavailable as e: | |
| raise HTTPException(503, str(e)) | |
| return Response(data, media_type="audio/mpeg", headers={"Cache-Control": "no-store", "X-TTS-Engine": used}) | |
| def score(tasks: BackgroundTasks, phrase_id: int = Form(...), audio: UploadFile = File(...), | |
| user: dict | None = Depends(signed_in)): | |
| phrase = phrase_of(phrase_id) | |
| data = audio.file.read(MAX_UPLOAD + 1) | |
| if len(data) > MAX_UPLOAD: | |
| raise HTTPException(413, "recording too long") | |
| try: | |
| wav = decode_audio(data) | |
| except ValueError: | |
| raise HTTPException(400, "could not decode audio") | |
| if wav.size < 16_000 * 0.3: | |
| return {"error": "too_short", "message": "לא נשמע דיבור. נסו שוב, קרוב יותר למיקרופון."} | |
| refs = app.state.refs.get(phrase_id, phrase) if app.state.refs else [] | |
| result = app.state.scorer.score(wav, phrase, refs, diagnostics=bool(user and user["teacher"])) | |
| if user and result.get("score") is not None: # the student's progress; sent after the answer | |
| issues = [{"word": wi, "expected": p["ipa"], "heard": p.get("heard") or "_", "score": round(p["score"])} | |
| for wi, word in enumerate(result.get("words", [])) for p in word.get("phonemes", []) if p.get("flagged")] | |
| tasks.add_task(app.state.auth.record, user["token"], { | |
| "cat": phrase["cat"], "phrase": phrase.get("key", phrase["he"]), "score": round(result["score"]), | |
| "unrelated": bool(result.get("unrelated")), "issues": issues[:12], "model": MODEL_ID, | |
| "version": build_version()}) | |
| game_ids = {g["id"] for g in GAMES} | |
| tasks.add_task(app.state.auth.log_event, "game" if phrase["cat"] in game_ids else "exercise", user, | |
| phrase.get("he", ""), {"cat": phrase["cat"], "score": round(result["score"]), | |
| "flags": len(issues), "unrelated": bool(result.get("unrelated"))}) | |
| return result | |
| def flag(phrase_id: int = Form(...), audio: UploadFile = File(...), note: str = Form(""), | |
| user: dict | None = Depends(signed_in)): | |
| """A tutor's flag: this recording of the phrase was said right and the score was wrong. It is scored again in | |
| memory. The structured diagnosis is kept in `flags` and the audio in its private Storage bucket.""" | |
| if not user or not user["tutor"]: | |
| raise HTTPException(403, "tutors only") | |
| phrase = phrase_of(phrase_id) | |
| data = audio.file.read(MAX_UPLOAD + 1) | |
| if len(data) > MAX_UPLOAD: | |
| raise HTTPException(413, "recording too long") | |
| try: | |
| wav = decode_audio(data) | |
| except ValueError: | |
| raise HTTPException(400, "could not decode audio") | |
| refs = app.state.refs.get(phrase_id, phrase) if app.state.refs else [] | |
| # Keep a structured pipeline trace for the diagnostic dashboard alongside the private recording. | |
| result = app.state.scorer.score(wav, phrase, refs, diagnostics=True) | |
| if result.get("error"): | |
| raise HTTPException(400, result["message"]) | |
| # The trace already contains every value used by the dashboard. The dense heatmap is only a display aid | |
| # for the immediate scoring screen and would make every database report unnecessarily large. | |
| result.pop("phoneme_heatmap", None) | |
| return save_flag(phrase, result, note, user, data, audio.content_type or "application/octet-stream") | |
| def save_flag(phrase: dict, result: dict, note: str, user: dict, audio_data: bytes, audio_mime: str) -> dict: | |
| """Store a reviewed-score candidate and its recording from a bank or dynamic verb-game phrase.""" | |
| row = {"email": user["email"].lower(), "cat": phrase["cat"], "phrase": phrase.get("key", phrase["he"]), | |
| "he": phrase["he"], "ar": result["variant"]["ar"], "ipa": result["variant"]["ipa"], | |
| "heard_ipa": result["heard_ipa"], "score": result["score"], "unrelated": bool(result["unrelated"]), | |
| "note": " ".join(note.split())[:MAX_NOTE], "result": result, "model": MODEL_ID, "version": build_version()} | |
| mime = audio_mime.lower().partition(";")[0].strip() | |
| ext = AUDIO_EXT.get(mime.rpartition("/")[2], "bin") | |
| name = f"{date.today():%Y-%m}/{uuid.uuid4().hex}.{ext}" | |
| try: | |
| kept = app.state.auth.add_flag(row, audio_data, mime, name) | |
| except AuthUnavailable as e: | |
| if "answered 404" in str(e): | |
| raise HTTPException(503, "הטבלה flags לא קיימת ב-Supabase. הריצו ב-SQL Editor: " | |
| f"{CREATE_FLAGS} אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';") | |
| raise HTTPException(503, f"Supabase לא זמין: {e}") | |
| row["audio"] = name if kept else None | |
| return {"ok": True, "audio": kept, "flag": row} | |
| return app | |
| app = create_app() | |