mathly / app /answer_scans.py
3v324v23's picture
Add tests for drafting and reviewing student answers
7badcff
Raw History Blame Contribute Delete
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")
@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