3v324v23 commited on
Commit
afd13cb
·
1 Parent(s): 4aac055

feat: add AI queue for math requests, including database migration and tests

Browse files

- Implemented a new table `math_ai_requests` to handle student requests for AI assistance in math problems.
- Created tests for the AI queue functionality, ensuring proper request handling and state management.
- Added browser tests to verify the user guide's accessibility and functionality for both students and teachers.
- Developed a class report feature for teachers to view student progress and grades.

README.md CHANGED
@@ -18,6 +18,10 @@ Photographed problems are sent to the configured AI provider for solving and are
18
 
19
  When the computer asks for a photo of a handwritten answer, the student's phone can get a notification, even with the app closed. The phone offers it after its first photo is sent. It needs `VAPID_PRIVATE_KEY` (make one with `python -m app.push`) and the migration `supabase/migrations/20261103180000_math_push_subs.sql`. On an iPhone it works only from the app added to the home screen.
20
 
 
 
 
 
21
  ## Science riddles (class vote)
22
 
23
  The **מדעים** subject has a unit **חידות** with the skill **טיל**: the class guesses how much fuel a rocket needs. The teacher opens a vote and shows its 4-digit code; students join with the code on their own devices and vote. Votes stay hidden until the teacher presses check; the answer most students chose is launched, and the rocket flies, falls back (too little fuel) or stays on the pad (too heavy). A tie is not launched: students can change their vote and the teacher checks again. Riddles are in `app/science.json`. Votes live in the server's memory (the app runs as one process), so a restart ends the votes in progress. No sign-in or migration is needed.
@@ -72,6 +76,18 @@ Checks and feedback are stored privately against the original attempt and photo,
72
 
73
  For rollback, turn `MATHLY_ANSWER_SCANS_ENABLED` off and restart; preserve the new tables and private bucket. The skills API serves the original multiple-choice geometry sample while scanning is disabled, including for guest practice and older frontends. Enabling scanning again restores open-ended presentation and access to saved answers. Keep stored answer records for recovery. No destructive down migration is needed.
74
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  ### Lesson resources (teachers)
76
 
77
  A teacher taps a unit's title on the path (for example **טכניקה אלגברית 1**), photographs an exercise or an explanation, and saves it to that lesson's resources. Each photo is marked as **תרגיל חדש**, **הסבר תיאורטי**, or **אחר** (with a short free-text description), and can optionally name the topic it belongs to (for example **פילוג מורחב**). Apply [the additive resources migration](supabase/migrations/20261101090000_math_unit_resources.sql); it adds the `math_unit_resources` table and the private `mathly-resources` bucket. Only accounts with the teacher role see, add, or open resources, and only the teacher who added a photo (or a manager) can delete it. Students do not see resources yet.
 
18
 
19
  When the computer asks for a photo of a handwritten answer, the student's phone can get a notification, even with the app closed. The phone offers it after its first photo is sent. It needs `VAPID_PRIVATE_KEY` (make one with `python -m app.push`) and the migration `supabase/migrations/20261103180000_math_push_subs.sql`. On an iPhone it works only from the app added to the home screen.
20
 
21
+ ## User guide
22
+
23
+ The **?** button on the sign-in screen and in the app's header opens a short guide in Hebrew, with a tab for students and one for teachers. Its text is in `static/guide.js`; update it there when a feature changes.
24
+
25
  ## Science riddles (class vote)
26
 
27
  The **מדעים** subject has a unit **חידות** with the skill **טיל**: the class guesses how much fuel a rocket needs. The teacher opens a vote and shows its 4-digit code; students join with the code on their own devices and vote. Votes stay hidden until the teacher presses check; the answer most students chose is launched, and the rocket flies, falls back (too little fuel) or stays on the pad (too heavy). A tie is not launched: students can change their vote and the teacher checks again. Riddles are in `app/science.json`. Votes live in the server's memory (the app runs as one process), so a restart ends the votes in progress. No sign-in or migration is needed.
 
76
 
77
  For rollback, turn `MATHLY_ANSWER_SCANS_ENABLED` off and restart; preserve the new tables and private bucket. The skills API serves the original multiple-choice geometry sample while scanning is disabled, including for guest practice and older frontends. Enabling scanning again restores open-ended presentation and access to saved answers. Keep stored answer records for recovery. No destructive down migration is needed.
78
 
79
+ ### Class teacher
80
+
81
+ Each class has one teacher, chosen from its students in the class editor (**ניהול → כיתות → עריכה → מורה הכיתה**). That account gets a **הכיתה שלי** button that opens the class report: for every other student, the skills finished in the class's units, the mistakes that keep coming back, the **בוחן מסכם** grade per unit, and the **מבחני תרגול** results, plus the mistakes most common across the class. Managers open the same report from a class's card (**מעקב**). The report reads the students' saved progress (`math_progress`) and Supabase's user list with the server's secret key; it needs no new migration. A student's numbers show up once they have signed in and practised on the current version of the app.
82
+
83
+ ### The teacher's AI queue
84
+
85
+ Students never call the AI themselves. When a student saves an open answer (or hands in a **בוחן מסכם**, or sends a photographed problem), the request goes to their class teacher's **תור AI** instead of to Claude. The teacher sees each waiting request with the student's photo and the question, then presses **שליחה ל-AI** or **דחייה** (with an optional note for the student). The student's page shows the answer as waiting for the teacher, then the grade and feedback once it is sent, or the teacher's note if it was declined (the student can ask again). A failed send stays in the queue for the teacher to retry. Managers see every class's requests.
86
+
87
+ Accounts with the teacher, admin or developer role, and every class teacher, still send to the AI directly. A student can have at most 5 requests waiting; the daily limits on checks and photos still apply. A photographed problem is kept (as a clean JPEG) only until the teacher sends or declines it.
88
+
89
+ Apply [the additive queue migration](supabase/migrations/20261104090000_math_ai_requests.sql) before deploying this code: it adds the service-only `math_ai_requests` table. Without it, students cannot have answers checked (they see that checking is unavailable); teachers are not affected.
90
+
91
  ### Lesson resources (teachers)
92
 
93
  A teacher taps a unit's title on the path (for example **טכניקה אלגברית 1**), photographs an exercise or an explanation, and saves it to that lesson's resources. Each photo is marked as **תרגיל חדש**, **הסבר תיאורטי**, or **אחר** (with a short free-text description), and can optionally name the topic it belongs to (for example **פילוג מורחב**). Apply [the additive resources migration](supabase/migrations/20261101090000_math_unit_resources.sql); it adds the `math_unit_resources` table and the private `mathly-resources` bucket. Only accounts with the teacher role see, add, or open resources, and only the teacher who added a photo (or a manager) can delete it. Students do not see resources yet.
app/ai_queue.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The teacher's AI queue: a student never calls Claude. A teacher reviews each request and sends it.
2
+
3
+ A student's photographed problem (POST /api/math/photo) or the answer they want checked
4
+ (POST /api/math/answer-attempts/{id}/grade, and the automatic check after an upload) becomes a request in the
5
+ Supabase table `math_ai_requests`. The class's teacher (the account chosen in the class editor) sees the requests of
6
+ their class's students, and a manager sees every request. The teacher looks at the photo and either sends it to the
7
+ AI or declines it with an optional note. The student's page shows the request waiting, and then its result.
8
+
9
+ Who sends straight to the AI, with no queue: accounts with the teacher, admin or developer role, a class's
10
+ teacher, and everyone when sign-in is off (local development: there are no students to hold back).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import logging
16
+ import urllib.parse
17
+ from datetime import datetime, timedelta, timezone
18
+
19
+ from fastapi import Depends
20
+ from fastapi.responses import JSONResponse, Response
21
+
22
+ from .access import has_permission
23
+ from .answer_scans import ScanError, uuid_text
24
+ from .auth import AuthUnavailable, ClassConflict
25
+ from .math_photo import PhotoUnavailable
26
+
27
+ log = logging.getLogger("mathly.ai_queue")
28
+ MIGRATION = "supabase/migrations/20261104090000_math_ai_requests.sql"
29
+ TABLE = "/rest/v1/math_ai_requests"
30
+ FIELDS = ("id,created_at,user_id,email,kind,state,attempt_id,label,question,result,note,error,"
31
+ "reviewed_by,sent_at,finished_at")
32
+ OPEN = ("pending", "sending")
33
+ MAX_OPEN = 5 # a student's requests waiting at once
34
+ STALE_SEND = timedelta(minutes=5) # a send that never finished (a restart) may be sent again after this
35
+ RECENT = timedelta(hours=24) # handled requests stay on the teacher's list this long
36
+ DIRECT_ROLES = frozenset({"teacher", "admin", "developer"})
37
+
38
+
39
+ def may_send(user: dict | None) -> bool:
40
+ """Whether this account calls the AI itself. Everyone else asks their teacher."""
41
+ if user is None:
42
+ return True # sign-in is off
43
+ if DIRECT_ROLES & set(user.get("roles", ())) or "*" in user.get("permissions", ()):
44
+ return True
45
+ email = str(user.get("email", "")).lower()
46
+ return any(email in row.get("teachers", ()) for row in user.get("classes", ()))
47
+
48
+
49
+ def now() -> str:
50
+ return datetime.now(timezone.utc).isoformat()
51
+
52
+
53
+ def stamp(when: datetime) -> str:
54
+ return urllib.parse.quote(when.isoformat())
55
+
56
+
57
+ class AiQueue:
58
+ def __init__(self, auth):
59
+ self.auth = auth
60
+ self._warned = False
61
+
62
+ @property
63
+ def configured(self) -> bool:
64
+ return bool(self.auth is not None and getattr(self.auth, "secret", None))
65
+
66
+ def _rest(self, query: str = "", method: str = "GET", body=None):
67
+ if not self.configured:
68
+ raise ScanError(503, "queue_unavailable")
69
+ try:
70
+ return self.auth._admin(TABLE + query, method, body)
71
+ except ClassConflict as exc:
72
+ raise ScanError(409, "already_queued") from exc
73
+ except AuthUnavailable as exc:
74
+ if "answered 404" in str(exc) and not self._warned:
75
+ log.warning("the math_ai_requests table is missing; apply %s", MIGRATION)
76
+ self._warned = True
77
+ else:
78
+ log.warning("AI queue: %s", exc)
79
+ raise ScanError(503, "queue_unavailable") from exc
80
+
81
+ # ---- the student's side
82
+ def add(self, row: dict) -> dict:
83
+ """A new pending request. For an answer that already has one open, that one."""
84
+ try:
85
+ return self._rest("?select=" + FIELDS, "POST", row)[0]
86
+ except ScanError as exc:
87
+ if exc.code == "already_queued" and row.get("attempt_id"):
88
+ found = self.for_attempt(row["user_id"], row["attempt_id"])
89
+ if found:
90
+ return found
91
+ raise
92
+
93
+ def for_attempt(self, user_id: str, attempt_id: str) -> dict | None:
94
+ rows = self._rest(f"?select={FIELDS}&user_id=eq.{user_id}&attempt_id=eq.{attempt_id}"
95
+ "&order=created_at.desc&limit=1")
96
+ return rows[0] if rows else None
97
+
98
+ def open_count(self, user_id: str) -> int:
99
+ return len(self._rest(f"?select=id&user_id=eq.{user_id}&state=in.({','.join(OPEN)})&limit={MAX_OPEN + 1}"))
100
+
101
+ def mine(self, user_id: str, limit: int = 20) -> list[dict]:
102
+ return self._rest(f"?select={FIELDS}&user_id=eq.{user_id}&kind=eq.photo&order=created_at.desc&limit={limit}")
103
+
104
+ def cancel(self, user_id: str, request_id: str) -> dict | None:
105
+ rows = self._rest(f"?id=eq.{request_id}&user_id=eq.{user_id}&state=eq.pending&select={FIELDS}", "PATCH",
106
+ {"state": "cancelled", "image": None, "finished_at": now()})
107
+ return rows[0] if rows else None
108
+
109
+ def reopen(self, request_id: str) -> dict:
110
+ """A failed request the student asks for again goes back to pending, instead of a second request."""
111
+ rows = self._rest(f"?id=eq.{request_id}&state=in.(failed,declined)&select={FIELDS}", "PATCH",
112
+ {"state": "pending", "error": "", "note": "", "finished_at": None})
113
+ if not rows:
114
+ raise ScanError(409, "request_taken")
115
+ return rows[0]
116
+
117
+ # ---- the teacher's side
118
+ def listing(self, emails: set[str] | None, limit: int = 100) -> list[dict]:
119
+ """Open requests, and the ones handled in the last day. `emails`: only these students (None: all)."""
120
+ if emails is not None and not emails:
121
+ return []
122
+ who = ""
123
+ if emails is not None:
124
+ names = ",".join('"' + e.replace('"', "") + '"' for e in sorted(emails))
125
+ who = "&email=in." + urllib.parse.quote(f"({names})", safe="(),")
126
+ since = stamp(datetime.now(timezone.utc) - RECENT)
127
+ return self._rest(f"?select={FIELDS}{who}&or=(state.in.(pending,sending,failed),finished_at.gte.{since})"
128
+ f"&order=created_at.desc&limit={limit}")
129
+
130
+ def get(self, request_id: str, image: bool = False) -> dict | None:
131
+ rows = self._rest(f"?select={FIELDS}{',image' if image else ''}&id=eq.{request_id}")
132
+ return rows[0] if rows else None
133
+
134
+ def claim(self, request_id: str, reviewer: str) -> dict:
135
+ """Takes the request for sending. Two teachers clicking at once: one gets it, the other 409."""
136
+ stale = stamp(datetime.now(timezone.utc) - STALE_SEND)
137
+ rows = self._rest(f"?id=eq.{request_id}&or=(state.in.(pending,failed),and(state.eq.sending,sent_at.lt.{stale}))"
138
+ f"&select={FIELDS},image", "PATCH",
139
+ {"state": "sending", "sent_at": now(), "reviewed_by": reviewer, "error": ""})
140
+ if not rows:
141
+ raise ScanError(409, "request_taken")
142
+ return rows[0]
143
+
144
+ def finish(self, request_id: str, state: str, **fields) -> dict:
145
+ rows = self._rest(f"?id=eq.{request_id}&select={FIELDS}", "PATCH",
146
+ {"state": state, "finished_at": now(), **fields})
147
+ return rows[0] if rows else {}
148
+
149
+ def decline(self, request_id: str, reviewer: str, note: str) -> dict:
150
+ rows = self._rest(f"?id=eq.{request_id}&state=in.(pending,failed)&select={FIELDS}", "PATCH",
151
+ {"state": "declined", "image": None, "note": note, "reviewed_by": reviewer,
152
+ "finished_at": now()})
153
+ if not rows:
154
+ raise ScanError(409, "request_taken")
155
+ return rows[0]
156
+
157
+
158
+ def for_student(row: dict | None) -> dict | None:
159
+ """What the student's page reads of a request: no teacher email, no other student's data."""
160
+ if not row:
161
+ return None
162
+ return {k: row.get(k) for k in ("id", "created_at", "kind", "state", "label", "result", "note", "finished_at")}
163
+
164
+
165
+ def install_ai_queue_routes(app, signed_in):
166
+ if not hasattr(app.state, "ai_queue"):
167
+ app.state.ai_queue = AiQueue(app.state.auth)
168
+
169
+ def queue() -> AiQueue:
170
+ return app.state.ai_queue
171
+
172
+ def account(user=Depends(signed_in)) -> dict:
173
+ if not user or not user.get("id"):
174
+ raise ScanError(401, "sign_in_required")
175
+ return user
176
+
177
+ def watched(user: dict) -> set[str] | None:
178
+ """The students whose requests this account reviews; None: everyone's (a manager)."""
179
+ if has_permission(user, "management.access"):
180
+ return None
181
+ email = str(user.get("email", "")).lower()
182
+ try:
183
+ classes = app.state.auth.classes()
184
+ except AuthUnavailable as exc:
185
+ raise ScanError(503, "queue_unavailable") from exc
186
+ out: set[str] = set()
187
+ for row in classes:
188
+ if email in row.get("teachers", ()):
189
+ out |= set(row.get("emails", ())) - set(row.get("teachers", ()))
190
+ return out
191
+
192
+ def reviewer(user=Depends(account)) -> dict:
193
+ if not may_send(user) or watched(user) == set(): # a teacher with no class has no one to review
194
+ raise ScanError(403, "teacher_required")
195
+ return user
196
+
197
+ def reviewed(user: dict, request_id: str, image: bool = False) -> dict:
198
+ row = queue().get(uuid_text(request_id), image=image)
199
+ emails = watched(user)
200
+ if not row or (emails is not None and row["email"] not in emails):
201
+ raise ScanError(404, "not_found")
202
+ return row
203
+
204
+ def failure(code: str, reason: str = "", detail: str = "") -> str:
205
+ """A failed send's `error`: "code|reason|detail", read by the teacher's error details (math.js)."""
206
+ return "|".join([code, reason or "", " ".join((detail or "").split())])[:300]
207
+
208
+ def reply(value, status=200):
209
+ return JSONResponse(value, status_code=status, headers={"Cache-Control": "no-store"})
210
+
211
+ def note(user, kind, row):
212
+ actions = getattr(app.state, "actions", None)
213
+ if actions is not None:
214
+ what = row.get("label") or ("תרגיל מצולם" if row.get("kind") == "photo" else "")
215
+ actions.record(user, kind, " · ".join(filter(None, [what, row.get("email", "")])))
216
+
217
+ # ---- the student
218
+ @app.get("/api/math/ai-requests")
219
+ def my_requests(user=Depends(account)):
220
+ return reply({"direct": may_send(user), "requests": [for_student(r) for r in queue().mine(user["id"])]})
221
+
222
+ @app.post("/api/math/ai-requests/{request_id}/cancel")
223
+ def cancel_request(request_id: str, user=Depends(account)):
224
+ row = queue().cancel(user["id"], uuid_text(request_id))
225
+ if row is None:
226
+ raise ScanError(409, "request_taken")
227
+ return reply(for_student(row))
228
+
229
+ # ---- the teacher
230
+ @app.get("/api/ai-queue")
231
+ def listing(user=Depends(reviewer)):
232
+ rows = queue().listing(watched(user))
233
+ return reply({"requests": rows, "all": has_permission(user, "management.access")})
234
+
235
+ @app.get("/api/ai-queue/{request_id}/image")
236
+ def request_image(request_id: str, user=Depends(reviewer)):
237
+ row = reviewed(user, request_id, image=True)
238
+ if row["kind"] == "photo":
239
+ if not row.get("image"):
240
+ raise ScanError(404, "not_found")
241
+ data = base64.b64decode(row["image"])
242
+ else:
243
+ answer = app.state.answers.action(row["user_id"], "get", attempt_id=row["attempt_id"])
244
+ if not answer.get("attachment"):
245
+ raise ScanError(404, "not_found")
246
+ data = app.state.answers.read(answer["attachment"]["object_key"])
247
+ return Response(data, media_type="image/jpeg",
248
+ headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"})
249
+
250
+ @app.post("/api/ai-queue/{request_id}/send")
251
+ def send(request_id: str, user=Depends(reviewer)):
252
+ reviewed(user, request_id) # visibility first: another class's request is "not found"
253
+ row = queue().claim(uuid_text(request_id), user.get("email", ""))
254
+ if row["kind"] == "photo":
255
+ row = send_photo(row)
256
+ else:
257
+ row = send_answer(row)
258
+ note(user, "ai_sent" if row.get("state") == "done" else "ai_failed", row)
259
+ return reply(row)
260
+
261
+ def send_photo(row: dict) -> dict:
262
+ from . import ai_usage
263
+ try:
264
+ result = app.state.solve_photo(base64.b64decode(row.get("image") or ""), "image/jpeg")
265
+ except PhotoUnavailable as exc:
266
+ return queue().finish(row["id"], "failed", error=failure("photo_failed", exc.reason, exc.detail))
267
+ except Exception as exc:
268
+ log.error("AI queue photo failed: %s", type(exc).__name__)
269
+ return queue().finish(row["id"], "failed", error=failure(
270
+ "photo_failed", "server_error", f"{type(exc).__name__}: {str(exc)[:200]}"))
271
+ ai_usage.record(app.state.auth, row["user_id"], "photo", result.pop("usage", None))
272
+ return queue().finish(row["id"], "done", result=result, image=None)
273
+
274
+ def send_answer(row: dict) -> dict:
275
+ try:
276
+ result = app.state.grade_attempt(row["user_id"], row["attempt_id"])
277
+ except ScanError as exc:
278
+ return queue().finish(row["id"], "failed", error=failure(exc.code, exc.reason, exc.detail))
279
+ grading = (result or {}).get("grading") or {}
280
+ if grading.get("state") == "completed":
281
+ return queue().finish(row["id"], "done", result={"verdict": grading["result"].get("verdict"),
282
+ "score": grading["result"].get("score")})
283
+ return queue().finish(row["id"], "failed", error=failure(grading.get("error") or "grading_failed"))
284
+
285
+ @app.post("/api/ai-queue/{request_id}/decline")
286
+ def decline(request_id: str, body: dict | None = None, user=Depends(reviewer)):
287
+ reviewed(user, request_id)
288
+ text = " ".join(str((body or {}).get("note") or "").split())[:500]
289
+ row = queue().decline(uuid_text(request_id), user.get("email", ""), text)
290
+ note(user, "ai_declined", row)
291
+ return reply(row)
app/answer_grading.py CHANGED
@@ -6,7 +6,7 @@ import json
6
  import logging
7
 
8
  from .ai_usage import usage_of
9
- from .math_photo import PhotoUnavailable, math_model, photo_client
10
 
11
  log = logging.getLogger("mathly.answer_grading")
12
  BOOLEAN_FIELDS = ("readable", "confident", "answer_correct", "reasoning_correct",
@@ -89,13 +89,19 @@ class AnswerGrader:
89
  )
90
  except anthropic.APIError as exc:
91
  # Do not log student work or provider response bodies.
92
- log.warning("answer grading provider failed: %s", type(exc).__name__)
93
- raise PhotoUnavailable("הבדיקה אינה זמינה כרגע. נסו שוב בעוד דקה.") from exc
 
94
  if response.stop_reason != "end_turn":
95
- raise PhotoUnavailable("הבדיקה לא הושלמה. נסו שוב.")
 
96
  try:
97
  value = json.loads(next(b.text for b in response.content if b.type == "text"))
98
  except (ValueError, StopIteration) as exc:
99
- raise PhotoUnavailable("הבדיקה לא הושלמה. נסו שוב.") from exc
 
 
 
 
100
  # The route pops "usage" before storing the result and records it (ai_usage.py).
101
- return {**validate_assessment(value), "model": response.model, "usage": usage_of(response)}
 
6
  import logging
7
 
8
  from .ai_usage import usage_of
9
+ from .math_photo import PhotoUnavailable, failure_reason, math_model, photo_client
10
 
11
  log = logging.getLogger("mathly.answer_grading")
12
  BOOLEAN_FIELDS = ("readable", "confident", "answer_correct", "reasoning_correct",
 
89
  )
90
  except anthropic.APIError as exc:
91
  # Do not log student work or provider response bodies.
92
+ reason, detail = failure_reason(exc)
93
+ log.warning("answer grading provider failed: %s (%s)", reason, detail)
94
+ raise PhotoUnavailable("הבדיקה אינה זמינה כרגע. נסו שוב בעוד דקה.", reason, detail) from exc
95
  if response.stop_reason != "end_turn":
96
+ raise PhotoUnavailable("הבדיקה לא הושלמה. נסו שוב.", "refused" if response.stop_reason == "refusal"
97
+ else "incomplete", f"stop_reason={response.stop_reason} ({response.model})")
98
  try:
99
  value = json.loads(next(b.text for b in response.content if b.type == "text"))
100
  except (ValueError, StopIteration) as exc:
101
+ raise PhotoUnavailable("הבדיקה לא הושלמה. נסו שוב.", "bad_output", "the answer was not JSON") from exc
102
+ try:
103
+ checked = validate_assessment(value)
104
+ except PhotoUnavailable as exc:
105
+ raise PhotoUnavailable(str(exc), "bad_output", "the answer did not pass the rubric's checks") from exc
106
  # The route pops "usage" before storing the result and records it (ai_usage.py).
107
+ return {**checked, "model": response.model, "usage": usage_of(response)}
app/answer_routes.py CHANGED
@@ -10,6 +10,7 @@ from fastapi.responses import JSONResponse, Response
10
  from starlette.concurrency import run_in_threadpool
11
 
12
  from . import ai_usage
 
13
  from .answer_scans import AnswerScans, MAX_IMAGE, ScanError, clean_image, public_result, uuid_text
14
  from .answer_grading import AnswerGrader
15
  from .math_photo import PhotoUnavailable
@@ -35,10 +36,15 @@ def grading_key():
35
  def install_answer_routes(app, signed_in):
36
  if not hasattr(app.state, "answers"):
37
  app.state.answers = AnswerScans(app.state.auth)
 
 
38
 
39
  @app.exception_handler(ScanError)
40
  async def scan_error(_request, exc):
41
- return JSONResponse({"detail": exc.code}, status_code=exc.status,
 
 
 
42
  headers={"Cache-Control": "no-store"})
43
 
44
  def student(user=Depends(signed_in)):
@@ -58,6 +64,12 @@ def install_answer_routes(app, signed_in):
58
  # A staged deployment must not block saved images or upload receipt.
59
  result["grading"] = None
60
  result["grading_available"] = False
 
 
 
 
 
 
61
  return result
62
 
63
  def note(user, kind, result, extra=""):
@@ -110,10 +122,13 @@ def install_answer_routes(app, signed_in):
110
  return reply(call(user, "get", attempt_id=uuid_text(attempt_id)))
111
 
112
  @app.post("/api/math/answer-attempts/{attempt_id}/grade")
113
- def grade_answer(attempt_id: str, body: dict | None = None, user=Depends(student)):
114
- return evaluate_answer(user, uuid_text(attempt_id), automatic=bool(body and body.get("automatic") is True))
 
115
 
116
- def evaluate_answer(user, aid, *, automatic=False):
 
 
117
  if open_ended_locked():
118
  raise ScanError(403, "open_ended_locked") # before any storage read or provider call
119
  result = app.state.answers.action(user, "get", attempt_id=aid)
@@ -124,7 +139,9 @@ def install_answer_routes(app, signed_in):
124
  return reply(call(user, "get", attempt_id=aid))
125
  key = grading_key()
126
  if not key:
127
- raise ScanError(503, "grading_unavailable")
 
 
128
  lease = str(uuid.uuid4())
129
  grading = app.state.answers.grade(user, "start", attempt_id=aid, lease_id=lease)
130
  if grading["state"] != "grading" or grading["lease_id"] != lease:
@@ -144,14 +161,43 @@ def install_answer_routes(app, signed_in):
144
  pass # an uncertain commit may already have stored the successful check
145
  if isinstance(exc, ScanError):
146
  raise
147
- if not isinstance(exc, PhotoUnavailable):
 
 
148
  log.error("answer grader failed: %s", type(exc).__name__)
149
- raise ScanError(503, "grading_failed") from exc
 
150
  return reply(call(user, "get", attempt_id=aid))
151
 
152
- def automatic_grade(user, attempt_id):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  try:
154
- evaluate_answer(user, attempt_id, automatic=True)
155
  except ScanError as exc:
156
  # Upload is already committed. Keep failures separate and retryable.
157
  log.info("automatic answer grading stopped: %s", exc.code)
@@ -205,7 +251,7 @@ def install_answer_routes(app, signed_in):
205
 
206
  @app.post("/api/math/answer-scans/{scan_id}/uploads/{lease_id}")
207
  async def upload(scan_id: str, lease_id: str, background_tasks: BackgroundTasks,
208
- photo: UploadFile = File(...), user=Depends(student)):
209
  sid, lease = uuid_text(scan_id), uuid_text(lease_id)
210
  data = await photo.read(MAX_IMAGE + 1)
211
  result = await run_in_threadpool(submit, user, sid, lease, data, photo.content_type)
@@ -213,7 +259,7 @@ def install_answer_routes(app, signed_in):
213
  # a quiz answer is graded only once the quiz is handed in (the page asks for it then)
214
  quiz = str(result.get("attempt", {}).get("question_id", "")).startswith("quiz:")
215
  if result.get("grading_available") and not result.get("grading") and not quiz:
216
- background_tasks.add_task(automatic_grade, user, result["attempt"]["id"])
217
  return reply(result)
218
 
219
  def submit(user, sid, lease, data, content_type):
 
10
  from starlette.concurrency import run_in_threadpool
11
 
12
  from . import ai_usage
13
+ from .ai_queue import AiQueue, for_student, may_send
14
  from .answer_scans import AnswerScans, MAX_IMAGE, ScanError, clean_image, public_result, uuid_text
15
  from .answer_grading import AnswerGrader
16
  from .math_photo import PhotoUnavailable
 
36
  def install_answer_routes(app, signed_in):
37
  if not hasattr(app.state, "answers"):
38
  app.state.answers = AnswerScans(app.state.auth)
39
+ if not hasattr(app.state, "ai_queue"):
40
+ app.state.ai_queue = AiQueue(app.state.auth)
41
 
42
  @app.exception_handler(ScanError)
43
  async def scan_error(_request, exc):
44
+ body = {"detail": exc.code}
45
+ if exc.reason:
46
+ body.update(reason=exc.reason, error_detail=exc.detail)
47
+ return JSONResponse(body, status_code=exc.status,
48
  headers={"Cache-Control": "no-store"})
49
 
50
  def student(user=Depends(signed_in)):
 
64
  # A staged deployment must not block saved images or upload receipt.
65
  result["grading"] = None
66
  result["grading_available"] = False
67
+ result["ai_request"] = None # the teacher's queue (ai_queue.py): this answer's latest request
68
+ if app.state.ai_queue.configured and (result.get("grading") or {}).get("state") != "completed":
69
+ try:
70
+ result["ai_request"] = for_student(app.state.ai_queue.for_attempt(user, result["attempt"]["id"]))
71
+ except ScanError:
72
+ pass
73
  return result
74
 
75
  def note(user, kind, result, extra=""):
 
122
  return reply(call(user, "get", attempt_id=uuid_text(attempt_id)))
123
 
124
  @app.post("/api/math/answer-attempts/{attempt_id}/grade")
125
+ def grade_answer(attempt_id: str, body: dict | None = None, user=Depends(student), account=Depends(signed_in)):
126
+ return evaluate_answer(user, uuid_text(attempt_id), automatic=bool(body and body.get("automatic") is True),
127
+ account=account)
128
 
129
+ def evaluate_answer(user, aid, *, automatic=False, account=None):
130
+ """Checks the answer with the AI. `account`: the signed-in student asking; one who may not call the AI
131
+ (ai_queue.may_send) puts the answer in their teacher's queue instead. None: the teacher's queue sending it."""
132
  if open_ended_locked():
133
  raise ScanError(403, "open_ended_locked") # before any storage read or provider call
134
  result = app.state.answers.action(user, "get", attempt_id=aid)
 
139
  return reply(call(user, "get", attempt_id=aid))
140
  key = grading_key()
141
  if not key:
142
+ raise ScanError(503, "grading_unavailable", "missing_key", "ANTHROPIC_API_KEY is not set")
143
+ if account is not None and not may_send(account):
144
+ return ask_teacher(user, account, aid, result, automatic)
145
  lease = str(uuid.uuid4())
146
  grading = app.state.answers.grade(user, "start", attempt_id=aid, lease_id=lease)
147
  if grading["state"] != "grading" or grading["lease_id"] != lease:
 
161
  pass # an uncertain commit may already have stored the successful check
162
  if isinstance(exc, ScanError):
163
  raise
164
+ if isinstance(exc, PhotoUnavailable):
165
+ reason, detail = exc.reason or "provider_error", exc.detail
166
+ else:
167
  log.error("answer grader failed: %s", type(exc).__name__)
168
+ reason, detail = "server_error", f"{type(exc).__name__}: {' '.join(str(exc).split())[:200]}"
169
+ raise ScanError(503, "grading_failed", reason, detail) from exc
170
  return reply(call(user, "get", attempt_id=aid))
171
 
172
+ def ask_teacher(user, account, aid, result, automatic):
173
+ queue = app.state.ai_queue
174
+ if not queue.configured:
175
+ raise ScanError(503, "grading_unavailable")
176
+ latest = queue.for_attempt(user, aid)
177
+ if latest and latest["state"] in ("pending", "sending"):
178
+ pass # already waiting
179
+ elif latest and automatic:
180
+ pass # declined, failed or done before: only the student's own click asks again
181
+ elif latest and latest["state"] in ("failed", "declined"):
182
+ queue.reopen(latest["id"])
183
+ note(user, "ai_request", result)
184
+ else:
185
+ snapshot = result["attempt"].get("snapshot") or {}
186
+ queue.add({"user_id": user, "email": str(account.get("email", "")).lower(), "kind": "grading",
187
+ "attempt_id": aid, "label": str(snapshot.get("name", ""))[:300],
188
+ "question": str(snapshot.get("question", ""))[:4000]})
189
+ note(user, "ai_request", result)
190
+ return reply(call(user, "get", attempt_id=aid))
191
+
192
+ # the teacher's queue sends a student's answer: the same check, with no queue in front of it
193
+ def grade_for_queue(user_id, aid):
194
+ evaluate_answer(user_id, aid)
195
+ return call(user_id, "get", attempt_id=aid)
196
+ app.state.grade_attempt = grade_for_queue
197
+
198
+ def automatic_grade(user, attempt_id, account=None):
199
  try:
200
+ evaluate_answer(user, attempt_id, automatic=True, account=account)
201
  except ScanError as exc:
202
  # Upload is already committed. Keep failures separate and retryable.
203
  log.info("automatic answer grading stopped: %s", exc.code)
 
251
 
252
  @app.post("/api/math/answer-scans/{scan_id}/uploads/{lease_id}")
253
  async def upload(scan_id: str, lease_id: str, background_tasks: BackgroundTasks,
254
+ photo: UploadFile = File(...), user=Depends(student), account=Depends(signed_in)):
255
  sid, lease = uuid_text(scan_id), uuid_text(lease_id)
256
  data = await photo.read(MAX_IMAGE + 1)
257
  result = await run_in_threadpool(submit, user, sid, lease, data, photo.content_type)
 
259
  # a quiz answer is graded only once the quiz is handed in (the page asks for it then)
260
  quiz = str(result.get("attempt", {}).get("question_id", "")).startswith("quiz:")
261
  if result.get("grading_available") and not result.get("grading") and not quiz:
262
+ background_tasks.add_task(automatic_grade, user, result["attempt"]["id"], account)
263
  return reply(result)
264
 
265
  def submit(user, sid, lease, data, content_type):
app/answer_scans.py CHANGED
@@ -38,6 +38,8 @@ log = logging.getLogger("mathly.answers")
38
  class ScanError(Exception):
39
  status: int
40
  code: str
 
 
41
 
42
 
43
  def clean_image(data: bytes, content_type: str) -> bytes:
 
38
  class ScanError(Exception):
39
  status: int
40
  code: str
41
+ reason: str = "" # why an AI check failed (math_photo.failure_reason), for the teacher's error details
42
+ detail: str = "" # the technical line behind it
43
 
44
 
45
  def clean_image(data: bytes, content_type: str) -> bytes:
app/auth.py CHANGED
@@ -160,6 +160,22 @@ class SupabaseAuth:
160
  grades = []
161
  return {"attempts": attempts, "grades": grades}
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  def access_for(self, user_id: str, fresh: bool = False) -> dict | None:
164
  if not self.secret:
165
  return None
 
160
  grades = []
161
  return {"attempts": attempts, "grades": grades}
162
 
163
+ def user_ids(self, emails: list[str]) -> dict[str, str]:
164
+ """The account id of each email that has signed in at least once (Supabase's admin user list), by email."""
165
+ wanted, found = {email.lower() for email in emails}, {}
166
+ page = 1
167
+ while wanted - found.keys():
168
+ data = self._admin(f"/auth/v1/admin/users?page={page}&per_page=1000") or {}
169
+ users = data.get("users") if isinstance(data, dict) else data
170
+ for user in users or []:
171
+ email = str(user.get("email") or "").lower()
172
+ if email in wanted and user.get("id"):
173
+ found[email] = user["id"]
174
+ if len(users or []) < 1000 or page >= 50:
175
+ break
176
+ page += 1
177
+ return found
178
+
179
  def access_for(self, user_id: str, fresh: bool = False) -> dict | None:
180
  if not self.secret:
181
  return None
app/class_report.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The class teacher's view of the class: each student's path progress, difficulties and grades.
2
+
3
+ A class has one teacher, chosen from its students (`classes.teachers`). That account, and any manager, can read
4
+ GET /api/classes/{id}/report: for each other student of the class, the parts of their saved progress
5
+ (app/progress.py) the teacher's page shows. The page works out the rest (stars, mistake patterns) the same way the
6
+ student's own page does.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from fastapi import Depends, HTTPException
13
+ from fastapi.responses import JSONResponse
14
+
15
+ from .access import has_permission
16
+ from .auth import AuthUnavailable
17
+
18
+ log = logging.getLogger("mathly.class_report")
19
+ TABLE = "/rest/v1/math_progress"
20
+ SLIPS_SENT = 300
21
+
22
+
23
+ def report_part(data: dict) -> dict:
24
+ """What the teacher's page reads from a student's progress: finished skills, mistakes, and both kinds of tests."""
25
+ def obj(key):
26
+ value = data.get(key)
27
+ return value if isinstance(value, dict) else {}
28
+
29
+ def items(key):
30
+ value = data.get(key)
31
+ return [x for x in value if isinstance(x, dict)] if isinstance(value, list) else []
32
+
33
+ quizzes = [{k: q.get(k) for k in ("at", "n", "right", "ms", "units")} for q in items("quizzes")]
34
+ unit_quizzes = {}
35
+ for unit, state in obj("uq").items():
36
+ history = state.get("history") if isinstance(state, dict) else None
37
+ sitting = state.get("sitting") if isinstance(state, dict) else None
38
+ unit_quizzes[unit] = {
39
+ "history": [{k: h.get(k) for k in ("at", "score", "timeUp")} for h in history or [] if isinstance(h, dict)],
40
+ "going": bool(isinstance(sitting, dict) and sitting.get("started") and not sitting.get("submitted"))}
41
+ return {"done": obj("done"), "gold": obj("gold"), "days": obj("days"), "missed": obj("missed"),
42
+ "slips": items("slips")[-SLIPS_SENT:], "quizzes": quizzes, "uq": unit_quizzes,
43
+ "updated": data.get("updated")}
44
+
45
+
46
+ def install_class_report_routes(app, signed_in, units_of):
47
+ def class_of(class_id: int, user) -> dict:
48
+ try:
49
+ row = next((r for r in app.state.auth.classes() if r.get("id") == class_id), None)
50
+ except AuthUnavailable as exc:
51
+ raise HTTPException(503, str(exc)) from exc
52
+ if row is None:
53
+ raise HTTPException(404, "class not found")
54
+ email = str((user or {}).get("email", "")).lower()
55
+ if email not in row.get("teachers", ()) and not has_permission(user, "management.access"):
56
+ raise HTTPException(403, "only the class's teacher can see its report")
57
+ return row
58
+
59
+ @app.get("/api/classes/{class_id}/report")
60
+ def class_report(class_id: int, user=Depends(signed_in)):
61
+ if app.state.auth is None:
62
+ raise HTTPException(404, "classes need sign-in")
63
+ row = class_of(class_id, user)
64
+ teachers = set(row.get("teachers", ()))
65
+ students = [email for email in row.get("emails", ()) if email not in teachers]
66
+ auth = app.state.auth
67
+ try:
68
+ ids = auth.user_ids(students) if students else {}
69
+ rows = auth._admin(f"{TABLE}?select=user_id,data,updated_at&user_id=in.({','.join(ids.values())})") \
70
+ if ids else []
71
+ except AuthUnavailable as exc:
72
+ log.error("class report failed: %s", exc)
73
+ raise HTTPException(503, "report_unavailable") from exc
74
+ by_id = {r["user_id"]: r for r in rows or []}
75
+ out = []
76
+ for email in students:
77
+ saved = by_id.get(ids.get(email, ""))
78
+ data = saved.get("data") if saved else None
79
+ out.append({"email": email, "signed_in": email in ids,
80
+ "progress": report_part(data) if isinstance(data, dict) else None,
81
+ "saved_at": saved.get("updated_at") if saved else None})
82
+ return JSONResponse({"class": {"id": row["id"], "name": row["name"], "lessons": units_of(row)},
83
+ "students": out}, headers={"Cache-Control": "no-store"})
app/main.py CHANGED
@@ -1,6 +1,7 @@
1
  """Math-only HTTP service for the Mathly algebra path."""
2
  from __future__ import annotations
3
 
 
4
  import logging
5
  import os
6
  import time
@@ -15,12 +16,15 @@ from fastapi.staticfiles import StaticFiles
15
  from starlette.concurrency import run_in_threadpool
16
 
17
  from . import ai_usage
 
18
  from .access import has_permission, resolve_access
19
  from .answer_routes import install_answer_routes, open_ended_locked
 
20
  from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
21
  from .math_photo import (DAILY_PHOTOS, MAX_PHOTO, MEDIA_TYPES as PHOTO_TYPES,
22
  PhotoSolver, PhotoUnavailable, math_model, photo_client)
23
  from .math_skills import algebra9, practice_graph
 
24
  from .progress import install_progress_routes
25
  from .push import install_push_routes
26
  from .monitoring import ActionLog, RETURN_GAP, install_monitoring_routes
@@ -128,6 +132,12 @@ def create_app(auth: SupabaseAuth | None = None) -> FastAPI:
128
  "roles": (user or {}).get("roles", []),
129
  "permissions": (user or {}).get("permissions", []),
130
  "teacher": bool((user or {}).get("teacher")),
 
 
 
 
 
 
131
  "classes": [{"id": row["id"], "name": row["name"], "lessons": units_of(row),
132
  "exams": row.get("exams") or [], "exam_note": row.get("exam_note") or "",
133
  "exam_title": row.get("exam_title") or "",
@@ -157,9 +167,12 @@ def create_app(auth: SupabaseAuth | None = None) -> FastAPI:
157
  raw = body.get("emails", "")
158
  parts = raw.replace(",", " ").split() if isinstance(raw, str) else raw
159
  emails = sorted({str(part).strip().lower() for part in parts if str(part).strip()})
 
 
 
 
160
  try:
161
- app.state.auth.save_class(class_id, name, emails, {}, selected,
162
- teachers=body.get("teachers") or [])
163
  except ClassConflict as exc:
164
  raise HTTPException(409, "a class with that name already exists") from exc
165
  except AuthUnavailable as exc:
@@ -201,10 +214,51 @@ def create_app(auth: SupabaseAuth | None = None) -> FastAPI:
201
  app.state.photo_key = key
202
  return app.state.photo
203
 
 
 
 
 
 
 
 
 
 
204
  @app.post("/api/math/photo")
205
  async def solve_photo(photo: UploadFile = File(...), user: dict | None = Depends(signed_in)):
 
 
206
  return await solve_upload(photo, user)
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  async def solve_upload(photo: UploadFile, user: dict | None):
209
  service = solver()
210
  email = (user or {}).get("email", "").lower()
@@ -239,8 +293,10 @@ def create_app(auth: SupabaseAuth | None = None) -> FastAPI:
239
  return {**result, "left": DAILY_PHOTOS - photo_counts.get(counter, 0) if counter else None}
240
 
241
  install_progress_routes(app, signed_in)
 
242
  install_push_routes(app, signed_in)
243
  install_answer_routes(app, signed_in)
 
244
  install_resource_routes(app, signed_in)
245
  install_science_routes(app)
246
  install_monitoring_routes(app, manager)
 
1
  """Math-only HTTP service for the Mathly algebra path."""
2
  from __future__ import annotations
3
 
4
+ import base64
5
  import logging
6
  import os
7
  import time
 
16
  from starlette.concurrency import run_in_threadpool
17
 
18
  from . import ai_usage
19
+ from .ai_queue import MAX_OPEN, for_student, install_ai_queue_routes, may_send
20
  from .access import has_permission, resolve_access
21
  from .answer_routes import install_answer_routes, open_ended_locked
22
+ from .answer_scans import ScanError, clean_image
23
  from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
24
  from .math_photo import (DAILY_PHOTOS, MAX_PHOTO, MEDIA_TYPES as PHOTO_TYPES,
25
  PhotoSolver, PhotoUnavailable, math_model, photo_client)
26
  from .math_skills import algebra9, practice_graph
27
+ from .class_report import install_class_report_routes
28
  from .progress import install_progress_routes
29
  from .push import install_push_routes
30
  from .monitoring import ActionLog, RETURN_GAP, install_monitoring_routes
 
132
  "roles": (user or {}).get("roles", []),
133
  "permissions": (user or {}).get("permissions", []),
134
  "teacher": bool((user or {}).get("teacher")),
135
+ # the AI: this account sends to it itself (ai_direct), or asks its teacher (app/ai_queue.py);
136
+ # ai_review: it reviews students' requests
137
+ "ai_direct": may_send(user),
138
+ "ai_review": bool(user) and may_send(user) and (
139
+ has_permission(user, "management.access")
140
+ or any(email.lower() in (row.get("teachers") or []) for row in mine)),
141
  "classes": [{"id": row["id"], "name": row["name"], "lessons": units_of(row),
142
  "exams": row.get("exams") or [], "exam_note": row.get("exam_note") or "",
143
  "exam_title": row.get("exam_title") or "",
 
167
  raw = body.get("emails", "")
168
  parts = raw.replace(",", " ").split() if isinstance(raw, str) else raw
169
  emails = sorted({str(part).strip().lower() for part in parts if str(part).strip()})
170
+ # the class's teacher is one of its students (or none yet)
171
+ teachers = sorted({str(t).strip().lower() for t in body.get("teachers") or [] if str(t).strip()})
172
+ if len(teachers) > 1 or not set(teachers) <= set(emails):
173
+ raise HTTPException(400, "choose one of the class's students as its teacher")
174
  try:
175
+ app.state.auth.save_class(class_id, name, emails, {}, selected, teachers=teachers)
 
176
  except ClassConflict as exc:
177
  raise HTTPException(409, "a class with that name already exists") from exc
178
  except AuthUnavailable as exc:
 
214
  app.state.photo_key = key
215
  return app.state.photo
216
 
217
+ # the teacher's AI queue sends a student's photo with this (app/ai_queue.py)
218
+ def solve_for_queue(image: bytes, media_type: str) -> dict:
219
+ try:
220
+ service = solver()
221
+ except HTTPException as exc:
222
+ raise PhotoUnavailable("פתרון מתמונה לא זמין כרגע.", "missing_key", "ANTHROPIC_API_KEY is not set") from exc
223
+ return service.solve(image, media_type)
224
+ app.state.solve_photo = solve_for_queue
225
+
226
  @app.post("/api/math/photo")
227
  async def solve_photo(photo: UploadFile = File(...), user: dict | None = Depends(signed_in)):
228
+ if not may_send(user):
229
+ return await ask_teacher(photo, user)
230
  return await solve_upload(photo, user)
231
 
232
+ async def ask_teacher(photo: UploadFile, user: dict):
233
+ """A student's photo waits in their teacher's AI queue; the teacher sends it to the AI."""
234
+ queue = app.state.ai_queue
235
+ if not queue.configured:
236
+ raise HTTPException(503, "עזרה בצילום אינה זמינה כרגע.")
237
+ counter = (user["email"].lower(), datetime.now(LOCAL_TZ).date().isoformat())
238
+ if photo.content_type not in PHOTO_TYPES:
239
+ raise HTTPException(415, "send a JPEG, PNG, or WebP image")
240
+ raw = await photo.read(MAX_PHOTO + 1)
241
+ if not raw:
242
+ raise HTTPException(400, "the image is empty")
243
+ if len(raw) > MAX_PHOTO:
244
+ raise HTTPException(413, "the image is too large")
245
+ if photo_counts.get(counter, 0) >= DAILY_PHOTOS:
246
+ raise HTTPException(429, "daily photo limit reached")
247
+ try:
248
+ if await run_in_threadpool(queue.open_count, user["id"]) >= MAX_OPEN:
249
+ raise HTTPException(429, "כבר יש לכם כמה בקשות שמחכות למורה. חכו שהמורה יענה עליהן.")
250
+ image = await run_in_threadpool(clean_image, raw, photo.content_type) # also drops the photo's metadata
251
+ row = await run_in_threadpool(queue.add, {
252
+ "user_id": user["id"], "email": user["email"].lower(), "kind": "photo",
253
+ "image": base64.standard_b64encode(image).decode("ascii")})
254
+ except ScanError as exc:
255
+ if exc.code.endswith("image"):
256
+ raise HTTPException(415, "לא הצלחנו לקרוא את התמונה. צלמו שוב.") from exc
257
+ raise HTTPException(503, "עזרה בצילום אינה זמינה כרגע.") from exc
258
+ photo_counts[counter] = photo_counts.get(counter, 0) + 1
259
+ app.state.actions.record(user, "ai_request", "תרגיל מצולם")
260
+ return {"queued": True, "request": for_student(row), "left": DAILY_PHOTOS - photo_counts[counter]}
261
+
262
  async def solve_upload(photo: UploadFile, user: dict | None):
263
  service = solver()
264
  email = (user or {}).get("email", "").lower()
 
293
  return {**result, "left": DAILY_PHOTOS - photo_counts.get(counter, 0) if counter else None}
294
 
295
  install_progress_routes(app, signed_in)
296
+ install_class_report_routes(app, signed_in, units_of)
297
  install_push_routes(app, signed_in)
298
  install_answer_routes(app, signed_in)
299
+ install_ai_queue_routes(app, signed_in)
300
  install_resource_routes(app, signed_in)
301
  install_science_routes(app)
302
  install_monitoring_routes(app, manager)
app/math_photo.py CHANGED
@@ -26,7 +26,43 @@ MEDIA_TYPES = ("image/jpeg", "image/png", "image/webp")
26
 
27
 
28
  class PhotoUnavailable(Exception):
29
- """The photo could not be solved; the message is for the student (Hebrew)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
 
32
  def photo_schema(skill_ids: list[str]) -> dict:
@@ -84,27 +120,28 @@ class PhotoSolver:
84
  )
85
  except anthropic.RateLimitError as e:
86
  log.warning("math photo rate limited: %s", e)
87
- raise PhotoUnavailable("עמוס כרגע. נסו שוב בעוד דקה.") from e
88
  except anthropic.AuthenticationError as e:
89
  log.error("math photo: the API key was refused: %s", e)
90
- raise PhotoUnavailable("פתרון מתמונה לא זמין כרגע (מפתח ה-API לא תקין).") from e
91
  except anthropic.APIStatusError as e:
92
  log.warning("math photo: the API answered %s: %s", e.status_code, e.message)
93
- raise PhotoUnavailable("פתרון מתמונה לא זמין כרגע. נסו שוב.") from e
94
  except anthropic.APIConnectionError as e:
95
  log.warning("math photo: no connection: %s", e)
96
- raise PhotoUnavailable("אין חיבור כרגע. נסו שוב.") from e
97
  if res.stop_reason == "refusal":
98
  log.warning("math photo: the model declined (%s)", res.stop_details)
99
- raise PhotoUnavailable("לא הצלחנו לפתור את התמונה הזו. נסו לצלם תרגיל אחר.")
 
100
  if res.stop_reason == "max_tokens":
101
- raise PhotoUnavailable("הפתרון נקטע. נסו שוב.")
102
  text = next((b.text for b in res.content if b.type == "text"), "")
103
  try:
104
  out = json.loads(text)
105
  except ValueError as e:
106
  log.warning("math photo: an answer that is not JSON: %r", text[:300])
107
- raise PhotoUnavailable("לא הצלחנו לפתור את התרגיל. נסו שוב.") from e
108
  log.info("math photo: %s, %d+%d tokens", res.model, res.usage.input_tokens, res.usage.output_tokens)
109
  return {**out, "usage": usage_of(res)} # the route pops "usage" and records it (ai_usage.py)
110
 
 
26
 
27
 
28
  class PhotoUnavailable(Exception):
29
+ """The photo could not be solved; the message is for the student (Hebrew). `reason` names the cause for the
30
+ teacher's error details (see failure_reason), and `detail` is the technical line behind it."""
31
+
32
+ def __init__(self, message: str = "", reason: str = "", detail: str = ""):
33
+ super().__init__(message)
34
+ self.reason, self.detail = reason, detail
35
+
36
+
37
+ def failure_reason(exc) -> tuple[str, str]:
38
+ """Why an Anthropic API call failed, as (reason, technical detail). The detail is the error's type, HTTP status
39
+ and the API's own short message, never the request (which holds a student's work)."""
40
+ import anthropic
41
+ status = getattr(exc, "status_code", None)
42
+ body = getattr(exc, "body", None)
43
+ said = body.get("error", {}).get("message") if isinstance(body, dict) and isinstance(body.get("error"), dict) else ""
44
+ message = " ".join(str(said or getattr(exc, "message", "") or exc).split())
45
+ low = message.lower()
46
+ if isinstance(exc, anthropic.AuthenticationError):
47
+ reason = "api_key_invalid"
48
+ elif isinstance(exc, anthropic.PermissionDeniedError):
49
+ reason = "permission"
50
+ elif isinstance(exc, anthropic.NotFoundError):
51
+ reason = "model_not_found"
52
+ elif isinstance(exc, anthropic.RateLimitError):
53
+ reason = "rate_limited"
54
+ elif isinstance(exc, anthropic.APIConnectionError):
55
+ reason = "no_connection"
56
+ elif "credit balance" in low or "billing" in low:
57
+ reason = "no_credit"
58
+ elif status == 529 or "overloaded" in low:
59
+ reason = "overloaded"
60
+ elif isinstance(exc, anthropic.BadRequestError):
61
+ reason = "bad_request"
62
+ else:
63
+ reason = "provider_error"
64
+ detail = type(exc).__name__ + (f" {status}" if status else "") + (f": {message[:200]}" if message else "")
65
+ return reason, detail
66
 
67
 
68
  def photo_schema(skill_ids: list[str]) -> dict:
 
120
  )
121
  except anthropic.RateLimitError as e:
122
  log.warning("math photo rate limited: %s", e)
123
+ raise PhotoUnavailable("עמוס כרגע. נסו שוב בעוד דקה.", *failure_reason(e)) from e
124
  except anthropic.AuthenticationError as e:
125
  log.error("math photo: the API key was refused: %s", e)
126
+ raise PhotoUnavailable("פתרון מתמונה לא זמין כרגע (מפתח ה-API לא תקין).", *failure_reason(e)) from e
127
  except anthropic.APIStatusError as e:
128
  log.warning("math photo: the API answered %s: %s", e.status_code, e.message)
129
+ raise PhotoUnavailable("פתרון מתמונה לא זמין כרגע. נסו שוב.", *failure_reason(e)) from e
130
  except anthropic.APIConnectionError as e:
131
  log.warning("math photo: no connection: %s", e)
132
+ raise PhotoUnavailable("אין חיבור כרגע. נסו שוב.", *failure_reason(e)) from e
133
  if res.stop_reason == "refusal":
134
  log.warning("math photo: the model declined (%s)", res.stop_details)
135
+ raise PhotoUnavailable("לא הצלחנו לפתור את התמונה הזו. נסו לצלם תרגיל אחר.", "refused",
136
+ f"stop_reason=refusal ({res.model})")
137
  if res.stop_reason == "max_tokens":
138
+ raise PhotoUnavailable("הפתרון נקטע. נסו שוב.", "incomplete", f"stop_reason=max_tokens ({res.model})")
139
  text = next((b.text for b in res.content if b.type == "text"), "")
140
  try:
141
  out = json.loads(text)
142
  except ValueError as e:
143
  log.warning("math photo: an answer that is not JSON: %r", text[:300])
144
+ raise PhotoUnavailable("לא הצלחנו לפתור את התרגיל. נסו שוב.", "bad_output", "the answer was not JSON") from e
145
  log.info("math photo: %s, %d+%d tokens", res.model, res.usage.input_tokens, res.usage.output_tokens)
146
  return {**out, "usage": usage_of(res)} # the route pops "usage" and records it (ai_usage.py)
147
 
app/monitoring.py CHANGED
@@ -27,7 +27,8 @@ LOCAL_TZ = ZoneInfo("Asia/Jerusalem")
27
  PERIODS = {"day": (15, 96), "week": (60, 168), "month": (24 * 60, 30)}
28
  SEEN_KEEP = 24 * 3600
29
  RETURN_GAP = 30 * 60 # a sign-in after this long away counts as a new visit
30
- ACTION_KINDS = frozenset({"visit", "photo", "answer_upload", "answer_graded", "class_saved", "class_deleted"})
 
31
 
32
 
33
  class ActionLog:
 
27
  PERIODS = {"day": (15, 96), "week": (60, 168), "month": (24 * 60, 30)}
28
  SEEN_KEEP = 24 * 3600
29
  RETURN_GAP = 30 * 60 # a sign-in after this long away counts as a new visit
30
+ ACTION_KINDS = frozenset({"visit", "photo", "answer_upload", "answer_graded", "class_saved", "class_deleted",
31
+ "ai_request", "ai_sent", "ai_failed", "ai_declined"})
32
 
33
 
34
  class ActionLog:
static/answer-scans.js CHANGED
@@ -5,15 +5,41 @@ const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&
5
  const fmt=s=>esc(s).replace(/\$([^$]+)\$/g,'<span class="m">$1</span>');
6
  const states={waiting:"ממתינים לטלפון",confirmed:"ממתינים לטלפון",uploading:"מעלים את התשובה…",received:"התשובה התקבלה",failed:"ההעלאה נכשלה",expired:"בקשת הצילום פגה",superseded:"הבקשה הוחלפה",declined:"הבקשה נדחתה בטלפון"};
7
  const errors={expired:"בקשת הצילום פגה. צרו בקשה חדשה ונסו שוב.",superseded:"הבקשה הוחלפה. בחרו את הבקשה העדכנית.",upload_in_progress:"העלאה כבר מתבצעת. ממתינים לעדכון…",confirmation_required:"יש לאשר את בקשת הצילום תחילה.",invalid_upload:"ההעלאה הופסקה. נסו שוב.",question_changed:"השאלה השתנתה. רעננו את העמוד ופתחו אותה מחדש.",invalid_image:"לא ניתן לקרוא את התמונה. צלמו שוב.",image_too_large:"התמונה גדולה מדי. צלמו שוב.",unsupported_image:"בחרו תמונת JPEG, PNG או WebP.",answer_storage_unavailable:"שמירת תשובות אינה זמינה כרגע. נסו שוב מאוחר יותר.",sign_in_required:"יש להיכנס לאותו חשבון בשני המכשירים."};
8
- Object.assign(errors,{grading_unavailable:"בדיקת תשובות אינה זמינה כרגע באתר.",grading_failed:"הבדיקה נכשלה. התמונה נשמרה; אפשר לנסות שוב.",grading_interrupted:"הבדיקה הופסקה. אפשר לנסות שוב.",grading_limit:"הגעתם למכסת 20 הבדיקות היומית. נסו שוב מחר.",grading_busy:"כבר מתבצעות בדיקות בחשבון. נסו שוב בעוד רגע.",answer_required:"יש לצלם ולשלוח תשובה לפני הבדיקה."});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  const verdicts={correct:"נכון! הפתרון מלא",partial:"פתרון נכון בחלקו",incorrect:"יש לתקן את הפתרון",unreadable:"לא ניתן לקרוא את התשובה",needs_review:"נדרשת הבהרה לפני מתן ציון"};
 
10
  let api=null,account=null,epoch=0,question=null,inbox=false,inboxTimer=null,inboxPoll=null,capture=null,visibleRoot=null,onGraded=null;
11
  const handled=new Set(); // requests this phone already answered (photographed or declined): not shown again
12
  const live=(e)=>e===epoch&&!!account;
13
  async function json(url,options={}){
14
  const r=await api(url,{cache:"no-store",...options});
15
  const d=await r.json().catch(()=>({}));
16
- if(!r.ok){if(r.status===401)reset();const error=Error(errors[d.detail]||"החיבור נקטע. נסו שוב.");error.status=r.status;error.code=d.detail;throw error;}
17
  return d;
18
  }
19
  const post=(url,body={})=>json(url,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
@@ -28,7 +54,7 @@ function reset(){
28
  inbox=false;account=null;onGraded=null;PUSH_MODE="hidden";paintPush();document.querySelector(".mx-push-ask")?.remove();
29
  }
30
  function configure(options){
31
- reset();api=options.api;account=options.enabled?options.account:null;visibleRoot=options.root;onGraded=options.onGraded;
32
  if(!account)return;
33
  // The phone (the responsive app/PWA) watches for requests from the computer and pops them up.
34
  if(matchMedia("(max-width: 700px)").matches||navigator.maxTouchPoints){inbox=true;pollInbox(epoch);pushSync(epoch);}
@@ -223,11 +249,12 @@ async function pollQuestion(q){
223
  async function renderQuestion(q){
224
  if(!current(q))return;
225
  const r=q.result,s=r.scan,state=s?.state,received=!!r.attachment,g=r.grading;
226
- const checking=q.grading||g?.state==="grading";
 
227
  // while the phone has the request, its code and progress are in the phone box itself (phoneLive)
228
- const statusText=checking?"בודקים את התשובה…":g?.state==="completed"?verdicts[g.result.verdict]:g?.state==="failed"?errors[g.error]||errors.grading_failed:!received&&phonePending(state)?"":states[state]||"כתבו את הפתרון על דף וצלמו אותו בטלפון.";
229
  // Rebuilding controls on every poll would steal focus and race a button click.
230
- const signature=[state,s?.id,r.attachment?.id,g?.id,g?.state,r.grading_available].join(":");
231
  if(q.signature!==signature){
232
  q.signature=signature;
233
  q.node.innerHTML=`<small>ניסיון ${esc(r.attempt.id.slice(0,8))}</small><p role="status" aria-live="polite">${esc(statusText)}</p>
@@ -289,20 +316,33 @@ function gradingPanel(r,checking){
289
  <small>בדיקה אוטומטית באמצעות AI. בדקו שהתמלול תואם למה שכתבתם.</small></section>`;
290
  }
291
  if(!r.grading_available&&!checking)return '<p>התמונה נשמרה כתשובה. בדיקה אוטומטית אינה זמינה כרגע באתר.</p>';
 
 
 
 
 
 
 
 
292
  return `<p>התשובה נשלחת לבדיקה אוטומטית באמצעות AI.</p>
293
  <button class="mx-btn solid" data-grade ${checking?"disabled":""}>${checking?"בודקים את התשובה…":g?.state==="failed"?"ניסיון בדיקה נוסף":"בדיקת התשובה"}</button>`;
294
  }
 
 
295
  async function checkAnswer(q,automatic=false){
296
  if(!current(q)||q.grading)return;
297
  q.grading=true;q.signature=null;await renderQuestion(q);schedule(q);
298
  try{
299
  const r=await post(`/api/math/answer-attempts/${q.result.attempt.id}/grade`,{automatic});
300
  if(!current(q))return;q.result=r;
301
- }catch(error){if(current(q))q.gradeError=error.message;}
302
  finally{
303
  if(current(q)){
304
  q.grading=false;q.signature=null;await renderQuestion(q);
305
- if(q.gradeError){q.node.querySelector("[role=status]").textContent=q.gradeError;q.gradeError=null;}
 
 
 
306
  schedule(q);
307
  }
308
  }
@@ -450,5 +490,5 @@ document.addEventListener("visibilitychange",()=>{
450
  if(question?.result)pollQuestion(question);
451
  });
452
  window.addEventListener("online",()=>{if(account&&inbox)pollInbox(epoch);if(question?.result)pollQuestion(question);});
453
- window.MathlyAnswers={configure,mountQuestion,closeQuestion,reset,phoneLive,forgetPush,pushButton,paintPush,togglePush};
454
  })();
 
5
  const fmt=s=>esc(s).replace(/\$([^$]+)\$/g,'<span class="m">$1</span>');
6
  const states={waiting:"ממתינים לטלפון",confirmed:"ממתינים לטלפון",uploading:"מעלים את התשובה…",received:"התשובה התקבלה",failed:"ההעלאה נכשלה",expired:"בקשת הצילום פגה",superseded:"הבקשה הוחלפה",declined:"הבקשה נדחתה בטלפון"};
7
  const errors={expired:"בקשת הצילום פגה. צרו בקשה חדשה ונסו שוב.",superseded:"הבקשה הוחלפה. בחרו את הבקשה העדכנית.",upload_in_progress:"העלאה כבר מתבצעת. ממתינים לעדכון…",confirmation_required:"יש לאשר את בקשת הצילום תחילה.",invalid_upload:"ההעלאה הופסקה. נסו שוב.",question_changed:"השאלה השתנתה. רעננו את העמוד ופתחו אותה מחדש.",invalid_image:"לא ניתן לקרוא את התמונה. צלמו שוב.",image_too_large:"התמונה גדולה מדי. צלמו שוב.",unsupported_image:"בחרו תמונת JPEG, PNG או WebP.",answer_storage_unavailable:"שמירת תשובות אינה זמינה כרגע. נסו שוב מאוחר יותר.",sign_in_required:"יש להיכנס לאותו חשבון בשני המכשירים."};
8
+ Object.assign(errors,{grading_unavailable:"בדיקת תשובות אינה זמינה כרגע באתר.",grading_failed:"הבדיקה נכשלה. התמונה נשמרה; אפשר לנסות שוב.",grading_interrupted:"הבדיקה הופסקה. אפשר לנסות שוב.",grading_limit:"הגעתם למכסת 20 הבדיקות היומית. נסו שוב מחר.",grading_busy:"כבר מתבצעות בדיקות בחשבון. נסו שוב בעוד רגע.",answer_required:"יש לצלם ולשלוח תשובה לפני הבדיקה.",queue_unavailable:"שליחת תשובות לבדיקת המורה אינה זמינה כרגע.",request_taken:"המורה כבר טיפל בבקשה. רעננו את העמוד."});
9
+ /* Why an AI check failed (the server's `reason`, app/math_photo.py: failure_reason), for the people who can fix it:
10
+ a teacher sending from the AI queue, or checking their own answer. Each is what happened and what to do. */
11
+ const reasons={
12
+ missing_key:["חסר מפתח API בשרת","משתנה הסביבה ANTHROPIC_API_KEY לא מוגדר בשרת. הוסיפו אותו להגדרות השרת (Secrets או קובץ .env) והפעילו את השרת מחדש."],
13
+ api_key_invalid:["מפתח ה-API לא תקין","Anthropic דחתה את המפתח (401). בדקו שהערך של ANTHROPIC_API_KEY הועתק במלואו, ושהמפתח לא בוטל ב-Anthropic Console."],
14
+ permission:["אין הרשאה","למפתח ה-API אין הרשאה למודל או לפעולה הזו (403). בדקו את הרשאות המפתח וה-Workspace ב-Anthropic Console."],
15
+ model_not_found:["המודל לא נמצא","המודל שמוגדר בשרת (MATH_MODEL) לא קיים או לא זמין למפתח הזה (404). בדקו את שם המודל, או מחקו את MATH_MODEL כדי לחזור לברירת המחדל."],
16
+ no_credit:["נגמר הקרדיט","אין מספיק קרדיט בחשבון Anthropic. הוסיפו קרדיט ב-Anthropic Console, בעמוד Billing."],
17
+ rate_limited:["יותר מדי בקשות","הגעתם למגבלת הקצב של ה-API (429). חכו דקה ונסו שוב."],
18
+ overloaded:["שירות ה-AI עמוס","השרתים של Anthropic עמוסים כרגע. נסו שוב בעוד כמה דקות."],
19
+ no_connection:["אין חיבור ל-AI","השרת לא הצליח להתחבר ל-API של Anthropic (רשת או זמן המתנה ארוך מדי). בדקו את החיבור של השרת ונסו שוב."],
20
+ bad_request:["ה-API דחה את הבקשה","Anthropic החזירה שגיאה 400. הפרטים הטכניים למטה מסבירים למה."],
21
+ refused:["ה-AI סירב לבדוק","המודל סירב לענות על הבקשה הזו. אפשר לנסות שוב או לבקש מהתלמיד לצלם מחדש."],
22
+ incomplete:["הבדיקה נקטעה","ה-AI הפסיק לפני שסיים את הבדיקה. נסו שוב."],
23
+ bad_output:["תשובה לא תקינה מה-AI","ה-AI החזיר תשובה שלא עברה את בדיקות השרת, ולכן לא ניתן ציון. נסו שוב."],
24
+ provider_error:["שגיאה בשירות ה-AI","Anthropic החזירה שגיאה לא צפויה. נסו שוב, ואם זה חוזר, ראו את הפרטים הטכניים."],
25
+ server_error:["שגיאה בשרת שלנו","משהו נכשל בשרת של Mathly. הפרטים הטכניים למטה, וגם ביומן השגיאות בלשונית ניטור."],
26
+ answer_storage_unavailable:["האחסון לא זמין","לא הצלחנו לקרוא את התמונה השמורה. בדקו את Supabase או את MATHLY_ANSWER_BUCKET ו-HF_TOKEN."],
27
+ open_ended_locked:["שאלות פתוחות נעולות","בשרת מוגדר MATHLY_OPEN_ENDED_LOCKED=1. שנו ל-0 והפעילו מחדש כדי לאפשר בדיקה."]};
28
+ // a "פירוט השגיאה" button that opens what happened, what to do, and the technical line; "" without a reason
29
+ function errorDetails(reason,detail){
30
+ if(!reason&&!detail)return "";
31
+ const [title,say]=reasons[reason]||["שגיאה לא מוכרת","לא זיהינו את סוג השגיאה. הפרטים הטכניים למטה."];
32
+ return `<details class="mx-error-details"><summary>פירוט השגיאה</summary><p><b>${esc(title)}</b></p><p>${esc(say)}</p>${detail?`<code dir="ltr">${esc(detail)}</code>`:""}</details>`;
33
+ }
34
  const verdicts={correct:"נכון! הפתרון מלא",partial:"פתרון נכון בחלקו",incorrect:"יש לתקן את הפתרון",unreadable:"לא ניתן לקרוא את התשובה",needs_review:"נדרשת הבהרה לפני מתן ציון"};
35
+ let queued=false; // a student: an answer is checked only after their teacher sends it to the AI (app/ai_queue.py)
36
  let api=null,account=null,epoch=0,question=null,inbox=false,inboxTimer=null,inboxPoll=null,capture=null,visibleRoot=null,onGraded=null;
37
  const handled=new Set(); // requests this phone already answered (photographed or declined): not shown again
38
  const live=(e)=>e===epoch&&!!account;
39
  async function json(url,options={}){
40
  const r=await api(url,{cache:"no-store",...options});
41
  const d=await r.json().catch(()=>({}));
42
+ if(!r.ok){if(r.status===401)reset();const error=Error(errors[d.detail]||"החיבור נקטע. נסו שוב.");error.status=r.status;error.code=d.detail;error.reason=d.reason||"";error.detail=d.error_detail||"";throw error;}
43
  return d;
44
  }
45
  const post=(url,body={})=>json(url,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
 
54
  inbox=false;account=null;onGraded=null;PUSH_MODE="hidden";paintPush();document.querySelector(".mx-push-ask")?.remove();
55
  }
56
  function configure(options){
57
+ reset();api=options.api;account=options.enabled?options.account:null;visibleRoot=options.root;onGraded=options.onGraded;queued=!!options.queued;
58
  if(!account)return;
59
  // The phone (the responsive app/PWA) watches for requests from the computer and pops them up.
60
  if(matchMedia("(max-width: 700px)").matches||navigator.maxTouchPoints){inbox=true;pollInbox(epoch);pushSync(epoch);}
 
249
  async function renderQuestion(q){
250
  if(!current(q))return;
251
  const r=q.result,s=r.scan,state=s?.state,received=!!r.attachment,g=r.grading;
252
+ const checking=q.grading||g?.state==="grading", req=r.ai_request;
253
+ const waiting=!checking&&g?.state!=="completed"&&waitingTeacher(req);
254
  // while the phone has the request, its code and progress are in the phone box itself (phoneLive)
255
+ const statusText=checking?(q.grading&&queued?"שולחים למורה…":"בודקים את התשובה…"):waiting?"התשובה מחכה למורה":g?.state==="completed"?verdicts[g.result.verdict]:g?.state==="failed"?errors[g.error]||errors.grading_failed:!received&&phonePending(state)?"":states[state]||"כתבו את הפתרון על דף וצלמו אותו בטלפון.";
256
  // Rebuilding controls on every poll would steal focus and race a button click.
257
+ const signature=[state,s?.id,r.attachment?.id,g?.id,g?.state,r.grading_available,req?.id,req?.state].join(":");
258
  if(q.signature!==signature){
259
  q.signature=signature;
260
  q.node.innerHTML=`<small>ניסיון ${esc(r.attempt.id.slice(0,8))}</small><p role="status" aria-live="polite">${esc(statusText)}</p>
 
316
  <small>בדיקה אוטומטית באמצעות AI. בדקו שהתמלול תואם למה שכתבתם.</small></section>`;
317
  }
318
  if(!r.grading_available&&!checking)return '<p>התמונה נשמרה כתשובה. בדיקה אוטומטית אינה זמינה כרגע באתר.</p>';
319
+ if(queued&&!checking){
320
+ const req=r.ai_request;
321
+ if(waitingTeacher(req))return '<p>התשובה נשלחה למורה. אחרי שהמורה יאשר את הבדיקה, התוצאה תופיע כאן.</p>';
322
+ if(req?.state==="declined")return `<p>המורה לא אישר את הבדיקה.${req.note?` <b>הערת המורה:</b> ${esc(req.note)}`:""}</p>
323
+ <button class="mx-btn" data-grade>שליחה מחדש למורה</button>`;
324
+ return `<p>${g?.state==="failed"||req?.state==="failed"?"הבדיקה נכשלה. אפשר לשלוח שוב למורה.":"שלחו את התשובה למורה, והמורה יעביר אותה לבדיקה."}</p>
325
+ <button class="mx-btn solid" data-grade>שליחה לבדיקת המורה</button>`;
326
+ }
327
  return `<p>התשובה נשלחת לבדיקה אוטומטית באמצעות AI.</p>
328
  <button class="mx-btn solid" data-grade ${checking?"disabled":""}>${checking?"בודקים את התשובה…":g?.state==="failed"?"ניסיון בדיקה נוסף":"בדיקת התשובה"}</button>`;
329
  }
330
+ // the answer's request is in the teacher's queue (a failed send stays there for the teacher to try again)
331
+ const waitingTeacher=req=>!!req&&["pending","sending","failed"].includes(req.state);
332
  async function checkAnswer(q,automatic=false){
333
  if(!current(q)||q.grading)return;
334
  q.grading=true;q.signature=null;await renderQuestion(q);schedule(q);
335
  try{
336
  const r=await post(`/api/math/answer-attempts/${q.result.attempt.id}/grade`,{automatic});
337
  if(!current(q))return;q.result=r;
338
+ }catch(error){if(current(q))q.gradeError=error;}
339
  finally{
340
  if(current(q)){
341
  q.grading=false;q.signature=null;await renderQuestion(q);
342
+ if(q.gradeError){
343
+ const status=q.node.querySelector("[role=status]");status.textContent=q.gradeError.message;
344
+ status.insertAdjacentHTML("afterend",errorDetails(q.gradeError.reason,q.gradeError.detail));q.gradeError=null;
345
+ }
346
  schedule(q);
347
  }
348
  }
 
490
  if(question?.result)pollQuestion(question);
491
  });
492
  window.addEventListener("online",()=>{if(account&&inbox)pollInbox(epoch);if(question?.result)pollQuestion(question);});
493
+ window.MathlyAnswers={errorDetails,configure,mountQuestion,closeQuestion,reset,phoneLive,forgetPush,pushButton,paintPush,togglePush};
494
  })();
static/guide.js ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* The user guide: how to use the app, for students and for teachers.
2
+ A "?" button on the start screen (before and after sign-in) opens it as a dialog with a tab for each.
3
+ It stands alone, with its own styles, so it also works on the sign-in screen before math.css is loaded.
4
+ Other screens add the same button with MathlyGuide.button(). */
5
+ (()=>{
6
+ const STUDENT=[
7
+ ["🚪","כניסה ובחירת מקצוע",`<p>נכנסים עם חשבון Google של בית הספר. כדאי להיכנס תמיד עם <b>אותו חשבון</b> בכל מכשיר: ההתקדמות נשמרת בחשבון ועוברת בין המחשב לטלפון.</p>
8
+ <p>במסך <b>המקצועות שלי</b> בוחרים מקצוע כדי לפתוח את המסלול שלו. לכל מקצוע מופיעים גם המבחנים הקרובים של הכיתה.</p>`],
9
+ ["⭐","המסלול",`<p>כל יחידה היא קבוצת כוכבים, וכל כוכב הוא מיומנות. כל השיעורים פתוחים, והינשוף מסמן איפה <b>ממשיכים</b>.</p>
10
+ <ul><li>לוחצים על כוכב כדי לפתוח את המיומנות ולענות על השאלות שלה.</li>
11
+ <li>הכוכב נדלק כשעונים נכון על כל השאלות, והופך ל<b>זהב</b> אחרי כמה תשובות נכונות ברצף בניסיון הראשון.</li>
12
+ <li>כוכב שלא תרגלו שבועיים <b>דועך</b>. תשובה נכונה אחת מחזירה לו את האור.</li>
13
+ <li>תשובה שגויה לא מגלה את התשובה הנכונה. השאלה תחזור אחרי האחרות, ואפשר לנסות שוב.</li></ul>`],
14
+ ["🔥","היעד היומי והרצף",`<p>היעד היומי הוא 5 תשובות נכונות. מי שמגיע אליו כל יום בונה <b>רצף</b>. לוחצים על הלהבה כדי לראות את לוח הרצף של החודש.</p>`],
15
+ ["🔁","חזרה מעורבת ודפוסים שחוזרים",`<p><b>חזרה מעורבת</b> מופיעה בבית כשמיומנויות שכבר סיימתם צריכות חזרה: כמה שאלות מנושאים שונים, כדי שמה שלמדתם יישאר.</p>
16
+ <p><b>דפוסים שחוזרים</b> מראה טעות שחוזרת אצלכם בכמה נושאים, ואיזו מיומנות כדאי לחזק כדי שהיא תיעלם.</p>`],
17
+ ["📝","מבחן תרגול",`<p>12 שאלות מעורבבות מכמה יחידות, כמו במבחן אמיתי: שם הנושא לא מופיע ואין משוב באמצע. בוחרים אילו יחידות ייכנסו, ובסוף מקבלים ציון ותוכנית: מה לחזק עד המבחן. השעון רק מראה את הזמן, בלי לחץ.</p>`],
18
+ ["🧾","בוחן מסכם ליחידה",`<p>בסוף כל יחידה יש בוחן עם שאלות פתוחות ושעון שסופר לאחור.</p>
19
+ <ul><li>כותבים את הפתרונות על דף, כל שאלה בנפרד, עם דרך פתרון מלאה.</li>
20
+ <li>מצלמים את התשובה לכל שאלה: מהטלפון, מהמחשב (<b>צילום בטלפון</b> נותן קוד, ופותחים את האפליקציה בטלפון עם אותו חשבון), או פותרים ב<b>לוח</b> ומגישים ממנו.</li>
21
+ <li>כשהזמן לכתיבה נגמר יש עוד 10 דקות לצלם ולהגיש.</li>
22
+ <li>הבדיקה האוטומטית, הציון והפתרונות מגיעים אחרי ההגשה. הבדיקה יכולה לטעות: בדקו את התמלול שמוצג.</li></ul>`],
23
+ ["✏️","לוח טיוטה",`<p>הכפתור <b>לוח</b> בכל שאלה פותח דף לכתיבה: עט, קו ישר, עיגול, נוסחה מוקלדת ומחק. בטלפון הלוח נפתח במסך מלא, ו<b>חזרה לשאלה</b> מחזיר. גוללים עם שתי אצבעות או עם כלי ההזזה (כף היד). כפתור הלוח עם הדף (<b>השאלה</b>) מעתיק את השאלה לראש הדף.</p>`],
24
+ ["🔔","התראות בטלפון",`<p>כשהמחשב מבקש צילום של תשובה, הטלפון יכול לקבל התראה גם כשהאפליקציה סגורה. מפעילים אותה בכפתור הפעמון. באייפון זה עובד רק מהאפליקציה שהוספה למסך הבית.</p>`],
25
+ ["☄️","האוסף ושביטים",`<p>בלשונית <b>האוסף</b> רואים את כל הכוכבים וקבוצות הכוכבים שאספתם. לפעמים חולף על המסך שביט בזמן תרגול: מי שלוחץ עליו בזמן תופס אותו.</p>`],
26
+ ["🚀","מדעים: חידות בהצבעה",`<p>המורה פותח הצבעה ומציג על הלוח <b>קוד בן 4 ספרות</b>. נכנסים לחידה, מקלידים את הקוד ב<b>הצטרפות להצבעה</b> ומצביעים. אפשר לשנות הצבעה עד שהמורה בודק.</p>`],
27
+ ];
28
+ const TEACHER=[
29
+ ["👩‍🏫","להיות מורה הכיתה",`<p>לכל כיתה יש מורה אחד. מנהל בוחר אותו ב<b>ניהול ← כיתות ← עריכה ← מורה הכיתה</b>, מתוך החשבונות של הכיתה. אחרי הבחירה מופיע למורה הכפתור <b>הכיתה שלי</b>.</p>`],
30
+ ["📊","דוח הכיתה",`<p><b>הכיתה שלי</b> פותח דוח על כל תלמיד:</p>
31
+ <ul><li>המיומנויות שסיים ביחידות של הכיתה.</li>
32
+ <li>הטעויות שחוזרות אצלו.</li>
33
+ <li>הציון ב<b>בוחן המסכם</b> של כל יחידה, ותוצאות <b>מבחני התרגול</b>.</li></ul>
34
+ <p>בראש הדוח מופיעות הטעויות הנפוצות בכל הכיתה, כדי לדעת מה כדאי לחזור עליו בשיעור. הנתונים של תלמיד מופיעים אחרי שנכנס לחשבון ותרגל.</p>`],
35
+ ["📷","משאבי שיעור",`<p>במסלול המתמטיקה, לוחצים על שם יחידה (או על <b>📷 משאבים</b> מתחתיה) כדי לפתוח את המשאבים של השיעור.</p>
36
+ <ul><li><b>📷 צילום תמונה</b>: מצלמים תרגיל או הסבר מהלוח או מהספר.</li>
37
+ <li>מסמנים את הסוג: <b>תרגיל חדש</b>, <b>הסבר תיאורטי</b> או <b>אחר</b>, ואפשר לכתוב לאיזה נושא הוא שייך.</li>
38
+ <li>רק מי שהוסיף תמונה (או מנהל) יכול למחוק אותה.</li></ul>
39
+ <p>בשלב זה המשאבים גלויים למורים בלבד.</p>`],
40
+ ["🚀","הצבעה כיתתית במדעים",`<p>במקצוע <b>מדעים</b> בוחרים חידה ולוחצים <b>פתיחת הצבעה לכיתה</b>. על המסך מופיע קוד בן 4 ספרות: מציגים אותו על הלוח והתלמידים מצטרפים איתו מהמכשירים שלהם.</p>
41
+ <ul><li>ההצבעות מוסתרות עד שלוחצים <b>בדיקה</b>. הטיל משוגר עם התשובה שרוב הכיתה בחרה.</li>
42
+ <li>בתיקו הטיל לא משוגר: התלמידים יכולים לשנות הצבעה, ובודקים שוב.</li>
43
+ <li><b>לחידה הבאה</b> ממשיך באותה הצבעה.</li></ul>`],
44
+ ["🗓️","מבחנים ותרגול לקראתם",`<p>לוח המבחנים של הכיתה מופיע לתלמידים במסך המקצועות ובמסלול. כשמבחן מתקרב, <b>מבחן התרגול</b> מכוון אליו ומציע את היחידות שנלמדו עד אז. כדאי להזכיר לתלמידים לעשות אותו בשבוע שלפני המבחן.</p>`],
45
+ ["⚙️","ניהול (למנהלים)",`<p>הכפתור <b>ניהול</b> מופיע למי שיש לו הרשאה. שם יוצרים כיתות, מוסיפים תלמידים לפי כתובת דוא״ל (שורה לכל תלמיד), בוחרים יחידות ומקצועות, מגדירים לוח מבחנים ובוחרים את מורה הכיתה. כל כיתה פותחת לתלמידים שלה רק את היחידות שנבחרו לה. בלשונית המעקב רואים פעילות ושימוש.</p>`],
46
+ ];
47
+
48
+ const css=`
49
+ .mg-help{position:absolute;top:max(14px,env(safe-area-inset-top,0px));left:16px;width:44px;height:44px;border-radius:50%;
50
+ border:1px solid #7EE3C8;background:transparent;color:#7EE3C8;font:700 20px/1 system-ui,sans-serif;cursor:pointer;display:grid;place-items:center}
51
+ .mg-help:hover,.mg-help:focus-visible{background:#163A36}
52
+ .mg-help.inline{position:static;width:36px;height:36px;font-size:17px}
53
+ .mg-scrim{position:fixed;inset:0;z-index:1100;background:rgb(3 5 14 / .78);display:flex;align-items:flex-end;justify-content:center}
54
+ .mg-scrim[hidden]{display:none}
55
+ .mg-box{direction:rtl;width:min(720px,100%);max-height:90vh;display:flex;flex-direction:column;background:#121B3B;color:#F2EFFF;
56
+ border:1px solid #27335F;border-radius:20px 20px 0 0;font:400 16px/1.6 "Rubik",system-ui,-apple-system,"Segoe UI",sans-serif;
57
+ padding-bottom:env(safe-area-inset-bottom,0px)}
58
+ @media (min-width:760px){.mg-scrim{align-items:center}.mg-box{border-radius:20px;max-height:86vh}}
59
+ .mg-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:18px 20px 10px}
60
+ .mg-head h2{margin:0;font:400 24px/1.2 "Secular One","Rubik",system-ui,sans-serif}
61
+ .mg-x{border:1px solid #27335F;background:none;color:#9BA5CD;width:38px;height:38px;border-radius:50%;font-size:16px;cursor:pointer;flex:none}
62
+ .mg-tabs{display:flex;gap:8px;padding:0 20px 12px;border-bottom:1px solid #27335F}
63
+ .mg-tabs button{flex:1;border:1px solid #7EE3C8;background:transparent;color:#7EE3C8;font:700 15px/1 "Rubik",system-ui,sans-serif;padding:11px 12px;border-radius:999px;cursor:pointer}
64
+ .mg-tabs button[aria-selected="true"]{background:#7EE3C8;color:#05231B}
65
+ .mg-body{overflow:auto;padding:14px 20px 22px;overscroll-behavior:contain}
66
+ .mg-body[hidden]{display:none}
67
+ .mg-body details{border:1px solid #27335F;border-radius:14px;background:#18234A;margin-bottom:10px}
68
+ .mg-body summary{display:flex;align-items:center;gap:10px;padding:13px 14px;cursor:pointer;font-weight:700;list-style:none}
69
+ .mg-body summary::-webkit-details-marker{display:none}
70
+ .mg-body summary::after{content:"‹";margin-inline-start:auto;color:#9BA5CD;transition:transform .15s}
71
+ .mg-body details[open] summary::after{transform:rotate(-90deg)}
72
+ .mg-body summary i{font-style:normal;font-size:20px;width:26px;text-align:center}
73
+ .mg-body .mg-txt{padding:0 14px 12px 14px;color:#D9DDF2}
74
+ .mg-body p{margin:0 0 8px}.mg-body ul{margin:0 0 8px;padding-inline-start:20px}.mg-body li{margin-bottom:4px}
75
+ .mg-body b{color:#F2EFFF}
76
+ .mg-intro{color:#9BA5CD;margin:0 0 12px}
77
+ @media (max-width:420px){.mg-head,.mg-tabs,.mg-body{padding-inline:16px}}
78
+ `;
79
+
80
+ let scrim=null, back=null;
81
+ const section=([icon,title,body],i)=>`<details${i===0?" open":""}><summary><i aria-hidden="true">${icon}</i><span>${title}</span></summary><div class="mg-txt">${body}</div></details>`;
82
+ const style=document.createElement("style"); style.textContent=css; document.head.append(style);
83
+ function build(){
84
+ scrim=document.createElement("div"); scrim.className="mg-scrim"; scrim.hidden=true;
85
+ scrim.innerHTML=`<div class="mg-box" role="dialog" aria-modal="true" aria-labelledby="mgTitle">
86
+ <div class="mg-head"><h2 id="mgTitle">איך משתמשים באפליקציה</h2><button class="mg-x" data-mg="close" aria-label="סגירת המדריך">✕</button></div>
87
+ <div class="mg-tabs" role="tablist">
88
+ <button role="tab" id="mgTabS" aria-controls="mgS" aria-selected="true" data-mg-tab="S">לתלמידים</button>
89
+ <button role="tab" id="mgTabT" aria-controls="mgT" aria-selected="false" data-mg-tab="T" tabindex="-1">למורים</button></div>
90
+ <div class="mg-body" id="mgS" role="tabpanel" aria-labelledby="mgTabS"><p class="mg-intro">מתרגלים במסלול, אוספים כוכבים ומתכוננים למבחנים. לחצו על נושא כדי לפתוח אותו.</p>${STUDENT.map(section).join("")}</div>
91
+ <div class="mg-body" id="mgT" role="tabpanel" aria-labelledby="mgTabT" hidden><p class="mg-intro">רואים איך הכיתה מתקדמת, שומרים חומרים לשיעור ומפעילים את הכיתה. לחצו על נושא כדי לפתוח אותו.</p>${TEACHER.map(section).join("")}</div></div>`;
92
+ document.body.append(scrim);
93
+ scrim.addEventListener("click",e=>{
94
+ if(e.target===scrim||e.target.closest("[data-mg=close]")) return close();
95
+ const tab=e.target.closest("[data-mg-tab]"); if(tab) pick(tab.dataset.mgTab);
96
+ });
97
+ scrim.addEventListener("keydown",e=>{
98
+ if(e.key==="Escape"){ e.stopPropagation(); return close(); }
99
+ if(e.target.matches("[data-mg-tab]")&&["ArrowLeft","ArrowRight"].includes(e.key)){
100
+ const to=e.target.dataset.mgTab==="S"?"T":"S"; pick(to); scrim.querySelector(`[data-mg-tab=${to}]`).focus();
101
+ }
102
+ });
103
+ }
104
+ function pick(which){
105
+ scrim.querySelectorAll("[data-mg-tab]").forEach(b=>{ const on=b.dataset.mgTab===which; b.setAttribute("aria-selected",on); b.tabIndex=on?0:-1; });
106
+ scrim.querySelector("#mgS").hidden=which!=="S"; scrim.querySelector("#mgT").hidden=which!=="T";
107
+ }
108
+ function open(which="S"){
109
+ if(!scrim) build();
110
+ back=document.activeElement; pick(which); scrim.hidden=false;
111
+ scrim.querySelector(".mg-x").focus({preventScroll:true});
112
+ }
113
+ function close(){ if(!scrim||scrim.hidden) return; scrim.hidden=true; back?.focus?.({preventScroll:true}); }
114
+
115
+ const button=(cls="inline")=>`<button type="button" class="mg-help ${cls}" data-guide-open aria-label="מדריך שימוש" title="מדריך שימוש">?</button>`;
116
+ // one listener for every "?" button, wherever a screen draws it (capture: before a screen's own handlers)
117
+ document.addEventListener("click",e=>{ if(e.target.closest?.("[data-guide-open]")){ e.preventDefault(); open(); } },true);
118
+
119
+ // the sign-in screen's button
120
+ const boot=document.getElementById("boot");
121
+ if(boot){ boot.style.position="relative"; boot.insertAdjacentHTML("afterbegin",button("")); }
122
+
123
+ window.MathlyGuide={open,close,button};
124
+ })();
static/index.html CHANGED
@@ -24,6 +24,7 @@
24
  </main>
25
  <script>window.MATHLY={supabaseUrl:"__SUPABASE_URL__",supabaseKey:"__SUPABASE_KEY__",token:"",email:"",classes:[],permissions:[]};</script>
26
  <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
 
27
  <script src="/static/photo-image.js"></script>
28
  <script src="/static/answer-scans.js"></script>
29
  <script src="/static/math-tex.js"></script>
 
24
  </main>
25
  <script>window.MATHLY={supabaseUrl:"__SUPABASE_URL__",supabaseKey:"__SUPABASE_KEY__",token:"",email:"",classes:[],permissions:[]};</script>
26
  <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
27
+ <script src="/static/guide.js"></script>
28
  <script src="/static/photo-image.js"></script>
29
  <script src="/static/answer-scans.js"></script>
30
  <script src="/static/math-tex.js"></script>
static/math-board.js CHANGED
@@ -21,7 +21,27 @@ const setOn=on=>{ try{ localStorage.setItem(PREF,on?"on":"off"); }catch{} };
21
 
22
  const INKS=[["#1B2140","שחור"],["#2563D9","כחול"],["#D3455B","אדום"]];
23
  const ERASERS=[[16,"קטן"],[40,"בינוני"],[90,"גדול"]];
24
- const TOOLS=[["move","✋","הזזה"],["pen","✏️","עט"],["line","╱","קו ישר"],["circle","◯","עיגול"],["math","∑","נוסחה"],["eraser","🧽","מחק"]];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  const MATH_SIZE=26;
26
  const PEN=2.6, SHAPE=2.4, SNAP=12, GRAB=9, STEP=2, HISTORY=100;
27
  const PAPER_INK="#1B2140", PAPER_FAINT="#C5CBDF", SELECT="#2563D9";
@@ -49,21 +69,21 @@ const same=(a,b)=>dist(a,b)<.5;
49
 
50
  function build(){
51
  el=document.createElement("aside"); el.className="mx-board"; el.setAttribute("aria-label","לוח טיוטה");
52
- el.innerHTML=`<div class="mx-board-head"><b data-title>לוח טיוטה</b><button type="button" data-do="hide" class="mx-board-back">↩ חזרה לשאלה</button></div>
53
  <div class="mx-board-bar" role="toolbar" aria-label="כלי הלוח">
54
- <span class="mx-board-group">${TOOLS.map(([t,icon,name])=>`<button type="button" data-tool="${t}" aria-pressed="${t===tool}" title="${name}"><i aria-hidden="true">${icon}</i><span>${name}</span></button>`).join("")}</span>
55
  <span class="mx-board-group mx-board-inks">${INKS.map(([c,name])=>`<button type="button" data-ink="${c}" aria-pressed="${c===ink}" aria-label="${name}" title="${name}" style="--dot:${c}"></button>`).join("")}</span>
56
  <span class="mx-board-group mx-board-sizes">${ERASERS.map(([w,name])=>`<button type="button" data-eraser="${w}" aria-pressed="${w===eraser}" aria-label="מחק ${name}" title="מחק ${name}" style="--d:${Math.round(6+w/6)}px"></button>`).join("")}</span>
57
- <span class="mx-board-gap"></span>
58
- <button type="button" data-do="question" title="העתקת השאלה לראש הלוח"><i aria-hidden="true">📋</i><span>השאלה</span></button>
59
- <button type="button" data-do="undo" title="ביטול (Ctrl+Z)"><i aria-hidden="true">↶</i></button>
60
- <button type="button" data-do="clear" title="ניקוי הלוח"><i aria-hidden="true">🗑</i></button>
61
- <button type="button" data-do="hide" aria-label="סגירת הלוח" title="סגירת הלוח">✕</button>
62
  </div><div class="mx-board-row"><div class="mx-board-pages" role="tablist" aria-label="הדף של כל שאלה"></div>
63
  <button type="button" data-do="send" class="mx-board-send" hidden></button></div>
64
  <div class="mx-board-stage"><canvas></canvas>
65
- <button type="button" data-do="delete" class="mx-board-del" title="מחיקת מה שנבחר (Delete)" hidden>✕ מחיקה</button>
66
- <button type="button" data-do="top" class="mx-board-top" hidden>⇡ לראש הדף</button></div>`;
67
  canvas=el.querySelector("canvas"); ctx=canvas.getContext("2d");
68
  el.addEventListener("click",e=>{
69
  const b=e.target.closest("button"); if(!b) return;
@@ -107,7 +127,7 @@ function select(list){ selection=list; }
107
  function turn(p){ if(editing) finishMath(true); page=p; key=`${base}:${page}`; reset(); drawPages(); drawSend(); paint(); }
108
  function drawSend(){
109
  const b=el.querySelector(".mx-board-send"); b.hidden=!hand; el.querySelector(".mx-board-row").hidden=pages<2&&!hand; if(!hand) return;
110
- b.disabled=false; b.innerHTML=`<i aria-hidden="true">📤</i><span>${sendLabel||`הגשה לשאלה ${page+1}`}</span>`; b.title=sendLabel||`הגשת הלוח כתשובה לשאלה ${page+1}`;
111
  }
112
  function drawPages(){
113
  const box=el.querySelector(".mx-board-pages"); el.querySelector(".mx-board-row").hidden=pages<2&&!hand;
@@ -427,7 +447,7 @@ async function send(){
427
  if(!blob){ say(`<span>הלוח ריק: אין מה להגיש</span>`); return setTimeout(()=>page===p&&drawSend(),2500); }
428
  b.disabled=true; say(`<span>מגישים…</span>`);
429
  let r; try{ r=await hand(p,blob); }catch(e){ r={error:e.message}; }
430
- b.disabled=false; say(r?.error?`<span>⚠ ${r.error}</span>`:`<i aria-hidden="true">✓</i><span>${sendLabel?"נשלחה":`הוגשה לשאלה ${p+1}`}</span>`);
431
  if(r?.close&&full) return setTimeout(closeFull,900); // done: back to the window that asked
432
  setTimeout(()=>page===p&&!b.disabled&&drawSend(),3000);
433
  }
@@ -481,7 +501,7 @@ function openFull({host,key:k,pages:n=1,page:p=0,question=null,submit=null,turne
481
  el.querySelector("[data-title]").textContent=title;
482
  el.classList.add("full"); el.setAttribute("role","dialog"); el.setAttribute("aria-modal","true");
483
  reset(); drawPages(); drawSend(); host.append(el); fit();
484
- el.querySelector(".mx-board-back").focus({preventScroll:true});
485
  }
486
  function closeFull(){
487
  if(!full) return; if(editing) finishMath(true); const f=full; full=null;
@@ -490,7 +510,7 @@ function closeFull(){
490
  if(layer?.isConnected&&desk()) again();
491
  }
492
  // the header's switch: beside the exercise on a desktop screen, full screen on a phone
493
- const button=()=>`<button class="mx-board-btn" data-board-toggle aria-pressed="${isOn()&&desk()}" title="לוח טיוטה">✏️ לוח</button>`;
494
 
495
  window.MathlyBoard={button,mount,toggle,show,openFull,closeFull,isOn};
496
  })();
 
21
 
22
  const INKS=[["#1B2140","שחור"],["#2563D9","כחול"],["#D3455B","אדום"]];
23
  const ERASERS=[[16,"קטן"],[40,"בינוני"],[90,"גדול"]];
24
+ const TOOLS=[["move","הזזה"],["pen","עט"],["line","קו ישר"],["circle","עיגול"],["math","נוסחה"],["eraser","מחק"]];
25
+ /* one line-drawn icon set (24×24, the button's text colour), so every button looks alike on every device and
26
+ follows the button's hover and pressed colours; "flip" icons point the other way in a right-to-left page */
27
+ const DOT='fill="currentColor" stroke="none"';
28
+ const ICONS={
29
+ move:'<path d="M18 11V6.5a1.5 1.5 0 0 0-3 0V11M15 10V4.5a1.5 1.5 0 0 0-3 0V10M12 10V5.5a1.5 1.5 0 0 0-3 0V13M18 9.5a1.5 1.5 0 0 1 3 0V14a7 7 0 0 1-7 7h-1.5c-2.2 0-3.7-.8-5-2.1l-3.3-3.3a1.5 1.5 0 0 1 2.1-2.1L9 15.2"/>',
30
+ pen:'<path d="M4 20l1.2-4.2L16.5 4.5a2.1 2.1 0 0 1 3 3L8.2 18.8z"/><path d="M14.5 6.5l3 3"/>',
31
+ line:`<path d="M6 18L18 6"/><circle cx="6" cy="18" r="2" ${DOT}/><circle cx="18" cy="6" r="2" ${DOT}/>`,
32
+ circle:`<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="1.6" ${DOT}/>`,
33
+ math:'<path d="M17.5 5.5V4H6.5l6 8-6 8h11v-1.5"/>',
34
+ eraser:'<path d="M20 20H9.5M4.6 15.4l9.8-9.8a2 2 0 0 1 2.8 0l2.2 2.2a2 2 0 0 1 0 2.8L11 19H8.2l-3.6-3.6"/><path d="M9.5 10.5l5 5"/>',
35
+ question:'<rect x="5.5" y="5" width="13" height="16" rx="2"/><path d="M9 5V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1M9 11h6M9 15h4"/>',
36
+ undo:'<path d="M9 14L4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
37
+ clear:'<path d="M4 7h16M10 11v6M14 11v6M6 7l1 12.2A2 2 0 0 0 9 21h6a2 2 0 0 0 2-1.8L18 7M9 7V4.5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1V7"/>',
38
+ close:'<path d="M6 6l12 12M18 6L6 18"/>',
39
+ send:'<path d="M12 15V4M7 9l5-5 5 5M5 15v4a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-4"/>',
40
+ done:'<path d="M5 12.5l4.5 4.5L19 7"/>',
41
+ back:'<path d="M19 12H5M11 6l-6 6 6 6"/>',
42
+ top:'<path d="M12 19V5M6 11l6-6 6 6"/>'};
43
+ const FLIP=new Set(["undo","back"]);
44
+ const ic=n=>`<svg class="mx-ic${FLIP.has(n)?" mx-ic-flip":""}" viewBox="0 0 24 24" aria-hidden="true">${ICONS[n]}</svg>`;
45
  const MATH_SIZE=26;
46
  const PEN=2.6, SHAPE=2.4, SNAP=12, GRAB=9, STEP=2, HISTORY=100;
47
  const PAPER_INK="#1B2140", PAPER_FAINT="#C5CBDF", SELECT="#2563D9";
 
69
 
70
  function build(){
71
  el=document.createElement("aside"); el.className="mx-board"; el.setAttribute("aria-label","לוח טיוטה");
72
+ el.innerHTML=`<div class="mx-board-head"><b data-title>לוח טיוטה</b><button type="button" data-do="hide" class="mx-board-back">${ic("back")}<span>חזרה לשאלה</span></button></div>
73
  <div class="mx-board-bar" role="toolbar" aria-label="כלי הלוח">
74
+ <span class="mx-board-group">${TOOLS.map(([t,name])=>`<button type="button" data-tool="${t}" aria-pressed="${t===tool}" title="${name}">${ic(t)}<span>${name}</span></button>`).join("")}</span>
75
  <span class="mx-board-group mx-board-inks">${INKS.map(([c,name])=>`<button type="button" data-ink="${c}" aria-pressed="${c===ink}" aria-label="${name}" title="${name}" style="--dot:${c}"></button>`).join("")}</span>
76
  <span class="mx-board-group mx-board-sizes">${ERASERS.map(([w,name])=>`<button type="button" data-eraser="${w}" aria-pressed="${w===eraser}" aria-label="מחק ${name}" title="מחק ${name}" style="--d:${Math.round(6+w/6)}px"></button>`).join("")}</span>
77
+ <span class="mx-board-group mx-board-acts">
78
+ <button type="button" data-do="question" title="העתקת השאלה לראש הלוח">${ic("question")}<span>השאלה</span></button>
79
+ <button type="button" data-do="undo" title="ביטול (Ctrl+Z)">${ic("undo")}<span>ביטול</span></button>
80
+ <button type="button" data-do="clear" title="ניקוי הלוח">${ic("clear")}<span>ניקוי</span></button></span>
81
+ <button type="button" data-do="hide" class="mx-board-close" aria-label="ס��ירת הלוח" title="סגירת הלוח">${ic("close")}</button>
82
  </div><div class="mx-board-row"><div class="mx-board-pages" role="tablist" aria-label="הדף של כל שאלה"></div>
83
  <button type="button" data-do="send" class="mx-board-send" hidden></button></div>
84
  <div class="mx-board-stage"><canvas></canvas>
85
+ <button type="button" data-do="delete" class="mx-board-del" title="מחיקת מה שנבחר (Delete)" hidden>${ic("clear")}מחיקה</button>
86
+ <button type="button" data-do="top" class="mx-board-top" hidden>${ic("top")}לראש הדף</button></div>`;
87
  canvas=el.querySelector("canvas"); ctx=canvas.getContext("2d");
88
  el.addEventListener("click",e=>{
89
  const b=e.target.closest("button"); if(!b) return;
 
127
  function turn(p){ if(editing) finishMath(true); page=p; key=`${base}:${page}`; reset(); drawPages(); drawSend(); paint(); }
128
  function drawSend(){
129
  const b=el.querySelector(".mx-board-send"); b.hidden=!hand; el.querySelector(".mx-board-row").hidden=pages<2&&!hand; if(!hand) return;
130
+ b.disabled=false; b.innerHTML=`${ic("send")}<span>${sendLabel||`הגשה לשאלה ${page+1}`}</span>`; b.title=sendLabel||`הגשת הלוח כתשובה לשאלה ${page+1}`;
131
  }
132
  function drawPages(){
133
  const box=el.querySelector(".mx-board-pages"); el.querySelector(".mx-board-row").hidden=pages<2&&!hand;
 
447
  if(!blob){ say(`<span>הלוח ריק: אין מה להגיש</span>`); return setTimeout(()=>page===p&&drawSend(),2500); }
448
  b.disabled=true; say(`<span>מגישים…</span>`);
449
  let r; try{ r=await hand(p,blob); }catch(e){ r={error:e.message}; }
450
+ b.disabled=false; say(r?.error?`<span>⚠ ${r.error}</span>`:`${ic("done")}<span>${sendLabel?"נשלחה":`הוגשה לשאלה ${p+1}`}</span>`);
451
  if(r?.close&&full) return setTimeout(closeFull,900); // done: back to the window that asked
452
  setTimeout(()=>page===p&&!b.disabled&&drawSend(),3000);
453
  }
 
501
  el.querySelector("[data-title]").textContent=title;
502
  el.classList.add("full"); el.setAttribute("role","dialog"); el.setAttribute("aria-modal","true");
503
  reset(); drawPages(); drawSend(); host.append(el); fit();
504
+ el.querySelector(".mx-board-back").focus({preventScroll:true,focusVisible:false}); // reachable by keyboard, without a ring on a tap
505
  }
506
  function closeFull(){
507
  if(!full) return; if(editing) finishMath(true); const f=full; full=null;
 
510
  if(layer?.isConnected&&desk()) again();
511
  }
512
  // the header's switch: beside the exercise on a desktop screen, full screen on a phone
513
+ const button=()=>`<button class="mx-board-btn" data-board-toggle aria-pressed="${isOn()&&desk()}" title="לוח טיוטה">${ic("pen")}<span>לוח</span></button>`;
514
 
515
  window.MathlyBoard={button,mount,toggle,show,openFull,closeFull,isOn};
516
  })();
static/math.css CHANGED
@@ -50,6 +50,14 @@ html.math-on, html.math-on body{overflow:hidden}
50
  .mx-subject-icon{flex:none;width:46px;height:46px;border-radius:14px;display:grid;place-items:center;background:color-mix(in srgb,var(--exam) 22%,var(--bg));color:var(--exam);font:700 24px/1 var(--f-body)}
51
  .mx-subject-card>span:nth-child(2){display:flex;flex:1;flex-direction:column;gap:4px;min-width:0}.mx-subject-card b{font-size:18px}.mx-subject-card small{color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.mx-subject-card i{color:var(--exam);font-size:26px;font-style:normal}
52
  .mx-subject-empty{--exam:var(--mx-accent);display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center;border:1px solid color-mix(in srgb,var(--exam) 45%,var(--line));border-radius:18px;background:var(--surface);padding:32px 20px}.mx-subject-empty>span{font-size:42px}.mx-subject-empty h2{color:var(--exam)}.mx-subject-empty p{max-width:480px;margin:0;color:var(--muted);line-height:1.5}
 
 
 
 
 
 
 
 
53
  @media (max-width:520px){.mx-subject-grid{grid-template-columns:1fr}}
54
 
55
  /* a skill's sheet */
@@ -211,6 +219,7 @@ html.math-on, html.math-on body{overflow:hidden}
211
  .mx-class-form label>small,.mx-class-form legend small{display:block;color:var(--muted);font-size:12px;font-weight:500;margin-top:3px}
212
  .mx-class-form input[type="text"],.mx-class-form input[name="name"],.mx-class-form textarea{box-sizing:border-box;width:100%;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--ink);padding:11px 12px;font:inherit}
213
  .mx-class-form textarea{min-height:110px;resize:vertical;text-align:left}
 
214
  .mx-class-form fieldset{margin:0}
215
  .mx-subject-picks{--exam:var(--mx-accent);display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:8px}
216
  .mx-subject-picks label{display:flex;align-items:center;gap:10px;border:1px solid var(--line);border-radius:13px;padding:9px 10px;cursor:pointer}
@@ -661,7 +670,11 @@ button.sky-chip.flame{padding:6px 11px;color:var(--ink)}
661
  /* the sketch board beside a skill's exercise (static/math-board.js): a desktop screen with a mouse or a pen only */
662
  .mx-shacts{display:flex;align-items:center;gap:6px}
663
  .mx-board{display:none} /* the buttons that open it show everywhere: beside the exercise on a desktop, full screen on a phone */
664
- .mx-board-btn{border:1px solid var(--line);background:none;color:var(--muted);font:700 14px/1 var(--f-body);padding:7px 12px;border-radius:999px;cursor:pointer}
 
 
 
 
665
  .mx-board-btn[aria-pressed="true"]{border-color:var(--mx-accent);background:var(--mx-accent-soft);color:var(--ink)}
666
  @media (min-width:1000px) and (any-pointer:fine){
667
  .mx-sheet.with-board{inset:16px 16px 16px auto;width:min(600px,42vw);max-height:none;border-radius:20px}
@@ -669,20 +682,29 @@ button.sky-chip.flame{padding:6px 11px;color:var(--ink)}
669
  background:var(--surface);border:1px solid var(--line);border-radius:20px;overflow:hidden;box-shadow:0 6px 30px rgb(0 0 0/.25)}
670
  }
671
  .mx-board-bar{display:flex;align-items:center;gap:6px;padding:8px 10px;border-bottom:1px solid var(--line);flex-wrap:wrap}
672
- .mx-board-bar button{display:inline-flex;align-items:center;gap:5px;border:1px solid transparent;background:none;color:var(--muted);
673
- font:500 14px/1 var(--f-body);padding:7px 10px;border-radius:10px;cursor:pointer;min-height:34px}
674
- .mx-board-bar button i{font-style:normal;font-size:16px;line-height:1}
675
  .mx-board-bar button:hover{color:var(--ink);background:var(--surface-2)}
676
  .mx-board-bar button[hidden]{display:none}
677
  .mx-board-bar button[aria-pressed="true"]{color:var(--ink);border-color:var(--mx-accent);background:var(--mx-accent-soft)}
678
  @media (max-width:1600px){.mx-board-bar button span{display:none}} /* a narrower board: icons only, names stay as tooltips */
679
  .mx-board-group{display:inline-flex;align-items:center;gap:3px}
680
- .mx-board-inks,.mx-board-sizes{padding-inline:6px;border-inline-start:1px solid var(--line)}
681
- .mx-board-inks button,.mx-board-sizes button{width:34px;padding:0;justify-content:center}
682
- .mx-board-inks button::before{content:"";width:16px;height:16px;border-radius:50%;background:var(--dot);box-shadow:0 0 0 2px #fff}
683
- .mx-board-sizes button::before{content:"";width:var(--d);height:var(--d);border-radius:50%;border:1.5px solid currentColor}
684
- .mx-board:not([data-tool="eraser"]) .mx-board-sizes{display:none} /* the sizes show with the eraser */
685
- .mx-board-gap{flex:1}
 
 
 
 
 
 
 
 
 
 
686
  .mx-board-row{display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--line)}
687
  .mx-board-row[hidden]{display:none}
688
  .mx-board-pages{display:flex;align-items:center;gap:4px;flex:1;color:var(--muted);font-size:13px}
@@ -694,10 +716,13 @@ button.sky-chip.flame{padding:6px 11px;color:var(--ink)}
694
  .mx-board canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:crosshair;
695
  background-color:#F8F7F2;background-image:linear-gradient(rgb(27 33 64/.07) 1px,transparent 1px),linear-gradient(90deg,rgb(27 33 64/.07) 1px,transparent 1px);
696
  background-size:28px 28px}
 
 
697
  .mx-board-del{position:absolute;z-index:1;border:1px solid #E7B3BB;background:#FFF1F3;color:#B4233A;font:700 13px/1 var(--f-body);
698
  padding:7px 11px;border-radius:999px;cursor:pointer;box-shadow:0 2px 8px rgb(27 33 64/.15)}
699
  .mx-board-del[hidden]{display:none}
700
 
 
701
  .mx-board-send{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--mx-accent);background:var(--mx-accent);color:var(--mx-on-accent);
702
  font:700 14px/1 var(--f-body);padding:8px 14px;border-radius:999px;cursor:pointer}
703
  .mx-board-send[hidden]{display:none}
@@ -712,11 +737,11 @@ button.sky-chip.flame{padding:6px 11px;color:var(--ink)}
712
  .mx-board.full{display:flex;flex-direction:column;position:fixed;inset:0;z-index:1100;border:0;border-radius:0;box-shadow:none;background:var(--surface);
713
  padding:env(safe-area-inset-top,0px) env(safe-area-inset-right,0px) env(safe-area-inset-bottom,0px) env(safe-area-inset-left,0px)}
714
  .mx-board.full .mx-board-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 12px;border-bottom:1px solid var(--line);color:var(--ink)}
715
- .mx-board-back{border:1px solid var(--mx-accent);background:var(--mx-accent-soft);color:var(--ink);font:700 14px/1 var(--f-body);padding:9px 14px;border-radius:999px;cursor:pointer}
716
  .mx-board.full .mx-board-bar{flex-wrap:wrap;row-gap:4px} /* every tool in sight: two rows on a phone rather than hidden off the edge */
717
- .mx-board.full .mx-board-gap{flex:0}
718
  .mx-board.full .mx-board-bar [data-do="hide"]{display:none} /* the head's "back" does it */
719
- .mx-board.full .mx-board-bar button{min-height:40px;min-width:40px;justify-content:center}
 
720
 
721
  /* typing math on the board: a bar over the board's top; MathLive's keyboard sits above everything, the board too */
722
  body{--keyboard-zindex:3000}
@@ -727,3 +752,60 @@ body{--keyboard-zindex:3000}
727
  .mx-board-mathedit button{flex:none;width:40px;height:40px;border-radius:10px;border:1px solid #C5CBDF;background:#F4F6FC;color:#1B2140;font:700 18px/1 var(--f-body);cursor:pointer}
728
  .mx-board-mathedit button[data-math="ok"]{background:var(--mx-accent);border-color:var(--mx-accent);color:var(--mx-on-accent)}
729
  .mx-board-mathwait{flex:1;color:#5B6694;font:500 14px/1.3 var(--f-body);padding:8px}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  .mx-subject-icon{flex:none;width:46px;height:46px;border-radius:14px;display:grid;place-items:center;background:color-mix(in srgb,var(--exam) 22%,var(--bg));color:var(--exam);font:700 24px/1 var(--f-body)}
51
  .mx-subject-card>span:nth-child(2){display:flex;flex:1;flex-direction:column;gap:4px;min-width:0}.mx-subject-card b{font-size:18px}.mx-subject-card small{color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.mx-subject-card i{color:var(--exam);font-size:26px;font-style:normal}
52
  .mx-subject-empty{--exam:var(--mx-accent);display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center;border:1px solid color-mix(in srgb,var(--exam) 45%,var(--line));border-radius:18px;background:var(--surface);padding:32px 20px}.mx-subject-empty>span{font-size:42px}.mx-subject-empty h2{color:var(--exam)}.mx-subject-empty p{max-width:480px;margin:0;color:var(--muted);line-height:1.5}
53
+ .mx-subject-left{display:flex;align-items:center;gap:6px}
54
+ .mx-subject-dots{display:inline-flex;gap:3px;flex:none}.mx-subject-dots>span{width:8px;height:8px;border-radius:50%;background:var(--exam)}.mx-subject-dots>span.past{background:transparent;box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--exam) 55%,var(--line))}
55
+ .mx-subject-bar{display:block;height:6px;border-radius:99px;background:color-mix(in srgb,var(--exam) 28%,var(--line));overflow:hidden;margin-top:2px}.mx-subject-bar>span{display:block;height:100%;border-radius:inherit;background:var(--exam)}
56
+ .mx-subject-count{flex:none;display:flex;flex-direction:column;align-items:center;min-width:48px;padding:6px 8px;border-radius:12px;background:color-mix(in srgb,var(--exam) 14%,var(--bg));line-height:1.1}
57
+ .mx-subject-card .mx-subject-count b{font:700 22px/1.1 var(--f-display)}.mx-subject-card .mx-subject-count small{font-size:11px}
58
+ .mx-subject-count.soon{background:var(--amber-bg);color:var(--amber)}.mx-subject-count.urgent{background:var(--sumac-bg);color:var(--sumac)}
59
+ .mx-subject-count.soon small,.mx-subject-count.urgent small{color:inherit}
60
+ .mx-subject-card.is-over{opacity:.55;filter:saturate(.4)}.mx-subject-card.is-over:hover{opacity:.8}
61
  @media (max-width:520px){.mx-subject-grid{grid-template-columns:1fr}}
62
 
63
  /* a skill's sheet */
 
219
  .mx-class-form label>small,.mx-class-form legend small{display:block;color:var(--muted);font-size:12px;font-weight:500;margin-top:3px}
220
  .mx-class-form input[type="text"],.mx-class-form input[name="name"],.mx-class-form textarea{box-sizing:border-box;width:100%;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--ink);padding:11px 12px;font:inherit}
221
  .mx-class-form textarea{min-height:110px;resize:vertical;text-align:left}
222
+ .mx-class-form select{box-sizing:border-box;width:100%;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--ink);padding:11px 12px;font:inherit;text-align:left}
223
  .mx-class-form fieldset{margin:0}
224
  .mx-subject-picks{--exam:var(--mx-accent);display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:8px}
225
  .mx-subject-picks label{display:flex;align-items:center;gap:10px;border:1px solid var(--line);border-radius:13px;padding:9px 10px;cursor:pointer}
 
670
  /* the sketch board beside a skill's exercise (static/math-board.js): a desktop screen with a mouse or a pen only */
671
  .mx-shacts{display:flex;align-items:center;gap:6px}
672
  .mx-board{display:none} /* the buttons that open it show everywhere: beside the exercise on a desktop, full screen on a phone */
673
+ .mx-board-btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:none;color:var(--muted);font:700 14px/1 var(--f-body);padding:7px 12px;border-radius:999px;cursor:pointer}
674
+ .mx-board-btn .mx-ic{width:16px;height:16px}
675
+ /* the board's icons: one line-drawn set in the button's own colour */
676
+ .mx-ic{width:20px;height:20px;flex:none;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
677
+ [dir="rtl"] .mx-ic-flip{transform:scaleX(-1)} /* back and undo point to the start of a right-to-left line */
678
  .mx-board-btn[aria-pressed="true"]{border-color:var(--mx-accent);background:var(--mx-accent-soft);color:var(--ink)}
679
  @media (min-width:1000px) and (any-pointer:fine){
680
  .mx-sheet.with-board{inset:16px 16px 16px auto;width:min(600px,42vw);max-height:none;border-radius:20px}
 
682
  background:var(--surface);border:1px solid var(--line);border-radius:20px;overflow:hidden;box-shadow:0 6px 30px rgb(0 0 0/.25)}
683
  }
684
  .mx-board-bar{display:flex;align-items:center;gap:6px;padding:8px 10px;border-bottom:1px solid var(--line);flex-wrap:wrap}
685
+ .mx-board-bar button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid transparent;background:none;color:var(--muted);
686
+ font:500 14px/1 var(--f-body);padding:0 7px;border-radius:10px;cursor:pointer;height:36px;min-width:36px}
 
687
  .mx-board-bar button:hover{color:var(--ink);background:var(--surface-2)}
688
  .mx-board-bar button[hidden]{display:none}
689
  .mx-board-bar button[aria-pressed="true"]{color:var(--ink);border-color:var(--mx-accent);background:var(--mx-accent-soft)}
690
  @media (max-width:1600px){.mx-board-bar button span{display:none}} /* a narrower board: icons only, names stay as tooltips */
691
  .mx-board-group{display:inline-flex;align-items:center;gap:3px}
692
+ /* the inks and the eraser's sizes sit on a strip of the paper's colour, so each ink shows as it will be drawn
693
+ (the black one too); the chosen one has a ring, not the tools' outline. The sizes take the inks' place with
694
+ the eraser, so the bar keeps its shape. */
695
+ .mx-board-inks,.mx-board-sizes{background:#F8F7F2;border-radius:12px;padding:2px;gap:2px}
696
+ .mx-board-bar .mx-board-inks button,.mx-board-bar .mx-board-sizes button{width:34px;min-width:0;height:32px;padding:0;border:0;border-radius:9px;background:none}
697
+ .mx-board-bar .mx-board-inks button:hover,.mx-board-bar .mx-board-sizes button:hover{background:rgb(27 33 64/.08)}
698
+ .mx-board-inks button::before,.mx-board-sizes button::before{content:"";border-radius:50%}
699
+ .mx-board-inks button::before{width:18px;height:18px;background:var(--dot)}
700
+ .mx-board-sizes button::before{width:var(--d);height:var(--d);background:#8A93B8}
701
+ .mx-board-bar .mx-board-inks button[aria-pressed="true"],.mx-board-bar .mx-board-sizes button[aria-pressed="true"]{background:none}
702
+ .mx-board-inks button[aria-pressed="true"]::before{box-shadow:0 0 0 2px #F8F7F2,0 0 0 4px #1B2140}
703
+ .mx-board-sizes button[aria-pressed="true"]::before{background:#1B2140;box-shadow:0 0 0 2px #F8F7F2,0 0 0 4px #1B2140}
704
+ .mx-board:not([data-tool="eraser"]) .mx-board-sizes,.mx-board[data-tool="eraser"] .mx-board-inks{display:none}
705
+ .mx-board-acts{margin-inline-start:auto} /* the page's actions at the far end, apart from the tools */
706
+ .mx-board-bar .mx-board-close{position:relative;margin-inline-start:9px} /* close, kept apart from clear by a line */
707
+ .mx-board-close::before{content:"";position:absolute;inset-inline-start:-8px;top:8px;bottom:8px;width:1px;background:var(--line)}
708
  .mx-board-row{display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--line)}
709
  .mx-board-row[hidden]{display:none}
710
  .mx-board-pages{display:flex;align-items:center;gap:4px;flex:1;color:var(--muted);font-size:13px}
 
716
  .mx-board canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:crosshair;
717
  background-color:#F8F7F2;background-image:linear-gradient(rgb(27 33 64/.07) 1px,transparent 1px),linear-gradient(90deg,rgb(27 33 64/.07) 1px,transparent 1px);
718
  background-size:28px 28px}
719
+ .mx-board-del,.mx-board-top{display:inline-flex;align-items:center;gap:5px}
720
+ .mx-board-del .mx-ic,.mx-board-top .mx-ic{width:16px;height:16px}
721
  .mx-board-del{position:absolute;z-index:1;border:1px solid #E7B3BB;background:#FFF1F3;color:#B4233A;font:700 13px/1 var(--f-body);
722
  padding:7px 11px;border-radius:999px;cursor:pointer;box-shadow:0 2px 8px rgb(27 33 64/.15)}
723
  .mx-board-del[hidden]{display:none}
724
 
725
+ .mx-board-send .mx-ic{width:18px;height:18px}
726
  .mx-board-send{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--mx-accent);background:var(--mx-accent);color:var(--mx-on-accent);
727
  font:700 14px/1 var(--f-body);padding:8px 14px;border-radius:999px;cursor:pointer}
728
  .mx-board-send[hidden]{display:none}
 
737
  .mx-board.full{display:flex;flex-direction:column;position:fixed;inset:0;z-index:1100;border:0;border-radius:0;box-shadow:none;background:var(--surface);
738
  padding:env(safe-area-inset-top,0px) env(safe-area-inset-right,0px) env(safe-area-inset-bottom,0px) env(safe-area-inset-left,0px)}
739
  .mx-board.full .mx-board-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 12px;border-bottom:1px solid var(--line);color:var(--ink)}
740
+ .mx-board-back{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--mx-accent);background:var(--mx-accent-soft);color:var(--ink);font:700 14px/1 var(--f-body);padding:9px 14px;border-radius:999px;cursor:pointer}
741
  .mx-board.full .mx-board-bar{flex-wrap:wrap;row-gap:4px} /* every tool in sight: two rows on a phone rather than hidden off the edge */
 
742
  .mx-board.full .mx-board-bar [data-do="hide"]{display:none} /* the head's "back" does it */
743
+ .mx-board.full .mx-board-bar button{height:40px;min-width:40px}
744
+ .mx-board.full .mx-board-bar .mx-board-inks button,.mx-board.full .mx-board-bar .mx-board-sizes button{height:36px;width:40px}
745
 
746
  /* typing math on the board: a bar over the board's top; MathLive's keyboard sits above everything, the board too */
747
  body{--keyboard-zindex:3000}
 
752
  .mx-board-mathedit button{flex:none;width:40px;height:40px;border-radius:10px;border:1px solid #C5CBDF;background:#F4F6FC;color:#1B2140;font:700 18px/1 var(--f-body);cursor:pointer}
753
  .mx-board-mathedit button[data-math="ok"]{background:var(--mx-accent);border-color:var(--mx-accent);color:var(--mx-on-accent)}
754
  .mx-board-mathwait{flex:1;color:#5B6694;font:500 14px/1.3 var(--f-body);padding:8px}
755
+
756
+ /* the class teacher's report (math.js: classReport) */
757
+ .mx-report-tabs{display:flex;gap:6px;flex-wrap:wrap}
758
+ .mx-report-tabs [aria-pressed="true"]{border-color:var(--mx-accent);color:var(--mx-accent)}
759
+ .mx-report{display:flex;flex-direction:column;gap:10px}
760
+ .mx-report h4{margin:10px 0 0;font-size:18px}
761
+ .mx-report-common{border:1px solid var(--amber);border-radius:14px;background:var(--amber-bg);padding:14px 16px}
762
+ .mx-report-common h4{margin:0 0 6px;color:var(--amber)}
763
+ .mx-report-list{margin:0;padding-inline-start:18px;display:flex;flex-direction:column;gap:6px}
764
+ .mx-report-list li small{display:block;color:var(--muted)}
765
+ .mx-report-student{border:1px solid var(--line);border-radius:14px;background:var(--surface)}
766
+ .mx-report-student>summary{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;padding:12px 14px;cursor:pointer;list-style:none}
767
+ .mx-report-student>summary::-webkit-details-marker{display:none}
768
+ .mx-report-student>summary b{min-width:8ch}
769
+ .mx-report-student[open]>summary{border-bottom:1px solid var(--line)}
770
+ .mx-report-prog{display:flex;align-items:center;gap:8px;flex:1;min-width:150px}
771
+ .mx-report-prog .mx-meter{flex:1;height:8px;background:var(--surface-2)}
772
+ .mx-report-prog .mx-meter i{background:var(--aurora)}
773
+ .mx-report-prog small{color:var(--muted);white-space:nowrap}
774
+ .mx-report-chip{font:700 12px/1 var(--f-body);padding:5px 9px;border-radius:999px;background:var(--surface-2);color:var(--muted);white-space:nowrap}
775
+ .mx-report-chip.good,.mx-report-grade.good{color:var(--ok)}.mx-report-chip.good{background:var(--ok-bg)}
776
+ .mx-report-chip.mid,.mx-report-grade.mid{color:var(--amber)}.mx-report-chip.mid{background:var(--amber-bg)}
777
+ .mx-report-chip.low,.mx-report-grade.low{color:var(--sumac)}.mx-report-chip.low{background:var(--sumac-bg)}
778
+ .mx-report-body{display:flex;flex-direction:column;gap:16px;padding:14px}
779
+ .mx-report-body h5{margin:0 0 8px;font-size:15px;color:var(--mx-accent)}
780
+ .mx-report-body p{margin:0}
781
+ .mx-report-table{width:100%;border-collapse:collapse;font-size:14px}
782
+ .mx-report-table th{color:var(--muted);font-weight:400;text-align:start;padding:4px 6px;border-bottom:1px solid var(--line)}
783
+ .mx-report-table td{padding:6px;border-bottom:1px solid var(--line)}
784
+ .mx-report-grade{font-weight:700}
785
+ @media (max-width:600px){.mx-report-prog{flex-basis:100%}.mx-report-table{font-size:13px}}
786
+
787
+ /* the teacher's AI queue (math.js: aiQueue) */
788
+ .mx-aq-badge{display:inline-block;min-width:1.6em;margin-inline-start:4px;padding:2px 6px;border-radius:999px;background:var(--sumac);color:#fff;font:700 12px/1.2 var(--f-body);text-align:center}
789
+ .mx-aq{display:flex;flex-direction:column;gap:10px}
790
+ .mx-aq-done{margin:10px 0 0;font-size:16px;color:var(--muted)}
791
+ .mx-aq-card{display:flex;flex-direction:column;gap:8px;border:1px solid var(--line);border-radius:14px;background:var(--surface);padding:12px 14px}
792
+ .mx-aq-card p{margin:0}
793
+ .mx-aq-card.done,.mx-aq-card.declined,.mx-aq-card.cancelled{opacity:.8}
794
+ .mx-aq-head{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px}
795
+ .mx-aq-head small{color:var(--muted)}
796
+ .mx-aq-state{margin-inline-start:auto;font:700 12px/1 var(--f-body);padding:5px 9px;border-radius:999px;background:var(--surface-2);color:var(--muted)}
797
+ .mx-aq-card.pending .mx-aq-state{background:var(--amber-bg);color:var(--amber)}
798
+ .mx-aq-card.done .mx-aq-state{background:var(--ok-bg);color:var(--ok)}
799
+ .mx-aq-card.failed .mx-aq-state,.mx-aq-card.declined .mx-aq-state{background:var(--sumac-bg);color:var(--sumac)}
800
+ .mx-aq-label{font-weight:700}
801
+ .mx-aq-q{color:var(--muted);font-size:14px}
802
+ .mx-aq-error{color:var(--sumac)}
803
+ .mx-aq-img{max-width:100%;max-height:360px;object-fit:contain;border-radius:10px;border:1px solid var(--line);align-self:flex-start}
804
+ /* "פירוט השגיאה": why an AI check failed (answer-scans.js: errorDetails) */
805
+ .mx-error-details{align-self:flex-start;max-width:100%}
806
+ .mx-error-details>summary{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid var(--line);border-radius:999px;cursor:pointer;font:500 13px/1.2 var(--f-body);color:var(--sumac);list-style:none}
807
+ .mx-error-details>summary::-webkit-details-marker{display:none}
808
+ .mx-error-details>summary::before{content:"ⓘ"}
809
+ .mx-error-details[open]>summary{border-color:var(--sumac)}
810
+ .mx-error-details p{margin:8px 0 0}
811
+ .mx-error-details code{display:block;margin-top:8px;padding:8px 10px;border-radius:8px;background:var(--surface-2);color:var(--muted);font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;text-align:left}
static/math.js CHANGED
@@ -164,7 +164,8 @@ async function show(){
164
  if(remote){ try{ localStorage.setItem(key(),JSON.stringify(P)); }catch{} queuePush(); } // this device's own work goes up too
165
  if(!P.set||typeof P.set!=="object") P.set={}; SHOWN=new Set(shownUnits().map(u=>u.id));
166
  hasExams()?drawSubjectPicker():selectSubject("מתמטיקה"); // no exams yet: straight to math, science is a tap away
167
- window.MathlyAnswers?.configure({api,account:CFG.userId,enabled:CFG.answerScans,root,onGraded:({questionId,grading})=>{
 
168
  if(grading.result.verdict!=="correct"||P.done[questionId])return;
169
  const unitWas=unitDone(unitOf(questionId));
170
  P.done[questionId]=grading.finished_at||new Date().toISOString(); Sky.mastered(P,questionId);
@@ -185,16 +186,32 @@ function subjectOptions(){
185
  const item=bySubject.get(exam.subject)||{name:exam.subject,dates:[],classes:new Set()};
186
  item.dates.push(exam.date);item.classes.add(row.name);bySubject.set(exam.subject,item);
187
  }));
188
- return [...bySubject.values()].map(item=>({...item,dates:item.dates.sort()})).sort((a,b)=>!a.dates.length-!b.dates.length||String(a.dates[0]||"").localeCompare(String(b.dates[0]||"")));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  }
190
  function drawSubjectPicker(){
191
  ACTIVE_SUBJECT=""; nav.hidden=true;
192
  const manage=can("management.access"), subjects=subjectOptions(), today=new Date();today.setHours(0,0,0,0);
193
- path.innerHTML=`<header class="mx-top"><div><h1>המקצועות שלי</h1><p class="mx-sub">בחרו מקצוע כדי לפתוח את המסלול שלו</p></div><div class="mx-acts">${manage?'<button class="mx-btn" data-act="manage">ניהול</button>':""}${window.MathlyAnswers?.pushButton?.()||""}${AUTH_URL?'<button class="mx-btn" data-act="out">יציאה</button>':""}</div></header>
194
- <section class="mx-subject-grid" aria-label="מקצועות הכיתה">${subjects.map(item=>{
195
- const [icon,tone]=examLooks[item.name]||["●","other"],future=item.dates.find(date=>examDate(date)>=today),count=item.dates.length;
196
- return `<button class="mx-subject-card exam-${tone}" data-subject="${esc(item.name)}"><span class="mx-subject-icon" aria-hidden="true">${icon}</span><span><b>${esc(item.name)}</b><small>${count?`${count} מבחנים${future?` · הבא ${examDay(future)}`:" · הסתיימו"}`:item.name==="מדעים"?"חידות בהצבעה כיתתית":"מסלול תרגול"}</small></span><i aria-hidden="true">‹</i></button>`;
197
- }).join("")}</section>`;
198
  }
199
  function selectSubject(subject){
200
  ACTIVE_SUBJECT=subject;
@@ -244,7 +261,7 @@ function nextExam(){
244
  function topBar(){
245
  const st=skyStats(), hot=Sky.today(P)>=Sky.GOAL, manage=can("management.access"), on=Sky.soundOn(), streak=Sky.streak(P);
246
  return `<header class="mx-top"><div class="mx-brand">${Sky.spark()}<span>מתמטיקה</span></div>
247
- <div class="mx-acts"><button class="mx-btn" data-act="subjects">מקצועות</button>${manage?'<button class="mx-btn" data-act="manage">ניהול</button>':""}${window.MathlyAnswers?.pushButton?.()||""}${AUTH_URL?'<button class="mx-btn" data-act="out">יציאה</button>':""}</div></header>
248
  <div class="mx-hud"><div class="mx-stats">
249
  <button class="sky-chip flame${hot?"":" cold"}" data-act="streak" title="${streak} ימים ברצף">${Sky.FLAME}<b>${streak}</b><span class="mx-sr">ימים ברצף</span></button>
250
  <span class="sky-chip stars" title="${st.lit} כוכבים">${Sky.spark()}<b>${st.lit}</b><span class="mx-sr">כוכבים</span></span>
@@ -448,7 +465,7 @@ function open(id){
448
  sheet.querySelector(".mx-x").focus({preventScroll:true});
449
  if(openEnded&&st!=="off")window.MathlyAnswers?.mountQuestion(sheet.querySelector(".mx-answer"),id,s.question_version);
450
  }
451
- function closeSheet(){ stopPhone(); dropResources(); cancelAdvance(); REV=null; stopQuizClock(); QZ=null; stopUnitQuiz(); if(sheet)sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
452
 
453
  // counts down, then opens `target` (a skill id); stops if the student moved on in the meantime
454
  function autoAdvance(id,target){
@@ -969,7 +986,7 @@ async function uqResult(uid,h,check=false){
969
  sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
970
  <div class="mx-sheet mx-uq-sheet u-${uid}" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
971
  <div class="mx-shtop"><div><span class="mx-uchip">${esc(u.name)}</span><h3 id="mxTitle">בוחן מסכם: בודקים…</h3>
972
- <small class="mx-src" aria-live="polite">בודקים את התשובות, זה לוקח כחצי דקה לשאלה</small></div>
973
  <button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div><div class="mx-uq-report"></div></div>`;
974
  sheet.hidden=false; sheet.querySelector(".mx-x").focus({preventScroll:true});
975
  const results=[];
@@ -979,7 +996,7 @@ async function uqResult(uid,h,check=false){
979
  r=await uqJson(`${UQ_BASE}/${entry.attempts[i]}`);
980
  if(r.attachment&&r.grading_available&&(!r.grading||(check&&r.grading.state==="failed"))){
981
  if(UQ?.uid!==uid) return;
982
- sheet.querySelector(".mx-src").textContent=`בודקים את שאלה ${i+1} מתוך ${n}…`;
983
  try{ r=await uqPost(`${UQ_BASE}/${entry.attempts[i]}/grade`,{automatic:!check}); }catch(e){ r.error=e.message; }
984
  }
985
  if(r.attachment){ const b=await api(`${UQ_BASE}/${entry.attempts[i]}/image`,{cache:"no-store"}); if(b.ok&&UQ?.uid===uid) UQ.images[i]=URL.createObjectURL(await b.blob()); }
@@ -994,7 +1011,8 @@ async function uqResult(uid,h,check=false){
994
  let pts=null, say="";
995
  if(!r?.attachment) { pts=0; say="לא צולמה תשובה"; }
996
  else if(a&&a.score!=null){ pts=Math.round(q.points*a.score/100); say={correct:"נכון ומלא",partial:"נכון בחלקו",incorrect:"לא נכון"}[a.verdict]||""; }
997
- else { open++; say=a?.verdict==="unreadable"?"לא הצלחנו לקרוא את התמונה":a?.verdict==="needs_review"?"נדרשת בדיקה של מורה":r.error||(!r.grading_available?"הבדיקה האוטומטית אינה זמינה כרגע":"עוד לא נ��דקה"); }
 
998
  if(pts!=null) earned+=pts;
999
  return `<li class="mx-uq-q"><div class="mx-uq-qhead"><b>שאלה ${i+1}</b><span>${pts==null?"–":pts}/${q.points} נק׳</span></div>
1000
  <p class="mx-uq-text">${fmt(q.question).replace(/\n/g,"<br>")}</p>
@@ -1002,7 +1020,7 @@ async function uqResult(uid,h,check=false){
1002
  <p class="mx-uq-verdict ${a?.verdict||"none"}">${esc(say)}</p>
1003
  ${a?.feedback?`<p>${fmtLoose(a.feedback)}</p>`:""}${a?.next_step?`<p><b>הצעד הבא: </b>${fmtLoose(a.next_step)}</p>`:""}
1004
  ${a?.solution?`<details><summary>הפתרון המלא</summary><p class="mx-grade-solution">${fmtLoose(a.solution)}</p></details>`:""}
1005
- ${r?.attachment&&!a&&r.grading_available?`<button class="mx-btn" data-uq-result="${uid}:${h}:check">בדיקה נוספת</button>`:""}</li>`;
1006
  });
1007
  entry.score=open?null:earned; save(); draw();
1008
  const total=open?`${earned} נקודות עד עכשיו · ${open===1?"שאלה אחת לא נבדקה":`${open} שאלות לא נבדקו`}`:`${earned}/100`;
@@ -1033,8 +1051,15 @@ function onClick(e){
1033
  if(t.dataset.id) return open(t.dataset.id);
1034
  if(t.dataset.manageMode) return showManageMode(t.dataset.manageMode);
1035
  if(t.dataset.classAction) return manageClassAction(t.dataset.classAction,t.dataset.classId);
 
 
 
1036
  switch(t.dataset.act){
1037
  case "manage": return manageClasses();
 
 
 
 
1038
  case "manage-refresh": return loadManagedClasses(true);
1039
  case "manage-lab": return showManageMode("lab");
1040
  case "manage-practice": return closeSheet();
@@ -1166,7 +1191,8 @@ function showManageMode(mode){
1166
  /* Monitoring (ניטור): a snapshot of the system, open-answer activity, and the server's latest warnings.
1167
  Each source loads and fails on its own, so a slow Supabase does not hold up the rest. */
1168
  const MON_RANGES={day:["יום","24 השעות האחרונות","15 דקות"],week:["שבוע","7 הימים האחרונים","שעה"],month:["חודש","30 הימים האחרונים","יום"]};
1169
- const MON_ACTIONS={visit:["👋","נכנס/ה לאפליקציה"],photo:["📷","צילם/ה תרגיל לעזרה"],answer_upload:["📝","שלח/ה תשובה פתוחה"],answer_graded:["✔️","תשובה נבדקה"],class_saved:["🏫","שמר/ה כיתה"],class_deleted:["🗑️","מחק/ה כיתה"],resource_added:["🖼️","הוסיף/ה תמונה למשאבי שיעור"]};
 
1170
  const VERDICTS={correct:"נכונות",partial:"נכונות חלקית",incorrect:"שגויות",unreadable:"לא קריאות",needs_review:"דורשות בדיקה",grading:"בבדיקה",failed:"הבדיקה נכשלה"};
1171
  let MON={}, MON_RANGE=(()=>{try{return localStorage.getItem("mathly.monRange")}catch{return null}})();
1172
  if(!MON_RANGES[MON_RANGE]) MON_RANGE="week";
@@ -1306,8 +1332,8 @@ function drawManagedClasses(box){
1306
  const subjects=classSubjects(row);
1307
  const unitLine=units.length===G.units.length?`כל היחידות (${G.units.length})`:units.map(unit=>unit.name).join(" · ")||"לא נבחרו";
1308
  const students=row.emails||[], teachers=row.teachers||[];
1309
- return `<article class="mx-class-card"><div class="mx-class-card-head"><h5>${esc(row.name)}</h5><div class="mx-class-actions"><button class="mx-btn" data-class-action="edit" data-class-id="${esc(row.id)}">עריכה</button><button class="mx-btn danger" data-class-action="delete" data-class-id="${esc(row.id)}">מחיקה</button></div></div>
1310
- <dl><dt>יחידות</dt><dd>${esc(unitLine)}</dd><dt>מקצועות היחידות</dt><dd>${esc(subjects.join(" · ")||"לא הוגדרו")}</dd><dt>תלמידים</dt><dd>${students.length?`${students.length}: ${esc(students.join(" · "))}`:"עוד אין"}</dd><dt>מורים</dt><dd>${teachers.length?esc(teachers.join(" · ")):"עוד לא נבחרו"}</dd></dl>${examBoard(row)}</article>`;
1311
  }).join("")}</div>`:'<div class="mx-empty-class"><h5>עוד אין כיתות</h5><p>פתחו כיתה ראשונה, בחרו את יחידות הלימוד והוסיפו את כתובות התלמידים.</p></div>'}`;
1312
  }
1313
 
@@ -1318,6 +1344,7 @@ function manageClassAction(action,id){
1318
  if(!row) return;
1319
  if(action==="edit") return openClassEditor(row);
1320
  if(action==="delete") return deleteManagedClass(row);
 
1321
  }
1322
 
1323
  function openClassEditor(row){
@@ -1333,7 +1360,7 @@ function openClassEditor(row){
1333
  <p class="mx-note mx-no-subject">בחרו מקצוע כדי לראות את יחידות הלימוד שלו.</p>
1334
  ${subjects.map(subject=>`<section class="mx-subject-units" data-subject-units="${esc(subject)}"><h6 class="mx-pick-subject">${esc(subject)}</h6><div class="mx-unit-picks">${subjectUnits(subject).map(unit=>{const skills=unitSkills(unit.id);return `<label class="u-${unit.id}"><input type="checkbox" name="unit" value="${esc(unit.id)}" data-subject="${esc(subject)}"${selected.has(unit.id)?" checked":""}><span>${esc(unit.name)}<small>${skills.length} תת־נושאים · ${questionCount(skills)} שאלות</small></span></label>`}).join("")}</div></section>`).join("")}</fieldset>
1335
  <label>התלמידים<small>כתובת דוא״ל אחת בכל שורה. תלמיד יכול להשתייך ליותר מכיתה אחת.</small><textarea name="emails" dir="ltr" spellcheck="false" placeholder="student@example.com">${esc((row?.emails||[]).join("\n"))}</textarea></label>
1336
- <label>מורי הכיתה<small>כתובת דוא״ל אחת בכל שורה. למורים תהיה גישה לכיתה הזו.</small><textarea name="teachers" dir="ltr" spellcheck="false" placeholder="teacher@example.com">${esc((row?.teachers||[]).join("\n"))}</textarea></label>
1337
  <div class="mx-class-form-actions"><button class="mx-btn solid" type="submit">${creating?"פתיחת הכיתה":"שמירת השינויים"}</button><button class="mx-btn" type="button" data-class-action="cancel">ביטול</button><span role="status" aria-live="polite"></span></div></form>`;
1338
  const form=box.querySelector("form"), message=form.querySelector('[role="status"]');
1339
  const unitBoxes=subject=>[...form.querySelectorAll('input[name="unit"]')].filter(input=>input.dataset.subject===subject);
@@ -1356,19 +1383,26 @@ function openClassEditor(row){
1356
  if(input.name==="subject"||input.name==="unit") syncSubjects();
1357
  });
1358
  syncSubjects();
 
 
 
 
 
 
 
 
1359
  form.querySelector('input[name="name"]').focus();
1360
  form.onsubmit=async event=>{
1361
  event.preventDefault();
1362
  const lessons=[...form.querySelectorAll('input[name="unit"]:checked')].map(input=>input.value);
1363
  if(!lessons.length){message.textContent="בחרו לפחות מקצוע אחד.";return;}
1364
- const lines=name=>form.elements[name].value.split(/[\n,;]+/).map(value=>value.trim().toLowerCase()).filter(Boolean);
1365
- const body={name:form.elements.name.value.trim(),emails:lines("emails"),teachers:lines("teachers"),lessons};
1366
  const buttons=form.querySelectorAll("button"); buttons.forEach(button=>button.disabled=true); message.textContent=creating?"פותחים את הכיתה…":"שומרים…";
1367
  try{
1368
  const response=await api(creating?"/api/classes":`/api/classes/${encodeURIComponent(row.id)}`,{method:creating?"POST":"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
1369
  if(!response.ok){const detail=(await response.json().catch(()=>({}))).detail;throw new Error(detail||"לא הצלחנו לשמור את הכיתה. נסו שוב.");}
1370
  if(!creating){
1371
- Object.assign(row,body); MY_CLASSES.forEach(item=>{if(String(item.id)===String(row.id))Object.assign(item,body);});
1372
  if(String(MY_CLASS?.id)===String(row.id))Object.assign(MY_CLASS,body);
1373
  SHOWN=new Set(shownUnits().map(unit=>unit.id)); draw();
1374
  }
@@ -1390,6 +1424,190 @@ async function deleteManagedClass(row){
1390
  }catch(error){state.textContent=error.message;}
1391
  }
1392
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1393
  /* A photographed problem (/api/math/photo, app/math_photo.py): hints first. The sheet shows the problem as it was
1394
  read (a wrong reading is the student's cue to photograph again), then one hint; each further hint, each step and
1395
  the whole solution with its answer come only when the student asks for them. */
@@ -1406,16 +1624,38 @@ async function photo(file){
1406
  const form=new FormData(); form.append("photo",blob,"problem.jpg");
1407
  const res=await api("/api/math/photo",{method:"POST",body:form}), data=await res.json().catch(()=>({}));
1408
  if(!res.ok) throw new Error(typeof data.detail==="string"?data.detail:`השרת ענה ${res.status}`);
1409
- mine.data=data;
1410
  }catch(e){ mine.error=e.message; }
1411
  mine.busy=false;
1412
  if(PH===mine&&cur==="photo") drawPhoto(); // a newer photo, or a closed sheet, keeps its own
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1413
  }
1414
  function drawPhoto(){
1415
  const d=PH.data;
1416
  let body;
1417
  if(PH.busy) body='<div class="mx-wait"><span class="mx-spin" aria-hidden="true"></span><b>קוראים את התרגיל ופותרים…</b><small>זה לוקח כמה שניות.</small></div>';
1418
  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>`;
 
 
1419
  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>`;
1420
  else{
1421
  const n=d.steps.length, shown=PH.all?n:Math.min(PH.shown,n);
@@ -1698,7 +1938,7 @@ async function boot(){
1698
  const response=await api("/api/me");
1699
  if(!response.ok){ state.className="error"; state.textContent=response.status===403?"החשבון אינו משויך לכיתת מתמטיקה.":"לא ניתן להשלים את הכניסה."; return; }
1700
  const me=await response.json();CFG.userId=me.user_id;CFG.answerScans=me.answer_scans; ME_EMAIL=me.email||""; MY_CLASSES=me.classes||[]; MY_CLASS=MY_CLASSES.length===1?MY_CLASSES[0]:null;
1701
- CFG.permissions=me.permissions||[]; CFG.roles=me.roles||[];
1702
  }
1703
  document.getElementById("boot").hidden=true;
1704
  show();
 
164
  if(remote){ try{ localStorage.setItem(key(),JSON.stringify(P)); }catch{} queuePush(); } // this device's own work goes up too
165
  if(!P.set||typeof P.set!=="object") P.set={}; SHOWN=new Set(shownUnits().map(u=>u.id));
166
  hasExams()?drawSubjectPicker():selectSubject("מתמטיקה"); // no exams yet: straight to math, science is a tap away
167
+ if(CFG.aiReview) aqCount();
168
+ window.MathlyAnswers?.configure({api,account:CFG.userId,enabled:CFG.answerScans,root,queued:CFG.aiDirect===false,onGraded:({questionId,grading})=>{
169
  if(grading.result.verdict!=="correct"||P.done[questionId])return;
170
  const unitWas=unitDone(unitOf(questionId));
171
  P.done[questionId]=grading.finished_at||new Date().toISOString(); Sky.mastered(P,questionId);
 
186
  const item=bySubject.get(exam.subject)||{name:exam.subject,dates:[],classes:new Set()};
187
  item.dates.push(exam.date);item.classes.add(row.name);bySubject.set(exam.subject,item);
188
  }));
189
+ const today=new Date();today.setHours(0,0,0,0);
190
+ // first the subjects with an exam ahead (nearest first), then those with no exams yet, then those whose exams are over
191
+ return [...bySubject.values()].map(item=>{
192
+ const dates=item.dates.sort(), left=dates.filter(date=>examDate(date)>=today);
193
+ return {...item,dates,left,rank:left.length?0:dates.length?2:1};
194
+ }).sort((a,b)=>a.rank-b.rank||String(a.left[0]||"").localeCompare(String(b.left[0]||"")));
195
+ }
196
+ // the stars lit in a subject's units out of all its skills; null when the subject has no practice path here
197
+ function subjectSkills(name){
198
+ const ids=mineUnits().filter(u=>u.subject===name).flatMap(u=>unitIds(u).filter(live));
199
+ return ids.length?{lit:ids.filter(id=>P.done[id]).length,total:ids.length}:null;
200
+ }
201
+ function subjectCard(item,today){
202
+ const [icon,tone]=examLooks[item.name]||["●","other"], count=item.dates.length, left=item.left.length, over=count>0&&!left;
203
+ const days=left?Math.round((examDate(item.left[0])-today)/86400000):null, skills=subjectSkills(item.name);
204
+ const dots=count&&count<=12?`<span class="mx-subject-dots" aria-hidden="true">${item.dates.map(date=>`<span${examDate(date)<today?' class="past"':""}></span>`).join("")}</span>`:"";
205
+ const exams=count?(over?"כל המבחנים הסתיימו":`נשארו ${left} מתוך ${count} מבחנים`):item.name==="מדעים"?"חידות בהצבעה כיתתית":"מסלול תרגול";
206
+ const bar=skills?`<span class="mx-subject-bar" role="progressbar" aria-label="מיומנויות שהושלמו" aria-valuemin="0" aria-valuemax="${skills.total}" aria-valuenow="${skills.lit}"><span style="width:${Math.round(100*skills.lit/skills.total)}%"></span></span><small>${skills.lit} מתוך ${skills.total} מיומנויות</small>`:"";
207
+ const when=days===null?"":`<span class="mx-subject-count${days<3?" urgent":days<7?" soon":""}" title="${esc(examFullDate(item.left[0]))}">${days===0?"<b>היום</b>":days===1?"<b>מחר</b>":`<small>בעוד</small><b>${days}</b><small>ימים</small>`}</span>`;
208
+ return `<button class="mx-subject-card exam-${tone}${over?" is-over":""}" data-subject="${esc(item.name)}"><span class="mx-subject-icon" aria-hidden="true">${icon}</span><span><b>${esc(item.name)}</b><small class="mx-subject-left">${dots}${exams}</small>${bar}</span>${when}<i aria-hidden="true">‹</i></button>`;
209
  }
210
  function drawSubjectPicker(){
211
  ACTIVE_SUBJECT=""; nav.hidden=true;
212
  const manage=can("management.access"), subjects=subjectOptions(), today=new Date();today.setHours(0,0,0,0);
213
+ path.innerHTML=`<header class="mx-top"><div><h1>המקצועות שלי</h1><p class="mx-sub">��חרו מקצוע כדי לפתוח את המסלול שלו</p></div><div class="mx-acts">${window.MathlyGuide?.button()||""}${teachButton()}${manage?'<button class="mx-btn" data-act="manage">ניהול</button>':""}${window.MathlyAnswers?.pushButton?.()||""}${AUTH_URL?'<button class="mx-btn" data-act="out">יציאה</button>':""}</div></header>
214
+ <section class="mx-subject-grid" aria-label="מקצועות הכיתה">${subjects.map(item=>subjectCard(item,today)).join("")}</section>`;
 
 
 
215
  }
216
  function selectSubject(subject){
217
  ACTIVE_SUBJECT=subject;
 
261
  function topBar(){
262
  const st=skyStats(), hot=Sky.today(P)>=Sky.GOAL, manage=can("management.access"), on=Sky.soundOn(), streak=Sky.streak(P);
263
  return `<header class="mx-top"><div class="mx-brand">${Sky.spark()}<span>מתמטיקה</span></div>
264
+ <div class="mx-acts"><button class="mx-btn" data-act="subjects">מקצועות</button>${window.MathlyGuide?.button()||""}${teachButton()}${manage?'<button class="mx-btn" data-act="manage">ניהול</button>':""}${window.MathlyAnswers?.pushButton?.()||""}${AUTH_URL?'<button class="mx-btn" data-act="out">יציאה</button>':""}</div></header>
265
  <div class="mx-hud"><div class="mx-stats">
266
  <button class="sky-chip flame${hot?"":" cold"}" data-act="streak" title="${streak} ימים ברצף">${Sky.FLAME}<b>${streak}</b><span class="mx-sr">ימים ברצף</span></button>
267
  <span class="sky-chip stars" title="${st.lit} כוכבים">${Sky.spark()}<b>${st.lit}</b><span class="mx-sr">כוכבים</span></span>
 
465
  sheet.querySelector(".mx-x").focus({preventScroll:true});
466
  if(openEnded&&st!=="off")window.MathlyAnswers?.mountQuestion(sheet.querySelector(".mx-answer"),id,s.question_version);
467
  }
468
+ function closeSheet(){ stopPhone(); dropResources(); dropQueue(); cancelAdvance(); REV=null; stopQuizClock(); QZ=null; stopUnitQuiz(); if(sheet)sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
469
 
470
  // counts down, then opens `target` (a skill id); stops if the student moved on in the meantime
471
  function autoAdvance(id,target){
 
986
  sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
987
  <div class="mx-sheet mx-uq-sheet u-${uid}" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
988
  <div class="mx-shtop"><div><span class="mx-uchip">${esc(u.name)}</span><h3 id="mxTitle">בוחן מסכם: בודקים…</h3>
989
+ <small class="mx-src" aria-live="polite">${CFG.aiDirect===false?"שולחים את התשובות לבדיקת המורה":"בודקים את התשובות, זה לוקח כחצי דקה לשאלה"}</small></div>
990
  <button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div><div class="mx-uq-report"></div></div>`;
991
  sheet.hidden=false; sheet.querySelector(".mx-x").focus({preventScroll:true});
992
  const results=[];
 
996
  r=await uqJson(`${UQ_BASE}/${entry.attempts[i]}`);
997
  if(r.attachment&&r.grading_available&&(!r.grading||(check&&r.grading.state==="failed"))){
998
  if(UQ?.uid!==uid) return;
999
+ sheet.querySelector(".mx-src").textContent=CFG.aiDirect===false?`שולחים את שאלה ${i+1} מתוך ${n} למורה…`:`בודקים את שאלה ${i+1} מתוך ${n}…`;
1000
  try{ r=await uqPost(`${UQ_BASE}/${entry.attempts[i]}/grade`,{automatic:!check}); }catch(e){ r.error=e.message; }
1001
  }
1002
  if(r.attachment){ const b=await api(`${UQ_BASE}/${entry.attempts[i]}/image`,{cache:"no-store"}); if(b.ok&&UQ?.uid===uid) UQ.images[i]=URL.createObjectURL(await b.blob()); }
 
1011
  let pts=null, say="";
1012
  if(!r?.attachment) { pts=0; say="לא צולמה תשובה"; }
1013
  else if(a&&a.score!=null){ pts=Math.round(q.points*a.score/100); say={correct:"נכון ומלא",partial:"נכון בחלקו",incorrect:"לא נכון"}[a.verdict]||""; }
1014
+ else { open++; const req=a?null:r.ai_request;
1015
+ say=req&&["pending","sending","failed"].includes(req.state)?"מחכה למורה שיאשר את הבדיקה":req?.state==="declined"?`המורה לא אישר את הבדיקה${req.note?`: ${req.note}`:""}`:a?.verdict==="unreadable"?"לא הצלחנו לקרוא את התמונה":a?.verdict==="needs_review"?"נדרשת בדיקה של מורה":r.error||(!r.grading_available?"הבדיקה האוטומטית אינה זמינה כרגע":"עוד לא נבדקה"); }
1016
  if(pts!=null) earned+=pts;
1017
  return `<li class="mx-uq-q"><div class="mx-uq-qhead"><b>שאלה ${i+1}</b><span>${pts==null?"–":pts}/${q.points} נק׳</span></div>
1018
  <p class="mx-uq-text">${fmt(q.question).replace(/\n/g,"<br>")}</p>
 
1020
  <p class="mx-uq-verdict ${a?.verdict||"none"}">${esc(say)}</p>
1021
  ${a?.feedback?`<p>${fmtLoose(a.feedback)}</p>`:""}${a?.next_step?`<p><b>הצעד הבא: </b>${fmtLoose(a.next_step)}</p>`:""}
1022
  ${a?.solution?`<details><summary>הפתרון המלא</summary><p class="mx-grade-solution">${fmtLoose(a.solution)}</p></details>`:""}
1023
+ ${r?.attachment&&!a&&r.grading_available&&!["pending","sending","failed"].includes(r.ai_request?.state)?`<button class="mx-btn" data-uq-result="${uid}:${h}:check">${CFG.aiDirect===false?"שליחה מחדש למורה":"בדיקה נוספת"}</button>`:""}</li>`;
1024
  });
1025
  entry.score=open?null:earned; save(); draw();
1026
  const total=open?`${earned} נקודות עד עכשיו · ${open===1?"שאלה אחת לא נבדקה":`${open} שאלות לא נבדקו`}`:`${earned}/100`;
 
1051
  if(t.dataset.id) return open(t.dataset.id);
1052
  if(t.dataset.manageMode) return showManageMode(t.dataset.manageMode);
1053
  if(t.dataset.classAction) return manageClassAction(t.dataset.classAction,t.dataset.classId);
1054
+ if(t.dataset.reportClass!=null) return classReport(t.dataset.reportClass);
1055
+ if(t.dataset.aqSend) return aqSend(t.dataset.aqSend);
1056
+ if(t.dataset.aqDecline) return aqDecline(t.dataset.aqDecline);
1057
  switch(t.dataset.act){
1058
  case "manage": return manageClasses();
1059
+ case "class-report": return classReport();
1060
+ case "ai-queue": return aiQueue();
1061
+ case "aq-all": return aqAll();
1062
+ case "photo-cancel": return photoCancel();
1063
  case "manage-refresh": return loadManagedClasses(true);
1064
  case "manage-lab": return showManageMode("lab");
1065
  case "manage-practice": return closeSheet();
 
1191
  /* Monitoring (ניטור): a snapshot of the system, open-answer activity, and the server's latest warnings.
1192
  Each source loads and fails on its own, so a slow Supabase does not hold up the rest. */
1193
  const MON_RANGES={day:["יום","24 השעות האחרונות","15 דקות"],week:["שבוע","7 הימים האחרונים","שעה"],month:["חודש","30 הימים האחרונים","יום"]};
1194
+ const MON_ACTIONS={visit:["👋","נכנס/ה לאפליקציה"],photo:["📷","צילם/ה תרגיל לעזרה"],answer_upload:["📝","שלח/ה תשובה פתוחה"],answer_graded:["✔️","תשובה נבדקה"],class_saved:["🏫","שמר/ה כיתה"],class_deleted:["🗑️","מחק/ה כיתה"],resource_added:["🖼️","הוסיף/ה תמונה למשאבי שיעור"],
1195
+ ai_request:["🙋","ביקש/ה בדיקת AI מהמורה"],ai_sent:["🤖","שלח/ה בקשה של תלמיד ל-AI"],ai_failed:["⚠️","שליחה ל-AI נכשלה"],ai_declined:["✋","דחה/תה בקשת AI"]};
1196
  const VERDICTS={correct:"נכונות",partial:"נכונות חלקית",incorrect:"שגויות",unreadable:"לא קריאות",needs_review:"דורשות בדיקה",grading:"בבדיקה",failed:"הבדיקה נכשלה"};
1197
  let MON={}, MON_RANGE=(()=>{try{return localStorage.getItem("mathly.monRange")}catch{return null}})();
1198
  if(!MON_RANGES[MON_RANGE]) MON_RANGE="week";
 
1332
  const subjects=classSubjects(row);
1333
  const unitLine=units.length===G.units.length?`כל היחידות (${G.units.length})`:units.map(unit=>unit.name).join(" · ")||"לא נבחרו";
1334
  const students=row.emails||[], teachers=row.teachers||[];
1335
+ return `<article class="mx-class-card"><div class="mx-class-card-head"><h5>${esc(row.name)}</h5><div class="mx-class-actions"><button class="mx-btn" data-class-action="report" data-class-id="${esc(row.id)}">מעקב</button><button class="mx-btn" data-class-action="edit" data-class-id="${esc(row.id)}">עריכה</button><button class="mx-btn danger" data-class-action="delete" data-class-id="${esc(row.id)}">מחיקה</button></div></div>
1336
+ <dl><dt>יחידות</dt><dd>${esc(unitLine)}</dd><dt>מקצועות היחידות</dt><dd>${esc(subjects.join(" · ")||"לא הוגדרו")}</dd><dt>תלמידים</dt><dd>${students.length?`${students.length}: ${esc(students.join(" · "))}`:"עוד אין"}</dd><dt>מורה הכיתה</dt><dd dir="auto">${teachers.length?esc(teachers.join(" · ")):"עוד לא נבחר"}</dd></dl>${examBoard(row)}</article>`;
1337
  }).join("")}</div>`:'<div class="mx-empty-class"><h5>עוד אין כיתות</h5><p>פתחו כיתה ראשונה, בחרו את יחידות הלימוד והוסיפו את כתובות התלמידים.</p></div>'}`;
1338
  }
1339
 
 
1344
  if(!row) return;
1345
  if(action==="edit") return openClassEditor(row);
1346
  if(action==="delete") return deleteManagedClass(row);
1347
+ if(action==="report") return classReport(row.id);
1348
  }
1349
 
1350
  function openClassEditor(row){
 
1360
  <p class="mx-note mx-no-subject">בחרו מקצוע כדי לראות את יחידות הלימוד שלו.</p>
1361
  ${subjects.map(subject=>`<section class="mx-subject-units" data-subject-units="${esc(subject)}"><h6 class="mx-pick-subject">${esc(subject)}</h6><div class="mx-unit-picks">${subjectUnits(subject).map(unit=>{const skills=unitSkills(unit.id);return `<label class="u-${unit.id}"><input type="checkbox" name="unit" value="${esc(unit.id)}" data-subject="${esc(subject)}"${selected.has(unit.id)?" checked":""}><span>${esc(unit.name)}<small>${skills.length} תת־נושאים · ${questionCount(skills)} שאלות</small></span></label>`}).join("")}</div></section>`).join("")}</fieldset>
1362
  <label>התלמידים<small>כתובת דוא״ל אחת בכל שורה. תלמיד יכול להשתייך ליותר מכיתה אחת.</small><textarea name="emails" dir="ltr" spellcheck="false" placeholder="student@example.com">${esc((row?.emails||[]).join("\n"))}</textarea></label>
1363
+ <label>מורה הכיתה<small>אחד מתלמידי הכיתה. המורה רואה את ההתקדמות, הקשיים והציונים של כל התלמידים בכיתה.</small><select name="teacher" dir="ltr"></select></label>
1364
  <div class="mx-class-form-actions"><button class="mx-btn solid" type="submit">${creating?"פתיחת הכיתה":"שמירת השינויים"}</button><button class="mx-btn" type="button" data-class-action="cancel">ביטול</button><span role="status" aria-live="polite"></span></div></form>`;
1365
  const form=box.querySelector("form"), message=form.querySelector('[role="status"]');
1366
  const unitBoxes=subject=>[...form.querySelectorAll('input[name="unit"]')].filter(input=>input.dataset.subject===subject);
 
1383
  if(input.name==="subject"||input.name==="unit") syncSubjects();
1384
  });
1385
  syncSubjects();
1386
+ // the teacher is picked from the students: the list follows the students box as it is edited
1387
+ const lines=name=>form.elements[name].value.split(/[\n,;]+/).map(value=>value.trim().toLowerCase()).filter(Boolean);
1388
+ const pickTeacher=form.elements.teacher;
1389
+ const syncTeacher=()=>{
1390
+ const chosen=pickTeacher.options.length?pickTeacher.value:(row?.teachers||[])[0]||"", students=[...new Set(lines("emails"))];
1391
+ pickTeacher.innerHTML=`<option value="">ללא מורה</option>${students.map(email=>`<option value="${esc(email)}"${email===chosen?" selected":""}>${esc(email)}</option>`).join("")}`;
1392
+ };
1393
+ form.elements.emails.addEventListener("input",syncTeacher); syncTeacher();
1394
  form.querySelector('input[name="name"]').focus();
1395
  form.onsubmit=async event=>{
1396
  event.preventDefault();
1397
  const lessons=[...form.querySelectorAll('input[name="unit"]:checked')].map(input=>input.value);
1398
  if(!lessons.length){message.textContent="בחרו לפחות מקצוע אחד.";return;}
1399
+ const body={name:form.elements.name.value.trim(),emails:lines("emails"),teachers:pickTeacher.value?[pickTeacher.value]:[],lessons};
 
1400
  const buttons=form.querySelectorAll("button"); buttons.forEach(button=>button.disabled=true); message.textContent=creating?"פותחים את הכיתה…":"שומרים…";
1401
  try{
1402
  const response=await api(creating?"/api/classes":`/api/classes/${encodeURIComponent(row.id)}`,{method:creating?"POST":"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
1403
  if(!response.ok){const detail=(await response.json().catch(()=>({}))).detail;throw new Error(detail||"לא הצלחנו לשמור את הכיתה. נסו שוב.");}
1404
  if(!creating){
1405
+ Object.assign(row,body); MY_CLASSES.forEach(item=>{if(String(item.id)===String(row.id))Object.assign(item,body,{teacher:body.teachers.includes(ME_EMAIL.toLowerCase())});});
1406
  if(String(MY_CLASS?.id)===String(row.id))Object.assign(MY_CLASS,body);
1407
  SHOWN=new Set(shownUnits().map(unit=>unit.id)); draw();
1408
  }
 
1424
  }catch(error){state.textContent=error.message;}
1425
  }
1426
 
1427
+ /* ---------- the class teacher's report ----------
1428
+ A class's teacher is one of its students, chosen in the class editor. That account (and a manager, from the class's
1429
+ card) opens the class's report (/api/classes/{id}/report, app/class_report.py): each student's saved progress, read
1430
+ the same way the student's own page reads it. It shows, per student, the skills finished in the class's units,
1431
+ the mistakes that keep coming back (Sky.patterns), the summarizing quizzes' grades and the practice exams' results,
1432
+ and above them the mistakes most common in the class. */
1433
+ const teachClasses=()=>MY_CLASSES.filter(row=>row.teacher);
1434
+ const teachButton=()=>(teachClasses().length?'<button class="mx-btn" data-act="class-report">הכיתה שלי</button>':"")
1435
+ +(CFG.aiReview?`<button class="mx-btn" data-act="ai-queue">${aqLabel()}</button>`:"");
1436
+ let REPORT=null;
1437
+ const shortDate=at=>{ const d=new Date(at); return isNaN(d)?"":d.toLocaleDateString("he-IL",{day:"numeric",month:"numeric"}); };
1438
+ const pct=(a,b)=>b?Math.round(100*a/b):0;
1439
+ // one student's numbers, from their saved progress (null: nothing saved yet)
1440
+ function reportOf(student,units){
1441
+ const p=student.progress; if(!p) return null;
1442
+ const perUnit=units.map(u=>{ const ids=unitIds(u).filter(live); return {u,n:ids.length,done:ids.filter(id=>p.done?.[id]).length,
1443
+ gold:ids.filter(id=>p.gold?.[id]).length,stuck:ids.filter(id=>!p.done?.[id]&&(p.missed?.[id]||[]).length).map(id=>BY[id])}; });
1444
+ const n=perUnit.reduce((s,x)=>s+x.n,0), done=perUnit.reduce((s,x)=>s+x.done,0);
1445
+ const {recurring}=Sky.patterns(p,G.mistakes);
1446
+ const quizzes=units.filter(u=>u.quiz).map(u=>({u,st:p.uq?.[u.id]||{history:[]}}));
1447
+ const scores=quizzes.map(x=>x.st.history.filter(h=>h.score!=null).at(-1)?.score).filter(s=>s!=null);
1448
+ const exams=(p.quizzes||[]).slice().reverse();
1449
+ const days=Object.keys(p.days||{}).sort(), last=[p.updated?new Date(+p.updated):null,days.length?new Date(days.at(-1)):null].filter(d=>d&&!isNaN(d)).sort((a,b)=>b-a)[0];
1450
+ return {perUnit,n,done,recurring:recurring.filter(x=>G.mistakes[x.m]),quizzes,quizAvg:scores.length?Math.round(scores.reduce((a,b)=>a+b,0)/scores.length):null,exams,last};
1451
+ }
1452
+ async function classReport(id){
1453
+ const mine=teachClasses(), classId=id??REPORT?.id??mine[0]?.id; if(classId==null) return;
1454
+ if(!cur) back=document.activeElement;
1455
+ closeSheetQuietly(); cur="report"; REPORT={id:classId,data:null};
1456
+ const tabs=mine.length>1?`<div class="mx-report-tabs">${mine.map(row=>`<button class="mx-btn" data-report-class="${esc(row.id)}" aria-pressed="${String(row.id)===String(classId)}">${esc(row.name)}</button>`).join("")}</div>`:"";
1457
+ sheet.innerHTML=`<div class="mx-scrim"></div>
1458
+ <div class="mx-sheet mx-manage-sheet mx-report-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
1459
+ <div class="mx-manage-head"><h3 id="mxTitle">הכיתה שלי</h3><span class="mx-manage-spacer"></span>
1460
+ <button class="mx-btn" data-report-class="${esc(classId)}">רענון</button><button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div>
1461
+ ${tabs}<p class="mx-manage-state" aria-live="polite">טוענים את נתוני הכיתה…</p><div class="mx-report"></div></div>`;
1462
+ sheet.hidden=false; sheet.querySelector(".mx-x").focus({preventScroll:true});
1463
+ try{
1464
+ const r=await api(`/api/classes/${encodeURIComponent(classId)}/report`,{cache:"no-store"});
1465
+ if(!r.ok) throw Error(r.status===403?"רק מורה הכיתה יכול לראות את הנתונים שלה.":"לא הצלחנו לטעון את נתוני הכיתה. נסו שוב.");
1466
+ const data=await r.json(); if(cur!=="report"||REPORT?.id!==classId) return;
1467
+ REPORT.data=data; drawReport();
1468
+ }catch(e){ if(cur==="report") sheet.querySelector(".mx-manage-state").textContent=e.message; }
1469
+ }
1470
+ function closeSheetQuietly(){ const b=back; closeSheet(); back=b; }
1471
+ function drawReport(){
1472
+ const {class:row,students}=REPORT.data, ids=new Set(row.lessons||[]);
1473
+ const units=G.units.filter(u=>ids.has(u.id)&&unitIds(u).some(live));
1474
+ const all=students.map(s=>({s,r:reportOf(s,units)}));
1475
+ sheet.querySelector("#mxTitle").textContent=`הכיתה שלי: ${row.name}`;
1476
+ sheet.querySelector(".mx-manage-state").textContent=`${students.length} תלמידים · ${all.filter(x=>x.r).length} התחילו לתרגל`;
1477
+ // the mistakes that keep coming back for the most students
1478
+ const common={}; all.forEach(({s,r})=>r?.recurring.forEach(x=>(common[x.m]||=[]).push(s.email)));
1479
+ const top=Object.entries(common).sort((a,b)=>b[1].length-a[1].length).slice(0,5);
1480
+ const name=email=>email.split("@")[0];
1481
+ const quizUnits=units.filter(u=>u.quiz);
1482
+ const grade=score=>score==null?"–":`${score}`;
1483
+ const tone=score=>score==null?"":score>=80?" good":score>=55?" mid":" low";
1484
+ const card=({s,r})=>{
1485
+ if(!r) return `<details class="mx-report-student"><summary><b dir="ltr">${esc(name(s.email))}</b><span class="mx-small">${s.signed_in?"עוד לא התחיל לתרגל":"עוד לא נכנס לחשבון"}</span></summary><p class="mx-small" dir="ltr">${esc(s.email)}</p></details>`;
1486
+ const exam=r.exams[0];
1487
+ return `<details class="mx-report-student"><summary><b dir="ltr">${esc(name(s.email))}</b>
1488
+ <span class="mx-report-prog"><span class="mx-meter" aria-hidden="true"><i style="width:${pct(r.done,r.n)}%"></i></span><small>${r.done}/${r.n} מיומנויות</small></span>
1489
+ <span class="mx-report-chip${tone(r.quizAvg)}" title="ממוצע בחנים מסכמים">בוחן ${grade(r.quizAvg)}</span>
1490
+ <span class="mx-report-chip${tone(exam?pct(exam.right,exam.n):null)}" title="מבחן התרגול האחרון">תרגול ${exam?`${exam.right}/${exam.n}`:"–"}</span>
1491
+ ${r.recurring.length?`<span class="mx-report-chip low">${r.recurring.length} קשיים</span>`:""}</summary>
1492
+ <div class="mx-report-body">
1493
+ <p class="mx-small"><span dir="ltr">${esc(s.email)}</span>${r.last?` · פעילות אחרונה ${r.last.toLocaleDateString("he-IL")}`:""}</p>
1494
+ <section><h5>התקדמות ביחידות</h5><div class="mx-quiz-units-score">${r.perUnit.map(x=>`<div><span>${esc(x.u.name)}${x.gold?` <small>· ${x.gold} זהב</small>`:""}</span><b>${x.done}/${x.n}</b><span class="mx-meter" aria-hidden="true"><i style="width:${pct(x.done,x.n)}%"></i></span></div>`).join("")}</div></section>
1495
+ <section><h5>קשיים</h5>${r.recurring.length?`<ul class="mx-report-list">${r.recurring.map(x=>`<li><b>${fmt(G.mistakes[x.m].name)}</b> <small>${x.n} פעמים ב־${x.skills.length} מיומנויות, לאחרונה ${shortDate(x.last)}</small>${BY[x.practise]?`<small>לחזק: ${fmt(BY[x.practise].name)}</small>`:""}</li>`).join("")}</ul>`:'<p class="mx-small">אין טעויות שחוזרות לאחרונה.</p>'}
1496
+ ${r.perUnit.some(x=>x.stuck.length)?`<p class="mx-small">מיומנויות עם שאלות שעוד לא נפתרו: ${r.perUnit.flatMap(x=>x.stuck).slice(0,8).map(sk=>fmt(sk.name)).join(" · ")}</p>`:""}</section>
1497
+ <section><h5>בוחן מסכם</h5>${quizUnits.length?`<table class="mx-report-table"><thead><tr><th>יחידה</th><th>ציון אחרון</th><th>הגשות</th><th>תאריך</th></tr></thead><tbody>${r.quizzes.map(({u,st})=>{ const h=st.history.at(-1);
1498
+ return `<tr><td>${esc(u.name)}</td><td class="mx-report-grade${tone(h?.score)}">${st.going?"בתהליך":h?(h.score==null?"ממתין לבדיקה":`${h.score}/100`):"–"}</td><td>${st.history.length||"–"}</td><td>${h?shortDate(h.at):"–"}</td></tr>`; }).join("")}</tbody></table>`:'<p class="mx-small">ליחידות של הכיתה אין בוחן מסכם.</p>'}</section>
1499
+ <section><h5>מבחני תרגול</h5>${r.exams.length?`<table class="mx-report-table"><thead><tr><th>תאריך</th><th>ציון</th><th>תשובות נכונות</th><th>זמן</th></tr></thead><tbody>${r.exams.slice(0,10).map(q=>`<tr><td>${shortDate(q.at)}</td><td class="mx-report-grade${tone(pct(q.right,q.n))}">${pct(q.right,q.n)}</td><td>${q.right}/${q.n}</td><td>${q.ms?`${Math.round(q.ms/60000)} דק׳`:"–"}</td></tr>`).join("")}</tbody></table>`:'<p class="mx-small">עוד לא נעשו מבחני תרגול.</p>'}</section>
1500
+ </div></details>`;
1501
+ };
1502
+ sheet.querySelector(".mx-report").innerHTML=`
1503
+ ${top.length?`<section class="mx-report-common"><h4>קשיים נפוצים בכיתה</h4><ul class="mx-report-list">${top.map(([m,who])=>`<li><b>${fmt(G.mistakes[m].name)}</b> <small>${who.length} תלמידים: ${who.map(e=>esc(name(e))).join(", ")}</small></li>`).join("")}</ul></section>`:""}
1504
+ <h4>תלמידים</h4>${students.length?all.map(card).join(""):'<p class="mx-note">אין עדיין תלמידים בכיתה.</p>'}`;
1505
+ }
1506
+
1507
+ /* ---------- the teacher's AI queue (app/ai_queue.py) ----------
1508
+ A student does not call the AI. Their answer to check (or a photographed problem) waits here for their class's
1509
+ teacher, who looks at the photo and sends it to the AI or declines it with a note. The student's page shows the
1510
+ result. A manager sees every class's requests. */
1511
+ let AQ=null, AQ_OPEN=null; // AQ: the queue on the sheet; AQ_OPEN: how many requests wait (for the header button)
1512
+ const AQ_KIND={photo:"📷 תרגיל לעזרה",grading:"📝 בדיקת תשובה"};
1513
+ const AQ_STATE={pending:"ממתינה",sending:"נשלחת…",done:"נשלחה ל-AI",failed:"השליחה נכשלה",declined:"נדחתה",cancelled:"בוטלה"};
1514
+ const AQ_ERR={grading_failed:"הבדיקה נכשלה. אפשר לנסות שוב.",grading_unavailable:"בדיקה אוטומטית אינה זמינה כרגע באתר.",open_ended_locked:"שאלות פתוחות נעולות כרגע.",
1515
+ grading_limit:"התלמיד הגיע למכסת הבדיקות היומית.",grading_busy:"כבר מתבצעות בדיקות לתלמיד הזה. נסו שוב בעוד רגע.",grading_interrupted:"הבדיקה הופסקה. אפשר לנסות שוב.",
1516
+ request_taken:"הבקשה כבר טופלה (אולי ממכשיר אחר). רעננו את התור.",queue_unavailable:"התור אינו זמין כרגע.",
1517
+ answer_storage_unavailable:"לא הצלחנו לקרוא את התמונה השמורה.",photo_failed:"פתרון התמונה נכשל. אפשר לנסות שוב."};
1518
+ // a failed send's error is "code|reason|technical detail" (app/ai_queue.py)
1519
+ function aqError(error){
1520
+ const [code,reason="",...rest]=String(error||"").split("|"), detail=rest.join("|");
1521
+ return `<p class="mx-aq-error">${esc(AQ_ERR[code]||code||"השליחה נכשלה.")}</p>${window.MathlyAnswers?.errorDetails?.(reason,detail)||""}`;
1522
+ }
1523
+ const AQ_VERDICT={correct:"נכונה",partial:"נכונה חלקית",incorrect:"שגויה",unreadable:"לא קריאה",needs_review:"דורשת בדיקה שלכם"};
1524
+ const aqWaiting=r=>["pending","sending","failed"].includes(r.state);
1525
+ const aqLabel=()=>`תור AI${AQ_OPEN?` <span class="mx-aq-badge">${AQ_OPEN}</span>`:""}`;
1526
+ function aqBadge(rows){
1527
+ AQ_OPEN=rows.filter(r=>r.state==="pending").length;
1528
+ document.querySelectorAll('[data-act="ai-queue"]').forEach(b=>{ if(!b.closest(".mx-sheet")) b.innerHTML=aqLabel(); });
1529
+ }
1530
+ async function aqCount(){
1531
+ try{ const r=await api("/api/ai-queue",{cache:"no-store"}); if(r.ok) aqBadge((await r.json()).requests||[]); }catch{}
1532
+ }
1533
+ function dropQueue(){ if(AQ) Object.values(AQ.urls).forEach(u=>u&&URL.revokeObjectURL(u)); AQ=null; }
1534
+ async function aiQueue(){
1535
+ if(cur!=="aiqueue"){ if(!cur) back=document.activeElement; closeSheetQuietly(); cur="aiqueue"; AQ={rows:null,urls:{},busy:new Set()}; }
1536
+ const mine=AQ;
1537
+ sheet.innerHTML=`<div class="mx-scrim"></div>
1538
+ <div class="mx-sheet mx-manage-sheet mx-aq-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
1539
+ <div class="mx-manage-head"><h3 id="mxTitle">תור AI</h3><span class="mx-manage-spacer"></span>
1540
+ <button class="mx-btn" data-act="aq-all" hidden>שליחת כל הממתינות</button><button class="mx-btn" data-act="ai-queue">רענון</button><button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div>
1541
+ <p class="mx-small">בקשות התלמידים מחכות כאן. הסתכלו בתמונה, ואז שלחו אותה ל-AI או דחו אותה. התלמיד רואה את התוצאה בעמוד שלו.</p>
1542
+ <p class="mx-manage-state" aria-live="polite">טוענים את הבקשות…</p><div class="mx-aq"></div></div>`;
1543
+ sheet.hidden=false; sheet.querySelector(".mx-x").focus({preventScroll:true});
1544
+ try{
1545
+ const r=await api("/api/ai-queue",{cache:"no-store"});
1546
+ if(!r.ok) throw Error(r.status===403?"רק מורה של כיתה יכול לראות את התור.":"לא הצלחנו לטעון את התור. נסו שוב.");
1547
+ const d=await r.json(); if(AQ!==mine) return;
1548
+ mine.rows=d.requests||[]; drawQueue();
1549
+ }catch(e){ if(AQ===mine) sheet.querySelector(".mx-manage-state").textContent=e.message; }
1550
+ }
1551
+ function aqResult(r){
1552
+ const x=r.result||{};
1553
+ if(r.kind==="photo") return x.readable===false?"ה-AI לא הצליח לקרוא את התרגיל.":`התשובה שה-AI מצא: ${fmtLoose(x.answer||"")}`;
1554
+ return `תוצאה: ${esc(AQ_VERDICT[x.verdict]||"")}${x.score==null?"":` · ${esc(x.score)}/100`}`;
1555
+ }
1556
+ function drawQueue(){
1557
+ const box=sheet.querySelector(".mx-aq"); if(!box||!AQ?.rows) return;
1558
+ const open=AQ.rows.filter(aqWaiting), rest=AQ.rows.filter(r=>!aqWaiting(r));
1559
+ aqBadge(AQ.rows);
1560
+ sheet.querySelector(".mx-manage-state").textContent=open.length?(open.length===1?"בקשה אחת ממתינה":`${open.length} בקשות ממ��ינות`):"אין בקשות ממתינות.";
1561
+ sheet.querySelector('[data-act="aq-all"]').hidden=open.filter(r=>r.state==="pending").length<2;
1562
+ const when=at=>new Date(at).toLocaleString("he-IL",{dateStyle:"short",timeStyle:"short"});
1563
+ const card=r=>{
1564
+ const busy=AQ.busy.has(r.id), act=r.state==="pending"||r.state==="failed";
1565
+ return `<article class="mx-aq-card ${esc(r.state)}"><div class="mx-aq-head"><b dir="ltr">${esc(String(r.email).split("@")[0])}</b><span>${AQ_KIND[r.kind]||""}</span>
1566
+ <small>${when(r.created_at)}</small><span class="mx-aq-state">${busy?"שולחים ל-AI…":AQ_STATE[r.state]||esc(r.state)}</span></div>
1567
+ ${r.label?`<p class="mx-aq-label">${fmt(r.label)}</p>`:""}${r.question?`<p class="mx-aq-q">${fmtLoose(r.question).replace(/\n/g,"<br>")}</p>`:""}
1568
+ ${aqWaiting(r)?`<img class="mx-aq-img" alt="התמונה ששלח התלמיד" data-aq-img="${esc(r.id)}" hidden>`:""}
1569
+ ${r.state==="failed"&&r.error?aqError(r.error):""}
1570
+ ${r.state==="declined"&&r.note?`<p class="mx-small">ההערה שלכם: ${esc(r.note)}</p>`:""}
1571
+ ${r.state==="done"?`<p class="mx-small">${aqResult(r)}</p>`:""}
1572
+ ${act?`<div class="mx-row"><button class="mx-btn solid" data-aq-send="${esc(r.id)}"${busy?" disabled":""}>${r.state==="failed"?"שליחה חוזרת ל-AI":"שליחה ל-AI"}</button>
1573
+ <button class="mx-btn" data-aq-decline="${esc(r.id)}"${busy?" disabled":""}>דחייה</button></div>`:""}</article>`;
1574
+ };
1575
+ box.innerHTML=`${open.map(card).join("")}${rest.length?`<h4 class="mx-aq-done">טופלו ביממה האחרונה</h4>${rest.map(card).join("")}`:""}`;
1576
+ open.forEach(r=>aqImage(r.id));
1577
+ }
1578
+ async function aqImage(id){
1579
+ const mine=AQ, show=url=>{ const img=sheet.querySelector(`[data-aq-img="${CSS.escape(id)}"]`); if(img){ img.src=url; img.hidden=false; } };
1580
+ if(mine.urls[id]) return show(mine.urls[id]);
1581
+ if(id in mine.urls) return; // loading
1582
+ mine.urls[id]=null;
1583
+ try{
1584
+ const r=await api(`/api/ai-queue/${encodeURIComponent(id)}/image`,{cache:"no-store"}); if(!r.ok) throw Error();
1585
+ const url=URL.createObjectURL(await r.blob());
1586
+ if(AQ!==mine) return URL.revokeObjectURL(url);
1587
+ mine.urls[id]=url; show(url);
1588
+ }catch{ if(AQ===mine) delete mine.urls[id]; }
1589
+ }
1590
+ async function aqPost(id,what,body){
1591
+ const mine=AQ; if(!mine||mine.busy.has(id)) return;
1592
+ mine.busy.add(id); drawQueue();
1593
+ let row=null, err="";
1594
+ try{
1595
+ const r=await api(`/api/ai-queue/${encodeURIComponent(id)}/${what}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body||{})});
1596
+ const d=await r.json().catch(()=>({}));
1597
+ if(!r.ok) throw Error(AQ_ERR[d.detail]||"הפעולה נכשלה. נסו שוב.");
1598
+ row=d;
1599
+ }catch(e){ err=e.message; }
1600
+ mine.busy.delete(id); if(AQ!==mine) return;
1601
+ if(row){ const i=mine.rows.findIndex(x=>x.id===id); if(i>=0) mine.rows[i]=row; }
1602
+ drawQueue(); if(err) sheet.querySelector(".mx-manage-state").textContent=err;
1603
+ }
1604
+ const aqSend=id=>aqPost(id,"send");
1605
+ function aqDecline(id){
1606
+ const note=prompt("הערה לתלמיד (לא חובה), למשל: צלמו שוב, התמונה לא ברורה");
1607
+ if(note!==null) return aqPost(id,"decline",{note});
1608
+ }
1609
+ async function aqAll(){ for(const r of (AQ?.rows||[]).filter(x=>x.state==="pending")){ if(!AQ) return; await aqSend(r.id); } }
1610
+
1611
  /* A photographed problem (/api/math/photo, app/math_photo.py): hints first. The sheet shows the problem as it was
1612
  read (a wrong reading is the student's cue to photograph again), then one hint; each further hint, each step and
1613
  the whole solution with its answer come only when the student asks for them. */
 
1624
  const form=new FormData(); form.append("photo",blob,"problem.jpg");
1625
  const res=await api("/api/math/photo",{method:"POST",body:form}), data=await res.json().catch(()=>({}));
1626
  if(!res.ok) throw new Error(typeof data.detail==="string"?data.detail:`השרת ענה ${res.status}`);
1627
+ if(data.queued){ mine.req=data.request; mine.left=data.left; } else mine.data=data;
1628
  }catch(e){ mine.error=e.message; }
1629
  mine.busy=false;
1630
  if(PH===mine&&cur==="photo") drawPhoto(); // a newer photo, or a closed sheet, keeps its own
1631
+ if(mine.req) photoWait(mine);
1632
+ }
1633
+ // a student's photo waits for their teacher (app/ai_queue.py): the sheet looks for the answer while it is open
1634
+ const photoWaiting=req=>!!req&&["pending","sending","failed"].includes(req.state);
1635
+ async function photoWait(mine){
1636
+ while(PH===mine&&cur==="photo"&&photoWaiting(mine.req)){
1637
+ await new Promise(done=>setTimeout(done,8000));
1638
+ if(PH!==mine||cur!=="photo") return;
1639
+ try{
1640
+ const r=await api("/api/math/ai-requests",{cache:"no-store"}); if(!r.ok) continue;
1641
+ const row=((await r.json()).requests||[]).find(x=>x.id===mine.req.id); if(!row) continue;
1642
+ mine.req=row; if(row.state==="done"&&row.result) mine.data={...row.result,left:mine.left};
1643
+ }catch{ continue; }
1644
+ if(!photoWaiting(mine.req)&&PH===mine&&cur==="photo") drawPhoto();
1645
+ }
1646
+ }
1647
+ async function photoCancel(){
1648
+ const mine=PH; if(!mine?.req) return;
1649
+ try{ const r=await api(`/api/math/ai-requests/${encodeURIComponent(mine.req.id)}/cancel`,{method:"POST"}); if(r.ok) mine.req=await r.json(); }catch{}
1650
+ if(PH===mine&&cur==="photo") drawPhoto();
1651
  }
1652
  function drawPhoto(){
1653
  const d=PH.data;
1654
  let body;
1655
  if(PH.busy) body='<div class="mx-wait"><span class="mx-spin" aria-hidden="true"></span><b>קוראים את התרגיל ופותרים…</b><small>זה לוקח כמה שניות.</small></div>';
1656
  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>`;
1657
+ else if(!d&&photoWaiting(PH.req)) body=`<div class="mx-wait"><b>התמונה נשלחה למורה</b><small>אחרי שהמורה יאשר, הרמזים והפתרון יופיעו כאן.</small><button class="mx-btn" data-act="photo-cancel">ביטול הבקשה</button></div>`;
1658
+ else if(!d) body=`<div class="mx-bad"><b>${PH.req?.state==="cancelled"?"הבקשה בוטלה":"המורה לא אישר את הבקשה"}</b>${PH.req?.note?`<span>הערת המורה: ${esc(PH.req.note)}</span>`:""}<button class="mx-btn" data-act="retake">צילום חדש</button></div>`;
1659
  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>`;
1660
  else{
1661
  const n=d.steps.length, shown=PH.all?n:Math.min(PH.shown,n);
 
1938
  const response=await api("/api/me");
1939
  if(!response.ok){ state.className="error"; state.textContent=response.status===403?"החשבון אינו משויך לכיתת מתמטיקה.":"לא ניתן להשלים את הכניסה."; return; }
1940
  const me=await response.json();CFG.userId=me.user_id;CFG.answerScans=me.answer_scans; ME_EMAIL=me.email||""; MY_CLASSES=me.classes||[]; MY_CLASS=MY_CLASSES.length===1?MY_CLASSES[0]:null;
1941
+ CFG.permissions=me.permissions||[]; CFG.roles=me.roles||[]; CFG.aiDirect=me.ai_direct!==false; CFG.aiReview=!!me.ai_review;
1942
  }
1943
  document.getElementById("boot").hidden=true;
1944
  show();
supabase/migrations/20261104090000_math_ai_requests.sql ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- The teacher's AI queue (app/ai_queue.py): a student does not call Claude. Their photographed problem, or the answer
2
+ -- they want checked, waits here until their class's teacher (or a manager) looks at it and sends it to the AI or
3
+ -- declines it. Additive: one new table; nothing existing changes. Only the server's secret key reads or writes it.
4
+ begin;
5
+ create table public.math_ai_requests (
6
+ id uuid primary key default gen_random_uuid(),
7
+ created_at timestamptz not null default now(),
8
+ user_id uuid not null references auth.users(id) on delete cascade,
9
+ email text not null check (length(email) <= 320),
10
+ kind text not null check (kind in ('photo', 'grading')),
11
+ state text not null default 'pending'
12
+ check (state in ('pending', 'sending', 'done', 'failed', 'declined', 'cancelled')),
13
+ attempt_id uuid, -- grading: the saved answer (math_question_attempts) to check
14
+ label text not null default '' check (length(label) <= 300), -- grading: the question's name
15
+ question text not null default '' check (length(question) <= 4000), -- grading: the question, for the teacher
16
+ image text check (length(image) <= 8000000), -- photo: base64 JPEG; cleared once the request is handled
17
+ result jsonb, -- photo: the solution the student sees
18
+ note text not null default '' check (length(note) <= 500), -- the teacher's word when declining
19
+ error text not null default '' check (length(error) <= 300),
20
+ reviewed_by text not null default '' check (length(reviewed_by) <= 320),
21
+ sent_at timestamptz,
22
+ finished_at timestamptz,
23
+ check ((kind = 'grading') = (attempt_id is not null))
24
+ );
25
+ create index math_ai_requests_queue on public.math_ai_requests(state, created_at);
26
+ create index math_ai_requests_mine on public.math_ai_requests(user_id, created_at desc);
27
+ -- one open request per answer: a second click finds the first
28
+ create unique index math_ai_requests_one_open on public.math_ai_requests(attempt_id)
29
+ where state in ('pending', 'sending');
30
+ alter table public.math_ai_requests enable row level security;
31
+ revoke all on public.math_ai_requests from public, anon, authenticated;
32
+ grant select, insert, update, delete on public.math_ai_requests to service_role;
33
+ commit;
tests/scan_support.py CHANGED
@@ -32,9 +32,14 @@ class ScanAuth:
32
  url = 'https://test.invalid'
33
  key = 'public-test'
34
  secret = 'server-test'
35
- teachers = frozenset()
 
36
  developers = frozenset()
37
 
 
 
 
 
38
  def user(self, token):
39
  if token not in (OWNER, OTHER):
40
  return None
 
32
  url = 'https://test.invalid'
33
  key = 'public-test'
34
  secret = 'server-test'
35
+ # OWNER is a teacher, so these tests check answers straight with the AI; the students' queue is in test_ai_queue
36
+ teachers = frozenset({OWNER + '@test.invalid'})
37
  developers = frozenset()
38
 
39
+ def _admin(self, path, *args, **kwargs):
40
+ from app.auth import AuthUnavailable
41
+ raise AuthUnavailable('supabase answered 404: no tables here but the answer RPCs')
42
+
43
  def user(self, token):
44
  if token not in (OWNER, OTHER):
45
  return None
tests/test_ai_queue.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The teacher's AI queue: a student's request waits for the class teacher, who sends it to the AI or declines it."""
2
+ import asyncio
3
+ import base64
4
+ import uuid
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+ import pytest
10
+
11
+ from app.ai_queue import AiQueue, may_send
12
+ from app.answer_grading import validate_assessment
13
+ from app.answer_scans import ScanError
14
+ from app.main import create_app
15
+ from scan_support import OWNER, OTHER, ScanAuth, answer_store, postgres_server
16
+ from test_answer_grading import assessment
17
+ from test_answer_scans import begin, finish, png, request
18
+
19
+ STRANGER = '33333333-3333-4333-8333-333333333333'
20
+ mail = lambda uid: uid + '@test.invalid'
21
+
22
+
23
+ @pytest.fixture(autouse=True)
24
+ def grading_on(monkeypatch):
25
+ monkeypatch.setenv('MATHLY_OPEN_ENDED_LOCKED', '0')
26
+ monkeypatch.setenv('ANTHROPIC_API_KEY', 'test-key')
27
+
28
+
29
+ class ClassAuth(ScanAuth):
30
+ """OWNER is a student whose class teacher is OTHER; STRANGER teaches another class."""
31
+ teachers = frozenset()
32
+
33
+ def user(self, token):
34
+ return {'id': token, 'email': mail(token)} if token in (OWNER, OTHER, STRANGER) else None
35
+
36
+ def classes(self, fresh=False):
37
+ return [{'id': 1, 'name': 'ט1', 'emails': [mail(OWNER), mail(OTHER)], 'teachers': [mail(OTHER)]},
38
+ {'id': 2, 'name': 'ט2', 'emails': [mail(STRANGER), 'x@test.invalid'], 'teachers': [mail(STRANGER)]}]
39
+
40
+
41
+ class MemoryQueue(AiQueue):
42
+ """AiQueue's behaviour over a list, in place of the Supabase table."""
43
+ configured = True
44
+
45
+ def __init__(self):
46
+ self.rows = []
47
+
48
+ def _new(self, row):
49
+ out = {'id': str(uuid.uuid4()), 'created_at': datetime.now(timezone.utc).isoformat(), 'state': 'pending',
50
+ 'attempt_id': None, 'label': '', 'question': '', 'image': None, 'result': None, 'note': '',
51
+ 'error': '', 'reviewed_by': '', 'sent_at': None, 'finished_at': None, **row}
52
+ self.rows.append(out)
53
+ return out
54
+
55
+ def _public(self, row, image=False):
56
+ return {k: v for k, v in row.items() if image or k != 'image'} if row else None
57
+
58
+ def _find(self, rid):
59
+ return next((r for r in self.rows if r['id'] == rid), None)
60
+
61
+ def add(self, row):
62
+ if row.get('attempt_id') and any(r['attempt_id'] == row['attempt_id'] and r['state'] in ('pending', 'sending')
63
+ for r in self.rows):
64
+ return self.for_attempt(row['user_id'], row['attempt_id'])
65
+ return self._public(self._new(row))
66
+
67
+ def for_attempt(self, user_id, attempt_id):
68
+ rows = [r for r in self.rows if r['user_id'] == user_id and r['attempt_id'] == attempt_id]
69
+ return self._public(rows[-1]) if rows else None
70
+
71
+ def open_count(self, user_id):
72
+ return sum(r['user_id'] == user_id and r['state'] in ('pending', 'sending') for r in self.rows)
73
+
74
+ def mine(self, user_id, limit=20):
75
+ return [self._public(r) for r in reversed(self.rows) if r['user_id'] == user_id and r['kind'] == 'photo']
76
+
77
+ def cancel(self, user_id, rid):
78
+ r = self._find(rid)
79
+ if not r or r['user_id'] != user_id or r['state'] != 'pending':
80
+ return None
81
+ r.update(state='cancelled', image=None)
82
+ return self._public(r)
83
+
84
+ def reopen(self, rid):
85
+ r = self._find(rid)
86
+ if not r or r['state'] not in ('failed', 'declined'):
87
+ raise ScanError(409, 'request_taken')
88
+ r.update(state='pending', error='', note='')
89
+ return self._public(r)
90
+
91
+ def listing(self, emails, limit=100):
92
+ return [self._public(r) for r in reversed(self.rows) if emails is None or r['email'] in emails]
93
+
94
+ def get(self, rid, image=False):
95
+ return self._public(self._find(rid), image)
96
+
97
+ def claim(self, rid, reviewer):
98
+ r = self._find(rid)
99
+ if not r or r['state'] not in ('pending', 'failed'):
100
+ raise ScanError(409, 'request_taken')
101
+ r.update(state='sending', reviewed_by=reviewer, error='')
102
+ return self._public(r, image=True)
103
+
104
+ def finish(self, rid, state, **fields):
105
+ r = self._find(rid)
106
+ r.update(state=state, **fields)
107
+ return self._public(r)
108
+
109
+ def decline(self, rid, reviewer, note):
110
+ r = self._find(rid)
111
+ if not r or r['state'] not in ('pending', 'failed'):
112
+ raise ScanError(409, 'request_taken')
113
+ r.update(state='declined', image=None, note=note, reviewed_by=reviewer)
114
+ return self._public(r)
115
+
116
+
117
+ class Grader:
118
+ def __init__(self):
119
+ self.calls = []
120
+
121
+ def grade(self, image, snapshot):
122
+ self.calls.append((image, snapshot))
123
+ return validate_assessment(assessment())
124
+
125
+
126
+ class Solver:
127
+ def __init__(self):
128
+ self.calls = []
129
+
130
+ def solve(self, image, media_type):
131
+ self.calls.append((image, media_type))
132
+ return {"readable": True, "problem": "$x² = 9$", "steps": [{"hint": "h", "work": "w"}],
133
+ "answer": "$x = ±3$", "skill": "", "note": "", "usage": None}
134
+
135
+
136
+ def make_app(store=None):
137
+ app = create_app(auth=ClassAuth())
138
+ if store is not None:
139
+ app.state.answers = store
140
+ app.state.ai_queue, app.state.answer_grader = MemoryQueue(), Grader()
141
+ app.state.photo, app.state.photo_key = Solver(), 'test-key'
142
+ return app
143
+
144
+
145
+ def as_user(app, uid):
146
+ return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test',
147
+ headers={'Authorization': f'Bearer {uid}'})
148
+
149
+
150
+ def test_who_sends_to_the_ai_itself():
151
+ assert may_send(None) # sign-in off
152
+ assert not may_send({'roles': ['student'], 'permissions': ['path.use'], 'email': 's@x', 'classes': []})
153
+ assert may_send({'roles': ['teacher'], 'permissions': [], 'email': 't@x', 'classes': []})
154
+ assert may_send({'roles': ['developer'], 'permissions': ['*'], 'email': 'd@x', 'classes': []})
155
+ assert may_send({'roles': ['student'], 'permissions': [], 'email': 't@x',
156
+ 'classes': [{'teachers': ['t@x'], 'emails': ['t@x']}]})
157
+
158
+
159
+ def test_a_students_answer_waits_for_the_teacher_who_sends_it(answer_store):
160
+ store = answer_store
161
+ upload = begin(store, request(store))
162
+ sid, lid, aid = upload['scan']['id'], upload['scan']['lease_id'], upload['attempt']['id']
163
+ app = make_app(store)
164
+ grader, queue = app.state.answer_grader, app.state.ai_queue
165
+
166
+ async def check():
167
+ async with as_user(app, OWNER) as student, as_user(app, OTHER) as teacher, as_user(app, STRANGER) as other:
168
+ me = (await student.get('/api/me')).json()
169
+ assert me['ai_direct'] is False and me['ai_review'] is False
170
+ assert (await teacher.get('/api/me')).json()['ai_review'] is True
171
+ # the upload asks the teacher, never the AI
172
+ up = await student.post(f'/api/math/answer-scans/{sid}/uploads/{lid}',
173
+ files={'photo': ('a.png', png(), 'image/png')})
174
+ assert up.status_code == 200 and not grader.calls
175
+ got = (await student.get(f'/api/math/answer-attempts/{aid}')).json()
176
+ assert got['grading'] is None and got['ai_request']['state'] == 'pending'
177
+ assert 'email' not in got['ai_request']
178
+ # asking again does not make a second request, and does not call the AI
179
+ again = (await student.post(f'/api/math/answer-attempts/{aid}/grade')).json()
180
+ assert again['ai_request']['id'] == got['ai_request']['id'] and len(queue.rows) == 1 and not grader.calls
181
+ # only the class's teacher reviews it
182
+ assert (await student.get('/api/ai-queue')).status_code == 403
183
+ assert (await other.get('/api/ai-queue')).json()['requests'] == []
184
+ rid = got['ai_request']['id']
185
+ assert (await other.post(f'/api/ai-queue/{rid}/send')).status_code == 404
186
+ listed = (await teacher.get('/api/ai-queue')).json()['requests']
187
+ assert [r['id'] for r in listed] == [rid] and listed[0]['email'] == mail(OWNER)
188
+ image = await teacher.get(f'/api/ai-queue/{rid}/image')
189
+ assert image.status_code == 200 and image.headers['content-type'] == 'image/jpeg'
190
+ sent = (await teacher.post(f'/api/ai-queue/{rid}/send')).json()
191
+ assert sent['state'] == 'done' and sent['result']['verdict'] == 'correct' and len(grader.calls) == 1
192
+ assert (await teacher.post(f'/api/ai-queue/{rid}/send')).status_code == 409 # sent once
193
+ done = (await student.get(f'/api/math/answer-attempts/{aid}')).json()
194
+ assert done['grading']['state'] == 'completed' and done['grading']['result']['score'] == 100
195
+ asyncio.run(check())
196
+
197
+
198
+ def test_a_declined_answer_can_be_asked_again(answer_store):
199
+ store = answer_store
200
+ aid = finish(store, begin(store, request(store)))['attempt']['id']
201
+ app = make_app(store)
202
+
203
+ async def check():
204
+ async with as_user(app, OWNER) as student, as_user(app, OTHER) as teacher:
205
+ rid = (await student.post(f'/api/math/answer-attempts/{aid}/grade',
206
+ json={'automatic': True})).json()['ai_request']['id']
207
+ declined = (await teacher.post(f'/api/ai-queue/{rid}/decline', json={'note': 'צלמו שוב'})).json()
208
+ assert declined['state'] == 'declined' and declined['note'] == 'צלמו שוב'
209
+ seen = (await student.get(f'/api/math/answer-attempts/{aid}')).json()['ai_request']
210
+ assert seen['state'] == 'declined' and seen['note'] == 'צלמו שוב'
211
+ # the automatic check does not ask again; the student's click does
212
+ auto = (await student.post(f'/api/math/answer-attempts/{aid}/grade', json={'automatic': True})).json()
213
+ assert auto['ai_request']['state'] == 'declined'
214
+ asked = (await student.post(f'/api/math/answer-attempts/{aid}/grade')).json()
215
+ assert asked['ai_request']['id'] == rid and asked['ai_request']['state'] == 'pending'
216
+ assert not app.state.answer_grader.calls
217
+ asyncio.run(check())
218
+
219
+
220
+ def test_a_teachers_own_answer_goes_straight_to_the_ai(answer_store):
221
+ store = answer_store
222
+ aid = finish(store, begin(store, request(store)))['attempt']['id']
223
+ app = make_app(store)
224
+ app.state.auth.teachers = frozenset({mail(OWNER)}) # OWNER has the teacher role now
225
+
226
+ async def check():
227
+ async with as_user(app, OWNER) as me:
228
+ result = (await me.post(f'/api/math/answer-attempts/{aid}/grade')).json()
229
+ assert result['grading']['state'] == 'completed' and not app.state.ai_queue.rows
230
+ asyncio.run(check())
231
+
232
+
233
+ def test_a_students_photo_waits_for_the_teacher():
234
+ app = make_app()
235
+ solver, queue = app.state.photo, app.state.ai_queue
236
+
237
+ async def check():
238
+ async with as_user(app, OWNER) as student, as_user(app, OTHER) as teacher:
239
+ sent = await student.post('/api/math/photo', files={'photo': ('p.png', png(), 'image/png')})
240
+ assert sent.status_code == 200 and sent.json()['queued'] is True and not solver.calls
241
+ rid = sent.json()['request']['id']
242
+ assert base64.b64decode(queue.rows[0]['image'])[:2] == b'\xff\xd8' # kept as a clean JPEG
243
+ assert (await student.post('/api/math/photo', files={'photo': ('p.jpg', b'nope', 'image/jpeg')})).status_code == 415
244
+ mine = (await student.get('/api/math/ai-requests')).json()
245
+ assert mine['direct'] is False and mine['requests'][0]['state'] == 'pending'
246
+ assert (await teacher.get(f'/api/ai-queue/{rid}/image')).status_code == 200
247
+ done = (await teacher.post(f'/api/ai-queue/{rid}/send')).json()
248
+ assert done['state'] == 'done' and len(solver.calls) == 1 and solver.calls[0][1] == 'image/jpeg'
249
+ assert queue.rows[0]['image'] is None # the photo is not kept once it is answered
250
+ mine = (await student.get('/api/math/ai-requests')).json()['requests'][0]
251
+ assert mine['state'] == 'done' and mine['result']['answer'] == '$x = ±3$' and 'usage' not in mine['result']
252
+ # the teacher's own photo is solved at once
253
+ own = (await teacher.post('/api/math/photo', files={'photo': ('p.png', png(), 'image/png')})).json()
254
+ assert own['answer'] == '$x = ±3$' and len(queue.rows) == 1
255
+ asyncio.run(check())
256
+
257
+
258
+ def test_a_student_can_cancel_and_has_a_limit_of_open_requests():
259
+ app = make_app()
260
+
261
+ async def check():
262
+ async with as_user(app, OWNER) as student:
263
+ ids = []
264
+ for _ in range(5):
265
+ ids.append((await student.post('/api/math/photo', files={'photo': ('p.png', png(), 'image/png')})).json()['request']['id'])
266
+ assert (await student.post('/api/math/photo', files={'photo': ('p.png', png(), 'image/png')})).status_code == 429
267
+ assert (await student.post(f'/api/math/ai-requests/{ids[0]}/cancel')).json()['state'] == 'cancelled'
268
+ assert (await student.post(f'/api/math/ai-requests/{ids[0]}/cancel')).status_code == 409
269
+ assert (await student.post('/api/math/photo', files={'photo': ('p.png', png(), 'image/png')})).status_code == 200
270
+ asyncio.run(check())
271
+
272
+
273
+ def test_the_migration_keeps_one_open_request_per_answer(postgres_server):
274
+ import psycopg
275
+ database = 'queue_' + uuid.uuid4().hex
276
+ with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
277
+ c.execute('create database ' + database)
278
+ try:
279
+ with psycopg.connect(postgres_server.get_uri(database=database), autocommit=True) as c:
280
+ c.execute('create schema auth; create table auth.users(id uuid primary key);')
281
+ c.execute(Path('supabase/migrations/20261104090000_math_ai_requests.sql').read_text())
282
+ c.execute('insert into auth.users values(%s)', (OWNER,))
283
+ attempt = str(uuid.uuid4())
284
+ add = "insert into public.math_ai_requests(user_id,email,kind,attempt_id) values(%s,'s@x','grading',%s)"
285
+ c.execute(add, (OWNER, attempt))
286
+ with pytest.raises(psycopg.errors.UniqueViolation):
287
+ c.execute(add, (OWNER, attempt))
288
+ c.execute("update public.math_ai_requests set state='done'")
289
+ c.execute(add, (OWNER, attempt)) # a new request once the first is handled
290
+ with pytest.raises(psycopg.errors.CheckViolation): # a photo has no answer; an answer check needs one
291
+ c.execute("insert into public.math_ai_requests(user_id,email,kind,attempt_id) values(%s,'s@x','photo',%s)",
292
+ (OWNER, attempt))
293
+ c.execute("set role anon")
294
+ with pytest.raises(psycopg.errors.InsufficientPrivilege):
295
+ c.execute('select * from public.math_ai_requests')
296
+ finally:
297
+ with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
298
+ c.execute('drop database ' + database + ' with (force)')
299
+
300
+
301
+ def test_a_failed_send_keeps_why_it_failed(answer_store):
302
+ from app.math_photo import PhotoUnavailable
303
+ store = answer_store
304
+ aid = finish(store, begin(store, request(store)))['attempt']['id']
305
+ app = make_app(store)
306
+
307
+ class Refused:
308
+ def grade(self, image, snapshot):
309
+ raise PhotoUnavailable('הבדיקה אינה זמינה כרגע.', 'api_key_invalid', 'AuthenticationError 401: invalid x-api-key')
310
+ app.state.answer_grader = Refused()
311
+
312
+ async def check():
313
+ async with as_user(app, OWNER) as student, as_user(app, OTHER) as teacher:
314
+ rid = (await student.post(f'/api/math/answer-attempts/{aid}/grade')).json()['ai_request']['id']
315
+ failed = (await teacher.post(f'/api/ai-queue/{rid}/send')).json()
316
+ assert failed['state'] == 'failed'
317
+ assert failed['error'] == 'grading_failed|api_key_invalid|AuthenticationError 401: invalid x-api-key'
318
+ assert 'error' not in (await student.get(f'/api/math/answer-attempts/{aid}')).json()['ai_request']
319
+ asyncio.run(check())
320
+
321
+
322
+ def test_a_teachers_own_failed_check_says_why(answer_store, monkeypatch):
323
+ store = answer_store
324
+ aid = finish(store, begin(store, request(store)))['attempt']['id']
325
+ app = make_app(store)
326
+ app.state.auth.teachers = frozenset({mail(OWNER)})
327
+ monkeypatch.delenv('ANTHROPIC_API_KEY')
328
+
329
+ async def check():
330
+ async with as_user(app, OWNER) as me:
331
+ body = (await me.post(f'/api/math/answer-attempts/{aid}/grade')).json()
332
+ assert body == {'detail': 'grading_unavailable', 'reason': 'missing_key',
333
+ 'error_detail': 'ANTHROPIC_API_KEY is not set'}
334
+ asyncio.run(check())
335
+
336
+
337
+ @pytest.mark.parametrize('status,message,reason', [
338
+ (401, 'invalid x-api-key', 'api_key_invalid'), (403, 'no access', 'permission'),
339
+ (404, 'model: claude-x', 'model_not_found'), (429, 'slow down', 'rate_limited'),
340
+ (400, 'Your credit balance is too low to access the Anthropic API.', 'no_credit'),
341
+ (529, 'Overloaded', 'overloaded'), (400, 'bad image', 'bad_request')])
342
+ def test_api_errors_are_named(status, message, reason):
343
+ import anthropic
344
+ from app.math_photo import failure_reason
345
+ response = httpx.Response(status, request=httpx.Request('POST', 'https://api.anthropic.com/v1/messages'))
346
+ cls = {401: anthropic.AuthenticationError, 403: anthropic.PermissionDeniedError, 404: anthropic.NotFoundError,
347
+ 429: anthropic.RateLimitError, 400: anthropic.BadRequestError}.get(status, anthropic.InternalServerError)
348
+ exc = cls(message, response=response, body=None)
349
+ got, detail = failure_reason(exc)
350
+ assert got == reason and str(status) in detail and message[:20] in detail
351
+ assert failure_reason(anthropic.APIConnectionError(request=response.request))[0] == 'no_connection'
tests/test_ai_queue_browser.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In the browser: a student's quiz answers wait in the teacher's AI queue; the teacher sends them; the grade shows."""
2
+ import socket
3
+ import threading
4
+ import time
5
+
6
+ import pytest
7
+ import uvicorn
8
+
9
+ from app.answer_grading import validate_assessment
10
+ from app.main import create_app
11
+ from scan_support import OWNER, OTHER, answer_store, postgres_server
12
+ from test_ai_queue import ClassAuth, MemoryQueue
13
+ from test_answer_grading import assessment
14
+ from test_unit_quiz import photograph
15
+
16
+ playwright = pytest.importorskip("playwright.sync_api")
17
+
18
+
19
+ @pytest.fixture(autouse=True)
20
+ def grading_on(monkeypatch):
21
+ monkeypatch.setenv("MATHLY_OPEN_ENDED_LOCKED", "0")
22
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
23
+
24
+
25
+ @pytest.fixture
26
+ def queue_server(answer_store):
27
+ app = create_app(auth=ClassAuth()); app.state.answers = answer_store
28
+ app.state.ai_queue = MemoryQueue()
29
+ calls = []
30
+
31
+ class Grader:
32
+ def grade(self, image, snapshot):
33
+ if "fail" in calls:
34
+ from app.math_photo import PhotoUnavailable
35
+ raise PhotoUnavailable("x", "api_key_invalid", "AuthenticationError 401: invalid x-api-key")
36
+ calls.append(snapshot["name"])
37
+ return validate_assessment(assessment(score=100))
38
+ app.state.answer_grader = Grader()
39
+ with socket.socket() as sock:
40
+ sock.bind(("127.0.0.1", 0))
41
+ server = uvicorn.Server(uvicorn.Config(app, log_level="error"))
42
+ thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True)
43
+ thread.start()
44
+ try:
45
+ while not server.started:
46
+ time.sleep(.01)
47
+ yield f"http://127.0.0.1:{sock.getsockname()[1]}", calls
48
+ finally:
49
+ server.should_exit = True; thread.join(timeout=5)
50
+
51
+
52
+ def page_of(browser, url, owner, errors):
53
+ from test_answer_browser import session
54
+ page = session(browser, owner=owner).new_page()
55
+ page.on("pageerror", lambda e: errors.append(str(e)))
56
+ page.goto(url)
57
+ page.locator('.mx-nav [data-view="path"]').click()
58
+ return page
59
+
60
+
61
+ def test_the_teacher_sends_a_students_quiz_answer_to_the_ai(queue_server, tmp_path):
62
+ url, calls = queue_server
63
+ with playwright.sync_playwright() as p:
64
+ browser = p.chromium.launch()
65
+ errors = []
66
+ try:
67
+ student = page_of(browser, url, OWNER, errors)
68
+ playwright.expect(student.locator('[data-act="ai-queue"]')).to_have_count(0) # a student has no queue
69
+ student.locator('.mx-uquiz[data-uquiz="t"]').click()
70
+ student.locator("[data-act=uq-start]").click()
71
+ photograph(student, 0, tmp_path)
72
+ paper = student.locator(".mx-uq-paper")
73
+ paper.locator("[data-act=uq-submit]").click()
74
+ paper.locator("[data-act=uq-submit]").click() # past the warning about unanswered questions
75
+ sheet = student.locator(".mx-uq-sheet")
76
+ playwright.expect(sheet.locator(".mx-uq-verdict").first).to_have_text("מחכה למורה שיאשר את הבדיקה", timeout=15000)
77
+ assert not calls
78
+
79
+ teacher = page_of(browser, url, OTHER, errors)
80
+ button = teacher.locator('.mx-acts [data-act="ai-queue"]').first
81
+ playwright.expect(button).to_contain_text("1")
82
+ button.click()
83
+ card = teacher.locator(".mx-aq-card.pending")
84
+ playwright.expect(card).to_have_count(1)
85
+ playwright.expect(card.locator(".mx-aq-img")).to_be_visible()
86
+ card.locator("[data-aq-send]").click()
87
+ playwright.expect(teacher.locator(".mx-aq-card.done")).to_contain_text("נכונה · 100/100")
88
+ assert len(calls) == 1
89
+
90
+ sheet.locator('[data-act="close"]').last.click()
91
+ student.locator('.mx-uquiz[data-uquiz="t"]').click()
92
+ student.locator("[data-uq-result]").first.click()
93
+ playwright.expect(student.locator(".mx-uq-verdict").first).to_have_text("נכון ומלא", timeout=10000)
94
+ assert len(calls) == 1 and not errors, errors
95
+ finally:
96
+ browser.close()
97
+
98
+
99
+ def test_a_failed_send_has_an_error_details_button(queue_server, tmp_path):
100
+ from app.math_photo import PhotoUnavailable
101
+ url, calls = queue_server
102
+ with playwright.sync_playwright() as p:
103
+ browser = p.chromium.launch()
104
+ errors = []
105
+ try:
106
+ student = page_of(browser, url, OWNER, errors)
107
+ student.locator('.mx-uquiz[data-uquiz="t"]').click()
108
+ student.locator("[data-act=uq-start]").click()
109
+ photograph(student, 0, tmp_path)
110
+ paper = student.locator(".mx-uq-paper")
111
+ paper.locator("[data-act=uq-submit]").click()
112
+ paper.locator("[data-act=uq-submit]").click()
113
+ playwright.expect(student.locator(".mx-uq-verdict").first).to_have_text("מחכה למורה שיאשר את הבדיקה", timeout=15000)
114
+ calls.append("fail") # the grader below fails while this is in the list
115
+
116
+ teacher = page_of(browser, url, OTHER, errors)
117
+ teacher.locator('.mx-acts [data-act="ai-queue"]').first.click()
118
+ teacher.locator(".mx-aq-card.pending [data-aq-send]").click()
119
+ card = teacher.locator(".mx-aq-card.failed")
120
+ playwright.expect(card.locator(".mx-aq-error")).to_have_text("הבדיקה נכשלה. אפשר לנסות שוב.")
121
+ details = card.locator(".mx-error-details")
122
+ playwright.expect(details.locator("code")).to_be_hidden()
123
+ details.locator("summary").click()
124
+ playwright.expect(details).to_contain_text("מפתח ה-API לא תקין")
125
+ playwright.expect(details.locator("code")).to_have_text("AuthenticationError 401: invalid x-api-key")
126
+ assert not errors, errors
127
+ finally:
128
+ browser.close()
tests/test_class_report.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The class teacher's report: one of the students is the class's teacher and sees the others' progress and grades."""
2
+ import asyncio
3
+ import urllib.parse
4
+
5
+ import httpx
6
+
7
+ from app.main import create_app
8
+
9
+ TEACHER, STUDENT, NEWBIE, OUTSIDER = "t-id", "s-id", "n-id", "o-id"
10
+ EMAILS = {TEACHER: "teacher@x.test", STUDENT: "student@x.test", NEWBIE: "newbie@x.test", OUTSIDER: "out@x.test"}
11
+
12
+
13
+ class ReportAuth:
14
+ url, key, secret = "https://test.invalid", "public-test", "server-test"
15
+ teachers = developers = frozenset()
16
+
17
+ def __init__(self):
18
+ self.row = {"id": 3, "name": "ט׳1", "lessons": [], "teachers": ["teacher@x.test"],
19
+ "emails": ["newbie@x.test", "student@x.test", "teacher@x.test", "ghost@x.test"]}
20
+ self.saved = []
21
+ self.progress = {STUDENT: {"done": {"t1": "2026-11-01"}, "updated": 5, "comets": ["c1"],
22
+ "slips": [{"at": "2026-11-01T10:00:00Z", "id": "t1", "q": 0, "m": "sign"}],
23
+ "quizzes": [{"at": "2026-11-02", "n": 12, "right": 9, "ms": 600000, "units": ["t"],
24
+ "weak": ["t1"]}],
25
+ "uq": {"t": {"sitting": {"started": 1, "attempts": ["a"], "submitted": 2},
26
+ "history": [{"at": "2026-11-03", "attempts": ["a"], "score": 85}]}}},
27
+ TEACHER: {"done": {"secret": 1}}}
28
+
29
+ def user(self, token):
30
+ return {"id": token, "email": EMAILS[token]} if token in EMAILS else None
31
+
32
+ def classes(self, fresh=False):
33
+ return [self.row]
34
+
35
+ def access_for(self, _id, **kwargs):
36
+ return None
37
+
38
+ def user_ids(self, emails):
39
+ return {e: i for i, e in EMAILS.items() if e in emails and i != NEWBIE} | (
40
+ {"newbie@x.test": NEWBIE} if "newbie@x.test" in emails else {})
41
+
42
+ def save_class(self, class_id, name, emails, nicknames, lessons, *, teachers=None):
43
+ self.saved.append({"emails": emails, "teachers": teachers})
44
+
45
+ def _admin(self, path, method="GET", body=None, prefer=""):
46
+ table, _, query = path.partition("?")
47
+ assert table == "/rest/v1/math_progress"
48
+ ids = dict(urllib.parse.parse_qsl(query))["user_id"].removeprefix("in.(").removesuffix(")").split(",")
49
+ return [{"user_id": i, "data": self.progress[i], "updated_at": "2026-11-03T00:00:00Z"}
50
+ for i in ids if i in self.progress]
51
+
52
+
53
+ def client(app, user):
54
+ return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test",
55
+ headers={"Authorization": f"Bearer {user}"})
56
+
57
+
58
+ def test_teacher_sees_the_other_students():
59
+ app = create_app(auth=ReportAuth())
60
+
61
+ async def check():
62
+ async with client(app, TEACHER) as c:
63
+ r = await c.get("/api/classes/3/report")
64
+ assert r.status_code == 200
65
+ data = r.json()
66
+ assert data["class"]["name"] == "ט׳1" and data["class"]["lessons"]
67
+ students = {s["email"]: s for s in data["students"]}
68
+ assert "teacher@x.test" not in students # the teacher is not in their own report
69
+ assert students["ghost@x.test"] == {"email": "ghost@x.test", "signed_in": False, "progress": None,
70
+ "saved_at": None}
71
+ assert students["newbie@x.test"]["signed_in"] and students["newbie@x.test"]["progress"] is None
72
+ p = students["student@x.test"]["progress"]
73
+ assert p["done"] == {"t1": "2026-11-01"} and p["slips"][0]["m"] == "sign"
74
+ assert p["quizzes"] == [{"at": "2026-11-02", "n": 12, "right": 9, "ms": 600000, "units": ["t"]}]
75
+ assert p["uq"] == {"t": {"history": [{"at": "2026-11-03", "score": 85, "timeUp": None}], "going": False}}
76
+ assert "comets" not in p
77
+ assert (await c.get("/api/classes/99/report")).status_code == 404
78
+ me = (await c.get("/api/me")).json()
79
+ assert me["classes"][0]["teacher"] is True
80
+ for other in (STUDENT, OUTSIDER):
81
+ async with client(app, other) as c:
82
+ assert (await c.get("/api/classes/3/report")).status_code == 403
83
+
84
+ asyncio.run(check())
85
+
86
+
87
+ def test_the_teacher_is_one_of_the_students():
88
+ auth = ReportAuth()
89
+ app = create_app(auth=auth)
90
+
91
+ async def check():
92
+ # a manager (TEACHER_EMAILS) saves the class
93
+ auth.teachers = frozenset({"out@x.test"})
94
+ async with client(app, OUTSIDER) as c:
95
+ body = {"name": "ט׳1", "emails": ["a@x.test", "b@x.test"], "lessons": ["t"]}
96
+ assert (await c.put("/api/classes/3", json={**body, "teachers": ["c@x.test"]})).status_code == 400
97
+ assert (await c.put("/api/classes/3", json={**body, "teachers": ["a@x.test", "b@x.test"]})).status_code == 400
98
+ assert (await c.put("/api/classes/3", json={**body, "teachers": ["B@x.test"]})).status_code == 200
99
+ assert (await c.put("/api/classes/3", json=body)).status_code == 200
100
+ assert auth.saved == [{"emails": ["a@x.test", "b@x.test"], "teachers": ["b@x.test"]},
101
+ {"emails": ["a@x.test", "b@x.test"], "teachers": []}]
102
+ # a manager can open any class's report
103
+ assert (await c.get("/api/classes/3/report")).status_code == 200
104
+
105
+ asyncio.run(check())
tests/test_guide_browser.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Browser checks of the user guide (static/guide.js): the "?" button opens it, with a tab for students and one for teachers.
2
+ Optional, like the other browser tests: pip install playwright && playwright install chromium."""
3
+ import socket
4
+ import threading
5
+ import time
6
+
7
+ import pytest
8
+ import uvicorn
9
+
10
+ from app.main import create_app
11
+
12
+ playwright = pytest.importorskip("playwright.sync_api")
13
+
14
+
15
+ @pytest.fixture(scope="module")
16
+ def server():
17
+ app = create_app()
18
+ with socket.socket() as sock:
19
+ sock.bind(("127.0.0.1", 0))
20
+ srv = uvicorn.Server(uvicorn.Config(app, log_level="error"))
21
+ thread = threading.Thread(target=srv.run, kwargs={"sockets": [sock]}, daemon=True)
22
+ thread.start()
23
+ try:
24
+ deadline = time.monotonic() + 5
25
+ while not srv.started:
26
+ assert thread.is_alive() and time.monotonic() < deadline
27
+ time.sleep(.01)
28
+ yield f"http://127.0.0.1:{sock.getsockname()[1]}"
29
+ finally:
30
+ srv.should_exit = True
31
+ thread.join(timeout=5)
32
+
33
+
34
+ def test_guide_opens_with_student_and_teacher_tabs(server):
35
+ with playwright.sync_playwright() as p:
36
+ browser = p.chromium.launch()
37
+ try:
38
+ ctx = browser.new_context(viewport={"width": 390, "height": 844})
39
+ ctx.route("https://cdn.jsdelivr.net/**", lambda route: route.abort())
40
+ ctx.route("https://fonts.googleapis.com/**", lambda route: route.abort())
41
+ page = ctx.new_page()
42
+ errors = []
43
+ page.on("pageerror", lambda e: errors.append(str(e)))
44
+ page.goto(server)
45
+ assert page.locator("#boot [data-guide-open]").count() == 1 # on the sign-in screen
46
+ page.locator("#mathView [data-guide-open]").first.click() # and in the app's header
47
+ guide = page.get_by_role("dialog", name="איך משתמשים באפליקציה")
48
+ playwright.expect(guide).to_be_visible()
49
+ playwright.expect(page.locator("#mgS")).to_contain_text("בוחן מסכם")
50
+ playwright.expect(page.locator("#mgT")).to_be_hidden()
51
+ page.get_by_role("tab", name="למורים").click()
52
+ playwright.expect(page.locator("#mgT")).to_contain_text("הכיתה שלי")
53
+ playwright.expect(page.locator("#mgS")).to_be_hidden()
54
+ page.keyboard.press("Escape")
55
+ playwright.expect(guide).to_be_hidden()
56
+ assert errors == []
57
+ finally:
58
+ browser.close()
tests/test_phone_browser.py CHANGED
@@ -44,7 +44,7 @@ def test_class_management_geometry_and_permissions(phone_server, manager):
44
  import json
45
 
46
  row = {"id": 7, "name": "כיתת תרגול", "lessons": ["t"],
47
- "emails": ["student@example.com"], "teachers": ["teacher@example.com"],
48
  "exam_title": "לוח מבחנים – כיתה ט׳11, תשפ״ז",
49
  "exam_note": "ייתכנו שינויים.",
50
  "exams": [{"date": "2026-11-16", "subject": "מתמטיקה"}]}
@@ -126,3 +126,106 @@ def test_class_management_geometry_and_permissions(phone_server, manager):
126
  playwright.expect(page.locator('[data-id="a_30_side"]')).to_be_visible()
127
  finally:
128
  browser.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  import json
45
 
46
  row = {"id": 7, "name": "כיתת תרגול", "lessons": ["t"],
47
+ "emails": ["student@example.com", "teacher@example.com"], "teachers": ["teacher@example.com"],
48
  "exam_title": "לוח מבחנים – כיתה ט׳11, תשפ״ז",
49
  "exam_note": "ייתכנו שינויים.",
50
  "exams": [{"date": "2026-11-16", "subject": "מתמטיקה"}]}
 
126
  playwright.expect(page.locator('[data-id="a_30_side"]')).to_be_visible()
127
  finally:
128
  browser.close()
129
+
130
+
131
+ def test_class_teacher_report(phone_server):
132
+ """A student chosen as the class's teacher opens the class report: progress, difficulties and both kinds of grades."""
133
+ from datetime import datetime, timedelta, timezone
134
+
135
+ now = datetime.now(timezone.utc)
136
+ slips = [{"at": (now - timedelta(days=d)).isoformat(), "id": "t_ext", "q": 0, "m": "ext_partial"} for d in (1, 3)]
137
+ row = {"id": 4, "name": "ט׳2", "lessons": ["t"], "teacher": True, "exams": []}
138
+ report = {"class": {"id": 4, "name": "ט׳2", "lessons": ["t"]}, "students": [
139
+ {"email": "dana@example.com", "signed_in": True, "saved_at": None, "progress": {
140
+ "done": {"t_ext": "2026-11-01"}, "gold": {}, "days": {}, "missed": {}, "slips": slips, "updated": 1,
141
+ "quizzes": [{"at": "2026-11-02T10:00:00Z", "n": 12, "right": 9, "ms": 600000, "units": ["t"]}],
142
+ "uq": {"t": {"history": [{"at": "2026-11-03T10:00:00Z", "score": 85}], "going": False}}}},
143
+ {"email": "omer@example.com", "signed_in": False, "saved_at": None, "progress": None}]}
144
+ with playwright.sync_playwright() as p:
145
+ browser = p.chromium.launch()
146
+ try:
147
+ page = browser.new_page()
148
+ page.route("https://cdn.jsdelivr.net/**", lambda route: route.abort())
149
+ page.add_init_script('''window.supabase={createClient:()=>({auth:{
150
+ getSession:async()=>({data:{session:{access_token:"test",user:{id:"test"}}}}),
151
+ onAuthStateChange:()=>{},signOut:async()=>{}
152
+ }})};''')
153
+
154
+ def index(route):
155
+ response = route.fetch()
156
+ route.fulfill(response=response, body=response.text().replace(
157
+ 'supabaseUrl:""', 'supabaseUrl:"https://auth.invalid"'))
158
+
159
+ page.route(phone_server + "/", index)
160
+ page.route("**/api/me", lambda route: route.fulfill(json={
161
+ "email": "teacher@example.com", "user_id": "test", "answer_scans": False,
162
+ "permissions": ["path.use"], "classes": [row]}))
163
+ page.route("**/api/math/progress", lambda route: route.fulfill(json={"progress": {}}))
164
+ page.route("**/api/classes/4/report", lambda route: route.fulfill(json=report))
165
+ page.goto(phone_server)
166
+ page.locator('[data-act="class-report"]').click()
167
+ sheet = page.locator(".mx-report-sheet")
168
+ playwright.expect(sheet.locator("#mxTitle")).to_have_text("הכיתה שלי: ט׳2")
169
+ playwright.expect(sheet.locator(".mx-report-common")).to_contain_text("1 תלמידים: dana")
170
+ dana, omer = sheet.locator(".mx-report-student").nth(0), sheet.locator(".mx-report-student").nth(1)
171
+ playwright.expect(dana.locator("summary")).to_contain_text("בוחן 85")
172
+ playwright.expect(dana.locator("summary")).to_contain_text("תרגול 9/12")
173
+ playwright.expect(omer).to_contain_text("עוד לא נכנס לחשבון")
174
+ dana.locator("summary").click()
175
+ playwright.expect(dana.locator(".mx-report-table").first).to_contain_text("85/100")
176
+ playwright.expect(dana.locator(".mx-report-table").nth(1)).to_contain_text("75")
177
+ page.locator(".mx-report-sheet .mx-x").click()
178
+ playwright.expect(sheet).to_be_hidden()
179
+ finally:
180
+ browser.close()
181
+
182
+
183
+ def test_class_editor_picks_the_teacher_from_the_students(phone_server):
184
+ import json
185
+
186
+ row = {"id": 7, "name": "ט׳3", "lessons": ["t"], "emails": ["a@example.com", "b@example.com"],
187
+ "teachers": ["b@example.com"], "exams": []}
188
+ writes = []
189
+ with playwright.sync_playwright() as p:
190
+ browser = p.chromium.launch()
191
+ try:
192
+ page = browser.new_page()
193
+ page.route("https://cdn.jsdelivr.net/**", lambda route: route.abort())
194
+ page.add_init_script('''window.supabase={createClient:()=>({auth:{
195
+ getSession:async()=>({data:{session:{access_token:"test",user:{id:"test"}}}}),
196
+ onAuthStateChange:()=>{},signOut:async()=>{}
197
+ }})};''')
198
+
199
+ def index(route):
200
+ response = route.fetch()
201
+ route.fulfill(response=response, body=response.text().replace(
202
+ 'supabaseUrl:""', 'supabaseUrl:"https://auth.invalid"'))
203
+
204
+ def save(route):
205
+ writes.append(json.loads(route.request.post_data))
206
+ route.fulfill(json={"ok": True})
207
+
208
+ page.route(phone_server + "/", index)
209
+ page.route("**/api/me", lambda route: route.fulfill(json={
210
+ "email": "boss@example.com", "user_id": "test", "answer_scans": False,
211
+ "permissions": ["management.access", "classes.manage"], "classes": []}))
212
+ page.route("**/api/math/progress", lambda route: route.fulfill(json={"progress": {}}))
213
+ page.route("**/api/classes", lambda route: route.fulfill(json={"classes": [row]}))
214
+ page.route("**/api/classes/7", save)
215
+ page.goto(phone_server)
216
+ page.locator('[data-act="manage"]').click()
217
+ playwright.expect(page.locator(".mx-class-card")).to_contain_text("b@example.com")
218
+ page.get_by_role("button", name="עריכה").click()
219
+ teacher = page.locator('select[name="teacher"]')
220
+ playwright.expect(teacher).to_have_value("b@example.com")
221
+ assert teacher.locator("option").all_inner_texts() == ["ללא מורה", "a@example.com", "b@example.com"]
222
+ page.locator('textarea[name="emails"]').fill("a@example.com\nb@example.com\nc@example.com")
223
+ playwright.expect(teacher.locator("option")).to_have_count(4)
224
+ playwright.expect(teacher).to_have_value("b@example.com") # the choice stays while the list grows
225
+ teacher.select_option("c@example.com")
226
+ page.get_by_role("button", name="שמירת השינויים").click()
227
+ playwright.expect(page.locator(".mx-manage-state")).not_to_have_text("שומרים…")
228
+ assert writes[-1]["teachers"] == ["c@example.com"]
229
+ assert writes[-1]["emails"] == ["a@example.com", "b@example.com", "c@example.com"]
230
+ finally:
231
+ browser.close()
tests/test_unit_quiz.py CHANGED
@@ -256,7 +256,7 @@ def test_a_unit_quiz_answer_is_handed_in_from_the_board(quiz_server):
256
  playwright.expect(page.locator(".mx-board")).to_be_visible()
257
  playwright.expect(page.locator('.mx-board-pages [data-page="2"]')).to_have_attribute("aria-selected", "true")
258
  send = page.locator(".mx-board-send")
259
- playwright.expect(send).to_have_text("📤הגשה לשאלה 3")
260
  send.click() # an empty page is not handed in
261
  playwright.expect(send).to_contain_text("הלוח ריק")
262
  draw_line(page)
 
256
  playwright.expect(page.locator(".mx-board")).to_be_visible()
257
  playwright.expect(page.locator('.mx-board-pages [data-page="2"]')).to_have_attribute("aria-selected", "true")
258
  send = page.locator(".mx-board-send")
259
+ playwright.expect(send).to_have_text("הגשה לשאלה 3")
260
  send.click() # an empty page is not handed in
261
  playwright.expect(send).to_contain_text("הלוח ריק")
262
  draw_line(page)