wzender Claude Opus 5.5 commited on
Commit
2067656
·
1 Parent(s): aada0ab

AI review per question; the mistakes round shows where each question came from; a 15-question practice test on the weak topics

Browse files

- Board review: one Gemini request per question (paced for the free tier), each result saved and shown as it comes, then a short
text-only summary. A stopping failure (quota, key) keeps what was checked; the teacher can check or re-check one question alone.
- Mistakes round: each question shows its number in the test and what was chosen there.
- Exercise test 3 (מבחן תרגול: נושאים לחיזוק): 15 questions from the skills that lost points in the test's exercise tests or simulation,
or are still being learned. Not part of the readiness score. On the test card and on a result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

mathly2/boardreview.py CHANGED
@@ -32,26 +32,23 @@ RETRIES = 3 # tries after "too many reques
32
  SIDE = 1280 # a board is shrunk to this longest side before it is sent
33
  VERDICTS = ("correct", "partial", "wrong", "unclear")
34
 
35
- INSTRUCTIONS = """אתם מורים למתמטיקה בכיתה ט בישראל. תלמיד פתר מבחן אמריקאי, ולכל שאלה כתב את הדרך על לוח. קיבלתם את השאלות, התשובות האפשריות, \
36
- התשובה הנכונה (רק בשבילכם), התשובה שהתלמיד בחר, ותמונה של הלוח שלו לכל שאלה.
37
- לכל שאלה עם לוח, הַעריכו את הדרך (לא רק את התשובה הסופית):
38
  - verdict: "correct" אם הדרך נכונה ומובילה לתשובה; "partial" אם יש התחלה נכונה או רעיון נכון אבל טעות או חוסר; "wrong" אם השיטה שגויה; "unclear" אם אי אפשר לקרוא או שאין בלוח דרך של ממש.
39
  - feedback: משפט או שניים לתלמיד, בעברית, בפנייה ברבים (אתם), חם וענייני: מה טוב בדרך, ואם יש טעות, איפה היא הטעות הראשונה ומה סוגה.
40
  - tip: טיפ אחד קצר ומעשי לפעם הבאה.
41
  אסור לכתוב את התשובה הסופית הנכונה, את המספר שלה או איזו אפשרות נכונה. אם התלמיד טעה, כוונו אותו לשלב, אל תפתרו בשבילו.
42
- summary: משפט או שניים על הדרך במבחן כולו: חוזקה אחת ודבר אחד לשפר.
43
  נוסחאות כתבו בתוך $...$."""
44
 
45
- SCHEMA = {
46
- "type": "OBJECT",
47
- "properties": {
48
- "summary": {"type": "STRING"},
49
- "questions": {"type": "ARRAY", "items": {"type": "OBJECT", "properties": {
50
- "n": {"type": "INTEGER"}, "verdict": {"type": "STRING", "enum": list(VERDICTS)},
51
- "feedback": {"type": "STRING"}, "tip": {"type": "STRING"}}, "required": ["n", "verdict", "feedback", "tip"]}},
52
- },
53
- "required": ["summary", "questions"],
54
- }
55
 
56
 
57
  class ReviewFailure(Exception):
@@ -76,19 +73,18 @@ def _letter(i: int) -> str:
76
  class GeminiReviewer:
77
  """Gemini over its REST API (no SDK). `review(items)` -> {"summary", "questions": [{n, verdict, feedback, tip}]}."""
78
 
79
- def __init__(self, key: str, model: str = "", sleep=time.sleep) -> None:
80
- self.key, self.model, self.sleep = key, clean_model(model) or DEFAULT_MODEL, sleep
 
81
  self._lock = threading.Lock() # one request at a time: the free tier counts requests a minute
82
 
83
- def _parts(self, items: list[dict]) -> list[dict]:
84
- parts = [{"text": INSTRUCTIONS}]
85
- for it in items:
86
- choices = "\n".join(f" {_letter(i)}. {c}" for i, c in enumerate(it["choices"]))
87
- picked = _letter(it["picked"]) if it["picked"] is not None else "לא ענה"
88
- parts.append({"text": f"\n\nשאלה {it['n']}:\n{it['question']}\nאפשרויות:\n{choices}\nהתשובה הנכונה (לא לגלות): {_letter(it['correct'])}\n"
89
- f"התלמיד בחר: {picked}\nהלוח של התלמיד לשאלה {it['n']}:"})
90
- parts.append({"inline_data": {"mime_type": "image/jpeg", "data": base64.b64encode(_small(it["image"])).decode()}})
91
- return parts
92
 
93
  def _call(self, body: bytes) -> dict:
94
  request = urllib.request.Request(ENDPOINT.format(model=self.model), data=body, method="POST",
@@ -111,11 +107,24 @@ class GeminiReviewer:
111
  return None
112
  return pick_model(names, self.model)
113
 
114
- def review(self, items: list[dict], on_event=lambda step, **info: None) -> dict:
115
- body = json.dumps({"contents": [{"role": "user", "parts": self._parts(items)}],
116
- "generationConfig": {"temperature": 0.2, "responseMimeType": "application/json", "responseSchema": SCHEMA}}).encode()
 
 
 
 
 
 
 
 
 
117
  with self._lock:
118
  for attempt in range(RETRIES + 1):
 
 
 
 
119
  try:
120
  data = self._call(body)
121
  break
@@ -125,7 +134,7 @@ class GeminiReviewer:
125
  wait = _retry_after(detail, 20 * (attempt + 1))
126
  on_event("busy", seconds=round(wait))
127
  self.sleep(wait)
128
- on_event("sent")
129
  continue
130
  if exc.code == 404 and not getattr(self, "_switched", False):
131
  other = self._another_model()
@@ -137,12 +146,16 @@ class GeminiReviewer:
137
  429: "rate_limited"}.get(exc.code, "provider_error"), f"HTTP {exc.code} ({self.model}): {detail}") from None
138
  except (urllib.error.URLError, TimeoutError) as exc:
139
  raise ReviewFailure("no_connection", str(exc)[:300]) from None
 
 
140
  try:
141
  candidate = data["candidates"][0]
142
  if candidate.get("finishReason") not in (None, "STOP"):
143
  raise ReviewFailure("incomplete", str(candidate.get("finishReason")))
144
  text = "".join(p.get("text", "") for p in candidate["content"]["parts"] if not p.get("thought"))
145
  out = json.loads(text)
 
 
146
  except ReviewFailure:
147
  raise
148
  except (KeyError, IndexError, ValueError, TypeError) as exc:
@@ -202,48 +215,79 @@ def _items(app, row: dict) -> tuple[list[dict], dict]:
202
  continue
203
  correct = next((c["index"] for c in served["choices"] if (bank.grade(q["skill_id"], q["key"], c["index"]) or {}).get("correct")), 0)
204
  picked = row["answers"].get(q["key"])
205
- items.append({"n": n, "question": served["question"], "choices": [c["text"] for c in served["choices"]], "correct": correct,
 
206
  "picked": picked if isinstance(picked, int) else None, "image": app.state.pictures.get(picture["path"])})
207
  keys[n] = q["key"]
208
  return items, keys
209
 
210
 
211
- def run(app, attempt_id: str) -> None:
212
- """Review one handed-in attempt and store the result on it. Never raises: a failure is stored as {"state": "failed"}.
213
- While it runs, its steps are stored on the pending review (`events`: boards found, sent, the AI busy and waiting), for the screens."""
214
- store = app.state.store
 
 
 
 
 
 
 
215
  started = clock.now().isoformat()
216
  events: list[dict] = []
 
 
 
217
 
218
- def event(step: str, **info) -> None:
219
- events.append({"step": step, "at": clock.now().isoformat(), **info})
220
  try:
221
- store.save_attempt(attempt_id, review={"state": "pending", "at": started, "events": list(events)})
222
  except Exception: # the steps are a courtesy: never fail the review for them
223
  log.warning("could not save a review step")
 
 
 
 
 
 
224
  try:
225
- row = store.get_attempt(attempt_id)
226
- items, keys = _items(app, row)
 
227
  event("boards", count=len(items), total=len(row["questions"]))
228
- if not items:
229
- result = {"state": "done", "summary": None, "questions": {}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  else:
231
- event("sent")
232
- got = app.state.reviewer.review(items, on_event=event)
233
- questions = {}
234
- for q in got.get("questions", []):
235
- key = keys.get(q.get("n"))
236
- if key and q.get("verdict") in VERDICTS:
237
- questions[key] = {"verdict": q["verdict"], "feedback": str(q.get("feedback", ""))[:600], "tip": str(q.get("tip", ""))[:300]}
238
- result = {"state": "done", "summary": str(got.get("summary") or "")[:800] or None, "questions": questions}
239
- result["at"] = clock.now().isoformat()
240
- except ReviewFailure as exc:
241
- log.warning("board review failed: %s %s", exc.reason, exc.detail)
242
- result = {"state": "failed", "reason": exc.reason, "at": clock.now().isoformat(),
243
- "model": getattr(app.state.reviewer, "model", None)}
244
  except Exception:
245
  log.exception("board review failed")
246
- result = {"state": "failed", "reason": "provider_error", "at": clock.now().isoformat()}
 
247
  try:
248
  store.save_attempt(attempt_id, review=result)
249
  finally:
@@ -251,11 +295,11 @@ def run(app, attempt_id: str) -> None:
251
  _running.discard(attempt_id)
252
 
253
 
254
- def start_in_thread(app, attempt_id: str) -> None:
255
- threading.Thread(target=run, args=(app, attempt_id), name="board-review", daemon=True).start()
256
 
257
 
258
- def ensure(app, row: dict, force: bool = False) -> dict:
259
  """For a handed-in practice test or simulation: start its review if it has none yet (or a lost one). Returns the row as it is now.
260
  `force` (the teacher asked): any graded attempt, and again after a failed or a finished review."""
261
  if row["submitted_at"] is None or getattr(app.state, "reviewer", None) is None:
@@ -271,13 +315,18 @@ def ensure(app, row: dict, force: bool = False) -> dict:
271
  return row
272
  _running.add(row["id"])
273
  try:
274
- row = app.state.store.save_attempt(row["id"], review={"state": "pending", "at": clock.now().isoformat()})
 
 
275
  except Exception: # e.g. the review column's migration was not run: the result still shows
276
  log.exception("board review could not be saved")
277
  with _running_lock:
278
  _running.discard(row["id"])
279
  return {**row, "review": {"state": "failed", "reason": "database"}}
280
- app.state.run_review(app, row["id"])
 
 
 
281
  return app.state.store.get_attempt(row["id"]) or row
282
 
283
 
@@ -287,7 +336,7 @@ def student_view(row: dict) -> dict | None:
287
  if not review:
288
  return None
289
  if review["state"] == "pending":
290
- return {"state": "pending", "at": review.get("at"), "events": review.get("events", [])}
291
  if review["state"] != "done":
292
  return {"state": review["state"]}
293
  return {"state": "done", "summary": review.get("summary"), "questions": review.get("questions", {})}
 
32
  SIDE = 1280 # a board is shrunk to this longest side before it is sent
33
  VERDICTS = ("correct", "partial", "wrong", "unclear")
34
 
35
+ INSTRUCTIONS = """אתם מורים למתמטיקה בכיתה ט בישראל. תלמיד פתר שאלה אמריקאית במבחן, וכתב את הדרך על לוח. קיבלתם את השאלה, התשובות האפשריות, \
36
+ התשובה הנכונה (רק בשבילכם), התשובה שהתלמיד בחר, ותמונה של הלוח שלו.
37
+ הַעריכו את הדרך (לא רק את התשובה הסופית):
38
  - verdict: "correct" אם הדרך נכונה ומובילה לתשובה; "partial" אם יש התחלה נכונה או רעיון נכון אבל טעות או חוסר; "wrong" אם השיטה שגויה; "unclear" אם אי אפשר לקרוא או שאין בלוח דרך של ממש.
39
  - feedback: משפט או שניים לתלמיד, בעברית, בפנייה ברבים (אתם), חם וענייני: מה טוב בדרך, ואם יש טעות, איפה היא הטעות הראשונה ומה סוגה.
40
  - tip: טיפ אחד קצר ומעשי לפעם הבאה.
41
  אסור לכתוב את התשובה הסופית הנכונה, את המספר שלה או איזו אפשרות נכונה. אם התלמיד טעה, כוונו אותו לשלב, אל תפתרו בשבילו.
 
42
  נוסחאות כתבו בתוך $...$."""
43
 
44
+ SUMMARY_INSTRUCTIONS = """אתם מורים למתמטיקה בכיתה ט. לפניכם הערכות של הדרך של תלמיד בשאלות של מבחן אחד (לכל שאלה: הנושא, הערכה ומשוב).
45
+ כתבו summary: משפט או שניים לתלמיד, בעברית, בפנייה ברבים (אתם): חוזקה אחת בדרך ודבר אחד לשפר. בלי תשובות נכונות ובלי מספרים של תשובות."""
46
+
47
+ SCHEMA = {"type": "OBJECT", "properties": {"verdict": {"type": "STRING", "enum": list(VERDICTS)}, "feedback": {"type": "STRING"}, "tip": {"type": "STRING"}},
48
+ "required": ["verdict", "feedback", "tip"]}
49
+ SUMMARY_SCHEMA = {"type": "OBJECT", "properties": {"summary": {"type": "STRING"}}, "required": ["summary"]}
50
+ STOPPING = ("rate_limited", "api_key_invalid", "permission", "model_not_found", "no_connection") # no point in asking about the next question
51
+ MIN_GAP = 5.0 # seconds between requests: the free tier counts requests a minute
 
 
52
 
53
 
54
  class ReviewFailure(Exception):
 
73
  class GeminiReviewer:
74
  """Gemini over its REST API (no SDK). `review(items)` -> {"summary", "questions": [{n, verdict, feedback, tip}]}."""
75
 
76
+ def __init__(self, key: str, model: str = "", sleep=time.sleep, gap: float = MIN_GAP, now=time.monotonic) -> None:
77
+ self.key, self.model, self.sleep, self.gap, self.now = key, clean_model(model) or DEFAULT_MODEL, sleep, gap, now
78
+ self._last = None
79
  self._lock = threading.Lock() # one request at a time: the free tier counts requests a minute
80
 
81
+ def _parts(self, item: dict) -> list[dict]:
82
+ choices = "\n".join(f" {_letter(i)}. {c}" for i, c in enumerate(item["choices"]))
83
+ picked = _letter(item["picked"]) if item["picked"] is not None else "לא ענה"
84
+ return [{"text": INSTRUCTIONS},
85
+ {"text": f"\n\nהשאלה:\n{item['question']}\nאפשרויות:\n{choices}\nהתשובה הנכונה (לא לגלות): {_letter(item['correct'])}\n"
86
+ f"התלמיד בחר: {picked}\nהלוח של התלמיד:"},
87
+ {"inline_data": {"mime_type": "image/jpeg", "data": base64.b64encode(_small(item["image"])).decode()}}]
 
 
88
 
89
  def _call(self, body: bytes) -> dict:
90
  request = urllib.request.Request(ENDPOINT.format(model=self.model), data=body, method="POST",
 
107
  return None
108
  return pick_model(names, self.model)
109
 
110
+ def review_one(self, item: dict, on_event=lambda step, **info: None) -> dict:
111
+ """One question with its board -> {verdict, feedback, tip}."""
112
+ return self._ask(self._parts(item), SCHEMA, on_event)
113
+
114
+ def summarize(self, reviewed: list[dict], on_event=lambda step, **info: None) -> str:
115
+ """A line about the way in the whole test, from the per-question reviews (text only, no pictures)."""
116
+ lines = "\n".join(f"שאלה {r['n']} ({r.get('skill', '')}): {r['verdict']}. {r['feedback']}" for r in reviewed)
117
+ return str(self._ask([{"text": SUMMARY_INSTRUCTIONS}, {"text": "\n\n" + lines}], SUMMARY_SCHEMA, on_event).get("summary") or "")
118
+
119
+ def _ask(self, parts: list[dict], schema: dict, on_event) -> dict:
120
+ body = json.dumps({"contents": [{"role": "user", "parts": parts}],
121
+ "generationConfig": {"temperature": 0.2, "responseMimeType": "application/json", "responseSchema": schema}}).encode()
122
  with self._lock:
123
  for attempt in range(RETRIES + 1):
124
+ if self._last is not None and self.gap: # keep under the free tier's requests a minute
125
+ left = self.gap - (self.now() - self._last)
126
+ if left > 0:
127
+ self.sleep(left)
128
  try:
129
  data = self._call(body)
130
  break
 
134
  wait = _retry_after(detail, 20 * (attempt + 1))
135
  on_event("busy", seconds=round(wait))
136
  self.sleep(wait)
137
+ on_event("resumed")
138
  continue
139
  if exc.code == 404 and not getattr(self, "_switched", False):
140
  other = self._another_model()
 
146
  429: "rate_limited"}.get(exc.code, "provider_error"), f"HTTP {exc.code} ({self.model}): {detail}") from None
147
  except (urllib.error.URLError, TimeoutError) as exc:
148
  raise ReviewFailure("no_connection", str(exc)[:300]) from None
149
+ finally:
150
+ self._last = self.now()
151
  try:
152
  candidate = data["candidates"][0]
153
  if candidate.get("finishReason") not in (None, "STOP"):
154
  raise ReviewFailure("incomplete", str(candidate.get("finishReason")))
155
  text = "".join(p.get("text", "") for p in candidate["content"]["parts"] if not p.get("thought"))
156
  out = json.loads(text)
157
+ if not isinstance(out, dict):
158
+ raise ValueError("not an object")
159
  except ReviewFailure:
160
  raise
161
  except (KeyError, IndexError, ValueError, TypeError) as exc:
 
215
  continue
216
  correct = next((c["index"] for c in served["choices"] if (bank.grade(q["skill_id"], q["key"], c["index"]) or {}).get("correct")), 0)
217
  picked = row["answers"].get(q["key"])
218
+ items.append({"n": n, "skill": (bank.skill(q["skill_id"]) or {}).get("name", ""),
219
+ "question": served["question"], "choices": [c["text"] for c in served["choices"]], "correct": correct,
220
  "picked": picked if isinstance(picked, int) else None, "image": app.state.pictures.get(picture["path"])})
221
  keys[n] = q["key"]
222
  return items, keys
223
 
224
 
225
+ def _clean(got: dict) -> dict | None:
226
+ if not isinstance(got, dict) or got.get("verdict") not in VERDICTS:
227
+ return None
228
+ return {"verdict": got["verdict"], "feedback": str(got.get("feedback", ""))[:600], "tip": str(got.get("tip", ""))[:300]}
229
+
230
+
231
+ def run(app, attempt_id: str, keys: list[str] | None = None) -> None:
232
+ """Review a handed-in attempt question by question (`keys`: only these, kept with the earlier review of the rest), then a summary line.
233
+ Never raises: a review with no question checked is stored as {"state": "failed", "reason"}. While it runs, the pending review carries
234
+ its steps (`events`) and the questions checked so far, for the screens."""
235
+ store, reviewer = app.state.store, app.state.reviewer
236
  started = clock.now().isoformat()
237
  events: list[dict] = []
238
+ row = store.get_attempt(attempt_id)
239
+ earlier = (row.get("review") or {}) if row and keys else {} # one question: the rest of the review (kept on the pending one) stays
240
+ questions: dict = dict(earlier.get("questions") or {})
241
 
242
+ def save(review: dict) -> None:
 
243
  try:
244
+ store.save_attempt(attempt_id, review=review)
245
  except Exception: # the steps are a courtesy: never fail the review for them
246
  log.warning("could not save a review step")
247
+
248
+ def event(step: str, **info) -> None:
249
+ events.append({"step": step, "at": clock.now().isoformat(), **info})
250
+ save({"state": "pending", "at": started, "events": list(events), "questions": dict(questions), "summary": earlier.get("summary")})
251
+
252
+ reason, checked = None, 0
253
  try:
254
+ items, by_n = _items(app, row)
255
+ if keys:
256
+ items = [it for it in items if by_n[it["n"]] in keys]
257
  event("boards", count=len(items), total=len(row["questions"]))
258
+ reviewed = []
259
+ for i, item in enumerate(items):
260
+ key = by_n[item["n"]]
261
+ event("question", n=item["n"], i=i + 1, of=len(items))
262
+ try:
263
+ got = _clean(reviewer.review_one(item, on_event=event))
264
+ except ReviewFailure as exc:
265
+ log.warning("board review of a question failed: %s %s", exc.reason, exc.detail)
266
+ reason = exc.reason
267
+ if exc.reason in STOPPING:
268
+ break
269
+ continue
270
+ if got is None:
271
+ reason = "bad_output"
272
+ continue
273
+ questions[key], checked = got, checked + 1
274
+ reviewed.append({"n": item["n"], "skill": item["skill"], **got})
275
+ summary = earlier.get("summary")
276
+ if reviewed and not keys:
277
+ event("summary")
278
+ try:
279
+ summary = reviewer.summarize(reviewed, on_event=event)[:800] or None
280
+ except ReviewFailure as exc:
281
+ log.warning("board review summary failed: %s", exc.reason)
282
+ if items and not checked:
283
+ result = {"state": "failed", "reason": reason or "provider_error", "model": getattr(reviewer, "model", None)}
284
  else:
285
+ result = {"state": "done", "summary": summary, "questions": questions, "missing": len(items) - checked,
286
+ **({"reason": reason} if reason and checked < len(items) else {})}
 
 
 
 
 
 
 
 
 
 
 
287
  except Exception:
288
  log.exception("board review failed")
289
+ result = {"state": "failed", "reason": "provider_error"}
290
+ result["at"] = clock.now().isoformat()
291
  try:
292
  store.save_attempt(attempt_id, review=result)
293
  finally:
 
295
  _running.discard(attempt_id)
296
 
297
 
298
+ def start_in_thread(app, attempt_id: str, keys: list[str] | None = None) -> None:
299
+ threading.Thread(target=run, args=(app, attempt_id, keys), name="board-review", daemon=True).start()
300
 
301
 
302
+ def ensure(app, row: dict, force: bool = False, keys: list[str] | None = None) -> dict:
303
  """For a handed-in practice test or simulation: start its review if it has none yet (or a lost one). Returns the row as it is now.
304
  `force` (the teacher asked): any graded attempt, and again after a failed or a finished review."""
305
  if row["submitted_at"] is None or getattr(app.state, "reviewer", None) is None:
 
315
  return row
316
  _running.add(row["id"])
317
  try:
318
+ earlier = row.get("review") or {}
319
+ row = app.state.store.save_attempt(row["id"], review={"state": "pending", "at": clock.now().isoformat(),
320
+ **({"questions": earlier.get("questions", {}), "summary": earlier.get("summary")} if keys else {})})
321
  except Exception: # e.g. the review column's migration was not run: the result still shows
322
  log.exception("board review could not be saved")
323
  with _running_lock:
324
  _running.discard(row["id"])
325
  return {**row, "review": {"state": "failed", "reason": "database"}}
326
+ if keys:
327
+ app.state.run_review(app, row["id"], keys)
328
+ else:
329
+ app.state.run_review(app, row["id"])
330
  return app.state.store.get_attempt(row["id"]) or row
331
 
332
 
 
336
  if not review:
337
  return None
338
  if review["state"] == "pending":
339
+ return {"state": "pending", "at": review.get("at"), "events": review.get("events", []), "questions": review.get("questions", {})}
340
  if review["state"] != "done":
341
  return {"state": review["state"]}
342
  return {"state": "done", "summary": review.get("summary"), "questions": review.get("questions", {})}
mathly2/boardwork.py CHANGED
@@ -89,7 +89,9 @@ def _title(request: Request, row: dict) -> str:
89
  test_id, _, number = row["scope"].partition(":")
90
  test = store.get_test(int(test_id)) if test_id.isdigit() else None
91
  when = f" ({test['date'].day}.{test['date'].month})" if test else ""
92
- return (f"מבחן תרגול {number}" if row["kind"] == "exercise_test" else "מבחן סימולציה") + when
 
 
93
 
94
 
95
  def _iso(value):
@@ -151,7 +153,7 @@ def _ai_state(request: Request, row: dict) -> dict:
151
  review = row.get("review") or {}
152
  return {"available": getattr(request.app.state, "reviewer", None) is not None, "state": review.get("state"),
153
  "reason": review.get("reason"), "summary": review.get("summary"), "model": review.get("model"),
154
- "at": review.get("at"), "events": review.get("events", [])}
155
 
156
 
157
  @router.get("/teaching/work/{attempt_id}")
@@ -161,12 +163,18 @@ def work_open(attempt_id: str, request: Request, user: dict = Depends(need("clas
161
 
162
 
163
  @router.post("/teaching/work/{attempt_id}/review")
164
- def work_review(attempt_id: str, request: Request, user: dict = Depends(need("class.teach"))) -> dict:
165
- """The teacher asks for the AI review (again): after a failure, for a lesson quiz, or for a fresh look."""
 
166
  from . import boardreview
167
  if getattr(request.app.state, "reviewer", None) is None:
168
  raise HTTPException(409, "בדיקה עם AI לא מוגדרת בשרת: חסר GEMINI_API_KEY")
169
- return teacher_view(request, boardreview.ensure(request.app, _graded_attempt(request, user, attempt_id), force=True))
 
 
 
 
 
170
 
171
 
172
  class Credit(BaseModel):
 
89
  test_id, _, number = row["scope"].partition(":")
90
  test = store.get_test(int(test_id)) if test_id.isdigit() else None
91
  when = f" ({test['date'].day}.{test['date'].month})" if test else ""
92
+ from .exam import FOCUS
93
+ name = ("מבחן תרגול: נושאים לחיזוק" if number == str(FOCUS) else f"מבחן תרגול {number}") if row["kind"] == "exercise_test" else "מבחן סימולציה"
94
+ return name + when
95
 
96
 
97
  def _iso(value):
 
153
  review = row.get("review") or {}
154
  return {"available": getattr(request.app.state, "reviewer", None) is not None, "state": review.get("state"),
155
  "reason": review.get("reason"), "summary": review.get("summary"), "model": review.get("model"),
156
+ "at": review.get("at"), "events": review.get("events", []), "missing": review.get("missing", 0)}
157
 
158
 
159
  @router.get("/teaching/work/{attempt_id}")
 
163
 
164
 
165
  @router.post("/teaching/work/{attempt_id}/review")
166
+ def work_review(attempt_id: str, request: Request, key: str | None = Query(None, max_length=200),
167
+ user: dict = Depends(need("class.teach"))) -> dict:
168
+ """The teacher asks for the AI review (again): of the whole attempt, or of one question (`key`)."""
169
  from . import boardreview
170
  if getattr(request.app.state, "reviewer", None) is None:
171
  raise HTTPException(409, "בדיקה עם AI לא מוגדרת בשרת: חסר GEMINI_API_KEY")
172
+ row = _graded_attempt(request, user, attempt_id)
173
+ if key is not None and key not in {q["key"] for q in row["questions"]}:
174
+ raise HTTPException(404, "השאלה לא נמצאה")
175
+ if key is not None and key not in request.app.state.store.work_of(row["user_id"], row["id"]):
176
+ raise HTTPException(409, "אין לוח לשאלה הזו")
177
+ return teacher_view(request, boardreview.ensure(request.app, row, force=True, keys=[key] if key else None))
178
 
179
 
180
  class Credit(BaseModel):
mathly2/exam.py CHANGED
@@ -5,6 +5,8 @@ from __future__ import annotations
5
  import random
6
 
7
  EXERCISE_SHARE = {1: 0.6, 2: 0.3} # the share of questions that come from weak skills in exercise test 1 and 2; the simulation has none
 
 
8
 
9
 
10
  def _round(x: float) -> int:
@@ -55,3 +57,21 @@ def draw(bank, lessons: list[str], states: dict[str, str], n: int, *, share: flo
55
  progress = True
56
  rng.shuffle(taken)
57
  return {"questions": taken[:n], "weak": weak, "practice_first": practice_first}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import random
6
 
7
  EXERCISE_SHARE = {1: 0.6, 2: 0.3} # the share of questions that come from weak skills in exercise test 1 and 2; the simulation has none
8
+ FOCUS = 3 # the exercise test on the weak topics: only the skills that lost points or are still being learned
9
+ FOCUS_SIZE = 15
10
 
11
 
12
  def _round(x: float) -> int:
 
57
  progress = True
58
  rng.shuffle(taken)
59
  return {"questions": taken[:n], "weak": weak, "practice_first": practice_first}
60
+
61
+
62
+ def draw_focus(bank, weak: list[str], n: int = FOCUS_SIZE, used: dict[str, int] | None = None, rng: random.Random | None = None) -> list[dict]:
63
+ """`n` questions from the weak skills only, one per skill round after round (least-used questions first)."""
64
+ used, rng = used or {}, rng or random.Random()
65
+ pools = {i: sorted(bank.question_keys(i), key=lambda key: (used.get(key, 0), rng.random())) for i in weak if bank.question_keys(i)}
66
+ order, taken, seen = rng.sample(list(pools), len(pools)), [], set()
67
+ progress = True
68
+ while len(taken) < n and progress:
69
+ progress = False
70
+ for skill_id in order:
71
+ key = next((k for k in pools[skill_id] if k not in seen), None)
72
+ if key and len(taken) < n:
73
+ seen.add(key)
74
+ taken.append({"skill_id": skill_id, "key": key})
75
+ progress = True
76
+ rng.shuffle(taken)
77
+ return taken
mathly2/realtests.py CHANGED
@@ -82,9 +82,10 @@ def facts_for(request: Request, user_id: str | None, test: dict, today: date) ->
82
  rows = here.attempts_of(user_id, kind) if user_id else []
83
  return [attempts.settle(store, bank, r, now) for r in rows if r["scope"] == tid or r["scope"].startswith(tid + ":")]
84
 
85
- exercise = {1: [], 2: []}
86
  for row in mine("exercise_test"):
87
- exercise[int(row["scope"].split(":")[1])].append(row)
 
88
  simulation = next(iter(mine("simulation")), None)
89
  submitted = lambda rows: [r for r in rows if not attempts.is_open(r)]
90
  best = {n: max((r["grade"] for r in submitted(rows)), default=None) for n, rows in exercise.items()}
@@ -100,14 +101,20 @@ def facts_for(request: Request, user_id: str | None, test: dict, today: date) ->
100
  sim_taken = bool(simulation and not attempts.is_open(simulation))
101
  stale = sorted(covered, key=lambda i: (last.get(i) is not None, last.get(i) or date.min))
102
  used: dict[str, int] = {}
103
- for rows in list(exercise.values()) + [[simulation] if simulation else []]:
104
  for r in rows:
105
  for q in r["questions"]:
106
  used[q["key"]] = used.get(q["key"], 0) + 1
107
  return {"covered": covered, "states": states, "mastered": len(mastered), "path_done": bool(covered) and not unmastered,
108
  "next_path_skill": unmastered[0] if unmastered else None, "exercise": exercise, "best": best, "simulation": simulation,
109
  "sim_taken": sim_taken, "ex1_done": bool(submitted(exercise[1])), "ex2_done": bool(submitted(exercise[2])),
110
- "weak_before": weak_before, "failed": failed, "review_left": review_left, "stale": stale, "used": used}
 
 
 
 
 
 
111
 
112
 
113
  def readiness_for(facts: dict) -> dict:
@@ -135,11 +142,15 @@ def card(request: Request, user_id: str | None, test: dict, today: date) -> dict
135
  "open": bool(open_), "due_at": open_["due_at"].isoformat() if open_ and attempts.is_timed(open_) else None})
136
  sim = facts["simulation"]
137
  opens_in = max(0, days_left - p["sim"])
 
 
 
 
138
  return {"id": test["id"], "date": test["date"].isoformat(), "days_left": days_left, "note": test["note"], "subject_id": test.get("subject_id", "math"),
139
  "lessons": [{"id": i, "name": n} for i, n in zip(test["lessons"], lesson_names(request, test["lessons"]))],
140
  "questions": test["questions"], "minutes": test["minutes"],
141
  "plan": countdown.outline(p, facts), "task": countdown.task(days_left, p, facts), "readiness": readiness_for(facts),
142
- "exercise_tests": exercises,
143
  "practice_first": [{"id": i, "name": bank.skill(i)["name"]} for i in facts["covered"] if facts["states"].get(i, "not_started") == "not_started"],
144
  "simulation": {"open_now": countdown.sim_open(days_left, p), "opens_in_days": opens_in,
145
  "opens_on": (test["date"] - timedelta(days=p["sim"])).isoformat(), "required": countdown.sim_open(days_left, p) and not facts["sim_taken"],
@@ -313,10 +324,16 @@ def _start(request: Request, user: dict, test: dict, kind: str, scope: str, shar
313
  from .quizzes import view
314
  store, bank, now = request.app.state.store, request.app.state.bank, clock.now()
315
  facts = facts_for(request, user["id"], test, clock.school_day())
316
- mine = [r for r in (facts["exercise"][int(scope.split(":")[1])] if kind == "exercise_test" else [facts["simulation"]] if facts["simulation"] else [])]
 
317
  for row in mine:
318
  if attempts.is_open(row) or kind == "simulation": # open: resume; the simulation is taken once, so its result comes back
319
  return view(request, row)
 
 
 
 
 
320
  drawn = exam.draw(bank, test["lessons"], facts["states"], test["questions"], share=share, weak_before=facts["weak_before"], used=facts["used"],
321
  include_unstarted=include_unstarted)
322
  if not drawn["questions"] and kind == "exercise_test" and not include_unstarted: # nothing started yet: all the material, like the simulation
@@ -331,9 +348,11 @@ def _start(request: Request, user: dict, test: dict, kind: str, scope: str, shar
331
  @router.post("/tests/{test_id}/exercise/{number}/start")
332
  def start_exercise(test_id: int, number: int, request: Request, user: dict = Depends(learner),
333
  all_skills: bool = Query(False, description="the simulation's material: every skill of the test's lessons, started or not, evenly")) -> dict:
334
- if number not in exam.EXERCISE_SHARE:
335
  raise HTTPException(404, "מבחן התרגול לא נמצא")
336
  test = _student_test(request, user, test_id)
 
 
337
  share = 0.0 if all_skills else exam.EXERCISE_SHARE[number] # all_skills: drawn like the simulation (an even spread, no weak share)
338
  return _start(request, user, test, "exercise_test", f"{test_id}:{number}", share, all_skills)
339
 
 
82
  rows = here.attempts_of(user_id, kind) if user_id else []
83
  return [attempts.settle(store, bank, r, now) for r in rows if r["scope"] == tid or r["scope"].startswith(tid + ":")]
84
 
85
+ exercise, focus = {1: [], 2: []}, []
86
  for row in mine("exercise_test"):
87
+ number = int(row["scope"].split(":")[1])
88
+ (focus if number == exam.FOCUS else exercise[number]).append(row)
89
  simulation = next(iter(mine("simulation")), None)
90
  submitted = lambda rows: [r for r in rows if not attempts.is_open(r)]
91
  best = {n: max((r["grade"] for r in submitted(rows)), default=None) for n, rows in exercise.items()}
 
101
  sim_taken = bool(simulation and not attempts.is_open(simulation))
102
  stale = sorted(covered, key=lambda i: (last.get(i) is not None, last.get(i) or date.min))
103
  used: dict[str, int] = {}
104
+ for rows in list(exercise.values()) + [focus, [simulation] if simulation else []]:
105
  for r in rows:
106
  for q in r["questions"]:
107
  used[q["key"]] = used.get(q["key"], 0) + 1
108
  return {"covered": covered, "states": states, "mastered": len(mastered), "path_done": bool(covered) and not unmastered,
109
  "next_path_skill": unmastered[0] if unmastered else None, "exercise": exercise, "best": best, "simulation": simulation,
110
  "sim_taken": sim_taken, "ex1_done": bool(submitted(exercise[1])), "ex2_done": bool(submitted(exercise[2])),
111
+ "weak_before": weak_before, "failed": failed, "review_left": review_left, "stale": stale, "used": used, "focus": focus,
112
+ "focus_skills": _focus_skills(bank, covered, states, weak_before | set(failed) | {s["id"] for r in submitted(focus) for s in attempts.result(bank, r)["lost_skills"]})}
113
+
114
+
115
+ def _focus_skills(bank, covered: list[str], states: dict[str, str], lost: set[str]) -> list[str]:
116
+ """The weak topics of a test: covered skills that lost points in its exercise tests or simulation, or are still being learned."""
117
+ return [i for i in covered if (i in lost or states.get(i) == "learning") and bank.question_keys(i)]
118
 
119
 
120
  def readiness_for(facts: dict) -> dict:
 
142
  "open": bool(open_), "due_at": open_["due_at"].isoformat() if open_ and attempts.is_timed(open_) else None})
143
  sim = facts["simulation"]
144
  opens_in = max(0, days_left - p["sim"])
145
+ focus_open = next((r for r in facts["focus"] if attempts.is_open(r)), None)
146
+ focus_done = [r for r in facts["focus"] if not attempts.is_open(r)]
147
+ focus = {"number": exam.FOCUS, "size": exam.FOCUS_SIZE, "skills": [{"id": i, "name": bank.skill(i)["name"]} for i in facts["focus_skills"]],
148
+ "best": max((r["grade"] for r in focus_done), default=None), "attempts": len(focus_done), "open": bool(focus_open)}
149
  return {"id": test["id"], "date": test["date"].isoformat(), "days_left": days_left, "note": test["note"], "subject_id": test.get("subject_id", "math"),
150
  "lessons": [{"id": i, "name": n} for i, n in zip(test["lessons"], lesson_names(request, test["lessons"]))],
151
  "questions": test["questions"], "minutes": test["minutes"],
152
  "plan": countdown.outline(p, facts), "task": countdown.task(days_left, p, facts), "readiness": readiness_for(facts),
153
+ "exercise_tests": exercises, "focus": focus,
154
  "practice_first": [{"id": i, "name": bank.skill(i)["name"]} for i in facts["covered"] if facts["states"].get(i, "not_started") == "not_started"],
155
  "simulation": {"open_now": countdown.sim_open(days_left, p), "opens_in_days": opens_in,
156
  "opens_on": (test["date"] - timedelta(days=p["sim"])).isoformat(), "required": countdown.sim_open(days_left, p) and not facts["sim_taken"],
 
324
  from .quizzes import view
325
  store, bank, now = request.app.state.store, request.app.state.bank, clock.now()
326
  facts = facts_for(request, user["id"], test, clock.school_day())
327
+ number = int(scope.split(":")[1]) if kind == "exercise_test" else None
328
+ mine = facts["focus"] if number == exam.FOCUS else facts["exercise"][number] if kind == "exercise_test" else [facts["simulation"]] if facts["simulation"] else []
329
  for row in mine:
330
  if attempts.is_open(row) or kind == "simulation": # open: resume; the simulation is taken once, so its result comes back
331
  return view(request, row)
332
+ if number == exam.FOCUS: # the weak topics only, FOCUS_SIZE questions, untimed like the others
333
+ if not facts["focus_skills"]:
334
+ raise HTTPException(409, "אין כרגע נושאים לחיזוק במבחן הזה. כל הכבוד!")
335
+ drawn = exam.draw_focus(bank, facts["focus_skills"], used=facts["used"], rng=getattr(request.app.state, "quiz_rng", None))
336
+ return view(request, store.create_attempt(user["id"], kind, scope, now, now + timedelta(minutes=test["minutes"]), drawn))
337
  drawn = exam.draw(bank, test["lessons"], facts["states"], test["questions"], share=share, weak_before=facts["weak_before"], used=facts["used"],
338
  include_unstarted=include_unstarted)
339
  if not drawn["questions"] and kind == "exercise_test" and not include_unstarted: # nothing started yet: all the material, like the simulation
 
348
  @router.post("/tests/{test_id}/exercise/{number}/start")
349
  def start_exercise(test_id: int, number: int, request: Request, user: dict = Depends(learner),
350
  all_skills: bool = Query(False, description="the simulation's material: every skill of the test's lessons, started or not, evenly")) -> dict:
351
+ if number not in exam.EXERCISE_SHARE and number != exam.FOCUS:
352
  raise HTTPException(404, "מבחן התרגול לא נמצא")
353
  test = _student_test(request, user, test_id)
354
+ if number == exam.FOCUS:
355
+ return _start(request, user, test, "exercise_test", f"{test_id}:{number}", 1.0)
356
  share = 0.0 if all_skills else exam.EXERCISE_SHARE[number] # all_skills: drawn like the simulation (an even spread, no weak share)
357
  return _start(request, user, test, "exercise_test", f"{test_id}:{number}", share, all_skills)
358
 
mathly2/rounds.py CHANGED
@@ -117,11 +117,14 @@ def view(request: Request, row: dict) -> dict:
117
  lesson = next((l for l in bank.lessons if l["id"] == row["scope"]), {"id": row["scope"], "name": row["scope"]})
118
  done = _graded(bank, row)
119
  base = {"id": row["id"], "kind": row["kind"], "lesson": {"id": lesson["id"], "name": lesson["name"]}, "subject_id": lesson.get("subject_id", "math"),
120
- "total": len(row["questions"]), "results": [right for _, right, _ in done], "source": source}
121
  if attempts.is_open(row):
122
  nxt = row["questions"][len(done)] if len(done) < len(row["questions"]) else None
123
  served = bank.practice_question(nxt["skill_id"], nxt["key"]) if nxt else None
124
- return {**base, "state": "open", "question": {**served, "skill_name": bank.skill(nxt["skill_id"])["name"]} if served else None}
 
 
 
125
  uid = row["user_id"]
126
  progress = {p["skill_id"]: p for p in store.progress_of(uid)}
127
  started = _when(row["started_at"])
@@ -145,9 +148,10 @@ def view(request: Request, row: dict) -> dict:
145
  continue
146
  served = bank.practice_question(q["skill_id"], q["key"])
147
  missed.append({"question": served["question"] if served else "", "skill_name": (bank.skill(q["skill_id"]) or {}).get("name", ""),
 
148
  "mistake": {"name": mistake["name"], "tip": mistake["tip"]} if mistake else None})
149
  stars_now = None if source else _lesson_stars(request, uid, row["scope"], store.attempts_of(uid, KIND, row["scope"]))
150
- return {**base, "state": "done", "question": None,
151
  "result": {"right": sum(base["results"]), "total": base["total"], "grade": row["grade"], "stars": stars_now,
152
  "mastered": mastered, "almost": almost, "missed": missed}}
153
 
@@ -164,9 +168,24 @@ def _source_info(request: Request, row: dict) -> dict:
164
  source = request.app.state.store.get_attempt(row["scope"])
165
  kind = source["kind"] if source else "quiz"
166
  test_id = int(source["scope"].split(":")[0]) if source and kind != "quiz" and source["scope"].split(":")[0].isdigit() else None
167
- name = {"quiz": "הבוחן", "exercise_test": "מבחן התרגול", "simulation": "מבחן הסימולציה"}[kind]
 
168
  return {"id": row["scope"], "kind": kind, "test_id": test_id, "lesson_id": source["scope"] if source and kind == "quiz" else None,
169
- "title": f"חזרה על הטעויות ב{name}"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
 
172
  @router.post("/attempts/{attempt_id}/retry/start")
 
117
  lesson = next((l for l in bank.lessons if l["id"] == row["scope"]), {"id": row["scope"], "name": row["scope"]})
118
  done = _graded(bank, row)
119
  base = {"id": row["id"], "kind": row["kind"], "lesson": {"id": lesson["id"], "name": lesson["name"]}, "subject_id": lesson.get("subject_id", "math"),
120
+ "total": len(row["questions"]), "results": [right for _, right, _ in done], "source": _public(source)}
121
  if attempts.is_open(row):
122
  nxt = row["questions"][len(done)] if len(done) < len(row["questions"]) else None
123
  served = bank.practice_question(nxt["skill_id"], nxt["key"]) if nxt else None
124
+ question = {**served, "skill_name": bank.skill(nxt["skill_id"])["name"]} if served else None
125
+ if question and source:
126
+ question["context"] = _context(bank, source, nxt["key"], nxt["skill_id"])
127
+ return {**base, "state": "open", "question": question, "source": _public(source)}
128
  uid = row["user_id"]
129
  progress = {p["skill_id"]: p for p in store.progress_of(uid)}
130
  started = _when(row["started_at"])
 
148
  continue
149
  served = bank.practice_question(q["skill_id"], q["key"])
150
  missed.append({"question": served["question"] if served else "", "skill_name": (bank.skill(q["skill_id"]) or {}).get("name", ""),
151
+ "n": source["_order"].get(q["key"]) if source else None,
152
  "mistake": {"name": mistake["name"], "tip": mistake["tip"]} if mistake else None})
153
  stars_now = None if source else _lesson_stars(request, uid, row["scope"], store.attempts_of(uid, KIND, row["scope"]))
154
+ return {**base, "state": "done", "question": None, "source": _public(source),
155
  "result": {"right": sum(base["results"]), "total": base["total"], "grade": row["grade"], "stars": stars_now,
156
  "mastered": mastered, "almost": almost, "missed": missed}}
157
 
 
168
  source = request.app.state.store.get_attempt(row["scope"])
169
  kind = source["kind"] if source else "quiz"
170
  test_id = int(source["scope"].split(":")[0]) if source and kind != "quiz" and source["scope"].split(":")[0].isdigit() else None
171
+ from .boardwork import _title
172
+ name = _title(request, source) if source else "הבוחן"
173
  return {"id": row["scope"], "kind": kind, "test_id": test_id, "lesson_id": source["scope"] if source and kind == "quiz" else None,
174
+ "title": name, "total": len(source["questions"]) if source else None,
175
+ "_order": {q["key"]: i + 1 for i, q in enumerate(source["questions"])} if source else {},
176
+ "_answers": source["answers"] if source else {}}
177
+
178
+
179
+ def _public(source: dict | None) -> dict | None:
180
+ return {k: v for k, v in source.items() if not k.startswith("_")} if source else None
181
+
182
+
183
+ def _context(bank, source: dict, key: str, skill_id: str) -> dict:
184
+ """Where a question of a mistakes round came from: its number in the test, and what was chosen there (never whether it was right)."""
185
+ served = bank.practice_question(skill_id, key) or {"choices": []}
186
+ choice = source["_answers"].get(key)
187
+ picked = next((c["text"] for c in served["choices"] if c["index"] == choice), None) if isinstance(choice, int) else None
188
+ return {"title": source["title"], "n": source["_order"].get(key), "of": source["total"], "picked": picked}
189
 
190
 
191
  @router.post("/attempts/{attempt_id}/retry/start")
mathly2/static/app.css CHANGED
@@ -705,3 +705,9 @@ body:has(#lesson-map) p:has(> #to-subjects) { margin-block: 0 4px; }
705
  .rv-steps li.rv-now { font-weight: 600; }
706
  .rv-steps .rv-ok { color: var(--ok, #15803d); }
707
  .rv-time { margin: 2px 0 0; font-size: .9em; }
 
 
 
 
 
 
 
705
  .rv-steps li.rv-now { font-weight: 600; }
706
  .rv-steps .rv-ok { color: var(--ok, #15803d); }
707
  .rv-time { margin: 2px 0 0; font-size: .9em; }
708
+ .rd-context { margin-bottom: 6px; }
709
+ .rd-context p { margin: 4px 0 0; }
710
+ .focus-skills { display: flex; flex-wrap: wrap; gap: 6px; }
711
+ .rv-bar { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; margin: 6px 0; }
712
+ .rv-bar i { display: block; height: 100%; background: var(--accent); transition: width .4s; }
713
+ .work-ai-run { font-size: .9em; padding: 4px 10px; }
mathly2/static/grades.js CHANGED
@@ -25,7 +25,7 @@ function draw(view, { quizzes, exercise_tests: exercises = [], simulations = [],
25
  ${quizzes.length ? `<h3>בחנים</h3><ul class="plain grades" id="quiz-grades">${quizzes.map(g => row({ attrs: `data-lesson="${esc(g.lesson.id)}"`, href: `#/quiz/${esc(g.lesson.id)}`,
26
  title: fmt(g.lesson.name), best: g.best, attempts: g.attempts, last: g.last })).join("")}</ul>` : ""}
27
  ${exercises.length ? `<h3>מבחני תרגול</h3><ul class="plain grades" id="exercise-grades">${exercises.map(g => row({ attrs: `data-test="${g.test.id}" data-number="${g.number}"`,
28
- href: `#/test/${g.test.id}`, title: testTitle(g.test, `מבחן תרגול ${g.number}`), best: g.best, attempts: g.attempts, last: g.last })).join("")}</ul>` : ""}
29
  ${assignments.length ? `<h3>משימות</h3><ul class="plain grades" id="assignment-grades">${assignments.map(g => `<li data-assignment="${g.assignment.id}">
30
  <a href="#/assignment/${g.assignment.id}"><b>${fmt(g.assignment.title)}</b>${g.assignment.removed ? " (הוסרה)" : ""}</a>
31
  <span class="gr-num" aria-label="הציון ${g.grade}">${g.grade}</span>
 
25
  ${quizzes.length ? `<h3>בחנים</h3><ul class="plain grades" id="quiz-grades">${quizzes.map(g => row({ attrs: `data-lesson="${esc(g.lesson.id)}"`, href: `#/quiz/${esc(g.lesson.id)}`,
26
  title: fmt(g.lesson.name), best: g.best, attempts: g.attempts, last: g.last })).join("")}</ul>` : ""}
27
  ${exercises.length ? `<h3>מבחני תרגול</h3><ul class="plain grades" id="exercise-grades">${exercises.map(g => row({ attrs: `data-test="${g.test.id}" data-number="${g.number}"`,
28
+ href: `#/test/${g.test.id}`, title: testTitle(g.test, g.number === 3 ? "מבחן תרגול: נושאים לחיזוק" : `מבחן תרגול ${g.number}`), best: g.best, attempts: g.attempts, last: g.last })).join("")}</ul>` : ""}
29
  ${assignments.length ? `<h3>משימות</h3><ul class="plain grades" id="assignment-grades">${assignments.map(g => `<li data-assignment="${g.assignment.id}">
30
  <a href="#/assignment/${g.assignment.id}"><b>${fmt(g.assignment.title)}</b>${g.assignment.removed ? " (הוסרה)" : ""}</a>
31
  <span class="gr-num" aria-label="הציון ${g.grade}">${g.grade}</span>
mathly2/static/quiz.js CHANGED
@@ -46,7 +46,8 @@ export async function startSimulation(testId, allSkills = false) {
46
  }
47
 
48
  /** What the sheet is called, who it is about and where closing it leads, by kind. */
49
- const names = a => a.kind === "exercise_test" ? { title: `מבחן תרגול ${a.test.number}`, again: "מבחן חוזר" }
 
50
  : a.kind === "simulation" ? { title: "מבחן סימולציה", again: "" } : { title: "בוחן", again: "בוחן חוזר" };
51
  const chip = a => a.kind === "quiz" ? fmt(a.lesson.name) : a.test.lessons.map(fmt).join(", ");
52
  const leaveTo = () => !attempt || attempt.kind === "quiz" ? pathHref() : `#/test/${attempt.test.id}`;
@@ -199,19 +200,24 @@ function submit() {
199
  const VERDICT = { correct: ["✓", "דרך נכונה"], partial: ["◐", "חלק מהדרך נכון"], wrong: ["✗", "הדרך לא נכונה"], unclear: ["?", "לא הצלחנו לקרוא"] };
200
 
201
  /** The AI's review of the way (a practice test, the simulation): waiting, failed, or per question with a board its verdict, feedback and tip. */
 
 
 
 
 
 
 
202
  function reviewHtml(r) {
203
  const review = r.review;
204
  if (!review) return "";
205
  if (review.state === "pending") return `<section class="qz-review" id="qz-review" data-state="pending"><h4>משוב על הדרך</h4>
206
- <p class="muted">ה־AI עובר על הדרך שכתבתם בלוח. אפשר להמשיך לקרוא את התוצאה, המשוב יופיע כאן.</p>${stepsHtml(review)}</section>`;
 
207
  if (review.state === "failed") return `<section class="qz-review" id="qz-review" data-state="failed"><h4>משוב על הדרך</h4><p class="muted">לא הצלחנו לבדוק את הדרך הפעם. המורה יראו את הלוחות.</p></section>`;
208
- const rows = (r.keys || []).map((key, i) => [i + 1, review.questions[key]]).filter(([, q]) => q);
209
  return `<section class="qz-review" id="qz-review" data-state="done"><h4>משוב על הדרך</h4>
210
  ${review.summary ? `<p class="qz-review-sum">${fmt(review.summary)}</p>` : ""}
211
- ${rows.length ? `<ol class="plain qz-review-list">${rows.map(([n, q]) => `<li class="v-${esc(q.verdict)}" data-n="${n}"><p><b>שאלה ${n}</b>
212
- <span class="tag"><span aria-hidden="true">${VERDICT[q.verdict][0]}</span> ${VERDICT[q.verdict][1]}</span></p>
213
- <p>${fmt(q.feedback)}</p>${q.tip ? `<p class="muted">💡 ${fmt(q.tip)}</p>` : ""}</li>`).join("")}</ol>`
214
- : `<p class="muted">לא נשמרה דרך בלוח באף שאלה.</p>`}
215
  <p class="muted qz-review-note">משוב אוטומטי. המורה בודקים את הדרך ונותנים את הנקודות.</p></section>`;
216
  }
217
 
@@ -238,7 +244,8 @@ function renderResult() {
238
  <p class="muted" id="qz-best"></p>${r.kind === "simulation" ? `<p class="muted">מבחן סימולציה אפשר לעשות פעם אחת.</p>` : ""}</div>
239
  ${r.lost_skills.length ? `<h4>איפה אפשר להשתפר</h4><ul class="plain" id="qz-lost">${r.lost_skills.map(s =>
240
  `<li><a href="#/skill/${esc(s.id)}">${fmt(s.name)}</a><span class="muted">${s.missed} ${s.missed === 1 ? "שאלה שגויה" : "שאלות שגויות"}</span></li>`).join("")}</ul>
241
- <p class="muted">אפשר לתרגל את המיומנות ואז לנסות שוב.</p>` : `<p id="qz-perfect">כל התשובות נכונות. כל הכבוד!</p>`}
 
242
  ${r.missed.length ? `<h4>הטעויות</h4><ol class="qz-missed" id="qz-missed">${r.missed.map(m => `<li><p class="ex-text" dir="auto">${fmt(m.question)}</p>
243
  ${m.picked ? `<p class="muted">בחרתם: <span dir="auto">${fmt(m.picked)}</span></p>` : `<p class="muted">לא ענו על השאלה.</p>`}
244
  ${m.mistake ? `<p><b>${esc(m.mistake.name)}</b>${m.mistake.tip ? `<br>${fmt(m.mistake.tip)}` : ""}</p>` : ""}</li>`).join("")}</ol>` : ""}
 
46
  }
47
 
48
  /** What the sheet is called, who it is about and where closing it leads, by kind. */
49
+ const FOCUS = 3; // the exercise test on the weak topics
50
+ const names = a => a.kind === "exercise_test" ? (a.test.number === FOCUS ? { title: "מבחן תרגול: נושאים לחיזוק", again: "עוד מבחן על הנושאים" } : { title: `מבחן תרגול ${a.test.number}`, again: "מבחן חוזר" })
51
  : a.kind === "simulation" ? { title: "מבחן סימולציה", again: "" } : { title: "בוחן", again: "בוחן חוזר" };
52
  const chip = a => a.kind === "quiz" ? fmt(a.lesson.name) : a.test.lessons.map(fmt).join(", ");
53
  const leaveTo = () => !attempt || attempt.kind === "quiz" ? pathHref() : `#/test/${attempt.test.id}`;
 
200
  const VERDICT = { correct: ["✓", "דרך נכונה"], partial: ["◐", "חלק מהדרך נכון"], wrong: ["✗", "הדרך לא נכונה"], unclear: ["?", "לא הצלחנו לקרוא"] };
201
 
202
  /** The AI's review of the way (a practice test, the simulation): waiting, failed, or per question with a board its verdict, feedback and tip. */
203
+ function reviewRows(r, questions) {
204
+ const rows = (r.keys || []).map((key, i) => [i + 1, questions?.[key]]).filter(([, q]) => q);
205
+ return rows.length ? `<ol class="plain qz-review-list">${rows.map(([n, q]) => `<li class="v-${esc(q.verdict)}" data-n="${n}"><p><b>שאלה ${n}</b>
206
+ <span class="tag"><span aria-hidden="true">${VERDICT[q.verdict][0]}</span> ${VERDICT[q.verdict][1]}</span></p>
207
+ <p>${fmt(q.feedback)}</p>${q.tip ? `<p class="muted">💡 ${fmt(q.tip)}</p>` : ""}</li>`).join("")}</ol>` : "";
208
+ }
209
+
210
  function reviewHtml(r) {
211
  const review = r.review;
212
  if (!review) return "";
213
  if (review.state === "pending") return `<section class="qz-review" id="qz-review" data-state="pending"><h4>משוב על הדרך</h4>
214
+ <p class="muted">ה־AI עובר על הדרך שכתבתם בלוח, שאלה אחרי שאלה. אפשר להמשיך לקרוא את התוצאה, המשוב יופיע כאן.</p>${stepsHtml(review)}
215
+ ${reviewRows(r, review.questions)}</section>`;
216
  if (review.state === "failed") return `<section class="qz-review" id="qz-review" data-state="failed"><h4>משוב על הדרך</h4><p class="muted">לא הצלחנו לבדוק את הדרך הפעם. המורה יראו את הלוחות.</p></section>`;
217
+ const rows = reviewRows(r, review.questions);
218
  return `<section class="qz-review" id="qz-review" data-state="done"><h4>משוב על הדרך</h4>
219
  ${review.summary ? `<p class="qz-review-sum">${fmt(review.summary)}</p>` : ""}
220
+ ${rows || `<p class="muted">לא נשמרה דרך בלוח באף שאלה.</p>`}
 
 
 
221
  <p class="muted qz-review-note">משוב אוטומטי. המורה בודקים את הדרך ונותנים את הנקודות.</p></section>`;
222
  }
223
 
 
244
  <p class="muted" id="qz-best"></p>${r.kind === "simulation" ? `<p class="muted">מבחן סימולציה אפשר לעשות פעם אחת.</p>` : ""}</div>
245
  ${r.lost_skills.length ? `<h4>איפה אפשר להשתפר</h4><ul class="plain" id="qz-lost">${r.lost_skills.map(s =>
246
  `<li><a href="#/skill/${esc(s.id)}">${fmt(s.name)}</a><span class="muted">${s.missed} ${s.missed === 1 ? "שאלה שגויה" : "שאלות שגויות"}</span></li>`).join("")}</ul>
247
+ <p class="muted">אפשר לתרגל את המיומנות ואז לנסות שוב.</p>
248
+ ${r.kind !== "quiz" && r.test.number !== FOCUS ? `<p><a class="button" id="qz-focus" href="#/exam/${esc(r.test.id)}/${FOCUS}">🎯 מבחן תרגול על הנושאים לחיזוק (15 שאלות)</a></p>` : ""}` : `<p id="qz-perfect">כל התשובות נכונות. כל הכבוד!</p>`}
249
  ${r.missed.length ? `<h4>הטעויות</h4><ol class="qz-missed" id="qz-missed">${r.missed.map(m => `<li><p class="ex-text" dir="auto">${fmt(m.question)}</p>
250
  ${m.picked ? `<p class="muted">בחרתם: <span dir="auto">${fmt(m.picked)}</span></p>` : `<p class="muted">לא ענו על השאלה.</p>`}
251
  ${m.mistake ? `<p><b>${esc(m.mistake.name)}</b>${m.mistake.tip ? `<br>${fmt(m.mistake.tip)}` : ""}</p>` : ""}</li>`).join("")}</ol>` : ""}
mathly2/static/reviewsteps.js CHANGED
@@ -4,17 +4,35 @@ import { esc } from "./math.js";
4
 
5
  const boards = n => n === 1 ? "לוח אחד" : `${n} לוחות`;
6
 
7
- /** One line per step; the last one is the one happening now. */
8
  function lines(review) {
9
- const events = review.events || [];
10
  if (!events.length) return [["now", "⏳", "מתחילים…"]];
11
- return events.map((e, i) => {
12
- const last = i === events.length - 1;
13
- if (e.step === "boards") return ["done", "📋", `נמצאו ${boards(e.count)} עם דרך (מתוך ${e.total} שאלות)`];
14
- if (e.step === "busy") return [last ? "now" : "done", "☕", last ? `ה־AI עמוס כרגע. מנסים שוב בעוד ${e.seconds} שניות` : "ה־AI היה עמוס, ניסינו שוב"];
15
- if (e.step === "sent") return [last ? "now" : "done", last ? "🔍" : "📤", last ? "ה־AI קורא את הלוחות ובודק את הדרך, שאלה אחרי שאלה…" : "נשלח ל־AI"];
16
- return ["done", "•", esc(e.step)];
17
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  }
19
 
20
  const since = at => Math.max(0, Math.round((Date.now() - new Date(at)) / 1000));
@@ -22,9 +40,10 @@ const elapsed = secs => secs < 60 ? `${secs} שניות` : `${Math.floor(secs /
22
 
23
  /** The steps as HTML: a list, then "running for … (usually under a minute)". */
24
  export function stepsHtml(review) {
25
- const at = review.at;
26
- return `<ol class="plain rv-steps" aria-live="polite">${lines(review).map(([state, icon, text]) =>
27
- `<li class="rv-${state}"><span aria-hidden="true">${state === "now" ? `<span class="spin"></span>` : icon}</span> ${text}${state === "done" ? ' <span class="rv-ok" aria-hidden="true">✓</span>' : ""}</li>`).join("")}</ol>
 
28
  ${at ? `<p class="muted rv-time">כבר <span class="rv-elapsed" data-since="${esc(at)}">${elapsed(since(at))}</span> · בדרך כלל פחות מדקה</p>` : ""}`;
29
  }
30
 
 
4
 
5
  const boards = n => n === 1 ? "לוח אחד" : `${n} לוחות`;
6
 
7
+ /** The lines: the boards found, how many questions are checked, the one being checked now (or the AI busy), the summary. */
8
  function lines(review) {
9
+ const events = (review.events || []).filter(e => e.step !== "resumed");
10
  if (!events.length) return [["now", "⏳", "מתחילים…"]];
11
+ const out = [], last = events[events.length - 1];
12
+ const boardsEv = events.find(e => e.step === "boards");
13
+ if (boardsEv) out.push(["done", "📋", `נמצאו ${boards(boardsEv.count)} עם דרך (מתוך ${boardsEv.total} שאלות)`]);
14
+ const current = [...events].reverse().find(e => e.step === "question");
15
+ if (current) {
16
+ const done = current.i - 1, all = current.of;
17
+ if (done) out.push(["done", "🔍", `נבדקו ${done} מתוך ${all}`]);
18
+ const writing = events.some(e => e.step === "summary");
19
+ if (!writing) out.push(["now", "🔍", `בודקים את שאלה ${current.n} (${current.i} מתוך ${all})…`]);
20
+ else out.push(["done", "🔍", `נבדקו כל ${all} השאלות`]);
21
+ }
22
+ if (last.step === "busy") out.push(["now", "☕", `ה־AI עמוס כרגע. ממשיכים בעוד ${last.seconds} שניות`]);
23
+ if (events.some(e => e.step === "summary")) out.push([last.step === "busy" ? "done" : "now", "✍️", "כותבים סיכום קצר…"]);
24
+ if (last.step === "busy" && out.length > 1) { // while waiting, the step before it is not "now"
25
+ const prev = out[out.length - 2];
26
+ if (prev[0] === "now") prev[0] = "wait";
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /** How far along: the questions checked out of all of them (0..1), or null before the first. */
32
+ function share(review) {
33
+ const current = [...(review.events || [])].reverse().find(e => e.step === "question");
34
+ if (!current) return null;
35
+ return (review.events.some(e => e.step === "summary") ? current.of : current.i - 1) / current.of;
36
  }
37
 
38
  const since = at => Math.max(0, Math.round((Date.now() - new Date(at)) / 1000));
 
40
 
41
  /** The steps as HTML: a list, then "running for … (usually under a minute)". */
42
  export function stepsHtml(review) {
43
+ const at = review.at, part = share(review);
44
+ return `${part === null ? "" : `<div class="rv-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(part * 100)}"><i style="width:${Math.round(part * 100)}%"></i></div>`}
45
+ <ol class="plain rv-steps" aria-live="polite">${lines(review).map(([state, icon, text]) =>
46
+ `<li class="rv-${state}"><span aria-hidden="true">${state === "now" ? `<span class="spin"></span>` : state === "wait" ? "⏸" : icon}</span> ${text}${state === "done" ? ' <span class="rv-ok" aria-hidden="true">✓</span>' : ""}</li>`).join("")}</ol>
47
  ${at ? `<p class="muted rv-time">כבר <span class="rv-elapsed" data-since="${esc(at)}">${elapsed(since(at))}</span> · בדרך כלל פחות מדקה</p>` : ""}`;
48
  }
49
 
mathly2/static/round.js CHANGED
@@ -66,7 +66,10 @@ const progress = () => `<div class="ex-progress"><span id="rd-count">${countText
66
 
67
  function questionHtml() {
68
  const question = round.question;
69
- return `<div class="mx-q" id="ex-q" data-key="${esc(question.key)}"><p class="muted rd-skill">${fmt(question.skill_name)}</p>
 
 
 
70
  <p class="ex-text" dir="auto">${fmt(question.question)}</p>${figures(question)}
71
  <div class="ex-choices" id="ex-choices">${shuffled(question.choices).map(c =>
72
  `<button type="button" class="ex-choice" data-i="${c.index}" dir="auto">${fmt(c.text)}</button>`).join("")}</div>
@@ -81,7 +84,7 @@ function retryResultHtml() {
81
  <h4 class="rd-title">${all ? "כל הטעויות תוקנו!" : "סיימתם לחזור על הטעויות"}</h4>
82
  <p class="ex-score" id="rd-score"><b>${r.right}</b> מתוך ${r.total} נכונות הפעם</p>
83
  ${r.mastered.length ? `<p class="rd-mastered" id="rd-mastered">🎉 נשלטו: ${r.mastered.map(s => fmt(s.name)).join(", ")}</p>` : ""}
84
- ${r.missed.length ? `<h4>עדיין כדאי לחזק</h4><ol class="ex-missed" id="rd-missed">${r.missed.map(m => `<li><p class="muted">${fmt(m.skill_name)}</p><p class="ex-text" dir="auto">${fmt(m.question)}</p>
85
  ${m.mistake ? `<p><b>${esc(m.mistake.name)}</b>${m.mistake.tip ? `<br>${fmt(m.mistake.tip)}` : ""}</p>` : ""}</li>`).join("")}</ol>` : ""}
86
  <p class="muted">הציון במבחן לא משתנה. התרגול נספר לשליטה במיומנויות.</p>
87
  <p class="ex-actions">${all ? "" : `<button type="button" class="primary" id="rd-again">שוב על הטעויות</button> `}<button type="button" data-close>${round.source.test_id ? "חזרה למבחן" : "חזרה למסלול"}</button></p></section>`;
 
66
 
67
  function questionHtml() {
68
  const question = round.question;
69
+ const ctx = question.context;
70
+ const from = ctx ? `<div class="rd-context" id="rd-context"><span class="ex-chip">📝 ${ctx.n ? `שאלה ${ctx.n}${ctx.of ? ` מתוך ${ctx.of}` : ""} ב${fmt(ctx.title)}` : fmt(ctx.title)}</span>
71
+ <p class="muted">${ctx.picked ? `במבחן בחרתם: <span dir="auto">${fmt(ctx.picked)}</span>` : "במבחן לא עניתם על השאלה הזו."} נסו שוב.</p></div>` : "";
72
+ return `<div class="mx-q" id="ex-q" data-key="${esc(question.key)}">${from}<p class="muted rd-skill">${fmt(question.skill_name)}</p>
73
  <p class="ex-text" dir="auto">${fmt(question.question)}</p>${figures(question)}
74
  <div class="ex-choices" id="ex-choices">${shuffled(question.choices).map(c =>
75
  `<button type="button" class="ex-choice" data-i="${c.index}" dir="auto">${fmt(c.text)}</button>`).join("")}</div>
 
84
  <h4 class="rd-title">${all ? "כל הטעויות תוקנו!" : "סיימתם לחזור על הטעויות"}</h4>
85
  <p class="ex-score" id="rd-score"><b>${r.right}</b> מתוך ${r.total} נכונות הפעם</p>
86
  ${r.mastered.length ? `<p class="rd-mastered" id="rd-mastered">🎉 נשלטו: ${r.mastered.map(s => fmt(s.name)).join(", ")}</p>` : ""}
87
+ ${r.missed.length ? `<h4>עדיין כדאי לחזק</h4><ol class="ex-missed" id="rd-missed">${r.missed.map(m => `<li><p class="muted">${m.n ? `שאלה ${m.n} · ` : ""}${fmt(m.skill_name)}</p><p class="ex-text" dir="auto">${fmt(m.question)}</p>
88
  ${m.mistake ? `<p><b>${esc(m.mistake.name)}</b>${m.mistake.tip ? `<br>${fmt(m.mistake.tip)}` : ""}</p>` : ""}</li>`).join("")}</ol>` : ""}
89
  <p class="muted">הציון במבחן לא משתנה. התרגול נספר לשליטה במיומנויות.</p>
90
  <p class="ex-actions">${all ? "" : `<button type="button" class="primary" id="rd-again">שוב על הטעויות</button> `}<button type="button" data-close>${round.source.test_id ? "חזרה למבחן" : "חזרה למסלול"}</button></p></section>`;
mathly2/static/teacher_boardwork.js CHANGED
@@ -27,8 +27,10 @@ function aiPanel(data) {
27
  const button = label => `<button type="button" id="work-ai-run">${label}</button>`;
28
  const body = ai.state === "pending" ? `<p><b>🤖 בדיקה עם AI</b></p>${stepsHtml(ai)}`
29
  : ai.state === "failed" ? `<p class="error" role="alert">🤖 הבדיקה עם AI נכשלה. ${esc(REASONS[ai.reason] || "")}${ai.model ? ` <span dir="ltr">(${esc(ai.model)})</span>` : ""}</p>${button("לנסות שוב")}`
30
- : ai.state === "done" ? `<p>🤖 <b>סיכום ה־AI:</b> ${ai.summary ? fmt(ai.summary) : "לא היו לוחות לבדוק."}</p><p class="muted">הצעה בלבד: הנקודות שלכם קובעות.</p>${button("לבדוק שוב עם AI")}`
31
- : `<p class="muted">🤖 עוד לא נבדק עם AI.</p>${button("בדיקה עם AI")}`;
 
 
32
  return `<div class="card work-ai-panel" id="work-ai" data-state="${esc(ai.state || "none")}">${body}</div>`;
33
  }
34
 
@@ -48,6 +50,16 @@ export async function show(context) {
48
  }
49
 
50
  const pointsOf = q => q.credit ?? q.auto;
 
 
 
 
 
 
 
 
 
 
51
 
52
  function questionCard(q, i) {
53
  const picked = q.choices.find(c => c.index === q.picked);
@@ -57,7 +69,7 @@ function questionCard(q, i) {
57
  <ul class="plain work-choices">${q.choices.map(c => `<li class="${c.correct ? "right" : ""}${c.index === q.picked ? " picked" : ""}"><span dir="auto">${fmt(c.text)}</span>
58
  <span>${c.correct ? "✓ נכונה" : ""}${c.index === q.picked ? " ← בחר/ה" : ""}</span></li>`).join("")}</ul>
59
  ${picked ? "" : `<p class="muted">לא ענה/תה על השאלה.</p>`}
60
- <h4>הדרך</h4>${q.ai ? `<p class="work-ai"><span class="tag">AI: ${({ correct: "✓ נכונה", partial: "◐ חלקית", wrong: "✗ שגויה", unclear: "? לא ברורה" })[q.ai.verdict] || ""}</span> ${fmt(q.ai.feedback)}</p>` : ""}${q.work ? `<img class="work-pic" data-picture="${esc(q.work)}" alt="הלוח של התלמיד בשאלה ${i + 1}" hidden>` : `<p class="muted no-work">אין לוח לשאלה הזו.</p>`}
61
  <div class="work-points" role="group" aria-label="נקודות לשאלה ${i + 1}"><span class="muted">נקודות:</span>${POINTS.map(([v, label]) =>
62
  `<button type="button" data-points="${v === null ? "auto" : v}" aria-pressed="${(q.credit ?? null) === v}">${label}${v === null ? ` (${q.auto})` : ""}</button>`).join("")}
63
  <b class="work-now">${pointsOf(q)}</b></div></article>`;
@@ -69,6 +81,7 @@ async function open(id, note = "", given = null, keep = null) {
69
  const data = given || await api(`/api/teaching/work/${encodeURIComponent(id)}`);
70
  if (keep) data.questions.forEach(q => { if (q.key in keep) q.credit = keep[q.key]; }); // points picked but not saved yet stay picked
71
  const chosen = Object.fromEntries(data.questions.map(q => [q.key, q.credit ?? null]));
 
72
  ctx.draw(`${ctx.tabs("work")}<section id="work-check" data-attempt="${esc(data.id)}"><p><button type="button" id="work-back">‹ לרשימה</button></p>
73
  <h2>${fmt(data.title)}</h2><p class="muted" dir="ltr">${esc(data.student)}</p>
74
  <p>ציון: <b id="work-grade">${data.grade}</b>${data.checked_at ? ` <span class="muted">· נבדק</span>` : ""}</p>
@@ -79,19 +92,26 @@ async function open(id, note = "", given = null, keep = null) {
79
  ctx.wire();
80
  document.querySelectorAll("#work-check img[data-picture]").forEach(async img => { try { img.src = await pictureUrl(img.dataset.picture); img.hidden = false; } catch { img.replaceWith(Object.assign(document.createElement("p"), { className: "muted", textContent: "התמונה לא זמינה" })); } });
81
  document.getElementById("work-back").addEventListener("click", () => { clearTimeout(poll); show(ctx); });
82
- document.getElementById("work-ai-run")?.addEventListener("click", async event => {
83
- event.target.disabled = true;
84
- try { await open(id, "", await api(`/api/teaching/work/${encodeURIComponent(id)}/review`, { method: "POST" }), chosen); }
85
- catch (error) { event.target.disabled = false; event.target.insertAdjacentHTML("afterend", `<p class="error" role="alert">${esc(error.message || "משהו השתבש")}</p>`); }
86
- });
 
 
87
  if (data.ai?.state === "pending") { // ask again until the AI is done, then redraw with the picks kept
88
  const wait = () => { poll = setTimeout(async () => {
89
  if (document.getElementById("work-check")?.dataset.attempt !== data.id) return;
90
  let now;
91
  try { now = await api(`/api/teaching/work/${encodeURIComponent(id)}`); } catch { return wait(); }
92
  if (document.getElementById("work-check")?.dataset.attempt !== data.id) return;
93
- if (now.ai?.state === "pending") { // the new steps, in the box only
94
  document.getElementById("work-ai")?.replaceWith(Object.assign(document.createElement("div"), { innerHTML: aiPanel(now) }).firstElementChild);
 
 
 
 
 
95
  return wait();
96
  }
97
  open(id, "", now, chosen);
 
27
  const button = label => `<button type="button" id="work-ai-run">${label}</button>`;
28
  const body = ai.state === "pending" ? `<p><b>🤖 בדיקה עם AI</b></p>${stepsHtml(ai)}`
29
  : ai.state === "failed" ? `<p class="error" role="alert">🤖 הבדיקה עם AI נכשלה. ${esc(REASONS[ai.reason] || "")}${ai.model ? ` <span dir="ltr">(${esc(ai.model)})</span>` : ""}</p>${button("לנסות שוב")}`
30
+ : ai.state === "done" ? `<p>🤖 <b>סיכום ה־AI:</b> ${ai.summary ? fmt(ai.summary) : "אין סיכום."}</p>
31
+ ${ai.missing ? `<p class="error">${ai.missing === 1 ? "שאלה אחת לא נבדקה" : `${ai.missing} שאלות לא נבדקו`}. ${esc(REASONS[ai.reason] || "")} אפשר לבדוק כל שאלה בכפתור שלה.</p>` : ""}
32
+ <p class="muted">הצעה בלבד: הנקודות שלכם קובעות.</p>${button("לבדוק שוב את כל המבחן")}`
33
+ : `<p class="muted">🤖 עוד לא נבדק עם AI. ה־AI בודק כל שאלה בנפרד.</p>${button("בדיקה עם AI של כל השאלות")}`;
34
  return `<div class="card work-ai-panel" id="work-ai" data-state="${esc(ai.state || "none")}">${body}</div>`;
35
  }
36
 
 
50
  }
51
 
52
  const pointsOf = q => q.credit ?? q.auto;
53
+ const VERDICT = { correct: "✓ נכונה", partial: "◐ חלקית", wrong: "✗ שגויה", unclear: "? לא ברורה" };
54
+ let aiNow = {}; // the attempt's AI state, for the per-question buttons
55
+
56
+ /** One question's AI line, and its own button (check it alone, or again). */
57
+ function aiSlot(q) {
58
+ const line = q.ai ? `<p class="work-ai"><span class="tag">AI: ${VERDICT[q.ai.verdict] || ""}</span> ${fmt(q.ai.feedback)}${q.ai.tip ? `<br><span class="muted">💡 ${fmt(q.ai.tip)}</span>` : ""}</p>` : "";
59
+ const busy = aiNow.state === "pending";
60
+ const button = aiNow.available && q.work ? `<button type="button" class="work-ai-run" data-ai-key="${esc(q.key)}"${busy ? " disabled" : ""}>🤖 ${q.ai ? "לבדוק שוב את השאלה" : "בדיקה עם AI של השאלה"}</button>` : "";
61
+ return `<div class="work-ai-slot">${line}${button}</div>`;
62
+ }
63
 
64
  function questionCard(q, i) {
65
  const picked = q.choices.find(c => c.index === q.picked);
 
69
  <ul class="plain work-choices">${q.choices.map(c => `<li class="${c.correct ? "right" : ""}${c.index === q.picked ? " picked" : ""}"><span dir="auto">${fmt(c.text)}</span>
70
  <span>${c.correct ? "✓ נכונה" : ""}${c.index === q.picked ? " ← בחר/ה" : ""}</span></li>`).join("")}</ul>
71
  ${picked ? "" : `<p class="muted">לא ענה/תה על השאלה.</p>`}
72
+ <h4>הדרך</h4>${aiSlot(q)}${q.work ? `<img class="work-pic" data-picture="${esc(q.work)}" alt="הלוח של התלמיד בשאלה ${i + 1}" hidden>` : `<p class="muted no-work">אין לוח לשאלה הזו.</p>`}
73
  <div class="work-points" role="group" aria-label="נקודות לשאלה ${i + 1}"><span class="muted">נקודות:</span>${POINTS.map(([v, label]) =>
74
  `<button type="button" data-points="${v === null ? "auto" : v}" aria-pressed="${(q.credit ?? null) === v}">${label}${v === null ? ` (${q.auto})` : ""}</button>`).join("")}
75
  <b class="work-now">${pointsOf(q)}</b></div></article>`;
 
81
  const data = given || await api(`/api/teaching/work/${encodeURIComponent(id)}`);
82
  if (keep) data.questions.forEach(q => { if (q.key in keep) q.credit = keep[q.key]; }); // points picked but not saved yet stay picked
83
  const chosen = Object.fromEntries(data.questions.map(q => [q.key, q.credit ?? null]));
84
+ aiNow = data.ai || {};
85
  ctx.draw(`${ctx.tabs("work")}<section id="work-check" data-attempt="${esc(data.id)}"><p><button type="button" id="work-back">‹ לרשימה</button></p>
86
  <h2>${fmt(data.title)}</h2><p class="muted" dir="ltr">${esc(data.student)}</p>
87
  <p>ציון: <b id="work-grade">${data.grade}</b>${data.checked_at ? ` <span class="muted">· נבדק</span>` : ""}</p>
 
92
  ctx.wire();
93
  document.querySelectorAll("#work-check img[data-picture]").forEach(async img => { try { img.src = await pictureUrl(img.dataset.picture); img.hidden = false; } catch { img.replaceWith(Object.assign(document.createElement("p"), { className: "muted", textContent: "התמונה לא זמינה" })); } });
94
  document.getElementById("work-back").addEventListener("click", () => { clearTimeout(poll); show(ctx); });
95
+ const runAi = async (button, key) => {
96
+ button.disabled = true;
97
+ try { await open(id, "", await api(`/api/teaching/work/${encodeURIComponent(id)}/review${key ? `?key=${encodeURIComponent(key)}` : ""}`, { method: "POST" }), chosen); }
98
+ catch (error) { button.disabled = false; button.insertAdjacentHTML("afterend", `<p class="error" role="alert">${esc(error.message || "משהו השתבש")}</p>`); }
99
+ };
100
+ document.getElementById("work-ai-run")?.addEventListener("click", event => runAi(event.target, null));
101
+ document.querySelectorAll("[data-ai-key]").forEach(b => b.addEventListener("click", event => { event.stopPropagation(); runAi(b, b.dataset.aiKey); }));
102
  if (data.ai?.state === "pending") { // ask again until the AI is done, then redraw with the picks kept
103
  const wait = () => { poll = setTimeout(async () => {
104
  if (document.getElementById("work-check")?.dataset.attempt !== data.id) return;
105
  let now;
106
  try { now = await api(`/api/teaching/work/${encodeURIComponent(id)}`); } catch { return wait(); }
107
  if (document.getElementById("work-check")?.dataset.attempt !== data.id) return;
108
+ if (now.ai?.state === "pending") { // the new steps in the box, and each question's line as it is checked
109
  document.getElementById("work-ai")?.replaceWith(Object.assign(document.createElement("div"), { innerHTML: aiPanel(now) }).firstElementChild);
110
+ aiNow = now.ai;
111
+ now.questions.forEach(q => {
112
+ const slot = document.querySelector(`.work-q[data-key="${CSS.escape(q.key)}"] .work-ai-slot`);
113
+ if (slot && q.ai) slot.outerHTML = aiSlot(q);
114
+ });
115
  return wait();
116
  }
117
  open(id, "", now, chosen);
mathly2/static/testcard.js CHANGED
@@ -20,6 +20,17 @@ function exercises(test) {
20
  <a class="button" href="#/exam/${test.id}/${e.number}">${e.open ? "להמשיך" : e.attempts ? "שוב" : "התחלה"}</a></li>`).join("")}</ul>`;
21
  }
22
 
 
 
 
 
 
 
 
 
 
 
 
23
  function simulation(test) {
24
  const s = test.simulation;
25
  const lead = s.taken ? `<p>הציון: <b id="sim-grade">${s.grade}</b></p>${s.review.length ? `<p>מיומנויות לחזרה:</p><ul class="plain" id="sim-review">${s.review.map(r =>
@@ -41,7 +52,7 @@ export async function show(view, id) {
41
  <p class="muted">${test.lessons.map(l => fmt(l.name)).join(", ")}${test.note ? ` · ${esc(test.note)}` : ""}<br>${test.questions} שאלות · ${test.minutes} דקות</p>
42
  ${readinessBar(test.readiness)}</section>
43
  <section class="card"><h3>התוכנית</h3>${planList(test)}</section>
44
- <section class="card"><h3>מבחני תרגול</h3>${exercises(test)}
45
  ${test.practice_first.length ? `<p id="practice-first"><b>כדאי לתרגל קודם:</b> ${test.practice_first.map(s => `<a class="ex-chip link" href="#/skill/${esc(s.id)}">${fmt(s.name)}</a>`).join(" ")}</p>
46
  <p class="muted">אלה מיומנויות שעוד לא התחלתם, ולכן אין להן שאלות במבחני התרגול.</p>
47
  <p id="all-skills">${test.exercise_tests.map(e => `<a class="button" href="#/exam/${test.id}/${e.number}/all">מבחן תרגול ${e.number} על כל החומר</a>`).join(" ")}</p>
 
20
  <a class="button" href="#/exam/${test.id}/${e.number}">${e.open ? "להמשיך" : e.attempts ? "שוב" : "התחלה"}</a></li>`).join("")}</ul>`;
21
  }
22
 
23
+ /** The practice test on the weak topics: 15 questions from the skills that lost points (or are still being learned). */
24
+ function focus(test) {
25
+ const f = test.focus;
26
+ if (!f) return "";
27
+ if (!f.skills.length) return `<div id="focus" data-ready="false"><p class="muted">🎯 ${f.attempts ? "אין כרגע נושאים לחיזוק. כל הכבוד!" : "אחרי מבחן תרגול או סימולציה יופיע כאן מבחן על הנושאים שצריך לחזק."}</p></div>`;
28
+ return `<div id="focus" data-ready="true"><p><b>🎯 מבחן תרגול על הנושאים לחיזוק</b> <span class="muted">· ${f.size} שאלות</span></p>
29
+ <p class="focus-skills">${f.skills.map(s => `<a class="ex-chip link" href="#/skill/${esc(s.id)}">${fmt(s.name)}</a>`).join(" ")}</p>
30
+ ${f.best === null ? "" : `<p class="muted">הציון הטוב: ${f.best} · ${f.attempts} ${f.attempts === 1 ? "ניסיון" : "ניסיונות"}</p>`}
31
+ <p><a class="button primary" id="focus-start" href="#/exam/${test.id}/${f.number}">${f.open ? "להמשיך" : f.attempts ? "עוד מבחן על הנושאים" : "התחלה"}</a></p></div>`;
32
+ }
33
+
34
  function simulation(test) {
35
  const s = test.simulation;
36
  const lead = s.taken ? `<p>הציון: <b id="sim-grade">${s.grade}</b></p>${s.review.length ? `<p>מיומנויות לחזרה:</p><ul class="plain" id="sim-review">${s.review.map(r =>
 
52
  <p class="muted">${test.lessons.map(l => fmt(l.name)).join(", ")}${test.note ? ` · ${esc(test.note)}` : ""}<br>${test.questions} שאלות · ${test.minutes} דקות</p>
53
  ${readinessBar(test.readiness)}</section>
54
  <section class="card"><h3>התוכנית</h3>${planList(test)}</section>
55
+ <section class="card"><h3>מבחני תרגול</h3>${exercises(test)}${focus(test)}
56
  ${test.practice_first.length ? `<p id="practice-first"><b>כדאי לתרגל קודם:</b> ${test.practice_first.map(s => `<a class="ex-chip link" href="#/skill/${esc(s.id)}">${fmt(s.name)}</a>`).join(" ")}</p>
57
  <p class="muted">אלה מיומנויות שעוד לא התחלתם, ולכן אין להן שאלות במבחני התרגול.</p>
58
  <p id="all-skills">${test.exercise_tests.map(e => `<a class="button" href="#/exam/${test.id}/${e.number}/all">מבחן תרגול ${e.number} על כל החומר</a>`).join(" ")}</p>
mathly2/tests/test_boardreview_api.py CHANGED
@@ -19,14 +19,17 @@ from mathly2.tests.test_quizzes_api import right_index, skill_of
19
 
20
  class FakeReviewer:
21
  def __init__(self, fail=None):
22
- self.calls, self.fail = [], fail
23
 
24
- def review(self, items, on_event=lambda step, **info: None):
25
- self.calls.append(items)
26
  if self.fail:
27
  raise boardreview.ReviewFailure(self.fail)
28
- return {"summary": "דרך מסודרת. כדאי לבדוק סימנים.",
29
- "questions": [{"n": it["n"], "verdict": "partial", "feedback": f"בשאלה {it['n']} הטעות בשלב השני", "tip": "בדקו סימן"} for it in items]}
 
 
 
30
 
31
 
32
  def make(reviewer):
@@ -58,8 +61,8 @@ def test_handing_in_a_practice_test_sends_its_boards_once_and_the_student_gets_t
58
  fake = FakeReviewer()
59
  client, store, bank, test, _ = make(fake)
60
  attempt, result = hand_in(client, bank, test)
61
- assert len(fake.calls) == 1 and [it["n"] for it in fake.calls[0]] == [1, 2] # one request, only the questions with a board
62
- item = fake.calls[0][0]
63
  assert item["image"] and item["choices"] and isinstance(item["correct"], int) and KID not in json.dumps({k: v for k, v in item.items() if k != "image"})
64
  review = result["review"]
65
  assert review["state"] == "done" and review["summary"].startswith("דרך")
@@ -67,7 +70,7 @@ def test_handing_in_a_practice_test_sends_its_boards_once_and_the_student_gets_t
67
  assert set(review["questions"]) == {first, attempt["questions"][1]["key"]} and review["questions"][first]["verdict"] == "partial"
68
  assert result["keys"][0] == first
69
  again = client.get(f"/api/attempts/{attempt['id']}", headers=auth(KID)).json()
70
- assert again["review"] == review and len(fake.calls) == 1 # read again: not reviewed again
71
  teacher = client.get(f"/api/teaching/work/{attempt['id']}", headers=auth(TEACHER)).json()
72
  assert teacher["questions"][0]["ai"]["feedback"].startswith("בשאלה 1") and teacher["ai_summary"]
73
 
@@ -99,7 +102,7 @@ def test_a_review_lost_by_a_restart_is_started_again():
99
  client, store, bank, test, _ = make(fake)
100
  attempt, _ = hand_in(client, bank, test)
101
  store.save_attempt(attempt["id"], review={"state": "pending", "at": (clock.now() - timedelta(minutes=30)).isoformat()})
102
- assert client.get(f"/api/attempts/{attempt['id']}", headers=auth(KID)).json()["review"]["state"] == "done" and len(fake.calls) == 2
103
 
104
 
105
  # ---- the Gemini client itself ----
@@ -118,9 +121,9 @@ def http_error(code, body=b"{}"):
118
 
119
  def test_gemini_reviewer_sends_one_request_with_the_picture_and_retries_when_told_to_wait():
120
  waits, sent = [], []
121
- reviewer = boardreview.GeminiReviewer("key", sleep=waits.append)
122
  replies = [http_error(429, json.dumps({"error": {"details": [{"retryDelay": "7s"}]}}).encode()),
123
- answer({"summary": "s", "questions": [{"n": 3, "verdict": "wrong", "feedback": "f", "tip": "t"}]})]
124
 
125
  def call(body):
126
  sent.append(json.loads(body))
@@ -129,8 +132,8 @@ def test_gemini_reviewer_sends_one_request_with_the_picture_and_retries_when_tol
129
  raise reply
130
  return reply
131
  reviewer._call = call
132
- got = reviewer.review(items())
133
- assert got["questions"][0]["verdict"] == "wrong" and waits == [8.0] and len(sent) == 2
134
  parts = sent[0]["contents"][0]["parts"]
135
  assert any("inline_data" in p for p in parts) and "לא לגלות" in json.dumps(parts, ensure_ascii=False)
136
  assert sent[0]["generationConfig"]["responseMimeType"] == "application/json"
@@ -138,11 +141,11 @@ def test_gemini_reviewer_sends_one_request_with_the_picture_and_retries_when_tol
138
 
139
  @pytest.mark.parametrize("code,reason", [(400, "bad_request"), (403, "permission"), (404, "model_not_found")])
140
  def test_gemini_errors_have_reasons(code, reason):
141
- reviewer = boardreview.GeminiReviewer("key", sleep=lambda s: None)
142
  reviewer._call = lambda body: (_ for _ in ()).throw(http_error(code))
143
  reviewer._models = lambda: [] # no other model to fall back to
144
  with pytest.raises(boardreview.ReviewFailure) as caught:
145
- reviewer.review(items())
146
  assert caught.value.reason == reason
147
 
148
 
@@ -150,11 +153,11 @@ def test_gemini_bad_or_cut_output_is_a_failure():
150
  reviewer = boardreview.GeminiReviewer("key")
151
  reviewer._call = lambda body: {"candidates": [{"finishReason": "MAX_TOKENS", "content": {"parts": [{"text": "{"}]}}]}
152
  with pytest.raises(boardreview.ReviewFailure) as caught:
153
- reviewer.review(items())
154
  assert caught.value.reason == "incomplete"
155
  reviewer._call = lambda body: {"candidates": [{"finishReason": "STOP", "content": {"parts": [{"text": "not json"}]}}]}
156
  with pytest.raises(boardreview.ReviewFailure) as caught:
157
- reviewer.review(items())
158
  assert caught.value.reason == "bad_output"
159
 
160
 
@@ -166,7 +169,7 @@ def test_a_test_handed_in_before_the_key_was_set_is_reviewed_when_the_teacher_op
166
  assert store.get_attempt(attempt["id"]).get("review") is None
167
  client.app.state.reviewer = fake # the key is set now
168
  got = client.get(f"/api/teaching/work/{attempt['id']}", headers=auth(TEACHER)).json()
169
- assert len(fake.calls) == 1 and got["ai_summary"] and any(q["ai"] for q in got["questions"])
170
 
171
 
172
  def test_the_teacher_sees_the_ai_state_and_can_run_it_again_or_on_a_quiz():
@@ -178,7 +181,7 @@ def test_the_teacher_sees_the_ai_state_and_can_run_it_again_or_on_a_quiz():
178
  assert client.get(f"/api/teaching/work/{attempt['id']}", headers=auth(TEACHER)).json()["ai"]["state"] == "failed" # opening does not retry
179
  fake.fail = None
180
  again = client.post(f"/api/teaching/work/{attempt['id']}/review", headers=auth(TEACHER)).json()
181
- assert again["ai"]["state"] == "done" and again["ai"]["summary"] and len(fake.calls) == 2
182
  lesson = test["lessons"][0]
183
  quiz = client.post(f"/api/lessons/{lesson}/quiz/start", headers=auth(KID)).json()
184
  for q in quiz["questions"]:
@@ -204,7 +207,7 @@ def test_no_key_says_so_and_a_missing_column_does_not_break_the_result():
204
 
205
 
206
  def test_a_model_not_found_falls_back_to_a_flash_model_the_key_has():
207
- reviewer = boardreview.GeminiReviewer("key", " models/gemini-9-flash ")
208
  assert reviewer.model == "gemini-9-flash" # a pasted name is cleaned
209
  reviewer._models = lambda: ["gemini-2.0-flash-lite", "gemini-3-flash-preview", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-image",
210
  "gemini-3.8-flash", "text-embedding-004"]
@@ -214,9 +217,9 @@ def test_a_model_not_found_falls_back_to_a_flash_model_the_key_has():
214
  used.append(reviewer.model)
215
  if reviewer.model == "gemini-9-flash":
216
  raise http_error(404)
217
- return answer({"summary": "s", "questions": []})
218
  reviewer._call = call
219
- assert reviewer.review(items())["summary"] == "s"
220
  assert used == ["gemini-9-flash", "gemini-3.8-flash"] and reviewer.model == "gemini-3.8-flash"
221
 
222
 
@@ -229,19 +232,20 @@ def test_while_it_runs_the_review_stores_its_steps_for_the_screens():
229
  seen = []
230
 
231
  class Watching(FakeReviewer):
232
- def review(self, items, on_event=lambda step, **info: None):
233
  seen.append(boardreview.student_view(store.get_attempt(attempt_id)))
234
  on_event("busy", seconds=8)
235
  seen.append(boardreview.student_view(store.get_attempt(attempt_id)))
236
- on_event("sent")
237
- return super().review(items, on_event)
238
  fake = Watching()
239
  client, store, bank, test, _ = make(None)
240
  attempt, _ = hand_in(client, bank, test, boards=2)
241
  attempt_id = attempt["id"]
242
  client.app.state.reviewer = fake
243
  client.get(f"/api/attempts/{attempt_id}", headers=auth(KID))
244
- assert [e["step"] for e in seen[0]["events"]] == ["boards", "sent"] and seen[0]["events"][0]["count"] == 2
 
 
245
  assert seen[0]["state"] == "pending" and seen[0]["at"]
246
  assert seen[1]["events"][-1] == {**seen[1]["events"][-1], "step": "busy", "seconds": 8}
247
  assert store.get_attempt(attempt_id)["review"]["state"] == "done"
@@ -249,8 +253,51 @@ def test_while_it_runs_the_review_stores_its_steps_for_the_screens():
249
 
250
  def test_gemini_says_when_it_waits_for_the_free_tier():
251
  steps = []
252
- reviewer = boardreview.GeminiReviewer("key", sleep=lambda s: None)
253
- replies = [http_error(429, json.dumps({"error": {"details": [{"retryDelay": "7s"}]}}).encode()), answer({"summary": "s", "questions": []})]
254
  reviewer._call = lambda body: (lambda r: (_ for _ in ()).throw(r) if isinstance(r, Exception) else r)(replies.pop(0))
255
- reviewer.review(items(), on_event=lambda step, **info: steps.append((step, info)))
256
- assert steps == [("busy", {"seconds": 8}), ("sent", {})]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  class FakeReviewer:
21
  def __init__(self, fail=None):
22
+ self.calls, self.fail, self.summaries = [], fail, [] # calls: the questions asked about, one request each
23
 
24
+ def review_one(self, item, on_event=lambda step, **info: None):
25
+ self.calls.append(item)
26
  if self.fail:
27
  raise boardreview.ReviewFailure(self.fail)
28
+ return {"verdict": "partial", "feedback": f"בשאלה {item['n']} הטעות בשלב השני", "tip": "בדקו סימן"}
29
+
30
+ def summarize(self, reviewed, on_event=lambda step, **info: None):
31
+ self.summaries.append(reviewed)
32
+ return "דרך מסודרת. כדאי לבדוק סימנים."
33
 
34
 
35
  def make(reviewer):
 
61
  fake = FakeReviewer()
62
  client, store, bank, test, _ = make(fake)
63
  attempt, result = hand_in(client, bank, test)
64
+ assert [it["n"] for it in fake.calls] == [1, 2] and len(fake.summaries) == 1 # a request per question with a board, then a summary
65
+ item = fake.calls[0]
66
  assert item["image"] and item["choices"] and isinstance(item["correct"], int) and KID not in json.dumps({k: v for k, v in item.items() if k != "image"})
67
  review = result["review"]
68
  assert review["state"] == "done" and review["summary"].startswith("דרך")
 
70
  assert set(review["questions"]) == {first, attempt["questions"][1]["key"]} and review["questions"][first]["verdict"] == "partial"
71
  assert result["keys"][0] == first
72
  again = client.get(f"/api/attempts/{attempt['id']}", headers=auth(KID)).json()
73
+ assert again["review"] == review and len(fake.calls) == 2 # read again: not reviewed again
74
  teacher = client.get(f"/api/teaching/work/{attempt['id']}", headers=auth(TEACHER)).json()
75
  assert teacher["questions"][0]["ai"]["feedback"].startswith("בשאלה 1") and teacher["ai_summary"]
76
 
 
102
  client, store, bank, test, _ = make(fake)
103
  attempt, _ = hand_in(client, bank, test)
104
  store.save_attempt(attempt["id"], review={"state": "pending", "at": (clock.now() - timedelta(minutes=30)).isoformat()})
105
+ assert client.get(f"/api/attempts/{attempt['id']}", headers=auth(KID)).json()["review"]["state"] == "done" and len(fake.calls) == 4
106
 
107
 
108
  # ---- the Gemini client itself ----
 
121
 
122
  def test_gemini_reviewer_sends_one_request_with_the_picture_and_retries_when_told_to_wait():
123
  waits, sent = [], []
124
+ reviewer = boardreview.GeminiReviewer("key", sleep=waits.append, gap=0)
125
  replies = [http_error(429, json.dumps({"error": {"details": [{"retryDelay": "7s"}]}}).encode()),
126
+ answer({"verdict": "wrong", "feedback": "f", "tip": "t"})]
127
 
128
  def call(body):
129
  sent.append(json.loads(body))
 
132
  raise reply
133
  return reply
134
  reviewer._call = call
135
+ got = reviewer.review_one(items()[0])
136
+ assert got["verdict"] == "wrong" and waits == [8.0] and len(sent) == 2
137
  parts = sent[0]["contents"][0]["parts"]
138
  assert any("inline_data" in p for p in parts) and "לא לגלות" in json.dumps(parts, ensure_ascii=False)
139
  assert sent[0]["generationConfig"]["responseMimeType"] == "application/json"
 
141
 
142
  @pytest.mark.parametrize("code,reason", [(400, "bad_request"), (403, "permission"), (404, "model_not_found")])
143
  def test_gemini_errors_have_reasons(code, reason):
144
+ reviewer = boardreview.GeminiReviewer("key", sleep=lambda s: None, gap=0)
145
  reviewer._call = lambda body: (_ for _ in ()).throw(http_error(code))
146
  reviewer._models = lambda: [] # no other model to fall back to
147
  with pytest.raises(boardreview.ReviewFailure) as caught:
148
+ reviewer.review_one(items()[0])
149
  assert caught.value.reason == reason
150
 
151
 
 
153
  reviewer = boardreview.GeminiReviewer("key")
154
  reviewer._call = lambda body: {"candidates": [{"finishReason": "MAX_TOKENS", "content": {"parts": [{"text": "{"}]}}]}
155
  with pytest.raises(boardreview.ReviewFailure) as caught:
156
+ reviewer.review_one(items()[0])
157
  assert caught.value.reason == "incomplete"
158
  reviewer._call = lambda body: {"candidates": [{"finishReason": "STOP", "content": {"parts": [{"text": "not json"}]}}]}
159
  with pytest.raises(boardreview.ReviewFailure) as caught:
160
+ reviewer.review_one(items()[0])
161
  assert caught.value.reason == "bad_output"
162
 
163
 
 
169
  assert store.get_attempt(attempt["id"]).get("review") is None
170
  client.app.state.reviewer = fake # the key is set now
171
  got = client.get(f"/api/teaching/work/{attempt['id']}", headers=auth(TEACHER)).json()
172
+ assert len(fake.calls) == 2 and got["ai_summary"] and any(q["ai"] for q in got["questions"])
173
 
174
 
175
  def test_the_teacher_sees_the_ai_state_and_can_run_it_again_or_on_a_quiz():
 
181
  assert client.get(f"/api/teaching/work/{attempt['id']}", headers=auth(TEACHER)).json()["ai"]["state"] == "failed" # opening does not retry
182
  fake.fail = None
183
  again = client.post(f"/api/teaching/work/{attempt['id']}/review", headers=auth(TEACHER)).json()
184
+ assert again["ai"]["state"] == "done" and again["ai"]["summary"] and len(fake.calls) == 3 # the first stopped at "rate limited"
185
  lesson = test["lessons"][0]
186
  quiz = client.post(f"/api/lessons/{lesson}/quiz/start", headers=auth(KID)).json()
187
  for q in quiz["questions"]:
 
207
 
208
 
209
  def test_a_model_not_found_falls_back_to_a_flash_model_the_key_has():
210
+ reviewer = boardreview.GeminiReviewer("key", " models/gemini-9-flash ", gap=0)
211
  assert reviewer.model == "gemini-9-flash" # a pasted name is cleaned
212
  reviewer._models = lambda: ["gemini-2.0-flash-lite", "gemini-3-flash-preview", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-image",
213
  "gemini-3.8-flash", "text-embedding-004"]
 
217
  used.append(reviewer.model)
218
  if reviewer.model == "gemini-9-flash":
219
  raise http_error(404)
220
+ return answer({"verdict": "correct", "feedback": "f", "tip": "t"})
221
  reviewer._call = call
222
+ assert reviewer.review_one(items()[0])["verdict"] == "correct"
223
  assert used == ["gemini-9-flash", "gemini-3.8-flash"] and reviewer.model == "gemini-3.8-flash"
224
 
225
 
 
232
  seen = []
233
 
234
  class Watching(FakeReviewer):
235
+ def review_one(self, item, on_event=lambda step, **info: None):
236
  seen.append(boardreview.student_view(store.get_attempt(attempt_id)))
237
  on_event("busy", seconds=8)
238
  seen.append(boardreview.student_view(store.get_attempt(attempt_id)))
239
+ return super().review_one(item, on_event)
 
240
  fake = Watching()
241
  client, store, bank, test, _ = make(None)
242
  attempt, _ = hand_in(client, bank, test, boards=2)
243
  attempt_id = attempt["id"]
244
  client.app.state.reviewer = fake
245
  client.get(f"/api/attempts/{attempt_id}", headers=auth(KID))
246
+ assert [e["step"] for e in seen[0]["events"]] == ["boards", "question"] and seen[0]["events"][0]["count"] == 2
247
+ assert seen[0]["events"][1] == {**seen[0]["events"][1], "n": 1, "i": 1, "of": 2} and seen[0]["questions"] == {}
248
+ assert len(seen[2]["questions"]) == 1 # the first question's review shows before the second
249
  assert seen[0]["state"] == "pending" and seen[0]["at"]
250
  assert seen[1]["events"][-1] == {**seen[1]["events"][-1], "step": "busy", "seconds": 8}
251
  assert store.get_attempt(attempt_id)["review"]["state"] == "done"
 
253
 
254
  def test_gemini_says_when_it_waits_for_the_free_tier():
255
  steps = []
256
+ reviewer = boardreview.GeminiReviewer("key", sleep=lambda s: None, gap=0)
257
+ replies = [http_error(429, json.dumps({"error": {"details": [{"retryDelay": "7s"}]}}).encode()), answer({"verdict": "correct", "feedback": "f", "tip": "t"})]
258
  reviewer._call = lambda body: (lambda r: (_ for _ in ()).throw(r) if isinstance(r, Exception) else r)(replies.pop(0))
259
+ reviewer.review_one(items()[0], on_event=lambda step, **info: steps.append((step, info)))
260
+ assert steps == [("busy", {"seconds": 8}), ("resumed", {})]
261
+
262
+
263
+ def test_requests_keep_a_gap_for_the_free_tier_and_the_summary_has_no_picture():
264
+ waits, sent, t = [], [], [100.0]
265
+ reviewer = boardreview.GeminiReviewer("key", sleep=lambda s: (waits.append(round(s, 1)), t.__setitem__(0, t[0] + s)), gap=5.0, now=lambda: t[0])
266
+ reviewer._call = lambda body: (sent.append(json.loads(body)), answer({"verdict": "correct", "feedback": "f", "tip": "t", "summary": "סיכום"}))[1]
267
+ reviewer.review_one(items()[0])
268
+ t[0] += 1.0
269
+ reviewer.review_one(items()[0])
270
+ assert reviewer.summarize([{"n": 1, "skill": "s", "verdict": "correct", "feedback": "f"}]) == "סיכום"
271
+ assert waits == [4.0, 5.0] and not any("inline_data" in p for p in sent[2]["contents"][0]["parts"])
272
+
273
+
274
+ class QuotaAfterFirst(FakeReviewer):
275
+ """The first question works, the second hits the quota (a stopping failure); with `fail` cleared everything works."""
276
+ def __init__(self):
277
+ super().__init__()
278
+ self.quota = True
279
+
280
+ def review_one(self, item, on_event=None):
281
+ if self.quota and len(self.calls) == 1:
282
+ self.calls.append(item)
283
+ raise boardreview.ReviewFailure("rate_limited")
284
+ return super().review_one(item)
285
+
286
+
287
+ def test_a_stopping_failure_keeps_what_was_checked_and_one_question_can_be_checked_alone():
288
+ fake = QuotaAfterFirst()
289
+ client, store, bank, test, _ = make(None)
290
+ attempt, _ = hand_in(client, bank, test, boards=3)
291
+ client.app.state.reviewer = fake
292
+ view = client.get(f"/api/teaching/work/{attempt['id']}", headers=auth(TEACHER)).json()
293
+ review = store.get_attempt(attempt["id"])["review"]
294
+ assert review["state"] == "done" and len(review["questions"]) == 1 and review["missing"] == 2 and review["reason"] == "rate_limited"
295
+ assert len(fake.calls) == 2 and view["ai"]["missing"] == 2 # stopped: the third was never asked
296
+ fake.quota = False
297
+ third = attempt["questions"][2]["key"]
298
+ got = client.post(f"/api/teaching/work/{attempt['id']}/review?key={third}", headers=auth(TEACHER)).json()
299
+ review = store.get_attempt(attempt["id"])["review"]
300
+ assert fake.calls[-1]["n"] == 3 and len(fake.calls) == 3 and set(review["questions"]) == {attempt["questions"][0]["key"], third}
301
+ assert review["summary"] == got["ai"]["summary"] and next(q for q in got["questions"] if q["key"] == third)["ai"]
302
+ no_board = attempt["questions"][5]["key"]
303
+ assert client.post(f"/api/teaching/work/{attempt['id']}/review?key={no_board}", headers=auth(TEACHER)).status_code == 409
mathly2/tests/test_boardwork_browser.py CHANGED
@@ -183,6 +183,11 @@ def test_the_teacher_sees_why_the_ai_failed_and_runs_it_again(browser, bank):
183
  teacher.wait_for_selector("#work-ai[data-state='done']", timeout=15000)
184
  assert "סיכום ה־AI" in teacher.inner_text("#work-ai") and teacher.locator(".work-q .work-ai").count() == 1
185
  assert teacher.get_attribute(f".work-q[data-key='{second}'] [data-points='0.5']", "aria-pressed") == "true" # the pick stayed
 
 
 
 
 
186
  assert teacher.errors == []
187
 
188
 
@@ -194,10 +199,10 @@ def test_while_the_ai_works_the_student_and_the_teacher_see_its_steps(browser, b
194
  release = threading.Event()
195
 
196
  class Slow(FakeReviewer):
197
- def review(self, items, on_event=lambda step, **info: None):
198
  on_event("busy", seconds=30)
199
  release.wait(30)
200
- return super().review(items, on_event)
201
  lessons = [l["id"] for l in bank.lessons][:2]
202
  store = InMemoryStore()
203
  klass = store.create_class("setup", "A", lessons=lessons, teacher_email=TEACHER)
 
183
  teacher.wait_for_selector("#work-ai[data-state='done']", timeout=15000)
184
  assert "סיכום ה־AI" in teacher.inner_text("#work-ai") and teacher.locator(".work-q .work-ai").count() == 1
185
  assert teacher.get_attribute(f".work-q[data-key='{second}'] [data-points='0.5']", "aria-pressed") == "true" # the pick stayed
186
+ first = attempt["questions"][0]["key"]
187
+ asked = len(fake.calls)
188
+ teacher.click(f".work-q[data-key='{first}'] [data-ai-key]") # one question alone
189
+ teacher.wait_for_selector("#work-ai[data-state='done']", timeout=15000)
190
+ assert len(fake.calls) == asked + 1 and fake.calls[-1]["n"] == 1
191
  assert teacher.errors == []
192
 
193
 
 
199
  release = threading.Event()
200
 
201
  class Slow(FakeReviewer):
202
+ def review_one(self, item, on_event=lambda step, **info: None):
203
  on_event("busy", seconds=30)
204
  release.wait(30)
205
+ return super().review_one(item, on_event)
206
  lessons = [l["id"] for l in bank.lessons][:2]
207
  store = InMemoryStore()
208
  klass = store.create_class("setup", "A", lessons=lessons, teacher_email=TEACHER)
mathly2/tests/test_home_browser.py CHANGED
@@ -388,6 +388,8 @@ def test_after_the_simulation_the_wrong_questions_are_answered_again_in_a_round(
388
  page.click("#qz-retry")
389
  page.wait_for_selector(".round-layer #ex-q")
390
  assert "חזרה על הטעויות" in page.inner_text(".round-layer .ex-chip") and "מתוך 6" in page.inner_text("#rd-count")
 
 
391
  for n in range(6):
392
  page.locator(".round-layer .ex-choice").first.click()
393
  page.wait_for_selector(".round-layer .ex-ok, .round-layer .ex-bad")
@@ -400,6 +402,13 @@ def test_after_the_simulation_the_wrong_questions_are_answered_again_in_a_round(
400
  page.click("#rd-result [data-close]")
401
  page.wait_for_selector("#test-card")
402
  assert page.locator(".round-layer").count() == 0 and page.errors == []
 
 
 
 
 
 
 
403
 
404
 
405
  # --- 3.5 the grades screen has exercise tests and simulations ---
 
388
  page.click("#qz-retry")
389
  page.wait_for_selector(".round-layer #ex-q")
390
  assert "חזרה על הטעויות" in page.inner_text(".round-layer .ex-chip") and "מתוך 6" in page.inner_text("#rd-count")
391
+ context = page.inner_text("#rd-context")
392
+ assert "מבחן סימולציה" in context and "שאלה 1 מתוך 6" in context and "לא עניתם" in context # where the question came from
393
  for n in range(6):
394
  page.locator(".round-layer .ex-choice").first.click()
395
  page.wait_for_selector(".round-layer .ex-ok, .round-layer .ex-bad")
 
402
  page.click("#rd-result [data-close]")
403
  page.wait_for_selector("#test-card")
404
  assert page.locator(".round-layer").count() == 0 and page.errors == []
405
+ page.wait_for_selector("#focus[data-ready='true']") # the weak topics now have their own test
406
+ page.click("#focus-start")
407
+ page.wait_for_selector("#qz-q")
408
+ assert "נושאים לחיזוק" in page.inner_text(".mx-sheet h3") and 0 < page.locator(".qz-dot").count() <= 15
409
+ focus = store.attempts_of(KID_ID, "exercise_test")[0]
410
+ lost = {q["skill_id"] for q in store.attempts_of(KID_ID, "simulation")[0]["questions"]}
411
+ assert focus["scope"].endswith(":3") and {q["skill_id"] for q in focus["questions"]} <= lost and page.errors == []
412
 
413
 
414
  # --- 3.5 the grades screen has exercise tests and simulations ---
mathly2/tests/test_realtests_api.py CHANGED
@@ -341,7 +341,7 @@ def test_an_exercise_test_with_nothing_started_draws_all_the_material_and_a_weak
341
  test = set_test(client, lessons, days=14, questions=6).json()
342
  got = start_ex(client, test["id"])
343
  assert got.status_code == 200 and len(got.json()["questions"]) == 6 # not refused: drawn like the simulation
344
- assert start_ex(client, test["id"], 3).status_code == 404
345
 
346
 
347
  def test_an_exercise_test_with_all_skills_starts_even_when_nothing_is_started(world):
@@ -467,6 +467,8 @@ def test_after_the_simulation_its_wrong_questions_can_be_answered_again_without_
467
  assert rnd["kind"] == "mistakes_round" and rnd["total"] == 4 and rnd["source"]["test_id"] == test["id"]
468
  assert [q["key"] for q in store.get_attempt(rnd["id"])["questions"]] == [q["key"] for q in sim["questions"][8:]]
469
  assert '"correct"' not in json.dumps(rnd)
 
 
470
  assert client.post(f"/api/attempts/{sim['id']}/retry/start", headers=auth(KID)).json()["id"] == rnd["id"] # an open one resumes
471
  for ok in [True, False, True, True]:
472
  key = rnd["question"]["key"]
@@ -474,6 +476,7 @@ def test_after_the_simulation_its_wrong_questions_can_be_answered_again_without_
474
  assert got.status_code == 200 and got.json()["correct"] is ok
475
  rnd = got.json()["round"]
476
  assert rnd["state"] == "done" and rnd["result"]["right"] == 3 and rnd["result"]["stars"] is None and len(rnd["result"]["missed"]) == 1
 
477
  assert store.get_attempt(sim["id"])["grade"] == 67 # the simulation's grade stays
478
  assert client.post(f"/api/attempts/{sim['id']}/retry/start", headers=auth(KID)).json()["id"] != rnd["id"] # again: a new round
479
  assert client.post(f"/api/attempts/{sim['id']}/retry/start", headers=auth(TEACHER)).status_code == 404 # only the student's own
@@ -485,3 +488,26 @@ def test_a_test_with_no_mistakes_has_nothing_to_retry(world):
485
  started(client, store, lessons)
486
  done = answer_all(client, client.app.state.bank, start_ex(client, test["id"]).json(), [True] * 6)
487
  assert client.post(f"/api/attempts/{done['id']}/retry/start", headers=auth(KID)).status_code == 409
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  test = set_test(client, lessons, days=14, questions=6).json()
342
  got = start_ex(client, test["id"])
343
  assert got.status_code == 200 and len(got.json()["questions"]) == 6 # not refused: drawn like the simulation
344
+ assert start_ex(client, test["id"], 4).status_code == 404
345
 
346
 
347
  def test_an_exercise_test_with_all_skills_starts_even_when_nothing_is_started(world):
 
467
  assert rnd["kind"] == "mistakes_round" and rnd["total"] == 4 and rnd["source"]["test_id"] == test["id"]
468
  assert [q["key"] for q in store.get_attempt(rnd["id"])["questions"]] == [q["key"] for q in sim["questions"][8:]]
469
  assert '"correct"' not in json.dumps(rnd)
470
+ ctx = rnd["question"]["context"]
471
+ assert ctx["n"] == 9 and ctx["of"] == 12 and ctx["title"].startswith("מבחן סימולציה") and ctx["picked"] # where it came from, and the old pick
472
  assert client.post(f"/api/attempts/{sim['id']}/retry/start", headers=auth(KID)).json()["id"] == rnd["id"] # an open one resumes
473
  for ok in [True, False, True, True]:
474
  key = rnd["question"]["key"]
 
476
  assert got.status_code == 200 and got.json()["correct"] is ok
477
  rnd = got.json()["round"]
478
  assert rnd["state"] == "done" and rnd["result"]["right"] == 3 and rnd["result"]["stars"] is None and len(rnd["result"]["missed"]) == 1
479
+ assert rnd["result"]["missed"][0]["n"] == 10
480
  assert store.get_attempt(sim["id"])["grade"] == 67 # the simulation's grade stays
481
  assert client.post(f"/api/attempts/{sim['id']}/retry/start", headers=auth(KID)).json()["id"] != rnd["id"] # again: a new round
482
  assert client.post(f"/api/attempts/{sim['id']}/retry/start", headers=auth(TEACHER)).status_code == 404 # only the student's own
 
488
  started(client, store, lessons)
489
  done = answer_all(client, client.app.state.bank, start_ex(client, test["id"]).json(), [True] * 6)
490
  assert client.post(f"/api/attempts/{done['id']}/retry/start", headers=auth(KID)).status_code == 409
491
+
492
+
493
+ def test_the_weak_topics_test_has_15_questions_only_from_the_skills_that_lost_points(world):
494
+ client, store, lessons = world
495
+ bank = client.app.state.bank
496
+ test = set_test(client, lessons, days=14).json()
497
+ client.set_time(MON + 12 * DAY)
498
+ assert client.get(f"/api/tests/{test['id']}", headers=auth(KID)).json()["focus"]["skills"] == []
499
+ assert start_ex(client, test["id"], 3).status_code == 409 # nothing weak yet
500
+ sim = client.post(f"/api/tests/{test['id']}/simulation/start?all_skills=true", headers=auth(KID)).json()
501
+ done = answer_all(client, bank, sim, [True] * 8 + [False] * 4)
502
+ lost = {s["id"] for s in done["lost_skills"]}
503
+ card = client.get(f"/api/tests/{test['id']}", headers=auth(KID)).json()
504
+ assert {s["id"] for s in card["focus"]["skills"]} == lost and card["focus"]["size"] == 15 and card["focus"]["best"] is None
505
+ focus = start_ex(client, test["id"], 3).json()
506
+ assert focus["kind"] == "exercise_test" and focus["test"]["number"] == 3 and len(focus["questions"]) == 15
507
+ assert {q["skill_id"] for q in store.get_attempt(focus["id"])["questions"]} <= lost
508
+ assert len({q["key"] for q in store.get_attempt(focus["id"])["questions"]}) == 15 # no question twice
509
+ assert start_ex(client, test["id"], 3).json()["id"] == focus["id"] # open: it resumes
510
+ answer_all(client, bank, focus, [True] * 15)
511
+ card = client.get(f"/api/tests/{test['id']}", headers=auth(KID)).json()
512
+ assert card["focus"]["best"] == 100 and card["focus"]["attempts"] == 1
513
+ assert [e["number"] for e in card["exercise_tests"]] == [1, 2] and all(e["best"] is None for e in card["exercise_tests"]) # not exercise test 1 or 2