3v324v23 commited on
Commit
2a84a2a
·
1 Parent(s): e2dec0b

feat: integrate Hugging Face storage for answer images and update related functionality

Browse files
.env.example CHANGED
@@ -24,6 +24,14 @@ MATHLY_ANSWER_SCANS_ENABLED=0
24
  # (this spends API tokens). Anything else, or unset, keeps them locked.
25
  MATHLY_OPEN_ENDED_LOCKED=1
26
 
 
 
 
 
 
 
 
 
27
  # ---- Phone notifications for photo requests (app/push.py) ----
28
  # When the computer asks for a photo of an answer, the student's phone gets a notification, even with the app closed.
29
  # Apply supabase/migrations/20261103180000_math_push_subs.sql first. Make a key with: python -m app.push
 
24
  # (this spends API tokens). Anything else, or unset, keeps them locked.
25
  MATHLY_OPEN_ENDED_LOCKED=1
26
 
27
+ # ---- Answer images on Hugging Face (optional) ----
28
+ # A private Hugging Face storage bucket for new answer images (photos and the board's hand-ins), instead of
29
+ # Supabase Storage: reading them back to grade and show them is then not Supabase egress. Images saved before
30
+ # stay in Supabase and are still read from there. Leave empty to keep everything in Supabase.
31
+ MATHLY_ANSWER_BUCKET=
32
+ # A token that can write to that bucket (on a Space: add it as a secret).
33
+ HF_TOKEN=
34
+
35
  # ---- Phone notifications for photo requests (app/push.py) ----
36
  # When the computer asks for a photo of an answer, the student's phone gets a notification, even with the app closed.
37
  # Apply supabase/migrations/20261103180000_math_push_subs.sql first. Make a key with: python -m app.push
README.md CHANGED
@@ -58,6 +58,7 @@ Requests last **15 minutes**. A failed upload can be retried on the phone withou
58
  2. Apply [the additive answer-scan migration](supabase/migrations/20261031133254_math_answer_scans.sql) in that project's SQL editor or your normal migration pipeline. It adds three tables, ownership policies, a service-only transactional RPC, and the private `mathly-answers` bucket. **Do not rerun `docs/mathly-supabase.sql`**, which is the older destructive migration. Back up the target database before applying migrations.
59
  3. Set `MATHLY_ANSWER_SCANS_ENABLED=1` and restart the app after the migration succeeds. The scan feature needs no AI key. With the flag off or credentials absent, scan APIs fail closed; guest practice and AI problem help still work.
60
  4. Run `python -m app.answer_cleanup` hourly with the same server environment. It removes unreferenced private objects older than **24 hours** using the Storage API, including images orphaned by failed commits or account/attempt deletion. Referenced answers remain with their attempts; deleting an account/attempt cascades its records and cleanup removes the bytes after the grace period. The maintenance command continues to work when the student-facing feature flag is off. Monitor nonzero cleanup exit codes.
 
61
 
62
  Upload leases last at most **3 minutes**, bounded by request expiry. Interrupted leases become retryable failures on the next status/discovery check. Images are limited to 5 MB JPEG/PNG/WebP, decoded and re-encoded as JPEG to remove metadata, and accessed through owner-checked authenticated endpoints. When grading is configured, saving the image starts an automatic check using the saved image bytes and original question snapshot; the review screen explains this before submission. Private storage URLs are never sent. Upload receipt is independent of grading: an AI failure leaves the photograph saved and provides a retry option. The flow works across workers and server restarts.
63
 
 
58
  2. Apply [the additive answer-scan migration](supabase/migrations/20261031133254_math_answer_scans.sql) in that project's SQL editor or your normal migration pipeline. It adds three tables, ownership policies, a service-only transactional RPC, and the private `mathly-answers` bucket. **Do not rerun `docs/mathly-supabase.sql`**, which is the older destructive migration. Back up the target database before applying migrations.
59
  3. Set `MATHLY_ANSWER_SCANS_ENABLED=1` and restart the app after the migration succeeds. The scan feature needs no AI key. With the flag off or credentials absent, scan APIs fail closed; guest practice and AI problem help still work.
60
  4. Run `python -m app.answer_cleanup` hourly with the same server environment. It removes unreferenced private objects older than **24 hours** using the Storage API, including images orphaned by failed commits or account/attempt deletion. Referenced answers remain with their attempts; deleting an account/attempt cascades its records and cleanup removes the bytes after the grace period. The maintenance command continues to work when the student-facing feature flag is off. Monitor nonzero cleanup exit codes.
61
+ 5. Optional: keep new answer images in a **private** Hugging Face storage bucket instead of Supabase Storage, so reading them back (to grade and to show them) is not Supabase egress. Set `MATHLY_ANSWER_BUCKET` (e.g. `wzender/mathly-answers`) and `HF_TOKEN` (a token that can write to it; on a Space, a secret). New images get file names starting with `hf-` and go to the bucket under `answers/`; images saved before stay in Supabase and are still read from there. Cleanup covers both: for the bucket it lists images older than 24 hours and removes the ones no answer uses. Never use a public bucket: these are students' answers.
62
 
63
  Upload leases last at most **3 minutes**, bounded by request expiry. Interrupted leases become retryable failures on the next status/discovery check. Images are limited to 5 MB JPEG/PNG/WebP, decoded and re-encoded as JPEG to remove metadata, and accessed through owner-checked authenticated endpoints. When grading is configured, saving the image starts an automatic check using the saved image bytes and original question snapshot; the review screen explains this before submission. Private storage URLs are never sent. Upload receipt is independent of grading: an AI failure leaves the photograph saved and provides a retry option. The flow works across workers and server restarts.
64
 
app/answer_routes.py CHANGED
@@ -213,7 +213,7 @@ def install_answer_routes(app, signed_in):
213
  raise ScanError(409, "invalid_upload")
214
  try:
215
  image = clean_image(data, content_type)
216
- object_key = f"{user}/{sid}/{lease}/{uuid.uuid4()}.jpg"
217
  app.state.answers.put(object_key, image)
218
  result = usable(call(user, "finalize", scan_id=sid, lease_id=lease,
219
  object_key=object_key, byte_size=len(image)))
 
213
  raise ScanError(409, "invalid_upload")
214
  try:
215
  image = clean_image(data, content_type)
216
+ object_key = app.state.answers.new_key(user, sid, lease)
217
  app.state.answers.put(object_key, image)
218
  result = usable(call(user, "finalize", scan_id=sid, lease_id=lease,
219
  object_key=object_key, byte_size=len(image)))
app/answer_scans.py CHANGED
@@ -2,24 +2,36 @@
2
 
3
  No fallback to process memory is allowed. The adapter uses the same Supabase
4
  project as sign-in; a verified user ID is passed on every student operation.
 
 
 
 
 
5
  """
6
  from __future__ import annotations
7
 
8
  import io
9
  import json
 
10
  import os
 
11
  import urllib.error
12
  import urllib.parse
13
  import urllib.request
14
  import uuid
15
  import warnings
16
  from dataclasses import dataclass
 
 
17
 
18
  from PIL import Image, ImageOps, UnidentifiedImageError
19
 
20
  MAX_IMAGE = 5_000_000
21
  Image.MAX_IMAGE_PIXELS = 20_000_000
22
  BUCKET = "mathly-answers"
 
 
 
23
 
24
 
25
  @dataclass
@@ -54,10 +66,65 @@ def clean_image(data: bytes, content_type: str) -> bytes:
54
  raise ScanError(415, "invalid_image") from exc
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  class AnswerScans:
58
- def __init__(self, auth, *, enabled=None):
59
  self.auth = auth
60
  self.enabled = (os.environ.get("MATHLY_ANSWER_SCANS_ENABLED") == "1") if enabled is None else enabled
 
 
 
 
 
61
 
62
  @property
63
  def configured(self):
@@ -104,22 +171,55 @@ class AnswerScans:
104
  except (urllib.error.URLError, TimeoutError, OSError) as exc:
105
  raise ScanError(503, "answer_storage_unavailable") from exc
106
 
 
 
 
 
 
 
 
107
  def put(self, object_key, data):
 
 
108
  self._request(f"/storage/v1/object/{BUCKET}/{urllib.parse.quote(object_key, safe='/')}",
109
  "POST", data, "image/jpeg")
110
 
111
  def read(self, object_key):
 
 
112
  return self._request(f"/storage/v1/object/authenticated/{BUCKET}/{urllib.parse.quote(object_key, safe='/')}")
113
 
114
  def delete(self, object_key):
 
 
115
  self._request(f"/storage/v1/object/{BUCKET}", "DELETE",
116
  json.dumps({"prefixes": [object_key]}).encode(), "application/json")
117
 
 
 
 
 
 
 
 
 
 
 
 
118
  def cleanup(self):
 
119
  candidates = self.action(None, "cleanup_candidates")
120
  for key in candidates:
121
  self.delete(key)
122
- return len(candidates)
 
 
 
 
 
 
 
 
123
 
124
 
125
  def public_result(result):
 
2
 
3
  No fallback to process memory is allowed. The adapter uses the same Supabase
4
  project as sign-in; a verified user ID is passed on every student operation.
5
+
6
+ Images: with MATHLY_ANSWER_BUCKET set (a private Hugging Face storage bucket, written with HF_TOKEN), new answer
7
+ images are kept there instead of Supabase Storage, so reading them back (to grade and to show them) is not Supabase
8
+ egress. Their file names start with "hf-"; images saved before keep their names and stay in Supabase, where they
9
+ are still read from.
10
  """
11
  from __future__ import annotations
12
 
13
  import io
14
  import json
15
+ import logging
16
  import os
17
+ import tempfile
18
  import urllib.error
19
  import urllib.parse
20
  import urllib.request
21
  import uuid
22
  import warnings
23
  from dataclasses import dataclass
24
+ from datetime import datetime, timedelta, timezone
25
+ from pathlib import Path
26
 
27
  from PIL import Image, ImageOps, UnidentifiedImageError
28
 
29
  MAX_IMAGE = 5_000_000
30
  Image.MAX_IMAGE_PIXELS = 20_000_000
31
  BUCKET = "mathly-answers"
32
+ HUB_MARK = "hf-" # an image kept in the Hugging Face bucket: its file name starts with this
33
+ ORPHAN_AGE = timedelta(hours=24)
34
+ log = logging.getLogger("mathly.answers")
35
 
36
 
37
  @dataclass
 
66
  raise ScanError(415, "invalid_image") from exc
67
 
68
 
69
+ class HubImages:
70
+ """Answer images in a private Hugging Face storage bucket, under answers/<the object key>."""
71
+ PREFIX = "answers/"
72
+
73
+ def __init__(self, bucket: str, token: str | None = None):
74
+ from huggingface_hub import HfApi
75
+ from huggingface_hub.utils import disable_progress_bars
76
+ disable_progress_bars() # no progress bars in the server's log
77
+ self.bucket, self.api = bucket, HfApi(token=token or None)
78
+
79
+ @classmethod
80
+ def from_env(cls):
81
+ bucket = os.environ.get("MATHLY_ANSWER_BUCKET", "").strip()
82
+ return cls(bucket, os.environ.get("HF_TOKEN", "").strip()) if bucket else None
83
+
84
+ def _call(self, what, *args, **kwargs):
85
+ from huggingface_hub.errors import EntryNotFoundError
86
+ try:
87
+ return what(self.bucket, *args, **kwargs)
88
+ except EntryNotFoundError as exc:
89
+ raise ScanError(404, "not_found") from exc
90
+ except Exception as exc: # network, token or quota: never pass the details on to the browser
91
+ log.warning("answer bucket failed: %s", type(exc).__name__)
92
+ raise ScanError(503, "answer_storage_unavailable") from exc
93
+
94
+ def put(self, object_key: str, data: bytes):
95
+ self._call(self.api.batch_bucket_files, add=[(data, self.PREFIX + object_key)])
96
+
97
+ def read(self, object_key: str) -> bytes:
98
+ with tempfile.TemporaryDirectory() as tmp:
99
+ target = Path(tmp) / "image.jpg"
100
+ self._call(self.api.download_bucket_files, [(self.PREFIX + object_key, str(target))], raise_on_missing_files=True)
101
+ return target.read_bytes()
102
+
103
+ def delete(self, object_key: str):
104
+ self._call(self.api.batch_bucket_files, delete=[self.PREFIX + object_key])
105
+
106
+ def older_than(self, age: timedelta) -> list[str]:
107
+ """The object keys uploaded before now - age."""
108
+ cutoff = datetime.now(timezone.utc) - age
109
+ files = self._call(self.api.list_bucket_tree, prefix=self.PREFIX.rstrip("/"), recursive=True)
110
+ return [f.path[len(self.PREFIX):] for f in files
111
+ if getattr(f, "type", "") == "file" and f.path.startswith(self.PREFIX)
112
+ and (getattr(f, "uploaded_at", None) or getattr(f, "mtime", None) or cutoff) < cutoff]
113
+
114
+
115
+ def on_hub(object_key: str) -> bool:
116
+ return object_key.rsplit("/", 1)[-1].startswith(HUB_MARK)
117
+
118
+
119
  class AnswerScans:
120
+ def __init__(self, auth, *, enabled=None, images="env"):
121
  self.auth = auth
122
  self.enabled = (os.environ.get("MATHLY_ANSWER_SCANS_ENABLED") == "1") if enabled is None else enabled
123
+ self.images = HubImages.from_env() if images == "env" else images # None: every image in Supabase Storage
124
+
125
+ def new_key(self, user_id: str, scan_id: str, lease_id: str) -> str:
126
+ """A new image's object key; the database checks its user/scan/lease start."""
127
+ return f"{user_id}/{scan_id}/{lease_id}/{HUB_MARK if self.images else ''}{uuid.uuid4()}.jpg"
128
 
129
  @property
130
  def configured(self):
 
171
  except (urllib.error.URLError, TimeoutError, OSError) as exc:
172
  raise ScanError(503, "answer_storage_unavailable") from exc
173
 
174
+ def _hub(self, object_key):
175
+ if not on_hub(object_key):
176
+ return None
177
+ if not self.images: # an "hf-" image while the bucket is not configured: say so, never ask Supabase for it
178
+ raise ScanError(503, "answer_storage_unavailable")
179
+ return self.images
180
+
181
  def put(self, object_key, data):
182
+ if hub := self._hub(object_key):
183
+ return hub.put(object_key, data)
184
  self._request(f"/storage/v1/object/{BUCKET}/{urllib.parse.quote(object_key, safe='/')}",
185
  "POST", data, "image/jpeg")
186
 
187
  def read(self, object_key):
188
+ if hub := self._hub(object_key):
189
+ return hub.read(object_key)
190
  return self._request(f"/storage/v1/object/authenticated/{BUCKET}/{urllib.parse.quote(object_key, safe='/')}")
191
 
192
  def delete(self, object_key):
193
+ if hub := self._hub(object_key):
194
+ return hub.delete(object_key)
195
  self._request(f"/storage/v1/object/{BUCKET}", "DELETE",
196
  json.dumps({"prefixes": [object_key]}).encode(), "application/json")
197
 
198
+ def referenced(self, keys: list[str]) -> set[str]:
199
+ """Which of these object keys an answer still uses."""
200
+ found = set()
201
+ for i in range(0, len(keys), 40):
202
+ chunk = keys[i:i + 40]
203
+ names = ",".join('"' + k.replace('"', "") + '"' for k in chunk)
204
+ raw = self._request("/rest/v1/math_answer_attachments?select=object_key&object_key=in."
205
+ + urllib.parse.quote(f"({names})", safe="(),"))
206
+ found |= {row["object_key"] for row in json.loads(raw)}
207
+ return found
208
+
209
  def cleanup(self):
210
+ """Removes images no answer uses, a day after they were uploaded (an upload's answer is saved right after it)."""
211
  candidates = self.action(None, "cleanup_candidates")
212
  for key in candidates:
213
  self.delete(key)
214
+ removed = len(candidates)
215
+ if self.images: # the database cannot list the bucket: the bucket is listed and each image looked up
216
+ old = self.images.older_than(ORPHAN_AGE)
217
+ used = self.referenced(old) if old else set()
218
+ for key in old:
219
+ if key not in used:
220
+ self.images.delete(key)
221
+ removed += 1
222
+ return removed
223
 
224
 
225
  def public_result(result):
app/monitoring.py CHANGED
@@ -149,7 +149,7 @@ def install_monitoring_routes(app, manager) -> None:
149
  answers = app.state.answers
150
  return {"build": build_metadata(), "errors": log_errors.COUNTER.summary(),
151
  "seen": seen_summary(app.state.seen), "actions": app.state.actions.recent(), "photos": photo_summary(app.state.photo_counts),
152
- "answers": {"storage": answers.configured, "open_ended_locked": open_ended_locked(),
153
  "grading": bool(grading_key())},
154
  "photo_help": bool(os.environ.get("ANTHROPIC_API_KEY", "").strip()
155
  or os.environ.get("CLAUDE_API_KEY", "").strip())}
 
149
  answers = app.state.answers
150
  return {"build": build_metadata(), "errors": log_errors.COUNTER.summary(),
151
  "seen": seen_summary(app.state.seen), "actions": app.state.actions.recent(), "photos": photo_summary(app.state.photo_counts),
152
+ "answers": {"storage": answers.configured, "images": "huggingface" if getattr(answers, "images", None) else "supabase", "open_ended_locked": open_ended_locked(),
153
  "grading": bool(grading_key())},
154
  "photo_help": bool(os.environ.get("ANTHROPIC_API_KEY", "").strip()
155
  or os.environ.get("CLAUDE_API_KEY", "").strip())}
requirements.txt CHANGED
@@ -7,3 +7,4 @@ anthropic>=1.0 # photographed-problem hints and solutions
7
  tzdata>=2024.1
8
  Pillow==12.3.0 # validate and strip metadata; compatible with Python 3.10 and 3.11
9
  pywebpush>=2.0,<3 # phone notifications for photo requests (app/push.py)
 
 
7
  tzdata>=2024.1
8
  Pillow==12.3.0 # validate and strip metadata; compatible with Python 3.10 and 3.11
9
  pywebpush>=2.0,<3 # phone notifications for photo requests (app/push.py)
10
+ huggingface_hub>=1.16,<2 # answer images in a private Hugging Face storage bucket (MATHLY_ANSWER_BUCKET)
tests/conftest.py CHANGED
@@ -4,3 +4,4 @@ import os
4
  os.environ["SUPABASE_URL"] = ""
5
  os.environ["SUPABASE_PUBLISHABLE_KEY"] = ""
6
  os.environ["VAPID_PRIVATE_KEY"] = "" # push notifications are off unless a test turns them on
 
 
4
  os.environ["SUPABASE_URL"] = ""
5
  os.environ["SUPABASE_PUBLISHABLE_KEY"] = ""
6
  os.environ["VAPID_PRIVATE_KEY"] = "" # push notifications are off unless a test turns them on
7
+ os.environ["MATHLY_ANSWER_BUCKET"] = "" # answer images never go to a real Hugging Face bucket from a test
tests/test_answer_images.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Answer images in a private Hugging Face bucket (app/answer_scans.py): which store each image is in, and cleanup.
2
+ The bucket here is a stand-in; nothing leaves the machine."""
3
+ import json
4
+ import urllib.parse
5
+ import pytest
6
+
7
+ from app.answer_scans import AnswerScans, HubImages, ScanError, on_hub
8
+
9
+
10
+ class Auth:
11
+ url, secret = "https://example.supabase.co", "service-key"
12
+
13
+
14
+ class FakeHub:
15
+ def __init__(self):
16
+ self.files, self.old = {}, set()
17
+
18
+ def put(self, key, data): self.files[key] = data
19
+
20
+ def read(self, key):
21
+ if key not in self.files:
22
+ raise ScanError(404, "not_found")
23
+ return self.files[key]
24
+
25
+ def delete(self, key): self.files.pop(key, None)
26
+
27
+ def older_than(self, age): return [k for k in self.files if k in self.old]
28
+
29
+
30
+ class Store(AnswerScans):
31
+ """The adapter with Supabase's HTTP calls recorded instead of sent."""
32
+ def __init__(self, images, referenced=(), candidates=()):
33
+ super().__init__(Auth(), enabled=True, images=images)
34
+ self.calls, self._referenced, self._candidates = [], set(referenced), list(candidates)
35
+
36
+ def _request(self, path, method="GET", data=None, content_type=None):
37
+ self.calls.append((method, path))
38
+ if path.startswith("/rest/v1/math_answer_attachments"):
39
+ return json.dumps([{"object_key": k} for k in self._referenced if f'"{k}"' in urllib.parse.unquote(path)]).encode()
40
+ return b"supabase-image"
41
+
42
+ def action(self, user_id, action, **data):
43
+ assert action == "cleanup_candidates"
44
+ return self._candidates
45
+
46
+
47
+ def test_new_images_are_named_for_the_bucket_and_kept_there():
48
+ hub = FakeHub(); store = Store(hub)
49
+ key = store.new_key("u1", "s1", "l1")
50
+ assert key.startswith("u1/s1/l1/hf-") and key.endswith(".jpg") and on_hub(key)
51
+ store.put(key, b"jpeg")
52
+ assert hub.files == {key: b"jpeg"} and store.calls == [] # no Supabase Storage call at all
53
+ assert store.read(key) == b"jpeg"
54
+ store.delete(key)
55
+ assert hub.files == {} and store.calls == []
56
+
57
+
58
+ def test_images_saved_before_the_bucket_are_still_read_from_supabase():
59
+ store = Store(FakeHub())
60
+ assert store.read("u1/s1/l1/1234.jpg") == b"supabase-image"
61
+ assert store.calls == [("GET", "/storage/v1/object/authenticated/mathly-answers/u1/s1/l1/1234.jpg")]
62
+
63
+
64
+ def test_without_a_bucket_everything_stays_in_supabase():
65
+ store = Store(None)
66
+ key = store.new_key("u1", "s1", "l1")
67
+ assert not on_hub(key)
68
+ store.put(key, b"jpeg")
69
+ assert store.calls[0][0] == "POST" and store.calls[0][1].startswith("/storage/v1/object/mathly-answers/u1/s1/l1/")
70
+ with pytest.raises(ScanError) as err: # a bucket image while the bucket is off: unavailable, not a Supabase 404
71
+ store.read("u1/s1/l1/hf-x.jpg")
72
+ assert err.value.status == 503
73
+
74
+
75
+ def test_cleanup_removes_old_bucket_images_no_answer_uses():
76
+ hub = FakeHub()
77
+ kept, orphan, fresh = "u/s/l/hf-kept.jpg", "u/s/l/hf-orphan.jpg", "u/s/l/hf-fresh.jpg"
78
+ for k in (kept, orphan, fresh):
79
+ hub.put(k, b"x")
80
+ hub.old = {kept, orphan} # fresh is less than a day old: its answer may still be on its way
81
+ store = Store(hub, referenced={kept}, candidates=["u/s/l/old-supabase.jpg"])
82
+ assert store.cleanup() == 2 # the Supabase orphan and the bucket orphan
83
+ assert set(hub.files) == {kept, fresh}
84
+ assert ("DELETE", "/storage/v1/object/mathly-answers") in store.calls
85
+
86
+
87
+ def test_the_bucket_is_configured_from_the_environment(monkeypatch):
88
+ monkeypatch.setenv("MATHLY_ANSWER_BUCKET", "")
89
+ assert HubImages.from_env() is None
90
+ monkeypatch.setenv("MATHLY_ANSWER_BUCKET", "someone/answers")
91
+ monkeypatch.setenv("HF_TOKEN", "hf_test")
92
+ hub = HubImages.from_env()
93
+ assert hub.bucket == "someone/answers" and hub.api.token == "hf_test"