Download app/answer_scans.py from wzender/mathly: direct link, hf CLI and curl.
- Browser
- Download file 13.1 kB
-
https://huggingface.co/spaces/wzender/mathly/resolve/main/app/answer_scans.py
- Command line
-
hf download hf://spaces/wzender/mathly/app/answer_scans.py
-
curl -L -o answer_scans.py https://huggingface.co/spaces/wzender/mathly/resolve/main/app/answer_scans.py
13.1 kB
| """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") | |
| 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) | |
| 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" | |
| 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 | |