File size: 17,162 Bytes
e586f48 c869096 afd13cb c869096 9f81a43 e586f48 c869096 b9cb1c2 c869096 7b3e3e7 e586f48 9f81a43 228cbdf 9f81a43 c869096 4aac055 afd13cb e586f48 00f42f8 afd13cb e586f48 f14ef69 0ba5932 7badcff a4bc651 cb7f2ec 7a278ee 291b33d 8e0e8fe 8fb90d0 0ba5932 8fb90d0 a4bc651 c869096 e586f48 f98e127 e586f48 a6c6bd0 e586f48 a6c6bd0 e586f48 deaceb0 ca7d09e e586f48 ee0f8bd e586f48 21e1fef 291b33d 21e1fef 8fb90d0 ca7d09e c869096 e586f48 08aea0e e586f48 08aea0e e586f48 08aea0e ee0f8bd e586f48 291b33d ee0f8bd 3cb8d24 e586f48 3b6eb00 e586f48 ec553ac e586f48 08aea0e e586f48 69eaa4c 71ff4fa 7a278ee 71ff4fa e586f48 69eaa4c e586f48 0fdfc18 08aea0e e586f48 f14ef69 e586f48 afd13cb 8fb90d0 afd13cb e586f48 8417250 8fb90d0 d8b47d7 e586f48 00f42f8 69eaa4c e586f48 8fb90d0 afd13cb 8fb90d0 e586f48 afd13cb e586f48 a5c9173 291b33d a5c9173 291b33d a5c9173 291b33d 6c79804 291b33d e586f48 291b33d e586f48 6c79804 66d03bb 21e1fef 66d03bb e586f48 69eaa4c e586f48 69eaa4c afd13cb 69eaa4c e586f48 afd13cb 9f81a43 afd13cb 9f81a43 e586f48 69eaa4c e586f48 9f81a43 69eaa4c e586f48 69eaa4c e586f48 9f81a43 8fb90d0 9f81a43 e586f48 9f81a43 e586f48 9f81a43 8fb90d0 e586f48 9f81a43 8fb90d0 9f81a43 4aac055 8fb90d0 291b33d e586f48 0242444 a4bc651 cb7f2ec afd13cb 0ba5932 7a278ee f14ef69 0ba5932 afd13cb 0ba5932 7badcff 8fb90d0 7badcff 0ba5932 a4bc651 21e1fef c869096 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 | """Math-only HTTP service for the Mathly algebra path."""
from __future__ import annotations
import base64
import logging
import os
import time
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from starlette.concurrency import run_in_threadpool
from . import ai_usage
from .ai_queue import MAX_OPEN, for_student, install_ai_queue_routes, may_send
from .access import has_permission, resolve_access
from .answer_routes import install_answer_routes, open_ended_locked
from .answer_scans import ScanError, clean_image
from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
from .math_photo import (DAILY_PHOTOS, MAX_PHOTO, MEDIA_TYPES as PHOTO_TYPES,
PhotoSolver, PhotoUnavailable, math_model, photo_client)
from .math_skills import algebra9, practice_graph
from .class_report import install_class_report_routes, install_exam_topic_routes
from .teacher_review import install_teacher_review_routes
from .assignments import install_assignment_routes
from .progress import install_progress_routes
from .prompter import install_prompter_routes
from .push import install_push_routes
from .monitoring import ActionLog, RETURN_GAP, install_monitoring_routes
from .unit_resources import install_resource_routes
from .worksheets import install_worksheet_routes
from .quiz_modes import install_quiz_mode_routes
from .student_views import install_student_view_routes
from .ai_batches import install_ai_batch_routes
from .teacher_inbox import install_teacher_inbox_routes
from .teacher_ai import MESSAGE as TEACHER_AI_MESSAGE, LimitReached, TeacherQuota
from .science import install_science_routes
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("mathly")
ROOT = Path(__file__).resolve().parent.parent
STATIC = ROOT / "static"
LOCAL_TZ = ZoneInfo("Asia/Jerusalem")
SUBJECT = "诪转诪讟讬拽讛"
load_dotenv(ROOT / ".env")
def units_of(row: dict) -> list[str]:
"""A class's units. A unit split off an older one (its `from`) comes with it, so saved classes keep their content."""
units = algebra9()["units"]
known = [unit["id"] for unit in units]
selected = set(row.get("lessons") or known)
selected |= {unit["id"] for unit in units if selected & set(unit.get("from", []))}
return [unit for unit in known if unit in selected]
class FreshStatic(StaticFiles):
"""Static files the browser checks with the server before reusing, so a new deploy shows up on the next visit."""
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
response.headers["Cache-Control"] = "no-cache"
return response
def create_app(auth: SupabaseAuth | None = None) -> FastAPI:
"""Build the Mathly application."""
app = FastAPI(title="Mathly")
app.state.auth = auth or load_auth()
app.state.photo = None
app.state.photo_key = None
app.state.seen = {}
app.state.actions = ActionLog()
app.state.photo_counts = {}
app.state.teacher_ai = TeacherQuota() # a teacher's 3 AI requests a day (app/teacher_ai.py)
app.mount("/static", FreshStatic(directory=STATIC), name="static")
async def signed_in(request: Request) -> dict | None:
store: SupabaseAuth | None = app.state.auth
if store is None:
return None
scheme, _, token = request.headers.get("authorization", "").partition(" ")
if scheme.lower() != "bearer" or not token.strip():
raise HTTPException(401, "sign in required")
try:
user = resolve_access(store, token.strip())
except AuthUnavailable as exc:
raise HTTPException(503, f"sign-in check failed: {exc}") from exc
if user is None:
raise HTTPException(401, "sign in required")
if user.get("denied"):
raise HTTPException(403, "this account is not in a math class")
key, now = user.get("id") or user.get("email", ""), time.time()
if now - app.state.seen.get(key, 0) > RETURN_GAP:
app.state.actions.record(user, "visit")
app.state.seen[key] = now # for Monitoring's "signed in lately"
return user
async def manager(user: dict | None = Depends(signed_in)) -> dict:
if not has_permission(user, "management.access"):
raise HTTPException(403, "management permission required")
return user
def page() -> HTMLResponse:
html = (STATIC / "index.html").read_text(encoding="utf-8")
store = app.state.auth
html = html.replace("__SUPABASE_URL__", store.url if store else "")
html = html.replace("__SUPABASE_KEY__", store.key if store else "")
return HTMLResponse(html, headers={"Cache-Control": "no-cache"})
@app.get("/", response_class=HTMLResponse)
@app.get("/math", response_class=HTMLResponse, include_in_schema=False)
async def index():
return page()
@app.get("/service-worker.js", include_in_schema=False)
async def service_worker():
# Push notifications only (see app/push.py). It serves nothing from a cache: an older version kept /static/
# in its own cache forever, so on activation this one also deletes every cache that version left.
return Response((STATIC / "service-worker.js").read_text(encoding="utf-8"), media_type="text/javascript",
headers={"Cache-Control": "no-store"})
@app.get("/manifest.webmanifest", include_in_schema=False)
async def manifest():
return Response((STATIC / "manifest.webmanifest").read_text(encoding="utf-8"),
media_type="application/manifest+json")
@app.get("/api/health")
async def health():
return {"ok": True, "app": "mathly"}
@app.get("/api/me")
async def me(user: dict | None = Depends(signed_in)):
mine = (user or {}).get("classes", [])
email = (user or {}).get("email", "")
return {"email": email, "user_id": (user or {}).get("id"),
"answer_scans": app.state.answers.configured,
"roles": (user or {}).get("roles", []),
"permissions": (user or {}).get("permissions", []),
"teacher": bool((user or {}).get("teacher")),
# the AI: this account sends to it itself (ai_direct), or asks its teacher (app/ai_queue.py);
# ai_review: it reviews students' requests
"ai_direct": may_send(user),
"ai_left": app.state.teacher_ai.left(user), # a teacher's AI requests left today; None: no limit
"ai_review": bool(user) and may_send(user) and (
has_permission(user, "management.access")
or any(email.lower() in (row.get("teachers") or []) for row in mine)),
"classes": [{"id": row["id"], "name": row["name"], "lessons": units_of(row),
"exams": row.get("exams") or [], "exam_note": row.get("exam_note") or "",
"exam_title": row.get("exam_title") or "",
"teacher": email.lower() in (row.get("teachers") or []),
# for the teacher's classes card: how many students (the class's teachers left out)
**({"students": len(set(row.get("emails") or []) - set(row.get("teachers") or []))}
if email.lower() in (row.get("teachers") or []) else {})} for row in mine]}
@app.get("/api/math/skills")
async def math_skills():
return JSONResponse(practice_graph(app.state.answers.configured, open_ended_locked()),
headers={"Cache-Control": "no-store"})
@app.get("/api/classes")
async def classes(_user: dict = Depends(manager)):
try:
return {"classes": [{**row, "lessons": units_of(row)}
for row in app.state.auth.classes(fresh=True)]}
except AuthUnavailable as exc:
raise HTTPException(503, str(exc)) from exc
def save_class(class_id: int | None, body: dict):
name = " ".join(str(body.get("name") or "").split())
if not name or len(name) > 80:
raise HTTPException(400, "class name is required (up to 80 characters)")
known = [unit["id"] for unit in algebra9()["units"]]
selected = [unit for unit in known if unit in set(body.get("lessons") or known)]
if not selected:
raise HTTPException(400, "choose at least one math unit")
raw = body.get("emails", "")
parts = raw.replace(",", " ").split() if isinstance(raw, str) else raw
emails = sorted({str(part).strip().lower() for part in parts if str(part).strip()})
# the class has at most one teacher, a free email of its own: a teacher is never also a student
teachers = sorted({str(t).strip().lower() for t in body.get("teachers") or [] if str(t).strip()})
if len(teachers) > 1 or any("@" not in t for t in teachers):
raise HTTPException(400, "a class has one teacher, given by email")
if set(teachers) & set(emails):
raise HTTPException(400, "a teacher cannot also be a student of the class")
try:
app.state.auth.save_class(class_id, name, emails, {}, selected, teachers=teachers)
except ClassConflict as exc:
raise HTTPException(409, "a class with that name already exists") from exc
except AuthUnavailable as exc:
raise HTTPException(503, str(exc)) from exc
except KeyError as exc:
raise HTTPException(404, "class not found") from exc
return {"ok": True, "id": class_id, "name": name, "lessons": selected}
@app.post("/api/classes")
async def create_class(body: dict, user: dict = Depends(manager)):
result = save_class(None, body)
app.state.actions.record(user, "class_saved", f"谞驻转讞讛: {result['name']}")
return result
@app.put("/api/classes/{class_id}")
async def update_class(class_id: int, body: dict, user: dict = Depends(manager)):
result = save_class(class_id, body)
app.state.actions.record(user, "class_saved", f"谞注专讻讛: {result['name']}")
return result
@app.delete("/api/classes/{class_id}")
async def delete_class(class_id: int, user: dict = Depends(manager)):
try:
name = next((row["name"] for row in app.state.auth.classes() if row.get("id") == class_id), f"#{class_id}")
app.state.auth.delete_class(class_id)
app.state.actions.record(user, "class_deleted", name)
except AuthUnavailable as exc:
raise HTTPException(503, str(exc)) from exc
return {"ok": True}
photo_counts: dict[tuple[str, str], int] = app.state.photo_counts
def solver() -> PhotoSolver:
key = os.environ.get("ANTHROPIC_API_KEY", "").strip() or os.environ.get("CLAUDE_API_KEY", "").strip()
if not key:
raise HTTPException(503, "photographed-problem help is not configured")
if app.state.photo_key != key:
app.state.photo = PhotoSolver(photo_client(key), math_model(), algebra9()["skills"])
app.state.photo_key = key
return app.state.photo
# the teacher's AI queue sends a student's photo with this (app/ai_queue.py)
def solve_for_queue(image: bytes, media_type: str) -> dict:
try:
service = solver()
except HTTPException as exc:
raise PhotoUnavailable("驻转专讜谉 诪转诪讜谞讛 诇讗 讝诪讬谉 讻专讙注.", "missing_key", "ANTHROPIC_API_KEY is not set") from exc
return service.solve(image, media_type)
app.state.solve_photo = solve_for_queue
@app.post("/api/math/photo")
async def solve_photo(photo: UploadFile = File(...), user: dict | None = Depends(signed_in)):
if not may_send(user):
return await ask_teacher(photo, user)
return await solve_upload(photo, user)
async def ask_teacher(photo: UploadFile, user: dict):
"""A student's photo waits in their teacher's AI queue; the teacher sends it to the AI."""
queue = app.state.ai_queue
if not queue.configured:
raise HTTPException(503, "注讝专讛 讘爪讬诇讜诐 讗讬谞讛 讝诪讬谞讛 讻专讙注.")
counter = (user["email"].lower(), datetime.now(LOCAL_TZ).date().isoformat())
if photo.content_type not in PHOTO_TYPES:
raise HTTPException(415, "send a JPEG, PNG, or WebP image")
raw = await photo.read(MAX_PHOTO + 1)
if not raw:
raise HTTPException(400, "the image is empty")
if len(raw) > MAX_PHOTO:
raise HTTPException(413, "the image is too large")
if photo_counts.get(counter, 0) >= DAILY_PHOTOS:
raise HTTPException(429, "daily photo limit reached")
try:
if await run_in_threadpool(queue.open_count, user["id"]) >= MAX_OPEN:
raise HTTPException(429, "讻讘专 讬砖 诇讻诐 讻诪讛 讘拽砖讜转 砖诪讞讻讜转 诇诪讜专讛. 讞讻讜 砖讛诪讜专讛 讬注谞讛 注诇讬讛谉.")
image = await run_in_threadpool(clean_image, raw, photo.content_type) # also drops the photo's metadata
row = await run_in_threadpool(queue.add, {
"user_id": user["id"], "email": user["email"].lower(), "kind": "photo",
"image": base64.standard_b64encode(image).decode("ascii")})
except ScanError as exc:
if exc.code.endswith("image"):
raise HTTPException(415, "诇讗 讛爪诇讞谞讜 诇拽专讜讗 讗转 讛转诪讜谞讛. 爪诇诪讜 砖讜讘.") from exc
raise HTTPException(503, "注讝专讛 讘爪讬诇讜诐 讗讬谞讛 讝诪讬谞讛 讻专讙注.") from exc
photo_counts[counter] = photo_counts.get(counter, 0) + 1
app.state.actions.record(user, "ai_request", "转专讙讬诇 诪爪讜诇诐")
return {"queued": True, "request": for_student(row), "left": DAILY_PHOTOS - photo_counts[counter]}
async def solve_upload(photo: UploadFile, user: dict | None):
service = solver()
email = (user or {}).get("email", "").lower()
exempt = "developer" in (user or {}).get("roles", ())
counter = (email, datetime.now(LOCAL_TZ).date().isoformat()) if email and not exempt else None
if photo.content_type not in PHOTO_TYPES:
raise HTTPException(415, "send a JPEG, PNG, or WebP image")
image = await photo.read(MAX_PHOTO + 1)
if not image:
raise HTTPException(400, "the image is empty")
if len(image) > MAX_PHOTO:
raise HTTPException(413, "the image is too large")
if counter and photo_counts.get(counter, 0) >= DAILY_PHOTOS:
raise HTTPException(429, "daily photo limit reached")
try:
quota = app.state.teacher_ai.take(user)
except LimitReached as exc:
raise HTTPException(429, TEACHER_AI_MESSAGE) from exc
if counter:
photo_counts[counter] = photo_counts.get(counter, 0) + 1
try:
result = await run_in_threadpool(service.solve, image, photo.content_type)
except PhotoUnavailable as exc:
if counter:
photo_counts[counter] -= 1
app.state.teacher_ai.give_back(quota)
raise HTTPException(503, str(exc)) from exc
except Exception:
if counter:
photo_counts[counter] -= 1
app.state.teacher_ai.give_back(quota)
raise
ai_usage.record(app.state.auth, (user or {}).get("id"), "photo", result.pop("usage", None))
if not result.get("readable"):
if counter:
photo_counts[counter] -= 1
app.state.teacher_ai.give_back(quota)
skill = next((s["name"] for s in algebra9()["skills"] if s["id"] == result.get("skill")), "")
app.state.actions.record(user, "photo", skill if result.get("readable") else "讛转诪讜谞讛 诇讗 谞拽专讗讛")
return {**result, "left": DAILY_PHOTOS - photo_counts.get(counter, 0) if counter else None}
install_progress_routes(app, signed_in)
install_prompter_routes(app, signed_in)
install_class_report_routes(app, signed_in, units_of)
install_exam_topic_routes(app, signed_in, units_of)
install_push_routes(app, signed_in)
install_answer_routes(app, signed_in)
install_quiz_mode_routes(app, signed_in, units_of)
install_ai_queue_routes(app, signed_in)
install_ai_batch_routes(app, signed_in)
install_teacher_inbox_routes(app, signed_in)
install_teacher_review_routes(app, signed_in)
install_assignment_routes(app, signed_in, units_of)
install_worksheet_routes(app, signed_in)
install_resource_routes(app, signed_in, units_of)
install_student_view_routes(app, signed_in, units_of)
install_science_routes(app)
install_monitoring_routes(app, manager)
return app
app = create_app()
|