File size: 13,145 Bytes
f14ef69
 
 
 
2a84a2a
 
 
 
 
f14ef69
 
 
 
 
2a84a2a
f14ef69
2a84a2a
f14ef69
 
 
 
 
 
2a84a2a
 
f14ef69
 
 
 
 
 
2a84a2a
 
 
f14ef69
 
 
 
 
 
afd13cb
 
f14ef69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a84a2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f14ef69
2a84a2a
f14ef69
 
2a84a2a
 
 
 
 
f14ef69
 
 
 
 
 
 
 
 
 
 
 
6f5ba40
 
 
 
 
 
 
0818184
 
 
 
 
 
 
 
 
 
 
 
 
7badcff
 
 
 
 
 
 
 
 
 
 
 
 
 
f14ef69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7badcff
6f5ba40
 
0818184
f14ef69
 
 
 
 
2a84a2a
 
 
 
 
 
 
f14ef69
2a84a2a
 
f14ef69
 
 
 
2a84a2a
 
f14ef69
 
 
2a84a2a
 
f14ef69
 
 
2a84a2a
 
 
 
 
 
 
 
 
 
 
f14ef69
2a84a2a
f14ef69
 
 
2a84a2a
 
 
 
 
 
 
 
 
f14ef69
 
 
 
 
 
 
 
 
 
e79873b
f14ef69
 
6f5ba40
 
 
f14ef69
 
 
e79873b
 
 
 
 
 
 
 
f14ef69
 
 
 
 
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
"""Durable answer scans. RPC transactions own state; the server owns image access.

No fallback to process memory is allowed. The adapter uses the same Supabase
project as sign-in; a verified user ID is passed on every student operation.

Images: with MATHLY_ANSWER_BUCKET set (a private Hugging Face storage bucket, written with HF_TOKEN), new answer
images are kept there instead of Supabase Storage, so reading them back (to grade and to show them) is not Supabase
egress. Their file names start with "hf-"; images saved before keep their names and stay in Supabase, where they
are still read from.
"""
from __future__ import annotations

import io
import json
import logging
import os
import tempfile
import urllib.error
import urllib.parse
import urllib.request
import uuid
import warnings
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path

from PIL import Image, ImageOps, UnidentifiedImageError

MAX_IMAGE = 5_000_000
Image.MAX_IMAGE_PIXELS = 20_000_000
BUCKET = "mathly-answers"
HUB_MARK = "hf-"   # an image kept in the Hugging Face bucket: its file name starts with this
ORPHAN_AGE = timedelta(hours=24)
log = logging.getLogger("mathly.answers")


@dataclass
class ScanError(Exception):
    status: int
    code: str
    reason: str = ""   # why an AI check failed (math_photo.failure_reason), for the teacher's error details
    detail: str = ""   # the technical line behind it


def clean_image(data: bytes, content_type: str) -> bytes:
    if not data:
        raise ScanError(400, "empty_image")
    if len(data) > MAX_IMAGE:
        raise ScanError(413, "image_too_large")
    if content_type not in ("image/jpeg", "image/png", "image/webp"):
        raise ScanError(415, "unsupported_image")
    try:
        with warnings.catch_warnings():
            warnings.simplefilter("error", Image.DecompressionBombWarning)
            with Image.open(io.BytesIO(data)) as source:
                if source.format not in ("JPEG", "PNG", "WEBP") or source.width * source.height > 20_000_000:
                    raise ValueError("image dimensions or format")
                image = ImageOps.exif_transpose(source).convert("RGB")
                image.thumbnail((2400, 2400))
                output = io.BytesIO()
                image.save(output, "JPEG", quality=88)  # drops EXIF, GPS, and embedded metadata
                result = output.getvalue()
                if len(result) > MAX_IMAGE:
                    raise ScanError(413, "image_too_large")
                return result
    except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError,
            Image.DecompressionBombWarning) as exc:
        raise ScanError(415, "invalid_image") from exc


class HubImages:
    """Answer images in a private Hugging Face storage bucket, under answers/<the object key>."""
    PREFIX = "answers/"

    def __init__(self, bucket: str, token: str | None = None):
        from huggingface_hub import HfApi
        from huggingface_hub.utils import disable_progress_bars
        disable_progress_bars()   # no progress bars in the server's log
        self.bucket, self.api = bucket, HfApi(token=token or None)

    @classmethod
    def from_env(cls):
        bucket = os.environ.get("MATHLY_ANSWER_BUCKET", "").strip()
        return cls(bucket, os.environ.get("HF_TOKEN", "").strip()) if bucket else None

    def _call(self, what, *args, **kwargs):
        from huggingface_hub.errors import EntryNotFoundError
        try:
            return what(self.bucket, *args, **kwargs)
        except EntryNotFoundError as exc:
            raise ScanError(404, "not_found") from exc
        except Exception as exc:   # network, token or quota: never pass the details on to the browser
            log.warning("answer bucket failed: %s", type(exc).__name__)
            raise ScanError(503, "answer_storage_unavailable") from exc

    def put(self, object_key: str, data: bytes):
        self._call(self.api.batch_bucket_files, add=[(data, self.PREFIX + object_key)])

    def read(self, object_key: str) -> bytes:
        with tempfile.TemporaryDirectory() as tmp:
            target = Path(tmp) / "image.jpg"
            self._call(self.api.download_bucket_files, [(self.PREFIX + object_key, str(target))], raise_on_missing_files=True)
            return target.read_bytes()

    def delete(self, object_key: str):
        self._call(self.api.batch_bucket_files, delete=[self.PREFIX + object_key])

    def older_than(self, age: timedelta) -> list[str]:
        """The object keys uploaded before now - age."""
        cutoff = datetime.now(timezone.utc) - age
        files = self._call(self.api.list_bucket_tree, prefix=self.PREFIX.rstrip("/"), recursive=True)
        return [f.path[len(self.PREFIX):] for f in files
                if getattr(f, "type", "") == "file" and f.path.startswith(self.PREFIX)
                and (getattr(f, "uploaded_at", None) or getattr(f, "mtime", None) or cutoff) < cutoff]


def on_hub(object_key: str) -> bool:
    return object_key.rsplit("/", 1)[-1].startswith(HUB_MARK)


class AnswerScans:
    def __init__(self, auth, *, enabled=None, images="env"):
        self.auth = auth
        self.enabled = (os.environ.get("MATHLY_ANSWER_SCANS_ENABLED") == "1") if enabled is None else enabled
        self.images = HubImages.from_env() if images == "env" else images   # None: every image in Supabase Storage

    def new_key(self, user_id: str, scan_id: str, lease_id: str) -> str:
        """A new image's object key; the database checks its user/scan/lease start."""
        return f"{user_id}/{scan_id}/{lease_id}/{HUB_MARK if self.images else ''}{uuid.uuid4()}.jpg"

    @property
    def configured(self):
        return bool(self.enabled and self.auth and self.auth.secret)

    def action(self, user_id: str | None, action: str, **data):
        if not self.configured:
            raise ScanError(503, "answer_storage_unavailable")
        body = {"p_user": user_id, "p_action": action, "p_data": data}
        raw = self._request("/rest/v1/rpc/math_answer_action", "POST", json.dumps(body).encode(), "application/json")
        return json.loads(raw)

    def grade(self, user_id: str, action: str, **data):
        if not self.configured:
            raise ScanError(503, "answer_storage_unavailable")
        body = {"p_user": user_id, "p_action": action, "p_data": data}
        raw = self._request("/rest/v1/rpc/math_grade_action", "POST", json.dumps(body).encode(), "application/json")
        return json.loads(raw)

    def quiz(self, user_id: str | None, action: str, **data):
        """Unit-quiz sittings (app/quiz_grades.py): submit, list a student's own, or `report` for a set of accounts."""
        if not self.configured:
            raise ScanError(503, "answer_storage_unavailable")
        body = {"p_user": user_id, "p_action": action, "p_data": data}
        try:
            raw = self._request("/rest/v1/rpc/math_quiz_action", "POST", json.dumps(body).encode(), "application/json")
        except ScanError as exc:
            if exc.code == "answer_storage_unavailable":
                raise ScanError(503, "quiz_sittings_unavailable") from exc
            raise
        return json.loads(raw)

    def review(self, user_id: str | None, action: str, **data):
        """The teacher's reviews of answers and the students' requests to look again (app/teacher_review.py). The
        student's id is `user_id` (a teacher's route finds it first with the `owner` action)."""
        if not self.configured:
            raise ScanError(503, "answer_storage_unavailable")
        body = {"p_user": user_id, "p_action": action, "p_data": data}
        try:
            raw = self._request("/rest/v1/rpc/math_review_action", "POST", json.dumps(body).encode(), "application/json")
        except ScanError as exc:
            if exc.code == "answer_storage_unavailable":
                raise ScanError(503, "review_unavailable") from exc
            raise
        return json.loads(raw)

    def _request(self, path, method="GET", data=None, content_type=None):
        headers = {"Authorization": "Bearer " + self.auth.secret, "apikey": self.auth.secret,
                   "User-Agent": "mathly/1.0"}
        if content_type:
            headers["Content-Type"] = content_type
        req = urllib.request.Request(self.auth.url + path, data=data, method=method, headers=headers)
        try:
            with urllib.request.urlopen(req, timeout=30) as response:
                return response.read()
        except urllib.error.HTTPError as exc:
            # Only recognized database errors become public errors. Never expose SQL or keys.
            try:
                error = json.loads(exc.read())
            except (ValueError, OSError):
                error = {}
            code, message = error.get("code"), error.get("message", "")
            if code == "P0002":
                raise ScanError(404, "not_found") from exc
            if code == "P0001" and message in {"grading_limit", "review_limit"}:
                raise ScanError(429, message) from exc
            if code == "P0001" and message in {"superseded", "invalid_upload", "upload_in_progress", "confirmation_required",
                                               "answer_required", "grading_busy", "grading_interrupted", "invalid_sitting"}:
                raise ScanError(409, message) from exc
            raise ScanError(503, "answer_storage_unavailable") from exc
        except (urllib.error.URLError, TimeoutError, OSError) as exc:
            raise ScanError(503, "answer_storage_unavailable") from exc

    def _hub(self, object_key):
        if not on_hub(object_key):
            return None
        if not self.images:   # an "hf-" image while the bucket is not configured: say so, never ask Supabase for it
            raise ScanError(503, "answer_storage_unavailable")
        return self.images

    def put(self, object_key, data):
        if hub := self._hub(object_key):
            return hub.put(object_key, data)
        self._request(f"/storage/v1/object/{BUCKET}/{urllib.parse.quote(object_key, safe='/')}",
                      "POST", data, "image/jpeg")

    def read(self, object_key):
        if hub := self._hub(object_key):
            return hub.read(object_key)
        return self._request(f"/storage/v1/object/authenticated/{BUCKET}/{urllib.parse.quote(object_key, safe='/')}")

    def delete(self, object_key):
        if hub := self._hub(object_key):
            return hub.delete(object_key)
        self._request(f"/storage/v1/object/{BUCKET}", "DELETE",
                      json.dumps({"prefixes": [object_key]}).encode(), "application/json")

    def referenced(self, keys: list[str]) -> set[str]:
        """Which of these object keys an answer still uses."""
        found = set()
        for i in range(0, len(keys), 40):
            chunk = keys[i:i + 40]
            names = ",".join('"' + k.replace('"', "") + '"' for k in chunk)
            raw = self._request("/rest/v1/math_answer_attachments?select=object_key&object_key=in."
                                + urllib.parse.quote(f"({names})", safe="(),"))
            found |= {row["object_key"] for row in json.loads(raw)}
        return found

    def cleanup(self):
        """Removes images no answer uses, a day after they were uploaded (an upload's answer is saved right after it)."""
        candidates = self.action(None, "cleanup_candidates")
        for key in candidates:
            self.delete(key)
        removed = len(candidates)
        if self.images:   # the database cannot list the bucket: the bucket is listed and each image looked up
            old = self.images.older_than(ORPHAN_AGE)
            used = self.referenced(old) if old else set()
            for key in old:
                if key not in used:
                    self.images.delete(key)
                    removed += 1
        return removed


def public_result(result):
    """Keep storage paths and upload coordination secrets out of public responses."""
    if isinstance(result, list):
        return [public_result(item) for item in result]
    result = {k: dict(v) if isinstance(v, dict) else v for k, v in result.items()}
    if result.get("scan"):
        for key in ("idempotency_key", "lease_id", "lease_until"):
            result["scan"].pop(key, None)
        result["scan"]["code"] = scan_code(result["scan"].get("id"))
    if result.get("attachment"):
        result["attachment"].pop("object_key", None)
    if result.get("grading"):
        result["grading"].pop("lease_id", None)
        result["grading"].pop("lease_until", None)
    return result


def scan_code(scan_id):
    """The 4 digits shown on both the computer and the phone, so the student can match the request."""
    try:
        return f"{uuid.UUID(str(scan_id)).int % 10000:04d}"
    except (ValueError, TypeError, AttributeError):
        return ""


def uuid_text(value):
    try:
        return str(uuid.UUID(str(value)))
    except (ValueError, TypeError, AttributeError) as exc:
        raise ScanError(404, "not_found") from exc