3v324v23 commited on
Commit
69eaa4c
·
1 Parent(s): f90708b

Add the math path, photographed problems, math classes, backups and the mathly Space

Browse files
.gitignore CHANGED
@@ -13,3 +13,5 @@ book/
13
  # local Minerva clips cut by scripts/minerva_dataset.py (copyrighted videos; never committed)
14
  data/minerva/
15
  checkpoints/
 
 
 
13
  # local Minerva clips cut by scripts/minerva_dataset.py (copyrighted videos; never committed)
14
  data/minerva/
15
  checkpoints/
16
+ .env.*
17
+ !.env.example
README.md CHANGED
@@ -49,7 +49,7 @@ sudo apt install ffmpeg
49
  python3 -m venv .venv && source .venv/bin/activate
50
  pip install torch --index-url https://download.pytorch.org/whl/cpu
51
  pip install -r requirements.txt
52
- uvicorn app.main:app --port 7860
53
  ```
54
 
55
  Open http://localhost:7860. The first start downloads the model (~1.2 GB). CPU inference on a 3 s clip takes about a second.
@@ -253,7 +253,7 @@ alter table public.user_access add constraint valid_entitlements check (
253
 
254
  ### Flags: a score that is wrong
255
 
256
- Sometimes a sentence is said right and the system scores it wrong. The teachers (`TEACHER_EMAILS`) report such cases, and `TUTORS_EMAIL` names more accounts that do (several are separated by commas or spaces; they may sign in without being in a class, and get no management panel). The teacher also gives a student this tutor's rank in the panel: at the top of **תלמידים**, the part "🚩 מדווחים על ציונים שגויים" opens a list of every student of every class, each with a box to tick. The rank is the student's, whatever classes they are in, and by itself lets nobody sign in; the panel marks such a student with 🚩 in every class (`PUT /api/tutors`, teachers only: `{"email", "tutor": true|false}`). It is kept by email in the table `tutors`; create it once (until then the panel shows this command):
257
 
258
  ```sql
259
  create table public.tutors (
@@ -600,6 +600,12 @@ alter table public.classes add column if not exists nicknames jsonb not null def
600
  update public.classes set subject = 'ערבית' where subject = '';
601
  ```
602
 
 
 
 
 
 
 
603
  The server keeps the list for a minute, so a change in the SQL editor (rather than the panel) applies within that time.
604
 
605
  ### Live lessons
@@ -645,7 +651,7 @@ A few hundred utterances are enough to move the needle with the feature encoder
645
  ```bash
646
  pip install -r requirements-train.txt
647
  python -m train.finetune --train data/train.csv --eval data/eval.csv --out checkpoints/ihki-ft
648
- MODEL_ID=checkpoints/ihki-ft uvicorn app.main:app --port 7860
649
  ```
650
 
651
  The script reports phoneme error rate (PER) before and after training. A GPU is advisable. A free Colab T4 handles a few hundred clips in minutes.
 
49
  python3 -m venv .venv && source .venv/bin/activate
50
  pip install torch --index-url https://download.pytorch.org/whl/cpu
51
  pip install -r requirements.txt
52
+ uvicorn app.main:app --host 0.0.0.0 --port 7860
53
  ```
54
 
55
  Open http://localhost:7860. The first start downloads the model (~1.2 GB). CPU inference on a 3 s clip takes about a second.
 
253
 
254
  ### Flags: a score that is wrong
255
 
256
+ Sometimes a sentence is said right and the system scores it wrong. The teachers (`TEACHER_EMAILS`) report such cases, and `TUTORS_EMAIL` names more accounts that do (several are separated by commas or spaces; they may sign in without being in a class, and get no management panel). The rank is kept by email in the table `tutors`:
257
 
258
  ```sql
259
  create table public.tutors (
 
600
  update public.classes set subject = 'ערבית' where subject = '';
601
  ```
602
 
603
+ A subject is one of a fixed list, `SUBJECT_LABELS` in `app/main.py`: ערבית (served at `/`, and `/arabic`) and מתמטיקה (`/math`). It is kept in Hebrew, as the panel shows it, and the developer picks an admin's subject from that list in Monitoring; any other text is refused. `/api/me` names the subject of the account (an admin's own, or their class's), and the page moves an account that opened another subject's address to its own. A developer stays where they are. Each subject has its own title, colour and opening screen (`SUBJECTS`), and the installed app of a subject other than Arabic has its own manifest, `/math/manifest.webmanifest`, so its home-screen icon is named for it and opens `/math`.
604
+
605
+ **The math path.** On `/math`, the start button opens the math path (`static/math.js`, `static/math.css`) in place of the Arabic one. Its skill graph is `app/math_algebra9.json`, served at `/api/math/skills`: 9th-grade algebra for the 5-unit track, by the Ministry's 2026/27 teaching schedule, in six units (foundations, algebraic technique, function properties, the quadratic function and equation, powers and roots, word problems). Each skill names the skills it needs, and has a sample question whose wrong choices are each a known mistake; a mistake sends the student back to the skill it comes from. `app/math_skills.py` refuses a graph with a cycle, an unknown skill, a redundant link, a question without exactly one right answer, or a mistake that sends a student forward. A skill is open once every skill it needs is done, and the path goes on from the first open one; a locked skill can still be opened, with the skills to do first named on it. For now a right answer to the sample question marks the skill done, and progress is kept on the device (`ihki.math.v1:<email>`). The questions were written for the app and still need a 5-unit teacher's review.
606
+
607
+ **A photographed problem.** "צילום תרגיל" on the math path lets a student photograph a problem (camera or gallery). The page shrinks the photo to about 1600 px and sends it to `POST /api/math/photo`, where Claude (`app/math_photo.py`) reads it and solves it in a fixed JSON shape: the problem as read, the solution as steps (each a hint and the step itself), the answer, and the path's skill it practises. The sheet shows the reading and one hint; further hints, each step and the full solution with its answer come only when the student asks, and the skill links into the path. It runs on the tutor's Anthropic key (`ANTHROPIC_API_KEY`, or the panel's) and on `MATH_MODEL` (default `claude-opus-5-5`, with the API's default fallback model when it declines). A signed-in student has `DAILY_PHOTOS` (20) a day, counted in memory; a photo it could not read does not count, and developers are exempt. The photo is sent to Anthropic's API and not kept by the app; say so on the privacy page before students use it.
608
+
609
  The server keeps the list for a minute, so a change in the SQL editor (rather than the panel) applies within that time.
610
 
611
  ### Live lessons
 
651
  ```bash
652
  pip install -r requirements-train.txt
653
  python -m train.finetune --train data/train.csv --eval data/eval.csv --out checkpoints/ihki-ft
654
+ MODEL_ID=checkpoints/ihki-ft uvicorn app.main:app --host 0.0.0.0 --port 7860
655
  ```
656
 
657
  The script reports phoneme error rate (PER) before and after training. A GPU is advisable. A free Colab T4 handles a few hundred clips in minutes.
app/auth.py CHANGED
@@ -79,6 +79,15 @@ MAX_ATTEMPTS = 20_000 # of one student, read for their progress
79
  MAX_CLASS_ATTEMPTS = 200_000 # of all students, read for the teacher's panel
80
  CLASSES_TTL = 60 # seconds the class list is trusted without reading it again (each sign-in check uses it)
81
 
 
 
 
 
 
 
 
 
 
82
 
83
  class AuthUnavailable(RuntimeError):
84
  pass
@@ -218,7 +227,8 @@ class SupabaseAuth:
218
  with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
219
  return json.load(res)
220
 
221
- def _admin(self, path: str, method: str = "GET", body: dict | None = None, prefer: str = "return=representation"):
 
222
  """A call with the secret key, for the teacher's panel. Unlike a student's own progress, a failure is raised."""
223
  if not self.secret:
224
  raise AuthUnavailable("SUPABASE_SECRET_KEY is not set")
@@ -227,7 +237,7 @@ class SupabaseAuth:
227
  headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret, "Content-Type": "application/json",
228
  "Prefer": prefer, "User-Agent": "ihki/1.0"})
229
  try:
230
- with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
231
  return json.loads(res.read() or b"null")
232
  except urllib.error.HTTPError as e:
233
  detail = e.read().decode(errors="replace")[:300]
@@ -237,6 +247,119 @@ class SupabaseAuth:
237
  except (urllib.error.URLError, TimeoutError, ValueError) as e:
238
  raise AuthUnavailable(str(e)) from e
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  def classes(self, fresh: bool = False) -> list[dict]:
241
  """Every class, newest first: id, name, emails (lower-case), nicknames ({email: nickname}), lessons (lesson ids;
242
  empty in a class saved before classes had lessons), homeworks ([{"topic": a topic id, "due": its ISO date or
@@ -497,6 +620,36 @@ class SupabaseAuth:
497
  media_type = mimetypes.guess_type(name)[0] or media_type
498
  return data, media_type
499
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
500
  def add_flag(self, row: dict, audio: bytes, mime: str, name: str) -> bool:
501
  """Keeps a flagged score and its recording in the private `flags` bucket."""
502
  kept = self._put_object("flags", name, audio, mime)
 
79
  MAX_CLASS_ATTEMPTS = 200_000 # of all students, read for the teacher's panel
80
  CLASSES_TTL = 60 # seconds the class list is trusted without reading it again (each sign-in check uses it)
81
 
82
+ # Application-owned data. Supabase's auth users and managed schemas are deliberately not copied.
83
+ BACKUP_TABLES = (
84
+ "dailies", "library", "videos", "texts", "classes", "writings", "tutors", "flags",
85
+ "lab_samples", "calibrations", "scoring_rules", "user_access", "access_audit", "app_secrets",
86
+ "system_events", "tutor_chats",
87
+ )
88
+ BACKUP_NATURAL_KEYS = {"writings": "user_id,topic", "tutors": "email", "scoring_rules": "id",
89
+ "user_access": "user_id", "app_secrets": "name", "tutor_chats": "id"}
90
+
91
 
92
  class AuthUnavailable(RuntimeError):
93
  pass
 
227
  with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
228
  return json.load(res)
229
 
230
+ def _admin(self, path: str, method: str = "GET", body: dict | None = None, prefer: str = "return=representation",
231
+ timeout: int = TIMEOUT):
232
  """A call with the secret key, for the teacher's panel. Unlike a student's own progress, a failure is raised."""
233
  if not self.secret:
234
  raise AuthUnavailable("SUPABASE_SECRET_KEY is not set")
 
237
  headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret, "Content-Type": "application/json",
238
  "Prefer": prefer, "User-Agent": "ihki/1.0"})
239
  try:
240
+ with urllib.request.urlopen(req, timeout=timeout) as res:
241
  return json.loads(res.read() or b"null")
242
  except urllib.error.HTTPError as e:
243
  detail = e.read().decode(errors="replace")[:300]
 
247
  except (urllib.error.URLError, TimeoutError, ValueError) as e:
248
  raise AuthUnavailable(str(e)) from e
249
 
250
+ def backup_rows(self) -> dict[str, list[dict]]:
251
+ """All application-owned table rows. Tables not installed in an older deployment are omitted."""
252
+ result: dict[str, list[dict]] = {}
253
+ for table in BACKUP_TABLES:
254
+ rows = []
255
+ try:
256
+ for offset in range(0, 10_000_000, PAGE):
257
+ path = f"/rest/v1/{table}?select=*&limit={PAGE}&offset={offset}"
258
+ for attempt in range(3):
259
+ try:
260
+ batch = self._admin(path, timeout=60) or []
261
+ break
262
+ except AuthUnavailable as e:
263
+ if attempt == 2 or not any(x in str(e).lower() for x in ("timed out", "timeout")):
264
+ raise
265
+ rows.extend(batch)
266
+ if len(batch) < PAGE:
267
+ break
268
+ result[table] = rows
269
+ except AuthUnavailable as e:
270
+ if "answered 404" not in str(e):
271
+ raise
272
+ return result
273
+
274
+ def final_scores(self) -> list[dict]:
275
+ """One compact row per student and exercise: the highest score that drives the panel's final grades."""
276
+ best: dict[tuple[str, str, str], dict] = {}
277
+ for offset in range(0, MAX_CLASS_ATTEMPTS, PAGE):
278
+ query = urllib.parse.urlencode({"select": "user_id,cat,phrase,score,created_at",
279
+ "order": "id.asc", "limit": PAGE, "offset": offset})
280
+ path = f"/rest/v1/attempts?{query}"
281
+ for attempt in range(3):
282
+ try:
283
+ batch = self._admin(path, timeout=60) or []
284
+ break
285
+ except AuthUnavailable as e:
286
+ if attempt == 2 or not any(x in str(e).lower() for x in ("timed out", "timeout")):
287
+ raise
288
+ for row in batch:
289
+ key = (str(row["user_id"]), str(row["cat"]), str(row["phrase"]))
290
+ if key not in best or int(row["score"]) > int(best[key]["score"]):
291
+ best[key] = row
292
+ if len(batch) < PAGE:
293
+ break
294
+ return list(best.values())
295
+
296
+ def restore_final_scores(self, rows: list[dict]) -> int:
297
+ """Restore only grades that improve the currently stored best score, without restoring attempt history."""
298
+ current = {(r["user_id"], r["cat"], r["phrase"]): int(r["score"]) for r in self.final_scores()}
299
+ add = [{**r, "unrelated": False} for r in rows
300
+ if int(r.get("score", -1)) > current.get((r.get("user_id"), r.get("cat"), r.get("phrase")), -1)]
301
+ for start in range(0, len(add), 200):
302
+ self._admin("/rest/v1/attempts", "POST", add[start:start + 200], prefer="return=minimal", timeout=60)
303
+ return len(add)
304
+
305
+ def restore_rows(self, tables: dict[str, list[dict]]) -> dict[str, int]:
306
+ """Merge a validated application backup. Existing rows are updated; unrelated current rows remain."""
307
+ restored: dict[str, int] = {}
308
+ text_ids: dict[int, int] = {}
309
+ daily_ids: dict[int, int] = {}
310
+ for table in BACKUP_TABLES:
311
+ rows = [dict(r) for r in tables.get(table, [])]
312
+ if not rows:
313
+ continue
314
+ if table == "classes":
315
+ for row in rows:
316
+ row["dailies"] = [{**d, "video": daily_ids.get(d.get("video"), d.get("video"))}
317
+ for d in (row.get("dailies") or [])]
318
+ for field in ("homework",):
319
+ value = row.get(field, "")
320
+ if isinstance(value, str) and value.startswith("t") and value[1:].isdigit():
321
+ row[field] = "t" + str(text_ids.get(int(value[1:]), int(value[1:])))
322
+ row["homeworks"] = [{**h, "topic": "t" + str(text_ids.get(int(h["topic"][1:]), int(h["topic"][1:])))
323
+ if isinstance(h.get("topic"), str) and h["topic"].startswith("t") and h["topic"][1:].isdigit()
324
+ else h.get("topic")} for h in (row.get("homeworks") or [])]
325
+ if table == "writings":
326
+ field = "topic"
327
+ for row in rows:
328
+ value = row.get(field, "")
329
+ if isinstance(value, str) and value.startswith("t") and value[1:].isdigit():
330
+ row[field] = "t" + str(text_ids.get(int(value[1:]), int(value[1:])))
331
+ if table in BACKUP_NATURAL_KEYS:
332
+ key = BACKUP_NATURAL_KEYS[table]
333
+ for start in range(0, len(rows), 200):
334
+ self._admin(f"/rest/v1/{table}?on_conflict={key}", "POST", rows[start:start + 200],
335
+ prefer="resolution=merge-duplicates,return=minimal")
336
+ restored[table] = len(rows)
337
+ continue
338
+ for original in rows:
339
+ row = dict(original)
340
+ old_id = row.get("id")
341
+ if old_id is not None:
342
+ patch = dict(row)
343
+ patch.pop("id", None)
344
+ existing = self._admin(f"/rest/v1/{table}?id=eq.{urllib.parse.quote(str(old_id))}", "PATCH", patch)
345
+ if existing:
346
+ new_id = old_id
347
+ else:
348
+ row.pop("id", None) # identity columns reject explicitly supplied values
349
+ made = self._admin(f"/rest/v1/{table}", "POST", row)
350
+ new_id = made[0].get("id") if made else None
351
+ if table == "texts" and new_id is not None:
352
+ text_ids[int(old_id)] = int(new_id)
353
+ if table == "dailies" and new_id is not None:
354
+ daily_ids[int(old_id)] = int(new_id)
355
+ else:
356
+ self._admin(f"/rest/v1/{table}", "POST", row, prefer="return=minimal")
357
+ restored[table] = len(rows)
358
+ self._classes = self._videos = self._texts = self._ranked = None
359
+ self._level_videos.clear()
360
+ self._access.clear()
361
+ return restored
362
+
363
  def classes(self, fresh: bool = False) -> list[dict]:
364
  """Every class, newest first: id, name, emails (lower-case), nicknames ({email: nickname}), lessons (lesson ids;
365
  empty in a class saved before classes had lessons), homeworks ([{"topic": a topic id, "due": its ISO date or
 
620
  media_type = mimetypes.guess_type(name)[0] or media_type
621
  return data, media_type
622
 
623
+ def ensure_backup_bucket(self) -> None:
624
+ """Create the private application-backup bucket on first use."""
625
+ try:
626
+ self._admin("/storage/v1/bucket/backups")
627
+ except AuthUnavailable as e:
628
+ detail = str(e).lower()
629
+ if not any(mark in detail for mark in ("answered 404", '"statuscode":"404"', "nosuchbucket", "bucket not found")):
630
+ raise
631
+ self._admin("/storage/v1/bucket", "POST", {"id": "backups", "name": "backups", "public": False},
632
+ prefer="return=minimal")
633
+
634
+ def save_backup(self, name: str, data: bytes) -> None:
635
+ self.ensure_backup_bucket()
636
+ req = urllib.request.Request(
637
+ f"{self.url}/storage/v1/object/backups/{urllib.parse.quote(name)}", data=data, method="POST",
638
+ headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret,
639
+ "Content-Type": "application/zip", "User-Agent": "ihki/1.0"})
640
+ try:
641
+ urllib.request.urlopen(req, timeout=max(TIMEOUT, 60)).close()
642
+ except urllib.error.HTTPError as e:
643
+ raise AuthUnavailable(f"supabase storage answered {e.code}: {e.read().decode(errors='replace')[:300]}") from e
644
+ except (urllib.error.URLError, TimeoutError) as e:
645
+ raise AuthUnavailable(str(e)) from e
646
+
647
+ def list_backups(self) -> list[dict]:
648
+ self.ensure_backup_bucket()
649
+ rows = self._admin("/storage/v1/object/list/backups", "POST",
650
+ {"prefix": "", "limit": 100, "offset": 0, "sortBy": {"column": "name", "order": "desc"}})
651
+ return [r for r in (rows or []) if str(r.get("name", "")).endswith(".zip")]
652
+
653
  def add_flag(self, row: dict, audio: bytes, mime: str, name: str) -> bool:
654
  """Keeps a flagged score and its recording in the private `flags` bucket."""
655
  kept = self._put_object("flags", name, audio, mime)
app/live.py CHANGED
@@ -105,9 +105,11 @@ class LiveStore:
105
  session = self._sessions.get(code)
106
  if not session:
107
  raise KeyError(code)
 
 
108
  session["question_no"] += 1
109
  session["question"] = {"no": session["question_no"], "phrase_id": phrase["id"],
110
- "he": phrase["he"], "ar": phrase["ar"], "tr": phrase["tr"],
111
  "started_at": _now_iso()}
112
  session["answers"] = {}
113
  session["teacher_seen"] = now
 
105
  session = self._sessions.get(code)
106
  if not session:
107
  raise KeyError(code)
108
+ variants = phrase.get("variants") or []
109
+ arabic = phrase.get("ar") or (variants[0][0] if variants else "")
110
  session["question_no"] += 1
111
  session["question"] = {"no": session["question_no"], "phrase_id": phrase["id"],
112
+ "he": phrase["he"], "ar": arabic, "tr": phrase["tr"],
113
  "started_at": _now_iso()}
114
  session["answers"] = {}
115
  session["teacher_seen"] = now
app/main.py CHANGED
@@ -13,6 +13,8 @@ A local .env file is loaded if present; real environment variables win over it.
13
  from __future__ import annotations
14
 
15
  import hashlib
 
 
16
  import logging
17
  import os
18
  import re
@@ -20,6 +22,7 @@ import subprocess
20
  import time
21
  import urllib.parse
22
  import uuid
 
23
  from contextlib import asynccontextmanager
24
  from datetime import date, datetime, timedelta, timezone
25
  from pathlib import Path
@@ -36,6 +39,9 @@ from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
36
  from .keystore import (CREATE_APP_SECRETS, MAX_LEN as MAX_KEY_LEN, MIGRATE_APP_SECRETS, NAMES as KEY_NAMES, KeyRefused,
37
  check as check_key, load_keys)
38
  from .live import LiveConflict, LiveStore
 
 
 
39
  from .phrases import CATEGORIES, GAMES, LESSONS, LEVELS, PHRASES, public_phrases
40
  from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
41
  from .reference import References
@@ -55,13 +61,28 @@ load_dotenv(STATIC.parent / ".env")
55
  # A class topic's opening screen, by the path it is served at; "/" is Arabic.
56
  SUBJECTS = {
57
  "arabic": {"__TITLE__": "אהלא וסהלא", "__SPLASH_LOGO__": "/static/logo-intro.svg",
58
- "__SPLASH_ALT__": "אהלא וסהלא, בית הספר ללימוד השפה הערבית", "__START_LABEL__": "יאללה נבלש"},
 
59
  "math": {"__TITLE__": "מתמטיקה בכיף", "__SPLASH_LOGO__": "/static/logo-math.svg",
60
- "__SPLASH_ALT__": "מתמטיקה בכיף, המורה הפרטי למתמטיקה", "__START_LABEL__": "יאללה נתחיל"},
 
61
  }
 
 
 
 
 
 
 
 
 
 
 
 
62
  MODEL_ID = os.environ.get("MODEL_ID", "facebook/wav2vec2-xlsr-53-espeak-cv-ft")
63
  LOCAL_TZ = ZoneInfo("Asia/Jerusalem") # a day of practice is the students' calendar day
64
  MAX_UPLOAD = 2_000_000 # bytes; 15 s of opus is ~100 KB
 
65
  EMAIL = re.compile(r"[^@\s,;<>]+@[^@\s,;<>]+\.[^@\s,;<>]+")
66
  MAX_NICKNAME = 40
67
  ALL_LESSONS = [l["id"] for l in LESSONS] # with the lessons still to come: a class may take them ahead
@@ -237,11 +258,29 @@ MIGRATE_CLASSES = ("alter table public.classes add column if not exists nickname
237
  MAX_HOMEWORKS = 30
238
 
239
 
 
 
 
 
240
  def lessons_of(c: dict) -> list[str]:
241
- """A class's lessons in the course's order; a class saved before classes had lessons has every one."""
 
 
 
242
  return [l for l in ALL_LESSONS if l in c["lessons"]] or ALL_LESSONS
243
 
244
 
 
 
 
 
 
 
 
 
 
 
 
245
  def homework_of(c: dict, topics: dict[str, str] = TOPIC_LESSON) -> dict:
246
  """A class's homework: the topic it practises now (a category id, e.g. the conversation "l2-talk"; "" when none
247
  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
@@ -318,8 +357,10 @@ def class_members(text: str) -> tuple[list[str], dict[str, str]]:
318
 
319
 
320
  def build_version() -> str:
321
- """Debug build stamp: short content hash of the served code + newest file time (UTC)."""
322
- files = sorted(Path(__file__).resolve().parent.glob("*.py")) + [STATIC / "index.html"]
 
 
323
  h = hashlib.sha1()
324
  for f in files:
325
  h.update(f.read_bytes())
@@ -569,19 +610,33 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
569
  def page(subject: str) -> HTMLResponse:
570
  html = (STATIC / "index.html").read_text(encoding="utf-8")
571
  a: SupabaseAuth | None = app.state.auth
572
- marks = {"__VERSION__": build_version(), "__SUPABASE_URL__": a.url if a else "",
573
- "__SUPABASE_KEY__": a.key if a else "", "__SUBJECT__": subject, **SUBJECTS[subject]}
 
574
  for mark, value in marks.items():
575
  html = html.replace(mark, value)
576
  return HTMLResponse(html, headers={"Cache-Control": "no-cache"})
577
 
 
 
 
 
 
578
  @app.get("/")
579
  def index():
580
- return page("arabic")
581
 
582
  for subject in SUBJECTS: # the same app under a class topic's own opening screen: /arabic, /math
583
  app.get(f"/{subject}", include_in_schema=False)(lambda subject=subject: page(subject))
584
 
 
 
 
 
 
 
 
 
585
  @app.get("/service-worker.js", include_in_schema=False)
586
  def service_worker():
587
  return Response(
@@ -606,14 +661,19 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
606
  for d in [d for c in app.state.auth.classes() for d in dailies_of(c, daily_rows())] if can_manage else []:
607
  if d["id"] not in every or d["date"] < every[d["id"]]["date"]:
608
  every[d["id"]] = d
 
 
 
609
  return {"email": user["email"] if user else "", "teacher": bool(user and user["teacher"]),
 
610
  **({"tutor": True} if user and user["tutor"] else {}), # the page shows them the flag under a score
611
  "roles": user.get("roles", []) if user else [], "entitlements": user.get("entitlements", []) if user else [],
612
  "permissions": user.get("permissions", []) if user else ["*"],
613
  **({"dailies": sorted(every.values(), key=lambda d: (d["date"], d["id"]))} if can_all else {}),
614
- "classes": [{"name": c["name"], "lessons": lessons_of(c), **homework_of(c, topics()),
615
- "dailies": dailies_of(c, daily_rows())} for c in mine],
616
- **({"teaches": [{"id": c["id"], "name": c["name"], "lessons": lessons_of(c)}
 
617
  for c in user.get("teaches", [])]} if user and user.get("teaches") else {}),
618
  "lessons": lessons}
619
 
@@ -628,14 +688,15 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
628
  raise HTTPException(403, "רק מורה של הכיתה יכול/ה לנהל את המפגש")
629
 
630
  @app.get("/api/live")
631
- def live_list(user: dict | None = Depends(signed_in)):
 
632
  if not user:
633
  return {"sessions": [], "teaches": []}
634
  mine = {c["id"] for c in user.get("classes", [])}
635
  teaches = {c["id"] for c in user.get("teaches", [])}
636
  sessions = app.state.live.list_for(mine | teaches, user["id"])
637
  return {"sessions": [{**s, "teacher": s["class_id"] in teaches} for s in sessions],
638
- "teaches": [{"id": c["id"], "name": c["name"], "lessons": lessons_of(c)}
639
  for c in user.get("teaches", [])]}
640
 
641
  @app.post("/api/live")
@@ -674,7 +735,8 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
674
  return session, course
675
 
676
  @app.get("/api/live/{code}")
677
- def live_status(code: str, user: dict | None = Depends(signed_in)):
 
678
  session = app.state.live.get(code)
679
  if not session:
680
  raise HTTPException(404, "המפגש הסתיים")
@@ -829,7 +891,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
829
  if ranked is None:
830
  setup.append({"what": "הטבלה tutors לא קיימת ב-Supabase, ולכן אי אפשר לתת לתלמיד דרגת מדווח/ת (דגל 🚩 מתחת לציון).",
831
  "sql": CREATE_TUTORS})
832
- return {"classes": [{**c, "lessons": lessons_of(c), **homework_of(c, lesson_of)} for c in classes], "students": students,
833
  "setup": setup}
834
 
835
  @app.get("/api/activity")
@@ -992,8 +1054,9 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
992
  users, rows = a.users(), a.access_rows()
993
  except AuthUnavailable as e:
994
  raise HTTPException(503, f"Supabase לא זמין: {e}")
 
995
  if rows is None:
996
- return {"admins": [], "setup": CREATE_USER_ACCESS, "migration": MIGRATE_USER_ACCESS}
997
  assigned = {r["user_id"]: r for r in rows}
998
  out = []
999
  for account in users:
@@ -1004,7 +1067,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
1004
  if "admin" in roles:
1005
  out.append({"id": account["id"], "email": account["email"], "name": account.get("name", ""),
1006
  "subject": str(row.get("subject") or "")})
1007
- return {"admins": out, "setup": "", "migration": ""}
1008
 
1009
  @app.put("/api/admin-subjects/{target_id}")
1010
  def update_admin_subject(target_id: str, body: dict = Body(...),
@@ -1012,8 +1075,8 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
1012
  if not actor or "*" not in actor.get("permissions", ()):
1013
  raise HTTPException(403, "developers only")
1014
  subject = " ".join(str(body.get("subject", "")).split())
1015
- if not subject or len(subject) > 80:
1016
- raise HTTPException(400, "subject must contain 1–80 characters")
1017
  try:
1018
  target = next((u for u in a.users() if u["id"] == target_id), None)
1019
  if not target:
@@ -1033,6 +1096,114 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
1033
  raise HTTPException(503, f"Supabase לא זמין: {e}")
1034
  return {"ok": True, "subject": subject}
1035
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1036
  @app.get("/api/flags")
1037
  def flags(status: str = Query("pending"), a: SupabaseAuth = Depends(teacher_only)):
1038
  """The score reports awaiting a teacher's decision (or one reviewed state)."""
@@ -1686,6 +1857,19 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
1686
  return f"לטבלה classes חסרות עמודות. הריצו ב-SQL Editor של Supabase: {MIGRATE_CLASSES}"
1687
  return f"Supabase לא זמין: {e}"
1688
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1689
  def save_class(a: SupabaseAuth, id: int | None, body: dict, actor: dict | None = None) -> dict:
1690
  """Checks a class from the panel ({"name", "emails": the student list, see `class_members`, "lessons": lesson
1691
  ids, "homeworks": [{"topic": a topic of one of them, "due": its ISO date, or ""}], each topic once; or, as
@@ -1699,10 +1883,13 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
1699
  emails, nicks = class_members(str(body.get("emails", "")))
1700
  except ValueError as e:
1701
  raise HTTPException(400, f"בשורה הזו אין כתובת מייל: {e}")
 
 
 
1702
  lessons = body.get("lessons")
1703
- if not isinstance(lessons, list) or not lessons or not set(map(str, lessons)) <= set(ALL_LESSONS):
1704
- raise HTTPException(400, "בחרו לפחות שיעור אחד לכיתה")
1705
- lessons = [l for l in ALL_LESSONS if l in map(str, lessons)]
1706
  teachers = body.get("teachers")
1707
  if teachers is not None:
1708
  if not isinstance(teachers, list) or not all(isinstance(e, str) for e in teachers):
@@ -1710,7 +1897,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
1710
  teachers = list(dict.fromkeys(e.strip().lower() for e in teachers if e.strip()))
1711
  if not set(teachers) <= set(emails):
1712
  raise HTTPException(400, "מורה של הכיתה צריך להיות אחד מהתלמידים הרשומים בה")
1713
- listed = body.get("homeworks")
1714
  if listed is None:
1715
  listed = [{"topic": body.get("homework"), "due": body.get("due")}]
1716
  if not isinstance(listed, list) or len(listed) > MAX_HOMEWORKS or not all(isinstance(h, dict) for h in listed):
@@ -1728,7 +1915,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
1728
  homeworks.append({"topic": topic, "due": date.fromisoformat(due).isoformat() if due else None})
1729
  except ValueError:
1730
  raise HTTPException(400, "התאריך של שיעורי הבית לא תקין")
1731
- dailies = body.get("dailies")
1732
  if dailies is not None:
1733
  if not isinstance(dailies, list) or len(dailies) > MAX_DAILIES or not all(isinstance(d, dict) for d in dailies):
1734
  raise HTTPException(400, "הסרטונים היומיים לא תקינים")
@@ -1754,7 +1941,6 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
1754
  raise HTTPException(409, f"כבר יש כיתה בשם {taken}")
1755
  # a class that has no daily videos, and had none, is saved without them: a table made before them takes it
1756
  had = any(c["id"] == id and c.get("dailies") for c in visible)
1757
- subject = str((actor or {}).get("subject") or "").strip()
1758
  if (id is None and hasattr(a, "access_for") and actor and "admin" in actor.get("roles", ())
1759
  and "developer" not in actor.get("roles", ()) and not subject):
1760
  raise HTTPException(409, "לא הוגדר מקצוע למנהל/ת. מפתח/ת צריך להגדיר אותו בניטור.")
@@ -2067,6 +2253,47 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
2067
  out.append({"name": c["name"], "students": students, "unnamed": len(named) - len(students)})
2068
  return {"classes": out}
2069
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2070
  @app.get("/api/phrases")
2071
  def phrases():
2072
  return {"levels": LEVELS, "lessons": LESSONS, "categories": CATEGORIES, "games": GAMES, "phrases": public_phrases(),
 
13
  from __future__ import annotations
14
 
15
  import hashlib
16
+ import io
17
+ import json
18
  import logging
19
  import os
20
  import re
 
22
  import time
23
  import urllib.parse
24
  import uuid
25
+ import zipfile
26
  from contextlib import asynccontextmanager
27
  from datetime import date, datetime, timedelta, timezone
28
  from pathlib import Path
 
39
  from .keystore import (CREATE_APP_SECRETS, MAX_LEN as MAX_KEY_LEN, MIGRATE_APP_SECRETS, NAMES as KEY_NAMES, KeyRefused,
40
  check as check_key, load_keys)
41
  from .live import LiveConflict, LiveStore
42
+ from .math_photo import (DAILY_PHOTOS, MAX_PHOTO, MEDIA_TYPES as PHOTO_TYPES, PhotoSolver, PhotoUnavailable, math_model,
43
+ photo_client)
44
+ from .math_skills import algebra9
45
  from .phrases import CATEGORIES, GAMES, LESSONS, LEVELS, PHRASES, public_phrases
46
  from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
47
  from .reference import References
 
61
  # A class topic's opening screen, by the path it is served at; "/" is Arabic.
62
  SUBJECTS = {
63
  "arabic": {"__TITLE__": "אהלא וסהלא", "__SPLASH_LOGO__": "/static/logo-intro.svg",
64
+ "__SPLASH_ALT__": "אהלא וסהלא, בית הספר ללימוד השפה הערבית", "__START_LABEL__": "יאללה נבלש",
65
+ "__APP_NAME__": "אהלא וסהלא", "__THEME_COLOR__": "#4B6A2C", "__MANIFEST__": "/static/manifest.webmanifest"},
66
  "math": {"__TITLE__": "מתמטיקה בכיף", "__SPLASH_LOGO__": "/static/logo-math.svg",
67
+ "__SPLASH_ALT__": "מתמטיקה בכיף, המורה הפרטי למתמטיקה", "__START_LABEL__": "יאללה נתחיל",
68
+ "__APP_NAME__": "מתמטיקה בכיף", "__THEME_COLOR__": "#000000", "__MANIFEST__": "/math/manifest.webmanifest"},
69
  }
70
+ # 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.
71
+ SUBJECT_LABELS = {"arabic": "ערבית", "math": "מתמטיקה"}
72
+ # What the installed app of a subject other than Arabic changes in /static/manifest.webmanifest: its own name and
73
+ # start page, so a math student's home-screen icon opens /math. The scope stays "/", the service worker's.
74
+ MANIFESTS = {"math": {"id": "/math", "name": "מתמטיקה בכיף", "short_name": "מתמטיקה בכיף", "description": "תרגול מתמטיקה",
75
+ "start_url": "/math", "theme_color": "#000000", "background_color": "#000000"}}
76
+
77
+
78
+ def subject_code(label: object) -> str:
79
+ """The path of a stored subject ("ערבית" -> "arabic"); "" for none, and for one that is not in SUBJECT_LABELS."""
80
+ label = " ".join(str(label or "").split())
81
+ return next((code for code, name in SUBJECT_LABELS.items() if name == label), "")
82
  MODEL_ID = os.environ.get("MODEL_ID", "facebook/wav2vec2-xlsr-53-espeak-cv-ft")
83
  LOCAL_TZ = ZoneInfo("Asia/Jerusalem") # a day of practice is the students' calendar day
84
  MAX_UPLOAD = 2_000_000 # bytes; 15 s of opus is ~100 KB
85
+ MAX_BACKUP_UPLOAD = 250_000_000
86
  EMAIL = re.compile(r"[^@\s,;<>]+@[^@\s,;<>]+\.[^@\s,;<>]+")
87
  MAX_NICKNAME = 40
88
  ALL_LESSONS = [l["id"] for l in LESSONS] # with the lessons still to come: a class may take them ahead
 
258
  MAX_HOMEWORKS = 30
259
 
260
 
261
+ def is_math(c: dict) -> bool:
262
+ return subject_code(c.get("subject")) == "math"
263
+
264
+
265
  def lessons_of(c: dict) -> list[str]:
266
+ """A class's Arabic lessons in the course's order; a class saved before classes had lessons has every one.
267
+ A math class has none: its `lessons` column holds the math units it studies (see `units_of`)."""
268
+ if is_math(c):
269
+ return []
270
  return [l for l in ALL_LESSONS if l in c["lessons"]] or ALL_LESSONS
271
 
272
 
273
+ def units_of(c: dict) -> list[str]:
274
+ """A math class's units of the path (app/math_algebra9.json), in the path's order; none chosen means every one."""
275
+ ids = [u["id"] for u in algebra9()["units"]]
276
+ return [u for u in ids if u in (c.get("lessons") or [])] or ids
277
+
278
+
279
+ def class_lessons(c: dict) -> list[str]:
280
+ """What a class studies, as the page gets it: math units for a math class, Arabic lessons for any other."""
281
+ return units_of(c) if is_math(c) else lessons_of(c)
282
+
283
+
284
  def homework_of(c: dict, topics: dict[str, str] = TOPIC_LESSON) -> dict:
285
  """A class's homework: the topic it practises now (a category id, e.g. the conversation "l2-talk"; "" when none
286
  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
 
357
 
358
 
359
  def build_version() -> str:
360
+ """Debug build stamp: short content hash of the served code + newest file time (UTC). The hash also stamps the
361
+ page's own scripts and styles (?v=), so a change to one of them reaches the service worker's cache-first copy."""
362
+ files = (sorted(Path(__file__).resolve().parent.glob("*.py")) + [STATIC / "index.html"]
363
+ + sorted(STATIC.glob("*.js")) + sorted(STATIC.glob("*.css")))
364
  h = hashlib.sha1()
365
  for f in files:
366
  h.update(f.read_bytes())
 
610
  def page(subject: str) -> HTMLResponse:
611
  html = (STATIC / "index.html").read_text(encoding="utf-8")
612
  a: SupabaseAuth | None = app.state.auth
613
+ version = build_version()
614
+ marks = {"__VERSION__": version, "__ASSET_V__": version.split(" ")[0], "__SUPABASE_URL__": a.url if a else "",
615
+ "__SUPABASE_KEY__": a.key if a else "", "__SUBJECT__": subject, "__HOME__": home, **SUBJECTS[subject]}
616
  for mark, value in marks.items():
617
  html = html.replace(mark, value)
618
  return HTMLResponse(html, headers={"Cache-Control": "no-cache"})
619
 
620
+ # the subject "/" opens: Arabic, or on a Space of its own another one (HOME_SUBJECT=math on mathly); each also has its own path
621
+ home = os.environ.get("HOME_SUBJECT", "").strip() or "arabic"
622
+ if home not in SUBJECTS:
623
+ raise RuntimeError(f"HOME_SUBJECT must be one of: {', '.join(SUBJECTS)}")
624
+
625
  @app.get("/")
626
  def index():
627
+ return page(home)
628
 
629
  for subject in SUBJECTS: # the same app under a class topic's own opening screen: /arabic, /math
630
  app.get(f"/{subject}", include_in_schema=False)(lambda subject=subject: page(subject))
631
 
632
+ def manifest(subject: str) -> Response:
633
+ base = json.loads((STATIC / "manifest.webmanifest").read_text(encoding="utf-8"))
634
+ return Response(json.dumps({**base, **MANIFESTS[subject]}, ensure_ascii=False), media_type="application/manifest+json",
635
+ headers={"Cache-Control": "no-cache"})
636
+
637
+ for subject in MANIFESTS:
638
+ app.get(f"/{subject}/manifest.webmanifest", include_in_schema=False)(lambda subject=subject: manifest(subject))
639
+
640
  @app.get("/service-worker.js", include_in_schema=False)
641
  def service_worker():
642
  return Response(
 
661
  for d in [d for c in app.state.auth.classes() for d in dailies_of(c, daily_rows())] if can_manage else []:
662
  if d["id"] not in every or d["date"] < every[d["id"]]["date"]:
663
  every[d["id"]] = d
664
+ # the subject whose pages the account belongs on: an admin's own, else their class's (a student has one)
665
+ subject = subject_code(user.get("subject")) if user else ""
666
+ subject = subject or next((s for c in mine if (s := subject_code(c.get("subject")))), "")
667
  return {"email": user["email"] if user else "", "teacher": bool(user and user["teacher"]),
668
+ **({"subject": subject} if subject else {}),
669
  **({"tutor": True} if user and user["tutor"] else {}), # the page shows them the flag under a score
670
  "roles": user.get("roles", []) if user else [], "entitlements": user.get("entitlements", []) if user else [],
671
  "permissions": user.get("permissions", []) if user else ["*"],
672
  **({"dailies": sorted(every.values(), key=lambda d: (d["date"], d["id"]))} if can_all else {}),
673
+ "classes": [{"name": c["name"], "lessons": class_lessons(c), **homework_of(c, topics()),
674
+ "dailies": dailies_of(c, daily_rows()),
675
+ **({"subject": s} if (s := subject_code(c.get("subject"))) else {})} for c in mine],
676
+ **({"teaches": [{"id": c["id"], "name": c["name"], "lessons": class_lessons(c)}
677
  for c in user.get("teaches", [])]} if user and user.get("teaches") else {}),
678
  "lessons": lessons}
679
 
 
688
  raise HTTPException(403, "רק מורה של הכיתה יכול/ה לנהל את המפגש")
689
 
690
  @app.get("/api/live")
691
+ def live_list(response: Response, user: dict | None = Depends(signed_in)):
692
+ response.headers["Cache-Control"] = "no-store"
693
  if not user:
694
  return {"sessions": [], "teaches": []}
695
  mine = {c["id"] for c in user.get("classes", [])}
696
  teaches = {c["id"] for c in user.get("teaches", [])}
697
  sessions = app.state.live.list_for(mine | teaches, user["id"])
698
  return {"sessions": [{**s, "teacher": s["class_id"] in teaches} for s in sessions],
699
+ "teaches": [{"id": c["id"], "name": c["name"], "lessons": class_lessons(c)}
700
  for c in user.get("teaches", [])]}
701
 
702
  @app.post("/api/live")
 
735
  return session, course
736
 
737
  @app.get("/api/live/{code}")
738
+ def live_status(code: str, response: Response, user: dict | None = Depends(signed_in)):
739
+ response.headers["Cache-Control"] = "no-store"
740
  session = app.state.live.get(code)
741
  if not session:
742
  raise HTTPException(404, "המפגש הסתיים")
 
891
  if ranked is None:
892
  setup.append({"what": "הטבלה tutors לא קיימת ב-Supabase, ולכן אי אפשר לתת לתלמיד דרגת מדווח/ת (דגל 🚩 מתחת לציון).",
893
  "sql": CREATE_TUTORS})
894
+ return {"classes": [{**c, "lessons": class_lessons(c), **homework_of(c, lesson_of)} for c in classes], "students": students,
895
  "setup": setup}
896
 
897
  @app.get("/api/activity")
 
1054
  users, rows = a.users(), a.access_rows()
1055
  except AuthUnavailable as e:
1056
  raise HTTPException(503, f"Supabase לא זמין: {e}")
1057
+ subjects = [{"code": code, "label": label} for code, label in SUBJECT_LABELS.items()]
1058
  if rows is None:
1059
+ return {"admins": [], "subjects": subjects, "setup": CREATE_USER_ACCESS, "migration": MIGRATE_USER_ACCESS}
1060
  assigned = {r["user_id"]: r for r in rows}
1061
  out = []
1062
  for account in users:
 
1067
  if "admin" in roles:
1068
  out.append({"id": account["id"], "email": account["email"], "name": account.get("name", ""),
1069
  "subject": str(row.get("subject") or "")})
1070
+ return {"admins": out, "subjects": subjects, "setup": "", "migration": ""}
1071
 
1072
  @app.put("/api/admin-subjects/{target_id}")
1073
  def update_admin_subject(target_id: str, body: dict = Body(...),
 
1075
  if not actor or "*" not in actor.get("permissions", ()):
1076
  raise HTTPException(403, "developers only")
1077
  subject = " ".join(str(body.get("subject", "")).split())
1078
+ if not subject_code(subject): # a fixed list: a class matches its admin's subject only when both are spelled the same
1079
+ raise HTTPException(400, "subject must be one of: " + ", ".join(SUBJECT_LABELS.values()))
1080
  try:
1081
  target = next((u for u in a.users() if u["id"] == target_id), None)
1082
  if not target:
 
1096
  raise HTTPException(503, f"Supabase לא זמין: {e}")
1097
  return {"ok": True, "subject": subject}
1098
 
1099
+ def make_backup_archive(a: SupabaseAuth) -> tuple[str, bytes]:
1100
+ tables, archive = a.backup_rows(), io.BytesIO()
1101
+ tables["final_scores"] = a.final_scores()
1102
+ with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as out:
1103
+ manifest = {"format": "ihki-backup", "version": 1,
1104
+ "created_at": datetime.now(timezone.utc).isoformat(), "tables": tables, "files": []}
1105
+ out.writestr("backup.json", json.dumps(manifest, ensure_ascii=False, separators=(",", ":")))
1106
+ stamp = datetime.now(LOCAL_TZ).strftime("%Y-%m-%d_%H-%M-%S")
1107
+ return f"ihki-backup-{stamp}.zip", archive.getvalue()
1108
+
1109
+ def apply_backup_archive(raw: bytes, a: SupabaseAuth) -> dict[str, int]:
1110
+ with zipfile.ZipFile(io.BytesIO(raw)) as src:
1111
+ if sum(x.file_size for x in src.infolist()) > MAX_BACKUP_UPLOAD * 2:
1112
+ raise ValueError("הארכיון גדול מדי לאחר פתיחה")
1113
+ manifest = json.loads(src.read("backup.json"))
1114
+ if manifest.get("format") != "ihki-backup" or manifest.get("version") != 1:
1115
+ raise ValueError("זה אינו קובץ גיבוי נתמך של המערכת")
1116
+ tables, files = manifest.get("tables"), manifest.get("files")
1117
+ if not isinstance(tables, dict) or not isinstance(files, list) or \
1118
+ any(not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows)
1119
+ for rows in tables.values()):
1120
+ raise ValueError("מבנה הנתונים בגיבוי אינו תקין")
1121
+ restored = a.restore_rows(tables)
1122
+ if "final_scores" in tables:
1123
+ restored["final_scores"] = a.restore_final_scores(tables["final_scores"])
1124
+ return restored
1125
+
1126
+ @app.post("/api/admin/backups")
1127
+ def create_saved_backup(a: SupabaseAuth = Depends(require("*")), actor: dict | None = Depends(signed_in)):
1128
+ """Create a timestamped database-only archive in the private backups bucket."""
1129
+ if not actor or "*" not in actor.get("permissions", ()):
1130
+ raise HTTPException(403, "developers only")
1131
+ try:
1132
+ name, data = make_backup_archive(a)
1133
+ a.save_backup(name, data)
1134
+ return {"ok": True, "name": name}
1135
+ except AuthUnavailable as e:
1136
+ raise HTTPException(503, f"הגיבוי נכשל: {e}")
1137
+
1138
+ @app.get("/api/admin/backups")
1139
+ def saved_backups(a: SupabaseAuth = Depends(require("*")), actor: dict | None = Depends(signed_in)):
1140
+ if not actor or "*" not in actor.get("permissions", ()):
1141
+ raise HTTPException(403, "developers only")
1142
+ try:
1143
+ return {"backups": a.list_backups()}
1144
+ except AuthUnavailable as e:
1145
+ raise HTTPException(503, f"טעינת הגיבויים נכשלה: {e}")
1146
+
1147
+ @app.get("/api/admin/backups/file")
1148
+ def download_saved_backup(name: str = Query(...), a: SupabaseAuth = Depends(require("*")),
1149
+ actor: dict | None = Depends(signed_in)):
1150
+ if not actor or "*" not in actor.get("permissions", ()):
1151
+ raise HTTPException(403, "developers only")
1152
+ if not re.fullmatch(r"ihki-backup-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.zip", name):
1153
+ raise HTTPException(400, "שם גיבוי לא תקין")
1154
+ try:
1155
+ data, _ = a._get_object("backups", name)
1156
+ return Response(data, media_type="application/zip", headers={
1157
+ "Content-Disposition": f'attachment; filename="{name}"', "Cache-Control": "no-store"})
1158
+ except AuthUnavailable as e:
1159
+ raise HTTPException(503, f"הורדת הגיבוי נכשלה: {e}")
1160
+
1161
+ @app.get("/api/admin/backup")
1162
+ def download_backup(a: SupabaseAuth = Depends(require("*")), actor: dict | None = Depends(signed_in)):
1163
+ """Download a fresh archive directly; retained for manual off-site copies."""
1164
+ if not actor or "*" not in actor.get("permissions", ()):
1165
+ raise HTTPException(403, "developers only")
1166
+ try:
1167
+ name, data = make_backup_archive(a)
1168
+ return Response(data, media_type="application/zip", headers={
1169
+ "Content-Disposition": f'attachment; filename="{name}"', "Cache-Control": "no-store"})
1170
+ except AuthUnavailable as e:
1171
+ raise HTTPException(503, f"הגיבוי נכשל: {e}")
1172
+
1173
+ @app.post("/api/admin/backups/restore")
1174
+ def restore_saved_backup(body: dict = Body(...), a: SupabaseAuth = Depends(require("*")),
1175
+ actor: dict | None = Depends(signed_in)):
1176
+ if not actor or "*" not in actor.get("permissions", ()):
1177
+ raise HTTPException(403, "developers only")
1178
+ name = str(body.get("name", ""))
1179
+ if not re.fullmatch(r"ihki-backup-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.zip", name):
1180
+ raise HTTPException(400, "שם גיבוי לא תקין")
1181
+ try:
1182
+ raw, _ = a._get_object("backups", name)
1183
+ restored = apply_backup_archive(raw, a)
1184
+ return {"ok": True, "tables": restored, "name": name}
1185
+ except (ValueError, KeyError, json.JSONDecodeError, zipfile.BadZipFile) as e:
1186
+ raise HTTPException(400, f"קובץ הגיבוי אינו תקין: {e}")
1187
+ except AuthUnavailable as e:
1188
+ raise HTTPException(503, f"השחזור נעצר: {e}")
1189
+
1190
+ @app.post("/api/admin/restore")
1191
+ async def restore_backup(backup: UploadFile = File(...), a: SupabaseAuth = Depends(require("*")),
1192
+ actor: dict | None = Depends(signed_in)):
1193
+ """Merge a portable app backup. It updates matching rows but never deletes unrelated current data."""
1194
+ if not actor or "*" not in actor.get("permissions", ()):
1195
+ raise HTTPException(403, "developers only")
1196
+ raw = await backup.read(MAX_BACKUP_UPLOAD + 1)
1197
+ if len(raw) > MAX_BACKUP_UPLOAD:
1198
+ raise HTTPException(413, "קובץ הגיבוי גדול מדי (עד 250 MB)")
1199
+ try:
1200
+ restored = apply_backup_archive(raw, a)
1201
+ return {"ok": True, "tables": restored}
1202
+ except (ValueError, KeyError, json.JSONDecodeError, zipfile.BadZipFile) as e:
1203
+ raise HTTPException(400, f"קובץ הגיבוי אינו תקין: {e}")
1204
+ except AuthUnavailable as e:
1205
+ raise HTTPException(503, f"השחזור נעצר: {e}")
1206
+
1207
  @app.get("/api/flags")
1208
  def flags(status: str = Query("pending"), a: SupabaseAuth = Depends(teacher_only)):
1209
  """The score reports awaiting a teacher's decision (or one reviewed state)."""
 
1857
  return f"לטבלה classes חסרות עמודות. הריצו ב-SQL Editor של Supabase: {MIGRATE_CLASSES}"
1858
  return f"Supabase לא זמין: {e}"
1859
 
1860
+ def class_subject(a: SupabaseAuth, id: int | None, body: dict, actor: dict | None) -> str:
1861
+ """The subject of a class being saved: a stored class keeps its own; a new one takes its admin's, and one
1862
+ opened by a developer (who has none) the subject the page asks for ("subject", as SUBJECT_LABELS spells it)."""
1863
+ if id is not None:
1864
+ try:
1865
+ stored = next((c for c in managed_classes(a, actor, fresh=True) if c["id"] == id), None)
1866
+ except AuthUnavailable as e:
1867
+ raise HTTPException(503, class_store_error(e))
1868
+ return str((stored or {}).get("subject") or "").strip()
1869
+ own = str((actor or {}).get("subject") or "").strip()
1870
+ asked = " ".join(str(body.get("subject") or "").split())
1871
+ return own or (asked if subject_code(asked) else "")
1872
+
1873
  def save_class(a: SupabaseAuth, id: int | None, body: dict, actor: dict | None = None) -> dict:
1874
  """Checks a class from the panel ({"name", "emails": the student list, see `class_members`, "lessons": lesson
1875
  ids, "homeworks": [{"topic": a topic of one of them, "due": its ISO date, or ""}], each topic once; or, as
 
1883
  emails, nicks = class_members(str(body.get("emails", "")))
1884
  except ValueError as e:
1885
  raise HTTPException(400, f"בשורה הזו אין כתובת מייל: {e}")
1886
+ subject = class_subject(a, id, body, actor)
1887
+ math = subject_code(subject) == "math" # a math class studies units of the math path, and has no homework or daily videos yet
1888
+ known = [u["id"] for u in algebra9()["units"]] if math else ALL_LESSONS
1889
  lessons = body.get("lessons")
1890
+ if not isinstance(lessons, list) or not lessons or not set(map(str, lessons)) <= set(known):
1891
+ raise HTTPException(400, "בחרו לפחות יחידה אחת לכיתה" if math else "בחרו לפחות שיעור אחד לכיתה")
1892
+ lessons = [l for l in known if l in map(str, lessons)]
1893
  teachers = body.get("teachers")
1894
  if teachers is not None:
1895
  if not isinstance(teachers, list) or not all(isinstance(e, str) for e in teachers):
 
1897
  teachers = list(dict.fromkeys(e.strip().lower() for e in teachers if e.strip()))
1898
  if not set(teachers) <= set(emails):
1899
  raise HTTPException(400, "מורה של הכיתה צריך להיות אחד מהתלמידים הרשומים בה")
1900
+ listed = [] if math else body.get("homeworks")
1901
  if listed is None:
1902
  listed = [{"topic": body.get("homework"), "due": body.get("due")}]
1903
  if not isinstance(listed, list) or len(listed) > MAX_HOMEWORKS or not all(isinstance(h, dict) for h in listed):
 
1915
  homeworks.append({"topic": topic, "due": date.fromisoformat(due).isoformat() if due else None})
1916
  except ValueError:
1917
  raise HTTPException(400, "התאריך של שיעורי הבית לא תקין")
1918
+ dailies = None if math else body.get("dailies")
1919
  if dailies is not None:
1920
  if not isinstance(dailies, list) or len(dailies) > MAX_DAILIES or not all(isinstance(d, dict) for d in dailies):
1921
  raise HTTPException(400, "הסרטונים היומיים לא תקינים")
 
1941
  raise HTTPException(409, f"כבר יש כיתה בשם {taken}")
1942
  # a class that has no daily videos, and had none, is saved without them: a table made before them takes it
1943
  had = any(c["id"] == id and c.get("dailies") for c in visible)
 
1944
  if (id is None and hasattr(a, "access_for") and actor and "admin" in actor.get("roles", ())
1945
  and "developer" not in actor.get("roles", ()) and not subject):
1946
  raise HTTPException(409, "לא הוגדר מקצוע למנהל/ת. מפתח/ת צריך להגדיר אותו בניטור.")
 
2253
  out.append({"name": c["name"], "students": students, "unnamed": len(named) - len(students)})
2254
  return {"classes": out}
2255
 
2256
+ @app.get("/api/math/skills")
2257
+ def math_skills():
2258
+ """The math path's skill graph (see app/math_skills.py). Public, as the phrase list is."""
2259
+ return algebra9()
2260
+
2261
+ photo_counts: dict[tuple[str, str], int] = {} # (account, day) -> photos solved; in memory, so a restart resets it
2262
+
2263
+ def photo_solver() -> PhotoSolver:
2264
+ """The solver on the key in use now (the panel's, else the environment's): a key changed in the panel applies."""
2265
+ key = (app.state.keys.get("ANTHROPIC_API_KEY") or "").strip()
2266
+ if not key:
2267
+ raise HTTPException(503, "פתרון מתמונה לא מוגדר בשרת (חסר ANTHROPIC_API_KEY)")
2268
+ if getattr(app.state, "photo_key", None) != key:
2269
+ app.state.photo, app.state.photo_key = PhotoSolver(photo_client(key), math_model(), algebra9()["skills"]), key
2270
+ return app.state.photo
2271
+
2272
+ @app.post("/api/math/photo")
2273
+ def math_photo(photo: UploadFile = File(...), user: dict | None = Depends(signed_in)):
2274
+ """A photographed problem, solved by Claude (see app/math_photo.py): {readable, problem, steps: [{hint, work}],
2275
+ answer, skill, note, left}. `left` is how many photos the account has left today (None: no limit). 429 past
2276
+ DAILY_PHOTOS a day (developers and an open service are exempt), 502 when the model did not solve it."""
2277
+ solver = photo_solver()
2278
+ day = datetime.now(LOCAL_TZ).date().isoformat()
2279
+ who = (user["id"], day) if user and "developer" not in user.get("roles", ()) else None
2280
+ if who and photo_counts.get(who, 0) >= DAILY_PHOTOS:
2281
+ raise HTTPException(429, f"צילמתם {DAILY_PHOTOS} תרגילים היום. נתראה מחר!")
2282
+ if photo.content_type not in PHOTO_TYPES:
2283
+ raise HTTPException(415, "אפשר לשלוח תמונה מסוג JPEG,‏ PNG או WebP.")
2284
+ image = photo.file.read(MAX_PHOTO + 1)
2285
+ if not image:
2286
+ raise HTTPException(400, "התמונה ריקה.")
2287
+ if len(image) > MAX_PHOTO:
2288
+ raise HTTPException(413, "התמונה גדולה מדי.")
2289
+ try:
2290
+ out = solver.solve(image, photo.content_type)
2291
+ except PhotoUnavailable as e:
2292
+ raise HTTPException(502, str(e))
2293
+ if who and out.get("readable"): # an unreadable photo does not count against the day
2294
+ photo_counts[who] = photo_counts.get(who, 0) + 1
2295
+ return {**out, "left": DAILY_PHOTOS - photo_counts.get(who, 0) if who else None}
2296
+
2297
  @app.get("/api/phrases")
2298
  def phrases():
2299
  return {"levels": LEVELS, "lessons": LESSONS, "categories": CATEGORIES, "games": GAMES, "phrases": public_phrases(),
app/math_algebra9.json ADDED
@@ -0,0 +1,2380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "source": {
3
+ "schedule": "https://meyda.education.gov.il/files/Pop/0files/matmatika/Chativat-Beynayim/tashpaz/prisa_9A.pdf",
4
+ "summer": "https://pop.education.gov.il/maagal_hashana/summer-preparatory/mathematics-9-5/"
5
+ },
6
+ "mastery": "5 תשובות נכונות מתוך 6 האחרונות, מהן 2 לפחות ברמה 2",
7
+ "units": [
8
+ {
9
+ "id": "f",
10
+ "name": "יסודות",
11
+ "source": "חזרה מכיתות ז׳–ח׳",
12
+ "when": ""
13
+ },
14
+ {
15
+ "id": "t",
16
+ "name": "טכניקה אלגברית",
17
+ "source": "פריסת ט׳",
18
+ "when": "ספטמבר–אוקטובר, ינואר"
19
+ },
20
+ {
21
+ "id": "n",
22
+ "name": "תכונות של פונקציה",
23
+ "source": "פריסת ט׳",
24
+ "when": "מרץ"
25
+ },
26
+ {
27
+ "id": "q",
28
+ "name": "פונקציה ומשוואה ריבועית",
29
+ "source": "פריסת ט׳",
30
+ "when": "נובמבר–פברואר"
31
+ },
32
+ {
33
+ "id": "p",
34
+ "name": "חזקות ושורשים",
35
+ "source": "פריסת ט׳",
36
+ "when": "מאי"
37
+ },
38
+ {
39
+ "id": "w",
40
+ "name": "שאלות מילוליות",
41
+ "source": "פריסת ט׳",
42
+ "when": "מאי"
43
+ }
44
+ ],
45
+ "skills": [
46
+ {
47
+ "id": "f_neg",
48
+ "unit": "f",
49
+ "name": "מספרים מכוונים",
50
+ "short": "מספרים מכוונים",
51
+ "prereqs": [],
52
+ "description": "ארבע פעולות עם מספרים שליליים, כולל חיסור של מספר שלילי.",
53
+ "sample": {
54
+ "question": "$−3 − (−5)·2 = ?$",
55
+ "choices": [
56
+ {
57
+ "text": "$7$",
58
+ "correct": true
59
+ },
60
+ {
61
+ "text": "$−13$",
62
+ "mistake": "minus_minus"
63
+ },
64
+ {
65
+ "text": "$4$",
66
+ "mistake": "order_ops"
67
+ },
68
+ {
69
+ "text": "$−7$",
70
+ "mistake": "result_sign"
71
+ }
72
+ ]
73
+ }
74
+ },
75
+ {
76
+ "id": "f_frac",
77
+ "unit": "f",
78
+ "name": "פעולות בשברים",
79
+ "short": "שברים",
80
+ "prereqs": [],
81
+ "description": "חיבור, חיסור, כפל וחילוק של שברים, כולל מכנה משותף.",
82
+ "sample": {
83
+ "question": "$2/3 + 1/4 = ?$",
84
+ "choices": [
85
+ {
86
+ "text": "$11/12$",
87
+ "correct": true
88
+ },
89
+ {
90
+ "text": "$3/7$",
91
+ "mistake": "add_denoms"
92
+ },
93
+ {
94
+ "text": "$3/12$",
95
+ "mistake": "scale_numerators"
96
+ },
97
+ {
98
+ "text": "$1/6$",
99
+ "mistake": "mult_not_add"
100
+ }
101
+ ]
102
+ }
103
+ },
104
+ {
105
+ "id": "f_ops",
106
+ "unit": "f",
107
+ "name": "סדר פעולות והצבה",
108
+ "short": "סדר פעולות",
109
+ "prereqs": [
110
+ "f_neg",
111
+ "f_frac"
112
+ ],
113
+ "description": "הצבת מספרים, גם שליליים, בביטוי אלגברי לפי סדר הפעולות.",
114
+ "sample": {
115
+ "question": "הציבו $x = −2$: $3x² − x = ?$",
116
+ "choices": [
117
+ {
118
+ "text": "$14$",
119
+ "correct": true
120
+ },
121
+ {
122
+ "text": "$−10$",
123
+ "mistake": "neg_square"
124
+ },
125
+ {
126
+ "text": "$38$",
127
+ "mistake": "coef_in_power"
128
+ },
129
+ {
130
+ "text": "$10$",
131
+ "mistake": "minus_minus"
132
+ }
133
+ ]
134
+ }
135
+ },
136
+ {
137
+ "id": "f_like",
138
+ "unit": "f",
139
+ "name": "כינוס איברים דומים",
140
+ "short": "כינוס איברים",
141
+ "prereqs": [
142
+ "f_ops"
143
+ ],
144
+ "description": "פישוט ביטוי בחיבור וחיסור של איברים דומים.",
145
+ "sample": {
146
+ "question": "$5a − 2b − 3a + 4b = ?$",
147
+ "choices": [
148
+ {
149
+ "text": "$2a + 2b$",
150
+ "correct": true
151
+ },
152
+ {
153
+ "text": "$4ab$",
154
+ "mistake": "join_unlike"
155
+ },
156
+ {
157
+ "text": "$8a + 2b$",
158
+ "mistake": "sign_of_term"
159
+ },
160
+ {
161
+ "text": "$2a − 6b$",
162
+ "mistake": "sign_of_term"
163
+ }
164
+ ]
165
+ }
166
+ },
167
+ {
168
+ "id": "f_power",
169
+ "unit": "f",
170
+ "name": "חזקה כמכפלה חוזרת",
171
+ "short": "חזקה בסיסית",
172
+ "prereqs": [
173
+ "f_ops"
174
+ ],
175
+ "description": "$x² = x·x$, וגם המקדם עולה בחזקה. הפריסה דורשת רק את זה לפני הטכניקה האלגברית.",
176
+ "sample": {
177
+ "question": "$(−3x)² = ?$",
178
+ "choices": [
179
+ {
180
+ "text": "$9x²$",
181
+ "correct": true
182
+ },
183
+ {
184
+ "text": "$−9x²$",
185
+ "mistake": "neg_square"
186
+ },
187
+ {
188
+ "text": "$−3x²$",
189
+ "mistake": "coef_not_raised"
190
+ },
191
+ {
192
+ "text": "$−6x²$",
193
+ "mistake": "power_as_mult"
194
+ }
195
+ ]
196
+ }
197
+ },
198
+ {
199
+ "id": "f_dist",
200
+ "unit": "f",
201
+ "name": "חוק הפילוג",
202
+ "short": "חוק הפילוג",
203
+ "prereqs": [
204
+ "f_like"
205
+ ],
206
+ "description": "מספר כפול סוגריים: $a(b + c) = ab + ac$.",
207
+ "sample": {
208
+ "question": "$−2(3x − 4) + 5x = ?$",
209
+ "choices": [
210
+ {
211
+ "text": "$−x + 8$",
212
+ "correct": true
213
+ },
214
+ {
215
+ "text": "$−x − 8$",
216
+ "mistake": "sign_product"
217
+ },
218
+ {
219
+ "text": "$−x − 4$",
220
+ "mistake": "partial_dist"
221
+ }
222
+ ]
223
+ }
224
+ },
225
+ {
226
+ "id": "f_lin",
227
+ "unit": "f",
228
+ "name": "משוואה ממעלה ראשונה",
229
+ "short": "משוואה קווית",
230
+ "prereqs": [
231
+ "f_dist"
232
+ ],
233
+ "description": "פתרון משוואה עם סוגריים, כולל משוואה בלי פתרון או עם אינסוף פתרונות.",
234
+ "sample": {
235
+ "question": "$3(x − 2) = x + 8$. $x = ?$",
236
+ "choices": [
237
+ {
238
+ "text": "$7$",
239
+ "correct": true
240
+ },
241
+ {
242
+ "text": "$5$",
243
+ "mistake": "partial_dist"
244
+ },
245
+ {
246
+ "text": "$1$",
247
+ "mistake": "move_sign"
248
+ }
249
+ ]
250
+ }
251
+ },
252
+ {
253
+ "id": "f_linfun",
254
+ "unit": "f",
255
+ "name": "פונקציה קווית",
256
+ "short": "פונקציה קווית",
257
+ "prereqs": [
258
+ "f_lin"
259
+ ],
260
+ "description": "שיפוע, נקודת חיתוך עם ציר y, וישר דרך שתי נקודות.",
261
+ "sample": {
262
+ "question": "מהו השיפוע של הישר העובר דרך $(1, 2)$ ו־$(3, 8)$?",
263
+ "choices": [
264
+ {
265
+ "text": "$3$",
266
+ "correct": true
267
+ },
268
+ {
269
+ "text": "$1/3$",
270
+ "mistake": "slope_inverted"
271
+ },
272
+ {
273
+ "text": "$6$",
274
+ "mistake": "slope_dy_only"
275
+ }
276
+ ]
277
+ }
278
+ },
279
+ {
280
+ "id": "f_sys",
281
+ "unit": "f",
282
+ "name": "מערכת משוואות קוויות",
283
+ "short": "מערכת קווית",
284
+ "prereqs": [
285
+ "f_linfun"
286
+ ],
287
+ "description": "פתרון מערכת של שתי משוואות בהצבה או בהשוואת מקדמים.",
288
+ "sample": {
289
+ "question": "$x + y = 10$ , $x − y = 4$. $(x, y) = ?$",
290
+ "choices": [
291
+ {
292
+ "text": "$(7, 3)$",
293
+ "correct": true
294
+ },
295
+ {
296
+ "text": "$(3, 7)$",
297
+ "mistake": "swapped_pair"
298
+ },
299
+ {
300
+ "text": "$(14, −4)$",
301
+ "mistake": "forgot_divide"
302
+ }
303
+ ]
304
+ }
305
+ },
306
+ {
307
+ "id": "f_sqrt",
308
+ "unit": "f",
309
+ "name": "שורש ריבועי",
310
+ "short": "שורש ריבועי",
311
+ "prereqs": [
312
+ "f_neg"
313
+ ],
314
+ "description": "שורש ריבועי של מספר, וההבדל בין שורש של סכום לסכום שורשים.",
315
+ "sample": {
316
+ "question": "$√(9 + 16) = ?$",
317
+ "choices": [
318
+ {
319
+ "text": "$5$",
320
+ "correct": true
321
+ },
322
+ {
323
+ "text": "$7$",
324
+ "mistake": "sqrt_split"
325
+ },
326
+ {
327
+ "text": "$12.5$",
328
+ "mistake": "sqrt_half"
329
+ }
330
+ ]
331
+ }
332
+ },
333
+ {
334
+ "id": "t_ext",
335
+ "unit": "t",
336
+ "name": "חוק הפילוג המורחב",
337
+ "short": "פילוג מורחב",
338
+ "prereqs": [
339
+ "f_dist"
340
+ ],
341
+ "description": "סוגריים כפול סוגריים, ופישוט ביטויים בעזרתו.",
342
+ "month": "ספטמבר",
343
+ "core": true,
344
+ "sample": {
345
+ "question": "$(x + 3)(x − 5) = ?$",
346
+ "choices": [
347
+ {
348
+ "text": "$x² − 2x − 15$",
349
+ "correct": true
350
+ },
351
+ {
352
+ "text": "$x² − 15$",
353
+ "mistake": "first_last_only"
354
+ },
355
+ {
356
+ "text": "$x² − 8x − 15$",
357
+ "mistake": "sign_of_term"
358
+ },
359
+ {
360
+ "text": "$x² − 2x + 15$",
361
+ "mistake": "sign_product"
362
+ }
363
+ ]
364
+ }
365
+ },
366
+ {
367
+ "id": "t_ext_eq",
368
+ "unit": "t",
369
+ "name": "משוואות בעזרת הפילוג המורחב",
370
+ "short": "משוואה בפילוג",
371
+ "prereqs": [
372
+ "t_ext",
373
+ "f_lin"
374
+ ],
375
+ "description": "פתיחת סוגריים בשני האגפים, ולעתים קרובות $x²$ מצטמצם.",
376
+ "month": "ספטמבר",
377
+ "sample": {
378
+ "question": "$(x + 2)(x − 3) = x² + 4$. $x = ?$",
379
+ "choices": [
380
+ {
381
+ "text": "$−10$",
382
+ "correct": true
383
+ },
384
+ {
385
+ "text": "$10$",
386
+ "mistake": "div_neg"
387
+ },
388
+ {
389
+ "text": "אין פתרון",
390
+ "mistake": "first_last_only"
391
+ }
392
+ ]
393
+ }
394
+ },
395
+ {
396
+ "id": "t_square",
397
+ "unit": "t",
398
+ "name": "ריבוע של סכום והפרש",
399
+ "short": "ריבוע סכום",
400
+ "prereqs": [
401
+ "t_ext",
402
+ "f_power"
403
+ ],
404
+ "description": "$(a ± b)² = a² ± 2ab + b²$, בפיתוח אלגברי ובהמחשה גיאומטרית.",
405
+ "month": "ספטמבר",
406
+ "core": true,
407
+ "sample": {
408
+ "question": "$(2x − 3)² = ?$",
409
+ "choices": [
410
+ {
411
+ "text": "$4x² − 12x + 9$",
412
+ "correct": true
413
+ },
414
+ {
415
+ "text": "$4x² − 9$",
416
+ "mistake": "forgot_middle"
417
+ },
418
+ {
419
+ "text": "$2x² − 12x + 9$",
420
+ "mistake": "coef_not_raised"
421
+ },
422
+ {
423
+ "text": "$4x² − 6x + 9$",
424
+ "mistake": "middle_no_2"
425
+ }
426
+ ]
427
+ }
428
+ },
429
+ {
430
+ "id": "t_diffsq",
431
+ "unit": "t",
432
+ "name": "הפרש ריבועים",
433
+ "short": "הפרש ריבועים",
434
+ "prereqs": [
435
+ "t_ext"
436
+ ],
437
+ "description": "$(a − b)(a + b) = a² − b²$, גם לחישוב בעל־פה.",
438
+ "month": "ספטמבר",
439
+ "core": true,
440
+ "sample": {
441
+ "question": "חשבו בעל־פה: $51 · 49 = ?$",
442
+ "choices": [
443
+ {
444
+ "text": "$2499$",
445
+ "correct": true
446
+ },
447
+ {
448
+ "text": "$2501$",
449
+ "mistake": "diff_sign"
450
+ },
451
+ {
452
+ "text": "$2500$",
453
+ "mistake": "forgot_b2"
454
+ }
455
+ ]
456
+ }
457
+ },
458
+ {
459
+ "id": "t_gcf",
460
+ "unit": "t",
461
+ "name": "הוצאת גורם משותף",
462
+ "short": "גורם משותף",
463
+ "prereqs": [
464
+ "f_dist"
465
+ ],
466
+ "description": "פירוק לגורמים בהוצאת הגורם המשותף הגדול ביותר.",
467
+ "month": "אוקטובר",
468
+ "core": true,
469
+ "sample": {
470
+ "question": "פרקו: $6x² − 9x$",
471
+ "choices": [
472
+ {
473
+ "text": "$3x(2x − 3)$",
474
+ "correct": true
475
+ },
476
+ {
477
+ "text": "$3(2x² − 3x)$",
478
+ "mistake": "partial_gcf"
479
+ },
480
+ {
481
+ "text": "$x(6x − 9)$",
482
+ "mistake": "partial_gcf"
483
+ },
484
+ {
485
+ "text": "$3x(2x − 3x)$",
486
+ "mistake": "divide_each"
487
+ }
488
+ ]
489
+ }
490
+ },
491
+ {
492
+ "id": "t_factor_formula",
493
+ "unit": "t",
494
+ "name": "פירוק בנוסחאות הכפל המקוצר",
495
+ "short": "פירוק בנוסחאות",
496
+ "prereqs": [
497
+ "t_square",
498
+ "t_diffsq",
499
+ "t_gcf",
500
+ "f_sqrt"
501
+ ],
502
+ "description": "זיהוי הפרש ריבועים וריבוע של סכום בביטוי, ופירוקו.",
503
+ "month": "אוקטובר",
504
+ "sample": {
505
+ "question": "פרקו: $4x² − 25$",
506
+ "choices": [
507
+ {
508
+ "text": "$(2x − 5)(2x + 5)$",
509
+ "correct": true
510
+ },
511
+ {
512
+ "text": "$(2x − 5)²$",
513
+ "mistake": "diff_vs_square"
514
+ },
515
+ {
516
+ "text": "$(4x − 5)(4x + 5)$",
517
+ "mistake": "root_of_coef"
518
+ }
519
+ ]
520
+ }
521
+ },
522
+ {
523
+ "id": "t_trinomial",
524
+ "unit": "t",
525
+ "name": "פירוק טרינום $x² + bx + c$",
526
+ "short": "פירוק טרינום",
527
+ "prereqs": [
528
+ "t_ext"
529
+ ],
530
+ "description": "שני מספרים שמכפלתם c וסכומם b. לפי הפריסה רק כשהמקדם של $x²$ הוא 1.",
531
+ "month": "אוקטובר",
532
+ "sample": {
533
+ "question": "פרקו: $x² − x − 12$",
534
+ "choices": [
535
+ {
536
+ "text": "$(x − 4)(x + 3)$",
537
+ "correct": true
538
+ },
539
+ {
540
+ "text": "$(x + 4)(x − 3)$",
541
+ "mistake": "signs_swapped"
542
+ },
543
+ {
544
+ "text": "$(x − 6)(x + 2)$",
545
+ "mistake": "product_only"
546
+ },
547
+ {
548
+ "text": "$(x − 12)(x + 1)$",
549
+ "mistake": "product_only"
550
+ }
551
+ ]
552
+ }
553
+ },
554
+ {
555
+ "id": "t_domain",
556
+ "unit": "t",
557
+ "name": "תחום הצבה",
558
+ "short": "תחום הצבה",
559
+ "prereqs": [
560
+ "f_lin"
561
+ ],
562
+ "description": "אילו ערכים אסור להציב כי הם מאפסים את המכנה.",
563
+ "month": "ינואר",
564
+ "sample": {
565
+ "question": "מהו תחום ההצבה של $(x + 1)/(x(x + 4))$?",
566
+ "choices": [
567
+ {
568
+ "text": "$x ≠ 0$ , $x ≠ −4$",
569
+ "correct": true
570
+ },
571
+ {
572
+ "text": "$x ≠ 0$ , $x ≠ 4$",
573
+ "mistake": "zero_root_sign"
574
+ },
575
+ {
576
+ "text": "$x ≠ 0$ בלבד",
577
+ "mistake": "one_factor_only"
578
+ },
579
+ {
580
+ "text": "$x ≠ 0$ , $x ≠ −4$ , $x ≠ −1$",
581
+ "mistake": "numerator_domain"
582
+ }
583
+ ]
584
+ }
585
+ },
586
+ {
587
+ "id": "t_reduce",
588
+ "unit": "t",
589
+ "name": "צמצום שברים אלגבריים",
590
+ "short": "צמצום שברים",
591
+ "prereqs": [
592
+ "t_factor_formula",
593
+ "t_trinomial",
594
+ "t_domain"
595
+ ],
596
+ "description": "פירוק המונה והמכנה לגורמים וצמצום גורמים משותפים.",
597
+ "month": "אוקטובר",
598
+ "sample": {
599
+ "question": "צמצמו: $(x² − 9)/(x² + 5x + 6)$",
600
+ "choices": [
601
+ {
602
+ "text": "$(x − 3)/(x + 2)$",
603
+ "correct": true
604
+ },
605
+ {
606
+ "text": "$−9/(5x + 6)$",
607
+ "mistake": "cancel_terms"
608
+ },
609
+ {
610
+ "text": "$−3/2$",
611
+ "mistake": "cancel_terms"
612
+ },
613
+ {
614
+ "text": "$(x + 3)/(x + 2)$",
615
+ "mistake": "factor_sign"
616
+ }
617
+ ]
618
+ }
619
+ },
620
+ {
621
+ "id": "t_muldiv",
622
+ "unit": "t",
623
+ "name": "כפל וחילוק שברים אלגבריים",
624
+ "short": "כפל וחילוק שברים",
625
+ "prereqs": [
626
+ "t_reduce"
627
+ ],
628
+ "description": "כפל מונים ומכנים, חילוק ככפל בהופכי, ופירוק לפני צמצום.",
629
+ "month": "אוקטובר",
630
+ "sample": {
631
+ "question": "$2x/(x + 1) : 4x²/(x² − 1) = ?$",
632
+ "choices": [
633
+ {
634
+ "text": "$(x − 1)/(2x)$",
635
+ "correct": true
636
+ },
637
+ {
638
+ "text": "$8x³/((x + 1)²(x − 1))$",
639
+ "mistake": "div_not_flipped"
640
+ },
641
+ {
642
+ "text": "$(x − 1)/2$",
643
+ "mistake": "cancel_power"
644
+ },
645
+ {
646
+ "text": "$(x + 1)/(2x)$",
647
+ "mistake": "factor_sign"
648
+ }
649
+ ]
650
+ }
651
+ },
652
+ {
653
+ "id": "t_fraceq",
654
+ "unit": "t",
655
+ "name": "משוואות רציונליות",
656
+ "short": "משוואה רציונלית",
657
+ "prereqs": [
658
+ "t_reduce",
659
+ "q_eq_simple"
660
+ ],
661
+ "description": "פירוק המכנים, תחום הצבה, ופסילת פתרון שאינו קביל.",
662
+ "month": "ינואר",
663
+ "sample": {
664
+ "question": "$x²/(x − 3) = 9/(x − 3)$. $x = ?$",
665
+ "choices": [
666
+ {
667
+ "text": "$x = −3$",
668
+ "correct": true
669
+ },
670
+ {
671
+ "text": "$x = 3$ או $x = −3$",
672
+ "mistake": "excluded_root"
673
+ },
674
+ {
675
+ "text": "$x = 3$",
676
+ "mistake": "excluded_root"
677
+ }
678
+ ]
679
+ }
680
+ },
681
+ {
682
+ "id": "n_intercepts",
683
+ "unit": "n",
684
+ "name": "נקודות חיתוך עם הצירים",
685
+ "short": "חיתוך עם הצירים",
686
+ "prereqs": [
687
+ "f_linfun"
688
+ ],
689
+ "description": "חיתוך עם ציר x: $y = 0$. חיתוך עם ציר y: $x = 0$. כל נקודה היא זוג סדור.",
690
+ "month": "מרץ",
691
+ "core": true,
692
+ "sample": {
693
+ "question": "מהן נקודות החיתוך של $f(x) = 2x − 6$ עם הצירים?",
694
+ "choices": [
695
+ {
696
+ "text": "$(3, 0)$ , $(0, −6)$",
697
+ "correct": true
698
+ },
699
+ {
700
+ "text": "$(−6, 0)$ , $(0, 3)$",
701
+ "mistake": "axes_mixed"
702
+ },
703
+ {
704
+ "text": "$(3, 0)$ , $(0, 6)$",
705
+ "mistake": "sign_product"
706
+ }
707
+ ]
708
+ }
709
+ },
710
+ {
711
+ "id": "n_sign",
712
+ "unit": "n",
713
+ "name": "תחומי חיוביות ושליליות",
714
+ "short": "חיוביות ושליליות",
715
+ "prereqs": [
716
+ "n_intercepts"
717
+ ],
718
+ "description": "איפה הגרף מעל ציר x ואיפה מתחתיו.",
719
+ "month": "מרץ",
720
+ "core": true,
721
+ "sample": {
722
+ "question": "באיזה תחום הפונקציה שבגרף שלילית?",
723
+ "graph": {
724
+ "points": [
725
+ [
726
+ -3,
727
+ -3
728
+ ],
729
+ [
730
+ -2,
731
+ 0
732
+ ],
733
+ [
734
+ -0.5,
735
+ 2
736
+ ],
737
+ [
738
+ 1,
739
+ 0
740
+ ],
741
+ [
742
+ 2.5,
743
+ -2
744
+ ],
745
+ [
746
+ 4,
747
+ 0
748
+ ],
749
+ [
750
+ 5,
751
+ 2
752
+ ]
753
+ ],
754
+ "x": [
755
+ -3.5,
756
+ 5.5
757
+ ],
758
+ "y": [
759
+ -3.5,
760
+ 3.5
761
+ ]
762
+ },
763
+ "choices": [
764
+ {
765
+ "text": "$x < −2$ או $1 < x < 4$",
766
+ "correct": true
767
+ },
768
+ {
769
+ "text": "$−2 < x < 1$ או $x > 4$",
770
+ "mistake": "pos_neg_swap"
771
+ },
772
+ {
773
+ "text": "$x < 0$",
774
+ "mistake": "neg_x"
775
+ },
776
+ {
777
+ "text": "$1 < x < 4$ בלבד",
778
+ "mistake": "partial_intervals"
779
+ }
780
+ ]
781
+ }
782
+ },
783
+ {
784
+ "id": "n_extremum",
785
+ "unit": "n",
786
+ "name": "נקודות קיצון ותחומי עלייה וירידה",
787
+ "short": "קיצון, עלייה וירידה",
788
+ "prereqs": [
789
+ "n_sign"
790
+ ],
791
+ "description": "קריאה מהגרף: איפה הפונקציה עולה, איפה יורדת, ואיפה היא מחליפה כיוון.",
792
+ "month": "מרץ",
793
+ "core": true,
794
+ "sample": {
795
+ "question": "באילו תחומים הפונקציה שבגרף יורדת? (התחום המוצג: $−4 ≤ x ≤ 5$)",
796
+ "graph": {
797
+ "points": [
798
+ [
799
+ -4,
800
+ 1
801
+ ],
802
+ [
803
+ -2,
804
+ 4
805
+ ],
806
+ [
807
+ 1,
808
+ -2
809
+ ],
810
+ [
811
+ 3,
812
+ 1
813
+ ],
814
+ [
815
+ 5,
816
+ -1
817
+ ]
818
+ ],
819
+ "x": [
820
+ -4.5,
821
+ 5.5
822
+ ],
823
+ "y": [
824
+ -3,
825
+ 5
826
+ ]
827
+ },
828
+ "choices": [
829
+ {
830
+ "text": "$−2 < x < 1$ או $3 < x < 5$",
831
+ "correct": true
832
+ },
833
+ {
834
+ "text": "$−2 < x < 1$ בלבד",
835
+ "mistake": "partial_intervals"
836
+ },
837
+ {
838
+ "text": "$−2 < y < 4$",
839
+ "mistake": "x_vs_y_interval"
840
+ },
841
+ {
842
+ "text": "$−4 < x < −2$ או $1 < x < 3$",
843
+ "mistake": "dir_confusion"
844
+ }
845
+ ]
846
+ }
847
+ },
848
+ {
849
+ "id": "n_local_abs",
850
+ "unit": "n",
851
+ "name": "קיצון מקומי וקיצון מוחלט",
852
+ "short": "מקומי ומוחלט",
853
+ "prereqs": [
854
+ "n_extremum"
855
+ ],
856
+ "description": "ערך הקיצון הוא שיעור ה־y של הנקודה. מקסימום מקומי אינו בהכרח הגבוה ביותר. יש פונקציות בלי קיצון.",
857
+ "month": "מרץ",
858
+ "core": true,
859
+ "sample": {
860
+ "question": "מהו ערך המקסימום המוחלט של הפונקציה בתחום המוצג?",
861
+ "graph": {
862
+ "points": [
863
+ [
864
+ -3,
865
+ -1
866
+ ],
867
+ [
868
+ -1,
869
+ 3
870
+ ],
871
+ [
872
+ 1,
873
+ 0
874
+ ],
875
+ [
876
+ 3,
877
+ 5
878
+ ],
879
+ [
880
+ 4,
881
+ 4
882
+ ]
883
+ ],
884
+ "x": [
885
+ -3.5,
886
+ 4.5
887
+ ],
888
+ "y": [
889
+ -2,
890
+ 6
891
+ ]
892
+ },
893
+ "choices": [
894
+ {
895
+ "text": "$5$",
896
+ "correct": true
897
+ },
898
+ {
899
+ "text": "$3$",
900
+ "mistake": "local_as_abs"
901
+ },
902
+ {
903
+ "text": "$(3, 5)$",
904
+ "mistake": "value_vs_point"
905
+ },
906
+ {
907
+ "text": "$x = 3$",
908
+ "mistake": "value_vs_point"
909
+ }
910
+ ]
911
+ }
912
+ },
913
+ {
914
+ "id": "n_eq_graph",
915
+ "unit": "n",
916
+ "name": "משוואה וגרף: $f(x) = m$",
917
+ "short": "f(x) = m",
918
+ "prereqs": [
919
+ "n_intercepts"
920
+ ],
921
+ "description": "מספר הפתרונות של $f(x) = m$ הוא מספר הפעמים שהגרף ��וגש את הישר $y = m$.",
922
+ "month": "מרץ",
923
+ "core": true,
924
+ "sample": {
925
+ "question": "לכמה פתרונות יש המשוואה $f(x) = 1$ בתחום המוצג?",
926
+ "graph": {
927
+ "points": [
928
+ [
929
+ -4,
930
+ 3
931
+ ],
932
+ [
933
+ -2,
934
+ -1
935
+ ],
936
+ [
937
+ 0,
938
+ 2
939
+ ],
940
+ [
941
+ 2,
942
+ -2
943
+ ],
944
+ [
945
+ 4,
946
+ 0
947
+ ]
948
+ ],
949
+ "x": [
950
+ -4.5,
951
+ 4.5
952
+ ],
953
+ "y": [
954
+ -3,
955
+ 4
956
+ ]
957
+ },
958
+ "choices": [
959
+ {
960
+ "text": "$3$",
961
+ "correct": true
962
+ },
963
+ {
964
+ "text": "$1$",
965
+ "mistake": "x_equals_m"
966
+ },
967
+ {
968
+ "text": "$2$",
969
+ "mistake": "count_all"
970
+ }
971
+ ]
972
+ }
973
+ },
974
+ {
975
+ "id": "n_vshift",
976
+ "unit": "n",
977
+ "name": "הזזה אנכית של פונקציה",
978
+ "short": "הזזה אנכית",
979
+ "prereqs": [
980
+ "n_extremum"
981
+ ],
982
+ "description": "$f(x) + k$ מזיז את הגרף למעלה או למטה. נקודות הקיצון זזות, תחומי העלייה והירידה נשמרים.",
983
+ "month": "מרץ",
984
+ "core": true,
985
+ "sample": {
986
+ "question": "ל־f מקסימום ב־$(2, 5)$. מהו המקסימום של $g(x) = f(x) − 3$?",
987
+ "choices": [
988
+ {
989
+ "text": "$(2, 2)$",
990
+ "correct": true
991
+ },
992
+ {
993
+ "text": "$(2, 8)$",
994
+ "mistake": "vshift_dir"
995
+ },
996
+ {
997
+ "text": "$(−1, 5)$",
998
+ "mistake": "vshift_as_h"
999
+ }
1000
+ ]
1001
+ }
1002
+ },
1003
+ {
1004
+ "id": "n_hshift",
1005
+ "unit": "n",
1006
+ "name": "הזזה אופקית של פונקציה",
1007
+ "short": "הזזה אופקית",
1008
+ "prereqs": [
1009
+ "n_extremum"
1010
+ ],
1011
+ "description": "$f(x − b)$ מזיז את הגרף ימינה כש־$b > 0$. הפריסה מציינת שתלמידים נוטים להזיז לכיוון הלא נכון.",
1012
+ "month": "מרץ",
1013
+ "core": true,
1014
+ "sample": {
1015
+ "question": "ל־f מינימום ב־$(1, −4)$. מהו המינימום של $g(x) = f(x − 3)$?",
1016
+ "choices": [
1017
+ {
1018
+ "text": "$(4, −4)$",
1019
+ "correct": true
1020
+ },
1021
+ {
1022
+ "text": "$(−2, −4)$",
1023
+ "mistake": "hshift_sign"
1024
+ },
1025
+ {
1026
+ "text": "$(1, −7)$",
1027
+ "mistake": "h_as_v"
1028
+ }
1029
+ ]
1030
+ }
1031
+ },
1032
+ {
1033
+ "id": "n_reflect",
1034
+ "unit": "n",
1035
+ "name": "שיקוף ביחס לציר x",
1036
+ "short": "שיקוף",
1037
+ "prereqs": [
1038
+ "n_extremum"
1039
+ ],
1040
+ "description": "$−f(x)$ הופך את הגרף: מקסימום הופך למינימום, וחיוביות לשליליות.",
1041
+ "month": "מרץ",
1042
+ "core": true,
1043
+ "sample": {
1044
+ "question": "ל־f מקסימום ב־$(−2, 3)$. מה יש ל־$g(x) = −f(x)$?",
1045
+ "choices": [
1046
+ {
1047
+ "text": "מינימום ב־$(−2, −3)$",
1048
+ "correct": true
1049
+ },
1050
+ {
1051
+ "text": "מקסימום ב־$(−2, −3)$",
1052
+ "mistake": "reflect_type"
1053
+ },
1054
+ {
1055
+ "text": "מקסימום ב־$(2, 3)$",
1056
+ "mistake": "reflect_axis"
1057
+ }
1058
+ ]
1059
+ }
1060
+ },
1061
+ {
1062
+ "id": "n_props_graph",
1063
+ "unit": "n",
1064
+ "name": "מתכונות לגרף",
1065
+ "short": "מתכונות לגרף",
1066
+ "prereqs": [
1067
+ "n_local_abs"
1068
+ ],
1069
+ "description": "סרטוט גרף שמתאים לתכונות נתונות, והסקת תכונות נוספות מהן.",
1070
+ "month": "מרץ",
1071
+ "core": true,
1072
+ "sample": {
1073
+ "question": "f עולה ב־$x < 2$, יורדת ב־$x > 2$, ו־$f(2) = −1$. כמה נקודות חיתוך עם ציר x יש לה?",
1074
+ "choices": [
1075
+ {
1076
+ "text": "אף אחת",
1077
+ "correct": true
1078
+ },
1079
+ {
1080
+ "text": "שתיים",
1081
+ "mistake": "max_below"
1082
+ },
1083
+ {
1084
+ "text": "אחת",
1085
+ "mistake": "max_below"
1086
+ }
1087
+ ]
1088
+ }
1089
+ },
1090
+ {
1091
+ "id": "q_parabola",
1092
+ "unit": "q",
1093
+ "name": "הפרבולה $f(x) = ax²$",
1094
+ "short": "הפרבולה ax²",
1095
+ "prereqs": [
1096
+ "n_extremum",
1097
+ "f_power"
1098
+ ],
1099
+ "description": "לפי הפריסה, a קובע רק את סוג הקיצון: $a > 0$ מינימום, $a < 0$ מקסימום. בלי מתיחה וכיווץ.",
1100
+ "month": "נובמבר",
1101
+ "core": true,
1102
+ "sample": {
1103
+ "question": "מהי נקודת הקיצון של $f(x) = −2x²$, ומה סוגה?",
1104
+ "choices": [
1105
+ {
1106
+ "text": "מקסימום $(0, 0)$",
1107
+ "correct": true
1108
+ },
1109
+ {
1110
+ "text": "מינימום $(0, 0)$",
1111
+ "mistake": "opening_sign"
1112
+ },
1113
+ {
1114
+ "text": "מקסימום $(0, −2)$",
1115
+ "mistake": "a_as_vertex"
1116
+ }
1117
+ ]
1118
+ }
1119
+ },
1120
+ {
1121
+ "id": "q_vshift",
1122
+ "unit": "q",
1123
+ "name": "הזזה אנכית $ax² + k$",
1124
+ "short": "ax² + k",
1125
+ "prereqs": [
1126
+ "q_parabola",
1127
+ "n_vshift",
1128
+ "f_sqrt"
1129
+ ],
1130
+ "description": "קודקוד $(0, k)$, נקודות חיתוך עם הצירים, ותחומי חיוביות ושליליות.",
1131
+ "month": "נובמבר",
1132
+ "core": true,
1133
+ "sample": {
1134
+ "question": "מהן נקודות החיתוך של $y = x² − 4$ עם ציר x?",
1135
+ "choices": [
1136
+ {
1137
+ "text": "$(2, 0)$ , $(−2, 0)$",
1138
+ "correct": true
1139
+ },
1140
+ {
1141
+ "text": "$(0, −4)$",
1142
+ "mistake": "axes_mixed"
1143
+ },
1144
+ {
1145
+ "text": "$(2, 0)$ בלבד",
1146
+ "mistake": "missing_neg_root"
1147
+ },
1148
+ {
1149
+ "text": "$(4, 0)$ , $(−4, 0)$",
1150
+ "mistake": "forgot_root"
1151
+ }
1152
+ ]
1153
+ }
1154
+ },
1155
+ {
1156
+ "id": "q_hshift",
1157
+ "unit": "q",
1158
+ "name": "הזזה אופקית $a(x − b)²$",
1159
+ "short": "a(x − b)²",
1160
+ "prereqs": [
1161
+ "q_parabola",
1162
+ "n_hshift"
1163
+ ],
1164
+ "description": "קודקוד $(b, 0)$ וציר סימטריה $x = b$.",
1165
+ "month": "נובמבר",
1166
+ "core": true,
1167
+ "sample": {
1168
+ "question": "מהו הקודקוד של $y = (x + 3)²$?",
1169
+ "choices": [
1170
+ {
1171
+ "text": "$(−3, 0)$",
1172
+ "correct": true
1173
+ },
1174
+ {
1175
+ "text": "$(3, 0)$",
1176
+ "mistake": "hshift_sign"
1177
+ },
1178
+ {
1179
+ "text": "$(0, 3)$",
1180
+ "mistake": "h_as_v"
1181
+ },
1182
+ {
1183
+ "text": "$(0, 9)$",
1184
+ "mistake": "plug_zero"
1185
+ }
1186
+ ]
1187
+ }
1188
+ },
1189
+ {
1190
+ "id": "q_vertex",
1191
+ "unit": "q",
1192
+ "name": "הצגה קודקודית $a(x − b)² + k$",
1193
+ "short": "הצגה קודקודית",
1194
+ "prereqs": [
1195
+ "q_vshift",
1196
+ "q_hshift"
1197
+ ],
1198
+ "description": "שילוב שתי ההזזות: קודקוד $(b, k)$, וסוג הקיצון לפי הסימן של a.",
1199
+ "month": "נובמבר",
1200
+ "core": true,
1201
+ "sample": {
1202
+ "question": "מהי נקודת הקיצון של $f(x) = −2(x − 3)² + 8$?",
1203
+ "choices": [
1204
+ {
1205
+ "text": "מקסימום $(3, 8)$",
1206
+ "correct": true
1207
+ },
1208
+ {
1209
+ "text": "מינימום $(3, 8)$",
1210
+ "mistake": "opening_sign"
1211
+ },
1212
+ {
1213
+ "text": "מקסימום $(−3, 8)$",
1214
+ "mistake": "hshift_sign"
1215
+ },
1216
+ {
1217
+ "text": "מקסימום $(3, −8)$",
1218
+ "mistake": "k_sign"
1219
+ }
1220
+ ]
1221
+ }
1222
+ },
1223
+ {
1224
+ "id": "q_zero_prod",
1225
+ "unit": "q",
1226
+ "name": "מכפלה שווה לאפס",
1227
+ "short": "מכפלה = 0",
1228
+ "prereqs": [
1229
+ "f_lin"
1230
+ ],
1231
+ "description": "$a(x − m)(x − n) = 0$: כל גורם שמתאפס נותן פתרון.",
1232
+ "month": "אוקטובר",
1233
+ "sample": {
1234
+ "question": "$(x − 3)(2x + 1) = 0$. $x = ?$",
1235
+ "choices": [
1236
+ {
1237
+ "text": "$x = 3$ , $x = −½$",
1238
+ "correct": true
1239
+ },
1240
+ {
1241
+ "text": "$x = −3$ , $x = ½$",
1242
+ "mistake": "zero_sign"
1243
+ },
1244
+ {
1245
+ "text": "$x = 3$ , $x = −2$",
1246
+ "mistake": "divide_wrong"
1247
+ }
1248
+ ]
1249
+ }
1250
+ },
1251
+ {
1252
+ "id": "q_fform",
1253
+ "unit": "q",
1254
+ "name": "הצגה כמכפלה",
1255
+ "short": "הצגה כמכפלה",
1256
+ "prereqs": [
1257
+ "q_parabola",
1258
+ "q_zero_prod"
1259
+ ],
1260
+ "description": "$f(x) = a(x − m)(x − n)$: נקודות אפס, וציר סימטריה באמצע ביניהן.",
1261
+ "month": "נובמבר",
1262
+ "core": true,
1263
+ "sample": {
1264
+ "question": "ל־$h(x) = 2(x − 1)(x − 5)$: מהן נקודות האפס ומהו ציר הסימטריה?",
1265
+ "choices": [
1266
+ {
1267
+ "text": "אפסים $1, 5$ · ציר $x = 3$",
1268
+ "correct": true
1269
+ },
1270
+ {
1271
+ "text": "אפסים $−1, −5$ · ציר $x = −3$",
1272
+ "mistake": "zero_sign"
1273
+ },
1274
+ {
1275
+ "text": "אפסים $1, 5$ · ציר $x = 2$",
1276
+ "mistake": "axis_middle"
1277
+ },
1278
+ {
1279
+ "text": "אפסים $2, 1, 5$ · ציר $x = 3$",
1280
+ "mistake": "a_as_zero"
1281
+ }
1282
+ ]
1283
+ }
1284
+ },
1285
+ {
1286
+ "id": "q_sform",
1287
+ "unit": "q",
1288
+ "name": "הצגה סטנדרטית וקודקוד אלגברי",
1289
+ "short": "הצגה סטנדרטית",
1290
+ "prereqs": [
1291
+ "q_fform",
1292
+ "q_vertex"
1293
+ ],
1294
+ "description": "$ax² + bx + c$: ציר סימטריה $x = −b/(2a)$, קודקוד, וחיתוך עם ציר y ב־$(0, c)$.",
1295
+ "month": "נובמבר",
1296
+ "core": true,
1297
+ "sample": {
1298
+ "question": "מהו הקודקוד של $y = x² − 6x + 5$?",
1299
+ "choices": [
1300
+ {
1301
+ "text": "$(3, −4)$",
1302
+ "correct": true
1303
+ },
1304
+ {
1305
+ "text": "$(−3, 32)$",
1306
+ "mistake": "axis_sign"
1307
+ },
1308
+ {
1309
+ "text": "$(6, 5)$",
1310
+ "mistake": "forgot_2a_axis"
1311
+ },
1312
+ {
1313
+ "text": "$(0, 5)$",
1314
+ "mistake": "c_as_vertex"
1315
+ }
1316
+ ]
1317
+ }
1318
+ },
1319
+ {
1320
+ "id": "q_from_points",
1321
+ "unit": "q",
1322
+ "name": "מציאת הפונקציה מנקודות",
1323
+ "short": "פונקציה מנקודות",
1324
+ "prereqs": [
1325
+ "q_vertex",
1326
+ "q_fform"
1327
+ ],
1328
+ "description": "בונים את הביטוי האלגברי מקודקוד ונקודה, או מנקודות האפס ונקודה. המכינה ל־5 יח״ל דורשת זאת, הפריסה לא.",
1329
+ "core": true,
1330
+ "sample": {
1331
+ "question": "לפרבולה קודקוד $(2, −1)$, והיא עוברת דרך $(0, 3)$. מהי הפונקציה?",
1332
+ "choices": [
1333
+ {
1334
+ "text": "$y = (x − 2)² − 1$",
1335
+ "correct": true
1336
+ },
1337
+ {
1338
+ "text": "$y = (x + 2)² − 1$",
1339
+ "mistake": "hshift_sign"
1340
+ },
1341
+ {
1342
+ "text": "$y = 3(x − 2)² − 1$",
1343
+ "mistake": "a_from_point"
1344
+ },
1345
+ {
1346
+ "text": "$y = (x − 2)² + 3$",
1347
+ "mistake": "a_from_point"
1348
+ }
1349
+ ]
1350
+ }
1351
+ },
1352
+ {
1353
+ "id": "q_eq_simple",
1354
+ "unit": "q",
1355
+ "name": "משוואה $ax² = b$",
1356
+ "short": "ax² = b",
1357
+ "prereqs": [
1358
+ "f_sqrt",
1359
+ "f_lin"
1360
+ ],
1361
+ "description": "פתרון בבידוד $x²$ והוצאת שורש, בלי נוסחה.",
1362
+ "month": "אוקטובר",
1363
+ "sample": {
1364
+ "question": "$3x² = 48$. $x = ?$",
1365
+ "choices": [
1366
+ {
1367
+ "text": "$x = 4$ או $x = −4$",
1368
+ "correct": true
1369
+ },
1370
+ {
1371
+ "text": "$x = 4$",
1372
+ "mistake": "missing_neg_root"
1373
+ },
1374
+ {
1375
+ "text": "$x = ±√45$",
1376
+ "mistake": "sub_vs_div"
1377
+ },
1378
+ {
1379
+ "text": "$x = 8$",
1380
+ "mistake": "sqrt_half"
1381
+ }
1382
+ ]
1383
+ }
1384
+ },
1385
+ {
1386
+ "id": "q_eq_gcf",
1387
+ "unit": "q",
1388
+ "name": "משוואה $ax² + bx = 0$",
1389
+ "short": "ax² + bx = 0",
1390
+ "prereqs": [
1391
+ "q_zero_prod",
1392
+ "t_gcf"
1393
+ ],
1394
+ "description": "פתרון בהוצאת גורם משותף, בלי לחלק ב־x.",
1395
+ "month": "אוקטובר",
1396
+ "sample": {
1397
+ "question": "$5x² = 20x$. $x = ?$",
1398
+ "choices": [
1399
+ {
1400
+ "text": "$x = 0$ או $x = 4$",
1401
+ "correct": true
1402
+ },
1403
+ {
1404
+ "text": "$x = 4$",
1405
+ "mistake": "divide_by_x"
1406
+ },
1407
+ {
1408
+ "text": "$x = 0$ או $x = −4$",
1409
+ "mistake": "lost_sign_move"
1410
+ }
1411
+ ]
1412
+ }
1413
+ },
1414
+ {
1415
+ "id": "q_eq_factor",
1416
+ "unit": "q",
1417
+ "name": "פתרון משוואה בפירוק לגורמים",
1418
+ "short": "משוואה בפירוק",
1419
+ "prereqs": [
1420
+ "q_zero_prod",
1421
+ "t_trinomial"
1422
+ ],
1423
+ "description": "העברה לצורה $x² + bx + c = 0$, פירוק, ושימוש בכלל מכפלה שווה 0.",
1424
+ "month": "אוקטובר",
1425
+ "core": true,
1426
+ "sample": {
1427
+ "question": "$x² + 2x = 15$. $x = ?$",
1428
+ "choices": [
1429
+ {
1430
+ "text": "$x = −5$ , $x = 3$",
1431
+ "correct": true
1432
+ },
1433
+ {
1434
+ "text": "$x = 5$ , $x = −3$",
1435
+ "mistake": "signs_swapped"
1436
+ },
1437
+ {
1438
+ "text": "$x = 15$ , $x = 13$",
1439
+ "mistake": "product_not_zero"
1440
+ }
1441
+ ]
1442
+ }
1443
+ },
1444
+ {
1445
+ "id": "q_formula",
1446
+ "unit": "q",
1447
+ "name": "נוסחת השורשים",
1448
+ "short": "נוסחת השורשים",
1449
+ "prereqs": [
1450
+ "q_eq_simple",
1451
+ "q_eq_factor",
1452
+ "t_square"
1453
+ ],
1454
+ "description": "$ax² + bx + c = 0$ עם כל $a ≠ 0$. בוחרים את הדרך היעילה לפי צורת המשוואה.",
1455
+ "month": "נובמבר",
1456
+ "core": true,
1457
+ "sample": {
1458
+ "question": "$2x² − 3x − 2 = 0$. $x = ?$",
1459
+ "choices": [
1460
+ {
1461
+ "text": "$x = 2$ , $x = −½$",
1462
+ "correct": true
1463
+ },
1464
+ {
1465
+ "text": "$x = −2$ , $x = ½$",
1466
+ "mistake": "minus_b"
1467
+ },
1468
+ {
1469
+ "text": "$x = 4$ , $x = −1$",
1470
+ "mistake": "forgot_2a"
1471
+ },
1472
+ {
1473
+ "text": "אין פתרון",
1474
+ "mistake": "disc_sign"
1475
+ }
1476
+ ]
1477
+ }
1478
+ },
1479
+ {
1480
+ "id": "q_disc",
1481
+ "unit": "q",
1482
+ "name": "מספר הפתרונות של משוואה ריבועית",
1483
+ "short": "מספר פתרונות",
1484
+ "prereqs": [
1485
+ "q_formula"
1486
+ ],
1487
+ "description": "הביטוי שבתוך השורש, $b² − 4ac$, קובע אם יש שני פתרונות, אחד או אף אחד.",
1488
+ "month": "נובמבר",
1489
+ "sample": {
1490
+ "question": "כמה פתרונות יש למשוואה $x² − 6x + 9 = 0$?",
1491
+ "choices": [
1492
+ {
1493
+ "text": "פתרון אחד",
1494
+ "correct": true
1495
+ },
1496
+ {
1497
+ "text": "שני פתרונות",
1498
+ "mistake": "disc_meaning"
1499
+ },
1500
+ {
1501
+ "text": "אין פתרון",
1502
+ "mistake": "disc_meaning"
1503
+ }
1504
+ ]
1505
+ }
1506
+ },
1507
+ {
1508
+ "id": "q_sketch",
1509
+ "unit": "q",
1510
+ "name": "סרטוט פרבולה מהצגה סטנדרטית",
1511
+ "short": "סרטוט פרבולה",
1512
+ "prereqs": [
1513
+ "q_sform",
1514
+ "q_formula"
1515
+ ],
1516
+ "description": "כיוון, קודקוד, נקודות אפס וחיתוך עם ציר y, ומהם הסקיצה.",
1517
+ "month": "נובמבר",
1518
+ "sample": {
1519
+ "question": "מה נכון לגבי $y = x² − 2x − 3$?",
1520
+ "choices": [
1521
+ {
1522
+ "text": "פתוחה למעלה, קודקוד $(1, −4)$, אפסים $−1$ ו־$3$",
1523
+ "correct": true
1524
+ },
1525
+ {
1526
+ "text": "פתוחה למטה, קודקוד $(1, −4)$, אפסים $−1$ ו־$3$",
1527
+ "mistake": "opening_sign"
1528
+ },
1529
+ {
1530
+ "text": "פתוחה למעלה, קודקוד $(1, −4)$, אפסים $1$ ו־$−3$",
1531
+ "mistake": "zero_sign"
1532
+ },
1533
+ {
1534
+ "text": "פתוחה למעלה, קודקוד $(0, −3)$, אפסים $−1$ ו־$3$",
1535
+ "mistake": "c_as_vertex"
1536
+ }
1537
+ ]
1538
+ }
1539
+ },
1540
+ {
1541
+ "id": "q_ineq",
1542
+ "unit": "q",
1543
+ "name": "אי־שוויון ריבועי",
1544
+ "short": "אי־שוויון ריבועי",
1545
+ "prereqs": [
1546
+ "q_fform"
1547
+ ],
1548
+ "description": "פתרון בעזרת הפונקציה: נקודות האפס, כיוון הפתיחה, ותחומי החיוביות והשליליות.",
1549
+ "month": "דצמבר",
1550
+ "sample": {
1551
+ "question": "$(x − 1)(x − 5) < 0$. $x = ?$",
1552
+ "choices": [
1553
+ {
1554
+ "text": "$1 < x < 5$",
1555
+ "correct": true
1556
+ },
1557
+ {
1558
+ "text": "$x < 1$ או $x > 5$",
1559
+ "mistake": "sign_regions"
1560
+ },
1561
+ {
1562
+ "text": "$x < 0$",
1563
+ "mistake": "neg_x"
1564
+ },
1565
+ {
1566
+ "text": "$−5 < x < −1$",
1567
+ "mistake": "zero_sign"
1568
+ }
1569
+ ]
1570
+ }
1571
+ },
1572
+ {
1573
+ "id": "q_sys",
1574
+ "unit": "q",
1575
+ "name": "מערכת: ישר ופרבולה",
1576
+ "short": "ישר ופרבולה",
1577
+ "prereqs": [
1578
+ "f_sys",
1579
+ "q_formula"
1580
+ ],
1581
+ "description": "פתרון מערכת ממעלה שנייה, והקשר לנקודות החיתוך של הגרפים.",
1582
+ "month": "פברואר",
1583
+ "sample": {
1584
+ "question": "מהן נקודות החיתוך של $y = x²$ ו־$y = x + 2$?",
1585
+ "choices": [
1586
+ {
1587
+ "text": "$(2, 4)$ , $(−1, 1)$",
1588
+ "correct": true
1589
+ },
1590
+ {
1591
+ "text": "$(2, 0)$ , $(−1, 0)$",
1592
+ "mistake": "forgot_y"
1593
+ },
1594
+ {
1595
+ "text": "$(−2, 4)$ , $(1, 1)$",
1596
+ "mistake": "signs_swapped"
1597
+ }
1598
+ ]
1599
+ }
1600
+ },
1601
+ {
1602
+ "id": "q_sys2",
1603
+ "unit": "q",
1604
+ "name": "מערכת: שתי פרבולות",
1605
+ "short": "שתי פרבולות",
1606
+ "prereqs": [
1607
+ "q_sys"
1608
+ ],
1609
+ "description": "השוואת שני ביטויים ריבועיים. $x²$ לא תמיד מצטמצם.",
1610
+ "month": "פברואר",
1611
+ "sample": {
1612
+ "question": "מהן נקודות החיתוך של $y = x² − 3$ ו־$y = −x² + 5$?",
1613
+ "choices": [
1614
+ {
1615
+ "text": "$(2, 1)$ , $(−2, 1)$",
1616
+ "correct": true
1617
+ },
1618
+ {
1619
+ "text": "$(2, 1)$ בלבד",
1620
+ "mistake": "missing_neg_root"
1621
+ },
1622
+ {
1623
+ "text": "$(2, 0)$ , $(−2, 0)$",
1624
+ "mistake": "forgot_y"
1625
+ }
1626
+ ]
1627
+ }
1628
+ },
1629
+ {
1630
+ "id": "q_tri",
1631
+ "unit": "q",
1632
+ "name": "פרבולה ומשולשים",
1633
+ "short": "פרבולה ומשולשים",
1634
+ "prereqs": [
1635
+ "q_sform"
1636
+ ],
1637
+ "description": "שטח משולש שקודקודיו על הפרבולה ועל הצירים. דורש גם גיאומטריה אנליטית מפס הגיאומטריה.",
1638
+ "core": true,
1639
+ "geometry": true,
1640
+ "sample": {
1641
+ "question": "$y = x² − 4x$ חותכת את ציר x ב־O וב־A, וקודקודה B. מהו שטח המשולש OAB?",
1642
+ "choices": [
1643
+ {
1644
+ "text": "$8$",
1645
+ "correct": true
1646
+ },
1647
+ {
1648
+ "text": "$16$",
1649
+ "mistake": "forgot_half"
1650
+ },
1651
+ {
1652
+ "text": "$−8$",
1653
+ "mistake": "neg_height"
1654
+ }
1655
+ ]
1656
+ }
1657
+ },
1658
+ {
1659
+ "id": "p_laws1",
1660
+ "unit": "p",
1661
+ "name": "כפל וחילוק חזקות עם אותו בסיס",
1662
+ "short": "כפל וחילוק חזקות",
1663
+ "prereqs": [
1664
+ "f_power"
1665
+ ],
1666
+ "description": "$aⁿ·aᵐ = aⁿ⁺ᵐ$, $aⁿ : aᵐ = aⁿ⁻ᵐ$ ו־$a⁰ = 1$. לפי הפריסה רק מעריך טבעי או אפס.",
1667
+ "month": "מאי",
1668
+ "sample": {
1669
+ "question": "$a⁵ · a³ : a² = ?$",
1670
+ "choices": [
1671
+ {
1672
+ "text": "$a⁶$",
1673
+ "correct": true
1674
+ },
1675
+ {
1676
+ "text": "$a¹³$",
1677
+ "mistake": "mult_exponents"
1678
+ },
1679
+ {
1680
+ "text": "$a⁴$",
1681
+ "mistake": "div_exponents"
1682
+ }
1683
+ ]
1684
+ }
1685
+ },
1686
+ {
1687
+ "id": "p_laws2",
1688
+ "unit": "p",
1689
+ "name": "חזקה של מכפלה, של מנה ושל חזקה",
1690
+ "short": "חזקה של חזקה",
1691
+ "prereqs": [
1692
+ "p_laws1"
1693
+ ],
1694
+ "description": "$(ab)ⁿ = aⁿbⁿ$, $(a:b)ⁿ = aⁿ:bⁿ$, $(aⁿ)ᵐ = aⁿᵐ$.",
1695
+ "month": "מאי",
1696
+ "sample": {
1697
+ "question": "$(2x³)² = ?$",
1698
+ "choices": [
1699
+ {
1700
+ "text": "$4x⁶$",
1701
+ "correct": true
1702
+ },
1703
+ {
1704
+ "text": "$2x⁶$",
1705
+ "mistake": "coef_not_raised"
1706
+ },
1707
+ {
1708
+ "text": "$4x⁵$",
1709
+ "mistake": "pow_pow_add"
1710
+ },
1711
+ {
1712
+ "text": "$2x⁵$",
1713
+ "mistake": "coef_not_raised"
1714
+ }
1715
+ ]
1716
+ }
1717
+ },
1718
+ {
1719
+ "id": "p_compare",
1720
+ "unit": "p",
1721
+ "name": "השוואת חזקות",
1722
+ "short": "השוואת חזקות",
1723
+ "prereqs": [
1724
+ "p_laws2"
1725
+ ],
1726
+ "description": "השוואה בין חזקות בעזרת חוקי החזקה, בלי מחשבון.",
1727
+ "month": "מאי",
1728
+ "sample": {
1729
+ "question": "מה גדול יותר: $2³⁰⁰$ או $3²⁰⁰$?",
1730
+ "choices": [
1731
+ {
1732
+ "text": "$3²⁰⁰$",
1733
+ "correct": true
1734
+ },
1735
+ {
1736
+ "text": "$2³⁰⁰$",
1737
+ "mistake": "bigger_exp"
1738
+ },
1739
+ {
1740
+ "text": "הם שווים",
1741
+ "mistake": "bigger_exp"
1742
+ }
1743
+ ]
1744
+ }
1745
+ },
1746
+ {
1747
+ "id": "p_sqrt_laws",
1748
+ "unit": "p",
1749
+ "name": "חוקי שורש ריבועי",
1750
+ "short": "חוקי שורש",
1751
+ "prereqs": [
1752
+ "f_sqrt",
1753
+ "p_laws2"
1754
+ ],
1755
+ "description": "$√a·√b = √(ab)$ ו־$√a : √b = √(a:b)$, ופישוט שורשים.",
1756
+ "month": "מאי",
1757
+ "sample": {
1758
+ "question": "$√12 · √3 = ?$",
1759
+ "choices": [
1760
+ {
1761
+ "text": "$6$",
1762
+ "correct": true
1763
+ },
1764
+ {
1765
+ "text": "$√15$",
1766
+ "mistake": "sqrt_add"
1767
+ },
1768
+ {
1769
+ "text": "$36$",
1770
+ "mistake": "sqrt_forgot"
1771
+ }
1772
+ ]
1773
+ }
1774
+ },
1775
+ {
1776
+ "id": "w_area",
1777
+ "unit": "w",
1778
+ "name": "שאלות הנדסיות",
1779
+ "short": "שאלות הנדסיות",
1780
+ "prereqs": [
1781
+ "q_formula"
1782
+ ],
1783
+ "description": "בניית משוואה ריבועית משטח או מאורך, ובדיקה שהפתרון מתאים לבעיה.",
1784
+ "month": "מאי",
1785
+ "sample": {
1786
+ "question": "שטח מלבן הוא 48 סמ״ר, ואורכו גדול מרוחבו ב־2 ס״מ. מה רוחבו?",
1787
+ "choices": [
1788
+ {
1789
+ "text": "6 ס״מ",
1790
+ "correct": true
1791
+ },
1792
+ {
1793
+ "text": "6 ס״מ או −8 ס״מ",
1794
+ "mistake": "inadmissible"
1795
+ },
1796
+ {
1797
+ "text": "8 ס״מ",
1798
+ "mistake": "other_quantity"
1799
+ },
1800
+ {
1801
+ "text": "23 ס״מ",
1802
+ "mistake": "area_perimeter"
1803
+ }
1804
+ ]
1805
+ }
1806
+ },
1807
+ {
1808
+ "id": "w_motion",
1809
+ "unit": "w",
1810
+ "name": "שאלות תנועה",
1811
+ "short": "שאלות תנועה",
1812
+ "prereqs": [
1813
+ "q_formula",
1814
+ "t_fraceq"
1815
+ ],
1816
+ "description": "דרך, מהירות וזמן, כשהמשתנה במכנה מוביל למשוואה ריבועית.",
1817
+ "month": "מאי",
1818
+ "sample": {
1819
+ "question": "רוכב נוסע 120 ק״מ. אילו נסע מהר יותר ב־10 קמ״ש, היה מגיע שעה אחת מוקדם יותר. מה מהירותו?",
1820
+ "choices": [
1821
+ {
1822
+ "text": "30 קמ״ש",
1823
+ "correct": true
1824
+ },
1825
+ {
1826
+ "text": "30 או −40 קמ״ש",
1827
+ "mistake": "inadmissible"
1828
+ },
1829
+ {
1830
+ "text": "40 קמ״ש",
1831
+ "mistake": "other_quantity"
1832
+ }
1833
+ ]
1834
+ }
1835
+ },
1836
+ {
1837
+ "id": "w_percent",
1838
+ "unit": "w",
1839
+ "name": "שאלות עם אחוזים",
1840
+ "short": "שאלות אחוזים",
1841
+ "prereqs": [
1842
+ "q_eq_simple"
1843
+ ],
1844
+ "description": "שינוי חוזר באחוזים מוביל לביטוי בריבוע.",
1845
+ "month": "מאי",
1846
+ "sample": {
1847
+ "question": "מחיר עלה ב־p אחוזים, ואחר כך שוב ב־p אחוזים, מ־200 ₪ ל־242 ₪. מהו p?",
1848
+ "choices": [
1849
+ {
1850
+ "text": "$10$",
1851
+ "correct": true
1852
+ },
1853
+ {
1854
+ "text": "$10.5$",
1855
+ "mistake": "percent_add"
1856
+ },
1857
+ {
1858
+ "text": "$21$",
1859
+ "mistake": "other_quantity"
1860
+ }
1861
+ ]
1862
+ }
1863
+ },
1864
+ {
1865
+ "id": "w_extremum",
1866
+ "unit": "w",
1867
+ "name": "בעיות קיצון",
1868
+ "short": "בעיות קיצון",
1869
+ "prereqs": [
1870
+ "w_area",
1871
+ "q_vertex"
1872
+ ],
1873
+ "description": "מקסימום ומינימום בבעיה, בעזרת קודקוד הפרבולה.",
1874
+ "month": "מאי",
1875
+ "sample": {
1876
+ "question": "סכום שני מספרים הוא 10. מהי המכפלה הגדולה ביותר שלהם?",
1877
+ "choices": [
1878
+ {
1879
+ "text": "$25$",
1880
+ "correct": true
1881
+ },
1882
+ {
1883
+ "text": "$5$",
1884
+ "mistake": "other_quantity"
1885
+ },
1886
+ {
1887
+ "text": "$100$",
1888
+ "mistake": "square_total"
1889
+ },
1890
+ {
1891
+ "text": "$24$",
1892
+ "mistake": "guess_cases"
1893
+ }
1894
+ ]
1895
+ }
1896
+ }
1897
+ ],
1898
+ "mistakes": {
1899
+ "minus_minus": {
1900
+ "name": "חיסור של מספר שלילי",
1901
+ "tip": "חיסור של מספר שלילי הוא חיבור: $−3 − (−10) = −3 + 10$.",
1902
+ "back": "f_neg"
1903
+ },
1904
+ "order_ops": {
1905
+ "name": "סדר פעולות",
1906
+ "tip": "כפל לפני חיסור: קודם $(−5)·2 = −10$, ואחר כך החיסור.",
1907
+ "back": null
1908
+ },
1909
+ "result_sign": {
1910
+ "name": "סימן התוצאה",
1911
+ "tip": "$−3 + 10 = 7$: המספר החיובי גדול יותר בערכו המוחלט.",
1912
+ "back": null
1913
+ },
1914
+ "add_denoms": {
1915
+ "name": "חיבור מונים ומכנים",
1916
+ "tip": "מחברים שברים רק אחרי מכנה משותף: $2/3 = 8/12$, $1/4 = 3/12$.",
1917
+ "back": "f_frac"
1918
+ },
1919
+ "scale_numerators": {
1920
+ "name": "מכנה משותף בלי להרחיב את המונים",
1921
+ "tip": "כשמרחיבים למכנה 12 מכפילים גם את המונה: $2/3 = 8/12$.",
1922
+ "back": "f_frac"
1923
+ },
1924
+ "mult_not_add": {
1925
+ "name": "כפל במקום חיבור",
1926
+ "tip": "$2/3 · 1/4 = 1/6$, אבל כאן התבקש חיבור.",
1927
+ "back": "f_frac"
1928
+ },
1929
+ "neg_square": {
1930
+ "name": "ריבוע של מספר שלילי",
1931
+ "tip": "מספר שלילי בריבוע הוא חיובי: $(−2)² = 4$ ו־$(−3x)² = 9x²$.",
1932
+ "back": "f_neg"
1933
+ },
1934
+ "coef_in_power": {
1935
+ "name": "החזקה הוחלה על המקדם",
1936
+ "tip": "ב־$3x²$ רק x בריבוע: $3·(−2)² = 12$.",
1937
+ "back": "f_ops"
1938
+ },
1939
+ "join_unlike": {
1940
+ "name": "חיבור איברים שאינם דומים",
1941
+ "tip": "מכנסים רק איברים עם אותה אות: a עם a, ו־b עם b.",
1942
+ "back": "f_like"
1943
+ },
1944
+ "sign_of_term": {
1945
+ "name": "הסימן שלפ��י האיבר",
1946
+ "tip": "הסימן שלפני איבר שייך לו: $−3a$ ו־$−2b$ הם איברים שליליים.",
1947
+ "back": "f_like"
1948
+ },
1949
+ "coef_not_raised": {
1950
+ "name": "המקדם לא הועלה בחזקה",
1951
+ "tip": "גם המקדם עולה בחזקה: $(2x³)² = 2²·(x³)² = 4x⁶$.",
1952
+ "back": "f_power"
1953
+ },
1954
+ "power_as_mult": {
1955
+ "name": "חזקה ככפל במעריך",
1956
+ "tip": "חזקה היא כפל חוזר: $x² = x·x$, ולא $2·x$.",
1957
+ "back": "f_power"
1958
+ },
1959
+ "sign_product": {
1960
+ "name": "סימן של מכפלה",
1961
+ "tip": "שלילי כפול שלילי הוא חיובי, וחיובי כפול שלילי הוא שלילי.",
1962
+ "back": "f_neg"
1963
+ },
1964
+ "partial_dist": {
1965
+ "name": "פילוג רק לאיבר הראשון",
1966
+ "tip": "המספר שלפני הסוגריים כופל כל איבר בתוכם.",
1967
+ "back": "f_dist"
1968
+ },
1969
+ "move_sign": {
1970
+ "name": "העברת אגף בלי החלפת סימן",
1971
+ "tip": "איבר שעובר לאגף השני מחליף את סימנו.",
1972
+ "back": "f_lin"
1973
+ },
1974
+ "slope_inverted": {
1975
+ "name": "שיפוע הפוך",
1976
+ "tip": "שיפוע הוא השינוי ב־y חלקי השינוי ב־x: $(8 − 2)/(3 − 1) = 3$.",
1977
+ "back": "f_linfun"
1978
+ },
1979
+ "slope_dy_only": {
1980
+ "name": "שיפוע בלי חלוקה",
1981
+ "tip": "מחלקים את השינוי ב־y בשינוי ב־x.",
1982
+ "back": "f_linfun"
1983
+ },
1984
+ "swapped_pair": {
1985
+ "name": "סדר בזוג הסדור",
1986
+ "tip": "בזוג סדור x ראשון ו־y שני.",
1987
+ "back": "f_sys"
1988
+ },
1989
+ "forgot_divide": {
1990
+ "name": "שכחת לחלק",
1991
+ "tip": "חיבור המשוואות נותן $2x = 14$, ועוד צריך לחלק ב־2.",
1992
+ "back": "f_lin"
1993
+ },
1994
+ "sqrt_split": {
1995
+ "name": "שורש של סכום",
1996
+ "tip": "$√(a + b) ≠ √a + √b$. קודם מחברים: $√25 = 5$.",
1997
+ "back": "f_sqrt"
1998
+ },
1999
+ "sqrt_half": {
2000
+ "name": "שורש כחצי",
2001
+ "tip": "שורש ריבועי אינו חצי: $√16 = 4$ כי $4² = 16$.",
2002
+ "back": "f_sqrt"
2003
+ },
2004
+ "first_last_only": {
2005
+ "name": "פילוג חלקי",
2006
+ "tip": "בפילוג מורחב כל איבר בסוגריים הראשונים כופל כל איבר בשניים: ארבע מכפלות.",
2007
+ "back": "t_ext"
2008
+ },
2009
+ "div_neg": {
2010
+ "name": "חילוק במספר שלילי",
2011
+ "tip": "$−x = 10$, ולכן $x = −10$: מחלקים את שני האגפים ב־$−1$.",
2012
+ "back": "f_lin"
2013
+ },
2014
+ "forgot_middle": {
2015
+ "name": "שכחת את האיבר האמצעי",
2016
+ "tip": "$(a − b)² = a² − 2ab + b²$, ולא $a² − b²$.",
2017
+ "back": "t_square"
2018
+ },
2019
+ "middle_no_2": {
2020
+ "name": "האיבר האמצעי בלי 2",
2021
+ "tip": "האיבר האמצעי הוא $2ab = 2·2x·3 = 12x$.",
2022
+ "back": "t_square"
2023
+ },
2024
+ "diff_sign": {
2025
+ "name": "סימן בהפרש ריבועים",
2026
+ "tip": "$(a + b)(a − b) = a² − b²$, ולכן $50² − 1² = 2499$.",
2027
+ "back": "t_diffsq"
2028
+ },
2029
+ "forgot_b2": {
2030
+ "name": "שכחת את $b²$",
2031
+ "tip": "$51·49 = (50 + 1)(50 − 1) = 50² − 1²$.",
2032
+ "back": "t_diffsq"
2033
+ },
2034
+ "partial_gcf": {
2035
+ "name": "לא הגורם המשותף הגדול ביותר",
2036
+ "tip": "גם 3 וגם x משותפים לשני האיברים, ולכן מוציאים $3x$.",
2037
+ "back": "t_gcf"
2038
+ },
2039
+ "divide_each": {
2040
+ "name": "לא חילקת כל איבר",
2041
+ "tip": "בתוך הסוגריים נשאר כל איבר חלקי הגורם: $9x : 3x = 3$.",
2042
+ "back": "t_gcf"
2043
+ },
2044
+ "diff_vs_square": {
2045
+ "name": "הפרש ריבועים מול ריבוע של הפרש",
2046
+ "tip": "$a² − b² = (a − b)(a + b)$. ב־$(a − b)²$ יש גם איבר אמצעי.",
2047
+ "back": "t_diffsq"
2048
+ },
2049
+ "root_of_coef": {
2050
+ "name": "שורש של המקדם",
2051
+ "tip": "$4x² = (2x)²$, ולכן $a = 2x$.",
2052
+ "back": "f_sqrt"
2053
+ },
2054
+ "signs_swapped": {
2055
+ "name": "סימנים הפוכים בפירוק",
2056
+ "tip": "פתחו סוגריים לבדיקה: האיבר האמצעי צריך לצאת עם הסימן של המקור.",
2057
+ "back": "t_trinomial"
2058
+ },
2059
+ "product_only": {
2060
+ "name": "המכפלה נכונה, הסכום לא",
2061
+ "tip": "מחפשים שני מספרים שמכפלתם $−12$ וסכומם $−1$: $−4$ ו־$3$.",
2062
+ "back": "t_trinomial"
2063
+ },
2064
+ "zero_root_sign": {
2065
+ "name": "סימן הערך האסור",
2066
+ "tip": "$x + 4 = 0$ כאשר $x = −4$.",
2067
+ "back": "f_lin"
2068
+ },
2069
+ "one_factor_only": {
2070
+ "name": "בדקת רק גורם אחד",
2071
+ "tip": "מכפלה היא 0 כשאחד הגורמים הוא 0, ולכן בודקים כל גורם במכנה.",
2072
+ "back": "t_domain"
2073
+ },
2074
+ "numerator_domain": {
2075
+ "name": "תחום הצבה לפי המונה",
2076
+ "tip": "תחום ההצבה נקבע לפי המכנה בלבד: אסור לחלק ב־0. מונה 0 מותר.",
2077
+ "back": "t_domain"
2078
+ },
2079
+ "cancel_terms": {
2080
+ "name": "צמצום של מחוברים",
2081
+ "tip": "מצמצמים רק גורמים בכפל. קודם מפרקים את המונה ואת המכנה.",
2082
+ "back": "t_reduce"
2083
+ },
2084
+ "factor_sign": {
2085
+ "name": "טעות בפירוק",
2086
+ "tip": "$x² − 9 = (x − 3)(x + 3)$ ו־$x² − 1 = (x − 1)(x + 1)$. בדקו איזה גורם מצטמצם ואיזה נשאר.",
2087
+ "back": "t_factor_formula"
2088
+ },
2089
+ "div_not_flipped": {
2090
+ "name": "חילוק בלי הופכי",
2091
+ "tip": "חילוק בשבר הוא כפל בהופכי שלו: $: 4x²/(x² − 1)$ הופך ל־$· (x² − 1)/(4x²)$.",
2092
+ "back": "f_frac"
2093
+ },
2094
+ "cancel_power": {
2095
+ "name": "צמצום חזקות",
2096
+ "tip": "$2x / 4x² = 1/(2x)$: נשאר x אחד במכנה.",
2097
+ "back": "t_reduce"
2098
+ },
2099
+ "excluded_root": {
2100
+ "name": "פתרון שאינו קביל",
2101
+ "tip": "$x = 3$ מאפס את המכנה, ולכן הוא לא פתרון. בודקים כל פתרון מול תחום ההצבה.",
2102
+ "back": "t_domain"
2103
+ },
2104
+ "axes_mixed": {
2105
+ "name": "חיתוך עם הציר הלא נכון",
2106
+ "tip": "חיתוך עם ציר x: מציבים $y = 0$. חיתוך עם ציר y: מציבים $x = 0$.",
2107
+ "back": "n_intercepts"
2108
+ },
2109
+ "pos_neg_swap": {
2110
+ "name": "חיוביות ושליליות הפוכות",
2111
+ "tip": "הפונקציה שלילית איפה שהגרף מתחת לציר x.",
2112
+ "back": "n_sign"
2113
+ },
2114
+ "neg_x": {
2115
+ "name": "'שלילית' בערכי x",
2116
+ "tip": "פונקציה שלילית פירושו ערכי y שליליים, לא x שלילי.",
2117
+ "back": "n_sign"
2118
+ },
2119
+ "partial_intervals": {
2120
+ "name": "תחום אחד מתוך כמה",
2121
+ "tip": "עוברים על כל הגרף משמאל לימין: יכולים להיות כמה תחומים כאלה.",
2122
+ "back": "n_sign"
2123
+ },
2124
+ "dir_confusion": {
2125
+ "name": "עלייה במקום ירידה",
2126
+ "tip": "קוראים את הגרף משמאל לימין. יורדת: ככל ש־x גדל, y קטן.",
2127
+ "back": "n_extremum"
2128
+ },
2129
+ "x_vs_y_interval": {
2130
+ "name": "תחום בערכי y",
2131
+ "tip": "תחומי עלייה וירידה נכתבים בערכי x.",
2132
+ "back": "n_extremum"
2133
+ },
2134
+ "local_as_abs": {
2135
+ "name": "מקסימום מקומי כמוחלט",
2136
+ "tip": "ב־$(−1, 3)$ יש מקסימום מקומי. המוחלט הוא הערך הגבוה ביותר בכל התחום: 5.",
2137
+ "back": "n_local_abs"
2138
+ },
2139
+ "value_vs_point": {
2140
+ "name": "ערך הקיצון מול נקודת הקיצון",
2141
+ "tip": "ערך המקסימום הוא שיעור ה־y בלבד: 5. הנקודה היא $(3, 5)$.",
2142
+ "back": "n_local_abs"
2143
+ },
2144
+ "x_equals_m": {
2145
+ "name": "$f(x) = 1$ כ־$x = 1$",
2146
+ "tip": "$f(x) = 1$ שואלת איפה הגרף בגובה 1. מעבירים את הישר $y = 1$ וסופרים מפגשים.",
2147
+ "back": "n_eq_graph"
2148
+ },
2149
+ "count_all": {
2150
+ "name": "לא נספרו כל המפגשים",
2151
+ "tip": "עוברים על כל הגרף: הישר $y = 1$ פוגש אותו שלוש פעמים.",
2152
+ "back": "n_eq_graph"
2153
+ },
2154
+ "vshift_dir": {
2155
+ "name": "כיוון ההזזה האנכית",
2156
+ "tip": "$f(x) − 3$ מוריד את הגרף ב־3: $(2, 5)$ עובר ל־$(2, 2)$.",
2157
+ "back": "n_vshift"
2158
+ },
2159
+ "vshift_as_h": {
2160
+ "name": "הזזה אנכית כאופקית",
2161
+ "tip": "מספר מחוץ ל־f מזיז למעלה או למטה. x לא משתנה.",
2162
+ "back": "n_vshift"
2163
+ },
2164
+ "hshift_sign": {
2165
+ "name": "כיוון ההזזה האופקית",
2166
+ "tip": "$f(x − b)$ זז ימינה ב־b. ב־$(x + 3)²$ מתקיים $b = −3$, ולכן הקודקוד ב־$x = −3$.",
2167
+ "back": "n_hshift"
2168
+ },
2169
+ "h_as_v": {
2170
+ "name": "הזזה אופקית כאנכית",
2171
+ "tip": "מספר בתוך הסוגריים מזיז ימינה או שמאלה. y של הקיצון לא משתנה.",
2172
+ "back": "n_hshift"
2173
+ },
2174
+ "reflect_type": {
2175
+ "name": "סוג הקיצון בשיקוף",
2176
+ "tip": "שיקוף ביחס לציר x הופך מקסימום למינימום.",
2177
+ "back": "n_reflect"
2178
+ },
2179
+ "reflect_axis": {
2180
+ "name": "שיקוף ביחס לציר הלא נכון",
2181
+ "tip": "שיקוף ביחס לציר x משנה את הסימן של y. ה־x נשאר.",
2182
+ "back": "n_reflect"
2183
+ },
2184
+ "max_below": {
2185
+ "name": "מקסימום שלילי",
2186
+ "tip": "ערך המקסימום הוא $−1$, ולכן כל הגרף מתחת לציר x ואין נקודות חיתוך איתו.",
2187
+ "back": "n_props_graph"
2188
+ },
2189
+ "opening_sign": {
2190
+ "name": "סוג הקיצון לפי a",
2191
+ "tip": "כש־$a < 0$ יש מקסימום, וכש־$a > 0$ יש מינימום.",
2192
+ "back": "q_parabola"
2193
+ },
2194
+ "a_as_vertex": {
2195
+ "name": "a כשיעור הקודקוד",
2196
+ "tip": "a קובע רק את סוג הקיצון. הקודקוד של $ax²$ תמיד ב־$(0, 0)$.",
2197
+ "back": "q_parabola"
2198
+ },
2199
+ "missing_neg_root": {
2200
+ "name": "פתרון שלילי חסר",
2201
+ "tip": "גם המספר השלילי מקיים: $(−2)² = 4$. ל־$x² = 4$ יש שני פתרונות.",
2202
+ "back": "f_sqrt"
2203
+ },
2204
+ "forgot_root": {
2205
+ "name": "שכחת להוציא שורש",
2206
+ "tip": "$x² = 4$, ולכן $x = ±√4 = ±2$.",
2207
+ "back": "f_sqrt"
2208
+ },
2209
+ "plug_zero": {
2210
+ "name": "הצבת $x = 0$",
2211
+ "tip": "הצבת $x = 0$ נותנת את החיתוך עם ציר y, לא את הקודקוד.",
2212
+ "back": "n_intercepts"
2213
+ },
2214
+ "k_sign": {
2215
+ "name": "סימן ההזזה האנכית",
2216
+ "tip": "ב־$a(x − b)² + k$ הקודקוד הוא $(b, k)$, וכאן $k = 8$.",
2217
+ "back": "q_vshift"
2218
+ },
2219
+ "zero_sign": {
2220
+ "name": "סימן נקודות האפס",
2221
+ "tip": "$x − 1 = 0$ כאשר $x = 1$: נקודת האפס היא המספר בסוגריים עם סימן הפוך.",
2222
+ "back": "q_zero_prod"
2223
+ },
2224
+ "divide_wrong": {
2225
+ "name": "חילוק הפוך",
2226
+ "tip": "$2x = −1$, ולכן $x = −1/2$.",
2227
+ "back": "f_lin"
2228
+ },
2229
+ "axis_middle": {
2230
+ "name": "מיקום ציר הסימטריה",
2231
+ "tip": "ציר הסימטריה באמצע בין נקודות האפס: $(1 + 5)/2 = 3$.",
2232
+ "back": "q_fform"
2233
+ },
2234
+ "a_as_zero": {
2235
+ "name": "המקדם כנקודת אפס",
2236
+ "tip": "המקדם a אינו נקודת אפס. רק הגורמים עם x מתאפסים.",
2237
+ "back": "q_fform"
2238
+ },
2239
+ "axis_sign": {
2240
+ "name": "סימן ציר הסימטריה",
2241
+ "tip": "$x = −b/(2a)$. כאן $b = −6$, ולכן $x = 6/2 = 3$.",
2242
+ "back": "q_sform"
2243
+ },
2244
+ "forgot_2a_axis": {
2245
+ "name": "המכנה $2a$ בציר הסימטריה",
2246
+ "tip": "מחלקים ב־$2a$: $x = 6/2 = 3$, לא 6.",
2247
+ "back": "q_sform"
2248
+ },
2249
+ "c_as_vertex": {
2250
+ "name": "c כקודקוד",
2251
+ "tip": "c נותן את החיתוך עם ציר y. הקודקוד הוא על ציר הסימטריה $x = −b/(2a)$.",
2252
+ "back": "q_sform"
2253
+ },
2254
+ "a_from_point": {
2255
+ "name": "הנקודה במקום המקדם",
2256
+ "tip": "מציבים את הנקודה ופותרים את a: $a(0 − 2)² − 1 = 3$, ולכן $a = 1$.",
2257
+ "back": "q_from_points"
2258
+ },
2259
+ "sub_vs_div": {
2260
+ "name": "חיסור במקום חילוק",
2261
+ "tip": "מבודדים את $x²$ בחילוק ב־3: $x² = 16$.",
2262
+ "back": "f_lin"
2263
+ },
2264
+ "divide_by_x": {
2265
+ "name": "חילוק ב־x",
2266
+ "tip": "חילוק ב־x מאבד את הפתרון $x = 0$. מעבירים לאגף אחד ומוציאים גורם משותף: $5x(x − 4) = 0$.",
2267
+ "back": "q_eq_gcf"
2268
+ },
2269
+ "lost_sign_move": {
2270
+ "name": "סימן בהעברת אגף",
2271
+ "tip": "$5x² − 20x = 0$, ולכן $5x(x − 4) = 0$ ו־$x = 4$.",
2272
+ "back": "f_lin"
2273
+ },
2274
+ "product_not_zero": {
2275
+ "name": "מכפלה ששווה למספר שאינו 0",
2276
+ "tip": "הכלל 'מכפלה שווה 0' עובד רק כשבאגף השני 0. קודם מעבירים: $x² + 2x − 15 = 0$.",
2277
+ "back": "q_zero_prod"
2278
+ },
2279
+ "minus_b": {
2280
+ "name": "הסימן של $−b$",
2281
+ "tip": "בנוסחה מופיע $−b$. כאן $b = −3$, ולכן $−b = 3$.",
2282
+ "back": "q_formula"
2283
+ },
2284
+ "forgot_2a": {
2285
+ "name": "המכנה $2a$",
2286
+ "tip": "בנוסחת השורשים מחלקים ב־$2a = 4$, לא ב־2.",
2287
+ "back": "q_formula"
2288
+ },
2289
+ "disc_sign": {
2290
+ "name": "החישוב בתוך השורש",
2291
+ "tip": "$b² − 4ac$: כאן $c = −2$, ולכן $−4ac = +16$ והתוצאה $9 + 16 = 25$.",
2292
+ "back": "q_formula"
2293
+ },
2294
+ "disc_meaning": {
2295
+ "name": "משמעות הביטוי בתוך השורש",
2296
+ "tip": "חיובי: שני פתרונות. אפס: פתרון אחד. שלילי: אין פתרון. כאן $36 − 36 = 0$.",
2297
+ "back": "q_disc"
2298
+ },
2299
+ "sign_regions": {
2300
+ "name": "שלילית בין האפסים",
2301
+ "tip": "פרבולה עם מינימום שלילית בין נקודות האפס וחיובית מחוץ להן.",
2302
+ "back": "q_ineq"
2303
+ },
2304
+ "forgot_y": {
2305
+ "name": "שכחת את y",
2306
+ "tip": "אחרי שמוצאים x מציבים באחת המשוואות כדי למצוא y.",
2307
+ "back": "f_sys"
2308
+ },
2309
+ "forgot_half": {
2310
+ "name": "שכחת לחלק ב־2",
2311
+ "tip": "שטח משולש הוא בסיס כפול גובה חלקי 2: $4·4/2 = 8$.",
2312
+ "back": "q_tri"
2313
+ },
2314
+ "neg_height": {
2315
+ "name": "גובה שלילי",
2316
+ "tip": "הגובה הוא מרחק, ולכן חיובי: הקודקוד $(2, −4)$ נמצא 4 יחידות מתחת לציר x.",
2317
+ "back": "q_tri"
2318
+ },
2319
+ "mult_exponents": {
2320
+ "name": "כפל מעריכים",
2321
+ "tip": "בכפל חזקות עם אותו בסיס מחברים את המעריכים: $a⁵·a³ = a⁸$.",
2322
+ "back": "p_laws1"
2323
+ },
2324
+ "div_exponents": {
2325
+ "name": "חילוק מעריכים",
2326
+ "tip": "בחילוק חזקות עם אותו בסיס מחסרים את המעריכים: $a⁸ : a² = a⁶$.",
2327
+ "back": "p_laws1"
2328
+ },
2329
+ "pow_pow_add": {
2330
+ "name": "חיבור מעריכים בחזקה של חזקה",
2331
+ "tip": "בחזקה של חזקה כופלים מעריכים: $(x³)² = x⁶$.",
2332
+ "back": "p_laws2"
2333
+ },
2334
+ "bigger_exp": {
2335
+ "name": "מעריך גדול יותר, מספר גדול יותר",
2336
+ "tip": "כתבו את שניהם עם אותו מעריך: $2³⁰⁰ = 8¹⁰⁰$ ו־$3²⁰⁰ = 9¹⁰⁰$.",
2337
+ "back": "p_laws2"
2338
+ },
2339
+ "sqrt_add": {
2340
+ "name": "שורש של מכפלה כשורש של סכום",
2341
+ "tip": "$√a·√b = √(ab)$: $√12·√3 = √36$.",
2342
+ "back": "p_sqrt_laws"
2343
+ },
2344
+ "sqrt_forgot": {
2345
+ "name": "שכחת להוציא שורש",
2346
+ "tip": "$√36 = 6$, לא 36.",
2347
+ "back": "f_sqrt"
2348
+ },
2349
+ "inadmissible": {
2350
+ "name": "פתרון שלא מתאים לבעיה",
2351
+ "tip": "אורך ומהירות אינם שליליים. פוסלים פתרון שאינו מתאים לבעיה.",
2352
+ "back": null
2353
+ },
2354
+ "other_quantity": {
2355
+ "name": "ענית על גודל אחר",
2356
+ "tip": "קראו שוב מה נשאל. פתרתם נכון, אבל המספר שבחרתם מתאר גודל אחר בבעיה.",
2357
+ "back": null
2358
+ },
2359
+ "area_perimeter": {
2360
+ "name": "שטח מול היקף",
2361
+ "tip": "שטח הוא אורך כפול רוחב: $w(w + 2) = 48$.",
2362
+ "back": "w_area"
2363
+ },
2364
+ "percent_add": {
2365
+ "name": "חיבור אחוזים",
2366
+ "tip": "שתי עליות של p% הן כפל: $200·(1 + p/100)² = 242$, ולכן $1 + p/100 = 1.1$.",
2367
+ "back": "w_percent"
2368
+ },
2369
+ "square_total": {
2370
+ "name": "ריבוע הסכום",
2371
+ "tip": "$10² = 100$ הוא ריבוע הסכום, לא מכפלת המספרים.",
2372
+ "back": null
2373
+ },
2374
+ "guess_cases": {
2375
+ "name": "ניחוש ערכים",
2376
+ "tip": "ניסוי של כמה ערכים לא מוכיח מקסימום. מוצאים את הקודקוד של $y = x(10 − x)$.",
2377
+ "back": "q_vertex"
2378
+ }
2379
+ }
2380
+ }
app/math_photo.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A photographed math problem, solved for the math path: hints first, the full solution on request.
2
+
3
+ The student photographs a problem; Claude reads it, solves it, and answers in a fixed JSON shape (PHOTO_SCHEMA):
4
+ the problem as it read it (so the student can check the reading), the solution as steps, each a hint that does
5
+ not give the step away and the step itself, the final answer, and the path's skill the problem practises. The page
6
+ shows the hints one by one, and the steps and the answer only when the student asks for them.
7
+
8
+ Runs on the server's Anthropic key (the tutor's, ANTHROPIC_API_KEY or the panel's), on MATH_MODEL (default
9
+ DEFAULT_MODEL). The photo is sent to the API and not kept.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import json
15
+ import logging
16
+ import os
17
+
18
+ log = logging.getLogger("ihki.math_photo")
19
+
20
+ DEFAULT_MODEL = "claude-opus-5-5"
21
+ DAILY_PHOTOS = 20 # photos per signed-in account per day; developers are exempt in main.py
22
+ MAX_PHOTO = 5_000_000 # bytes; the page sends a resized JPEG of a few hundred KB
23
+ MEDIA_TYPES = ("image/jpeg", "image/png", "image/webp")
24
+
25
+
26
+ class PhotoUnavailable(Exception):
27
+ """The photo could not be solved; the message is for the student (Hebrew)."""
28
+
29
+
30
+ def photo_schema(skill_ids: list[str]) -> dict:
31
+ return {"type": "object", "additionalProperties": False,
32
+ "required": ["readable", "problem", "steps", "answer", "skill", "note"],
33
+ "properties": {
34
+ "readable": {"type": "boolean"},
35
+ "problem": {"type": "string"},
36
+ "steps": {"type": "array", "items": {"type": "object", "additionalProperties": False,
37
+ "required": ["hint", "work"],
38
+ "properties": {"hint": {"type": "string"}, "work": {"type": "string"}}}},
39
+ "answer": {"type": "string"},
40
+ "skill": {"type": "string", "enum": [*skill_ids, ""]},
41
+ "note": {"type": "string"}}}
42
+
43
+
44
+ def system_prompt(skills: list[dict]) -> str:
45
+ listed = "\n".join(f"- {s['id']}: {s['name'].replace('$', '')}" for s in skills)
46
+ return f"""אתה מורה פרטי למתמטיקה לתלמידי כיתה ט׳ בהקבצה של 5 יחידות, בישראל. תלמיד צילם תרגיל ושלח לך את התמונה.
47
+
48
+ 1. קרא את התרגיל מהתמונה בדיוק כפי שהוא כתוב, ורשום אותו ב־problem. אם בתמונה כמה תרגילים, פתור את הראשון וכתוב זאת ב־note.
49
+ 2. פתור את התרגיל נכון, ובדוק את הפתרון (למשל בהצבה) לפני שאתה עונה.
50
+ 3. חלק את הפתרון לצעדים קצרים, בסדר שבו תלמיד היה כותב אותם, 2 עד 6 צעדים. לכל צעד:
51
+ - hint: רמז או שאלה מכוונת שעוזרים לתלמיד לעשות את הצעד בעצמו, בלי לגלות את התוצאה שלו.
52
+ - work: הצעד עצמו, עם התוצאה.
53
+ 4. answer: התשובה הסופית בלבד, קצרה. אם יש פתרון שאינו קביל (למשל מאפס מכנה, או אורך שלילי), כתוב רק את הקבילים.
54
+ 5. skill: המזהה של המיומנות מהרשימה שהתרגיל מתרגל יותר מכל אחת אחרת, או מחרוזת ריקה אם אף אחת לא מתאימה.
55
+ 6. אם התמונה לא קריאה, או שאין בה תרגיל מתמטי, readable יהיה false, ו־note יסביר בקצרה לתלמיד מה לצלם מחדש. השאר את שאר השדות ריקים.
56
+
57
+ כתוב בעברית פשוטה, כמו מורה שמדבר עם תלמיד, ופנה בלשון רבים כמו שאר האפליקציה (למשל "העבירו", "חפשו", "הציבו"). כל ביטוי מתמטי עטוף בסימני דולר, למשל $x² − 4 = 0$, וגם כל מספר בודד בתוך משפט, בעיקר מספר שלילי: "שמכפלתם $−15$". בתוך הדולרים כתוב בתווי יוניקוד: ² ³ √ · − ≤ ≥ ≠ ±, שברים כ־a/b, ובלי פקודות LaTeX.
58
+
59
+ המיומנויות במסלול:
60
+ {listed}"""
61
+
62
+
63
+ class PhotoSolver:
64
+ def __init__(self, client, model: str = DEFAULT_MODEL, skills: list[dict] | None = None):
65
+ self.client, self.model, self.skills = client, model, skills or []
66
+
67
+ def solve(self, image: bytes, media_type: str) -> dict:
68
+ import anthropic
69
+ if media_type not in MEDIA_TYPES:
70
+ raise PhotoUnavailable("אפשר לשלוח תמונה מסוג JPEG,‏ PNG או WebP.")
71
+ data = base64.standard_b64encode(image).decode("ascii")
72
+ try:
73
+ res = self.client.beta.messages.create(
74
+ model=self.model, max_tokens=16000, betas=["server-side-fallback-2026-07-01"], fallbacks="default",
75
+ system=system_prompt(self.skills), cache_control={"type": "ephemeral"},
76
+ output_config={"effort": "medium",
77
+ "format": {"type": "json_schema", "schema": photo_schema([s["id"] for s in self.skills])}},
78
+ messages=[{"role": "user", "content": [
79
+ {"type": "image", "source": {"type": "base64", "media_type": media_type, "data": data}},
80
+ {"type": "text", "text": "זה התרגיל שצילמתי."}]}],
81
+ )
82
+ except anthropic.RateLimitError as e:
83
+ log.warning("math photo rate limited: %s", e)
84
+ raise PhotoUnavailable("עמוס כרגע. נסו שוב בעוד דקה.") from e
85
+ except anthropic.AuthenticationError as e:
86
+ log.error("math photo: the API key was refused: %s", e)
87
+ raise PhotoUnavailable("פתרון מתמונה לא זמין כרגע (מפתח ה-API לא תקין).") from e
88
+ except anthropic.APIStatusError as e:
89
+ log.warning("math photo: the API answered %s: %s", e.status_code, e.message)
90
+ raise PhotoUnavailable("פתרון מתמונה לא זמין כרגע. נסו שוב.") from e
91
+ except anthropic.APIConnectionError as e:
92
+ log.warning("math photo: no connection: %s", e)
93
+ raise PhotoUnavailable("אין חיבור כרגע. נסו שוב.") from e
94
+ if res.stop_reason == "refusal":
95
+ log.warning("math photo: the model declined (%s)", res.stop_details)
96
+ raise PhotoUnavailable("לא הצלחנו לפתור את התמונה הזו. נסו לצלם תרגיל אחר.")
97
+ if res.stop_reason == "max_tokens":
98
+ raise PhotoUnavailable("הפתרון נקטע. נסו שוב.")
99
+ text = next((b.text for b in res.content if b.type == "text"), "")
100
+ try:
101
+ out = json.loads(text)
102
+ except ValueError as e:
103
+ log.warning("math photo: an answer that is not JSON: %r", text[:300])
104
+ raise PhotoUnavailable("לא הצלחנו לפתור את התרגיל. נסו שוב.") from e
105
+ log.info("math photo: %s, %d+%d tokens", res.model, res.usage.input_tokens, res.usage.output_tokens)
106
+ return out
107
+
108
+
109
+ def photo_client(key: str):
110
+ import anthropic
111
+ return anthropic.Anthropic(api_key=key, timeout=120.0, max_retries=1)
112
+
113
+
114
+ def math_model() -> str:
115
+ return os.environ.get("MATH_MODEL", "").strip() or DEFAULT_MODEL
app/math_skills.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The 9th-grade algebra skill graph for the math path (`math_algebra9.json`).
2
+
3
+ Each skill names the skills it needs first, a sample question, and for each wrong choice the mistake it shows.
4
+ A mistake sends the student back to the skill it comes from (`back`), or keeps them on the same skill (`null`).
5
+ The graph follows the Ministry of Education's 9th-grade teaching schedule (2026/27) and the summer review for
6
+ students placed in 5 units; `core` marks what that review stresses. The questions are written for the app, and
7
+ every skill, link and mistake still needs a 5-unit teacher's review.
8
+
9
+ `problems` is run by the tests and on loading: a broken graph is refused before a student meets it.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from functools import cache
15
+ from pathlib import Path
16
+
17
+ GRAPH = Path(__file__).with_name("math_algebra9.json")
18
+
19
+
20
+ def problems(graph: dict) -> list[str]:
21
+ """What is wrong with a graph: unknown units, skills or mistakes, a cycle, a link implied by another,
22
+ a question without exactly one right answer, or a mistake that sends the student to a skill that is not before it."""
23
+ units = {u["id"] for u in graph["units"]}
24
+ skills = {s["id"]: s for s in graph["skills"]}
25
+ mistakes = graph["mistakes"]
26
+ out = [f"{s['id']}: unknown unit {s['unit']}" for s in skills.values() if s["unit"] not in units]
27
+ out += [f"{s['id']}: unknown prerequisite {p}" for s in skills.values() for p in s["prereqs"] if p not in skills]
28
+ if out:
29
+ return out
30
+ below: dict[str, set[str]] = {}
31
+
32
+ def ancestors(sid: str, path: tuple[str, ...] = ()) -> set[str]:
33
+ if sid in path:
34
+ raise ValueError(" -> ".join(path + (sid,)))
35
+ if sid not in below:
36
+ below[sid] = {a for p in skills[sid]["prereqs"] for a in (p, *ancestors(p, path + (sid,)))}
37
+ return below[sid]
38
+
39
+ try:
40
+ for sid in skills:
41
+ ancestors(sid)
42
+ except ValueError as cycle:
43
+ return [f"cycle: {cycle}"]
44
+ for s in skills.values():
45
+ out += [f"{s['id']}: {p} is implied by {q}" for p in s["prereqs"] for q in s["prereqs"] if p != q and p in below[q]]
46
+ choices = s["sample"]["choices"]
47
+ if sum(bool(c.get("correct")) for c in choices) != 1:
48
+ out.append(f"{s['id']}: needs exactly one correct choice")
49
+ if len({c["text"] for c in choices}) != len(choices):
50
+ out.append(f"{s['id']}: two choices read the same")
51
+ for c in choices:
52
+ m = c.get("mistake")
53
+ if c.get("correct"):
54
+ continue
55
+ if m not in mistakes:
56
+ out.append(f"{s['id']}: unknown mistake {m}")
57
+ elif (back := mistakes[m]["back"]) and back != s["id"] and back not in below[s["id"]]:
58
+ out.append(f"{s['id']}: mistake {m} sends back to {back}, which is not before it")
59
+ out += [f"mistake {k}: unknown skill {m['back']}" for k, m in mistakes.items() if m["back"] and m["back"] not in skills]
60
+ return out
61
+
62
+
63
+ @cache
64
+ def algebra9() -> dict:
65
+ graph = json.loads(GRAPH.read_text(encoding="utf-8"))
66
+ if found := problems(graph):
67
+ raise ValueError(f"{GRAPH.name}: " + "; ".join(found))
68
+ return graph
docs/mathly-supabase.sql ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- The whole database of a new Supabase project for the app, in one go: paste it into the SQL Editor and run it.
2
+ -- Gathered from the README's sections (each is explained there); made for mathly, the math Space, whose classes
3
+ -- are math classes by default (the last lines).
4
+ create table public.attempts (
5
+ id bigint generated always as identity primary key,
6
+ user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
7
+ cat text not null, -- the topic id, e.g. l1-verbs
8
+ phrase text not null, -- the Hebrew prompt
9
+ score int not null, -- 0-100
10
+ unrelated boolean not null default false, -- "different sentence?"
11
+ issues jsonb not null default '[]'::jsonb, -- compact [{word, expected, heard, score}], never audio
12
+ model text not null default '',
13
+ version text not null default '',
14
+ created_at timestamptz not null default now()
15
+ );
16
+ create index attempts_user_time on public.attempts (user_id, created_at desc);
17
+ create index attempts_created_at_idx on public.attempts (created_at desc);
18
+
19
+ alter table public.attempts enable row level security;
20
+ grant select, insert on public.attempts to authenticated;
21
+ create policy "students add their own attempts" on public.attempts
22
+ for insert to authenticated with check (user_id = auth.uid());
23
+ create policy "students read their own attempts" on public.attempts
24
+ for select to authenticated using (user_id = auth.uid());
25
+
26
+ -- Anonymous aggregate buckets for the teacher's Monitoring graph. Only the backend service role may call it.
27
+ create or replace function public.activity_buckets(p_hours integer default 24, p_bucket_minutes integer default 15)
28
+ returns table (bucket timestamptz, attempts bigint, active_students bigint)
29
+ language sql stable security invoker set search_path = '' as $$
30
+ select date_bin(make_interval(mins => greatest(1, least(p_bucket_minutes, 60))), a.created_at,
31
+ timestamptz '2000-01-01 00:00:00+00'),
32
+ count(*)::bigint, count(distinct a.user_id)::bigint
33
+ from public.attempts a
34
+ where a.created_at >= now() - make_interval(hours => greatest(1, least(p_hours, 168)))
35
+ group by 1 order by 1
36
+ $$;
37
+ revoke execute on function public.activity_buckets(integer, integer) from public, anon, authenticated;
38
+ grant execute on function public.activity_buckets(integer, integer) to service_role;
39
+
40
+ -- Totals for the full selected window. This is separate from the buckets so a student who practised in several
41
+ -- buckets is counted only once.
42
+ drop function if exists public.activity_summary(integer);
43
+ create or replace function public.activity_summary(p_hours integer default 24)
44
+ returns table (attempts bigint, active_students bigint, returning_students bigint, active_days bigint)
45
+ language sql stable security invoker set search_path = '' as $$
46
+ with windowed as (
47
+ select a.user_id, (a.created_at at time zone 'Asia/Jerusalem')::date as practice_day
48
+ from public.attempts a
49
+ where a.created_at >= now() - make_interval(hours => greatest(1, least(p_hours, 168)))
50
+ ), per_user as (
51
+ select user_id, count(distinct practice_day) as days from windowed group by user_id
52
+ )
53
+ select (select count(*) from windowed)::bigint,
54
+ (select count(*) from per_user)::bigint,
55
+ (select count(*) from per_user where days >= 2)::bigint,
56
+ (select count(distinct practice_day) from windowed)::bigint
57
+ $$;
58
+ revoke execute on function public.activity_summary(integer) from public, anon, authenticated;
59
+ grant execute on function public.activity_summary(integer) to service_role;
60
+
61
+ -- Recent system activity shown only in the management panel. The backend writes with the service-role key;
62
+ -- no browser role gets direct table access.
63
+ create table public.system_events (
64
+ id bigint generated always as identity primary key,
65
+ kind text not null check (kind in ('exercise','game','permission','content')),
66
+ actor_id uuid references auth.users(id) on delete set null,
67
+ actor_email text not null default '',
68
+ label text not null default '',
69
+ details jsonb not null default '{}'::jsonb,
70
+ created_at timestamptz not null default now()
71
+ );
72
+ create index system_events_created_at_idx on public.system_events (created_at desc);
73
+ alter table public.system_events enable row level security;
74
+
75
+ create table public.app_secrets (
76
+ name text primary key check (name in ('SAWTAK_API_KEY','ANTHROPIC_API_KEY','TUTOR_MODEL')),
77
+ ciphertext text not null,
78
+ last4 text not null default '',
79
+ updated_by text not null default '',
80
+ updated_at timestamptz not null default now()
81
+ );
82
+ alter table public.app_secrets enable row level security;
83
+ revoke all on public.app_secrets from anon, authenticated;
84
+
85
+ alter table public.app_secrets drop constraint if exists app_secrets_name_check;
86
+ alter table public.app_secrets add constraint app_secrets_name_check
87
+ check (name in ('SAWTAK_API_KEY','ANTHROPIC_API_KEY','TUTOR_MODEL'));
88
+
89
+ alter table public.attempts
90
+ add column if not exists issues jsonb not null default '[]'::jsonb,
91
+ add column if not exists model text not null default '',
92
+ add column if not exists version text not null default '';
93
+
94
+ create table public.user_access (
95
+ user_id uuid primary key references auth.users(id) on delete cascade,
96
+ email text not null,
97
+ roles text[] not null default array['student']::text[],
98
+ entitlements text[] not null default '{}'::text[],
99
+ subject text not null default '', -- developer-set subject for admin-created classes
100
+ updated_at timestamptz not null default now(),
101
+ updated_by uuid references auth.users(id),
102
+ constraint valid_roles check (roles <@ array['student','teacher','flagger','admin','developer']::text[]),
103
+ constraint valid_entitlements check (entitlements <@ array['practice','games','tutor','path','library']::text[])
104
+ );
105
+ create unique index user_access_email_idx on public.user_access (lower(email));
106
+ alter table public.user_access enable row level security;
107
+ create table public.access_audit (
108
+ id bigint generated always as identity primary key,
109
+ target_user_id uuid not null,
110
+ changed_by uuid not null,
111
+ old_access jsonb not null,
112
+ new_access jsonb not null,
113
+ created_at timestamptz not null default now()
114
+ );
115
+ alter table public.access_audit enable row level security;
116
+
117
+ alter table public.user_access add column if not exists subject text not null default '';
118
+ alter table public.user_access drop constraint if exists valid_entitlements;
119
+ update public.user_access
120
+ set entitlements = array_append(entitlements, 'practice')
121
+ where 'path' = any(entitlements) and not ('practice' = any(entitlements));
122
+ alter table public.user_access add constraint valid_entitlements check (
123
+ entitlements <@ array['practice','games','tutor','path','library']::text[]
124
+ );
125
+
126
+ create table public.tutors (
127
+ email text primary key,
128
+ granted_by text not null default '', -- the teacher who gave the rank
129
+ created_at timestamptz not null default now()
130
+ );
131
+ alter table public.tutors enable row level security; -- no policy: only the secret key reads and writes it
132
+
133
+ create table public.flags (
134
+ id bigint generated always as identity primary key,
135
+ email text not null, -- the tutor
136
+ cat text not null, -- the topic id
137
+ phrase text not null,
138
+ he text not null, -- the Hebrew prompt
139
+ ar text not null, -- the accepted answer the scorer matched
140
+ ipa text not null, -- its phonemes: what the tutor says was said
141
+ heard_ipa text not null, -- the model's free decode
142
+ score int not null,
143
+ unrelated boolean not null default false,
144
+ note text not null default '',
145
+ result jsonb not null, -- the whole score response
146
+ model text not null,
147
+ version text not null default '',
148
+ audio text, -- object name in the private flags Storage bucket
149
+ status text not null default 'pending', -- pending, confirmed, rejected, or fixed
150
+ category text,
151
+ reviewed_by text,
152
+ reviewed_at timestamptz,
153
+ review_note text not null default '',
154
+ false_issues jsonb not null default '[]'::jsonb, -- [{word, expected, heard}]
155
+ created_at timestamptz not null default now()
156
+ );
157
+ alter table public.flags enable row level security; -- no policy: only the secret key reads and writes it
158
+
159
+ insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
160
+ values (
161
+ 'flags',
162
+ 'flags',
163
+ false,
164
+ 2000000,
165
+ array['audio/webm','audio/ogg','audio/mp4','audio/mpeg','audio/wav','audio/x-wav']
166
+ )
167
+ on conflict (id) do update set
168
+ public = excluded.public,
169
+ file_size_limit = excluded.file_size_limit,
170
+ allowed_mime_types = excluded.allowed_mime_types;
171
+
172
+ alter table public.flags
173
+ add column if not exists status text not null default 'pending',
174
+ add column if not exists category text,
175
+ add column if not exists reviewed_by text,
176
+ add column if not exists reviewed_at timestamptz,
177
+ add column if not exists review_note text not null default '',
178
+ add column if not exists false_issues jsonb not null default '[]'::jsonb;
179
+
180
+ create table public.lab_samples (
181
+ id bigint generated always as identity primary key, email text not null,
182
+ item text not null, ar text not null, ipa text not null, speaker text not null default '',
183
+ label text not null, -- 'correct' or 'mistake'
184
+ mistake jsonb, -- {"index", "expected"}: the sound said wrong on purpose
185
+ device text not null default '', score int not null, heard_ipa text not null,
186
+ flagged jsonb not null default '[]'::jsonb, -- the sounds the scorer marked down, "expected>heard"
187
+ result jsonb not null, model text not null, version text not null default '',
188
+ audio text, -- the recording's name in the storage bucket lab
189
+ created_at timestamptz not null default now());
190
+ alter table public.lab_samples enable row level security;
191
+ insert into storage.buckets (id, name) values ('lab', 'lab') on conflict (id) do nothing;
192
+
193
+ create table public.calibrations (
194
+ id bigint generated always as identity primary key,
195
+ expected text not null,
196
+ heard text not null,
197
+ floor numeric not null default 70,
198
+ scope text not null default 'global', -- global or phrase
199
+ phrase text not null default '',
200
+ evidence jsonb not null default '{}', -- report/reporter counts and supporting phrase keys
201
+ model text not null default '',
202
+ status text not null default 'draft', -- draft, applied, or rejected
203
+ proposed_by text not null,
204
+ reviewed_by text,
205
+ reviewed_at timestamptz,
206
+ created_at timestamptz not null default now()
207
+ );
208
+ alter table public.calibrations enable row level security;
209
+
210
+ create table public.scoring_rules (
211
+ id text primary key, -- a rule of rules.TOGGLES, e.g. dialect_Q, margins, rule_pharyngeal_h
212
+ active boolean not null default true,
213
+ updated_by text not null default '',
214
+ updated_at timestamptz not null default now()
215
+ );
216
+ alter table public.scoring_rules enable row level security;
217
+
218
+ create table public.writings (
219
+ user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
220
+ topic text not null, -- the topic id, e.g. l1-hw
221
+ lines jsonb not null, -- the sentences as written, "" for one not written yet
222
+ updated_at timestamptz not null default now(),
223
+ primary key (user_id, topic)
224
+ );
225
+
226
+ alter table public.writings enable row level security;
227
+ grant select, insert, update on public.writings to authenticated;
228
+ create policy "students read their own writings" on public.writings
229
+ for select to authenticated using (user_id = auth.uid());
230
+ create policy "students add their own writings" on public.writings
231
+ for insert to authenticated with check (user_id = auth.uid());
232
+ create policy "students change their own writings" on public.writings
233
+ for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid());
234
+
235
+ create table public.videos (
236
+ id bigint generated always as identity primary key,
237
+ lesson text not null, -- the lesson id, e.g. 2 or 2.1
238
+ title text not null, -- the topic's name on the path
239
+ url text not null, -- https://vimeo.com/<number>[/<hash>] or https://www.youtube.com/watch?v=<id>
240
+ pos int, -- how many of the lesson's own topics stand before it; null = after them all
241
+ ord int not null default 0, -- its turn among the videos of one place
242
+ created_at timestamptz not null default now()
243
+ );
244
+ alter table public.videos enable row level security;
245
+
246
+ alter table public.videos add column if not exists pos int, add column if not exists ord int not null default 0;
247
+
248
+ create table public.dailies (
249
+ id bigint generated always as identity primary key,
250
+ level text not null, -- a level id, e.g. 1
251
+ title text not null,
252
+ url text not null, -- https://vimeo.com/<number>[/<hash>] or a YouTube link
253
+ created_at timestamptz not null default now()
254
+ );
255
+ alter table public.dailies enable row level security;
256
+
257
+ create table public.library (
258
+ id bigint generated always as identity primary key,
259
+ level text not null, -- a level id, e.g. 1
260
+ title text not null,
261
+ url text not null,
262
+ ord int, -- its turn in the list; none until the teacher moves a video
263
+ created_at timestamptz not null default now()
264
+ );
265
+ alter table public.library enable row level security;
266
+
267
+ alter table public.library add column if not exists ord int;
268
+ notify pgrst, 'reload schema';
269
+
270
+ create table public.texts (
271
+ id bigint generated always as identity primary key,
272
+ lesson text not null, -- the lesson id, e.g. 2 or 2.1
273
+ title text not null, -- the topic's name on the path
274
+ voices text[] not null default '{}', -- a dialogue's two voices by name (odd lines, even lines); empty = a story
275
+ lines jsonb not null, -- [[Hebrew, Arabic in Hebrew letters], ...]
276
+ pos int, -- as in videos
277
+ ord int not null default 0,
278
+ created_at timestamptz not null default now()
279
+ );
280
+ alter table public.texts enable row level security;
281
+
282
+ create table public.classes (
283
+ id bigint generated always as identity primary key,
284
+ name text not null unique, -- e.g. אוקטובר 2026
285
+ emails text[] not null default '{}', -- the students' Google emails, lower-case
286
+ nicknames jsonb not null default '{}', -- {email: nickname}, for the students who have one
287
+ lessons text[] not null default '{}', -- the lesson ids the class studies; empty = every lesson
288
+ subject text not null default 'ערבית', -- existing classes; new ones copy the creating admin
289
+ teachers text[] not null default '{}', -- class-scoped teachers; each address is also in emails
290
+ homework text not null default '', -- the topic id the class practises now, e.g. l2-talk; '' = none
291
+ due date, -- the day the homework is due, if it has one
292
+ homeworks jsonb not null default '[]', -- every homework of the class: [{"topic": ..., "due": ...}]
293
+ dailies jsonb not null default '[]', -- the days of its daily videos: [{"video": id, "date": ...}]
294
+ created_at timestamptz not null default now()
295
+ );
296
+ alter table public.classes enable row level security;
297
+
298
+ alter table public.classes add column if not exists nicknames jsonb not null default '{}',
299
+ add column if not exists lessons text[] not null default '{}',
300
+ add column if not exists homework text not null default '',
301
+ add column if not exists due date,
302
+ add column if not exists homeworks jsonb not null default '[]',
303
+ add column if not exists teachers text[] not null default '{}',
304
+ add column if not exists dailies jsonb not null default '[]',
305
+ add column if not exists subject text not null default 'ערבית';
306
+ update public.classes set subject = 'ערבית' where subject = '';
307
+
308
+ -- the tutor's conversations (app/tutor.py)
309
+ create table public.tutor_chats (id uuid primary key, user_id uuid, email text not null default '', scenario text not null, gender text not null default '', messages jsonb not null default '[]'::jsonb, turns int not null default 0, finished boolean not null default false, model text not null default '', input_tokens int not null default 0, output_tokens int not null default 0, created_at timestamptz not null default now(), updated_at timestamptz not null default now()); alter table public.tutor_chats enable row level security;
310
+
311
+ -- mathly: a class is a math class unless it says otherwise
312
+ alter table public.classes alter column subject set default 'מתמטיקה';
313
+ notify pgrst, 'reload schema';
static/index.html CHANGED
@@ -1,14 +1,14 @@
1
  <!doctype html>
2
- <html lang="he" dir="rtl" data-subject="__SUBJECT__">
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
- <meta name="theme-color" content="#4B6A2C">
7
  <meta name="mobile-web-app-capable" content="yes">
8
  <meta name="apple-mobile-web-app-capable" content="yes">
9
  <meta name="apple-mobile-web-app-status-bar-style" content="default">
10
- <meta name="apple-mobile-web-app-title" content="אהלא וסהלא">
11
- <link rel="manifest" href="/static/manifest.webmanifest">
12
  <link rel="icon" href="/static/icons/icon-192.png?v=2" sizes="192x192">
13
  <link rel="apple-touch-icon" href="/static/icons/icon-192.png?v=2">
14
  <title>__TITLE__</title>
@@ -740,10 +740,10 @@ body.manage #manage{width:100%;max-width:none}
740
  .modes button{display:flex;flex-direction:column;align-items:center;gap:2px;padding:8px 6px;border:0;border-radius:10px;background:transparent;color:var(--ink);font:700 16px/1.2 var(--f-body);cursor:pointer}
741
  .modes button small{font:600 12px/1.2 var(--f-body);color:var(--muted)}
742
  .modes button[aria-selected=true]{background:var(--surface);box-shadow:0 1px 2px rgba(0,0,0,.15)}
743
- #manage.classes :is(#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.classes) #mClasses{display:none}
744
- #manage.content :is(#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.content) #mContent{display:none}
745
- #manage.flags :is(#mClasses,#mContent,#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.flags) #mFlags{display:none}
746
- #manage.access :is(#mClasses,#mContent,#mFlags,#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.access) #mAccess{display:none}
747
  .access-actions{display:flex;justify-content:flex-end;margin:8px 0}.access-scroll{overflow:auto}.access-table{width:100%;border-collapse:collapse;min-width:1180px}.access-table :is(th,td){padding:9px 10px;border-bottom:1px solid var(--line);text-align:center}.access-table th{position:sticky;top:0;background:var(--surface);font-size:13px;white-space:nowrap;z-index:1}.access-table th>span{display:block;margin-bottom:4px}.access-table .access-all{display:inline-flex;align-items:center;gap:4px;color:var(--muted);font-weight:600;cursor:pointer}.access-table .access-all small{font-size:11px}.access-table :is(th,td):first-child{text-align:start;position:sticky;right:0;background:var(--surface);z-index:2;min-width:260px}.access-table td:first-child{font-weight:700}.access-table td:first-child small{display:block;color:var(--muted);font-weight:400;direction:ltr;overflow:hidden;text-overflow:ellipsis}.access-table input{width:18px;height:18px}.access-table .save-cell{min-width:96px}
748
  @media(max-width:700px){.modes{grid-template-columns:repeat(2,1fr)}}
749
  .flag-review{gap:10px}.flag-review h2{margin:0}.flag-review .meta{color:var(--muted);font-size:13px}.flag-review fieldset{border:0;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:7px}
@@ -875,17 +875,12 @@ body.manage #manage{width:100%;max-width:none}
875
  #mClasses>.btn{align-self:flex-start}
876
  #mClasses .card>.btn{align-self:flex-start}
877
  /* a student's own screen takes the class's place in the panel: the way back, their figures, their lessons, their sentences */
878
- #manage.student :is(#mTutors,#mTabs,#mHead,#mLessons,#mRoster),#manage:not(.student) #mStudent{display:none}
879
  #mStudent{display:flex;flex-direction:column;gap:inherit}
880
  #mStudent .back{align-self:flex-start}
881
  .student-classes{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:10px}
882
  .student-classes>span{color:var(--muted);font-size:13px}
883
  .student-classes .tab{padding:7px 11px;font-size:13px}
884
- #mTutors .note{margin:0;padding:8px 16px}
885
- #mTutors label{display:flex;align-items:center;gap:10px;padding:7px 16px;border-top:1px solid var(--line);cursor:pointer}
886
- #mTutors label span{display:flex;flex-direction:column;min-width:0}
887
- #mTutors label b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
888
- #mTutors label small{color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
889
  .cform{display:flex;flex-direction:column;gap:10px}
890
  .cform label{display:flex;flex-direction:column;gap:4px;font:700 14px/1.3 var(--f-body)}
891
  .cform label small{font-weight:500;color:var(--muted)}
@@ -1226,10 +1221,16 @@ html[data-subject="arabic"] #mailForm{animation-delay:6.55s}
1226
  .install-actions{display:flex;gap:8px;justify-content:flex-end}
1227
  .install-actions button{padding:9px 16px;border-radius:10px;border:1px solid var(--olive);background:var(--surface);color:var(--olive);font:700 15px/1.2 var(--f-body);cursor:pointer}
1228
  .install-actions .primary{background:var(--olive);color:var(--on-olive)}
1229
- /* The math class (/math) opens on its own logo, with the buttons in its blue. */
 
 
1230
  html[data-subject="math"] :is(#startBtn,#loginBtn,#roles button){background:#2F5597}
1231
- html[data-subject="math"] #roles :is(#libraryBtn,#manageBtn,#labBtn){background:#fff;color:#2F5597;box-shadow:inset 0 0 0 2px #2F5597}
1232
- html[data-subject="math"] #mailForm button{border-color:#2F5597;color:#2F5597}
 
 
 
 
1233
  @keyframes start-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
1234
  @media (prefers-reduced-motion:reduce){#startBtn,#loginBtn,#roles button,#mailForm{animation:none;opacity:1}#logo-splash{transition:none}}
1235
  #wrap{opacity:1;transition:opacity .65s ease .1s}
@@ -1284,7 +1285,7 @@ html[data-subject="math"] #mailForm button{border-color:#2F5597;color:#2F5597}
1284
  <span class="reward-totals"><span class="reward-week" id="rewardWeek">0/4 השבוע</span><span class="reward-total" id="rewardTotal">0 ★</span></span>
1285
  </button>
1286
  <section id="vids" aria-live="polite"></section>
1287
- <nav id="nav" aria-label="ניווט"><button data-view="">מסלול</button><button data-view="live" hidden>שיעור חי</button><button data-view="verbs">פעלים</button><button data-view="games" hidden>משחקים</button><button data-view="refer">חבר מביא חבר</button></nav>
1288
  <nav class="path" id="path" aria-label="מסלול הלימוד"></nav>
1289
  <section class="live-page" id="liveView" aria-live="polite"></section>
1290
  <button id="toNow" hidden></button>
@@ -1368,7 +1369,6 @@ html[data-subject="math"] #mailForm button{border-color:#2F5597;color:#2F5597}
1368
  <div id="mContent"></div>
1369
  <div id="mFlags"></div>
1370
  <div class="card" id="mAccess"></div>
1371
- <div class="card hard" id="mTutors" hidden></div>
1372
  <div class="tabs" id="mTabs" role="tablist" aria-label="סינון תלמידים לפי כיתה" hidden></div>
1373
  <div class="card" id="mHead" hidden></div>
1374
  <div class="card hard" id="mLessons" hidden></div>
@@ -1469,7 +1469,7 @@ html[data-subject="math"] #mailForm button{border-color:#2F5597;color:#2F5597}
1469
  <fieldset class="lpick"><legend>סרטונים יומיים<small>הסרטונים של הרמות שהכיתה לומדת, מהספרייה שתחת „תוכן”. קבעו תאריך לסרטון, ומאותו יום התלמידים רואים אותו בכפתור „סרטון יומי” שמעל המסלול, והוא מסומן עד שצופים בו. סרטון בלי תאריך לא מוצג לכיתה.</small></legend><div class="dailies" id="editDailies"></div></fieldset>
1470
  <label>התלמידים<small>שורה לכל תלמיד: כתובת הדוא"ל שבה הוא נכנס, ואחריה כינוי אם רוצים. בכיתה רואים את הכינוי, או את השם מ-Google. תלמיד יכול להיות בכמה כיתות.</small>
1471
  <textarea id="editEmails" spellcheck="false" placeholder="dana@gmail.com דנה&#10;avi@gmail.com"></textarea></label>
1472
- <fieldset class="lpick"><legend>מורי הכיתה<small>בחרו אחד או יותר מתלמידי הכיתה. ההרשאה היא לכיתה הזו בלבד.</small></legend><div class="teacher-picks" id="editTeachers"></div></fieldset>
1473
  <div class="row"><button class="btn primary" id="editSave"></button><span class="sub" id="editCount"></span><span class="sep"></span><button class="btn danger" type="button" id="editDelete">מחיקת הכיתה</button><span class="msg" id="editMsg" role="alert"></span></div>
1474
  </form>
1475
  </dialog>
@@ -1739,7 +1739,7 @@ const store={get(k,d){try{const v=localStorage.getItem(k);return v?JSON.parse(v)
1739
  let driving=false;
1740
  /* LESSONS and CATS are the student's: the lessons of their classes (all of them for a teacher, or before the first class) */
1741
  let TEACHER_DAILIES=[]; // for a teacher: the daily videos of every class, {id, title, embed, date}
1742
- let ME_EMAIL=""; // the signed-in account: in the lab, who records
1743
  let MY_CLASSES=[], MY_CLASS=null; // a student's classes ({name, lessons, homeworks, dailies}), and the one they practise in now
1744
  let daily=null, dailySaid=new Set(), dailyWas={}; // the daily session on the card: its sentence ids, those said in it, and their best scores before it
1745
  let ALL_LEVELS=[], ALL_LESSONS=[], ALL_CATS=[], GAMES=[], MY_LESSONS=null, LESSONS=[], CATS=[], PHRASES=[], cat=store.get("ihki.cat",""), best=store.get("ihki.best2",{}), order=[], pos=0, cur=null;
@@ -2440,14 +2440,14 @@ function showView(view){ // "" is the path, which is laid out again: it was no
2440
  if(view) document.body.dataset.view=view; else delete document.body.dataset.view;
2441
  scrollTo(0,0); drawPath(true); if(view==="live") loadLive();
2442
  }
2443
- const LIVE_TAB_ENABLED=false; // Keep the finished live-class flow hidden until it is ready for release.
2444
  let LIVE={sessions:[],teaches:[],code:"",timer:null,rec:null,busy:false};
2445
  const liveEsc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
2446
  const livePhrase=p=>`${liveEsc(p.he)} · ${liveEsc((ALL_CATS.find(c=>c.id===p.cat)||{}).name||"")}`;
2447
  function liveSchedule(){ clearTimeout(LIVE.timer); if(document.body.dataset.view==="live"&&LIVE.code) LIVE.timer=setTimeout(()=>loadLiveSession(),3000); }
2448
  async function loadLive(silent=false){
2449
  try{
2450
- const res=await api("/api/live"), data=await res.json(); if(!res.ok) throw new Error(data.detail||res.status);
2451
  LIVE.sessions=data.sessions||[]; LIVE.teaches=data.teaches||[];
2452
  const tab=$("nav").querySelector('[data-view="live"]'); tab.hidden=!LIVE_TAB_ENABLED; tab.classList.toggle("live-on",LIVE_TAB_ENABLED&&LIVE.sessions.length>0);
2453
  if(!silent&&document.body.dataset.view==="live"&&!LIVE.code) drawLiveHome();
@@ -2480,7 +2480,7 @@ function openLive(code,teacher){ LIVE.code=code; LIVE.teacher=teacher; loadLiveS
2480
  async function loadLiveSession(){
2481
  if(!LIVE.code||LIVE.busy) return liveSchedule();
2482
  try{
2483
- const res=await api(`/api/live/${LIVE.code}`), data=await res.json().catch(()=>({}));
2484
  if(res.status===404){ LIVE.code=""; await loadLive(); return; }
2485
  if(!res.ok) throw new Error(data.detail||res.status);
2486
  if(LIVE.teacher) drawLiveTeacher(data.session); else drawLiveStudent(data.session);
@@ -4415,7 +4415,7 @@ async function loadManage(){
4415
  const detail=(await res.json().catch(()=>({}))).detail||res.status;
4416
  state.textContent=res.status===403?"הפאנל פתוח למורים בלבד.":`נתוני התלמידים לא זמינים כרגע (${detail}).`; return;
4417
  }
4418
- CLASS=await res.json(); await loadContent();
4419
  if(mMode==="flags") await loadFlags();
4420
  if(mMode==="access") await loadAccess();
4421
  TEACHER_DAILIES=CLASS.classes.flatMap(classDailies).map(d=>({...d.v,date:d.date})).sort((a,b)=>a.date.localeCompare(b.date)); // as just saved; the earliest day of each comes first
@@ -4531,7 +4531,7 @@ function drawManage(){
4531
  if(!["classes","students","content","flags","access"].includes(mMode)) mMode=classes.length?"students":"classes";
4532
  $("mModes").hidden=false; for(const mode of ["classes","content","flags","access"]) $("manage").classList.toggle(mode,mMode===mode);
4533
  for(const b of $("mModes").children) b.setAttribute("aria-selected",b.dataset.mode===mMode);
4534
- drawClasses(classes); drawContent(); drawFlags(); drawAccess(); drawTutors(); drawTabs(classes); drawHead(klass,students);
4535
  if(klass||!classes.length) drawLessons(students,listed); else{ $("mLessons").innerHTML=""; $("mLessons").hidden=true; }
4536
  drawRoster(klass,students,listed);
4537
  const one=students.find(s=>s.email===shownStudent); // a student who left the class has no screen in it
@@ -4639,6 +4639,7 @@ function drawFlags(){
4639
  const box=$("mFlags"); box.innerHTML=""; for(const url of flagAudioUrls) URL.revokeObjectURL(url); flagAudioUrls=[];
4640
  drawActivity(box);
4641
  drawAdminSubjects(box);
 
4642
  drawApiKeys(box);
4643
  drawSystemActivity(box);
4644
  drawTtsCacheControls(box);
@@ -4754,6 +4755,54 @@ function drawFlags(){
4754
  }
4755
  }
4756
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4757
  /* Developer-only: the subject stamped onto every class an admin creates. The API enforces the same boundary. */
4758
  function drawAdminSubjects(box){
4759
  if(!can("*")) return;
@@ -4764,8 +4813,11 @@ function drawAdminSubjects(box){
4764
  if(data.setup){ const code=mark("",data.setup,"code"); code.dir="ltr"; list.append(mark("setup","יש ליצור את טבלת ההרשא��ת:","p"),code); return; }
4765
  if(!(data.admins||[]).length){ list.append(mark("sub","אין כרגע מנהלים.","p")); return; }
4766
  for(const admin of data.admins){
4767
- const row=mark("key-row",""), who=document.createElement("div"), form=document.createElement("form"), input=document.createElement("input"), save=mark("btn primary","שמירה","button"), msg=mark("msg","");
4768
- who.append(mark("",admin.name||admin.email,"b"),mark("",admin.email,"small")); input.value=admin.subject||""; input.maxLength=80; input.required=true; input.placeholder="למשל: מתמטיקה או ערבית"; input.setAttribute("aria-label",`המקצוע של ${admin.name||admin.email}`); save.type="submit";
 
 
 
4769
  form.onsubmit=async e=>{ e.preventDefault(); save.disabled=true; msg.textContent="שומר…"; try{ const res=await api(`/api/admin-subjects/${admin.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({subject:input.value})}), data=await res.json().catch(()=>({})); if(!res.ok) throw new Error(data.detail||res.status); admin.subject=data.subject; input.value=data.subject; msg.textContent="נשמר"; }catch(err){ msg.textContent="השמירה נכשלה: "+err.message; } save.disabled=false; };
4770
  form.append(input,save,msg); row.append(who,form); list.append(row);
4771
  }
@@ -4983,6 +5035,16 @@ function drawActivityOnly(){
4983
  }
4984
  const lessonsLine=ls=>byLevel(ls).map(g=>`${g.level.name}: ${lessonsWord(g.lessons)} (${g.lessons.filter(l=>!l.soon).length} מוכנים)`).join(" · "); // "שלב א: שיעורים 1–10 (2 מוכנים)"
4985
  /* The classes' part: a card per class with what it is made of, and its buttons: edit, delete, and on to its students' progress. */
 
 
 
 
 
 
 
 
 
 
4986
  function drawClasses(classes){
4987
  const box=$("mClasses"); box.innerHTML="";
4988
  if(!classes.length){
@@ -4995,15 +5057,17 @@ function drawClasses(classes){
4995
  const card=mark("card",""), top=mark("chead",""), name=mark("","","h2"), n=document.createElement("bdi"), edit=mark("btn","עריכה","button"), del=mark("btn danger","מחיקה","button");
4996
  n.textContent=c.name; name.append(n); edit.onclick=()=>editClass(c); del.onclick=()=>deleteClass(c); top.append(name,edit,del);
4997
  const facts=mark("facts","","dl"), fact=(what,...value)=>{ const dd=document.createElement("dd"); dd.append(...value); facts.append(mark("",what,"dt"),dd); };
 
4998
  fact("מקצוע",c.subject||"לא הוגדר");
4999
- fact("שיעורים",lessonsLine(ALL_LESSONS.filter(l=>c.lessons.includes(l.id))));
 
5000
  const mine=CLASS.students.filter(s=>c.name in s.classes);
5001
  fact("תלמידים",...(mine.length?[`${mine.length}: `,...mine.flatMap((s,k)=>{ const b=document.createElement("bdi"); b.textContent=s.classes[c.name]||s.name||s.email; return k?[", ",b]:[b]; })]:["עוד אין"]));
5002
  const teachers=(c.teachers||[]).map(e=>{ const s=mine.find(x=>x.email.toLowerCase()===e); return s&&(s.classes[c.name]||s.name)||e; });
5003
  fact("מורים",teachers.length?teachers.join(" · "):"עוד לא נבחרו");
5004
  const hws=hwOf(c,ALL_CATS).map(h=>mark("",[h.c.name,lessonName(ALL_LESSONS.find(l=>l.id===h.c.lesson)),dueText(h.due).text].filter(Boolean).join(" · "),"div"));
5005
- fact("שיעורי בית",...(hws.length?hws:["אין"]));
5006
- const days=classDailies(c);
5007
  if(days.length) fact("סרטונים יומיים",...days.map(d=>mark("",`${d.v.title} · ${dayWord(d.date)}`,"div")));
5008
  const go=mark("btn","התקדמות התלמידים ‹","button"); go.onclick=()=>{ shownClass=c.name; store.set("ihki.mstudentClass",c.name); shownStudent=""; showMode("students"); scrollTo(0,0); };
5009
  card.append(top,facts,go); box.append(card);
@@ -5200,33 +5264,6 @@ addEventListener("popstate",e=>{
5200
  const to=e.state&&e.state.student||""; if(to===shownStudent||!CLASS) return;
5201
  shownStudent=to; drawManage(); scrollTo(0,to?0:classScroll);
5202
  });
5203
- /* Above the classes' tabs: every student of every class, each with the tutor's rank to tick. The rank is the student's
5204
- whatever class they are in: one who has it gets the flag under each score, and 🚩 beside their name in the lists. */
5205
- let tutorsOpen=false;
5206
- function drawTutors(){
5207
- const box=$("mTutors"), all=[...CLASS.students].sort((a,b)=>(a.name||a.email).localeCompare(b.name||b.email,"he"));
5208
- box.innerHTML=""; box.hidden=!all.length; if(!all.length) return;
5209
- const sec=part(box), said=mark("note","מי שמסומן רואה דגל 🚩 מתחת לכל ציון, ומדווח כשאמר נכון והמערכת טעתה. הדרגה חלה בכל הכיתות של התלמיד/ה.","p");
5210
- sec.open=tutorsOpen; sec.ontoggle=()=>{ tutorsOpen=sec.open; };
5211
- sec.firstChild.append(mark("","🚩 מדווחים על ציונים שגויים"),mark("",`${all.filter(s=>s.tutor).length} מתוך ${all.length} תלמידים`,"small"));
5212
- sec.append(said);
5213
- for(const s of all){
5214
- const row=document.createElement("label"), tick=document.createElement("input"), who=mark("",""), name=mark("",Object.values(s.classes).find(Boolean)||s.name||s.email,"b"), mail=document.createElement("bdi");
5215
- name.dir="auto"; mail.dir="ltr"; mail.textContent=s.email;
5216
- const more=mark("","","small"); more.append(mail,[...Object.keys(s.classes).map(c=>" · "+c),s.tutor_fixed?" · נקבע בהגדרות השרת":""].join(""));
5217
- who.append(name,more);
5218
- tick.type="checkbox"; tick.checked=!!s.tutor; tick.disabled=!!s.tutor_fixed;
5219
- tick.onchange=async()=>{
5220
- const on=tick.checked; tick.disabled=true;
5221
- try{
5222
- const res=await api("/api/tutors",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:s.email,tutor:on})});
5223
- if(!res.ok) throw new Error((await res.json().catch(()=>({}))).detail||res.status);
5224
- s.tutor=on; drawManage();
5225
- }catch(err){ tick.checked=!on; tick.disabled=false; said.textContent="הדרגה לא נשמרה: "+err.message; }
5226
- };
5227
- row.append(tick,who); sec.append(row);
5228
- }
5229
- }
5230
  function drawTabs(classes){
5231
  const tabs=$("mTabs"); tabs.innerHTML=""; tabs.hidden=!classes.length;
5232
  const all=mark("tab","כל התלמידים","button"); all.setAttribute("role","tab"); all.setAttribute("aria-selected",shownClass==="*");
@@ -5263,7 +5300,7 @@ function drawHead(klass,students){ // the class's name and its figures; before
5263
  }
5264
  top.append(mark("","כל התלמידים","h2"),mark("","כל תלמיד מופיע פעם אחת. לבדיקת שיעורי בית והתקדמות מאז פתיחת כיתה, בחרו כיתה במסנן.","p"));
5265
  }else{
5266
- top.append(mark("",klass.name,"h2"),mark("",lessonsLine(ALL_LESSONS.filter(l=>klass.lessons.includes(l.id))),"p"));
5267
  }
5268
  head.append(top);
5269
  const tiles=mark("tiles",""), tile=(n,what)=>{ const t=mark("tile",""), b=mark("",n,"b"); b.dir="ltr"; t.append(b,mark("",what,"small")); tiles.append(t); };
@@ -5437,26 +5474,36 @@ function editTeacherChoices(){
5437
  }
5438
  function editClass(c){
5439
  editing=c;
 
5440
  editTeacherSet=new Set(c&&c.teachers||[]);
5441
  $("editTitle").textContent=c?`עריכת ${c.name}`:"כיתה חדשה";
5442
  $("editName").value=c?c.name:"";
 
 
 
 
 
5443
  // the lessons by level: a level's box ticks all of it, its chips one lesson each; a new class starts with the first level
5444
- const pick=$("editLessons"), chosen=c?c.lessons:ALL_LESSONS.filter(l=>l.level===(ALL_LEVELS[0]||{}).id).map(l=>l.id);
 
 
5445
  pick.innerHTML="";
5446
- for(const v of ALL_LEVELS){
 
 
5447
  const row=mark("lvl",""), head=mark("lvlhead","","label"), all=document.createElement("input"), chips=mark("chips",""), boxes=[];
5448
- all.type="checkbox"; head.append(all,v.name); row.append(head,chips); pick.append(row);
5449
- for(const l of ALL_LESSONS.filter(l=>l.level===v.id)){
5450
- const box=document.createElement("input"), lab=mark(l.soon?"soon":"","","label");
5451
- box.type="checkbox"; box.value=l.id; box.checked=chosen.includes(l.id); boxes.push(box);
5452
- lab.append(box,String(l.n)); lab.title=l.soon?`${l.name}: עוד לא מוכן, ייפתח לכיתה כשיהיה`:l.name; chips.append(lab);
5453
  }
5454
  const sync=()=>{ const n=boxes.filter(b=>b.checked).length; all.checked=n===boxes.length; all.indeterminate=n>0&&n<boxes.length; };
5455
- all.onchange=()=>{ boxes.forEach(b=>b.checked=all.checked); sync(); dailyRows($("editDailies"),editLevels(),editDays); };
5456
- boxes.forEach(b=>b.onchange=()=>{ sync(); dailyRows($("editDailies"),editLevels(),editDays); }); sync();
5457
  }
5458
- editDays=Object.fromEntries((c&&c.dailies||[]).map(d=>[d.video,d.date])); dailyRows($("editDailies"),editLevels(),editDays);
5459
- $("editHws").replaceChildren(); for(const h of hwOf(c,ALL_CATS)) hwRow(h.c.id,h.due);
5460
  $("editEmails").value=c?c.emails.map(e=>[e,c.nicknames&&c.nicknames[e]].filter(Boolean).join(" ")).join("\n"):"";
5461
  $("editSave").textContent=c?"שמירה":"פתיחת הכיתה"; $("editDelete").hidden=!c; $("editMsg").textContent="";
5462
  countStudents(); editTeacherChoices(); $("editDlg").showModal(); (c?$("editEmails"):$("editName")).focus();
@@ -5498,12 +5545,14 @@ async function sendClass(method,body){
5498
  }
5499
  $("editForm").onsubmit=e=>{
5500
  e.preventDefault();
5501
- const lessons=[...$("editLessons").querySelectorAll(".chips :checked")].map(b=>b.value);
5502
- if(!lessons.length){ $("editMsg").textContent="בחרו לפחות שיעור אחד."; return; }
 
 
5503
  const homeworks=hwRows();
5504
  if(homeworks.some(h=>!lessons.includes((ALL_CATS.find(c=>c.id===h.topic)||{}).lesson))){ $("editMsg").textContent="שיעורי הבית הם נושאים משיעורי הכיתה: סמנו את השיעור שלהם למעלה, או הסירו אותם."; return; }
5505
  if(new Set(homeworks.map(h=>h.topic)).size<homeworks.length){ $("editMsg").textContent="אותו נושא מופיע פעמיים בשיעורי הבית."; return; }
5506
- sendClass(editing?"PUT":"POST",{name:$("editName").value,emails:$("editEmails").value,teachers:[...editTeacherSet],lessons,homeworks,dailies:daysOf(editDays,editLevels())});
5507
  };
5508
  $("editDelete").onclick=()=>{
5509
  if(confirm(`למחוק את הכיתה ${editing.name}? התלמידים שלה לא יוכלו להיכנס עוד. ההקלטות וההתקדמות שלהם נשמרות.`)) sendClass("DELETE");
@@ -5511,17 +5560,25 @@ $("editDelete").onclick=()=>{
5511
 
5512
  /* A new class is opened in steps, a question each: its name, its level (the class studies the level's lessons), its
5513
  students with their nicknames, its homework, and a summary to confirm, which says where all of it is changed later. */
5514
- const WIZ=["שם הכיתה","רמה","תלמידים","שיעורי בית וסרטונים","אישור"], ADDRESS=/[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+/g;
5515
  let wiz=null; // {step, students: [{email, name, on, nick}]}: the accounts the panel knows, and those added here
5516
  const wizName=()=>$("newName").value.trim().replace(/\s+/g," ");
5517
  const wizLevel=()=>ALL_LEVELS.find(v=>v.id===($("newLevels").querySelector(":checked")||{}).value)||ALL_LEVELS[0];
5518
  const wizLessons=()=>ALL_LESSONS.filter(l=>l.level===wizLevel().id);
5519
  const wizWho=s=>s.nick.trim()||s.name||s.email;
 
 
5520
  function newClass(){
5521
- wiz={step:0,days:{},students:CLASS.students.map(s=>({email:s.email.toLowerCase(),name:s.name,on:false,nick:""}))};
5522
  $("newName").value=$("newEmail").value=$("newDue").value=""; $("newHw").innerHTML="";
5523
- const levels=$("newLevels"); levels.innerHTML="";
5524
- ALL_LEVELS.forEach((v,k)=>{
 
 
 
 
 
 
5525
  const lab=document.createElement("label"), box=document.createElement("input"), text=document.createElement("span"), ls=ALL_LESSONS.filter(l=>l.level===v.id);
5526
  box.type="radio"; box.name="newLevel"; box.value=v.id; box.checked=!k;
5527
  text.append(v.name,mark("",`${lessonsWord(ls)} · ${ls.filter(l=>!l.soon).length} מוכנים`,"small")); lab.append(box,text); levels.append(lab);
@@ -5531,9 +5588,10 @@ function newClass(){
5531
  function wizStep(n){
5532
  wiz.step=n; $("newMsg").textContent="";
5533
  [...$("newForm").querySelectorAll("section")].forEach((s,k)=>s.hidden=k!==n);
5534
- $("newStep").textContent=`שלב ${n+1} מתוך ${WIZ.length}: ${WIZ[n]}`;
5535
- $("newDots").replaceChildren(...WIZ.map((_,k)=>mark(k<=n?"on":"","","i")));
5536
- $("newBack").hidden=!n; $("newNext").textContent=n===WIZ.length-1?"פתיחת הכיתה":"המשך";
 
5537
  if(n===3) wizHomework();
5538
  if(n===4) wizSummary();
5539
  }
@@ -5583,15 +5641,16 @@ function wizSummary(){
5583
  const line=(what,...value)=>{ const dd=document.createElement("dd"); dd.append(...value); sum.append(mark("",what,"dt"),dd); };
5584
  sum.innerHTML="";
5585
  const name=document.createElement("bdi"); name.textContent=wizName(); line("שם",name);
5586
- line("רמה",`${level.name}, ${lessonsWord(wizLessons())}`);
 
5587
  line("תלמידים",...(picked.length?[`${picked.length}: `,...picked.flatMap((s,k)=>{ const b=document.createElement("bdi"); b.textContent=wizWho(s); return k?[", ",b]:[b]; })]:["עוד אין. אפשר להוסיף אחר כך."]));
5588
- line("שיעורי בית",topic?[topic.name,ALL_LESSONS.find(l=>l.id===topic.lesson).name,dueText($("newDue").value).text].filter(Boolean).join(" · "):"אין בינתיים");
5589
- const days=daysOf(wiz.days,[level.id]); if(days.length) line("סרטונים יומיים",`${days.length} עם תאריך`);
5590
  const after=$("newAfter"); after.innerHTML="";
5591
  after.append(mark("","מה קורה אחרי הפתיחה?","b"),"הכיתה מופיעה ברשימה תחת „כיתות”, והתלמידים שלה רואים את השיעורים במסלול. את ההתקדמות שלהם רואים תחת „תלמידים”. שום דבר כאן לא סופי: ליד כל כיתה ברשימה יש כפתור „עריכה”, שבו משנים את השם, השיעורים, התלמידים והכינויים ושיעורי הבית והתאריכים של הסרטונים היומיים, וכפתור „מחיקה”.");
5592
  if(!CLASS.classes.length) after.append(mark("","שימו לב: זו הכיתה הראשונה. מרגע שהיא נפתחת, נכנסים לאפליקציה רק התלמידים שבכיתות והמורים.","b"));
5593
  }
5594
- $("newBack").onclick=()=>wizStep(wiz.step-1);
5595
  $("newClose").onclick=()=>$("newDlg").close();
5596
  $("newForm").onsubmit=async e=>{ // "next" checks the step on show; on the last one the class is opened
5597
  e.preventDefault();
@@ -5600,14 +5659,16 @@ $("newForm").onsubmit=async e=>{ // "next" checks the step on show; on the las
5600
  const taken=CLASS.classes.find(c=>c.name.replace(/\s+/g," ").trim().toLowerCase()===name.toLowerCase());
5601
  if(!name||taken){ msg.textContent=name?`כבר יש כיתה בשם ${taken.name}. בחרו שם אחר.`:"כתבו שם לכיתה."; $("newName").focus(); return; }
5602
  }
 
5603
  if(n===2&&!wizAdd()) return; // an address still in the field joins too
5604
- if(n<WIZ.length-1){ wizStep(n+1); return; }
5605
  const homework=$("newHw").value, buttons=$("newForm").querySelectorAll("button");
5606
  const emails=wiz.students.filter(s=>s.on).map(s=>`${s.email} ${s.nick.replace(/[\s@]+/g," ").trim()}`.trim()).join("\n");
5607
  msg.textContent=""; buttons.forEach(b=>b.disabled=true);
5608
  try{
5609
  const res=await api("/api/classes",{method:"POST",headers:{"Content-Type":"application/json"},
5610
- body:JSON.stringify({name,emails,lessons:wizLessons().map(l=>l.id),homework,due:homework?$("newDue").value:"",dailies:daysOf(wiz.days,[wizLevel().id])})});
 
5611
  if(res.ok){ shownClass=name; store.set("ihki.mstudentClass",name); $("newDlg").close(); loadManage(); }
5612
  else{
5613
  const detail=(await res.json().catch(()=>({}))).detail||`הפתיחה נכשלה (${res.status})`;
@@ -6698,6 +6759,9 @@ if(AUTH_URL){
6698
  const res=await Promise.race([api("/api/me").catch(()=>null),new Promise(r=>setTimeout(()=>r(null),10000))]); // a session or server that hangs leaves the logo alone on the screen
6699
  const ok=!res||res.status!==403; // a server that cannot be reached is not a refusal
6700
  const me=res&&res.ok?await res.json().catch(()=>({})):{}, teacher=!!me.teacher;
 
 
 
6701
  ME_PERMISSIONS=new Set(me.permissions||[]); ME_ROLES=me.roles||[]; ME_ENTITLEMENTS=me.entitlements||[];
6702
  $("nav").querySelector('[data-view=""]').hidden=!can("path.use");
6703
  $("nav").querySelector('[data-view="verbs"]').hidden=!can("games.use");
@@ -6744,5 +6808,6 @@ if("serviceWorker" in navigator){
6744
  addEventListener("load",()=>navigator.serviceWorker.register("/service-worker.js").catch(()=>{}));
6745
  }
6746
  </script>
 
6747
  </body>
6748
  </html>
 
1
  <!doctype html>
2
+ <html lang="he" dir="rtl" data-subject="__SUBJECT__" data-home="__HOME__">
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
+ <meta name="theme-color" content="__THEME_COLOR__">
7
  <meta name="mobile-web-app-capable" content="yes">
8
  <meta name="apple-mobile-web-app-capable" content="yes">
9
  <meta name="apple-mobile-web-app-status-bar-style" content="default">
10
+ <meta name="apple-mobile-web-app-title" content="__APP_NAME__">
11
+ <link rel="manifest" href="__MANIFEST__">
12
  <link rel="icon" href="/static/icons/icon-192.png?v=2" sizes="192x192">
13
  <link rel="apple-touch-icon" href="/static/icons/icon-192.png?v=2">
14
  <title>__TITLE__</title>
 
740
  .modes button{display:flex;flex-direction:column;align-items:center;gap:2px;padding:8px 6px;border:0;border-radius:10px;background:transparent;color:var(--ink);font:700 16px/1.2 var(--f-body);cursor:pointer}
741
  .modes button small{font:600 12px/1.2 var(--f-body);color:var(--muted)}
742
  .modes button[aria-selected=true]{background:var(--surface);box-shadow:0 1px 2px rgba(0,0,0,.15)}
743
+ #manage.classes :is(#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.classes) #mClasses{display:none}
744
+ #manage.content :is(#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.content) #mContent{display:none}
745
+ #manage.flags :is(#mClasses,#mContent,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.flags) #mFlags{display:none}
746
+ #manage.access :is(#mClasses,#mContent,#mFlags,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.access) #mAccess{display:none}
747
  .access-actions{display:flex;justify-content:flex-end;margin:8px 0}.access-scroll{overflow:auto}.access-table{width:100%;border-collapse:collapse;min-width:1180px}.access-table :is(th,td){padding:9px 10px;border-bottom:1px solid var(--line);text-align:center}.access-table th{position:sticky;top:0;background:var(--surface);font-size:13px;white-space:nowrap;z-index:1}.access-table th>span{display:block;margin-bottom:4px}.access-table .access-all{display:inline-flex;align-items:center;gap:4px;color:var(--muted);font-weight:600;cursor:pointer}.access-table .access-all small{font-size:11px}.access-table :is(th,td):first-child{text-align:start;position:sticky;right:0;background:var(--surface);z-index:2;min-width:260px}.access-table td:first-child{font-weight:700}.access-table td:first-child small{display:block;color:var(--muted);font-weight:400;direction:ltr;overflow:hidden;text-overflow:ellipsis}.access-table input{width:18px;height:18px}.access-table .save-cell{min-width:96px}
748
  @media(max-width:700px){.modes{grid-template-columns:repeat(2,1fr)}}
749
  .flag-review{gap:10px}.flag-review h2{margin:0}.flag-review .meta{color:var(--muted);font-size:13px}.flag-review fieldset{border:0;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:7px}
 
875
  #mClasses>.btn{align-self:flex-start}
876
  #mClasses .card>.btn{align-self:flex-start}
877
  /* a student's own screen takes the class's place in the panel: the way back, their figures, their lessons, their sentences */
878
+ #manage.student :is(#mTabs,#mHead,#mLessons,#mRoster),#manage:not(.student) #mStudent{display:none}
879
  #mStudent{display:flex;flex-direction:column;gap:inherit}
880
  #mStudent .back{align-self:flex-start}
881
  .student-classes{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:10px}
882
  .student-classes>span{color:var(--muted);font-size:13px}
883
  .student-classes .tab{padding:7px 11px;font-size:13px}
 
 
 
 
 
884
  .cform{display:flex;flex-direction:column;gap:10px}
885
  .cform label{display:flex;flex-direction:column;gap:4px;font:700 14px/1.3 var(--f-body)}
886
  .cform label small{font-weight:500;color:var(--muted)}
 
1221
  .install-actions{display:flex;gap:8px;justify-content:flex-end}
1222
  .install-actions button{padding:9px 16px;border-radius:10px;border:1px solid var(--olive);background:var(--surface);color:var(--olive);font:700 15px/1.2 var(--f-body);cursor:pointer}
1223
  .install-actions .primary{background:var(--olive);color:var(--on-olive)}
1224
+ /* The math class (/math) opens on its own logo, on black (the logo's ink is light for it), with the buttons in its blue. */
1225
+ html[data-subject="math"] #logo-splash{background:#000}
1226
+ html[data-subject="math"] #libraryBtn{display:none} /* the digital library is Arabic videos */
1227
  html[data-subject="math"] :is(#startBtn,#loginBtn,#roles button){background:#2F5597}
1228
+ html[data-subject="math"] #roles :is(#libraryBtn,#manageBtn,#labBtn){background:#000;color:#7FA6F0;box-shadow:inset 0 0 0 2px #7FA6F0}
1229
+ html[data-subject="math"] #mailForm span{color:#9EA7A0}
1230
+ html[data-subject="math"] #mailForm input{background:#111;border-color:#3A403C;color:#F4F2EC}
1231
+ html[data-subject="math"] #mailForm button,html[data-subject="math"] #denied button{background:#000;border-color:#7FA6F0;color:#7FA6F0}
1232
+ html[data-subject="math"] :is(#mailMsg,#denied){color:#F4F2EC}
1233
+ html[data-subject="math"] #bootErr{color:#F08A8A}
1234
  @keyframes start-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
1235
  @media (prefers-reduced-motion:reduce){#startBtn,#loginBtn,#roles button,#mailForm{animation:none;opacity:1}#logo-splash{transition:none}}
1236
  #wrap{opacity:1;transition:opacity .65s ease .1s}
 
1285
  <span class="reward-totals"><span class="reward-week" id="rewardWeek">0/4 השבוע</span><span class="reward-total" id="rewardTotal">0 ★</span></span>
1286
  </button>
1287
  <section id="vids" aria-live="polite"></section>
1288
+ <nav id="nav" aria-label="ניווט"><button data-view="">מסלול</button><button data-view="live">שיעור חי</button><button data-view="verbs">פעלים</button><button data-view="games" hidden>משחקים</button><button data-view="refer">חבר מביא חבר</button></nav>
1289
  <nav class="path" id="path" aria-label="מסלול הלימוד"></nav>
1290
  <section class="live-page" id="liveView" aria-live="polite"></section>
1291
  <button id="toNow" hidden></button>
 
1369
  <div id="mContent"></div>
1370
  <div id="mFlags"></div>
1371
  <div class="card" id="mAccess"></div>
 
1372
  <div class="tabs" id="mTabs" role="tablist" aria-label="סינון תלמידים לפי כיתה" hidden></div>
1373
  <div class="card" id="mHead" hidden></div>
1374
  <div class="card hard" id="mLessons" hidden></div>
 
1469
  <fieldset class="lpick"><legend>סרטונים יומיים<small>הסרטונים של הרמות שהכיתה לומדת, מהספרייה שתחת „תוכן”. קבעו תאריך לסרטון, ומאותו יום התלמידים רואים אותו בכפתור „סרטון יומי” שמעל המסלול, והוא מסומן עד שצופים בו. סרטון בלי תאריך לא מוצג לכיתה.</small></legend><div class="dailies" id="editDailies"></div></fieldset>
1470
  <label>התלמידים<small>שורה לכל תלמיד: כתובת הדוא"ל שבה הוא נכנס, ואחריה כינוי אם רוצים. בכיתה רואים את הכינוי, או את השם מ-Google. תלמיד יכול להיות בכמה כיתות.</small>
1471
  <textarea id="editEmails" spellcheck="false" placeholder="dana@gmail.com דנה&#10;avi@gmail.com"></textarea></label>
1472
+ <fieldset class="lpick" id="editTeachersField" hidden><legend>מורי הכיתה<small>בחרו אחד או יותר מתלמידי הכיתה. ההרשאה היא לכיתה הזו בלבד.</small></legend><div class="teacher-picks" id="editTeachers"></div></fieldset>
1473
  <div class="row"><button class="btn primary" id="editSave"></button><span class="sub" id="editCount"></span><span class="sep"></span><button class="btn danger" type="button" id="editDelete">מחיקת הכיתה</button><span class="msg" id="editMsg" role="alert"></span></div>
1474
  </form>
1475
  </dialog>
 
1739
  let driving=false;
1740
  /* LESSONS and CATS are the student's: the lessons of their classes (all of them for a teacher, or before the first class) */
1741
  let TEACHER_DAILIES=[]; // for a teacher: the daily videos of every class, {id, title, embed, date}
1742
+ let ME_EMAIL="", ME_SUBJECT=""; // ME_SUBJECT: the account's subject as /api/me gives it ("math"), "" for none // the signed-in account: in the lab, who records
1743
  let MY_CLASSES=[], MY_CLASS=null; // a student's classes ({name, lessons, homeworks, dailies}), and the one they practise in now
1744
  let daily=null, dailySaid=new Set(), dailyWas={}; // the daily session on the card: its sentence ids, those said in it, and their best scores before it
1745
  let ALL_LEVELS=[], ALL_LESSONS=[], ALL_CATS=[], GAMES=[], MY_LESSONS=null, LESSONS=[], CATS=[], PHRASES=[], cat=store.get("ihki.cat",""), best=store.get("ihki.best2",{}), order=[], pos=0, cur=null;
 
2440
  if(view) document.body.dataset.view=view; else delete document.body.dataset.view;
2441
  scrollTo(0,0); drawPath(true); if(view==="live") loadLive();
2442
  }
2443
+ const LIVE_TAB_ENABLED=true;
2444
  let LIVE={sessions:[],teaches:[],code:"",timer:null,rec:null,busy:false};
2445
  const liveEsc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
2446
  const livePhrase=p=>`${liveEsc(p.he)} · ${liveEsc((ALL_CATS.find(c=>c.id===p.cat)||{}).name||"")}`;
2447
  function liveSchedule(){ clearTimeout(LIVE.timer); if(document.body.dataset.view==="live"&&LIVE.code) LIVE.timer=setTimeout(()=>loadLiveSession(),3000); }
2448
  async function loadLive(silent=false){
2449
  try{
2450
+ const res=await api("/api/live",{cache:"no-store"}), data=await res.json(); if(!res.ok) throw new Error(data.detail||res.status);
2451
  LIVE.sessions=data.sessions||[]; LIVE.teaches=data.teaches||[];
2452
  const tab=$("nav").querySelector('[data-view="live"]'); tab.hidden=!LIVE_TAB_ENABLED; tab.classList.toggle("live-on",LIVE_TAB_ENABLED&&LIVE.sessions.length>0);
2453
  if(!silent&&document.body.dataset.view==="live"&&!LIVE.code) drawLiveHome();
 
2480
  async function loadLiveSession(){
2481
  if(!LIVE.code||LIVE.busy) return liveSchedule();
2482
  try{
2483
+ const res=await api(`/api/live/${LIVE.code}`,{cache:"no-store"}), data=await res.json().catch(()=>({}));
2484
  if(res.status===404){ LIVE.code=""; await loadLive(); return; }
2485
  if(!res.ok) throw new Error(data.detail||res.status);
2486
  if(LIVE.teacher) drawLiveTeacher(data.session); else drawLiveStudent(data.session);
 
4415
  const detail=(await res.json().catch(()=>({}))).detail||res.status;
4416
  state.textContent=res.status===403?"הפאנל פתוח למורים בלבד.":`נתוני התלמידים לא זמינים כרגע (${detail}).`; return;
4417
  }
4418
+ CLASS=await res.json(); await Promise.all([loadContent(),loadMathUnits()]);
4419
  if(mMode==="flags") await loadFlags();
4420
  if(mMode==="access") await loadAccess();
4421
  TEACHER_DAILIES=CLASS.classes.flatMap(classDailies).map(d=>({...d.v,date:d.date})).sort((a,b)=>a.date.localeCompare(b.date)); // as just saved; the earliest day of each comes first
 
4531
  if(!["classes","students","content","flags","access"].includes(mMode)) mMode=classes.length?"students":"classes";
4532
  $("mModes").hidden=false; for(const mode of ["classes","content","flags","access"]) $("manage").classList.toggle(mode,mMode===mode);
4533
  for(const b of $("mModes").children) b.setAttribute("aria-selected",b.dataset.mode===mMode);
4534
+ drawClasses(classes); drawContent(); drawFlags(); drawAccess(); drawTabs(classes); drawHead(klass,students);
4535
  if(klass||!classes.length) drawLessons(students,listed); else{ $("mLessons").innerHTML=""; $("mLessons").hidden=true; }
4536
  drawRoster(klass,students,listed);
4537
  const one=students.find(s=>s.email===shownStudent); // a student who left the class has no screen in it
 
4639
  const box=$("mFlags"); box.innerHTML=""; for(const url of flagAudioUrls) URL.revokeObjectURL(url); flagAudioUrls=[];
4640
  drawActivity(box);
4641
  drawAdminSubjects(box);
4642
+ drawBackupControls(box);
4643
  drawApiKeys(box);
4644
  drawSystemActivity(box);
4645
  drawTtsCacheControls(box);
 
4755
  }
4756
  }
4757
 
4758
+ let SAVED_BACKUPS=null, backupsLoading=false;
4759
+ /* Developer-only database archives kept in a private Supabase Storage bucket. */
4760
+ function drawBackupControls(box){
4761
+ if(!can("*")) return;
4762
+ const card=mark("card",""), actions=mark("row",""), create=mark("btn primary","יצירת גיבוי","button"),
4763
+ select=document.createElement("select"), restore=mark("btn","שחזור הגיבוי הנבחר","button"), download=mark("btn","הורדת עותק","button"), msg=mark("msg","");
4764
+ const fill=()=>{
4765
+ select.replaceChildren();
4766
+ if(!SAVED_BACKUPS||!SAVED_BACKUPS.length){ const o=document.createElement("option"); o.textContent=backupsLoading?"טוען גיבויים…":"אין גיבויים שמורים"; o.value=""; select.append(o); }
4767
+ else for(const b of SAVED_BACKUPS){ const o=document.createElement("option"), match=b.name.match(/(\d{4}-\d{2}-\d{2})_(\d{2})-(\d{2})-(\d{2})/), stamp=match?`${match[1]} · ${match[2]}:${match[3]}:${match[4]}`:b.name; o.value=b.name; o.textContent=stamp+(b.metadata&&b.metadata.size?` · ${Math.ceil(b.metadata.size/1024)} KB`:""); select.append(o); }
4768
+ restore.disabled=!select.value; download.disabled=!select.value;
4769
+ };
4770
+ if(SAVED_BACKUPS===null&&!backupsLoading){
4771
+ backupsLoading=true; api("/api/admin/backups").then(async res=>{ const data=await res.json().catch(()=>({})); if(!res.ok) throw new Error(data.detail||res.status); SAVED_BACKUPS=data.backups||[]; })
4772
+ .catch(err=>msg.textContent="טעינת הגיבויים נכשלה: "+err.message).finally(()=>{ backupsLoading=false; fill(); });
4773
+ }
4774
+ fill();
4775
+ create.onclick=async()=>{
4776
+ create.disabled=restore.disabled=download.disabled=true; msg.textContent="יוצר גיבוי…";
4777
+ try{ const res=await api("/api/admin/backups",{method:"POST"}), data=await res.json().catch(()=>({})); if(!res.ok) throw new Error(data.detail||res.status); const list=await api("/api/admin/backups"), listed=await list.json(); if(!list.ok) throw new Error(listed.detail||list.status); SAVED_BACKUPS=listed.backups||[]; fill(); select.value=data.name; msg.textContent="הגיבוי נשמר."; }
4778
+ catch(err){ msg.textContent=err.message.startsWith("הגיבוי נכשל")?err.message:"הגיבוי נכשל: "+err.message; }
4779
+ create.disabled=false; fill();
4780
+ };
4781
+ download.onclick=async()=>{
4782
+ download.disabled=restore.disabled=create.disabled=true; msg.textContent="מכין הורדה…";
4783
+ try{
4784
+ const res=await api(`/api/admin/backups/file?name=${encodeURIComponent(select.value)}`); if(!res.ok) throw new Error((await res.json().catch(()=>({}))).detail||res.status);
4785
+ const url=URL.createObjectURL(await res.blob()), a=document.createElement("a"); a.href=url; a.download=select.value; a.click(); setTimeout(()=>URL.revokeObjectURL(url),30000); msg.textContent="העותק הורד.";
4786
+ }catch(err){ msg.textContent="הגיבוי נכשל: "+err.message; }
4787
+ create.disabled=false; fill();
4788
+ };
4789
+ restore.onclick=async()=>{
4790
+ const name=select.value; if(!name||!confirm(`לשחזר מהגיבוי ${name}? נתונים בעלי אותו מזהה יעודכנו. נתונים חדשים שאינם בגיבוי לא יימחקו.`)) return;
4791
+ restore.disabled=download.disabled=create.disabled=true; msg.textContent="משחזר… אין לסגור את החלון.";
4792
+ try{
4793
+ const res=await api("/api/admin/backups/restore",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name})}), data=await res.json().catch(()=>({}));
4794
+ if(!res.ok) throw new Error(data.detail||res.status);
4795
+ const rows=Object.values(data.tables||{}).reduce((a,b)=>a+b,0);
4796
+ msg.textContent=`השחזור הושלם: ${rows} רשומות.`;
4797
+ CLASS=null; flagsLoaded=false; ADMIN_SUBJECTS=null; await loadManage();
4798
+ }catch(err){ msg.textContent="השחזור נכשל: "+err.message; }
4799
+ create.disabled=false; fill();
4800
+ };
4801
+ actions.append(create,select,restore,download,msg);
4802
+ card.append(mark("","גיבוי ושחזור","h2"),mark("sub","הגיבויים נשמרים לפי חותמת זמן וכוללים את נתוני האפליקציה ואת הציון הטוב ביותר של כל תלמיד בכל משפט. היסטוריית הניסיונות וההקלטות אינן נשמרות. השחזור ממזג את הגיבוי ואינו מוחק נתונים חדשים. משתמשי ההתחברות עצמם וגיבויי התשתית של Supabase אינם כלולים.","p"),actions);
4803
+ box.append(card);
4804
+ }
4805
+
4806
  /* Developer-only: the subject stamped onto every class an admin creates. The API enforces the same boundary. */
4807
  function drawAdminSubjects(box){
4808
  if(!can("*")) return;
 
4813
  if(data.setup){ const code=mark("",data.setup,"code"); code.dir="ltr"; list.append(mark("setup","יש ליצור את טבלת ההרשא��ת:","p"),code); return; }
4814
  if(!(data.admins||[]).length){ list.append(mark("sub","אין כרגע מנהלים.","p")); return; }
4815
  for(const admin of data.admins){
4816
+ const row=mark("key-row",""), who=document.createElement("div"), form=document.createElement("form"), input=document.createElement("select"), save=mark("btn primary","שמירה","button"), msg=mark("msg","");
4817
+ who.append(mark("",admin.name||admin.email,"b"),mark("",admin.email,"small")); const labels=(data.subjects||[]).map(s=>s.label), option=(value,text,off)=>{ const o=document.createElement("option"); o.value=value; o.textContent=text; o.disabled=!!off; input.append(o); };
4818
+ if(!admin.subject) option("","בחירת מקצוע",true);
4819
+ else if(!labels.includes(admin.subject)) option(admin.subject,`${admin.subject} (לא ברשימה, יש לבחור מחדש)`,true); // typed before the list: no class can match it
4820
+ labels.forEach(l=>option(l,l)); input.value=admin.subject||""; input.required=true; input.setAttribute("aria-label",`המקצוע של ${admin.name||admin.email}`); save.type="submit";
4821
  form.onsubmit=async e=>{ e.preventDefault(); save.disabled=true; msg.textContent="שומר…"; try{ const res=await api(`/api/admin-subjects/${admin.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({subject:input.value})}), data=await res.json().catch(()=>({})); if(!res.ok) throw new Error(data.detail||res.status); admin.subject=data.subject; input.value=data.subject; msg.textContent="נשמר"; }catch(err){ msg.textContent="השמירה נכשלה: "+err.message; } save.disabled=false; };
4822
  form.append(input,save,msg); row.append(who,form); list.append(row);
4823
  }
 
5035
  }
5036
  const lessonsLine=ls=>byLevel(ls).map(g=>`${g.level.name}: ${lessonsWord(g.lessons)} (${g.lessons.filter(l=>!l.soon).length} מוכנים)`).join(" · "); // "שלב א: שיעורים 1–10 (2 מוכנים)"
5037
  /* The classes' part: a card per class with what it is made of, and its buttons: edit, delete, and on to its students' progress. */
5038
+ /* A math class (subject "מתמטיקה") studies units of the math path, kept as its lessons; it has no homework or daily
5039
+ videos yet. A new class is a math one when its admin's subject is, or, for a developer, when it is opened on /math. */
5040
+ const MATH_LABEL="מתמטיקה";
5041
+ let MATH_UNITS=null; // [{id, name, count}]: the math path's units, loaded with the panel
5042
+ const isMathClass=c=>c?c.subject===MATH_LABEL:(ME_SUBJECT||document.documentElement.dataset.subject)==="math";
5043
+ async function loadMathUnits(){
5044
+ if(MATH_UNITS) return;
5045
+ try{ const r=await fetch("/api/math/skills"); if(r.ok){ const g=await r.json(); MATH_UNITS=g.units.map(u=>({id:u.id,name:u.name,count:g.skills.filter(s=>s.unit===u.id).length})); } }catch{}
5046
+ }
5047
+ const unitsLine=ids=>{ const all=MATH_UNITS||[], mine=all.filter(u=>ids.includes(u.id)); return mine.length===all.length?`כל היחידות (${all.length})`:mine.map(u=>u.name).join(" · "); };
5048
  function drawClasses(classes){
5049
  const box=$("mClasses"); box.innerHTML="";
5050
  if(!classes.length){
 
5057
  const card=mark("card",""), top=mark("chead",""), name=mark("","","h2"), n=document.createElement("bdi"), edit=mark("btn","עריכה","button"), del=mark("btn danger","מחיקה","button");
5058
  n.textContent=c.name; name.append(n); edit.onclick=()=>editClass(c); del.onclick=()=>deleteClass(c); top.append(name,edit,del);
5059
  const facts=mark("facts","","dl"), fact=(what,...value)=>{ const dd=document.createElement("dd"); dd.append(...value); facts.append(mark("",what,"dt"),dd); };
5060
+ const math=isMathClass(c);
5061
  fact("מקצוע",c.subject||"לא הוגדר");
5062
+ if(math) fact("יחידות",unitsLine(c.lessons));
5063
+ else fact("שיעורים",lessonsLine(ALL_LESSONS.filter(l=>c.lessons.includes(l.id))));
5064
  const mine=CLASS.students.filter(s=>c.name in s.classes);
5065
  fact("תלמידים",...(mine.length?[`${mine.length}: `,...mine.flatMap((s,k)=>{ const b=document.createElement("bdi"); b.textContent=s.classes[c.name]||s.name||s.email; return k?[", ",b]:[b]; })]:["עוד אין"]));
5066
  const teachers=(c.teachers||[]).map(e=>{ const s=mine.find(x=>x.email.toLowerCase()===e); return s&&(s.classes[c.name]||s.name)||e; });
5067
  fact("מורים",teachers.length?teachers.join(" · "):"עוד לא נבחרו");
5068
  const hws=hwOf(c,ALL_CATS).map(h=>mark("",[h.c.name,lessonName(ALL_LESSONS.find(l=>l.id===h.c.lesson)),dueText(h.due).text].filter(Boolean).join(" · "),"div"));
5069
+ if(!math) fact("שיעורי בית",...(hws.length?hws:["אין"]));
5070
+ const days=math?[]:classDailies(c);
5071
  if(days.length) fact("סרטונים יומיים",...days.map(d=>mark("",`${d.v.title} · ${dayWord(d.date)}`,"div")));
5072
  const go=mark("btn","התקדמות התלמידים ‹","button"); go.onclick=()=>{ shownClass=c.name; store.set("ihki.mstudentClass",c.name); shownStudent=""; showMode("students"); scrollTo(0,0); };
5073
  card.append(top,facts,go); box.append(card);
 
5264
  const to=e.state&&e.state.student||""; if(to===shownStudent||!CLASS) return;
5265
  shownStudent=to; drawManage(); scrollTo(0,to?0:classScroll);
5266
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5267
  function drawTabs(classes){
5268
  const tabs=$("mTabs"); tabs.innerHTML=""; tabs.hidden=!classes.length;
5269
  const all=mark("tab","כל התלמידים","button"); all.setAttribute("role","tab"); all.setAttribute("aria-selected",shownClass==="*");
 
5300
  }
5301
  top.append(mark("","כל התלמידים","h2"),mark("","כל תלמיד מופיע פעם אחת. לבדיקת שיעורי בית והתקדמות מאז פתיחת כיתה, בחרו כיתה במסנן.","p"));
5302
  }else{
5303
+ top.append(mark("",klass.name,"h2"),mark("",isMathClass(klass)?`מתמטיקה: ${unitsLine(klass.lessons)}. ההתקדמות במסלול המתמטיקה נשמרת בינתיים במכשיר של התלמיד, ולכן לא מופיעה כאן.`:lessonsLine(ALL_LESSONS.filter(l=>klass.lessons.includes(l.id))),"p"));
5304
  }
5305
  head.append(top);
5306
  const tiles=mark("tiles",""), tile=(n,what)=>{ const t=mark("tile",""), b=mark("",n,"b"); b.dir="ltr"; t.append(b,mark("",what,"small")); tiles.append(t); };
 
5474
  }
5475
  function editClass(c){
5476
  editing=c;
5477
+ $("editTeachersField").hidden=!LIVE_TAB_ENABLED;
5478
  editTeacherSet=new Set(c&&c.teachers||[]);
5479
  $("editTitle").textContent=c?`עריכת ${c.name}`:"כיתה חדשה";
5480
  $("editName").value=c?c.name:"";
5481
+ // a math class picks units of the math path, and has no homework or daily videos; any other picks Arabic lessons
5482
+ const math=isMathClass(c), lessonsBox=$("editLessons").closest("fieldset"), legend=lessonsBox.querySelector("legend"), note=legend.querySelector("small");
5483
+ note.dataset.text??=note.textContent; legend.firstChild.textContent=math?"היחידות":"השיעורים";
5484
+ note.textContent=math?"התלמידים של הכיתה רואים במסלול רק את המיומנויות של היחידות האלה.":note.dataset.text;
5485
+ for(const box of [$("editHws").closest("fieldset"),$("editDailies").closest("fieldset")]) box.hidden=math;
5486
  // the lessons by level: a level's box ticks all of it, its chips one lesson each; a new class starts with the first level
5487
+ const units=MATH_UNITS||[], groups=math?[{name:"מתמטיקה כיתה ט׳ · 5 יח״ל",items:units.map(u=>({id:u.id,label:u.name,title:`${u.count} מיומנויות`}))}]
5488
+ :ALL_LEVELS.map(v=>({name:v.name,items:ALL_LESSONS.filter(l=>l.level===v.id).map(l=>({id:l.id,label:String(l.n),soon:l.soon,title:l.soon?`${l.name}: עוד לא מוכן, ייפתח לכיתה כשיהיה`:l.name}))}));
5489
+ const pick=$("editLessons"), chosen=c?c.lessons:math?units.map(u=>u.id):ALL_LESSONS.filter(l=>l.level===(ALL_LEVELS[0]||{}).id).map(l=>l.id);
5490
  pick.innerHTML="";
5491
+ if(math&&!units.length) pick.append(mark("note","לא הצלחנו לטעון את יחידות המתמטיקה. לחצו „רענון” ונסו שוב.","p"));
5492
+ const days=()=>{ if(!math) dailyRows($("editDailies"),editLevels(),editDays); };
5493
+ for(const g of groups){
5494
  const row=mark("lvl",""), head=mark("lvlhead","","label"), all=document.createElement("input"), chips=mark("chips",""), boxes=[];
5495
+ all.type="checkbox"; head.append(all,g.name); row.append(head,chips); pick.append(row);
5496
+ for(const it of g.items){
5497
+ const box=document.createElement("input"), lab=mark(it.soon?"soon":"","","label");
5498
+ box.type="checkbox"; box.value=it.id; box.checked=chosen.includes(it.id); boxes.push(box);
5499
+ lab.append(box,it.label); lab.title=it.title; chips.append(lab);
5500
  }
5501
  const sync=()=>{ const n=boxes.filter(b=>b.checked).length; all.checked=n===boxes.length; all.indeterminate=n>0&&n<boxes.length; };
5502
+ all.onchange=()=>{ boxes.forEach(b=>b.checked=all.checked); sync(); days(); };
5503
+ boxes.forEach(b=>b.onchange=()=>{ sync(); days(); }); sync();
5504
  }
5505
+ editDays=Object.fromEntries((c&&c.dailies||[]).map(d=>[d.video,d.date])); days();
5506
+ $("editHws").replaceChildren(); if(!math) for(const h of hwOf(c,ALL_CATS)) hwRow(h.c.id,h.due);
5507
  $("editEmails").value=c?c.emails.map(e=>[e,c.nicknames&&c.nicknames[e]].filter(Boolean).join(" ")).join("\n"):"";
5508
  $("editSave").textContent=c?"שמירה":"פתיחת הכיתה"; $("editDelete").hidden=!c; $("editMsg").textContent="";
5509
  countStudents(); editTeacherChoices(); $("editDlg").showModal(); (c?$("editEmails"):$("editName")).focus();
 
5545
  }
5546
  $("editForm").onsubmit=e=>{
5547
  e.preventDefault();
5548
+ const lessons=[...$("editLessons").querySelectorAll(".chips :checked")].map(b=>b.value), math=isMathClass(editing);
5549
+ if(!lessons.length){ $("editMsg").textContent=math?"בחרו לפחות יחידה אחת.":"בחרו לפחות שיעור אחד."; return; }
5550
+ const who={name:$("editName").value,emails:$("editEmails").value,...(LIVE_TAB_ENABLED?{teachers:[...editTeacherSet]}:{})};
5551
+ if(math){ sendClass(editing?"PUT":"POST",{...who,lessons,...(editing?{}:{subject:MATH_LABEL})}); return; }
5552
  const homeworks=hwRows();
5553
  if(homeworks.some(h=>!lessons.includes((ALL_CATS.find(c=>c.id===h.topic)||{}).lesson))){ $("editMsg").textContent="שיעורי הבית הם נושאים משיעורי הכיתה: סמנו את השיעור שלהם למעלה, או הסירו אותם."; return; }
5554
  if(new Set(homeworks.map(h=>h.topic)).size<homeworks.length){ $("editMsg").textContent="אותו נושא מופיע פעמיים בשיעורי הבית."; return; }
5555
+ sendClass(editing?"PUT":"POST",{name:$("editName").value,emails:$("editEmails").value,...(LIVE_TAB_ENABLED?{teachers:[...editTeacherSet]}:{}),lessons,homeworks,dailies:daysOf(editDays,editLevels())});
5556
  };
5557
  $("editDelete").onclick=()=>{
5558
  if(confirm(`למחוק את הכיתה ${editing.name}? התלמידים שלה לא יוכלו להיכנס עוד. ההקלטות וההתקדמות שלהם נשמרות.`)) sendClass("DELETE");
 
5560
 
5561
  /* A new class is opened in steps, a question each: its name, its level (the class studies the level's lessons), its
5562
  students with their nicknames, its homework, and a summary to confirm, which says where all of it is changed later. */
5563
+ const WIZ=["שם הכיתה","רמה","תלמידים","שיעורי בית וסרטונים","אישור"], WIZ_MATH=["שם הכיתה","יחידות","תלמידים","","אישור"], ADDRESS=/[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+/g;
5564
  let wiz=null; // {step, students: [{email, name, on, nick}]}: the accounts the panel knows, and those added here
5565
  const wizName=()=>$("newName").value.trim().replace(/\s+/g," ");
5566
  const wizLevel=()=>ALL_LEVELS.find(v=>v.id===($("newLevels").querySelector(":checked")||{}).value)||ALL_LEVELS[0];
5567
  const wizLessons=()=>ALL_LESSONS.filter(l=>l.level===wizLevel().id);
5568
  const wizWho=s=>s.nick.trim()||s.name||s.email;
5569
+ const wizSteps=()=>wiz.math?[0,1,2,4]:[0,1,2,3,4]; // a math class has no homework or daily videos to set
5570
+ const wizUnits=()=>[...$("newLevels").querySelectorAll(":checked")].map(b=>b.value);
5571
  function newClass(){
5572
+ wiz={step:0,days:{},math:isMathClass(null),students:CLASS.students.map(s=>({email:s.email.toLowerCase(),name:s.name,on:false,nick:""}))};
5573
  $("newName").value=$("newEmail").value=$("newDue").value=""; $("newHw").innerHTML="";
5574
+ const levels=$("newLevels"), legend=levels.closest("fieldset").querySelector("legend"), note=legend.querySelector("small"); levels.innerHTML="";
5575
+ note.dataset.text??=note.textContent; legend.firstChild.textContent=wiz.math?"אילו יחידות הכיתה לומדת?":"באיזו רמה הכיתה לומדת?";
5576
+ note.textContent=wiz.math?"התלמידים יראו במסלול את המיומנויות של היחידות שסימנתם. אפשר לשנות אחר כך בעריכת הכיתה.":note.dataset.text;
5577
+ if(wiz.math) for(const u of MATH_UNITS||[]){
5578
+ const lab=document.createElement("label"), box=document.createElement("input"), text=document.createElement("span");
5579
+ box.type="checkbox"; box.value=u.id; box.checked=true; text.append(u.name,mark("",`${u.count} מיומנויות`,"small")); lab.append(box,text); levels.append(lab);
5580
+ }
5581
+ else ALL_LEVELS.forEach((v,k)=>{
5582
  const lab=document.createElement("label"), box=document.createElement("input"), text=document.createElement("span"), ls=ALL_LESSONS.filter(l=>l.level===v.id);
5583
  box.type="radio"; box.name="newLevel"; box.value=v.id; box.checked=!k;
5584
  text.append(v.name,mark("",`${lessonsWord(ls)} · ${ls.filter(l=>!l.soon).length} מוכנים`,"small")); lab.append(box,text); levels.append(lab);
 
5588
  function wizStep(n){
5589
  wiz.step=n; $("newMsg").textContent="";
5590
  [...$("newForm").querySelectorAll("section")].forEach((s,k)=>s.hidden=k!==n);
5591
+ const order=wizSteps(), at=order.indexOf(n);
5592
+ $("newStep").textContent=`שלב ${at+1} מתוך ${order.length}: ${(wiz.math?WIZ_MATH:WIZ)[n]}`;
5593
+ $("newDots").replaceChildren(...order.map((_,k)=>mark(k<=at?"on":"","","i")));
5594
+ $("newBack").hidden=!at; $("newNext").textContent=at===order.length-1?"פתיחת הכיתה":"המשך";
5595
  if(n===3) wizHomework();
5596
  if(n===4) wizSummary();
5597
  }
 
5641
  const line=(what,...value)=>{ const dd=document.createElement("dd"); dd.append(...value); sum.append(mark("",what,"dt"),dd); };
5642
  sum.innerHTML="";
5643
  const name=document.createElement("bdi"); name.textContent=wizName(); line("שם",name);
5644
+ if(wiz.math) line("יחידות",unitsLine(wizUnits()));
5645
+ else line("רמה",`${level.name}, ${lessonsWord(wizLessons())}`);
5646
  line("תלמידים",...(picked.length?[`${picked.length}: `,...picked.flatMap((s,k)=>{ const b=document.createElement("bdi"); b.textContent=wizWho(s); return k?[", ",b]:[b]; })]:["עוד אין. אפשר להוסיף אחר כך."]));
5647
+ if(!wiz.math) line("שיעורי בית",topic?[topic.name,ALL_LESSONS.find(l=>l.id===topic.lesson).name,dueText($("newDue").value).text].filter(Boolean).join(" · "):"אין בינתיים");
5648
+ const days=wiz.math?[]:daysOf(wiz.days,[level.id]); if(days.length) line("סרטונים יומיים",`${days.length} עם תאריך`);
5649
  const after=$("newAfter"); after.innerHTML="";
5650
  after.append(mark("","מה קורה אחרי הפתיחה?","b"),"הכיתה מופיעה ברשימה תחת „כיתות”, והתלמידים שלה רואים את השיעורים במסלול. את ההתקדמות שלהם רואים תחת „תלמידים”. שום דבר כאן לא סופי: ליד כל כיתה ברשימה יש כפתור „עריכה”, שבו משנים את השם, השיעורים, התלמידים והכינויים ושיעורי הבית והתאריכים של הסרטונים היומיים, וכפתור „מחיקה”.");
5651
  if(!CLASS.classes.length) after.append(mark("","שימו לב: זו הכיתה הראשונה. מרגע שהיא נפתחת, נכנסים לאפליקציה רק התלמידים שבכיתות והמורים.","b"));
5652
  }
5653
+ $("newBack").onclick=()=>{ const order=wizSteps(); wizStep(order[order.indexOf(wiz.step)-1]); };
5654
  $("newClose").onclick=()=>$("newDlg").close();
5655
  $("newForm").onsubmit=async e=>{ // "next" checks the step on show; on the last one the class is opened
5656
  e.preventDefault();
 
5659
  const taken=CLASS.classes.find(c=>c.name.replace(/\s+/g," ").trim().toLowerCase()===name.toLowerCase());
5660
  if(!name||taken){ msg.textContent=name?`כבר יש כיתה בשם ${taken.name}. בחרו שם אחר.`:"כתבו שם לכיתה."; $("newName").focus(); return; }
5661
  }
5662
+ if(n===1&&wiz.math&&!wizUnits().length){ msg.textContent=MATH_UNITS?"סמנו לפחות יחידה אחת.":"לא הצלחנו לטעון את יחידות המתמטיקה. סגרו, לחצו „רענון” ונסו שוב."; return; }
5663
  if(n===2&&!wizAdd()) return; // an address still in the field joins too
5664
+ const order=wizSteps(); if(n!==order[order.length-1]){ wizStep(order[order.indexOf(n)+1]); return; }
5665
  const homework=$("newHw").value, buttons=$("newForm").querySelectorAll("button");
5666
  const emails=wiz.students.filter(s=>s.on).map(s=>`${s.email} ${s.nick.replace(/[\s@]+/g," ").trim()}`.trim()).join("\n");
5667
  msg.textContent=""; buttons.forEach(b=>b.disabled=true);
5668
  try{
5669
  const res=await api("/api/classes",{method:"POST",headers:{"Content-Type":"application/json"},
5670
+ body:JSON.stringify(wiz.math?{name,emails,lessons:wizUnits(),subject:MATH_LABEL}
5671
+ :{name,emails,lessons:wizLessons().map(l=>l.id),homework,due:homework?$("newDue").value:"",dailies:daysOf(wiz.days,[wizLevel().id])})});
5672
  if(res.ok){ shownClass=name; store.set("ihki.mstudentClass",name); $("newDlg").close(); loadManage(); }
5673
  else{
5674
  const detail=(await res.json().catch(()=>({}))).detail||`הפתיחה נכשלה (${res.status})`;
 
6759
  const res=await Promise.race([api("/api/me").catch(()=>null),new Promise(r=>setTimeout(()=>r(null),10000))]); // a session or server that hangs leaves the logo alone on the screen
6760
  const ok=!res||res.status!==403; // a server that cannot be reached is not a refusal
6761
  const me=res&&res.ok?await res.json().catch(()=>({})):{}, teacher=!!me.teacher;
6762
+ // an account belongs on its subject's pages (its class's, or an admin's own): a math student who opens "/" goes to /math
6763
+ if(me.subject&&me.subject!==document.documentElement.dataset.subject){ location.replace(me.subject===document.documentElement.dataset.home?"/":"/"+me.subject); return; }
6764
+ ME_SUBJECT=me.subject||"";
6765
  ME_PERMISSIONS=new Set(me.permissions||[]); ME_ROLES=me.roles||[]; ME_ENTITLEMENTS=me.entitlements||[];
6766
  $("nav").querySelector('[data-view=""]').hidden=!can("path.use");
6767
  $("nav").querySelector('[data-view="verbs"]').hidden=!can("games.use");
 
6808
  addEventListener("load",()=>navigator.serviceWorker.register("/service-worker.js").catch(()=>{}));
6809
  }
6810
  </script>
6811
+ <script src="/static/math.js?v=__ASSET_V__" defer></script>
6812
  </body>
6813
  </html>
static/logo-math.svg CHANGED
static/math.css ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* The math path (static/math.js). Layout: one narrow column like the Arabic path: progress, "go on here", then each
2
+ unit's skills as a winding trail of circles; a skill opens in a bottom sheet. It sits over the Arabic page, which
3
+ stays loaded underneath. The math class is black in either system theme, as its opening screen is: the path sets
4
+ the app's colour tokens itself, with a blue accent and a colour per unit. */
5
+ @import url("https://fonts.googleapis.com/css2?family=STIX+Two+Text:wght@400;600&display=swap");
6
+ #mathView{
7
+ color-scheme:dark;
8
+ --bg:#000000; --surface:#121413; --ink:#F4F2EC; --muted:#9EA7A0; --line:#2C312E;
9
+ --ok:#6CC08A; --ok-bg:#12291B; --sumac:#F08A96; --sumac-bg:#33161B; --amber:#E0B45A; --amber-bg:#2E2512;
10
+ --olive:#A6C77A; --olive-soft:#1E2618;
11
+ --mx-accent:#7FA6F0; --mx-accent-soft:#18243A; --mx-on-accent:#06101F;
12
+ --u-f:#A3AD9F; --u-t:#93B85F; --u-n:#5CC2B2; --u-q:#6FB3D2; --u-p:#E0B45A; --u-w:#D98AAE;
13
+ --mx-scrim:rgb(0 0 0 / .7);
14
+ --f-math:"STIX Two Text","Cambria Math","Times New Roman",serif;
15
+ }
16
+ html.math-on, html.math-on body{overflow:hidden}
17
+ #mathView{position:fixed;inset:0;z-index:60;overflow:auto;overscroll-behavior:contain;background:var(--bg);color:var(--ink);
18
+ padding-block:max(12px, env(safe-area-inset-top, 0px)) calc(32px + env(safe-area-inset-bottom, 0px));
19
+ padding-left:max(16px, env(safe-area-inset-left, 0px));padding-right:max(16px, env(safe-area-inset-right, 0px))}
20
+ #mathView[hidden]{display:none}
21
+ #mathView .u-f{--uc:var(--u-f)} #mathView .u-t{--uc:var(--u-t)} #mathView .u-n{--uc:var(--u-n)}
22
+ #mathView .u-q{--uc:var(--u-q)} #mathView .u-p{--uc:var(--u-p)} #mathView .u-w{--uc:var(--u-w)}
23
+ #mathView h1,#mathView h2,#mathView h3{font-family:var(--f-display);font-weight:400;margin:0;text-wrap:balance}
24
+ #mathView button{font-family:var(--f-body)}
25
+ #mathView button:focus-visible{outline:3px solid var(--mx-accent);outline-offset:2px}
26
+ #mathView .m{font-family:var(--f-math);direction:ltr;unicode-bidi:isolate;white-space:nowrap;font-size:1.05em}
27
+ .mx-wrap{max-width:680px;margin:0 auto;display:flex;flex-direction:column;gap:16px}
28
+ .mx-top{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
29
+ .mx-top h1{font-size:28px;line-height:1.15;color:var(--mx-accent)}
30
+ .mx-sub{margin:2px 0 0;color:var(--muted);font-size:15px}
31
+ .mx-acts{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}
32
+ .mx-btn{border:1px solid var(--mx-accent);background:transparent;color:var(--mx-accent);font:700 14px/1 var(--f-body);padding:9px 14px;border-radius:999px;cursor:pointer}
33
+ .mx-btn.solid{background:var(--mx-accent);color:var(--mx-on-accent)}
34
+ .mx-note{margin:0;color:var(--muted);font-size:14px}
35
+
36
+ /* progress: a ring for the whole path, a bar per unit */
37
+ .mx-progress{display:flex;gap:16px;align-items:center;background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:14px}
38
+ .mx-ring{flex:none;width:92px;height:92px;border-radius:50%;display:grid;place-content:center;text-align:center;
39
+ background:radial-gradient(closest-side,var(--surface) 78%,transparent 80%),conic-gradient(var(--mx-accent) calc(var(--p)*1%),var(--line) 0)}
40
+ .mx-ring b{font:400 28px/1 var(--f-display);color:var(--mx-accent);font-variant-numeric:tabular-nums}
41
+ .mx-ring small{font-size:12px;color:var(--muted)}
42
+ .mx-units{flex:1;min-width:0;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px 12px}
43
+ .mx-su{display:flex;flex-direction:column;gap:4px;min-width:0}
44
+ .mx-su b{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
45
+ .mx-su small{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
46
+ .mx-bar{height:6px;border-radius:3px;background:var(--line);overflow:hidden}
47
+ .mx-bar i{display:block;height:100%;background:var(--uc)}
48
+ @media (max-width:480px){.mx-progress{flex-direction:column;align-items:stretch}.mx-ring{align-self:center}.mx-units{grid-template-columns:repeat(2,minmax(0,1fr))}}
49
+
50
+ .mx-next{display:flex;flex-direction:column;gap:2px;align-items:flex-start;text-align:start;border:0;border-radius:16px;padding:14px 16px;cursor:pointer;
51
+ background:var(--mx-accent);color:var(--mx-on-accent)}
52
+ .mx-next small{font-size:13px;opacity:.85}
53
+ .mx-next b{font-size:19px}
54
+
55
+ /* the trail */
56
+ .mx-unit{display:flex;flex-direction:column;gap:4px}
57
+ .mx-uhead{display:flex;align-items:baseline;gap:4px 10px;flex-wrap:wrap;border-bottom:2px solid var(--uc);padding-bottom:6px}
58
+ .mx-uhead h2{font-size:21px;color:var(--uc)}
59
+ .mx-uhead span{font-size:13px;color:var(--muted)}
60
+ .mx-trail{display:flex;flex-direction:column;gap:4px;padding-top:8px}
61
+ .mx-node{display:flex;align-items:center;gap:12px;width:min(76%,330px);padding:6px 8px;border:0;border-radius:14px;background:none;color:inherit;text-align:start;cursor:pointer}
62
+ .mx-node:hover{background:var(--surface)}
63
+ .mx-trail .mx-node:nth-child(4n+2){margin-inline-start:12%}
64
+ .mx-trail .mx-node:nth-child(4n+3){margin-inline-start:24%}
65
+ .mx-trail .mx-node:nth-child(4n+4){margin-inline-start:12%}
66
+ .mx-dot{flex:none;width:54px;height:54px;border-radius:50%;display:grid;place-items:center;background:var(--surface);border:3px solid var(--uc);color:var(--uc);font:400 20px/1 var(--f-display)}
67
+ .mx-lbl{display:flex;flex-direction:column;gap:3px;min-width:0}
68
+ .mx-lbl b{font-size:16px;line-height:1.25}
69
+ .mx-lbl small{display:flex;gap:6px;flex-wrap:wrap}
70
+ .st-done .mx-dot{background:var(--uc);color:var(--surface)}
71
+ .st-locked .mx-dot{border-style:dashed;border-color:var(--line);color:var(--muted)}
72
+ .st-locked .mx-lbl b{color:var(--muted)}
73
+ .mx-node.here .mx-dot{box-shadow:0 0 0 5px var(--mx-accent-soft)}
74
+ .mx-core,.mx-go{font:700 11px/1 var(--f-body);padding:4px 7px;border-radius:999px;white-space:nowrap}
75
+ .mx-core{background:var(--olive-soft);color:var(--olive)}
76
+ .mx-go{background:var(--mx-accent);color:var(--mx-on-accent)}
77
+
78
+ /* a skill's sheet */
79
+ .mx-layer[hidden]{display:none}
80
+ .mx-scrim{position:fixed;inset:0;z-index:61;background:var(--mx-scrim)}
81
+ .mx-sheet{position:fixed;inset:auto 0 0 0;z-index:62;max-height:88vh;overflow:auto;background:var(--surface);border-radius:20px 20px 0 0;
82
+ box-shadow:0 -6px 30px rgb(0 0 0/.2);display:flex;flex-direction:column;gap:14px;
83
+ padding-block:16px calc(24px + env(safe-area-inset-bottom, 0px));
84
+ padding-left:max(18px, env(safe-area-inset-left, 0px));padding-right:max(18px, env(safe-area-inset-right, 0px))}
85
+ @media (min-width:720px){.mx-sheet{inset:auto calc(50% - 330px) 0 calc(50% - 330px)}}
86
+ .mx-sheet>p{margin:0}
87
+ .mx-shtop{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}
88
+ .mx-shtop h3{font-size:24px;line-height:1.2;margin-top:8px}
89
+ .mx-uchip{display:inline-block;font:700 13px/1 var(--f-body);color:var(--uc);border:1px solid var(--uc);padding:5px 10px;border-radius:999px}
90
+ .mx-src{display:block;color:var(--muted);font-size:13px;margin-top:4px}
91
+ .mx-x{border:0;background:none;color:var(--muted);font-size:22px;line-height:1;padding:6px 10px;border-radius:8px;cursor:pointer}
92
+ .mx-hint{background:var(--amber-bg);color:var(--ink);border-radius:12px;padding:10px 12px;font-size:15px;display:flex;flex-direction:column;gap:6px}
93
+ .mx-q{background:var(--bg);border-radius:14px;padding:14px;display:flex;flex-direction:column;gap:10px}
94
+ .mx-q>p{margin:0;font-size:19px}
95
+ .mx-choices{display:grid;grid-template-columns:1fr 1fr;gap:8px}
96
+ @media (max-width:420px){.mx-choices{grid-template-columns:1fr}}
97
+ .mx-choices button{padding:12px;border-radius:12px;border:1px solid var(--line);background:var(--surface);color:var(--ink);font:600 17px/1.3 var(--f-body);cursor:pointer}
98
+ .mx-choices button:hover{border-color:var(--mx-accent)}
99
+ .mx-choices button.right{background:var(--ok-bg);border-color:var(--ok)}
100
+ .mx-choices button.wrong{background:var(--sumac-bg);border-color:var(--sumac)}
101
+ .mx-ok,.mx-bad{border-radius:12px;padding:12px;display:flex;flex-direction:column;gap:6px;align-items:flex-start}
102
+ .mx-ok{background:var(--ok-bg);color:var(--ok)}
103
+ .mx-bad{background:var(--sumac-bg)}
104
+ .mx-bad b{color:var(--sumac)}
105
+ .mx-small{font-size:13px;color:var(--muted)}
106
+ .mx-sec h4{margin:0 0 6px;font:700 12px/1 var(--f-body);letter-spacing:.06em;color:var(--muted)}
107
+ .mx-chips{display:flex;gap:6px;flex-wrap:wrap}
108
+ .mx-chips em{font-style:normal;color:var(--muted);font-size:14px}
109
+ .mx-chip{border:1px solid var(--uc);background:var(--bg);color:var(--ink);font:600 14px/1 var(--f-body);padding:7px 10px;border-radius:999px;cursor:pointer}
110
+ .mx-chip.is-done{background:var(--uc);color:var(--surface)}
111
+ .mx-plot{direction:ltr;align-self:center;width:100%;max-width:340px}
112
+ .mx-plot svg{display:block;width:100%;height:auto}
113
+ .mx-plot .g{stroke:var(--line);stroke-width:1}
114
+ .mx-plot .a{stroke:var(--muted);stroke-width:1.5}
115
+ .mx-plot .c{fill:none;stroke:var(--uc);stroke-width:2.5}
116
+ .mx-plot circle{fill:var(--uc)}
117
+ .mx-plot text{font:11px var(--f-body);fill:var(--muted)}
118
+ /* a photographed problem */
119
+ .mx-photo{display:flex;flex-direction:column;gap:3px;align-items:flex-start;text-align:start;border:1.5px dashed var(--mx-accent);border-radius:16px;
120
+ padding:14px 16px;background:transparent;color:var(--ink);cursor:pointer}
121
+ .mx-photo b{font-size:18px;color:var(--mx-accent)}
122
+ .mx-photo small{font-size:14px;color:var(--muted)}
123
+ .mx-thumb{display:block;max-width:100%;max-height:180px;object-fit:contain;align-self:center;border-radius:10px;border:1px solid var(--line)}
124
+ .mx-pbody{display:flex;flex-direction:column;gap:12px}
125
+ .mx-wait{display:flex;flex-direction:column;align-items:center;gap:6px;padding:16px;text-align:center}
126
+ .mx-wait small{color:var(--muted)}
127
+ .mx-spin{width:26px;height:26px;border-radius:50%;border:3px solid var(--line);border-top-color:var(--mx-accent);animation:mx-spin 1s linear infinite}
128
+ @keyframes mx-spin{to{transform:rotate(360deg)}}
129
+ @media (prefers-reduced-motion:reduce){.mx-spin{animation:none}}
130
+ .mx-read{background:var(--bg);border-radius:12px;padding:10px 12px;display:flex;flex-direction:column;gap:2px;align-items:flex-start}
131
+ .mx-read small{color:var(--muted);font-size:13px}
132
+ .mx-read p{margin:0;font-size:20px}
133
+ .mx-link{border:0;background:none;padding:2px 0;color:var(--mx-accent);font:600 14px/1.3 var(--f-body);text-decoration:underline;text-underline-offset:3px;cursor:pointer}
134
+ .mx-steps{margin:0;padding-inline-start:22px;display:flex;flex-direction:column;gap:10px}
135
+ .mx-steps li{padding-inline-start:4px;line-height:1.5}
136
+ .mx-steps li::marker{color:var(--mx-accent);font-weight:700}
137
+ .mx-work{margin-top:6px;padding:8px 10px;border-radius:10px;background:var(--mx-accent-soft)}
138
+ .mx-row{display:flex;gap:8px;flex-wrap:wrap}
139
+ @media (prefers-reduced-motion:no-preference){.mx-sheet{animation:mx-up .22s ease-out}@keyframes mx-up{from{transform:translateY(30px);opacity:.4}}}
static/math.js ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* The math path, on /math only: after the opening screen it takes the whole screen in place of the Arabic path.
2
+ The skill graph comes from /api/math/skills (app/math_skills.py). A skill is open once every skill it needs is
3
+ done, and the first open one is where the student goes on. A student may still open a locked skill: without a
4
+ placement test, one who already knows the foundations should not have to answer them first.
5
+ For now each skill has one sample question, and answering it right marks the skill done. Progress is kept on
6
+ this device, per account (ihki.math.v1:<email>), until the server keeps attempts. */
7
+ (()=>{
8
+ if(document.documentElement.dataset.subject!=="math") return;
9
+ const css=document.createElement("link"); css.rel="stylesheet"; css.href=document.currentScript.src.replace("math.js","math.css");
10
+ document.head.append(css);
11
+
12
+ const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
13
+ const fmt=s=>esc(s).replace(/\$([^$]+)\$/g,'<span class="m">$1</span>'); // $…$ is math: set left to right
14
+ const plain=s=>String(s).replace(/\$/g,"");
15
+ // the model's text: as fmt, and a signed number it left outside $…$ is set left to right too, or its minus lands after it
16
+ const fmtLoose=s=>String(s??"").split("$").map((part,i)=>i%2?`<span class="m">${esc(part)}</span>`
17
+ :esc(part).replace(/(^|[\s(־])([−-]\d+(?:[.,]\d+)?)/g,'$1<span class="m">$2</span>')).join("");
18
+ const key=()=>"ihki.math.v1:"+(ME_EMAIL||"guest");
19
+ const load=()=>{ try{ return JSON.parse(localStorage.getItem(key())||"{}"); }catch{ return {}; } };
20
+ const save=()=>{ try{ localStorage.setItem(key(),JSON.stringify(P)); }catch{} };
21
+
22
+ let G=null, BY={}, POST={}, DEPTH={}, ORDER=[], P={done:{},tries:{}}, root=null, path=null, sheet=null, cur=null, back=null;
23
+ let picker=null, PH=null; // the photo file picker, and the photographed problem on the sheet (see photo())
24
+
25
+ function index(graph){
26
+ G=graph; BY=Object.fromEntries(G.skills.map(s=>[s.id,s])); POST=Object.fromEntries(G.skills.map(s=>[s.id,[]]));
27
+ G.skills.forEach(s=>s.prereqs.forEach(p=>POST[p].push(s.id)));
28
+ const depth=id=>DEPTH[id]??(DEPTH[id]=BY[id].prereqs.length?1+Math.max(...BY[id].prereqs.map(depth)):0);
29
+ G.skills.forEach(s=>depth(s.id));
30
+ // the path's order: unit by unit, and in a unit each skill after the ones it needs
31
+ ORDER=G.units.flatMap(u=>G.skills.filter(s=>s.unit===u.id).sort((a,b)=>DEPTH[a.id]-DEPTH[b.id]).map(s=>s.id));
32
+ }
33
+ // the units the student's class studies (its "lessons"); a teacher, or an account in no class, has every unit.
34
+ // A skill of a unit the class does not study counts as known: it does not lock the skills that need it.
35
+ const shownUnits=()=>{
36
+ const mine=typeof MY_CLASSES==="undefined"?[]:MY_CLASS?[MY_CLASS]:MY_CLASSES, ids=new Set(mine.flatMap(c=>c.lessons||[]));
37
+ const units=G.units.filter(u=>ids.has(u.id)); return units.length?units:G.units;
38
+ };
39
+ let SHOWN=new Set();
40
+ const status=id=>P.done[id]?"done":BY[id].prereqs.every(p=>P.done[p]||!SHOWN.has(BY[p].unit))?"open":"locked";
41
+ const next=()=>ORDER.find(id=>SHOWN.has(BY[id].unit)&&status(id)==="open")||null;
42
+
43
+ async function show(){
44
+ document.documentElement.classList.add("math-on");
45
+ if(!root){
46
+ root=document.createElement("section"); root.id="mathView"; root.setAttribute("aria-label","מסלול המתמטיקה");
47
+ path=document.createElement("div"); path.className="mx-wrap";
48
+ sheet=document.createElement("div"); sheet.className="mx-layer"; sheet.hidden=true;
49
+ // no capture attribute: the phone offers the camera and the gallery both
50
+ picker=document.createElement("input"); picker.type="file"; picker.accept="image/*"; picker.hidden=true;
51
+ picker.onchange=()=>{ const f=picker.files[0]; picker.value=""; if(f) photo(f); };
52
+ root.append(path,sheet,picker); document.body.append(root);
53
+ root.addEventListener("click",onClick);
54
+ document.addEventListener("keydown",e=>{ if(e.key==="Escape"&&cur) closeSheet(); });
55
+ }
56
+ root.hidden=false;
57
+ if(!G){
58
+ path.innerHTML='<p class="mx-note">טוענים את המסלול…</p>';
59
+ try{ const r=await fetch("/api/math/skills"); if(!r.ok) throw new Error(`השרת ענה ${r.status}`); index(await r.json()); }
60
+ catch(e){ path.innerHTML=`<p class="mx-note">לא הצלחנו לטעון את המסלול: ${esc(e.message)}. רעננו את העמוד ונסו שוב.</p>`; return; }
61
+ }
62
+ P=Object.assign({done:{},tries:{}},load()); SHOWN=new Set(shownUnits().map(u=>u.id)); draw();
63
+ }
64
+ function hide(){ if(root) root.hidden=true; document.documentElement.classList.remove("math-on"); }
65
+
66
+ function draw(){
67
+ const UNITS=G.units.filter(u=>SHOWN.has(u.id)), mine=G.skills.filter(s=>SHOWN.has(s.unit));
68
+ const total=mine.length, doneN=mine.filter(s=>P.done[s.id]).length, nxt=next();
69
+ const cls=typeof MY_CLASS!=="undefined"&&MY_CLASS?` · כיתת ${esc(MY_CLASS.name)}`:"";
70
+ const manage=AUTH_URL&&can("management.access"), pct=Math.round(100*doneN/total);
71
+ const units=UNITS.map(u=>{
72
+ const ids=ORDER.filter(id=>BY[id].unit===u.id), d=ids.filter(id=>P.done[id]).length;
73
+ return `<div class="mx-su u-${u.id}"><b>${esc(u.name)}</b><span class="mx-bar"><i style="width:${Math.round(100*d/ids.length)}%"></i></span><small>${d} מתוך ${ids.length}</small></div>`;
74
+ }).join("");
75
+ const trail=UNITS.map(u=>{
76
+ const ids=ORDER.filter(id=>BY[id].unit===u.id);
77
+ return `<section class="mx-unit u-${u.id}"><div class="mx-uhead"><h2>${esc(u.name)}</h2><span>${esc(u.source)}${u.when?" · "+esc(u.when):""}</span></div>
78
+ <div class="mx-trail">${ids.map((id,i)=>{
79
+ const s=BY[id], st=status(id), here=id===nxt;
80
+ return `<button class="mx-node st-${st}${here?" here":""}" data-id="${id}" aria-label="${esc(plain(s.name))}, ${st==="done"?"הושלמה":st==="open"?"פתוחה":"נעולה"}">
81
+ <span class="mx-dot">${st==="done"?"✓":i+1}</span>
82
+ <span class="mx-lbl"><b>${fmt(s.name)}</b><small>${s.core?'<span class="mx-core">ליבה 5 יח״ל</span>':""}${here?'<span class="mx-go">ממשיכים כאן</span>':""}</small></span></button>`;
83
+ }).join("")}</div></section>`;
84
+ }).join("");
85
+ path.innerHTML=`<header class="mx-top"><div><h1>המסלול שלי</h1><p class="mx-sub">אלגברה כיתה ט׳ · 5 יח״ל${cls}</p></div>
86
+ <div class="mx-acts">${manage?'<button class="mx-btn" data-act="manage">ניהול</button>':""}${AUTH_URL?'<button class="mx-btn" data-act="out">יציאה</button>':""}</div></header>
87
+ <section class="mx-progress" aria-label="ההתקדמות שלי">
88
+ <div class="mx-ring" style="--p:${pct}"><b>${doneN}</b><small>מתוך ${total}</small></div>
89
+ <div class="mx-units">${units}</div></section>
90
+ ${nxt?`<button class="mx-next" data-id="${nxt}"><small>ממשיכים מכאן</small><b>${fmt(BY[nxt].name)}</b></button>`:'<p class="mx-note">סיימתם את כל המסלול. כל הכבוד!</p>'}
91
+ <button class="mx-photo" data-act="photo"><b>צילום תרגיל</b><small>צלמו תרגיל מהמחברת או מהדף, ונעזור לכם לפתור אותו צעד אחר צעד</small></button>
92
+ ${trail}
93
+ <p class="mx-note">בשלב הזה יש שאלה אחת לדוגמה לכל מיומנות. ההתקדמות נשמרת במכשיר הזה.</p>`;
94
+ }
95
+
96
+ function chips(ids){
97
+ return ids.length?ids.map(id=>`<button class="mx-chip u-${BY[id].unit}${P.done[id]?" is-done":""}" data-id="${id}">${P.done[id]?"✓ ":""}${esc(BY[id].short)}</button>`).join(""):'<em>אין</em>';
98
+ }
99
+ function open(id){
100
+ const s=BY[id], st=status(id), unit=G.units.find(u=>u.id===s.unit), rot=ORDER.indexOf(id)%s.sample.choices.length;
101
+ const choices=s.sample.choices.slice(rot).concat(s.sample.choices.slice(0,rot)), missing=s.prereqs.filter(p=>!P.done[p]&&SHOWN.has(BY[p].unit));
102
+ if(!cur) back=document.activeElement;
103
+ cur=id;
104
+ sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
105
+ <div class="mx-sheet u-${s.unit}" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
106
+ <div class="mx-shtop"><div><span class="mx-uchip">${esc(unit.name)}</span>${s.core?' <span class="mx-core">ליבה 5 יח״ל</span>':""}
107
+ <h3 id="mxTitle">${fmt(s.name)}</h3><small class="mx-src">${s.month?`בפריסה: ${esc(s.month)}`:esc(unit.source)}${st==="done"?" · הושלמה ✓":""}</small></div>
108
+ <button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div>
109
+ <p>${fmt(s.description)}</p>
110
+ ${st==="locked"?`<div class="mx-hint">מומלץ לעבור קודם על: <span class="mx-chips">${chips(missing)}</span></div>`:""}
111
+ <div class="mx-q"><p>${fmt(s.sample.question)}</p>${s.sample.graph?plot(s.sample.graph):""}
112
+ <div class="mx-choices">${choices.map((c,i)=>`<button data-choice="${i}" data-m="${c.correct?"":esc(c.mistake)}">${fmt(c.text)}</button>`).join("")}</div>
113
+ <div class="mx-fb" aria-live="polite"></div></div>
114
+ <div class="mx-sec"><h4>דורשת</h4><div class="mx-chips">${chips(s.prereqs)}</div></div>
115
+ <div class="mx-sec"><h4>פותחת</h4><div class="mx-chips">${chips(POST[id])}</div></div>
116
+ </div>`;
117
+ sheet.hidden=false; sheet.querySelector(".mx-sheet").scrollTop=0;
118
+ sheet.querySelector(".mx-x").focus({preventScroll:true});
119
+ }
120
+ function closeSheet(){ sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
121
+
122
+ function answer(btn){
123
+ const id=cur, m=btn.dataset.m, fb=sheet.querySelector(".mx-fb");
124
+ sheet.querySelectorAll("[data-choice]").forEach(b=>b.classList.remove("right","wrong"));
125
+ btn.classList.add(m?"wrong":"right");
126
+ P.tries[id]=(P.tries[id]||0)+1;
127
+ if(!m){
128
+ const first=!P.done[id]; P.done[id]=P.done[id]||new Date().toISOString(); save(); draw();
129
+ const nxt=next();
130
+ fb.innerHTML=`<div class="mx-ok"><b>נכון!</b>${first?"<span>המיומנות סומנה כהושלמה.</span>":""}
131
+ ${nxt&&nxt!==id?`<button class="mx-btn solid" data-id="${nxt}">למיומנות הבאה: ${esc(BY[nxt].short)}</button>`:""}</div>`;
132
+ return;
133
+ }
134
+ save();
135
+ const M=G.mistakes[m], to=M.back&&M.back!==id?M.back:null;
136
+ fb.innerHTML=`<div class="mx-bad"><b>${fmt(M.name)}</b><span>${fmt(M.tip)}</span>
137
+ ${to?`<button class="mx-btn" data-id="${to}">חזרה אל: ${esc(BY[to].short)}</button>`:"<span class=\"mx-small\">אפשר לבחור תשובה אחרת.</span>"}</div>`;
138
+ }
139
+
140
+ function onClick(e){
141
+ const t=e.target.closest("button,[data-act]"); if(!t||!root.contains(t)) return;
142
+ if(t.dataset.choice!=null) return answer(t);
143
+ if(t.dataset.id) return open(t.dataset.id);
144
+ switch(t.dataset.act){
145
+ case "photo": case "retake": return picker.click();
146
+ case "hint": PH.shown++; return drawPhoto();
147
+ case "work": PH.works.add(+t.dataset.i); return drawPhoto();
148
+ case "all": PH.all=true; return drawPhoto();
149
+ case "close": return closeSheet();
150
+ case "manage": hide(); return showManage();
151
+ case "out": return document.getElementById("logoutBtn").click();
152
+ }
153
+ }
154
+
155
+ /* A photographed problem (/api/math/photo, app/math_photo.py): hints first. The sheet shows the problem as it was
156
+ read (a wrong reading is the student's cue to photograph again), then one hint; each further hint, each step and
157
+ the whole solution with its answer come only when the student asks for them. */
158
+ async function shrink(file){ // a phone photo is several MB: about 1600 px on its long side is enough to read it
159
+ const bmp=await createImageBitmap(file,{imageOrientation:"from-image"}), k=Math.min(1,1600/Math.max(bmp.width,bmp.height));
160
+ const c=document.createElement("canvas"); c.width=Math.round(bmp.width*k); c.height=Math.round(bmp.height*k);
161
+ c.getContext("2d").drawImage(bmp,0,0,c.width,c.height); bmp.close?.();
162
+ return new Promise((ok,no)=>c.toBlob(b=>b?ok(b):no(new Error("toBlob")),"image/jpeg",.85));
163
+ }
164
+ async function photo(file){
165
+ let blob; try{ blob=await shrink(file); }catch{ blob=file; } // a format the browser cannot draw goes as it is
166
+ if(PH?.url) URL.revokeObjectURL(PH.url);
167
+ if(cur!=="photo"){ if(!cur) back=document.activeElement; cur="photo"; }
168
+ const mine=PH={url:URL.createObjectURL(blob),data:null,error:"",busy:true,shown:1,works:new Set(),all:false,fresh:true};
169
+ drawPhoto();
170
+ try{
171
+ const form=new FormData(); form.append("photo",blob,"problem.jpg");
172
+ const res=await api("/api/math/photo",{method:"POST",body:form}), data=await res.json().catch(()=>({}));
173
+ if(!res.ok) throw new Error(typeof data.detail==="string"?data.detail:`השרת ענה ${res.status}`);
174
+ mine.data=data;
175
+ }catch(e){ mine.error=e.message; }
176
+ mine.busy=false;
177
+ if(PH===mine&&cur==="photo") drawPhoto(); // a newer photo, or a closed sheet, keeps its own
178
+ }
179
+ function drawPhoto(){
180
+ const d=PH.data;
181
+ let body;
182
+ if(PH.busy) body='<div class="mx-wait"><span class="mx-spin" aria-hidden="true"></span><b>קוראים את התרגיל ופותרים…</b><small>זה לוקח כמה שניות.</small></div>';
183
+ else if(PH.error) body=`<div class="mx-bad"><b>לא הצלחנו</b><span>${esc(PH.error)}</span><button class="mx-btn" data-act="retake">צילום מחדש</button></div>`;
184
+ else if(!d.readable) body=`<div class="mx-bad"><b>לא הצלחנו לקרוא את התרגיל</b><span>${fmtLoose(d.note||"צלמו שוב, קרוב יותר ובאור טוב.")}</span><button class="mx-btn" data-act="retake">צילום מחדש</button></div>`;
185
+ else{
186
+ const n=d.steps.length, shown=PH.all?n:Math.min(PH.shown,n);
187
+ body=`<div class="mx-read"><small>זה התרגיל שקראנו:</small><p>${fmtLoose(d.problem)}</p><button class="mx-link" data-act="retake">לא נכון? צלמו שוב</button></div>
188
+ ${d.note?`<p class="mx-small">${fmtLoose(d.note)}</p>`:""}
189
+ <ol class="mx-steps">${d.steps.slice(0,shown).map((s,i)=>`<li><span>${fmtLoose(s.hint)}</span>
190
+ ${PH.all||PH.works.has(i)?`<div class="mx-work">${fmtLoose(s.work)}</div>`:`<button class="mx-link" data-act="work" data-i="${i}">הראו את הצעד</button>`}</li>`).join("")}</ol>
191
+ ${PH.all?`<div class="mx-ok"><b>התשובה</b><span>${fmtLoose(d.answer)}</span></div>`:`<div class="mx-row">
192
+ ${shown<n?'<button class="mx-btn solid" data-act="hint">רמז הבא</button>':""}<button class="mx-btn" data-act="all">הפתרון המלא</button></div>`}
193
+ ${d.skill&&BY[d.skill]?`<div class="mx-sec"><h4>המיומנות שהתרגיל מתרגל</h4><div class="mx-chips">${chips([d.skill])}</div></div>`:""}
194
+ ${d.left!=null?`<p class="mx-small">נשארו לכם היום ${d.left} צילומים.</p>`:""}`;
195
+ }
196
+ sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
197
+ <div class="mx-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
198
+ <div class="mx-shtop"><div><h3 id="mxTitle">פתרון מתמונה</h3><small class="mx-src">קודם רמזים, והפתרון המלא כשתבקשו.</small></div>
199
+ <button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div>
200
+ <img class="mx-thumb" src="${PH.url}" alt="התמונה ששלחתם">
201
+ <div class="mx-pbody" aria-live="polite">${body}</div></div>`;
202
+ sheet.hidden=false;
203
+ if(PH.fresh){ PH.fresh=false; sheet.querySelector(".mx-x").focus({preventScroll:true}); }
204
+ }
205
+
206
+ // a function's graph through the given points: monotone cubic (Fritsch-Carlson), so it never passes the marked extrema
207
+ function plot(g){
208
+ const W=320, H=210, PAD=24, [x0,x1]=g.x, [y0,y1]=g.y;
209
+ const sx=x=>PAD+(x-x0)/(x1-x0)*(W-2*PAD), sy=y=>H-PAD-(y-y0)/(y1-y0)*(H-2*PAD);
210
+ const xs=g.points.map(p=>p[0]), ys=g.points.map(p=>p[1]), k=xs.length;
211
+ const d=xs.slice(0,-1).map((x,i)=>(ys[i+1]-ys[i])/(xs[i+1]-x));
212
+ const m=xs.map((_,i)=>i===0?d[0]:i===k-1?d[k-2]:d[i-1]*d[i]<=0?0:(d[i-1]+d[i])/2);
213
+ d.forEach((di,i)=>{ if(di===0){ m[i]=m[i+1]=0; return; } const a=m[i]/di, b=m[i+1]/di, s=a*a+b*b;
214
+ if(s>9){ const t=3/Math.sqrt(s); m[i]=t*a*di; m[i+1]=t*b*di; } });
215
+ let line="";
216
+ for(let i=0;i<k-1;i++){ const h=xs[i+1]-xs[i];
217
+ for(let j=0;j<=20;j++){ const t=j/20, t2=t*t, t3=t2*t;
218
+ const y=(2*t3-3*t2+1)*ys[i]+(t3-2*t2+t)*h*m[i]+(-2*t3+3*t2)*ys[i+1]+(t3-t2)*h*m[i+1];
219
+ line+=`${line?"L":"M"}${sx(xs[i]+t*h).toFixed(1)},${sy(y).toFixed(1)}`; } }
220
+ let grid="", ticks="";
221
+ for(let x=Math.ceil(x0);x<=x1;x++){ grid+=`<line class="g" x1="${sx(x)}" x2="${sx(x)}" y1="${sy(y0)}" y2="${sy(y1)}"/>`;
222
+ if(x) ticks+=`<text x="${sx(x)}" y="${Math.min(sy(0)+13,H-4)}" text-anchor="middle">${x}</text>`; }
223
+ for(let y=Math.ceil(y0);y<=y1;y++){ grid+=`<line class="g" x1="${sx(x0)}" x2="${sx(x1)}" y1="${sy(y)}" y2="${sy(y)}"/>`;
224
+ if(y) ticks+=`<text x="${Math.max(sx(0)-5,10)}" y="${sy(y)+4}" text-anchor="end">${y}</text>`; }
225
+ const dots=g.points.map(([x,y])=>`<circle cx="${sx(x)}" cy="${sy(y)}" r="3.5"/>`).join("");
226
+ return `<div class="mx-plot"><svg viewBox="0 0 ${W} ${H}" role="img" aria-label="גרף הפונקציה">${grid}
227
+ <line class="a" x1="${sx(x0)}" x2="${sx(x1)}" y1="${sy(0)}" y2="${sy(0)}"/><line class="a" x1="${sx(0)}" x2="${sx(0)}" y1="${sy(y0)}" y2="${sy(y1)}"/>
228
+ ${ticks}<path class="c" d="${line}"/>${dots}</svg></div>`;
229
+ }
230
+
231
+ // the start button (and a teacher's "תרגול") opens the math path once the opening screen is gone
232
+ const arabicEnter=enter;
233
+ const enterMath=()=>{ arabicEnter(); if(!AUTH_URL||can("path.use")) show(); };
234
+ document.getElementById("startBtn").onclick=document.getElementById("practiceBtn").onclick=enterMath;
235
+
236
+ // the manage panel: "לתרגול" goes to the math path, and "תוכן" lists the math path (units and their skills) in
237
+ // place of the Arabic levels. The graph is a file in the repo (app/math_algebra9.json), so here it is only shown.
238
+ const toPractice=document.getElementById("mPractice"), arabicPractice=toPractice.onclick;
239
+ const practice=async id=>{ arabicPractice(); await show(); if(id&&G) open(id); };
240
+ toPractice.onclick=()=>practice();
241
+ const contentTab=document.querySelector('#mModes [data-mode="content"] small'); if(contentTab) contentTab.textContent="יחידות ומיומנויות";
242
+ async function drawContent(){
243
+ const box=document.getElementById("mContent");
244
+ if(!G){
245
+ box.innerHTML='<p class="mx-note">טוענים את המסלול…</p>';
246
+ try{ const r=await fetch("/api/math/skills"); if(!r.ok) throw new Error(`השרת ענה ${r.status}`); index(await r.json()); }
247
+ catch(e){ box.innerHTML=`<p>לא הצלחנו לטעון את המסלול: ${esc(e.message)}. לחצו „רענון” ונסו שוב.</p>`; return; }
248
+ }
249
+ const wide=(()=>{ try{ return JSON.parse(localStorage.getItem("ihki.math.copen")||"{}"); }catch{ return {}; } })();
250
+ box.innerHTML=`<p class="sub">המסלול של כיתה ט׳, 5 יח״ל: ${G.skills.length} מיומנויות ב־${G.units.length} יחידות, לפי סדר המסלול. לכל מיומנות שאלה לדוגמה, וכל תשובה שגויה בה היא טעות מוכרת עם הסבר. ב„תרגול” רואים את השאלה כמו שהתלמידים רואים אותה.</p>`+
251
+ G.units.map(u=>{
252
+ const mine=ORDER.filter(id=>BY[id].unit===u.id).map(id=>BY[id]);
253
+ const rows=mine.map((s,k)=>{
254
+ const needs=s.prereqs.map(p=>plain(BY[p].short)).join(", "), wrong=s.sample.choices.filter(c=>!c.correct).length;
255
+ const meta=[needs?`דורשת: ${esc(needs)}`:"בלי דרישות קודמות",s.month?`בפריסה: ${esc(s.month)}`:"",s.core?"ליבה 5 יח״ל":"",`${wrong} טעויות מוכרות`].filter(Boolean).join(" · ");
256
+ return `<li class="crow"><i>${k+1}</i><div class="two"><b>${fmt(s.name)}</b><small>${meta}</small></div><div class="acts"><button class="btn" data-mxpractice="${s.id}">תרגול</button></div></li>`;
257
+ }).join("");
258
+ return `<details class="csec" data-unit="${u.id}"${wide[u.id]?" open":""}><summary class="clevel">${esc(u.name)}<small>${mine.length} מיומנויות${u.when?` · ${esc(u.when)}`:""}</small></summary>
259
+ <div class="card"><div class="chead"><h2>${esc(u.name)}<small>${esc(u.source||"")}</small></h2></div><ol class="clist">${rows}</ol></div></details>`;
260
+ }).join("");
261
+ box.querySelectorAll("details.csec").forEach(d=>d.ontoggle=()=>{ wide[d.dataset.unit]=d.open?1:0; try{ localStorage.setItem("ihki.math.copen",JSON.stringify(wide)); }catch{} });
262
+ box.querySelectorAll("[data-mxpractice]").forEach(b=>b.onclick=()=>practice(b.dataset.mxpractice));
263
+ }
264
+ window.drawContent=drawContent;
265
+ })();
static/service-worker.js CHANGED
@@ -1,4 +1,4 @@
1
- const CACHE = "ihki-shell-v17";
2
  const SHELL = [
3
  "/static/manifest.webmanifest",
4
  "/static/icons/icon-192.png?v=2",
 
1
+ const CACHE = "ihki-shell-v18";
2
  const SHELL = [
3
  "/static/manifest.webmanifest",
4
  "/static/icons/icon-192.png?v=2",
tests/test_live.py CHANGED
@@ -43,3 +43,11 @@ def test_failed_recording_can_be_retried_and_end_erases_session():
43
  live.begin_answer(code, STUDENT["id"])
44
  live.end(code)
45
  assert live.get(code) is None
 
 
 
 
 
 
 
 
 
43
  live.begin_answer(code, STUDENT["id"])
44
  live.end(code)
45
  assert live.get(code) is None
46
+
47
+
48
+ def test_question_accepts_the_server_phrase_shape():
49
+ live = LiveStore()
50
+ code = live.start(COURSE, TEACHER)["code"]
51
+ question = live.ask(code, {"id": 13, "he": "מה שלומך?", "tr": "כִּיף חַאלַכּ?",
52
+ "variants": [("كيف حالك؟", "kiːf ħaːlak")]})["question"]
53
+ assert question["ar"] == "كيف حالك؟"
tests/test_math_skills.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import copy
2
+
3
+ from app.math_skills import algebra9, problems
4
+
5
+
6
+ def test_the_algebra_graph_is_whole():
7
+ graph = algebra9()
8
+ assert problems(graph) == []
9
+ assert len(graph["skills"]) == 56 and {u["id"] for u in graph["units"]} == {"f", "t", "n", "q", "p", "w"}
10
+ # every unit opens somewhere a student can start: foundations need nothing
11
+ assert all(s["prereqs"] == [] for s in graph["skills"] if s["id"] in ("f_neg", "f_frac"))
12
+
13
+
14
+ def test_a_broken_graph_is_named():
15
+ graph = algebra9()
16
+ skill = lambda g, sid: next(s for s in g["skills"] if s["id"] == sid)
17
+
18
+ g = copy.deepcopy(graph)
19
+ skill(g, "f_neg")["prereqs"] = ["f_ops"] # f_ops needs f_neg
20
+ assert problems(g)[0].startswith("cycle:")
21
+
22
+ g = copy.deepcopy(graph)
23
+ skill(g, "f_ops")["prereqs"].append("f_neg_typo")
24
+ assert problems(g) == ["f_ops: unknown prerequisite f_neg_typo"]
25
+
26
+ g = copy.deepcopy(graph)
27
+ skill(g, "f_like")["prereqs"].append("f_neg") # f_ops already brings it
28
+ assert problems(g) == ["f_like: f_neg is implied by f_ops"]
29
+
30
+ g = copy.deepcopy(graph)
31
+ skill(g, "f_neg")["sample"]["choices"][1] = {"text": "$−13$", "correct": True}
32
+ assert problems(g) == ["f_neg: needs exactly one correct choice"]
33
+
34
+ g = copy.deepcopy(graph)
35
+ g["mistakes"]["minus_minus"]["back"] = "t_ext" # f_ops's mistake may not send it forward
36
+ assert "f_ops: mistake minus_minus sends back to t_ext, which is not before it" in problems(g)
37
+
38
+
39
+ class FakeSolver:
40
+ def __init__(self, readable=True):
41
+ self.seen, self.readable = [], readable
42
+
43
+ def solve(self, image, media_type):
44
+ self.seen.append((image, media_type))
45
+ return {"readable": self.readable, "problem": "$x² = 9$", "steps": [{"hint": "h", "work": "w"}],
46
+ "answer": "$x = ±3$", "skill": "q_eq_simple", "note": ""}
47
+
48
+
49
+ def photo_app(monkeypatch, auth=None):
50
+ from fastapi.testclient import TestClient
51
+ from app.main import create_app
52
+ from tests.test_scoring import emissions, scorer_for
53
+
54
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
55
+ c = TestClient(create_app(scorer_for(emissions(["w"])), auth=auth))
56
+ c.__enter__()
57
+ c.app.state.photo, c.app.state.photo_key = FakeSolver(), "sk-test"
58
+ return c
59
+
60
+
61
+ def test_a_photographed_problem_is_solved(monkeypatch):
62
+ c = photo_app(monkeypatch)
63
+ send = lambda data=b"jpeg", kind="image/jpeg": c.post("/api/math/photo", files={"photo": ("p.jpg", data, kind)})
64
+ r = send()
65
+ assert r.status_code == 200 and r.json()["answer"] == "$x = ±3$" and r.json()["left"] is None # open service: no limit
66
+ assert c.app.state.photo.seen == [(b"jpeg", "image/jpeg")]
67
+ assert send(kind="application/pdf").status_code == 415 and send(data=b"").status_code == 400
68
+ assert send(data=b"x" * 5_000_001).status_code == 413 and len(c.app.state.photo.seen) == 1
69
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "") # no key: the feature says it is off
70
+ monkeypatch.delenv("CLAUDE_API_KEY", raising=False)
71
+ assert send().status_code == 503
72
+ c.__exit__(None, None, None)
73
+
74
+
75
+ def test_a_student_has_a_daily_number_of_photos(monkeypatch):
76
+ import app.main as main
77
+
78
+ class Auth:
79
+ url, key, teachers, tutors, developers = "https://p.supabase.co", "k", frozenset(), frozenset(), frozenset()
80
+ def user(self, token): return {"id": "u1", "email": "s@b.c"} if token == "s" else None
81
+ def classes(self, fresh=False): return [{"id": 1, "name": "ט", "emails": ["s@b.c"], "nicknames": {}, "lessons": []}]
82
+ def ranked(self, fresh=False): return frozenset()
83
+ def __getattr__(self, name): return lambda *a, **k: []
84
+
85
+ monkeypatch.setattr(main, "DAILY_PHOTOS", 2)
86
+ c = photo_app(monkeypatch, Auth())
87
+ send = lambda: c.post("/api/math/photo", files={"photo": ("p.jpg", b"jpeg", "image/jpeg")}, headers={"Authorization": "Bearer s"})
88
+ assert c.post("/api/math/photo", files={"photo": ("p.jpg", b"jpeg", "image/jpeg")}).status_code == 401
89
+ c.app.state.photo.readable = False # a photo it could not read does not count
90
+ assert send().json()["left"] == 2
91
+ c.app.state.photo.readable = True
92
+ assert [send().json()["left"], send().json()["left"]] == [1, 0] and send().status_code == 429
93
+ c.__exit__(None, None, None)
94
+
95
+
96
+ def test_the_path_reads_the_graph():
97
+ from fastapi.testclient import TestClient
98
+ from app.main import create_app
99
+ from tests.test_scoring import emissions, scorer_for
100
+
101
+ with TestClient(create_app(scorer_for(emissions(["w"])))) as c:
102
+ r = c.get("/api/math/skills")
103
+ assert r.status_code == 200 and r.json() == algebra9()
104
+
105
+
106
+ def test_a_math_class_studies_math_units():
107
+ from fastapi.testclient import TestClient
108
+ from app.main import create_app, ALL_LESSONS
109
+ from tests.test_scoring import emissions, scorer_for
110
+
111
+ class Auth:
112
+ url, key, teachers, tutors, developers = "https://p.supabase.co", "k", frozenset(), frozenset(), {"d@b.c"}
113
+ users_ = {t: {"id": i, "email": f"{t}@b.c", "name": t} for t, i in (("s", "u1"), ("m", "u2"), ("d", "u3"))}
114
+ def __init__(self): self.cls, self.saved = [], []
115
+ def user(self, token): return self.users_.get(token)
116
+ def users(self): return list(self.users_.values())
117
+ def classes(self, fresh=False): return self.cls
118
+ def access_for(self, user_id): return {"user_id": "u2", "roles": ["admin"], "subject": "מתמטיקה"} if user_id == "u2" else None
119
+ def access_rows(self): return [self.access_for("u2")]
120
+ def ranked(self, fresh=False): return frozenset()
121
+ def save_class(self, *args, **kw): self.saved.append(args)
122
+ def __getattr__(self, name): return lambda *a, **k: []
123
+
124
+ tok = lambda t: {"Authorization": f"Bearer {t}"}
125
+ with TestClient(create_app(scorer_for(emissions(["w"])), auth=Auth())) as c:
126
+ auth = c.app.state.auth
127
+ auth.cls = [{"id": 1, "name": "ט1", "emails": ["s@b.c"], "nicknames": {}, "lessons": ["q", "f"], "subject": "מתמטיקה"},
128
+ {"id": 2, "name": "ערבית", "emails": [], "nicknames": {}, "lessons": [], "subject": "ערבית"}]
129
+ me = c.get("/api/me", headers=tok("s")).json() # the student's path shows the class's units, in the path's order
130
+ assert me["classes"][0]["lessons"] == ["f", "q"] and me["lessons"] == []
131
+ listed = {k["name"]: k["lessons"] for k in c.get("/api/class", headers=tok("d")).json()["classes"]}
132
+ assert listed == {"ט1": ["f", "q"], "ערבית": ALL_LESSONS}
133
+ # the math admin opens a math class: units only, and no homework or daily videos even when sent
134
+ new = lambda body, who="m": c.post("/api/classes", json={"name": "ט2", "emails": "", **body}, headers=tok(who))
135
+ assert new({"lessons": [ALL_LESSONS[0]]}).status_code == 400
136
+ assert new({"lessons": ["t", "f"], "homeworks": [{"topic": "x", "due": ""}], "dailies": [{"video": 9, "date": "2026-01-01"}]}).status_code == 200
137
+ assert auth.saved[-1][4:] == (["f", "t"], [], None, "מתמטיקה")
138
+ # a developer has no subject: the page says which; an edit keeps the class's own
139
+ assert new({"lessons": ["n"], "subject": "מתמטיקה"}, "d").status_code == 200 and auth.saved[-1][4:] == (["n"], [], None, "מתמטיקה")
140
+ assert new({"lessons": ["n"], "subject": "חשבון"}, "d").status_code == 400 # no subject: Arabic lessons
141
+ assert c.put("/api/classes/1", json={"name": "ט1", "emails": "s@b.c", "lessons": ["w"]}, headers=tok("d")).status_code == 200
142
+ assert auth.saved[-1][4] == ["w"]
143
+
144
+
145
+ def test_a_space_of_its_own_opens_on_its_subject(monkeypatch):
146
+ from fastapi.testclient import TestClient
147
+ from app.main import create_app
148
+ from tests.test_scoring import emissions, scorer_for
149
+
150
+ monkeypatch.setenv("HOME_SUBJECT", "math") # mathly: "/" is the math app, and Arabic still has its own path
151
+ with TestClient(create_app(scorer_for(emissions(["w"])))) as c:
152
+ assert 'data-subject="math" data-home="math"' in c.get("/").text
153
+ assert 'data-subject="arabic" data-home="math"' in c.get("/arabic").text
154
+ monkeypatch.delenv("HOME_SUBJECT")
155
+ with TestClient(create_app(scorer_for(emissions(["w"])))) as c:
156
+ assert 'data-subject="arabic" data-home="arabic"' in c.get("/").text
tests/test_scoring.py CHANGED
@@ -2113,3 +2113,72 @@ def test_each_class_topic_opens_on_its_own_screen():
2113
  assert "__SUBJECT__" not in math and "__START_LABEL__" not in math
2114
  assert c.get("/history").status_code == 404
2115
  assert c.get("/static/logo-math.svg").status_code == 200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2113
  assert "__SUBJECT__" not in math and "__START_LABEL__" not in math
2114
  assert c.get("/history").status_code == 404
2115
  assert c.get("/static/logo-math.svg").status_code == 200
2116
+ # its installed app has its own name, colour and start page; Arabic keeps the static manifest
2117
+ assert '<meta name="theme-color" content="#000000">' in math and 'href="/math/manifest.webmanifest"' in math
2118
+ assert '"__' not in math and '<meta name="theme-color" content="#4B6A2C">' in c.get("/").text
2119
+ assert re.search(r'<script src="/static/math\.js\?v=[0-9a-f]{7}" defer>', math) # the path's script, stamped
2120
+ assert 'href="/static/manifest.webmanifest"' in c.get("/").text
2121
+ installed = c.get("/math/manifest.webmanifest")
2122
+ assert installed.headers["content-type"].startswith("application/manifest+json")
2123
+ assert {k: installed.json()[k] for k in ("id", "name", "start_url", "scope")} == {
2124
+ "id": "/math", "name": "מתמטיקה בכיף", "start_url": "/math", "scope": "/"}
2125
+ assert c.get("/arabic/manifest.webmanifest").status_code == 404
2126
+
2127
+
2128
+ def test_an_account_is_sent_to_its_subjects_pages():
2129
+ from fastapi.testclient import TestClient
2130
+ from app.main import create_app, subject_code
2131
+
2132
+ assert subject_code("מתמטיקה") == "math" and subject_code(" ערבית ") == "arabic"
2133
+ assert subject_code("חשבון") == subject_code("") == subject_code(None) == ""
2134
+
2135
+ class FakeAuth:
2136
+ url, key, teachers, tutors, developers = "https://proj.supabase.co", "sb_publishable_x", frozenset(), frozenset(), {"d@b.c"}
2137
+ users_ = {"s": {"id": "u1", "email": "s@b.c"}, "t": {"id": "u2", "email": "t@b.c", "name": "T"},
2138
+ "d": {"id": "u3", "email": "d@b.c"}}
2139
+
2140
+ def __init__(self):
2141
+ self.cls, self.access = [], {"u2": {"user_id": "u2", "roles": ["admin"], "subject": "מתמטיקה"}}
2142
+
2143
+ def user(self, token):
2144
+ return self.users_.get(token)
2145
+
2146
+ def users(self):
2147
+ return list(self.users_.values())
2148
+
2149
+ def classes(self, fresh=False):
2150
+ return self.cls
2151
+
2152
+ def access_for(self, user_id):
2153
+ return self.access.get(user_id)
2154
+
2155
+ def access_rows(self):
2156
+ return list(self.access.values())
2157
+
2158
+ def set_admin_subject(self, target, subject, actor):
2159
+ self.access[target["id"]]["subject"] = subject
2160
+
2161
+ def ranked(self, fresh=False):
2162
+ return frozenset()
2163
+
2164
+ def __getattr__(self, name): # the content tables /api/me reads: empty
2165
+ return lambda *a, **k: []
2166
+
2167
+ tok = lambda t: {"Authorization": f"Bearer {t}"}
2168
+ with TestClient(create_app(scorer_for(emissions(["w"])), auth=FakeAuth())) as c:
2169
+ auth = c.app.state.auth
2170
+ auth.cls = [{"id": 1, "name": "ט1", "emails": ["s@b.c"], "nicknames": {}, "lessons": [], "subject": "מתמטיקה"}]
2171
+ me = c.get("/api/me", headers=tok("s")).json() # a student goes where their class is
2172
+ assert me["subject"] == "math" and me["classes"][0]["subject"] == "math"
2173
+ assert c.get("/api/me", headers=tok("t")).json()["subject"] == "math" # an admin, where their own subject is
2174
+ assert "subject" not in c.get("/api/me", headers=tok("d")).json() # a developer stays on the page they opened
2175
+ auth.cls[0]["subject"] = "חשבון" # a subject outside the list sends nowhere
2176
+ assert "subject" not in c.get("/api/me", headers=tok("s")).json()
2177
+ # the developer picks an admin's subject from the list; free text is refused
2178
+ listed = c.get("/api/admin-subjects", headers=tok("d")).json()
2179
+ assert listed["subjects"] == [{"code": "arabic", "label": "ערבית"}, {"code": "math", "label": "מתמטיקה"}]
2180
+ assert listed["admins"] == [{"id": "u2", "email": "t@b.c", "name": "T", "subject": "מתמטיקה"}]
2181
+ put = lambda subject: c.put("/api/admin-subjects/u2", json={"subject": subject}, headers=tok("d"))
2182
+ assert put("חשבון").status_code == 400 and auth.access["u2"]["subject"] == "מתמטיקה"
2183
+ assert put(" ערבית ").json() == {"ok": True, "subject": "ערבית"} and auth.access["u2"]["subject"] == "ערבית"
2184
+ assert c.get("/api/me", headers=tok("t")).json()["subject"] == "arabic"