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()