Add the math path, photographed problems, math classes, backups and the mathly Space
Browse files- .gitignore +2 -0
- README.md +9 -3
- app/auth.py +155 -2
- app/live.py +3 -1
- app/main.py +252 -25
- app/math_algebra9.json +2380 -0
- app/math_photo.py +115 -0
- app/math_skills.py +68 -0
- docs/mathly-supabase.sql +313 -0
- static/index.html +151 -86
- static/logo-math.svg +6 -6
- static/math.css +139 -0
- static/math.js +265 -0
- static/service-worker.js +1 -1
- tests/test_live.py +8 -0
- tests/test_math_skills.py +156 -0
- tests/test_scoring.py +69 -0
.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
|
| 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=
|
| 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":
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 573 |
-
|
|
|
|
| 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(
|
| 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":
|
| 615 |
-
"dailies": dailies_of(c, daily_rows())
|
| 616 |
-
|
|
|
|
| 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":
|
| 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":
|
| 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
|
| 1016 |
-
raise HTTPException(400, "subject must
|
| 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(
|
| 1704 |
-
raise HTTPException(400, "בחרו לפחות שיעור אחד לכיתה")
|
| 1705 |
-
lessons = [l for l in
|
| 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="
|
| 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="
|
| 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(#
|
| 744 |
-
#manage.content :is(#
|
| 745 |
-
#manage.flags :is(#mClasses,#mContent,#
|
| 746 |
-
#manage.access :is(#mClasses,#mContent,#mFlags,#
|
| 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(#
|
| 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:#
|
| 1232 |
-
html[data-subject="math"] #mailForm
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 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 דנה 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=
|
| 2444 |
let LIVE={sessions:[],teaches:[],code:"",timer:null,rec:null,busy:false};
|
| 2445 |
const liveEsc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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();
|
| 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("
|
| 4768 |
-
who.append(mark("",admin.name||admin.email,"b"),mark("",admin.email,"small"));
|
|
|
|
|
|
|
|
|
|
| 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("
|
|
|
|
| 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
|
|
|
|
|
|
|
| 5445 |
pick.innerHTML="";
|
| 5446 |
-
|
|
|
|
|
|
|
| 5447 |
const row=mark("lvl",""), head=mark("lvlhead","","label"), all=document.createElement("input"), chips=mark("chips",""), boxes=[];
|
| 5448 |
-
all.type="checkbox"; head.append(all,
|
| 5449 |
-
for(const
|
| 5450 |
-
const box=document.createElement("input"), lab=mark(
|
| 5451 |
-
box.type="checkbox"; box.value=
|
| 5452 |
-
lab.append(box,
|
| 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();
|
| 5456 |
-
boxes.forEach(b=>b.onchange=()=>{ sync();
|
| 5457 |
}
|
| 5458 |
-
editDays=Object.fromEntries((c&&c.dailies||[]).map(d=>[d.video,d.date]));
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 5535 |
-
$("
|
| 5536 |
-
$("
|
|
|
|
| 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("
|
|
|
|
| 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
|
| 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:
|
|
|
|
| 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 דנה 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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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-
|
| 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"
|