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 +110 -61
- mathly2/boardwork.py +13 -5
- mathly2/exam.py +20 -0
- mathly2/realtests.py +26 -7
- mathly2/rounds.py +24 -5
- mathly2/static/app.css +6 -0
- mathly2/static/grades.js +1 -1
- mathly2/static/quiz.js +15 -8
- mathly2/static/reviewsteps.js +31 -12
- mathly2/static/round.js +5 -2
- mathly2/static/teacher_boardwork.js +29 -9
- mathly2/static/testcard.js +12 -1
- mathly2/tests/test_boardreview_api.py +77 -30
- mathly2/tests/test_boardwork_browser.py +7 -2
- mathly2/tests/test_home_browser.py +9 -0
- mathly2/tests/test_realtests_api.py +27 -1
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 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 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,
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 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
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 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, "
|
|
|
|
| 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
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
started = clock.now().isoformat()
|
| 216 |
events: list[dict] = []
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
-
def
|
| 219 |
-
events.append({"step": step, "at": clock.now().isoformat(), **info})
|
| 220 |
try:
|
| 221 |
-
store.save_attempt(attempt_id, review=
|
| 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 |
-
|
| 226 |
-
|
|
|
|
| 227 |
event("boards", count=len(items), total=len(row["questions"]))
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
else:
|
| 231 |
-
|
| 232 |
-
|
| 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"
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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,
|
| 165 |
-
|
|
|
|
| 166 |
from . import boardreview
|
| 167 |
if getattr(request.app.state, "reviewer", None) is None:
|
| 168 |
raise HTTPException(409, "בדיקה עם AI לא מוגדרת בשרת: חסר GEMINI_API_KEY")
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 168 |
return {"id": row["scope"], "kind": kind, "test_id": test_id, "lesson_id": source["scope"] if source and kind == "quiz" else None,
|
| 169 |
-
"title":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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)}
|
|
|
|
| 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
|
| 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
|
| 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>
|
|
|
|
| 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 |
-
/**
|
| 8 |
function lines(review) {
|
| 9 |
-
const events = review.events || [];
|
| 10 |
if (!events.length) return [["now", "⏳", "מתחילים…"]];
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 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 `<
|
| 27 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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) : "
|
| 31 |
-
|
|
|
|
|
|
|
| 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>${
|
| 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 |
-
|
| 83 |
-
|
| 84 |
-
try { await open(id, "", await api(`/api/teaching/work/${encodeURIComponent(id)}/review`, { method: "POST" }), chosen); }
|
| 85 |
-
catch (error) {
|
| 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
|
| 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
|
| 25 |
-
self.calls.append(
|
| 26 |
if self.fail:
|
| 27 |
raise boardreview.ReviewFailure(self.fail)
|
| 28 |
-
return {"
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 62 |
-
item = fake.calls[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) ==
|
| 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) ==
|
| 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({"
|
| 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.
|
| 133 |
-
assert got["
|
| 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.
|
| 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.
|
| 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.
|
| 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) ==
|
| 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) ==
|
| 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({"
|
| 218 |
reviewer._call = call
|
| 219 |
-
assert reviewer.
|
| 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
|
| 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 |
-
|
| 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", "
|
|
|
|
|
|
|
| 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({"
|
| 254 |
reviewer._call = lambda body: (lambda r: (_ for _ in ()).throw(r) if isinstance(r, Exception) else r)(replies.pop(0))
|
| 255 |
-
reviewer.
|
| 256 |
-
assert steps == [("busy", {"seconds": 8}), ("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 198 |
on_event("busy", seconds=30)
|
| 199 |
release.wait(30)
|
| 200 |
-
return super().
|
| 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"],
|
| 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
|