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