Board review: the steps of a running review (boards found, sent, the AI busy) on the student's and the teacher's screens
Browse files- mathly2/boardreview.py +21 -4
- mathly2/boardwork.py +2 -1
- mathly2/static/app.css +9 -1
- mathly2/static/quiz.js +2 -1
- mathly2/static/reviewsteps.js +31 -0
- mathly2/static/teacher_boardwork.js +6 -2
- mathly2/tests/test_boardreview_api.py +33 -2
- mathly2/tests/test_boardwork_browser.py +44 -0
mathly2/boardreview.py
CHANGED
|
@@ -111,7 +111,7 @@ class GeminiReviewer:
|
|
| 111 |
return None
|
| 112 |
return pick_model(names, self.model)
|
| 113 |
|
| 114 |
-
def review(self, items: list[dict]) -> dict:
|
| 115 |
body = json.dumps({"contents": [{"role": "user", "parts": self._parts(items)}],
|
| 116 |
"generationConfig": {"temperature": 0.2, "responseMimeType": "application/json", "responseSchema": SCHEMA}}).encode()
|
| 117 |
with self._lock:
|
|
@@ -122,7 +122,10 @@ class GeminiReviewer:
|
|
| 122 |
except urllib.error.HTTPError as exc:
|
| 123 |
detail = exc.read().decode("utf-8", "replace")[:500]
|
| 124 |
if exc.code == 429 and attempt < RETRIES:
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
| 126 |
continue
|
| 127 |
if exc.code == 404 and not getattr(self, "_switched", False):
|
| 128 |
other = self._another_model()
|
|
@@ -206,15 +209,27 @@ def _items(app, row: dict) -> tuple[list[dict], dict]:
|
|
| 206 |
|
| 207 |
|
| 208 |
def run(app, attempt_id: str) -> None:
|
| 209 |
-
"""Review one handed-in attempt and store the result on it. Never raises: a failure is stored as {"state": "failed"}.
|
|
|
|
| 210 |
store = app.state.store
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
try:
|
| 212 |
row = store.get_attempt(attempt_id)
|
| 213 |
items, keys = _items(app, row)
|
|
|
|
| 214 |
if not items:
|
| 215 |
result = {"state": "done", "summary": None, "questions": {}}
|
| 216 |
else:
|
| 217 |
-
|
|
|
|
| 218 |
questions = {}
|
| 219 |
for q in got.get("questions", []):
|
| 220 |
key = keys.get(q.get("n"))
|
|
@@ -271,6 +286,8 @@ def student_view(row: dict) -> dict | None:
|
|
| 271 |
review = row.get("review")
|
| 272 |
if not review:
|
| 273 |
return None
|
|
|
|
|
|
|
| 274 |
if review["state"] != "done":
|
| 275 |
return {"state": review["state"]}
|
| 276 |
return {"state": "done", "summary": review.get("summary"), "questions": review.get("questions", {})}
|
|
|
|
| 111 |
return None
|
| 112 |
return pick_model(names, self.model)
|
| 113 |
|
| 114 |
+
def review(self, items: list[dict], on_event=lambda step, **info: None) -> dict:
|
| 115 |
body = json.dumps({"contents": [{"role": "user", "parts": self._parts(items)}],
|
| 116 |
"generationConfig": {"temperature": 0.2, "responseMimeType": "application/json", "responseSchema": SCHEMA}}).encode()
|
| 117 |
with self._lock:
|
|
|
|
| 122 |
except urllib.error.HTTPError as exc:
|
| 123 |
detail = exc.read().decode("utf-8", "replace")[:500]
|
| 124 |
if exc.code == 429 and attempt < RETRIES:
|
| 125 |
+
wait = _retry_after(detail, 20 * (attempt + 1))
|
| 126 |
+
on_event("busy", seconds=round(wait))
|
| 127 |
+
self.sleep(wait)
|
| 128 |
+
on_event("sent")
|
| 129 |
continue
|
| 130 |
if exc.code == 404 and not getattr(self, "_switched", False):
|
| 131 |
other = self._another_model()
|
|
|
|
| 209 |
|
| 210 |
|
| 211 |
def run(app, attempt_id: str) -> None:
|
| 212 |
+
"""Review one handed-in attempt and store the result on it. Never raises: a failure is stored as {"state": "failed"}.
|
| 213 |
+
While it runs, its steps are stored on the pending review (`events`: boards found, sent, the AI busy and waiting), for the screens."""
|
| 214 |
store = app.state.store
|
| 215 |
+
started = clock.now().isoformat()
|
| 216 |
+
events: list[dict] = []
|
| 217 |
+
|
| 218 |
+
def event(step: str, **info) -> None:
|
| 219 |
+
events.append({"step": step, "at": clock.now().isoformat(), **info})
|
| 220 |
+
try:
|
| 221 |
+
store.save_attempt(attempt_id, review={"state": "pending", "at": started, "events": list(events)})
|
| 222 |
+
except Exception: # the steps are a courtesy: never fail the review for them
|
| 223 |
+
log.warning("could not save a review step")
|
| 224 |
try:
|
| 225 |
row = store.get_attempt(attempt_id)
|
| 226 |
items, keys = _items(app, row)
|
| 227 |
+
event("boards", count=len(items), total=len(row["questions"]))
|
| 228 |
if not items:
|
| 229 |
result = {"state": "done", "summary": None, "questions": {}}
|
| 230 |
else:
|
| 231 |
+
event("sent")
|
| 232 |
+
got = app.state.reviewer.review(items, on_event=event)
|
| 233 |
questions = {}
|
| 234 |
for q in got.get("questions", []):
|
| 235 |
key = keys.get(q.get("n"))
|
|
|
|
| 286 |
review = row.get("review")
|
| 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", {})}
|
mathly2/boardwork.py
CHANGED
|
@@ -150,7 +150,8 @@ def _ai_state(request: Request, row: dict) -> dict:
|
|
| 150 |
"""The AI review as the teacher sees it: can it run here (a key is set), its state, and why it failed."""
|
| 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 |
|
| 155 |
|
| 156 |
@router.get("/teaching/work/{attempt_id}")
|
|
|
|
| 150 |
"""The AI review as the teacher sees it: can it run here (a key is set), its state, and why it failed."""
|
| 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}")
|
mathly2/static/app.css
CHANGED
|
@@ -692,8 +692,16 @@ body:has(#lesson-map) p:has(> #to-subjects) { margin-block: 0 4px; }
|
|
| 692 |
.qz-review-list li p { margin: 4px 0; }
|
| 693 |
.qz-review-list li.v-correct .tag { border-color: var(--ok, #15803d); }
|
| 694 |
.qz-review-list li.v-wrong .tag { border-color: var(--danger); }
|
| 695 |
-
.qz-review .spin { display: inline-block; width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--line); border-top-color: var(--accent); animation: spin 1s linear infinite; vertical-align: middle; }
|
| 696 |
@keyframes spin { to { transform: rotate(360deg); } }
|
| 697 |
.work-ai { margin: 4px 0 8px; }
|
| 698 |
.work-ai-panel p { margin: 4px 0; }
|
| 699 |
.work-ai-panel button { margin-top: 6px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
.qz-review-list li p { margin: 4px 0; }
|
| 693 |
.qz-review-list li.v-correct .tag { border-color: var(--ok, #15803d); }
|
| 694 |
.qz-review-list li.v-wrong .tag { border-color: var(--danger); }
|
| 695 |
+
.qz-review .spin, .rv-steps .spin { display: inline-block; width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--line); border-top-color: var(--accent); animation: spin 1s linear infinite; vertical-align: middle; }
|
| 696 |
@keyframes spin { to { transform: rotate(360deg); } }
|
| 697 |
.work-ai { margin: 4px 0 8px; }
|
| 698 |
.work-ai-panel p { margin: 4px 0; }
|
| 699 |
.work-ai-panel button { margin-top: 6px; }
|
| 700 |
+
|
| 701 |
+
/* the steps of an AI review while it runs */
|
| 702 |
+
.rv-steps { margin: 6px 0; }
|
| 703 |
+
.rv-steps li { display: flex; gap: 8px; align-items: center; padding: 3px 0; }
|
| 704 |
+
.rv-steps li.rv-done { color: var(--muted); }
|
| 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; }
|
mathly2/static/quiz.js
CHANGED
|
@@ -7,6 +7,7 @@ import { figures } from "./figures.js";
|
|
| 7 |
import { esc, fmt } from "./math.js";
|
| 8 |
import { pathHref, shuffled } from "./ui.js";
|
| 9 |
import { hasWork, sendWork } from "./boardwork.js";
|
|
|
|
| 10 |
|
| 11 |
const LIVE_AT = new Set([300, 60, 10, 0]); // seconds left at which the timer is announced to a screen reader
|
| 12 |
|
|
@@ -202,7 +203,7 @@ function reviewHtml(r) {
|
|
| 202 |
const review = r.review;
|
| 203 |
if (!review) return "";
|
| 204 |
if (review.state === "pending") return `<section class="qz-review" id="qz-review" data-state="pending"><h4>משוב על הדרך</h4>
|
| 205 |
-
<p class="muted">
|
| 206 |
if (review.state === "failed") return `<section class="qz-review" id="qz-review" data-state="failed"><h4>משוב על הדרך</h4><p class="muted">לא הצלחנו לבדוק את הדרך הפעם. המורה יראו את הלוחות.</p></section>`;
|
| 207 |
const rows = (r.keys || []).map((key, i) => [i + 1, review.questions[key]]).filter(([, q]) => q);
|
| 208 |
return `<section class="qz-review" id="qz-review" data-state="done"><h4>משוב על הדרך</h4>
|
|
|
|
| 7 |
import { esc, fmt } from "./math.js";
|
| 8 |
import { pathHref, shuffled } from "./ui.js";
|
| 9 |
import { hasWork, sendWork } from "./boardwork.js";
|
| 10 |
+
import { stepsHtml } from "./reviewsteps.js";
|
| 11 |
|
| 12 |
const LIVE_AT = new Set([300, 60, 10, 0]); // seconds left at which the timer is announced to a screen reader
|
| 13 |
|
|
|
|
| 203 |
const review = r.review;
|
| 204 |
if (!review) return "";
|
| 205 |
if (review.state === "pending") return `<section class="qz-review" id="qz-review" data-state="pending"><h4>משוב על הדרך</h4>
|
| 206 |
+
<p class="muted">ה־AI עובר על הדרך שכתבתם בלוח. אפשר להמשיך לקרוא את התוצאה, המשוב יופיע כאן.</p>${stepsHtml(review)}</section>`;
|
| 207 |
if (review.state === "failed") return `<section class="qz-review" id="qz-review" data-state="failed"><h4>משוב על הדרך</h4><p class="muted">לא הצלחנו לבדוק את הדרך הפעם. המורה יראו את הלוחות.</p></section>`;
|
| 208 |
const rows = (r.keys || []).map((key, i) => [i + 1, review.questions[key]]).filter(([, q]) => q);
|
| 209 |
return `<section class="qz-review" id="qz-review" data-state="done"><h4>משוב על הדרך</h4>
|
mathly2/static/reviewsteps.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// The steps of an AI review while it runs (board-review): what the server has done so far, as a short list that grows, and the time since
|
| 2 |
+
// it started. Shown on the student's result and on the teacher's "בדיקת דרך" screen; both ask the server again every few seconds.
|
| 3 |
+
import { esc } from "./math.js";
|
| 4 |
+
|
| 5 |
+
const boards = n => n === 1 ? "לוח אחד" : `${n} לוחות`;
|
| 6 |
+
|
| 7 |
+
/** One line per step; the last one is the one happening now. */
|
| 8 |
+
function lines(review) {
|
| 9 |
+
const events = review.events || [];
|
| 10 |
+
if (!events.length) return [["now", "⏳", "מתחילים…"]];
|
| 11 |
+
return events.map((e, i) => {
|
| 12 |
+
const last = i === events.length - 1;
|
| 13 |
+
if (e.step === "boards") return ["done", "📋", `נמצאו ${boards(e.count)} עם דרך (מתוך ${e.total} שאלות)`];
|
| 14 |
+
if (e.step === "busy") return [last ? "now" : "done", "☕", last ? `ה־AI עמוס כרגע. מנסים שוב בעוד ${e.seconds} שניות` : "ה־AI היה עמוס, ניסינו שוב"];
|
| 15 |
+
if (e.step === "sent") return [last ? "now" : "done", last ? "🔍" : "📤", last ? "ה־AI קורא את הלוחות ובודק את הדרך, שאלה אחרי שאלה…" : "נשלח ל־AI"];
|
| 16 |
+
return ["done", "•", esc(e.step)];
|
| 17 |
+
});
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const since = at => Math.max(0, Math.round((Date.now() - new Date(at)) / 1000));
|
| 21 |
+
const elapsed = secs => secs < 60 ? `${secs} שניות` : `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")} דקות`;
|
| 22 |
+
|
| 23 |
+
/** The steps as HTML: a list, then "running for … (usually under a minute)". */
|
| 24 |
+
export function stepsHtml(review) {
|
| 25 |
+
const at = review.at;
|
| 26 |
+
return `<ol class="plain rv-steps" aria-live="polite">${lines(review).map(([state, icon, text]) =>
|
| 27 |
+
`<li class="rv-${state}"><span aria-hidden="true">${state === "now" ? `<span class="spin"></span>` : icon}</span> ${text}${state === "done" ? ' <span class="rv-ok" aria-hidden="true">✓</span>' : ""}</li>`).join("")}</ol>
|
| 28 |
+
${at ? `<p class="muted rv-time">כבר <span class="rv-elapsed" data-since="${esc(at)}">${elapsed(since(at))}</span> · בדרך כלל פחות מדקה</p>` : ""}`;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
setInterval(() => document.querySelectorAll(".rv-elapsed[data-since]").forEach(el => { el.textContent = elapsed(since(el.dataset.since)); }), 1000);
|
mathly2/static/teacher_boardwork.js
CHANGED
|
@@ -4,6 +4,7 @@ import { api } from "./api.js";
|
|
| 4 |
import { figures } from "./figures.js";
|
| 5 |
import { esc, fmt } from "./math.js";
|
| 6 |
import { pictureUrl } from "./media.js";
|
|
|
|
| 7 |
|
| 8 |
const KINDS = { quiz: "בוחן", exercise_test: "מבחן תרגול", simulation: "סימולציה" };
|
| 9 |
const POINTS = [[null, "אוטומטי"], [0, "0"], [0.5, "½"], [1, "מלא"]];
|
|
@@ -24,7 +25,7 @@ function aiPanel(data) {
|
|
| 24 |
const ai = data.ai || {};
|
| 25 |
if (!ai.available) return `<div class="card work-ai-panel" id="work-ai"><p class="muted">🤖 בדיקה עם AI לא מוגדרת בשרת: חסר GEMINI_API_KEY.</p></div>`;
|
| 26 |
const button = label => `<button type="button" id="work-ai-run">${label}</button>`;
|
| 27 |
-
const body = ai.state === "pending" ? `<p
|
| 28 |
: ai.state === "failed" ? `<p class="error" role="alert">🤖 הבדיקה עם AI נכשלה. ${esc(REASONS[ai.reason] || "")}${ai.model ? ` <span dir="ltr">(${esc(ai.model)})</span>` : ""}</p>${button("לנסות שוב")}`
|
| 29 |
: ai.state === "done" ? `<p>🤖 <b>סיכום ה־AI:</b> ${ai.summary ? fmt(ai.summary) : "לא היו לוחות לבדוק."}</p><p class="muted">הצעה בלבד: הנקודות שלכם קובעות.</p>${button("לבדוק שוב עם AI")}`
|
| 30 |
: `<p class="muted">🤖 עוד לא נבדק עם AI.</p>${button("בדיקה עם AI")}`;
|
|
@@ -89,7 +90,10 @@ async function open(id, note = "", given = null, keep = null) {
|
|
| 89 |
let now;
|
| 90 |
try { now = await api(`/api/teaching/work/${encodeURIComponent(id)}`); } catch { return wait(); }
|
| 91 |
if (document.getElementById("work-check")?.dataset.attempt !== data.id) return;
|
| 92 |
-
if (now.ai?.state === "pending")
|
|
|
|
|
|
|
|
|
|
| 93 |
open(id, "", now, chosen);
|
| 94 |
}, 4000); };
|
| 95 |
wait();
|
|
|
|
| 4 |
import { figures } from "./figures.js";
|
| 5 |
import { esc, fmt } from "./math.js";
|
| 6 |
import { pictureUrl } from "./media.js";
|
| 7 |
+
import { stepsHtml } from "./reviewsteps.js";
|
| 8 |
|
| 9 |
const KINDS = { quiz: "בוחן", exercise_test: "מבחן תרגול", simulation: "סימולציה" };
|
| 10 |
const POINTS = [[null, "אוטומטי"], [0, "0"], [0.5, "½"], [1, "מלא"]];
|
|
|
|
| 25 |
const ai = data.ai || {};
|
| 26 |
if (!ai.available) return `<div class="card work-ai-panel" id="work-ai"><p class="muted">🤖 בדיקה עם AI לא מוגדרת בשרת: חסר GEMINI_API_KEY.</p></div>`;
|
| 27 |
const button = label => `<button type="button" id="work-ai-run">${label}</button>`;
|
| 28 |
+
const body = ai.state === "pending" ? `<p><b>🤖 בדיקה עם AI</b></p>${stepsHtml(ai)}`
|
| 29 |
: ai.state === "failed" ? `<p class="error" role="alert">🤖 הבדיקה עם AI נכשלה. ${esc(REASONS[ai.reason] || "")}${ai.model ? ` <span dir="ltr">(${esc(ai.model)})</span>` : ""}</p>${button("לנסות שוב")}`
|
| 30 |
: ai.state === "done" ? `<p>🤖 <b>סיכום ה־AI:</b> ${ai.summary ? fmt(ai.summary) : "לא היו לוחות לבדוק."}</p><p class="muted">הצעה בלבד: הנקודות שלכם קובעות.</p>${button("לבדוק שוב עם AI")}`
|
| 31 |
: `<p class="muted">🤖 עוד לא נבדק עם AI.</p>${button("בדיקה עם AI")}`;
|
|
|
|
| 90 |
let now;
|
| 91 |
try { now = await api(`/api/teaching/work/${encodeURIComponent(id)}`); } catch { return wait(); }
|
| 92 |
if (document.getElementById("work-check")?.dataset.attempt !== data.id) return;
|
| 93 |
+
if (now.ai?.state === "pending") { // the new steps, in the box only
|
| 94 |
+
document.getElementById("work-ai")?.replaceWith(Object.assign(document.createElement("div"), { innerHTML: aiPanel(now) }).firstElementChild);
|
| 95 |
+
return wait();
|
| 96 |
+
}
|
| 97 |
open(id, "", now, chosen);
|
| 98 |
}, 4000); };
|
| 99 |
wait();
|
mathly2/tests/test_boardreview_api.py
CHANGED
|
@@ -21,7 +21,7 @@ class FakeReviewer:
|
|
| 21 |
def __init__(self, fail=None):
|
| 22 |
self.calls, self.fail = [], fail
|
| 23 |
|
| 24 |
-
def review(self, items):
|
| 25 |
self.calls.append(items)
|
| 26 |
if self.fail:
|
| 27 |
raise boardreview.ReviewFailure(self.fail)
|
|
@@ -174,7 +174,7 @@ def test_the_teacher_sees_the_ai_state_and_can_run_it_again_or_on_a_quiz():
|
|
| 174 |
client, store, bank, test, _ = make(fake)
|
| 175 |
attempt, _ = hand_in(client, bank, test)
|
| 176 |
view = client.get(f"/api/teaching/work/{attempt['id']}", headers=auth(TEACHER)).json()
|
| 177 |
-
assert view["ai"] == {"available": True, "state": "failed", "reason": "rate_limited", "summary": None
|
| 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()
|
|
@@ -223,3 +223,34 @@ def test_a_model_not_found_falls_back_to_a_flash_model_the_key_has():
|
|
| 223 |
def test_pick_model_prefers_stable_full_and_new():
|
| 224 |
assert boardreview.pick_model(["gemini-3-flash-preview", "gemini-2.5-flash-lite", "gemini-2.5-flash"]) == "gemini-2.5-flash"
|
| 225 |
assert boardreview.pick_model(["gemini-2.5-pro", "gemini-2.5-flash-preview-tts"]) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
def __init__(self, fail=None):
|
| 22 |
self.calls, self.fail = [], fail
|
| 23 |
|
| 24 |
+
def review(self, items, on_event=lambda step, **info: None):
|
| 25 |
self.calls.append(items)
|
| 26 |
if self.fail:
|
| 27 |
raise boardreview.ReviewFailure(self.fail)
|
|
|
|
| 174 |
client, store, bank, test, _ = make(fake)
|
| 175 |
attempt, _ = hand_in(client, bank, test)
|
| 176 |
view = client.get(f"/api/teaching/work/{attempt['id']}", headers=auth(TEACHER)).json()
|
| 177 |
+
assert {k: view["ai"][k] for k in ("available", "state", "reason", "summary")} == {"available": True, "state": "failed", "reason": "rate_limited", "summary": None}
|
| 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()
|
|
|
|
| 223 |
def test_pick_model_prefers_stable_full_and_new():
|
| 224 |
assert boardreview.pick_model(["gemini-3-flash-preview", "gemini-2.5-flash-lite", "gemini-2.5-flash"]) == "gemini-2.5-flash"
|
| 225 |
assert boardreview.pick_model(["gemini-2.5-pro", "gemini-2.5-flash-preview-tts"]) is None
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_while_it_runs_the_review_stores_its_steps_for_the_screens():
|
| 229 |
+
seen = []
|
| 230 |
+
|
| 231 |
+
class Watching(FakeReviewer):
|
| 232 |
+
def review(self, items, on_event=lambda step, **info: None):
|
| 233 |
+
seen.append(boardreview.student_view(store.get_attempt(attempt_id)))
|
| 234 |
+
on_event("busy", seconds=8)
|
| 235 |
+
seen.append(boardreview.student_view(store.get_attempt(attempt_id)))
|
| 236 |
+
on_event("sent")
|
| 237 |
+
return super().review(items, on_event)
|
| 238 |
+
fake = Watching()
|
| 239 |
+
client, store, bank, test, _ = make(None)
|
| 240 |
+
attempt, _ = hand_in(client, bank, test, boards=2)
|
| 241 |
+
attempt_id = attempt["id"]
|
| 242 |
+
client.app.state.reviewer = fake
|
| 243 |
+
client.get(f"/api/attempts/{attempt_id}", headers=auth(KID))
|
| 244 |
+
assert [e["step"] for e in seen[0]["events"]] == ["boards", "sent"] and seen[0]["events"][0]["count"] == 2
|
| 245 |
+
assert seen[0]["state"] == "pending" and seen[0]["at"]
|
| 246 |
+
assert seen[1]["events"][-1] == {**seen[1]["events"][-1], "step": "busy", "seconds": 8}
|
| 247 |
+
assert store.get_attempt(attempt_id)["review"]["state"] == "done"
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def test_gemini_says_when_it_waits_for_the_free_tier():
|
| 251 |
+
steps = []
|
| 252 |
+
reviewer = boardreview.GeminiReviewer("key", sleep=lambda s: None)
|
| 253 |
+
replies = [http_error(429, json.dumps({"error": {"details": [{"retryDelay": "7s"}]}}).encode()), answer({"summary": "s", "questions": []})]
|
| 254 |
+
reviewer._call = lambda body: (lambda r: (_ for _ in ()).throw(r) if isinstance(r, Exception) else r)(replies.pop(0))
|
| 255 |
+
reviewer.review(items(), on_event=lambda step, **info: steps.append((step, info)))
|
| 256 |
+
assert steps == [("busy", {"seconds": 8}), ("sent", {})]
|
mathly2/tests/test_boardwork_browser.py
CHANGED
|
@@ -184,3 +184,47 @@ def test_the_teacher_sees_why_the_ai_failed_and_runs_it_again(browser, bank):
|
|
| 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 == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 189 |
+
def test_while_the_ai_works_the_student_and_the_teacher_see_its_steps(browser, bank):
|
| 190 |
+
import threading
|
| 191 |
+
from datetime import timedelta
|
| 192 |
+
from mathly2 import clock
|
| 193 |
+
from mathly2.tests.test_boardreview_api import FakeReviewer
|
| 194 |
+
release = threading.Event()
|
| 195 |
+
|
| 196 |
+
class Slow(FakeReviewer):
|
| 197 |
+
def review(self, items, on_event=lambda step, **info: None):
|
| 198 |
+
on_event("busy", seconds=30)
|
| 199 |
+
release.wait(30)
|
| 200 |
+
return super().review(items, on_event)
|
| 201 |
+
lessons = [l["id"] for l in bank.lessons][:2]
|
| 202 |
+
store = InMemoryStore()
|
| 203 |
+
klass = store.create_class("setup", "A", lessons=lessons, teacher_email=TEACHER)
|
| 204 |
+
store.add_member(klass["id"], KID)
|
| 205 |
+
test = store.create_test(klass["id"], clock.school_day() + timedelta(days=9), lessons, TEACHER, created_at=clock.now() - timedelta(days=20))
|
| 206 |
+
app = create_app(Settings(teachers=frozenset({TEACHER})), bank=bank, store=store, reviewer=Slow())
|
| 207 |
+
with running(app) as url:
|
| 208 |
+
page = sign_in(browser, url, f"#/exam/{test['id']}/1/all")
|
| 209 |
+
page.wait_for_selector("#qz-q")
|
| 210 |
+
page.click("[data-board-toggle]")
|
| 211 |
+
page.wait_for_selector(".mx-board.full canvas")
|
| 212 |
+
draw_line(page)
|
| 213 |
+
page.click(".mx-board .mx-board-back")
|
| 214 |
+
page.locator(".qz-choice").first.click()
|
| 215 |
+
page.wait_for_selector(".qz-choice.picked")
|
| 216 |
+
page.click("#qz-submit")
|
| 217 |
+
page.click("#qz-yes")
|
| 218 |
+
page.wait_for_selector("#qz-review[data-state='pending'] .rv-steps li.rv-now:has-text('עמוס')", timeout=15000)
|
| 219 |
+
assert "נמצאו לוח אחד" in page.inner_text("#qz-review") and page.locator(".rv-elapsed").count() == 1
|
| 220 |
+
attempt = store.attempts_of(KID_ID, "exercise_test")[0]
|
| 221 |
+
|
| 222 |
+
teacher = teacher_page(browser, url)
|
| 223 |
+
teacher.click("#teacher-tabs [data-tab='work']")
|
| 224 |
+
teacher.click(f"[data-open='{attempt['id']}']")
|
| 225 |
+
teacher.wait_for_selector("#work-ai[data-state='pending'] .rv-steps")
|
| 226 |
+
assert "עמוס" in teacher.inner_text("#work-ai")
|
| 227 |
+
release.set()
|
| 228 |
+
page.wait_for_selector("#qz-review[data-state='done']", timeout=15000)
|
| 229 |
+
teacher.wait_for_selector("#work-ai[data-state='done']", timeout=15000)
|
| 230 |
+
assert page.errors == [] and teacher.errors == []
|